From 514684bc816a868f90e365ab5a1004acefbf4aee Mon Sep 17 00:00:00 2001 From: Julian Geiger Date: Tue, 21 Oct 2025 17:12:12 +0200 Subject: [PATCH 1/3] first version --- example_workflows/while_loop/README.md | 271 ++++++++++++++++++ .../while_loop/nested_optimization.json | 180 ++++++++++++ .../while_loop/nested_workflow.py | 110 +++++++ .../while_loop/simple_counter.json | 50 ++++ .../while_loop/simple_counter_expression.json | 50 ++++ .../while_loop/test_while_node.py | 219 ++++++++++++++ example_workflows/while_loop/workflow.py | 78 +++++ .../expression_eval.py | 215 ++++++++++++++ .../src/python_workflow_definition/models.py | 102 ++++++- 9 files changed, 1273 insertions(+), 2 deletions(-) create mode 100644 example_workflows/while_loop/README.md create mode 100644 example_workflows/while_loop/nested_optimization.json create mode 100644 example_workflows/while_loop/nested_workflow.py create mode 100644 example_workflows/while_loop/simple_counter.json create mode 100644 example_workflows/while_loop/simple_counter_expression.json create mode 100644 example_workflows/while_loop/test_while_node.py create mode 100644 example_workflows/while_loop/workflow.py create mode 100644 python_workflow_definition/src/python_workflow_definition/expression_eval.py diff --git a/example_workflows/while_loop/README.md b/example_workflows/while_loop/README.md new file mode 100644 index 0000000..2c3f3c9 --- /dev/null +++ b/example_workflows/while_loop/README.md @@ -0,0 +1,271 @@ +# While Loop Control Flow Examples + +This directory contains examples demonstrating the `while` loop control flow node added to the Python Workflow Definition syntax. + +## Overview + +The `PythonWorkflowDefinitionWhileNode` enables iterative execution in workflows, supporting two main modes: + +1. **Simple mode**: Condition and body as Python functions +2. **Complex mode**: Nested workflow as loop body + +## Files + +### Core Implementation + +- `models.py`: Contains the `PythonWorkflowDefinitionWhileNode` class (in main source tree) +- `expression_eval.py`: Safe expression evaluator for condition expressions + +### Examples + +1. **simple_counter.json** - Basic while loop with function-based condition +2. **simple_counter_expression.json** - While loop with expression-based condition +3. **nested_optimization.json** - Complex nested workflow example + +### Supporting Files + +- `workflow.py`: Simple workflow functions for basic examples +- `nested_workflow.py`: Functions for the geometry optimization example +- `test_while_node.py`: Test suite validating the implementation + +## WhileNode Schema + +```json +{ + "id": , + "type": "while", + + // Condition (exactly one required) + "conditionFunction": "", // OR + "conditionExpression": "", // Safe Python expression + + // Body (exactly one required) + "bodyFunction": "", // OR + "bodyWorkflow": { ... }, // Nested workflow definition + + // Configuration + "maxIterations": 1000, // Safety limit (default: 1000) + "stateVars": ["var1", "var2"] // Optional: tracked variables +} +``` + +## Example 1: Simple Counter (Function-based) + +**File**: `simple_counter.json` + +Counts from `m=0` to `n=10` using function-based condition and body. + +**Workflow Functions** (`workflow.py`): + +```python +def is_less_than(n, m): + """Condition: continue while m < n""" + return m < n + +def increment_m(n, m): + """Body: increment m by 1""" + return {"n": n, "m": m + 1} +``` + +**Workflow Graph**: + +``` +Input(n=10) ──┐ + ├──> While(condition=is_less_than, body=increment_m) ──> Output(result) +Input(m=0) ──┘ +``` + +## Example 2: Simple Counter (Expression-based) + +**File**: `simple_counter_expression.json` + +Same as Example 1, but uses a condition expression instead of a function: + +```json +{ + "type": "while", + "conditionExpression": "m < n", + "bodyFunction": "workflow.increment_m" +} +``` + +**Supported Expression Operators**: +- Comparison: `<`, `<=`, `>`, `>=`, `==`, `!=`, `in`, `not in`, `is`, `is not` +- Boolean: `and`, `or`, `not` +- Arithmetic: `+`, `-`, `*`, `/`, `//`, `%`, `**` +- Subscript: `data[0]`, `dict["key"]` + +**Safety Features**: +- No function calls allowed +- No imports allowed +- No access to `__builtins__` +- No dunder attribute access (e.g., `__import__`) + +## Example 3: Nested Workflow (Geometry Optimization) + +**File**: `nested_optimization.json` + +Demonstrates a complex iterative algorithm with a nested workflow as the loop body. + +**Use Case**: Geometry optimization of a molecular structure + +**Outer Loop** (While node): +- **Condition**: `not_converged(threshold, energy_change)` +- **Body**: Nested workflow (see below) +- **Max iterations**: 100 + +**Inner Workflow** (Loop body): +1. `calculate_energy(structure)` → energy +2. `calculate_forces(structure, energy)` → forces +3. `update_geometry(structure, forces)` → new_structure +4. `check_convergence(old_energy, new_structure)` → energy_change + +**State Flow**: + +``` +Iteration N: + structure_N, energy_N → [nested workflow] → structure_{N+1}, energy_{N+1}, energy_change + +Check condition: + not_converged(threshold, energy_change) → continue or stop +``` + +**Workflow Functions** (`nested_workflow.py`): + +```python +def not_converged(threshold, energy_change): + return abs(energy_change) > threshold + +def calculate_energy(structure): + # Compute energy from atomic positions + ... + +def calculate_forces(structure, energy): + # Compute forces on atoms + ... + +def update_geometry(structure, forces): + # Update positions using steepest descent + ... + +def check_convergence(old_energy, new_structure): + # Calculate energy change + new_energy = calculate_energy(new_structure) + return {"energy": new_energy, "energy_change": new_energy - old_energy} +``` + +## State Management + +The while loop follows the current workflow edge model for state management: + +### Input Ports +- Initial values flow into the while node via edges targeting specific ports +- Example: `{"source": 0, "target": 2, "targetPort": "n"}` + +### Output Ports +- Loop body functions return dictionaries with updated state +- Output ports extract specific values from the result +- Example: `{"source": 2, "sourcePort": "m", "target": 3}` + +### Iteration State Flow + +For each iteration: +1. Input state flows into the condition function +2. If condition returns `True`, state flows into body +3. Body returns updated state (dict) +4. Updated state becomes input for next iteration +5. Loop terminates when condition returns `False` or `maxIterations` reached + +## Running the Tests + +```bash +cd example_workflows/while_loop +python test_while_node.py +``` + +**Test Coverage**: +- ✓ Schema validation (valid/invalid node configurations) +- ✓ JSON workflow loading (all three examples) +- ✓ Safe expression evaluation (valid expressions and security tests) + +## Design Rationale + +### Hybrid Approach (Option 3 + 4) + +The implementation combines: +1. **Simple function-based** loops (easy to author, backend-compatible) +2. **Nested workflow** loops (powerful for complex multi-step iterations) + +### Condition Evaluation Options + +**Option A**: `conditionFunction` only +- ✅ Safe, backend-compatible +- ❌ Requires writing functions for simple checks + +**Option B**: `conditionExpression` only +- ✅ Natural syntax for simple conditions +- ❌ Requires safe eval mechanism + +**Option C**: **Hybrid (Implemented)** ⭐ +- ✅ Flexibility: use functions OR expressions +- ✅ Safety: restricted AST validation +- ✅ Convenience: no function needed for `m < n` + +### Port-based State Management + +Following the existing edge model: +- ✅ Consistent with current architecture +- ✅ Explicit data flow +- ✅ Backend translation is straightforward +- ✅ Visualizable in graph UIs + +## Implementation Details + +### Files Modified/Created + +**Core Schema** (`models.py`): +- Added `PythonWorkflowDefinitionWhileNode` class +- Updated discriminated union to include while nodes +- Added validators for condition/body requirements + +**Expression Evaluator** (`expression_eval.py`): +- `evaluate_expression()`: Safe eval with AST validation +- `evaluate_condition()`: Wrapper ensuring boolean results +- `SAFE_NODES`: Whitelist of allowed AST node types + +**Examples** (this directory): +- Three JSON workflow examples +- Two Python function modules +- Test suite + +### Future Extensions + +The while node design can be extended to support: + +1. **Other control flow**: + - `if/else` conditional nodes + - `for` loop nodes with iterables + - `break`/`continue` conditions + +2. **Advanced features**: + - Loop carry state (accumulator pattern) + - Parallel iteration (map-reduce style) + - Dynamic max iterations based on state + +3. **Debugging support**: + - Iteration count tracking + - State snapshots per iteration + - Convergence history logging + +## Notes + +- **DAG Preservation**: While nodes do not create cycles in the graph. The while node itself is atomic and maintains the DAG structure. +- **Safety**: `maxIterations` prevents infinite loops. Default is 1000. +- **Backend Compatibility**: Backends can choose to: + - Unroll loops (for static execution) + - Use native loop constructs (for dynamic execution) + - Implement custom while loop handling + +## Questions? + +See the test file (`test_while_node.py`) for detailed usage examples and validation. diff --git a/example_workflows/while_loop/nested_optimization.json b/example_workflows/while_loop/nested_optimization.json new file mode 100644 index 0000000..10100a3 --- /dev/null +++ b/example_workflows/while_loop/nested_optimization.json @@ -0,0 +1,180 @@ +{ + "version": "0.1.0", + "nodes": [ + { + "id": 0, + "type": "input", + "name": "structure", + "value": { + "positions": [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + "atom_types": ["H", "H"] + } + }, + { + "id": 1, + "type": "input", + "name": "threshold", + "value": 0.001 + }, + { + "id": 2, + "type": "input", + "name": "initial_energy", + "value": 1000.0 + }, + { + "id": 3, + "type": "while", + "conditionFunction": "nested_workflow.not_converged", + "bodyWorkflow": { + "version": "0.1.0", + "nodes": [ + { + "id": 100, + "type": "input", + "name": "structure" + }, + { + "id": 101, + "type": "input", + "name": "energy" + }, + { + "id": 102, + "type": "function", + "value": "nested_workflow.calculate_energy" + }, + { + "id": 103, + "type": "function", + "value": "nested_workflow.calculate_forces" + }, + { + "id": 104, + "type": "function", + "value": "nested_workflow.update_geometry" + }, + { + "id": 105, + "type": "function", + "value": "nested_workflow.check_convergence" + }, + { + "id": 106, + "type": "output", + "name": "threshold" + }, + { + "id": 107, + "type": "output", + "name": "energy_change" + }, + { + "id": 108, + "type": "output", + "name": "structure" + }, + { + "id": 109, + "type": "output", + "name": "energy" + } + ], + "edges": [ + { + "source": 100, + "sourcePort": null, + "target": 102, + "targetPort": "structure" + }, + { + "source": 102, + "sourcePort": null, + "target": 103, + "targetPort": "energy" + }, + { + "source": 100, + "sourcePort": null, + "target": 103, + "targetPort": "structure" + }, + { + "source": 103, + "sourcePort": null, + "target": 104, + "targetPort": "forces" + }, + { + "source": 100, + "sourcePort": null, + "target": 104, + "targetPort": "structure" + }, + { + "source": 104, + "sourcePort": null, + "target": 105, + "targetPort": "new_structure" + }, + { + "source": 101, + "sourcePort": null, + "target": 105, + "targetPort": "old_energy" + }, + { + "source": 105, + "sourcePort": "energy_change", + "target": 107, + "targetPort": null + }, + { + "source": 105, + "sourcePort": "structure", + "target": 108, + "targetPort": null + }, + { + "source": 105, + "sourcePort": "energy", + "target": 109, + "targetPort": null + } + ] + }, + "maxIterations": 100 + }, + { + "id": 4, + "type": "output", + "name": "optimized_structure" + } + ], + "edges": [ + { + "source": 0, + "sourcePort": null, + "target": 3, + "targetPort": "structure" + }, + { + "source": 1, + "sourcePort": null, + "target": 3, + "targetPort": "threshold" + }, + { + "source": 2, + "sourcePort": null, + "target": 3, + "targetPort": "energy" + }, + { + "source": 3, + "sourcePort": "structure", + "target": 4, + "targetPort": null + } + ] +} diff --git a/example_workflows/while_loop/nested_workflow.py b/example_workflows/while_loop/nested_workflow.py new file mode 100644 index 0000000..75c4d8e --- /dev/null +++ b/example_workflows/while_loop/nested_workflow.py @@ -0,0 +1,110 @@ +""" +Complex nested workflow example for geometry optimization. + +This demonstrates a while loop with a nested workflow, +simulating an iterative geometry optimization algorithm. +""" + + +def not_converged(threshold, energy_change): + """ + Check if the optimization has converged. + + Args: + threshold: Convergence threshold for energy change + energy_change: Change in energy from previous iteration + + Returns: + bool: True if not converged (should continue) + """ + return abs(energy_change) > threshold + + +def calculate_energy(structure): + """ + Calculate the energy of a structure. + + This is a simplified placeholder for actual energy calculation. + + Args: + structure: Structure data (e.g., atomic positions) + + Returns: + float: Calculated energy + """ + # Simplified energy calculation + if isinstance(structure, dict) and "positions" in structure: + positions = structure["positions"] + # Simple harmonic potential around origin + energy = sum(x**2 + y**2 + z**2 for x, y, z in positions) + return energy + return 0.0 + + +def calculate_forces(structure, energy): + """ + Calculate forces on atoms from energy. + + Args: + structure: Structure data + energy: Current energy + + Returns: + dict: Forces on each atom + """ + # Simplified force calculation (gradient of harmonic potential) + if isinstance(structure, dict) and "positions" in structure: + positions = structure["positions"] + forces = [[-2*x, -2*y, -2*z] for x, y, z in positions] + return {"forces": forces, "energy": energy} + return {"forces": [], "energy": energy} + + +def update_geometry(structure, forces): + """ + Update atomic positions based on forces. + + Args: + structure: Current structure + forces: Forces dict from calculate_forces + + Returns: + dict: Updated structure with new positions + """ + if isinstance(structure, dict) and "positions" in structure: + positions = structure["positions"] + force_vectors = forces["forces"] + step_size = 0.1 + + # Simple steepest descent update + new_positions = [ + [x + step_size * fx, y + step_size * fy, z + step_size * fz] + for (x, y, z), (fx, fy, fz) in zip(positions, force_vectors) + ] + + return { + "positions": new_positions, + "atom_types": structure.get("atom_types", []), + } + return structure + + +def check_convergence(old_energy, new_structure): + """ + Calculate energy change and check convergence. + + Args: + old_energy: Energy from previous iteration + new_structure: Updated structure + + Returns: + dict: Contains new energy and energy change + """ + new_energy = calculate_energy(new_structure) + energy_change = new_energy - old_energy + + return { + "energy": new_energy, + "energy_change": energy_change, + "structure": new_structure, + } diff --git a/example_workflows/while_loop/simple_counter.json b/example_workflows/while_loop/simple_counter.json new file mode 100644 index 0000000..16ed1db --- /dev/null +++ b/example_workflows/while_loop/simple_counter.json @@ -0,0 +1,50 @@ +{ + "version": "0.1.0", + "nodes": [ + { + "id": 0, + "type": "input", + "name": "n", + "value": 10 + }, + { + "id": 1, + "type": "input", + "name": "m", + "value": 0 + }, + { + "id": 2, + "type": "while", + "conditionFunction": "workflow.is_less_than", + "bodyFunction": "workflow.increment_m", + "maxIterations": 1000, + "stateVars": ["n", "m"] + }, + { + "id": 3, + "type": "output", + "name": "result" + } + ], + "edges": [ + { + "source": 0, + "sourcePort": null, + "target": 2, + "targetPort": "n" + }, + { + "source": 1, + "sourcePort": null, + "target": 2, + "targetPort": "m" + }, + { + "source": 2, + "sourcePort": "m", + "target": 3, + "targetPort": null + } + ] +} diff --git a/example_workflows/while_loop/simple_counter_expression.json b/example_workflows/while_loop/simple_counter_expression.json new file mode 100644 index 0000000..3e0c037 --- /dev/null +++ b/example_workflows/while_loop/simple_counter_expression.json @@ -0,0 +1,50 @@ +{ + "version": "0.1.0", + "nodes": [ + { + "id": 0, + "type": "input", + "name": "n", + "value": 10 + }, + { + "id": 1, + "type": "input", + "name": "m", + "value": 0 + }, + { + "id": 2, + "type": "while", + "conditionExpression": "m < n", + "bodyFunction": "workflow.increment_m", + "maxIterations": 1000, + "stateVars": ["n", "m"] + }, + { + "id": 3, + "type": "output", + "name": "result" + } + ], + "edges": [ + { + "source": 0, + "sourcePort": null, + "target": 2, + "targetPort": "n" + }, + { + "source": 1, + "sourcePort": null, + "target": 2, + "targetPort": "m" + }, + { + "source": 2, + "sourcePort": "m", + "target": 3, + "targetPort": null + } + ] +} diff --git a/example_workflows/while_loop/test_while_node.py b/example_workflows/while_loop/test_while_node.py new file mode 100644 index 0000000..981563d --- /dev/null +++ b/example_workflows/while_loop/test_while_node.py @@ -0,0 +1,219 @@ +""" +Test script to validate the WhileNode implementation. + +This script tests: +1. Schema validation for WhileNode +2. Loading JSON workflows with while loops +3. Safe expression evaluation +""" + +import sys +from pathlib import Path + +# Add the source directory to Python path +src_path = Path(__file__).parent.parent.parent / "python_workflow_definition" / "src" +sys.path.insert(0, str(src_path)) + +from python_workflow_definition.models import ( + PythonWorkflowDefinitionWorkflow, + PythonWorkflowDefinitionWhileNode, +) +from python_workflow_definition.expression_eval import ( + evaluate_expression, + evaluate_condition, + UnsafeExpressionError, +) + + +def test_schema_validation(): + """Test WhileNode schema validation.""" + print("=" * 60) + print("Testing WhileNode schema validation...") + print("=" * 60) + + # Test 1: Valid function-based while node + print("\n1. Testing valid function-based while node...") + try: + node = PythonWorkflowDefinitionWhileNode( + id=1, + type="while", + conditionFunction="workflow.check", + bodyFunction="workflow.body", + maxIterations=100, + ) + print(" ✓ Valid function-based node created successfully") + except Exception as e: + print(f" ✗ Failed: {e}") + + # Test 2: Valid expression-based while node + print("\n2. Testing valid expression-based while node...") + try: + node = PythonWorkflowDefinitionWhileNode( + id=2, + type="while", + conditionExpression="m < n", + bodyFunction="workflow.body", + ) + print(" ✓ Valid expression-based node created successfully") + except Exception as e: + print(f" ✗ Failed: {e}") + + # Test 3: Invalid - no condition specified + print("\n3. Testing invalid node (no condition)...") + try: + node = PythonWorkflowDefinitionWhileNode( + id=3, + type="while", + bodyFunction="workflow.body", + ) + print(" ✗ Should have raised ValueError") + except ValueError as e: + print(f" ✓ Correctly rejected: {e}") + + # Test 4: Invalid - both condition methods specified + print("\n4. Testing invalid node (both condition methods)...") + try: + node = PythonWorkflowDefinitionWhileNode( + id=4, + type="while", + conditionFunction="workflow.check", + conditionExpression="m < n", + bodyFunction="workflow.body", + ) + print(" ✗ Should have raised ValueError") + except ValueError as e: + print(f" ✓ Correctly rejected: {e}") + + # Test 5: Invalid - no body specified + print("\n5. Testing invalid node (no body)...") + try: + node = PythonWorkflowDefinitionWhileNode( + id=5, + type="while", + conditionFunction="workflow.check", + ) + print(" ✗ Should have raised ValueError") + except ValueError as e: + print(f" ✓ Correctly rejected: {e}") + + +def test_workflow_loading(): + """Test loading JSON workflows with while loops.""" + print("\n" + "=" * 60) + print("Testing workflow JSON loading...") + print("=" * 60) + + # Test loading simple counter workflow + print("\n1. Loading simple_counter.json...") + try: + workflow_path = Path(__file__).parent / "simple_counter.json" + workflow = PythonWorkflowDefinitionWorkflow.load_json_file(workflow_path) + print(f" ✓ Loaded successfully") + print(f" - Nodes: {len(workflow['nodes'])}") + print(f" - Edges: {len(workflow['edges'])}") + while_node = [n for n in workflow["nodes"] if n["type"] == "while"][0] + print(f" - While node ID: {while_node['id']}") + print(f" - Condition: {while_node.get('conditionFunction')}") + print(f" - Body: {while_node.get('bodyFunction')}") + except Exception as e: + print(f" ✗ Failed: {e}") + + # Test loading expression-based workflow + print("\n2. Loading simple_counter_expression.json...") + try: + workflow_path = Path(__file__).parent / "simple_counter_expression.json" + workflow = PythonWorkflowDefinitionWorkflow.load_json_file(workflow_path) + print(f" ✓ Loaded successfully") + while_node = [n for n in workflow["nodes"] if n["type"] == "while"][0] + print(f" - Condition expression: {while_node.get('conditionExpression')}") + except Exception as e: + print(f" ✗ Failed: {e}") + + # Test loading nested workflow + print("\n3. Loading nested_optimization.json...") + try: + workflow_path = Path(__file__).parent / "nested_optimization.json" + workflow = PythonWorkflowDefinitionWorkflow.load_json_file(workflow_path) + print(f" ✓ Loaded successfully") + while_node = [n for n in workflow["nodes"] if n["type"] == "while"][0] + print(f" - Has nested workflow: {while_node.get('bodyWorkflow') is not None}") + if while_node.get("bodyWorkflow"): + nested = while_node["bodyWorkflow"] + print(f" - Nested nodes: {len(nested['nodes'])}") + print(f" - Nested edges: {len(nested['edges'])}") + except Exception as e: + print(f" ✗ Failed: {e}") + + +def test_expression_evaluation(): + """Test safe expression evaluation.""" + print("\n" + "=" * 60) + print("Testing safe expression evaluation...") + print("=" * 60) + + # Test 1: Simple comparison + print("\n1. Testing simple comparison: 'm < n'") + try: + result = evaluate_condition("m < n", {"m": 5, "n": 10}) + print(f" ✓ Result: {result} (expected: True)") + except Exception as e: + print(f" ✗ Failed: {e}") + + # Test 2: Complex boolean expression + print("\n2. Testing complex boolean: 'a > 5 and b < 10'") + try: + result = evaluate_condition("a > 5 and b < 10", {"a": 7, "b": 8}) + print(f" ✓ Result: {result} (expected: True)") + except Exception as e: + print(f" ✗ Failed: {e}") + + # Test 3: Arithmetic in expression + print("\n3. Testing arithmetic: 'x + y > 10'") + try: + result = evaluate_condition("x + y > 10", {"x": 7, "y": 5}) + print(f" ✓ Result: {result} (expected: True)") + except Exception as e: + print(f" ✗ Failed: {e}") + + # Test 4: Unsafe expression (should fail) + print("\n4. Testing unsafe expression: '__import__(\"os\")'") + try: + result = evaluate_expression("__import__('os')", {}) + print(f" ✗ Should have raised UnsafeExpressionError") + except UnsafeExpressionError as e: + print(f" ✓ Correctly rejected: {e}") + + # Test 5: Function call (should fail) + print("\n5. Testing function call: 'print(\"hello\")'") + try: + result = evaluate_expression("print('hello')", {}) + print(f" ✗ Should have raised UnsafeExpressionError") + except UnsafeExpressionError as e: + print(f" ✓ Correctly rejected: {e}") + + # Test 6: List/dict access + print("\n6. Testing subscript access: 'data[0] > 5'") + try: + result = evaluate_condition("data[0] > 5", {"data": [10, 20, 30]}) + print(f" ✓ Result: {result} (expected: True)") + except Exception as e: + print(f" ✗ Failed: {e}") + + +def main(): + """Run all tests.""" + print("\n" + "=" * 60) + print("WhileNode Implementation Tests") + print("=" * 60) + + test_schema_validation() + test_workflow_loading() + test_expression_evaluation() + + print("\n" + "=" * 60) + print("All tests completed!") + print("=" * 60 + "\n") + + +if __name__ == "__main__": + main() diff --git a/example_workflows/while_loop/workflow.py b/example_workflows/while_loop/workflow.py new file mode 100644 index 0000000..724b25e --- /dev/null +++ b/example_workflows/while_loop/workflow.py @@ -0,0 +1,78 @@ +""" +Example workflow functions for while loop demonstration. + +This module contains simple functions used in while loop examples: +1. Simple counter increment +2. Convergence checking for iterative algorithms +""" + + +def is_less_than(n, m): + """ + Condition function: Check if m < n. + + Args: + n: Upper bound + m: Current value + + Returns: + bool: True if m < n, False otherwise + """ + return m < n + + +def increment_m(n, m): + """ + Body function: Increment m by 1. + + This function maintains the loop state by returning + a dictionary with updated values. + + Args: + n: Upper bound (unchanged) + m: Current value + + Returns: + dict: Updated state with incremented m + """ + return {"n": n, "m": m + 1} + + +def not_converged(threshold, current_error): + """ + Condition function: Check if iteration should continue. + + Args: + threshold: Convergence threshold + current_error: Current error value + + Returns: + bool: True if not converged (error > threshold) + """ + return current_error > threshold + + +def iterative_step(threshold, current_error, data): + """ + Body function: Perform one iteration of an algorithm. + + This is a placeholder for more complex iterative algorithms. + + Args: + threshold: Convergence threshold + current_error: Current error value + data: Input data to process + + Returns: + dict: Updated state with new error and processed data + """ + # Simulate error reduction + new_error = current_error * 0.5 + # Simulate data transformation + new_data = [x * 1.1 for x in data] + + return { + "threshold": threshold, + "current_error": new_error, + "data": new_data, + } diff --git a/python_workflow_definition/src/python_workflow_definition/expression_eval.py b/python_workflow_definition/src/python_workflow_definition/expression_eval.py new file mode 100644 index 0000000..426a5b9 --- /dev/null +++ b/python_workflow_definition/src/python_workflow_definition/expression_eval.py @@ -0,0 +1,215 @@ +""" +Safe expression evaluator for while loop conditions. + +This module provides safe evaluation of Python expressions with restricted +operators and no access to dangerous built-ins or imports. +""" + +import ast +from typing import Any, Dict, Set + + +# Define allowed AST node types for safe expression evaluation +SAFE_NODES: Set[type] = { + # Literals + ast.Constant, # Python 3.8+ (replaces Num, Str, Bytes, NameConstant, etc.) + ast.Num, # Legacy: numbers + ast.Str, # Legacy: strings + ast.Bytes, # Legacy: bytes + ast.NameConstant, # Legacy: None, True, False + ast.List, + ast.Tuple, + ast.Dict, + # Variables + ast.Name, + ast.Load, # Context for loading variables + # Expressions + ast.Expression, + ast.Expr, + # Comparison operators + ast.Compare, + ast.Lt, # < + ast.LtE, # <= + ast.Gt, # > + ast.GtE, # >= + ast.Eq, # == + ast.NotEq, # != + ast.Is, # is + ast.IsNot, # is not + ast.In, # in + ast.NotIn, # not in + # Boolean operators + ast.BoolOp, + ast.And, + ast.Or, + # Unary operators + ast.UnaryOp, + ast.Not, # not + ast.UAdd, # +x + ast.USub, # -x + # Binary operators (arithmetic) + ast.BinOp, + ast.Add, # + + ast.Sub, # - + ast.Mult, # * + ast.Div, # / + ast.FloorDiv, # // + ast.Mod, # % + ast.Pow, # ** + # Subscripting (for list/dict access) + ast.Subscript, + ast.Index, # Legacy: indexing + ast.Slice, +} + + +class UnsafeExpressionError(ValueError): + """Raised when an expression contains unsafe operations.""" + + pass + + +def validate_expression_ast(node: ast.AST, expr: str) -> None: + """ + Recursively validate that an AST only contains safe nodes. + + Args: + node: AST node to validate + expr: Original expression string (for error messages) + + Raises: + UnsafeExpressionError: If unsafe operations are detected + """ + node_type = type(node) + + if node_type not in SAFE_NODES: + raise UnsafeExpressionError( + f"Unsafe operation detected in expression '{expr}': " + f"{node_type.__name__} is not allowed" + ) + + # Special checks for specific node types + if isinstance(node, ast.Name): + # Prevent access to dangerous built-ins + if node.id.startswith("__"): + raise UnsafeExpressionError( + f"Access to dunder attributes is not allowed: '{node.id}'" + ) + + if isinstance(node, ast.Call): + # Function calls are not allowed in safe expressions + raise UnsafeExpressionError( + f"Function calls are not allowed in expression '{expr}'" + ) + + if isinstance(node, (ast.Import, ast.ImportFrom)): + raise UnsafeExpressionError("Import statements are not allowed") + + # Recursively validate child nodes + for child in ast.iter_child_nodes(node): + validate_expression_ast(child, expr) + + +def evaluate_expression( + expression: str, + variables: Dict[str, Any], + max_length: int = 500, +) -> Any: + """ + Safely evaluate a Python expression with restricted operations. + + This function: + 1. Parses the expression into an AST + 2. Validates that only safe operations are used + 3. Evaluates the expression with controlled namespace + + Args: + expression: Python expression string to evaluate + variables: Dictionary of variables available in the expression + max_length: Maximum allowed expression length + + Returns: + Result of the expression evaluation + + Raises: + UnsafeExpressionError: If the expression contains unsafe operations + SyntaxError: If the expression has invalid Python syntax + ValueError: If the expression is too long + + Examples: + >>> evaluate_expression("a < b", {"a": 1, "b": 2}) + True + >>> evaluate_expression("x + y * 2", {"x": 10, "y": 5}) + 20 + >>> evaluate_expression("not (a > 5 and b < 10)", {"a": 3, "b": 7}) + False + """ + # Check expression length + if len(expression) > max_length: + raise ValueError( + f"Expression too long ({len(expression)} > {max_length} characters)" + ) + + # Parse the expression + try: + tree = ast.parse(expression.strip(), mode="eval") + except SyntaxError as e: + raise SyntaxError(f"Invalid expression syntax: {e}") from e + + # Validate the AST contains only safe operations + validate_expression_ast(tree.body, expression) + + # Create a safe namespace with no built-ins + safe_globals = { + "__builtins__": {}, + # Add safe built-in constants + "True": True, + "False": False, + "None": None, + } + + # Evaluate the expression + try: + compiled = compile(tree, "", "eval") + result = eval(compiled, safe_globals, variables) + return result + except Exception as e: + raise ValueError(f"Error evaluating expression '{expression}': {e}") from e + + +def evaluate_condition( + expression: str, + variables: Dict[str, Any], +) -> bool: + """ + Evaluate a condition expression and ensure it returns a boolean. + + This is a convenience wrapper around evaluate_expression that + validates the result is a boolean value. + + Args: + expression: Condition expression (should evaluate to bool) + variables: Dictionary of variables available in the expression + + Returns: + Boolean result of the condition + + Raises: + TypeError: If the expression doesn't evaluate to a boolean + UnsafeExpressionError: If the expression contains unsafe operations + SyntaxError: If the expression has invalid syntax + + Examples: + >>> evaluate_condition("a < b", {"a": 1, "b": 2}) + True + >>> evaluate_condition("x in [1, 2, 3]", {"x": 2}) + True + """ + result = evaluate_expression(expression, variables) + + if not isinstance(result, bool): + raise TypeError( + f"Condition expression must evaluate to boolean, got {type(result).__name__}: {result}" + ) + + return result diff --git a/python_workflow_definition/src/python_workflow_definition/models.py b/python_workflow_definition/src/python_workflow_definition/models.py index 4980cfa..704be4c 100644 --- a/python_workflow_definition/src/python_workflow_definition/models.py +++ b/python_workflow_definition/src/python_workflow_definition/models.py @@ -1,6 +1,6 @@ from pathlib import Path -from typing import List, Union, Optional, Literal, Any, Annotated, Type, TypeVar -from pydantic import BaseModel, Field, field_validator, field_serializer +from typing import List, Union, Optional, Literal, Any, Annotated, Type, TypeVar, TYPE_CHECKING +from pydantic import BaseModel, Field, field_validator, field_serializer, model_validator from pydantic import ValidationError import json import logging @@ -10,10 +10,14 @@ INTERNAL_DEFAULT_HANDLE = "__result__" T = TypeVar("T", bound="PythonWorkflowDefinitionWorkflow") +if TYPE_CHECKING: + from typing import Self + __all__ = ( "PythonWorkflowDefinitionInputNode", "PythonWorkflowDefinitionOutputNode", "PythonWorkflowDefinitionFunctionNode", + "PythonWorkflowDefinitionWhileNode", "PythonWorkflowDefinitionEdge", "PythonWorkflowDefinitionWorkflow", ) @@ -64,12 +68,106 @@ def check_value_format(cls, v: str): return v +class PythonWorkflowDefinitionWhileNode(PythonWorkflowDefinitionBaseNode): + """ + Model for while loop control flow nodes. + + Supports two modes of operation: + 1. Simple mode: conditionFunction + bodyFunction (functions as strings) + 2. Complex mode: conditionFunction/conditionExpression + bodyWorkflow (nested workflow) + + Exactly one condition method (conditionFunction OR conditionExpression) must be specified. + Exactly one body method (bodyFunction OR bodyWorkflow) must be specified. + """ + + type: Literal["while"] + + # Condition evaluation (exactly one must be set) + conditionFunction: Optional[str] = None # Format: 'module.function' returns bool + conditionExpression: Optional[str] = None # Safe expression like "m < n" + + # Body execution (exactly one must be set) + bodyFunction: Optional[str] = None # Format: 'module.function' + bodyWorkflow: Optional["PythonWorkflowDefinitionWorkflow"] = None # Nested subgraph + + # Safety and configuration + maxIterations: int = Field(default=1000, ge=1) + + # Optional: Track specific state variables across iterations + stateVars: Optional[List[str]] = None + + @field_validator("conditionFunction") + @classmethod + def check_condition_function_format(cls, v: Optional[str]) -> Optional[str]: + """Validate conditionFunction format if provided.""" + if v is not None: + if not v or "." not in v or v.startswith(".") or v.endswith("."): + msg = ( + "WhileNode 'conditionFunction' must be a non-empty string " + "in 'module.function' format with at least one period." + ) + raise ValueError(msg) + return v + + @field_validator("bodyFunction") + @classmethod + def check_body_function_format(cls, v: Optional[str]) -> Optional[str]: + """Validate bodyFunction format if provided.""" + if v is not None: + if not v or "." not in v or v.startswith(".") or v.endswith("."): + msg = ( + "WhileNode 'bodyFunction' must be a non-empty string " + "in 'module.function' format with at least one period." + ) + raise ValueError(msg) + return v + + @model_validator(mode="after") + def check_exactly_one_condition(self) -> "Self": + """Ensure exactly one condition method is specified.""" + condition_count = sum([ + self.conditionFunction is not None, + self.conditionExpression is not None, + ]) + if condition_count == 0: + raise ValueError( + "WhileNode must specify exactly one condition method: " + "either 'conditionFunction' or 'conditionExpression'" + ) + if condition_count > 1: + raise ValueError( + "WhileNode must specify exactly one condition method, " + f"but {condition_count} were provided" + ) + return self + + @model_validator(mode="after") + def check_exactly_one_body(self) -> "Self": + """Ensure exactly one body method is specified.""" + body_count = sum([ + self.bodyFunction is not None, + self.bodyWorkflow is not None, + ]) + if body_count == 0: + raise ValueError( + "WhileNode must specify exactly one body method: " + "either 'bodyFunction' or 'bodyWorkflow'" + ) + if body_count > 1: + raise ValueError( + "WhileNode must specify exactly one body method, " + f"but {body_count} were provided" + ) + return self + + # Discriminated Union for Nodes PythonWorkflowDefinitionNode = Annotated[ Union[ PythonWorkflowDefinitionInputNode, PythonWorkflowDefinitionOutputNode, PythonWorkflowDefinitionFunctionNode, + PythonWorkflowDefinitionWhileNode, ], Field(discriminator="type"), ] From aab987216a2e2cf738454342978ee0cc986bd542 Mon Sep 17 00:00:00 2001 From: Julian Geiger Date: Wed, 22 Oct 2025 11:15:11 +0200 Subject: [PATCH 2/3] wip --- .bak/pwd-recursive-while/README.md | 2 + .bak/pwd-recursive-while/aiidaworkgraph.ipynb | 101 +++ .bak/pwd-recursive-while/environment.yml | 6 + .bak/pwd-recursive-while/jobflow.ipynb | 71 ++ .bak/pwd-recursive-while/jobflow_while.ipynb | 252 ++++++ .bak/pwd-recursive-while/postBuild | 1 + 593.dot.pdf | Bin 0 -> 13793 bytes 613.dot.pdf | Bin 0 -> 16439 bytes documentation/while_loop_export_approaches.md | 771 ++++++++++++++++++ example_workflows/__init__.py | 0 example_workflows/arithmetic/__init__.py | 0 example_workflows/while_loop/__init__.py | 0 example_workflows/while_loop/aiida.ipynb | 220 +++++ .../while_loop/arithmetic_with_while.json | 29 + example_workflows/while_loop/day_2.ipynb | 611 ++++++++++++++ example_workflows/while_loop/day_2.py | 234 ++++++ .../simple_workflow_with_while.json | 26 + .../while_loop/test_aiida_example.py | 52 ++ .../while_loop/test_statevars.json | 50 ++ .../while_loop/test_statevars.py | 52 ++ .../while_loop/test_while_node.py | 219 ----- example_workflows/while_loop/workflow.py | 54 +- .../while_loop/workflow_with_processing.json | 106 +++ html/WhileLoop.html | 290 +++++++ html/WorkGraph.html | 290 +++++++ pyproject.toml | 18 + .../src/python_workflow_definition/aiida.py | 280 ++++++- .../src/python_workflow_definition/jobflow.py | 65 +- while-demo.py | 91 +++ 29 files changed, 3649 insertions(+), 242 deletions(-) create mode 100644 .bak/pwd-recursive-while/README.md create mode 100644 .bak/pwd-recursive-while/aiidaworkgraph.ipynb create mode 100644 .bak/pwd-recursive-while/environment.yml create mode 100644 .bak/pwd-recursive-while/jobflow.ipynb create mode 100644 .bak/pwd-recursive-while/jobflow_while.ipynb create mode 100644 .bak/pwd-recursive-while/postBuild create mode 100644 593.dot.pdf create mode 100644 613.dot.pdf create mode 100644 documentation/while_loop_export_approaches.md create mode 100644 example_workflows/__init__.py create mode 100644 example_workflows/arithmetic/__init__.py create mode 100644 example_workflows/while_loop/__init__.py create mode 100644 example_workflows/while_loop/aiida.ipynb create mode 100644 example_workflows/while_loop/arithmetic_with_while.json create mode 100644 example_workflows/while_loop/day_2.ipynb create mode 100644 example_workflows/while_loop/day_2.py create mode 100644 example_workflows/while_loop/simple_workflow_with_while.json create mode 100644 example_workflows/while_loop/test_aiida_example.py create mode 100644 example_workflows/while_loop/test_statevars.json create mode 100644 example_workflows/while_loop/test_statevars.py delete mode 100644 example_workflows/while_loop/test_while_node.py create mode 100644 example_workflows/while_loop/workflow_with_processing.json create mode 100644 html/WhileLoop.html create mode 100644 html/WorkGraph.html create mode 100644 pyproject.toml create mode 100644 while-demo.py diff --git a/.bak/pwd-recursive-while/README.md b/.bak/pwd-recursive-while/README.md new file mode 100644 index 0000000..7facb67 --- /dev/null +++ b/.bak/pwd-recursive-while/README.md @@ -0,0 +1,2 @@ +# Python Workflow Definition - Recursive While +[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/jan-janssen/pwd-recursive-while/HEAD) diff --git a/.bak/pwd-recursive-while/aiidaworkgraph.ipynb b/.bak/pwd-recursive-while/aiidaworkgraph.ipynb new file mode 100644 index 0000000..f414087 --- /dev/null +++ b/.bak/pwd-recursive-while/aiidaworkgraph.ipynb @@ -0,0 +1,101 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "0", + "metadata": {}, + "outputs": [], + "source": [ + "from aiida_workgraph import task\n", + "from aiida import load_profile" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "load_profile()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "@task\n", + "def add(x, y):\n", + " return x + y" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "@task.graph\n", + "def WhileLoop(n, m):\n", + " if m >= n:\n", + " return m\n", + " m = add(x=m, y=1).result\n", + " return WhileLoop(n=n, m=m)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "wg = WhileLoop.build(n=4, m=0)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "wg.run()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/.bak/pwd-recursive-while/environment.yml b/.bak/pwd-recursive-while/environment.yml new file mode 100644 index 0000000..c606abe --- /dev/null +++ b/.bak/pwd-recursive-while/environment.yml @@ -0,0 +1,6 @@ +channels: + - conda-forge +dependencies: + - python =3.12 + - jobflow =0.2.0 + - aiida-workgraph =0.7.3 diff --git a/.bak/pwd-recursive-while/jobflow.ipynb b/.bak/pwd-recursive-while/jobflow.ipynb new file mode 100644 index 0000000..7ff964c --- /dev/null +++ b/.bak/pwd-recursive-while/jobflow.ipynb @@ -0,0 +1,71 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "0", + "metadata": {}, + "outputs": [], + "source": [ + "from jobflow import job, Flow, Response\n", + "from jobflow.managers.local import run_locally" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "@job\n", + "def while_loop(n, m):\n", + " if m > n: \n", + " return m\n", + " m += 1\n", + " job_1 = while_loop(n=n, m=m)\n", + " return Response(replace=job_1, output=job_1.output)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "flow = Flow([while_loop(n=5, m=0)])\n", + "run_locally(flow)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.8" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/.bak/pwd-recursive-while/jobflow_while.ipynb b/.bak/pwd-recursive-while/jobflow_while.ipynb new file mode 100644 index 0000000..4f4d227 --- /dev/null +++ b/.bak/pwd-recursive-while/jobflow_while.ipynb @@ -0,0 +1,252 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# While Loop with Jobflow\n", + "\n", + "This notebook demonstrates using the WhileNode with Jobflow backend.\n", + "\n", + "## Setup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "import sys\nfrom pathlib import Path\n\n# Add source to path\nsrc_path = Path.cwd().parent / \"python_workflow_definition\" / \"src\"\nsys.path.insert(0, str(src_path))\n\n# Add workflow examples to path so they can be imported\nworkflow_examples_path = Path.cwd().parent / \"example_workflows\" / \"while_loop\"\nsys.path.insert(0, str(workflow_examples_path))\n\nfrom jobflow import run_locally" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example 1: Load While Loop from JSON\n", + "\n", + "Load a simple counter workflow with while loop from JSON." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from python_workflow_definition.jobflow import load_workflow_json\n", + "\n", + "# Create the JSON workflow file path\n", + "json_file = Path.cwd().parent / \"example_workflows\" / \"while_loop\" / \"simple_counter.json\"\n", + "\n", + "# Load the workflow\n", + "flow = load_workflow_json(str(json_file))\n", + "\n", + "print(f\"Loaded Flow with {len(flow.jobs)} jobs\")\n", + "print(f\"Job names: {[job.name for job in flow.jobs]}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example 2: Run the Flow\n", + "\n", + "Execute the while loop workflow locally." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Run the flow locally\n", + "responses = run_locally(flow)\n", + "\n", + "print(\"Flow completed!\")\n", + "print(f\"Number of responses: {len(responses)}\")\n", + "\n", + "# Get the final result\n", + "for response in responses.values():\n", + " print(f\"Result: {response[1].output}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example 3: Expression-based Condition\n", + "\n", + "Load and run a while loop with expression-based condition." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "json_file_expr = Path.cwd().parent / \"example_workflows\" / \"while_loop\" / \"simple_counter_expression.json\"\n", + "\n", + "flow_expr = load_workflow_json(str(json_file_expr))\n", + "responses_expr = run_locally(flow_expr)\n", + "\n", + "print(\"Expression-based flow completed!\")\n", + "for response in responses_expr.values():\n", + " print(f\"Result: {response[1].output}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example 4: Create While Loop Manually\n", + "\n", + "Create a while loop workflow using Python API and export to JSON." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from python_workflow_definition.models import (\n", + " PythonWorkflowDefinitionWorkflow,\n", + " PythonWorkflowDefinitionWhileNode,\n", + " PythonWorkflowDefinitionInputNode,\n", + " PythonWorkflowDefinitionOutputNode,\n", + " PythonWorkflowDefinitionEdge,\n", + ")\n", + "\n", + "# Define workflow with custom parameters\n", + "workflow = PythonWorkflowDefinitionWorkflow(\n", + " version=\"0.1.0\",\n", + " nodes=[\n", + " PythonWorkflowDefinitionInputNode(id=0, type=\"input\", name=\"n\", value=20),\n", + " PythonWorkflowDefinitionInputNode(id=1, type=\"input\", name=\"m\", value=0),\n", + " PythonWorkflowDefinitionWhileNode(\n", + " id=2,\n", + " type=\"while\",\n", + " conditionFunction=\"workflow.is_less_than\",\n", + " bodyFunction=\"workflow.increment_m\",\n", + " maxIterations=500,\n", + " ),\n", + " PythonWorkflowDefinitionOutputNode(id=3, type=\"output\", name=\"result\"),\n", + " ],\n", + " edges=[\n", + " PythonWorkflowDefinitionEdge(source=0, target=2, targetPort=\"n\"),\n", + " PythonWorkflowDefinitionEdge(source=1, target=2, targetPort=\"m\"),\n", + " PythonWorkflowDefinitionEdge(source=2, sourcePort=\"m\", target=3),\n", + " ],\n", + ")\n", + "\n", + "# Save to JSON\n", + "output_file = Path(\"/tmp/custom_while_loop_jobflow.json\")\n", + "workflow.dump_json_file(output_file)\n", + "\n", + "print(f\"Saved workflow to {output_file}\")\n", + "print(workflow.dump_json())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example 5: Load and Execute Custom Workflow" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Load and run the custom workflow\n", + "flow_custom = load_workflow_json(str(output_file))\n", + "responses_custom = run_locally(flow_custom)\n", + "\n", + "print(\"Custom workflow completed!\")\n", + "for response in responses_custom.values():\n", + " result = response[1].output\n", + " print(f\"Final result: {result}\")\n", + " if isinstance(result, dict) and \"m\" in result:\n", + " print(f\"Counter reached: {result['m']}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example 6: Fibonacci with Accumulator\n", + "\n", + "More complex example using accumulator pattern." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create Fibonacci workflow\n", + "fib_workflow = PythonWorkflowDefinitionWorkflow(\n", + " version=\"0.1.0\",\n", + " nodes=[\n", + " PythonWorkflowDefinitionInputNode(id=0, type=\"input\", name=\"n\", value=10),\n", + " PythonWorkflowDefinitionInputNode(id=1, type=\"input\", name=\"current\", value=0),\n", + " PythonWorkflowDefinitionInputNode(id=2, type=\"input\", name=\"a\", value=0),\n", + " PythonWorkflowDefinitionInputNode(id=3, type=\"input\", name=\"b\", value=1),\n", + " PythonWorkflowDefinitionInputNode(id=4, type=\"input\", name=\"results\", value=[]),\n", + " PythonWorkflowDefinitionWhileNode(\n", + " id=5,\n", + " type=\"while\",\n", + " conditionExpression=\"current < n\",\n", + " bodyFunction=\"nested_workflow.fibonacci_step\", # You'd need to define this\n", + " maxIterations=50,\n", + " ),\n", + " PythonWorkflowDefinitionOutputNode(id=6, type=\"output\", name=\"fibonacci_result\"),\n", + " ],\n", + " edges=[\n", + " PythonWorkflowDefinitionEdge(source=0, target=5, targetPort=\"n\"),\n", + " PythonWorkflowDefinitionEdge(source=1, target=5, targetPort=\"current\"),\n", + " PythonWorkflowDefinitionEdge(source=2, target=5, targetPort=\"a\"),\n", + " PythonWorkflowDefinitionEdge(source=3, target=5, targetPort=\"b\"),\n", + " PythonWorkflowDefinitionEdge(source=4, target=5, targetPort=\"results\"),\n", + " PythonWorkflowDefinitionEdge(source=5, target=6),\n", + " ],\n", + ")\n", + "\n", + "print(\"Fibonacci workflow created (note: requires fibonacci_step function to be defined)\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "This notebook demonstrated:\n", + "1. Loading while loop workflows from JSON into Jobflow\n", + "2. Running workflows locally with `run_locally()`\n", + "3. Using both function-based and expression-based conditions\n", + "4. Creating workflows programmatically with the Python API\n", + "5. Exporting workflows to JSON format\n", + "6. More complex workflows with accumulator patterns" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/.bak/pwd-recursive-while/postBuild b/.bak/pwd-recursive-while/postBuild new file mode 100644 index 0000000..40cc949 --- /dev/null +++ b/.bak/pwd-recursive-while/postBuild @@ -0,0 +1 @@ +verdi presto --profile-name pwd diff --git a/593.dot.pdf b/593.dot.pdf new file mode 100644 index 0000000000000000000000000000000000000000..012a5e3f2af0900a2bfcf4059de628e18c181585 GIT binary patch literal 13793 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 literal 0 HcmV?d00001 diff --git a/613.dot.pdf b/613.dot.pdf new file mode 100644 index 0000000000000000000000000000000000000000..68110b05ff0d8a9e00f19d4f3e433149a4f5ec7b GIT binary patch literal 16439 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 IvZ6@;2RL|$6951J literal 0 HcmV?d00001 diff --git a/documentation/while_loop_export_approaches.md b/documentation/while_loop_export_approaches.md new file mode 100644 index 0000000..5feab6f --- /dev/null +++ b/documentation/while_loop_export_approaches.md @@ -0,0 +1,771 @@ +# While Loop Export Approaches: Design Document + +## Problem Statement + +### The Challenge + +We want to support a workflow that allows users to: +1. **Create** workflows using the aiida-workgraph Python API (natural, Pythonic) +2. **Export** workflows to a JSON interchange format (machine-readable) +3. **Import** workflows into other workflow managers (interoperability) + +The fundamental tension is: **How do we represent runtime constructs (like recursive `@task.graph` while loops) in a static JSON format?** + +### Specific Issue: While Loops in AiiDA-WorkGraph + +In aiida-workgraph, while loops are typically implemented using recursive `@task.graph` functions: + +```python +def condition(x, limit): + return limit > x + +@task +def function_body(x): + return x + 1 + +@task.graph() +def abstract_while_aiida(x, limit): + if not condition(x=x, limit=limit): + return x + x = function_body(x=x).result + return abstract_while_aiida(x=x, limit=limit) # Recursive call + +wg = abstract_while_aiida.build(x=0, limit=5) +``` + +When you call `.build()`, WorkGraph creates a graph structure containing: +- `graph_inputs`: Special node representing the graph's input interface +- `graph_outputs`: Special node representing the graph's output interface +- Task nodes (e.g., `function_body`) +- The recursion is "baked into" the graph structure itself through the recursive call + +**Problem**: This graph structure doesn't explicitly contain a "while loop" node - it's a pattern encoded in the recursion. How do we export this to our JSON format which has an explicit `"type": "while"` node? + +### The JSON While Loop Format + +Our target JSON format has an explicit while loop node type: + +```json +{ + "version": "0.1.0", + "nodes": [ + { + "id": 0, + "type": "while", + "conditionFunction": "module.condition", + "bodyFunction": "module.function_body", + "maxIterations": 1000 + } + ], + "edges": [] +} +``` + +Or with nested workflows: + +```json +{ + "id": 0, + "type": "while", + "conditionFunction": "module.condition", + "bodyWorkflow": { + "version": "0.1.0", + "nodes": [ + {"id": 100, "type": "input", "name": "x"}, + {"id": 101, "type": "function", "value": "module.step1"}, + {"id": 102, "type": "output", "name": "result"} + ], + "edges": [...] + }, + "maxIterations": 100 +} +``` + +## Current State + +### What Works ✅ + +1. **Loading from JSON**: The `load_workflow_json` function successfully loads while loop definitions from JSON and creates executable WorkGraphs + - Supports both simple mode (conditionFunction + bodyFunction) + - Supports complex mode (conditionFunction/conditionExpression + bodyWorkflow) + - Attaches metadata (`_while_node_metadata`) to the created functions for later export + +2. **Round-trip for JSON-originated workflows**: If you load a workflow from JSON, you can export it back to JSON successfully because the metadata is preserved + +### What Doesn't Work ❌ + +1. **Exporting manually-created `@task.graph` while loops**: When you create a while loop using the recursive pattern directly in Python (not loaded from JSON), there's no metadata to indicate: + - This is intended to be a while loop + - What the condition function is + - What the body function/workflow is + - Max iterations limit + +2. **Pattern detection**: Currently, we don't attempt to analyze the WorkGraph structure to detect while loop patterns automatically + +### Why graph_inputs and graph_outputs Exist + +The `graph_inputs` and `graph_outputs` nodes are WorkGraph's internal mechanism for representing the interface of a sub-graph: + +- `graph_inputs`: Collects all inputs to the graph and distributes them to tasks +- `graph_outputs`: Collects outputs from tasks and returns them from the graph + +In our JSON format, we represent this differently: +- Input nodes with `"type": "input"` +- Output nodes with `"type": "output"` +- Edges that connect them to the workflow tasks + +**Current handling**: We skip these internal nodes during export because they don't map directly to our JSON node types. However, for nested workflows (bodyWorkflow), we need to think about how to represent the interface properly. + +## Approach 1: Explicit Constructor Pattern + +### Concept + +Provide explicit functions to create while loops with the right metadata attached, rather than relying on `@task.graph` recursion. + +### API Design + +```python +from aiida_workgraph import WorkGraph +from python_workflow_definition.aiida import create_while_loop_task + +# Simple mode: condition + body as function references +wg = WorkGraph("my_workflow") +while_task = create_while_loop_task( + condition_function="examples.while_loop.condition", + body_function="examples.while_loop.function_body", + max_iterations=1000 +) +wg.add_task(while_task) + +# Add inputs +wg.add_task(orm.Int(0), name="initial_x") +wg.add_task(orm.Int(5), name="limit") + +# Connect inputs to while loop +wg.add_link(wg.tasks["initial_x"].outputs.result, while_task.inputs.x) +wg.add_link(wg.tasks["limit"].outputs.result, while_task.inputs.limit) +``` + +For complex mode with nested workflow: + +```python +# Build the body workflow separately +body_wg = WorkGraph("body") +body_wg.add_task(some_function, name="step1") +body_wg.add_task(another_function, name="step2") +# ... connect tasks ... + +# Create while loop with body workflow +wg = WorkGraph("main") +while_task = create_while_loop_task( + condition_function="examples.while_loop.condition", + body_workflow=body_wg, + max_iterations=100 +) +wg.add_task(while_task) +``` + +### Implementation Strategy + +1. `create_while_loop_task()` creates a `@task.graph` function internally +2. Attaches `_while_node_metadata` to store all the while loop configuration +3. Returns a task that can be added to a WorkGraph +4. During export, `write_workflow_json` detects the metadata and exports as a while node + +### Pros ✅ + +- **Explicit and clear**: Users explicitly declare "this is a while loop" +- **Easy to implement**: We control the construction, so we can attach metadata properly +- **Exportable by design**: Metadata is always present for export +- **No pattern matching needed**: We know it's a while loop because the user said so + +### Cons ❌ + +- **Different from natural Python**: Doesn't look like a normal while loop or recursion +- **More verbose**: Requires separate function definitions and explicit wiring +- **Less discoverable**: Users need to learn a new API rather than using familiar `@task.graph` +- **Doesn't work for existing code**: Can't export existing `@task.graph` while loops + +### Code Example: Full Workflow + +```python +from aiida_workgraph import WorkGraph, task +from aiida import orm +from python_workflow_definition.aiida import create_while_loop_task, write_workflow_json + +# Define condition and body as regular Python functions +def condition(x, limit): + """Check if we should continue iterating.""" + return x < limit + +@task +def increment(x): + """Body function that increments x.""" + return x + 1 + +# Create the main workflow +wg = WorkGraph("while_loop_example") + +# Add input data nodes +x_input = orm.Int(0) +limit_input = orm.Int(5) + +# Create the while loop task +while_task = create_while_loop_task( + condition_function="__main__.condition", + body_function="__main__.increment", + max_iterations=1000 +) + +wg.add_task(while_task, name="while_loop") + +# Wire up inputs +wg.add_link(x_input, while_task.inputs.x) +wg.add_link(limit_input, while_task.inputs.limit) + +# Export to JSON +write_workflow_json(wg, "while_example.json") +``` + +## Approach 2: Decorator-Based Annotation + +### Concept + +Extend the `@task.graph` decorator to accept while loop metadata, allowing users to keep the natural recursive pattern while adding export capability. + +### API Design + +```python +from aiida_workgraph import task + +def condition(x, limit): + return x < limit + +@task +def increment(x): + return x + 1 + +# Annotate the recursive function with while loop metadata +@task.graph.while_loop( + condition="__main__.condition", + body="__main__.increment", + max_iterations=1000 +) +def while_loop(x, limit): + """Natural recursive implementation with export metadata.""" + if not condition(x, limit): + return x + x = increment(x).result + return while_loop(x, limit) + +# Use it naturally +wg = while_loop.build(x=0, limit=5) +``` + +For complex mode with nested workflow: + +```python +@task.graph() +def body_workflow(x): + """The body as a separate graph.""" + step1 = calculate_something(x) + step2 = calculate_more(step1.result) + return step2.result + +@task.graph.while_loop( + condition="__main__.condition", + body_workflow=body_workflow, # Reference to another @task.graph + max_iterations=100 +) +def while_loop_nested(x, limit): + if not condition(x, limit): + return x + x = body_workflow(x).result + return while_loop_nested(x, limit) +``` + +### Implementation Strategy + +1. Extend `@task.graph` decorator to accept an optional `.while_loop()` method +2. When `.while_loop()` is called, it returns a modified decorator that: + - Still creates the normal recursive `@task.graph` function + - Attaches `_while_node_metadata` with the provided configuration +3. The runtime behavior is unchanged (still executes as recursion) +4. During export, the metadata is used to create a while node + +### Pros ✅ + +- **Natural syntax**: Looks like normal Python recursion +- **Keeps recursive pattern**: Users write the loop logic as they would naturally +- **Explicit export intent**: Metadata makes it clear this should be exported as a while loop +- **Minimal API surface**: Small extension to existing `@task.graph` decorator +- **Self-documenting**: The decorator parameters document the loop structure + +### Cons ❌ + +- **Metadata duplication**: The condition and body are specified both in the decorator and in the code +- **Potential inconsistency**: Runtime behavior might not match the metadata if they diverge +- **Limited validation**: Hard to verify that the metadata matches the actual recursive structure +- **Complexity in decorator**: Makes the `@task.graph` decorator more complex +- **Body workflow handling**: For bodyWorkflow, need to handle nested @task.graph references + +### Code Example: Full Workflow + +```python +from aiida_workgraph import task + +def condition(x, limit): + return x < limit + +@task +def increment(x): + return x + 1 + +# Simple mode: annotated recursive function +@task.graph.while_loop( + condition="__main__.condition", + body="__main__.increment", + max_iterations=1000 +) +def simple_while(x, limit): + if not condition(x, limit): + return {"x": x} + result = increment(x) + return simple_while(result.result, limit) + +# Build and export +wg = simple_while.build(x=0, limit=5) +write_workflow_json(wg, "annotated_while.json") + +# Complex mode: with nested workflow +@task +def step1(x): + return x * 2 + +@task +def step2(x): + return x + 1 + +@task.graph() +def body_tasks(x): + """Multi-step body as a workflow.""" + result1 = step1(x) + result2 = step2(result1.result) + return {"x": result2.result} + +@task.graph.while_loop( + condition="__main__.condition", + body_workflow="__main__.body_tasks", # Reference as string + max_iterations=100 +) +def complex_while(x, limit): + if not condition(x, limit): + return {"x": x} + result = body_tasks(x) + return complex_while(result["x"], limit) +``` + +## Approach 3: Pattern Detection and Analysis + +### Concept + +Automatically analyze the built WorkGraph structure to detect while loop patterns, extract the components, and reconstruct as a while loop in JSON. + +### Detection Strategy + +When exporting a WorkGraph, for each `@task.graph` node: + +1. **Detect recursion**: Check if the graph calls itself (name matches) +2. **Identify condition**: Look for conditional branching (if statements that return early) +3. **Extract body**: Find tasks executed between condition check and recursive call +4. **Determine inputs/outputs**: Analyze graph_inputs and graph_outputs to understand the interface +5. **Build while node**: Reconstruct as a while loop with detected components + +### Example Pattern + +```python +@task.graph() +def abstract_while_aiida(x, limit): + if not condition(x=x, limit=limit): # <-- Condition (exit case) + return x # <-- Early return + x = function_body(x=x).result # <-- Body + return abstract_while_aiida(x=x, limit=limit) # <-- Recursive call +``` + +Pattern analysis would detect: +- Recursive call to `abstract_while_aiida` +- Conditional branch with early return +- Condition function: `condition` +- Body function: `function_body` +- Loop variable: `x` (passed to both condition and body) + +### Implementation Challenges + +1. **Condition extraction**: + - Condition might be inline (`if x < limit`) or a function call (`if condition(x, limit)`) + - Need to serialize inline conditions to conditionExpression + - Need to resolve function references for conditionFunction + +2. **Body extraction**: + - Body might be a single task or multiple tasks + - Need to determine if it should be bodyFunction or bodyWorkflow + - Must extract the subgraph between condition and recursion + +3. **State variable tracking**: + - Determine which variables are loop state (passed to recursion) + - Which are constant (like `limit`) + - Map to input/output nodes correctly + +4. **Graph structure analysis**: + - Parse the AST or analyze the built graph structure + - Handle different code patterns (multiple if-statements, complex bodies, etc.) + - Deal with graph_inputs and graph_outputs mappings + +### Pros ✅ + +- **No API changes**: Works with existing `@task.graph` code +- **Automatic**: Users don't need to think about export metadata +- **Backward compatible**: Can export existing while loop patterns +- **Natural workflow**: Users write code as they normally would + +### Cons ❌ + +- **Very complex**: Requires sophisticated pattern matching and graph analysis +- **Fragile**: May not work for all valid while loop patterns +- **False positives/negatives**: Might misidentify patterns or miss valid loops +- **Hard to debug**: When it fails, users won't know why or how to fix it +- **Maintenance burden**: Need to update pattern matching as code patterns evolve +- **Performance**: Graph analysis could be expensive for large workflows +- **Limited patterns**: Might only work for simple, canonical while loop forms + +### Pseudo-code Implementation + +```python +def detect_while_loop_pattern(task_graph_node): + """Attempt to detect if a @task.graph is a while loop pattern.""" + + # 1. Check for recursive call + if not has_recursive_call(task_graph_node): + return None + + # 2. Analyze the graph structure + graph_structure = analyze_graph(task_graph_node) + + # 3. Look for conditional branching with early return + condition_info = find_condition_branch(graph_structure) + if not condition_info: + return None + + # 4. Extract body tasks (between condition and recursion) + body_tasks = extract_body_tasks(graph_structure, condition_info) + + # 5. Determine if body is simple (single task) or complex (subgraph) + if len(body_tasks) == 1: + body_type = "bodyFunction" + body_value = get_function_reference(body_tasks[0]) + else: + body_type = "bodyWorkflow" + body_value = extract_subworkflow(body_tasks, graph_structure) + + # 6. Extract condition function reference + condition_ref = extract_condition_reference(condition_info) + + # 7. Build while loop metadata + return { + "conditionFunction": condition_ref, + body_type: body_value, + "maxIterations": 1000, # Default, can't be determined from code + } +``` + +## Nested Workflow Body Options + +For the complex mode (`bodyWorkflow`), we need to decide how users specify the nested workflow structure. + +### Option A: Separate WorkGraph Instance + +Users create a WorkGraph for the body separately and pass it in. + +```python +# Build the body workflow +body = WorkGraph("body") +body.add_task(step1, name="s1") +body.add_task(step2, name="s2") +body.add_link(body.tasks.s1.outputs.result, body.tasks.s2.inputs.x) + +# Create while loop with body +while_task = create_while_loop_task( + condition_function="module.condition", + body_workflow=body, + max_iterations=100 +) +``` + +**Pros**: +- Explicit and clear +- Full control over body structure +- Can reuse body in multiple places +- Easy to test body independently + +**Cons**: +- Verbose +- Need to manage multiple WorkGraph instances +- Have to wire up inputs/outputs carefully + +### Option B: Builder Pattern with Context Manager + +Use a context manager to build the body inline. + +```python +while_task = create_while_loop_task( + condition_function="module.condition", + max_iterations=100 +) + +with while_task.body() as body: + s1 = body.add_task(step1, name="s1") + s2 = body.add_task(step2, name="s2") + body.add_link(s1.outputs.result, s2.inputs.x) +``` + +**Pros**: +- Cleaner syntax +- Clear scope for body tasks +- Context manager handles setup/teardown + +**Cons**: +- More complex API +- Harder to reuse body +- Limited flexibility + +### Option C: Inline task.graph Function + +Allow passing a `@task.graph` function as the body, which gets converted. + +```python +@task.graph() +def body_workflow(x): + s1 = step1(x) + s2 = step2(s1.result) + return s2.result + +while_task = create_while_loop_task( + condition_function="module.condition", + body_workflow=body_workflow, # Pass the task.graph function + max_iterations=100 +) +``` + +**Pros**: +- Natural Python function syntax +- Reusable body definition +- Can test body independently +- Consistent with @task.graph patterns + +**Cons**: +- Need to "unpack" the task.graph to extract the workflow +- Might be confusing (is it executed or converted?) +- Need to handle inputs/outputs mapping + +## Handling graph_inputs and graph_outputs + +### The Role of These Nodes + +In WorkGraph: +- `graph_inputs`: Entry point for data coming into a sub-graph +- `graph_outputs`: Exit point for data leaving a sub-graph + +For a while loop body workflow: +``` +graph_inputs (x=5) → body_task (x+1) → graph_outputs (result=6) +``` + +### Mapping to JSON Format + +In our JSON format, we use explicit input and output nodes: + +```json +{ + "bodyWorkflow": { + "nodes": [ + {"id": 100, "type": "input", "name": "x"}, + {"id": 101, "type": "function", "value": "module.body_task"}, + {"id": 102, "type": "output", "name": "result"} + ], + "edges": [ + {"source": 100, "target": 101, "targetPort": "x"}, + {"source": 101, "target": 102, "sourcePort": "result"} + ] + } +} +``` + +### Current Handling: Why We Skip Them + +Currently, `write_workflow_json` skips `graph_inputs` and `graph_outputs` because: + +1. **Top-level workflows**: For the main workflow, inputs are represented as data nodes with `"type": "input"` and explicit values +2. **Not directly mappable**: graph_inputs/outputs are WorkGraph internals, not user-defined nodes +3. **Implicit interface**: The workflow interface is defined by the edges, not explicit nodes + +### When They Should Be Converted + +For **nested workflows** (bodyWorkflow in while loops): + +1. **graph_inputs should become input nodes**: Each input to the body becomes an explicit input node +2. **graph_outputs should become output nodes**: Each output from the body becomes an explicit output node +3. **Edges need updating**: Connect input nodes to tasks, tasks to output nodes + +### Export Strategy for Nested Workflows + +When exporting a while loop with bodyWorkflow: + +```python +# For each socket in graph_inputs: +# Create an input node: {"id": N, "type": "input", "name": socket_name} +# Create edges from input node to tasks that use it + +# For each socket in graph_outputs: +# Create an output node: {"id": M, "type": "output", "name": socket_name} +# Create edges from tasks to output node + +# Regular tasks in between are exported normally +``` + +Example transformation: + +```python +# WorkGraph structure: +graph_inputs → [step1, step2] → graph_outputs + +# Becomes JSON: +{ + "nodes": [ + {"id": 100, "type": "input", "name": "x"}, + {"id": 101, "type": "function", "value": "module.step1"}, + {"id": 102, "type": "function", "value": "module.step2"}, + {"id": 103, "type": "output", "name": "result"} + ], + "edges": [ + {"source": 100, "target": 101}, + {"source": 101, "target": 102}, + {"source": 102, "target": 103} + ] +} +``` + +## Recommendations + +### Short-term: Explicit Constructor (Approach 1) + +**Recommendation**: Implement Approach 1 (Explicit Constructor) first. + +**Rationale**: +- Simplest to implement correctly +- Provides immediate value for users who want to export while loops +- Serves as a foundation for more sophisticated approaches later +- Clear and explicit, reducing confusion + +**Implementation plan**: +1. Create `create_while_loop_task()` function +2. Support both simple mode (conditionFunction + bodyFunction) and complex mode (conditionFunction + bodyWorkflow) +3. For bodyWorkflow, use Option C (inline task.graph function) or Option A (separate WorkGraph) +4. Update `write_workflow_json` to properly export nested bodyWorkflow with input/output nodes + +### Medium-term: Decorator Enhancement (Approach 2) + +**After** Approach 1 is working, consider adding Approach 2 as syntactic sugar. + +**Rationale**: +- Provides a more natural API for users familiar with @task.graph +- Can internally delegate to the explicit constructor +- Offers better developer experience while keeping implementation simple + +### Long-term: Pattern Detection (Approach 3) + +**Only if needed**: Consider Approach 3 for backward compatibility with existing code. + +**Rationale**: +- Complex and fragile +- Only valuable for exporting existing @task.graph while loops +- Most use cases can be served by Approaches 1 and 2 +- Could be implemented as a separate "migration tool" rather than core functionality + +### Hybrid Approach + +Combine approaches for maximum flexibility: + +1. **Explicit constructor** (Approach 1): For programmatic creation +2. **Decorator annotations** (Approach 2): For natural syntax +3. **Both internally use the same metadata mechanism** +4. **Pattern detection** (Approach 3): Optional tool for migration only + +### Testing Strategy + +To validate any implementation: + +1. **Round-trip tests**: Load JSON → Build WorkGraph → Export JSON → Compare +2. **Execution tests**: Verify that exported/imported workflows execute correctly +3. **Edge cases**: Empty bodies, complex conditions, nested loops, error handling +4. **Cross-platform**: Test with different workflow managers (jobflow, pyiron, etc.) + +## Example: Complete Flow with Approach 1 + +```python +# 1. Define reusable condition and body functions +def check_convergence(energy, threshold): + """Condition: stop when converged.""" + return abs(energy - prev_energy) > threshold + +@task +def calculate_energy(structure): + """Body: compute energy and update structure.""" + # ... DFT calculation ... + return {"structure": new_structure, "energy": energy} + +# 2. Create workflow with explicit while loop +from python_workflow_definition.aiida import create_while_loop_task + +wg = WorkGraph("optimization") + +# Add inputs +wg.add_task(orm.StructureData(...), name="initial_structure") +wg.add_task(orm.Float(0.001), name="threshold") + +# Create while loop +optimization_loop = create_while_loop_task( + condition_function="module.check_convergence", + body_function="module.calculate_energy", + max_iterations=100 +) +wg.add_task(optimization_loop, name="optimize") + +# Wire inputs +wg.add_link(wg.tasks.initial_structure.outputs.result, optimization_loop.inputs.structure) +wg.add_link(wg.tasks.threshold.outputs.result, optimization_loop.inputs.threshold) + +# 3. Export to JSON for other workflow managers +write_workflow_json(wg, "optimization.json") + +# 4. Later: Load in another workflow manager +# (jobflow, pyiron, etc. can read optimization.json) +``` + +## Open Questions + +1. **State management**: How do we handle loop state variables that need to persist across iterations? +2. **Error handling**: What happens if the condition function fails? Body function fails? +3. **Nested loops**: Should we support while loops inside while loop bodies? +4. **Break/continue**: Can we support early exit or skip semantics? +5. **Performance**: For large graphs, how do we efficiently detect patterns and export? +6. **Versioning**: How do we handle evolution of the JSON format over time? + +## Conclusion + +The while loop export problem is fundamentally about bridging the gap between: +- **Runtime**: Dynamic, recursive, Python-native +- **Static representation**: Fixed structure, declarative, JSON-based + +Each approach offers different trade-offs between naturalness, explicitness, and complexity. The recommended path is to start with explicit constructors (Approach 1) for clarity and correctness, then enhance with decorator syntax (Approach 2) for better developer experience, and only consider pattern detection (Approach 3) if backward compatibility requires it. + +The key insight is that **metadata preservation** is essential - whether attached via constructor, decorator, or detection, we need to know the while loop's structure to export it correctly. The approach that makes this metadata most explicit and maintainable will be the most successful long-term solution. diff --git a/example_workflows/__init__.py b/example_workflows/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/example_workflows/arithmetic/__init__.py b/example_workflows/arithmetic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/example_workflows/while_loop/__init__.py b/example_workflows/while_loop/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/example_workflows/while_loop/aiida.ipynb b/example_workflows/while_loop/aiida.ipynb new file mode 100644 index 0000000..a6f75ba --- /dev/null +++ b/example_workflows/while_loop/aiida.ipynb @@ -0,0 +1,220 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# While Loop with AiiDA WorkGraph\n", + "\n", + "This notebook demonstrates using the WhileNode with AiiDA WorkGraph backend.\n", + "\n", + "## Setup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "import sys\nfrom pathlib import Path\n\n# Add source to path\nsrc_path = Path.cwd().parent / \"python_workflow_definition\" / \"src\"\nsys.path.insert(0, str(src_path))\n\n# Add workflow examples to path so they can be imported\nworkflow_examples_path = Path.cwd().parent / \"example_workflows\" / \"while_loop\"\nsys.path.insert(0, str(workflow_examples_path))\n\nfrom aiida import load_profile\nload_profile()" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example 1: Load While Loop from JSON\n", + "\n", + "Load a simple counter workflow with while loop from JSON." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "ename": "ModuleNotFoundError", + "evalue": "No module named 'workflow'", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mModuleNotFoundError\u001b[0m Traceback (most recent call last)", + "Cell \u001b[0;32mIn[2], line 7\u001b[0m\n\u001b[1;32m 4\u001b[0m json_file \u001b[38;5;241m=\u001b[39m Path\u001b[38;5;241m.\u001b[39mcwd()\u001b[38;5;241m.\u001b[39mparent \u001b[38;5;241m/\u001b[39m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mexample_workflows\u001b[39m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;241m/\u001b[39m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mwhile_loop\u001b[39m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;241m/\u001b[39m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124msimple_counter.json\u001b[39m\u001b[38;5;124m\"\u001b[39m\n\u001b[1;32m 6\u001b[0m \u001b[38;5;66;03m# Load the workflow\u001b[39;00m\n\u001b[0;32m----> 7\u001b[0m wg \u001b[38;5;241m=\u001b[39m \u001b[43mload_workflow_json\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;28;43mstr\u001b[39;49m\u001b[43m(\u001b[49m\u001b[43mjson_file\u001b[49m\u001b[43m)\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 9\u001b[0m \u001b[38;5;28mprint\u001b[39m(\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mLoaded WorkGraph with \u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[38;5;28mlen\u001b[39m(wg\u001b[38;5;241m.\u001b[39mtasks)\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m tasks\u001b[39m\u001b[38;5;124m\"\u001b[39m)\n\u001b[1;32m 10\u001b[0m \u001b[38;5;28mprint\u001b[39m(\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mTasks: \u001b[39m\u001b[38;5;132;01m{\u001b[39;00m[t\u001b[38;5;241m.\u001b[39mname\u001b[38;5;250m \u001b[39m\u001b[38;5;28;01mfor\u001b[39;00m\u001b[38;5;250m \u001b[39mt\u001b[38;5;250m \u001b[39m\u001b[38;5;129;01min\u001b[39;00m\u001b[38;5;250m \u001b[39mwg\u001b[38;5;241m.\u001b[39mtasks]\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m)\n", + "File \u001b[0;32m~/aiida_projects/adis/git-repos/python-workflow-definition/python_workflow_definition/src/python_workflow_definition/aiida.py:96\u001b[0m, in \u001b[0;36mload_workflow_json\u001b[0;34m(file_name)\u001b[0m\n\u001b[1;32m 93\u001b[0m task_name_mapping[\u001b[38;5;28mid\u001b[39m] \u001b[38;5;241m=\u001b[39m wg\u001b[38;5;241m.\u001b[39mtasks[\u001b[38;5;241m-\u001b[39m\u001b[38;5;241m1\u001b[39m]\n\u001b[1;32m 94\u001b[0m \u001b[38;5;28;01melif\u001b[39;00m node_type \u001b[38;5;241m==\u001b[39m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mwhile\u001b[39m\u001b[38;5;124m\"\u001b[39m:\n\u001b[1;32m 95\u001b[0m \u001b[38;5;66;03m# Create while loop task\u001b[39;00m\n\u001b[0;32m---> 96\u001b[0m while_task \u001b[38;5;241m=\u001b[39m \u001b[43m_create_while_loop_task\u001b[49m\u001b[43m(\u001b[49m\u001b[43mnode\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 97\u001b[0m wg\u001b[38;5;241m.\u001b[39madd_task(while_task)\n\u001b[1;32m 98\u001b[0m \u001b[38;5;28;01mdel\u001b[39;00m wg\u001b[38;5;241m.\u001b[39mtasks[\u001b[38;5;241m-\u001b[39m\u001b[38;5;241m1\u001b[39m]\u001b[38;5;241m.\u001b[39moutputs[\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mresult\u001b[39m\u001b[38;5;124m\"\u001b[39m]\n", + "File \u001b[0;32m~/aiida_projects/adis/git-repos/python-workflow-definition/python_workflow_definition/src/python_workflow_definition/aiida.py:39\u001b[0m, in \u001b[0;36m_create_while_loop_task\u001b[0;34m(while_node_data)\u001b[0m\n\u001b[1;32m 37\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m condition_func:\n\u001b[1;32m 38\u001b[0m p, m \u001b[38;5;241m=\u001b[39m condition_func\u001b[38;5;241m.\u001b[39mrsplit(\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124m.\u001b[39m\u001b[38;5;124m\"\u001b[39m, \u001b[38;5;241m1\u001b[39m)\n\u001b[0;32m---> 39\u001b[0m mod \u001b[38;5;241m=\u001b[39m \u001b[43mimport_module\u001b[49m\u001b[43m(\u001b[49m\u001b[43mp\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 40\u001b[0m check_condition \u001b[38;5;241m=\u001b[39m \u001b[38;5;28mgetattr\u001b[39m(mod, m)\n\u001b[1;32m 41\u001b[0m \u001b[38;5;28;01melse\u001b[39;00m:\n\u001b[1;32m 42\u001b[0m \u001b[38;5;66;03m# Create condition function from expression\u001b[39;00m\n", + "File \u001b[0;32m/usr/lib/python3.10/importlib/__init__.py:126\u001b[0m, in \u001b[0;36mimport_module\u001b[0;34m(name, package)\u001b[0m\n\u001b[1;32m 124\u001b[0m \u001b[38;5;28;01mbreak\u001b[39;00m\n\u001b[1;32m 125\u001b[0m level \u001b[38;5;241m+\u001b[39m\u001b[38;5;241m=\u001b[39m \u001b[38;5;241m1\u001b[39m\n\u001b[0;32m--> 126\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[43m_bootstrap\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43m_gcd_import\u001b[49m\u001b[43m(\u001b[49m\u001b[43mname\u001b[49m\u001b[43m[\u001b[49m\u001b[43mlevel\u001b[49m\u001b[43m:\u001b[49m\u001b[43m]\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mpackage\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mlevel\u001b[49m\u001b[43m)\u001b[49m\n", + "File \u001b[0;32m:1050\u001b[0m, in \u001b[0;36m_gcd_import\u001b[0;34m(name, package, level)\u001b[0m\n", + "File \u001b[0;32m:1027\u001b[0m, in \u001b[0;36m_find_and_load\u001b[0;34m(name, import_)\u001b[0m\n", + "File \u001b[0;32m:1004\u001b[0m, in \u001b[0;36m_find_and_load_unlocked\u001b[0;34m(name, import_)\u001b[0m\n", + "\u001b[0;31mModuleNotFoundError\u001b[0m: No module named 'workflow'" + ] + } + ], + "source": [ + "from python_workflow_definition.aiida import load_workflow_json\n", + "\n", + "# Create the JSON workflow file path\n", + "json_file = Path.cwd().parent / \"example_workflows\" / \"while_loop\" / \"simple_counter.json\"\n", + "\n", + "# Load the workflow\n", + "wg = load_workflow_json(str(json_file))\n", + "\n", + "print(f\"Loaded WorkGraph with {len(wg.tasks)} tasks\")\n", + "print(f\"Tasks: {[t.name for t in wg.tasks]}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example 2: Build WorkGraph and Run\n", + "\n", + "Build and execute the while loop workflow." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Submit the workflow\n", + "wg.submit(wait=True)\n", + "\n", + "print(f\"Workflow state: {wg.state}\")\n", + "print(f\"Result: {wg.tasks[-1].outputs}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example 3: Expression-based Condition\n", + "\n", + "Load a while loop with expression-based condition." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "json_file_expr = Path.cwd().parent / \"example_workflows\" / \"while_loop\" / \"simple_counter_expression.json\"\n", + "\n", + "wg_expr = load_workflow_json(str(json_file_expr))\n", + "wg_expr.submit(wait=True)\n", + "\n", + "print(f\"Expression-based workflow state: {wg_expr.state}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example 4: Create While Loop Manually\n", + "\n", + "Create a while loop workflow using Python API and export to JSON." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from python_workflow_definition.models import (\n", + " PythonWorkflowDefinitionWorkflow,\n", + " PythonWorkflowDefinitionWhileNode,\n", + " PythonWorkflowDefinitionInputNode,\n", + " PythonWorkflowDefinitionOutputNode,\n", + " PythonWorkflowDefinitionEdge,\n", + ")\n", + "\n", + "# Define workflow\n", + "workflow = PythonWorkflowDefinitionWorkflow(\n", + " version=\"0.1.0\",\n", + " nodes=[\n", + " PythonWorkflowDefinitionInputNode(id=0, type=\"input\", name=\"n\", value=5),\n", + " PythonWorkflowDefinitionInputNode(id=1, type=\"input\", name=\"m\", value=0),\n", + " PythonWorkflowDefinitionWhileNode(\n", + " id=2,\n", + " type=\"while\",\n", + " conditionExpression=\"m < n\",\n", + " bodyFunction=\"workflow.increment_m\",\n", + " maxIterations=100,\n", + " ),\n", + " PythonWorkflowDefinitionOutputNode(id=3, type=\"output\", name=\"result\"),\n", + " ],\n", + " edges=[\n", + " PythonWorkflowDefinitionEdge(source=0, target=2, targetPort=\"n\"),\n", + " PythonWorkflowDefinitionEdge(source=1, target=2, targetPort=\"m\"),\n", + " PythonWorkflowDefinitionEdge(source=2, sourcePort=\"m\", target=3),\n", + " ],\n", + ")\n", + "\n", + "# Save to JSON\n", + "output_file = Path(\"/tmp/custom_while_loop.json\")\n", + "workflow.dump_json_file(output_file)\n", + "\n", + "print(f\"Saved workflow to {output_file}\")\n", + "print(workflow.dump_json())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example 5: Load and Execute Custom Workflow" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Load the custom workflow\n", + "wg_custom = load_workflow_json(str(output_file))\n", + "wg_custom.submit(wait=True)\n", + "\n", + "print(f\"Custom workflow completed with state: {wg_custom.state}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "This notebook demonstrated:\n", + "1. Loading while loop workflows from JSON\n", + "2. Executing workflows with AiiDA WorkGraph\n", + "3. Using both function-based and expression-based conditions\n", + "4. Creating workflows programmatically with the Python API\n", + "5. Exporting workflows to JSON format" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "PWD", + "language": "python", + "name": "pwd" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/example_workflows/while_loop/arithmetic_with_while.json b/example_workflows/while_loop/arithmetic_with_while.json new file mode 100644 index 0000000..ebb28c9 --- /dev/null +++ b/example_workflows/while_loop/arithmetic_with_while.json @@ -0,0 +1,29 @@ +{ + "version": "0.1.0", + "nodes": [ + {"id": 0, "type": "input", "value": 2, "name": "x"}, + {"id": 1, "type": "input", "value": 3, "name": "y"}, + {"id": 2, "type": "function", "value": "workflow.get_prod_and_div"}, + {"id": 3, "type": "function", "value": "workflow.get_sum"}, + {"id": 4, "type": "input", "value": 0, "name": "start"}, + + {"id": 5, "type": "while", + "conditionFunction": "workflow.is_less_than", + "bodyFunction": "workflow.increment_m", + "maxIterations": 1000 + }, + + {"id": 6, "type": "function", "value": "workflow.get_square"}, + {"id": 7, "type": "output", "name": "result"} + ], + "edges": [ + {"target": 2, "targetPort": "x", "source": 0, "sourcePort": null}, + {"target": 2, "targetPort": "y", "source": 1, "sourcePort": null}, + {"target": 3, "targetPort": "x", "source": 2, "sourcePort": "prod"}, + {"target": 3, "targetPort": "y", "source": 2, "sourcePort": "div"}, + {"target": 5, "targetPort": "n", "source": 3, "sourcePort": null}, + {"target": 5, "targetPort": "m", "source": 4, "sourcePort": null}, + {"target": 6, "targetPort": "x", "source": 5, "sourcePort": "m"}, + {"target": 7, "targetPort": null, "source": 6, "sourcePort": null} + ] +} diff --git a/example_workflows/while_loop/day_2.ipynb b/example_workflows/while_loop/day_2.ipynb new file mode 100644 index 0000000..293487a --- /dev/null +++ b/example_workflows/while_loop/day_2.ipynb @@ -0,0 +1,611 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "168e0c74-f2f2-435c-aed2-d74054cef224", + "metadata": {}, + "source": [ + "# Python" + ] + }, + { + "cell_type": "markdown", + "id": "e55a0c60-be82-46f9-83ad-281ab51707f5", + "metadata": {}, + "source": [ + "## Basic while loop" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "4f958a3a-f6e0-4459-95c8-0ec2e5e85025", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "5\n" + ] + } + ], + "source": [ + "x=0\n", + "limit = 5\n", + "\n", + "while x < limit:\n", + " x +=1\n", + "\n", + "print(x)" + ] + }, + { + "cell_type": "markdown", + "id": "0c777177-6e09-421b-bca8-0ce3af9dd033", + "metadata": {}, + "source": [ + "## Recursive " + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "4cf92ab8-67e6-453e-a945-e43b7220bd92", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "5" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "def while_loop(x, limit):\n", + " if not (x < limit):\n", + " return x\n", + " x += 1\n", + " return while_loop(x=x, limit=limit)\n", + "\n", + "while_loop(limit=5, x=0)" + ] + }, + { + "cell_type": "markdown", + "id": "f73d4ba6-0761-4935-8a66-96a968c3800f", + "metadata": {}, + "source": [ + "## Functional " + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "74a374fe-0c63-42ac-abe3-594782fcaedb", + "metadata": {}, + "outputs": [], + "source": [ + "def condition(x, limit):\n", + " return limit > x" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "bcf88d3a-eded-4263-b1f5-16e5c496a45b", + "metadata": {}, + "outputs": [], + "source": [ + "def function_body(x):\n", + " return x + 1" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "f9f5a531-d530-4e24-a99c-237b56ee1cc9", + "metadata": {}, + "outputs": [], + "source": [ + "def abstract_while(x, limit):\n", + " if not condition(x=x, limit=limit):\n", + " return x\n", + " x = function_body(x=x)\n", + " return abstract_while(x=x, limit=limit)" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "0d6cfdc7-b6a4-433a-9d6b-3d6456f3ae76", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "5" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "abstract_while(x=0, limit=5)" + ] + }, + { + "cell_type": "markdown", + "id": "4ba10282-0422-45f8-995c-ac6791a7f5ef", + "metadata": {}, + "source": [ + "# Workflow Manager" + ] + }, + { + "cell_type": "markdown", + "id": "c42cc98a-5034-45d0-b0e4-04b76469fee1", + "metadata": {}, + "source": [ + "## Aiida workgraph" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "32baafff-99fb-4d99-ba20-6ff6da05a866", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Profile" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from aiida_workgraph import task\n", + "from aiida import load_profile\n", + "load_profile()" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "d1c2dc59-ff59-4301-afd4-91167cbadbfb", + "metadata": {}, + "outputs": [], + "source": [ + "def condition(x, limit):\n", + " return limit > x" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "27539c6e-1fae-4c7c-9540-e9d31b4597da", + "metadata": {}, + "outputs": [], + "source": [ + "@task\n", + "def function_body(x):\n", + " return x + 1" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "7533fc03-4d8f-4286-8986-6252c5f6ed89", + "metadata": {}, + "outputs": [], + "source": [ + "@task.graph\n", + "def abstract_while_aiida(x, limit):\n", + " if not condition(x=x, limit=limit):\n", + " # if not limit > x:\n", + " return x\n", + " x = function_body(x=x).result\n", + " return abstract_while_aiida(x=x, limit=limit)" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "816f53d0-d252-44a2-860a-183e77ba9c4f", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "10/22/2025 09:17:05 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [619|WorkGraphEngine|continue_workgraph]: tasks ready to run: function_body\n", + "10/22/2025 09:17:06 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [619|WorkGraphEngine|update_task_state]: Task: function_body, type: PYFUNCTION, finished.\n", + "10/22/2025 09:17:06 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [619|WorkGraphEngine|continue_workgraph]: tasks ready to run: abstract_while_aiida\n", + "10/22/2025 09:17:06 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [619|WorkGraphEngine|on_wait]: Process status: Waiting for child processes: 622\n", + "10/22/2025 09:17:07 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [622|WorkGraphEngine|continue_workgraph]: tasks ready to run: function_body\n", + "10/22/2025 09:17:07 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [622|WorkGraphEngine|update_task_state]: Task: function_body, type: PYFUNCTION, finished.\n", + "10/22/2025 09:17:07 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [622|WorkGraphEngine|continue_workgraph]: tasks ready to run: abstract_while_aiida\n", + "10/22/2025 09:17:08 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [622|WorkGraphEngine|on_wait]: Process status: Waiting for child processes: 625\n", + "10/22/2025 09:17:08 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [625|WorkGraphEngine|continue_workgraph]: tasks ready to run: function_body\n", + "10/22/2025 09:17:09 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [625|WorkGraphEngine|update_task_state]: Task: function_body, type: PYFUNCTION, finished.\n", + "10/22/2025 09:17:09 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [625|WorkGraphEngine|continue_workgraph]: tasks ready to run: abstract_while_aiida\n", + "10/22/2025 09:17:10 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [625|WorkGraphEngine|on_wait]: Process status: Waiting for child processes: 628\n", + "10/22/2025 09:17:10 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [628|WorkGraphEngine|continue_workgraph]: tasks ready to run: function_body\n", + "10/22/2025 09:17:11 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [628|WorkGraphEngine|update_task_state]: Task: function_body, type: PYFUNCTION, finished.\n", + "10/22/2025 09:17:11 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [628|WorkGraphEngine|continue_workgraph]: tasks ready to run: abstract_while_aiida\n", + "10/22/2025 09:17:11 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [628|WorkGraphEngine|on_wait]: Process status: Waiting for child processes: 631\n", + "10/22/2025 09:17:12 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [631|WorkGraphEngine|continue_workgraph]: tasks ready to run: function_body\n", + "10/22/2025 09:17:12 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [631|WorkGraphEngine|update_task_state]: Task: function_body, type: PYFUNCTION, finished.\n", + "10/22/2025 09:17:12 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [631|WorkGraphEngine|continue_workgraph]: tasks ready to run: abstract_while_aiida\n", + "10/22/2025 09:17:13 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [631|WorkGraphEngine|on_wait]: Process status: Waiting for child processes: 634\n", + "10/22/2025 09:17:13 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [634|WorkGraphEngine|continue_workgraph]: tasks ready to run: \n", + "10/22/2025 09:17:13 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [634|WorkGraphEngine|finalize]: Finalize workgraph.\n", + "10/22/2025 09:17:13 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [631|WorkGraphEngine|update_task_state]: Task: abstract_while_aiida, type: GRAPH, finished.\n", + "10/22/2025 09:17:14 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [631|WorkGraphEngine|continue_workgraph]: tasks ready to run: \n", + "10/22/2025 09:17:14 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [631|WorkGraphEngine|finalize]: Finalize workgraph.\n", + "10/22/2025 09:17:14 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [628|WorkGraphEngine|update_task_state]: Task: abstract_while_aiida, type: GRAPH, finished.\n", + "10/22/2025 09:17:14 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [628|WorkGraphEngine|continue_workgraph]: tasks ready to run: \n", + "10/22/2025 09:17:14 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [628|WorkGraphEngine|finalize]: Finalize workgraph.\n", + "10/22/2025 09:17:15 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [625|WorkGraphEngine|update_task_state]: Task: abstract_while_aiida, type: GRAPH, finished.\n", + "10/22/2025 09:17:15 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [625|WorkGraphEngine|continue_workgraph]: tasks ready to run: \n", + "10/22/2025 09:17:15 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [625|WorkGraphEngine|finalize]: Finalize workgraph.\n", + "10/22/2025 09:17:15 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [622|WorkGraphEngine|update_task_state]: Task: abstract_while_aiida, type: GRAPH, finished.\n", + "10/22/2025 09:17:16 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [622|WorkGraphEngine|continue_workgraph]: tasks ready to run: \n", + "10/22/2025 09:17:16 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [622|WorkGraphEngine|finalize]: Finalize workgraph.\n", + "10/22/2025 09:17:16 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [619|WorkGraphEngine|update_task_state]: Task: abstract_while_aiida, type: GRAPH, finished.\n", + "10/22/2025 09:17:16 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [619|WorkGraphEngine|continue_workgraph]: tasks ready to run: \n", + "10/22/2025 09:17:16 AM <901431> aiida.orm.nodes.process.workflow.workchain.WorkChainNode: [REPORT] [619|WorkGraphEngine|finalize]: Finalize workgraph.\n" + ] + }, + { + "data": { + "text/plain": [ + "{'result': }" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "wg = abstract_while_aiida.build(x=0, limit=5)\n", + "wg.run()" + ] + }, + { + "cell_type": "markdown", + "id": "018e121a-3474-4d01-884c-9301724d8f68", + "metadata": {}, + "source": [ + "## jobflow " + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "64713160-d3db-470f-af5b-e9b49a2910c0", + "metadata": {}, + "outputs": [], + "source": [ + "from jobflow import job, Flow, Response\n", + "from jobflow.managers.local import run_locally" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "bbf832a5-c4cd-415d-b5dc-4fc227f5fef4", + "metadata": {}, + "outputs": [], + "source": [ + "def condition(x, limit):\n", + " return limit > x" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "94e09c9e-30e6-4f3a-b7cf-01c843096215", + "metadata": {}, + "outputs": [], + "source": [ + "def function_body(x):\n", + " return x + 1" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "17199730-0334-410d-80d7-219821a6ca5e", + "metadata": {}, + "outputs": [], + "source": [ + "@job\n", + "def abstract_while_jobflow(x, limit):\n", + " if not condition(x, limit): \n", + " return x\n", + " x = function_body(x)\n", + " job_obj = abstract_while_jobflow(x=x, limit=limit)\n", + " return Response(replace=job_obj, output=job_obj.output)" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "614fcd79-2c96-413e-b351-581ed9d7928e", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2025-10-22 03:28:06,229 INFO Started executing jobs locally\n", + "2025-10-22 03:28:06,492 INFO Starting job - abstract_while_jobflow (c3076712-2ba3-42d6-b95f-8114d9df440f)\n", + "2025-10-22 03:28:06,494 INFO Finished job - abstract_while_jobflow (c3076712-2ba3-42d6-b95f-8114d9df440f)\n", + "2025-10-22 03:28:06,495 INFO Starting job - abstract_while_jobflow (c3076712-2ba3-42d6-b95f-8114d9df440f, 2)\n", + "2025-10-22 03:28:06,496 INFO Finished job - abstract_while_jobflow (c3076712-2ba3-42d6-b95f-8114d9df440f, 2)\n", + "2025-10-22 03:28:06,497 INFO Starting job - abstract_while_jobflow (c3076712-2ba3-42d6-b95f-8114d9df440f, 3)\n", + "2025-10-22 03:28:06,498 INFO Finished job - abstract_while_jobflow (c3076712-2ba3-42d6-b95f-8114d9df440f, 3)\n", + "2025-10-22 03:28:06,498 INFO Starting job - abstract_while_jobflow (c3076712-2ba3-42d6-b95f-8114d9df440f, 4)\n", + "2025-10-22 03:28:06,499 INFO Finished job - abstract_while_jobflow (c3076712-2ba3-42d6-b95f-8114d9df440f, 4)\n", + "2025-10-22 03:28:06,500 INFO Starting job - abstract_while_jobflow (c3076712-2ba3-42d6-b95f-8114d9df440f, 5)\n", + "2025-10-22 03:28:06,501 INFO Finished job - abstract_while_jobflow (c3076712-2ba3-42d6-b95f-8114d9df440f, 5)\n", + "2025-10-22 03:28:06,502 INFO Starting job - abstract_while_jobflow (c3076712-2ba3-42d6-b95f-8114d9df440f, 6)\n", + "2025-10-22 03:28:06,504 INFO Finished job - abstract_while_jobflow (c3076712-2ba3-42d6-b95f-8114d9df440f, 6)\n", + "2025-10-22 03:28:06,507 INFO Finished executing jobs locally\n" + ] + }, + { + "data": { + "text/plain": [ + "{'c3076712-2ba3-42d6-b95f-8114d9df440f': {1: Response(output=OutputReference(c3076712-2ba3-42d6-b95f-8114d9df440f), detour=None, addition=None, replace=Flow(name='Flow', uuid='ab1426c4-f5ca-457d-b862-06420486e4e0')\n", + " 1. Job(name='abstract_while_jobflow', uuid='c3076712-2ba3-42d6-b95f-8114d9df440f'), stored_data=None, stop_children=False, stop_jobflow=False, job_dir=PosixPath('/home/jovyan')),\n", + " 2: Response(output=OutputReference(c3076712-2ba3-42d6-b95f-8114d9df440f), detour=None, addition=None, replace=Flow(name='Flow', uuid='8d1e656e-a405-4379-92e6-b76de0ce8d10')\n", + " 1. Job(name='abstract_while_jobflow', uuid='c3076712-2ba3-42d6-b95f-8114d9df440f'), stored_data=None, stop_children=False, stop_jobflow=False, job_dir=PosixPath('/home/jovyan')),\n", + " 3: Response(output=OutputReference(c3076712-2ba3-42d6-b95f-8114d9df440f), detour=None, addition=None, replace=Flow(name='Flow', uuid='33af5a51-18f4-43cc-9515-5e5df5b960b4')\n", + " 1. Job(name='abstract_while_jobflow', uuid='c3076712-2ba3-42d6-b95f-8114d9df440f'), stored_data=None, stop_children=False, stop_jobflow=False, job_dir=PosixPath('/home/jovyan')),\n", + " 4: Response(output=OutputReference(c3076712-2ba3-42d6-b95f-8114d9df440f), detour=None, addition=None, replace=Flow(name='Flow', uuid='ac44bc23-5a91-4ac6-bdbf-03c0f57bdcc2')\n", + " 1. Job(name='abstract_while_jobflow', uuid='c3076712-2ba3-42d6-b95f-8114d9df440f'), stored_data=None, stop_children=False, stop_jobflow=False, job_dir=PosixPath('/home/jovyan')),\n", + " 5: Response(output=OutputReference(c3076712-2ba3-42d6-b95f-8114d9df440f), detour=None, addition=None, replace=Flow(name='Flow', uuid='f97c0421-621d-45fd-aa9d-419ff3cdb2c3')\n", + " 1. Job(name='abstract_while_jobflow', uuid='c3076712-2ba3-42d6-b95f-8114d9df440f'), stored_data=None, stop_children=False, stop_jobflow=False, job_dir=PosixPath('/home/jovyan')),\n", + " 6: Response(output=5, detour=None, addition=None, replace=None, stored_data=None, stop_children=False, stop_jobflow=False, job_dir=PosixPath('/home/jovyan'))}}" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "flow = Flow([abstract_while_jobflow(limit=5, x=0)])\n", + "run_locally(flow)" + ] + }, + { + "cell_type": "markdown", + "id": "9c5df73c-e792-4fa1-912d-a83da5c1748e", + "metadata": {}, + "source": [ + "## pyiron_base" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "6d3450c2-8394-4844-a4ea-73b795603a83", + "metadata": {}, + "outputs": [], + "source": [ + "from pyiron_base import job, Project" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "871d74ae-8f9e-4ff3-b6c4-b6950102ad34", + "metadata": {}, + "outputs": [], + "source": [ + "def condition(x, limit):\n", + " return limit > x" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "b67e3205-3448-456d-ba0d-31bf09204b7b", + "metadata": {}, + "outputs": [], + "source": [ + "@job\n", + "def function_body(x):\n", + " return x + 1" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "36979cb7-fd46-4f1d-8540-43612ba2b30b", + "metadata": {}, + "outputs": [], + "source": [ + "# internal function\n", + "def while_generator(condition, function_body):\n", + " def abstract_while_pyiron(x, limit, pyiron_project=Project(\".\")):\n", + " if not condition(x=x, limit=limit):\n", + " return x\n", + " x = function_body(x=x, pyiron_project=pyiron_project).pull()\n", + " return abstract_while_pyiron(x=x, limit=limit, pyiron_project=pyiron_project)\n", + "\n", + " return abstract_while_pyiron" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "d509fe7c-8bc2-4c6a-b731-142f138cdf7d", + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "638b08b072e6411da227b283d2bc3834", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "0it [00:00, ?it/s]" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "pr = Project(\"test\")\n", + "pr.remove_jobs(recursive=True, silently=True)" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "2114315c-af3a-4e0d-b8d8-36f974b47365", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The job function_body_22fe092ee8c05b103ea1dd8cd57b603c was saved and received the ID: 1\n", + "The job function_body_0f4f6df822da6a6f04b8395467657bcc was saved and received the ID: 2\n", + "The job function_body_b1f9a2ea87658f57059d78a1a0368663 was saved and received the ID: 3\n", + "The job function_body_efaaa22b1b57e349af45c64dd0ef749d was saved and received the ID: 4\n", + "The job function_body_0b694a991ff7d973644d5ee70b7c8142 was saved and received the ID: 5\n" + ] + }, + { + "data": { + "text/plain": [ + "5" + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "while_generator(condition=condition, function_body=function_body)(x=0, limit=5, pyiron_project=pr)" + ] + }, + { + "cell_type": "markdown", + "id": "7b5ef791-937f-42f8-a625-a1866652aa04", + "metadata": {}, + "source": [ + "# Abstract Syntax Tree" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "c770a3bb-e82e-41a0-9a05-3b84bffb4d75", + "metadata": {}, + "outputs": [], + "source": [ + "from ast import dump, parse" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "id": "c480efbe-91ca-46c5-9331-cf8af257497c", + "metadata": {}, + "outputs": [], + "source": [ + "while_code = \"\"\"\\\n", + "x = 0 \n", + "while x < 5:\n", + " x += 1\n", + "\"\"\"" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "id": "1f67fb48-c98b-490b-91a4-90d4ed735743", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Module(\n", + " body=[\n", + " Assign(\n", + " targets=[\n", + " Name(id='x', ctx=Store())],\n", + " value=Constant(value=0)),\n", + " While(\n", + " test=Compare(\n", + " left=Name(id='x', ctx=Load()),\n", + " ops=[\n", + " Lt()],\n", + " comparators=[\n", + " Constant(value=5)]),\n", + " body=[\n", + " AugAssign(\n", + " target=Name(id='x', ctx=Store()),\n", + " op=Add(),\n", + " value=Constant(value=1))],\n", + " orelse=[])],\n", + " type_ignores=[])\n" + ] + } + ], + "source": [ + "print(dump(parse(while_code), indent=4))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "42152476-4c21-4da8-a4ca-45ba3b56e0ff", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/example_workflows/while_loop/day_2.py b/example_workflows/while_loop/day_2.py new file mode 100644 index 0000000..8a88fad --- /dev/null +++ b/example_workflows/while_loop/day_2.py @@ -0,0 +1,234 @@ +# coding: utf-8 + +# # Python + +# ## Basic while loop + +# In[1]: + + +x=0 +limit = 5 + +while x < limit: + x +=1 + +print(x) + + +# ## Recursive + +# In[2]: + + +def while_loop(x, limit): + if not (x < limit): + return x + x += 1 + return while_loop(x=x, limit=limit) + +while_loop(limit=5, x=0) + + +# ## Functional + +# In[3]: + + +def condition(x, limit): + return limit > x + + +# In[4]: + + +def function_body(x): + return x + 1 + + +# In[5]: + + +def abstract_while(x, limit): + if not condition(x=x, limit=limit): + return x + x = function_body(x=x) + return abstract_while(x=x, limit=limit) + + +# In[6]: + + +abstract_while(x=0, limit=5) + + +# # Workflow Manager + +# ## Aiida workgraph + +# In[7]: + + +from aiida_workgraph import task +from aiida import load_profile +load_profile() + + +# In[8]: + + +def condition(x, limit): + return limit > x + + +# In[9]: + +@task +def function_body(x): + return x + 1 + + +# In[10]: + + +@task.graph() +def abstract_while_aiida(x, limit): + if not condition(x=x, limit=limit): + # if not limit > x: + return x + x = function_body(x=x).result + return abstract_while_aiida(x=x, limit=limit) + + +# In[11]: + + +wg = abstract_while_aiida.build(x=0, limit=5) +# wg.run() + + +from python_workflow_definition.aiida import load_workflow_json, write_workflow_json + +write_workflow_json(wg=wg, file_name='write_while_loop.json') + +# ## jobflow + +# In[12]: + + +from jobflow import job, Flow, Response +from jobflow.managers.local import run_locally + + +# In[13]: + + +def condition(x, limit): + return limit > x + + +# In[14]: + + +def function_body(x): + return x + 1 + + +# In[15]: + + +@job +def abstract_while_jobflow(x, limit): + if not condition(x, limit): + return x + x = function_body(x) + job_obj = abstract_while_jobflow(x=x, limit=limit) + return Response(replace=job_obj, output=job_obj.output) + + +# In[16]: + + +flow = Flow([abstract_while_jobflow(limit=5, x=0)]) +run_locally(flow) + + +# ## pyiron_base + +# In[17]: + + +from pyiron_base import job, Project + + +# In[18]: + + +def condition(x, limit): + return limit > x + + +# In[19]: + + +@job +def function_body(x): + return x + 1 + + +# In[20]: + + +# internal function +def while_generator(condition, function_body): + def abstract_while_pyiron(x, limit, pyiron_project=Project(".")): + if not condition(x=x, limit=limit): + return x + x = function_body(x=x, pyiron_project=pyiron_project).pull() + return abstract_while_pyiron(x=x, limit=limit, pyiron_project=pyiron_project) + + return abstract_while_pyiron + + +# In[21]: + + +pr = Project("test") +pr.remove_jobs(recursive=True, silently=True) + + +# In[22]: + + +while_generator(condition=condition, function_body=function_body)(x=0, limit=5, pyiron_project=pr) + + +# # Abstract Syntax Tree + +# In[23]: + + +from ast import dump, parse + + +# In[24]: + + +while_code = """\ +x = 0 +while x < 5: + x += 1 +""" + + +# In[25]: + + +print(dump(parse(while_code), indent=4)) + + +# In[ ]: + + + + diff --git a/example_workflows/while_loop/simple_workflow_with_while.json b/example_workflows/while_loop/simple_workflow_with_while.json new file mode 100644 index 0000000..5a20324 --- /dev/null +++ b/example_workflows/while_loop/simple_workflow_with_while.json @@ -0,0 +1,26 @@ +{ + "version": "0.1.0", + "nodes": [ + {"id": 0, "type": "function", "value": "workflow.get_prod_and_div"}, + {"id": 1, "type": "function", "value": "workflow.get_sum"}, + {"id": 2, "type": "function", "value": "workflow.get_square"}, + {"id": 3, "type": "while", + "conditionFunction": "workflow.is_less_than", + "bodyFunction": "workflow.increment_m", + "maxIterations": 1000 + }, + {"id": 4, "type": "input", "value": 1, "name": "x"}, + {"id": 5, "type": "input", "value": 2, "name": "y"}, + {"id": 6, "type": "output", "name": "result"} + ], + "edges": [ + {"target": 3, "targetPort": "n", "source": 2, "sourcePort": null}, + {"target": 3, "targetPort": "m", "source": 2, "sourcePort": null}, + {"target": 0, "targetPort": "x", "source": 4, "sourcePort": null}, + {"target": 0, "targetPort": "y", "source": 5, "sourcePort": null}, + {"target": 1, "targetPort": "x", "source": 0, "sourcePort": "prod"}, + {"target": 1, "targetPort": "y", "source": 0, "sourcePort": "div"}, + {"target": 2, "targetPort": "x", "source": 1, "sourcePort": null}, + {"target": 6, "targetPort": null, "source": 2, "sourcePort": null} + ] +} diff --git a/example_workflows/while_loop/test_aiida_example.py b/example_workflows/while_loop/test_aiida_example.py new file mode 100644 index 0000000..1960459 --- /dev/null +++ b/example_workflows/while_loop/test_aiida_example.py @@ -0,0 +1,52 @@ +""" +Test AiiDA WorkGraph with while loop integration. + +This script demonstrates a realistic workflow: +1. Arithmetic pre-processing (prod_and_div, sum) +2. While loop (count from 0 to result) +3. Post-processing (square) +""" + +import sys +from pathlib import Path +from python_workflow_definition.aiida import load_workflow_json, write_workflow_json +from aiida import load_profile +from aiida_workgraph import task + +# Add source to path +src_path = Path(__file__).parent.parent.parent / "python_workflow_definition" / "src" +sys.path.insert(0, str(src_path)) + +# Add while_loop directory to path so we can import arithmetic_workflow and while_workflow +while_loop_path = Path(__file__).parent +sys.path.insert(0, str(while_loop_path)) + + +load_profile() + + +@task +def add(x, y): + return x + y + +@task +def compare(x, y): + return x < y + +@task +def multiply(x, y): + return x * y + +@task.graph +def WhileLoop(n, m): + if m >= n: + return m + m = add(x=m, y=1).result + return WhileLoop(n=n, m=m) + + +wg = WhileLoop.build(n=4, m=0) + +wg.to_html() + +write_workflow_json(wg=wg, file_name='write_while_loop.json') diff --git a/example_workflows/while_loop/test_statevars.json b/example_workflows/while_loop/test_statevars.json new file mode 100644 index 0000000..18c007b --- /dev/null +++ b/example_workflows/while_loop/test_statevars.json @@ -0,0 +1,50 @@ +{ + "version": "0.1.0", + "nodes": [ + { + "id": 0, + "type": "input", + "name": "n", + "value": 10 + }, + { + "id": 1, + "type": "input", + "name": "m", + "value": 0 + }, + { + "id": 2, + "type": "while", + "conditionFunction": "test_statevars.is_less_than", + "bodyFunction": "test_statevars.increment_m", + "maxIterations": 1000, + "stateVars": ["n", "m"] + }, + { + "id": 3, + "type": "output", + "name": "result" + } + ], + "edges": [ + { + "source": 0, + "sourcePort": null, + "target": 2, + "targetPort": "n" + }, + { + "source": 1, + "sourcePort": null, + "target": 2, + "targetPort": "m" + }, + { + "source": 2, + "sourcePort": "m", + "target": 3, + "targetPort": null + } + ] +} diff --git a/example_workflows/while_loop/test_statevars.py b/example_workflows/while_loop/test_statevars.py new file mode 100644 index 0000000..dd65bff --- /dev/null +++ b/example_workflows/while_loop/test_statevars.py @@ -0,0 +1,52 @@ +"""Test case for while loop with stateVars""" + +# Define the workflow functions +def is_less_than(n, m): + """Condition: check if m < n""" + return m < n + + +def increment_m(n, m): + """Body: increment m, keep n unchanged""" + return {"n": n, "m": m + 1} + + +if __name__ == "__main__": + from aiida import load_profile + load_profile() + + from python_workflow_definition.aiida import load_workflow_json + + # Load and run the workflow + wg = load_workflow_json("/home/geiger_j/aiida_projects/adis/git-repos/python-workflow-definition/example_workflows/while_loop/test_statevars.json") + + print("WorkGraph loaded successfully!") + print(f"Tasks: {[task.name for task in wg.tasks]}") + + # Check the while loop task + while_task = None + for task in wg.tasks: + if hasattr(task, 'get_executor'): + executor = task.get_executor() + if executor and hasattr(executor, 'callable'): + # This might be the while loop + try: + from node_graph.executor import RuntimeExecutor + runtime_exec = RuntimeExecutor(**executor.to_dict()) + if hasattr(runtime_exec.callable, '_while_node_metadata'): + while_task = task + break + except: + pass + + if while_task: + print(f"\nWhile loop task found: {while_task.name}") + print(f"Inputs: {list(while_task.inputs.keys())}") + print(f"Outputs: {list(while_task.outputs.keys())}") + + # Run the workflow + print("\nRunning workflow...") + wg.run() + + print(f"Workflow completed!") + print(f"Result: {wg.tasks[-1].outputs}") diff --git a/example_workflows/while_loop/test_while_node.py b/example_workflows/while_loop/test_while_node.py deleted file mode 100644 index 981563d..0000000 --- a/example_workflows/while_loop/test_while_node.py +++ /dev/null @@ -1,219 +0,0 @@ -""" -Test script to validate the WhileNode implementation. - -This script tests: -1. Schema validation for WhileNode -2. Loading JSON workflows with while loops -3. Safe expression evaluation -""" - -import sys -from pathlib import Path - -# Add the source directory to Python path -src_path = Path(__file__).parent.parent.parent / "python_workflow_definition" / "src" -sys.path.insert(0, str(src_path)) - -from python_workflow_definition.models import ( - PythonWorkflowDefinitionWorkflow, - PythonWorkflowDefinitionWhileNode, -) -from python_workflow_definition.expression_eval import ( - evaluate_expression, - evaluate_condition, - UnsafeExpressionError, -) - - -def test_schema_validation(): - """Test WhileNode schema validation.""" - print("=" * 60) - print("Testing WhileNode schema validation...") - print("=" * 60) - - # Test 1: Valid function-based while node - print("\n1. Testing valid function-based while node...") - try: - node = PythonWorkflowDefinitionWhileNode( - id=1, - type="while", - conditionFunction="workflow.check", - bodyFunction="workflow.body", - maxIterations=100, - ) - print(" ✓ Valid function-based node created successfully") - except Exception as e: - print(f" ✗ Failed: {e}") - - # Test 2: Valid expression-based while node - print("\n2. Testing valid expression-based while node...") - try: - node = PythonWorkflowDefinitionWhileNode( - id=2, - type="while", - conditionExpression="m < n", - bodyFunction="workflow.body", - ) - print(" ✓ Valid expression-based node created successfully") - except Exception as e: - print(f" ✗ Failed: {e}") - - # Test 3: Invalid - no condition specified - print("\n3. Testing invalid node (no condition)...") - try: - node = PythonWorkflowDefinitionWhileNode( - id=3, - type="while", - bodyFunction="workflow.body", - ) - print(" ✗ Should have raised ValueError") - except ValueError as e: - print(f" ✓ Correctly rejected: {e}") - - # Test 4: Invalid - both condition methods specified - print("\n4. Testing invalid node (both condition methods)...") - try: - node = PythonWorkflowDefinitionWhileNode( - id=4, - type="while", - conditionFunction="workflow.check", - conditionExpression="m < n", - bodyFunction="workflow.body", - ) - print(" ✗ Should have raised ValueError") - except ValueError as e: - print(f" ✓ Correctly rejected: {e}") - - # Test 5: Invalid - no body specified - print("\n5. Testing invalid node (no body)...") - try: - node = PythonWorkflowDefinitionWhileNode( - id=5, - type="while", - conditionFunction="workflow.check", - ) - print(" ✗ Should have raised ValueError") - except ValueError as e: - print(f" ✓ Correctly rejected: {e}") - - -def test_workflow_loading(): - """Test loading JSON workflows with while loops.""" - print("\n" + "=" * 60) - print("Testing workflow JSON loading...") - print("=" * 60) - - # Test loading simple counter workflow - print("\n1. Loading simple_counter.json...") - try: - workflow_path = Path(__file__).parent / "simple_counter.json" - workflow = PythonWorkflowDefinitionWorkflow.load_json_file(workflow_path) - print(f" ✓ Loaded successfully") - print(f" - Nodes: {len(workflow['nodes'])}") - print(f" - Edges: {len(workflow['edges'])}") - while_node = [n for n in workflow["nodes"] if n["type"] == "while"][0] - print(f" - While node ID: {while_node['id']}") - print(f" - Condition: {while_node.get('conditionFunction')}") - print(f" - Body: {while_node.get('bodyFunction')}") - except Exception as e: - print(f" ✗ Failed: {e}") - - # Test loading expression-based workflow - print("\n2. Loading simple_counter_expression.json...") - try: - workflow_path = Path(__file__).parent / "simple_counter_expression.json" - workflow = PythonWorkflowDefinitionWorkflow.load_json_file(workflow_path) - print(f" ✓ Loaded successfully") - while_node = [n for n in workflow["nodes"] if n["type"] == "while"][0] - print(f" - Condition expression: {while_node.get('conditionExpression')}") - except Exception as e: - print(f" ✗ Failed: {e}") - - # Test loading nested workflow - print("\n3. Loading nested_optimization.json...") - try: - workflow_path = Path(__file__).parent / "nested_optimization.json" - workflow = PythonWorkflowDefinitionWorkflow.load_json_file(workflow_path) - print(f" ✓ Loaded successfully") - while_node = [n for n in workflow["nodes"] if n["type"] == "while"][0] - print(f" - Has nested workflow: {while_node.get('bodyWorkflow') is not None}") - if while_node.get("bodyWorkflow"): - nested = while_node["bodyWorkflow"] - print(f" - Nested nodes: {len(nested['nodes'])}") - print(f" - Nested edges: {len(nested['edges'])}") - except Exception as e: - print(f" ✗ Failed: {e}") - - -def test_expression_evaluation(): - """Test safe expression evaluation.""" - print("\n" + "=" * 60) - print("Testing safe expression evaluation...") - print("=" * 60) - - # Test 1: Simple comparison - print("\n1. Testing simple comparison: 'm < n'") - try: - result = evaluate_condition("m < n", {"m": 5, "n": 10}) - print(f" ✓ Result: {result} (expected: True)") - except Exception as e: - print(f" ✗ Failed: {e}") - - # Test 2: Complex boolean expression - print("\n2. Testing complex boolean: 'a > 5 and b < 10'") - try: - result = evaluate_condition("a > 5 and b < 10", {"a": 7, "b": 8}) - print(f" ✓ Result: {result} (expected: True)") - except Exception as e: - print(f" ✗ Failed: {e}") - - # Test 3: Arithmetic in expression - print("\n3. Testing arithmetic: 'x + y > 10'") - try: - result = evaluate_condition("x + y > 10", {"x": 7, "y": 5}) - print(f" ✓ Result: {result} (expected: True)") - except Exception as e: - print(f" ✗ Failed: {e}") - - # Test 4: Unsafe expression (should fail) - print("\n4. Testing unsafe expression: '__import__(\"os\")'") - try: - result = evaluate_expression("__import__('os')", {}) - print(f" ✗ Should have raised UnsafeExpressionError") - except UnsafeExpressionError as e: - print(f" ✓ Correctly rejected: {e}") - - # Test 5: Function call (should fail) - print("\n5. Testing function call: 'print(\"hello\")'") - try: - result = evaluate_expression("print('hello')", {}) - print(f" ✗ Should have raised UnsafeExpressionError") - except UnsafeExpressionError as e: - print(f" ✓ Correctly rejected: {e}") - - # Test 6: List/dict access - print("\n6. Testing subscript access: 'data[0] > 5'") - try: - result = evaluate_condition("data[0] > 5", {"data": [10, 20, 30]}) - print(f" ✓ Result: {result} (expected: True)") - except Exception as e: - print(f" ✗ Failed: {e}") - - -def main(): - """Run all tests.""" - print("\n" + "=" * 60) - print("WhileNode Implementation Tests") - print("=" * 60) - - test_schema_validation() - test_workflow_loading() - test_expression_evaluation() - - print("\n" + "=" * 60) - print("All tests completed!") - print("=" * 60 + "\n") - - -if __name__ == "__main__": - main() diff --git a/example_workflows/while_loop/workflow.py b/example_workflows/while_loop/workflow.py index 724b25e..66d45ce 100644 --- a/example_workflows/while_loop/workflow.py +++ b/example_workflows/while_loop/workflow.py @@ -1,12 +1,31 @@ """ Example workflow functions for while loop demonstration. -This module contains simple functions used in while loop examples: -1. Simple counter increment +This module contains: +1. Simple counter increment functions for while loops 2. Convergence checking for iterative algorithms +3. Arithmetic functions for testing workflows """ +def double(x): + """Double a number.""" + return x * 2 + + +def get_square(x): + """Square a number or extract 'm' from dict and square it.""" + return x ** 2 + + +def get_prod_and_div(x, y): + return {"prod": x * y, "div": x / y} + + +def get_sum(x, y): + return x + y + + def is_less_than(n, m): """ Condition function: Check if m < n. @@ -38,6 +57,20 @@ def increment_m(n, m): return {"n": n, "m": m + 1} +def increment_simple(n, m): + """ + Simple increment that returns just m (for single-output workflows). + + Args: + n: Upper bound (unchanged) + m: Current value + + Returns: + int: Incremented m value + """ + return m + 1 + + def not_converged(threshold, current_error): """ Condition function: Check if iteration should continue. @@ -76,3 +109,20 @@ def iterative_step(threshold, current_error, data): "current_error": new_error, "data": new_data, } + + +# Pre-processing and post-processing functions for realistic workflows + + +def add_numbers(x, y): + """ + Add two numbers together. + + Args: + x: First number + y: Second number + + Returns: + int/float: Sum of x and y + """ + return x + y diff --git a/example_workflows/while_loop/workflow_with_processing.json b/example_workflows/while_loop/workflow_with_processing.json new file mode 100644 index 0000000..148b29f --- /dev/null +++ b/example_workflows/while_loop/workflow_with_processing.json @@ -0,0 +1,106 @@ +{ + "version": "0.1.0", + "nodes": [ + { + "id": 0, + "type": "input", + "name": "initial_value", + "value": 5 + }, + { + "id": 1, + "type": "input", + "name": "offset", + "value": 3 + }, + { + "id": 2, + "type": "function", + "value": "workflow.add_numbers" + }, + { + "id": 3, + "type": "function", + "value": "workflow.multiply_by_two" + }, + { + "id": 4, + "type": "input", + "name": "start_counter", + "value": 0 + }, + { + "id": 5, + "type": "while", + "conditionExpression": "m < n", + "bodyFunction": "workflow.increment_m", + "maxIterations": 1000, + "stateVars": ["n", "m"] + }, + { + "id": 6, + "type": "function", + "value": "workflow.square_number" + }, + { + "id": 7, + "type": "function", + "value": "workflow.format_result" + }, + { + "id": 8, + "type": "output", + "name": "final_result" + } + ], + "edges": [ + { + "source": 0, + "sourcePort": null, + "target": 2, + "targetPort": "x" + }, + { + "source": 1, + "sourcePort": null, + "target": 2, + "targetPort": "y" + }, + { + "source": 2, + "sourcePort": null, + "target": 3, + "targetPort": "x" + }, + { + "source": 3, + "sourcePort": null, + "target": 5, + "targetPort": "n" + }, + { + "source": 4, + "sourcePort": null, + "target": 5, + "targetPort": "m" + }, + { + "source": 5, + "sourcePort": "m", + "target": 6, + "targetPort": "x" + }, + { + "source": 6, + "sourcePort": null, + "target": 7, + "targetPort": "value" + }, + { + "source": 7, + "sourcePort": null, + "target": 8, + "targetPort": null + } + ] +} diff --git a/html/WhileLoop.html b/html/WhileLoop.html new file mode 100644 index 0000000..56b90ca --- /dev/null +++ b/html/WhileLoop.html @@ -0,0 +1,290 @@ + + + + + + + Rete.js with React in Vanilla JS + + + + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/html/WorkGraph.html b/html/WorkGraph.html new file mode 100644 index 0000000..9cecc9d --- /dev/null +++ b/html/WorkGraph.html @@ -0,0 +1,290 @@ + + + + + + + Rete.js with React in Vanilla JS + + + + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..77dd1e3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "python-workflow-definition" +version = "0.1.0" +requires-python = ">=3.12,<3.13" +dependencies = [ + "pydantic==2.11.4", + "hatch==1.14.1", + "hatchling==1.27.0", + "httpcore==1.0.7", + "jobflow==0.1.19", + "pyiron-base==0.12.0", + "pygraphviz==1.14", + "aiida-workgraph==0.7.3", + # "conda-subprocess==0.0.7", + "networkx==3.4.2", + "cwltool==3.1.20250110105449", + "ipdb", +] diff --git a/python_workflow_definition/src/python_workflow_definition/aiida.py b/python_workflow_definition/src/python_workflow_definition/aiida.py index e2464bd..9c98a15 100644 --- a/python_workflow_definition/src/python_workflow_definition/aiida.py +++ b/python_workflow_definition/src/python_workflow_definition/aiida.py @@ -7,6 +7,7 @@ from aiida_workgraph.socket import TaskSocketNamespace from python_workflow_definition.models import PythonWorkflowDefinitionWorkflow +from python_workflow_definition.expression_eval import evaluate_condition from python_workflow_definition.shared import ( convert_nodes_list_to_dict, update_node_names, @@ -23,20 +24,155 @@ ) -def load_workflow_json(file_name: str) -> WorkGraph: - data = remove_result( - workflow_dict=PythonWorkflowDefinitionWorkflow.load_json_file( - file_name=file_name - ) - ) +def _create_while_loop_task(while_node_data: dict): + """Create a recursive task.graph from while node definition. + + Stores the original while_node_data in the task's properties for later export. + """ + # Extract while node components + condition_func = while_node_data.get("conditionFunction") + condition_expr = while_node_data.get("conditionExpression") + body_func = while_node_data.get("bodyFunction") + body_workflow = while_node_data.get("bodyWorkflow") + max_iterations = while_node_data.get("maxIterations", 1000) + state_vars = while_node_data.get("stateVars", []) + + # Import condition function if needed + if condition_func: + p, m = condition_func.rsplit(".", 1) + mod = import_module(p) + check_condition = getattr(mod, m) + else: + # Create condition function from expression + def check_condition(**kwargs): + return evaluate_condition(condition_expr, kwargs) + + # Import body function if using function-based body + if body_func: + p, m = body_func.rsplit(".", 1) + mod = import_module(p) + body = getattr(mod, m) + body_task = task(body) + + # Create recursive task.graph + @task.graph + def while_loop(**kwargs): + iteration = kwargs.pop("_iteration", 0) + + # Extract state based on stateVars + if state_vars: + state = {var: kwargs[var] for var in state_vars if var in kwargs} + else: + # Backward compatibility: if no stateVars, use all kwargs + state = kwargs + + # Check termination conditions + if iteration >= max_iterations: + return state + if not check_condition(**state): + return state + + # Call body as a task to get outputs properly + result = body_task(**state) + + # Extract the actual result + if hasattr(result, 'result'): + result_value = result.result + else: + result_value = result + + # Map result back to state variables + if state_vars: + if isinstance(result_value, dict): + # Body returned a dict - update state with returned values + result_data = {**state, **result_value} + elif len(state_vars) == 1: + # Single state var - wrap the result + result_data = {state_vars[0]: result_value} + else: + # Multiple state vars but got single value - try to use as-is + # This handles cases where result might be a dict-like object + result_data = result_value if isinstance(result_value, dict) else state + else: + # No stateVars specified - assume result is a dict + result_data = result_value if isinstance(result_value, dict) else {"result": result_value} + + result_data["_iteration"] = iteration + 1 + return while_loop(**result_data) + + # Store metadata for export + while_loop._while_node_metadata = while_node_data + return while_loop + elif body_workflow: + # Handle nested workflow body + # First, recursively load the body workflow + nested_wg = load_workflow_json_from_dict(body_workflow) + + # Create recursive task.graph with nested workflow + @task.graph + def while_loop(**kwargs): + iteration = kwargs.pop("_iteration", 0) + + # Extract state based on stateVars + if state_vars: + state = {var: kwargs[var] for var in state_vars if var in kwargs} + else: + # Backward compatibility: if no stateVars, use all kwargs + state = kwargs + + # Check termination conditions + if iteration >= max_iterations: + return state + if not check_condition(**state): + return state + + # Execute the nested workflow with the current state + result = nested_wg(**state) + + # Extract the actual result + if hasattr(result, 'result'): + result_value = result.result + else: + result_value = result + + # Map result back to state variables + if state_vars: + if isinstance(result_value, dict): + # Workflow returned a dict - update state with returned values + result_data = {**state, **result_value} + elif len(state_vars) == 1: + # Single state var - wrap the result + result_data = {state_vars[0]: result_value} + else: + # Multiple state vars but got single value + result_data = result_value if isinstance(result_value, dict) else state + else: + # No stateVars specified - assume result is a dict + result_data = result_value if isinstance(result_value, dict) else {"result": result_value} + + result_data["_iteration"] = iteration + 1 + return while_loop(**result_data) + + # Store metadata for export + while_loop._while_node_metadata = while_node_data + return while_loop + else: + raise ValueError("While node must have either bodyFunction or bodyWorkflow") + + +def load_workflow_json_from_dict(workflow_dict: dict) -> WorkGraph: + """Load a WorkGraph from a workflow dictionary (used for nested workflows).""" + data = remove_result(workflow_dict=workflow_dict) wg = WorkGraph() task_name_mapping = {} - for id, identifier in convert_nodes_list_to_dict( - nodes_list=data[NODES_LABEL] - ).items(): - if isinstance(identifier, str) and "." in identifier: + for node in data[NODES_LABEL]: + id = str(node["id"]) + node_type = node["type"] + + if node_type == "function": + identifier = node["value"] p, m = identifier.rsplit(".", 1) mod = import_module(p) func = getattr(mod, m) @@ -44,10 +180,37 @@ def load_workflow_json(file_name: str) -> WorkGraph: # Remove the default result output, because we will add the outputs later from the data in the link del wg.tasks[-1].outputs["result"] task_name_mapping[id] = wg.tasks[-1] - else: + elif node_type == "while": + # Create while loop task + while_task_func = _create_while_loop_task(node) + wg.add_task(while_task_func) + task_obj = wg.tasks[-1] + + # Remove default result output + if "result" in task_obj.outputs: + del task_obj.outputs["result"] + + # Create explicit input/output sockets based on stateVars + state_vars = node.get("stateVars", []) + if state_vars: + # Add input sockets for each state variable + for var in state_vars: + if var not in task_obj.inputs: + task_obj.add_input("workgraph.any", name=var) + + # Add output sockets for each state variable + for var in state_vars: + if var not in task_obj.outputs: + task_obj.add_output("workgraph.any", name=var) + + task_name_mapping[id] = task_obj + elif node_type == "input": # data task - data_node = general_serializer(identifier) + data_node = general_serializer(node["value"]) task_name_mapping[id] = data_node + elif node_type == "output": + # output nodes are handled via edges + pass # add links for link in data[EDGES_LABEL]: @@ -73,7 +236,6 @@ def load_workflow_json(file_name: str) -> WorkGraph: "workgraph.any", name=link[SOURCE_PORT_LABEL], # name=str(link["sourcePort"]), - metadata={"is_function_output": True}, ) else: from_socket = from_task.outputs[link[SOURCE_PORT_LABEL]] @@ -85,22 +247,102 @@ def load_workflow_json(file_name: str) -> WorkGraph: return wg +def load_workflow_json(file_name: str) -> WorkGraph: + """Load a WorkGraph from a JSON file.""" + data = remove_result( + workflow_dict=PythonWorkflowDefinitionWorkflow.load_json_file( + file_name=file_name + ) + ) + return load_workflow_json_from_dict(data) + + def write_workflow_json(wg: WorkGraph, file_name: str) -> dict: + from node_graph.executor import RuntimeExecutor + data = {NODES_LABEL: [], EDGES_LABEL: []} node_name_mapping = {} data_node_name_mapping = {} i = 0 + + # Special WorkGraph internal nodes that should be converted to input/output nodes + INTERNAL_NODES = ['graph_inputs', 'graph_outputs', 'graph_ctx'] + for node in wg.tasks: - executor = node.get_executor() + # Handle internal WorkGraph meta-tasks + if node.name in INTERNAL_NODES: + # For now, we skip these as they're handled differently + # In nested workflows, these become explicit input/output nodes + # TODO: Convert graph_inputs to input nodes and graph_outputs to output nodes + continue + node_name_mapping[node.name] = i - callable_name = executor["callable_name"] - callable_name = f"{executor['module_path']}.{callable_name}" - data[NODES_LABEL].append({"id": i, "type": "function", "value": callable_name}) + # Get the executor + executor_spec = node.get_executor() + + # Try to get the actual callable to check for while loop metadata + is_while_loop = False + while_metadata = None + + if executor_spec: + try: + # Get the actual callable from the executor + runtime_exec = RuntimeExecutor(**executor_spec.to_dict()) + callable_obj = runtime_exec.callable + + # Check if this callable has while loop metadata attached + if hasattr(callable_obj, '_while_node_metadata'): + is_while_loop = True + while_metadata = callable_obj._while_node_metadata + except Exception: + # If we can't get the callable, it's not a while loop + pass + + if is_while_loop and while_metadata: + # This is a while loop - use the stored metadata + node_dict = {"id": i, "type": "while"} + + # Copy all the while loop configuration from metadata + if 'conditionFunction' in while_metadata: + node_dict['conditionFunction'] = while_metadata['conditionFunction'] + if 'conditionExpression' in while_metadata: + node_dict['conditionExpression'] = while_metadata['conditionExpression'] + if 'bodyFunction' in while_metadata: + node_dict['bodyFunction'] = while_metadata['bodyFunction'] + if 'bodyWorkflow' in while_metadata: + node_dict['bodyWorkflow'] = while_metadata['bodyWorkflow'] + if 'maxIterations' in while_metadata: + node_dict['maxIterations'] = while_metadata['maxIterations'] + + data[NODES_LABEL].append(node_dict) + elif executor_spec is None: + # This is a graph task (task.graph) without while loop metadata + # This typically happens when trying to export a manually-created @task.graph function + raise NotImplementedError( + f"Cannot export task '{node.name}' - it appears to be a @task.graph function " + f"created manually, not loaded from JSON.\n\n" + f"The write_workflow_json function currently only supports exporting:\n" + f"1. Regular function tasks (decorated with @task)\n" + f"2. While loops that were originally loaded from JSON using load_workflow_json\n\n" + f"To export this workflow, you need to first create it using the JSON format " + f"and load it with load_workflow_json, or manually construct a while loop node " + f"definition in the JSON format." + ) + else: + # Regular function node + callable_name = executor_spec["callable_name"] + callable_name = f"{executor_spec['module_path']}.{callable_name}" + data[NODES_LABEL].append({"id": i, "type": "function", "value": callable_name}) i += 1 for link in wg.links: link_data = link.to_dict() + + # Skip links involving internal nodes + if link_data["from_node"] in INTERNAL_NODES or link_data["to_node"] in INTERNAL_NODES: + continue + # if the from socket is the default result, we set it to None if link_data["from_socket"] == "result": link_data["from_socket"] = None @@ -111,6 +353,10 @@ def write_workflow_json(wg: WorkGraph, file_name: str) -> dict: data[EDGES_LABEL].append(link_data) for node in wg.tasks: + # Skip internal nodes when processing inputs + if node.name in INTERNAL_NODES: + continue + for input in node.inputs: # assume namespace is not used as input if isinstance(input, TaskSocketNamespace): diff --git a/python_workflow_definition/src/python_workflow_definition/jobflow.py b/python_workflow_definition/src/python_workflow_definition/jobflow.py index 969251c..41832ee 100644 --- a/python_workflow_definition/src/python_workflow_definition/jobflow.py +++ b/python_workflow_definition/src/python_workflow_definition/jobflow.py @@ -5,6 +5,7 @@ from jobflow import job, Flow from python_workflow_definition.models import PythonWorkflowDefinitionWorkflow +from python_workflow_definition.expression_eval import evaluate_condition from python_workflow_definition.shared import ( get_dict, get_list, @@ -25,6 +26,51 @@ ) +def _create_while_loop_job(while_node_data: dict): + """Create a recursive job from while node definition.""" + # Extract while node components + condition_func_str = while_node_data.get("conditionFunction") + condition_expr = while_node_data.get("conditionExpression") + body_func_str = while_node_data.get("bodyFunction") + body_workflow = while_node_data.get("bodyWorkflow") + max_iterations = while_node_data.get("maxIterations", 1000) + + # Import condition function if needed + if condition_func_str: + p, m = condition_func_str.rsplit(".", 1) + mod = import_module(p) + check_condition = getattr(mod, m) + else: + # Create condition function from expression + def check_condition(**kwargs): + return evaluate_condition(condition_expr, kwargs) + + # Import body function if using function-based body + if body_func_str: + p, m = body_func_str.rsplit(".", 1) + mod = import_module(p) + body_func = getattr(mod, m) + + # Create recursive job function + @job + def while_loop_job(**kwargs): + iteration = kwargs.pop("_iteration", 0) + if iteration >= max_iterations: + return kwargs + if not check_condition(**kwargs): + return kwargs + result = body_func(**kwargs) + if not isinstance(result, dict): + raise ValueError(f"While loop body must return dict, got {type(result)}") + result["_iteration"] = iteration + 1 + return while_loop_job(**result) + + return while_loop_job + else: + # TODO: Handle nested workflow body + raise NotImplementedError("Nested workflow bodies not yet supported in Jobflow backend") + + def _get_function_dict(flow: Flow): return {job.uuid: job.function for job in flow.jobs} @@ -295,13 +341,24 @@ def load_workflow_json(file_name: str) -> Flow: ) nodes_new_dict = {} - for k, v in convert_nodes_list_to_dict(nodes_list=content[NODES_LABEL]).items(): - if isinstance(v, str) and "." in v: + for node in content[NODES_LABEL]: + k = str(node["id"]) + node_type = node["type"] + + if node_type == "function": + v = node["value"] p, m = v.rsplit(".", 1) mod = import_module(p) nodes_new_dict[int(k)] = getattr(mod, m) - else: - nodes_new_dict[int(k)] = v + elif node_type == "while": + # Create while loop job + while_job = _create_while_loop_job(node) + nodes_new_dict[int(k)] = while_job + elif node_type == "input": + nodes_new_dict[int(k)] = node["value"] + elif node_type == "output": + # output nodes are handled via edges + pass source_handles_dict = get_source_handles(edges_lst=edges_new_lst) total_dict = _group_edges(edges_lst=edges_new_lst) diff --git a/while-demo.py b/while-demo.py new file mode 100644 index 0000000..7c9ddb5 --- /dev/null +++ b/while-demo.py @@ -0,0 +1,91 @@ +from aiida import load_profile +from aiida_workgraph import While, WorkGraph +from aiida_workgraph import task + +load_profile() + + +@task +def add(x, y): + return x + y + +@task +def compare(x, y): + return x < y + +@task +def multiply(x, y): + return x * y + + +### task.graph + +@task.graph +def WhileLoop(n, m): + if m >= n: + return m + m = add(x=m, y=1).result + return WhileLoop(n=n, m=m) + + +wg = WhileLoop.build(n=4, m=0) + +wg.to_html() +wg.run() + + +### Context manager + +with WorkGraph('while-context-manager') as wg: + n = add(x=1, y=1).result + wg.ctx.n = n + + (condition := wg.ctx.n < 8) << n + + with While(condition, max_iterations=10): + n = add(x=wg.ctx.n, y=1).result + wg.ctx.n = n + + wg.outputs.result = add(x=n, y=1).result + +wg.to_html() +wg.run() + +print(f'Result: {wg.outputs.result.value}') + + +### As task + +wg = WorkGraph('while-task-example') + +# Initialize 'n' with an initial value +initial_add_task = wg.add_task(add, x=1, y=1) # n = 2 +wg.ctx.n = initial_add_task.outputs.result + +# Define the condition for the while loop: n < 8 +# Here, we use the `compare` task as defined above +condition_task = wg.add_task(compare, x=wg.ctx.n, y=8) +# Ensure the condition task waits for the initial_add_task to complete +condition_task.waiting_on.add(initial_add_task) + +# Start the While Zone +while_task = wg.add_task('workgraph.while_zone', max_iterations=10, conditions=condition_task.outputs.result) + +# Tasks within the while loop +# First, add 1 to n +add_task_in_loop = while_task.add_task(add, x=wg.ctx.n, y=1) +# Then, multiply the result by 2 +multiply_task_in_loop = while_task.add_task(multiply, x=add_task_in_loop.outputs.result, y=2) +# Update 'n' for the next iteration of the loop +wg.ctx.n = multiply_task_in_loop.outputs.result + +# After the loop, add 1 to the final 'n' +final_add_task = wg.add_task(add, x=multiply_task_in_loop.outputs.result, y=1) +wg.outputs.result = final_add_task.outputs.result + +# Run the workflow +wg.to_html() +wg.run() + +print(f'State of WorkGraph: {wg.state}') +print(f'Result: {wg.outputs.result.value}') From 2107177dd3770b98442f02e47e11e32748e7f625 Mon Sep 17 00:00:00 2001 From: Julian Geiger Date: Wed, 22 Oct 2025 12:05:53 +0200 Subject: [PATCH 3/3] wip --- constructed-while-output.json | 106 +++++++++++ .../while_loop/test_aiida_example.py | 108 +++++++++-- .../while_loop/while-construct-pwd.py | 51 +++++ .../src/python_workflow_definition/models.py | 175 ++++++++++++++---- 4 files changed, 390 insertions(+), 50 deletions(-) create mode 100644 constructed-while-output.json create mode 100644 example_workflows/while_loop/while-construct-pwd.py diff --git a/constructed-while-output.json b/constructed-while-output.json new file mode 100644 index 0000000..bcc218b --- /dev/null +++ b/constructed-while-output.json @@ -0,0 +1,106 @@ +{ + "version": "1.0", + "nodes": [ + { + "id": 1, + "type": "input", + "name": "initial_x", + "value": 1 + }, + { + "id": 2, + "type": "input", + "name": "initial_y", + "value": 1 + }, + { + "id": 3, + "type": "function", + "value": "mymodule.add" + }, + { + "id": 4, + "type": "while", + "conditionFunction": null, + "conditionExpression": "ctx.n < 8", + "conditionWorkflow": null, + "bodyFunction": null, + "bodyWorkflow": { + "version": "1.0", + "nodes": [ + { + "id": 1, + "type": "function", + "value": "mymodule.add" + }, + { + "id": 2, + "type": "function", + "value": "mymodule.multiply" + } + ], + "edges": [ + { + "target": 2, + "targetPort": "x", + "source": 1, + "sourcePort": null + } + ] + }, + "contextVars": [ + "n" + ], + "inputPorts": { + "n": null + }, + "outputPorts": { + "n": "final_n" + }, + "maxIterations": 10, + "stateMapping": null + }, + { + "id": 5, + "type": "function", + "value": "mymodule.add" + }, + { + "id": 6, + "type": "output", + "name": "result" + } + ], + "edges": [ + { + "target": 3, + "targetPort": "x", + "source": 1, + "sourcePort": null + }, + { + "target": 3, + "targetPort": "y", + "source": 2, + "sourcePort": null + }, + { + "target": 4, + "targetPort": "n", + "source": 3, + "sourcePort": null + }, + { + "target": 5, + "targetPort": "x", + "source": 4, + "sourcePort": "final_n" + }, + { + "target": 6, + "targetPort": null, + "source": 5, + "sourcePort": null + } + ] +} diff --git a/example_workflows/while_loop/test_aiida_example.py b/example_workflows/while_loop/test_aiida_example.py index 1960459..8a22ce4 100644 --- a/example_workflows/while_loop/test_aiida_example.py +++ b/example_workflows/while_loop/test_aiida_example.py @@ -22,31 +22,101 @@ sys.path.insert(0, str(while_loop_path)) -load_profile() +from typing import Callable, Any +def my_while( + input_ports: dict, + condition_f: Callable[[dict, dict], bool], + body_f: Callable[[dict, dict], dict], + finalizer: Callable[[dict, dict], Any] +) -> Any: + ctx = {} + while condition_f(input_ports, ctx): + ctx = body_f(input_ports, ctx) + return finalizer(input_ports, ctx) -@task -def add(x, y): - return x + y -@task -def compare(x, y): - return x < y +# Example: Sum numbers from 0 to limit +def condition_f(input_ports: dict, ctx: dict) -> bool: + limit = input_ports.get('limit', 0) + current = ctx.get('current', 0) + return current < limit -@task -def multiply(x, y): - return x * y +def body_f(input_ports: dict, ctx: dict) -> dict: + current = ctx.get('current', 0) + total = ctx.get('total', 0) + return { + 'current': current + 1, + 'total': total + current + } -@task.graph -def WhileLoop(n, m): - if m >= n: - return m - m = add(x=m, y=1).result - return WhileLoop(n=n, m=m) +def finalizer(input_ports: dict, ctx: dict) -> dict: + return { + 'result': ctx.get('total', 0), + 'iterations': ctx.get('current', 0) + } -wg = WhileLoop.build(n=4, m=0) +# Run it +result = my_while( + input_ports={'limit': 10}, + condition_f=condition_f, + body_f=body_f, + finalizer=finalizer +) -wg.to_html() +print(result) # {'result': 45, 'iterations': 10} -write_workflow_json(wg=wg, file_name='write_while_loop.json') +raise SystemExit() + +# from typing import Callable +# +# def condition_f(x, limit): +# return limit > x +# +# def body_f(x): +# return x + 1 +# +# # def abstract_while(x, limit): +# # if not condition(x=x, limit=limit): +# # return x +# # x = function_body(x=x) +# # return abstract_while(x=x, limit=limit) +# +# +# def my_while(input_ports: dict, condition_f=Callable, body_f=Callable, finalizer=Callable): +# ctx = {} +# while condition_f(input_ports, ctx): +# ctx = body_f(input_ports, ctx) +# return finalizer(input_ports, ctx) # these become output ports +# +# my_while() + +# load_profile() +# +# +# @task +# def add(x, y): +# return x + y +# +# @task +# def compare(x, y): +# return x < y +# +# @task +# def multiply(x, y): +# return x * y +# +# @task.graph +# def WhileLoop(n, m): +# if m >= n: +# return m +# m = add(x=m, y=1).result +# return WhileLoop(n=n, m=m) +# +# +# wg = WhileLoop.build(n=4, m=0) +# +# wg.to_html() +# +# write_workflow_json(wg=wg, file_name='write_while_loop.json') diff --git a/example_workflows/while_loop/while-construct-pwd.py b/example_workflows/while_loop/while-construct-pwd.py new file mode 100644 index 0000000..7cdb4f4 --- /dev/null +++ b/example_workflows/while_loop/while-construct-pwd.py @@ -0,0 +1,51 @@ +from python_workflow_definition.models import * + +workflow = PythonWorkflowDefinitionWorkflow( + version="1.0", + nodes=[ + # Input node for initial value + PythonWorkflowDefinitionInputNode(id=1, type="input", name="initial_x", value=1), + PythonWorkflowDefinitionInputNode(id=2, type="input", name="initial_y", value=1), + + # Initial add to set up context + PythonWorkflowDefinitionFunctionNode(id=3, type="function", value="mymodule.add"), + + # While loop node + PythonWorkflowDefinitionWhileNode( + id=4, + type="while", + conditionExpression="ctx.n < 8", + bodyWorkflow=PythonWorkflowDefinitionWorkflow( + version="1.0", + nodes=[ + PythonWorkflowDefinitionFunctionNode(id=1, type="function", value="mymodule.add"), + PythonWorkflowDefinitionFunctionNode(id=2, type="function", value="mymodule.multiply"), + ], + edges=[ + PythonWorkflowDefinitionEdge(source=1, target=2, targetPort="x"), + ] + ), + contextVars=["n"], + inputPorts={"n": None}, # Will be connected via edge + outputPorts={"n": "final_n"}, + maxIterations=10 + ), + + # Final add after loop + PythonWorkflowDefinitionFunctionNode(id=5, type="function", value="mymodule.add"), + + # Output node + PythonWorkflowDefinitionOutputNode(id=6, type="output", name="result"), + ], + edges=[ + PythonWorkflowDefinitionEdge(source=1, target=3, targetPort="x"), + PythonWorkflowDefinitionEdge(source=2, target=3, targetPort="y"), + PythonWorkflowDefinitionEdge(source=3, target=4, targetPort="n"), + PythonWorkflowDefinitionEdge(source=4, sourcePort="final_n", target=5, targetPort="x"), + PythonWorkflowDefinitionEdge(source=5, target=6), + ] +) + +breakpoint() + +pass diff --git a/python_workflow_definition/src/python_workflow_definition/models.py b/python_workflow_definition/src/python_workflow_definition/models.py index 704be4c..4197e14 100644 --- a/python_workflow_definition/src/python_workflow_definition/models.py +++ b/python_workflow_definition/src/python_workflow_definition/models.py @@ -68,58 +68,152 @@ def check_value_format(cls, v: str): return v +# class PythonWorkflowDefinitionWhileNode(PythonWorkflowDefinitionBaseNode): +# """ +# Model for while loop control flow nodes. +# +# Supports two modes of operation: +# 1. Simple mode: conditionFunction + bodyFunction (functions as strings) +# 2. Complex mode: conditionFunction/conditionExpression + bodyWorkflow (nested workflow) +# +# Exactly one condition method (conditionFunction OR conditionExpression) must be specified. +# Exactly one body method (bodyFunction OR bodyWorkflow) must be specified. +# """ +# +# type: Literal["while"] +# +# # Condition evaluation (exactly one must be set) +# conditionFunction: Optional[str] = None # Format: 'module.function' returns bool +# conditionExpression: Optional[str] = None # Safe expression like "m < n" +# +# # Body execution (exactly one must be set) +# bodyFunction: Optional[str] = None # Format: 'module.function' +# bodyWorkflow: Optional["PythonWorkflowDefinitionWorkflow"] = None # Nested subgraph +# +# # Safety and configuration +# maxIterations: int = Field(default=1000, ge=1) +# +# # Optional: Track specific state variables across iterations +# stateVars: Optional[List[str]] = None +# +# @field_validator("conditionFunction") +# @classmethod +# def check_condition_function_format(cls, v: Optional[str]) -> Optional[str]: +# """Validate conditionFunction format if provided.""" +# if v is not None: +# if not v or "." not in v or v.startswith(".") or v.endswith("."): +# msg = ( +# "WhileNode 'conditionFunction' must be a non-empty string " +# "in 'module.function' format with at least one period." +# ) +# raise ValueError(msg) +# return v +# +# @field_validator("bodyFunction") +# @classmethod +# def check_body_function_format(cls, v: Optional[str]) -> Optional[str]: +# """Validate bodyFunction format if provided.""" +# if v is not None: +# if not v or "." not in v or v.startswith(".") or v.endswith("."): +# msg = ( +# "WhileNode 'bodyFunction' must be a non-empty string " +# "in 'module.function' format with at least one period." +# ) +# raise ValueError(msg) +# return v +# +# @model_validator(mode="after") +# def check_exactly_one_condition(self) -> "Self": +# """Ensure exactly one condition method is specified.""" +# condition_count = sum([ +# self.conditionFunction is not None, +# self.conditionExpression is not None, +# ]) +# if condition_count == 0: +# raise ValueError( +# "WhileNode must specify exactly one condition method: " +# "either 'conditionFunction' or 'conditionExpression'" +# ) +# if condition_count > 1: +# raise ValueError( +# "WhileNode must specify exactly one condition method, " +# f"but {condition_count} were provided" +# ) +# return self +# +# @model_validator(mode="after") +# def check_exactly_one_body(self) -> "Self": +# """Ensure exactly one body method is specified.""" +# body_count = sum([ +# self.bodyFunction is not None, +# self.bodyWorkflow is not None, +# ]) +# if body_count == 0: +# raise ValueError( +# "WhileNode must specify exactly one body method: " +# "either 'bodyFunction' or 'bodyWorkflow'" +# ) +# if body_count > 1: +# raise ValueError( +# "WhileNode must specify exactly one body method, " +# f"but {body_count} were provided" +# ) +# return self +# + + class PythonWorkflowDefinitionWhileNode(PythonWorkflowDefinitionBaseNode): """ Model for while loop control flow nodes. - - Supports two modes of operation: - 1. Simple mode: conditionFunction + bodyFunction (functions as strings) - 2. Complex mode: conditionFunction/conditionExpression + bodyWorkflow (nested workflow) - - Exactly one condition method (conditionFunction OR conditionExpression) must be specified. - Exactly one body method (bodyFunction OR bodyWorkflow) must be specified. + + Supports multiple execution modes: + 1. Function-based: conditionFunction + bodyFunction + 2. Workflow-based: conditionWorkflow + bodyWorkflow (nested subgraphs) + 3. Expression-based: conditionExpression + bodyFunction/bodyWorkflow """ - + type: Literal["while"] - + # Condition evaluation (exactly one must be set) - conditionFunction: Optional[str] = None # Format: 'module.function' returns bool - conditionExpression: Optional[str] = None # Safe expression like "m < n" - + conditionFunction: Optional[str] = None # 'module.function' returns bool + conditionExpression: Optional[str] = None # Expression like "ctx.n < 8" + conditionWorkflow: Optional["PythonWorkflowDefinitionWorkflow"] = None # Subgraph returning bool + # Body execution (exactly one must be set) - bodyFunction: Optional[str] = None # Format: 'module.function' + bodyFunction: Optional[str] = None # 'module.function' bodyWorkflow: Optional["PythonWorkflowDefinitionWorkflow"] = None # Nested subgraph - + + # Context state management + contextVars: Optional[List[str]] = None # Variables tracked in loop context (e.g., ["n", "sum"]) + + # Input/output port mappings for the while loop + inputPorts: Optional[dict[str, Any]] = None # Initial values for context vars + outputPorts: Optional[dict[str, str]] = None # Map context vars to output ports + # Safety and configuration maxIterations: int = Field(default=1000, ge=1) - - # Optional: Track specific state variables across iterations - stateVars: Optional[List[str]] = None + + # Optional: Specify which context variables to pass between iterations + stateMapping: Optional[dict[str, str]] = None # Map outputs to next iteration inputs @field_validator("conditionFunction") @classmethod def check_condition_function_format(cls, v: Optional[str]) -> Optional[str]: - """Validate conditionFunction format if provided.""" if v is not None: if not v or "." not in v or v.startswith(".") or v.endswith("."): - msg = ( - "WhileNode 'conditionFunction' must be a non-empty string " - "in 'module.function' format with at least one period." + raise ValueError( + "WhileNode 'conditionFunction' must be in 'module.function' format" ) - raise ValueError(msg) return v @field_validator("bodyFunction") @classmethod def check_body_function_format(cls, v: Optional[str]) -> Optional[str]: - """Validate bodyFunction format if provided.""" if v is not None: if not v or "." not in v or v.startswith(".") or v.endswith("."): - msg = ( - "WhileNode 'bodyFunction' must be a non-empty string " - "in 'module.function' format with at least one period." + raise ValueError( + "WhileNode 'bodyFunction' must be in 'module.function' format" ) - raise ValueError(msg) return v @model_validator(mode="after") @@ -128,15 +222,16 @@ def check_exactly_one_condition(self) -> "Self": condition_count = sum([ self.conditionFunction is not None, self.conditionExpression is not None, + self.conditionWorkflow is not None, ]) if condition_count == 0: raise ValueError( "WhileNode must specify exactly one condition method: " - "either 'conditionFunction' or 'conditionExpression'" + "'conditionFunction', 'conditionExpression', or 'conditionWorkflow'" ) if condition_count > 1: raise ValueError( - "WhileNode must specify exactly one condition method, " + f"WhileNode must specify exactly one condition method, " f"but {condition_count} were provided" ) return self @@ -151,14 +246,32 @@ def check_exactly_one_body(self) -> "Self": if body_count == 0: raise ValueError( "WhileNode must specify exactly one body method: " - "either 'bodyFunction' or 'bodyWorkflow'" + "'bodyFunction' or 'bodyWorkflow'" ) if body_count > 1: raise ValueError( - "WhileNode must specify exactly one body method, " + f"WhileNode must specify exactly one body method, " f"but {body_count} were provided" ) return self + + @model_validator(mode="after") + def check_context_vars_consistency(self) -> "Self": + """Ensure contextVars aligns with inputPorts/outputPorts if specified.""" + if self.contextVars: + if self.inputPorts: + for var in self.contextVars: + if var not in self.inputPorts: + raise ValueError( + f"Context variable '{var}' declared but not in inputPorts" + ) + if self.outputPorts: + for var in self.contextVars: + if var not in self.outputPorts: + raise ValueError( + f"Context variable '{var}' declared but not in outputPorts" + ) + return self # Discriminated Union for Nodes