🐛 Deserialize objects in the body of a task.graph - #799
Conversation
Mirror of the existing ``serialize`` pass on the write path: when a primitive-typed socket value (``workgraph.float`` / ``int`` / ``string`` / ``bool``) round-trips through AiiDA storage as an ``orm.BaseType`` node, ``deserialize`` now extracts ``.value`` so the ``@task.graph`` body sees the Python primitive its signature declared. Non-primitive sockets (``workgraph.any``, AiiDA-typed sockets, etc.) pass through unchanged. Without this, sub-tasks whose body did things like ``if nspin == 2:`` or ``conv_thr = 1.0e-9 * nelec`` were comparing/multiplying with ``orm.Int`` / ``orm.Float`` nodes — sometimes silently wrong (object identity comparison), sometimes blowing up at the ``QueryBuilder``/``orm.Dict`` boundary. Pairs with the corresponding ``materialize_graph`` deserialize-on-read hook in node-graph. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a dataclass-typed socket crosses a ``@task.graph`` boundary, its primitive fields are auto-promoted to ``orm.Int`` / ``orm.Float`` etc. (for provenance). ``node-graph``'s ``coerce_structured_value`` then rebuilds the dataclass via ``cls(**value)`` while the fields are still wrapped, so an ``int``-annotated field arrives as ``orm.Int`` and downstream stdlib code (``range(self.ntyp)`` etc.) raises ``TypeError`` because ``orm.Int`` lacks ``__index__``. Asymmetric with the existing primitive-socket path, which already unwraps. ``AiidaSerializationAdapter.deserialize`` now also walks dataclass instances and unwraps any ``orm.BaseType`` field via ``dataclasses.replace`` (frozen-safe). Companion fix in ``node-graph``'s ``_deserialize_inputs`` is needed to actually route structured-namespace values through the adapter (today it only recurses into dicts).
|
@edan-bainglass this PR ties in with our discussion yesterday about whether regular Python is guaranteed to work inside a @task.graph()
def parallel_add(x, N):
results = {}
# Launch N parallel tasks to add x with each index
for i in range(N): # <- regular python acting on an input node
results[f"add_{i}"] = add(x, i).result
return resultsor also @task.graph()
def conditional_workflow(x):
if x > 0: # <- regular python acting on an input nod
return add(x, 10).result
else:
return multiply(x, 10).resultObviously being able to write the code this way is a lot nicer than the stricter alternative where both the loop generator and the comparison need to themselves be tasks. So I think what we should be aiming for is that graph inputs should be concrete values whose type always matches their annotation. Meanwhile, task outputs remain sockets. In this context, this PR patches some instances where this expectation is not being met. |
A plain dict or list built in a parent graph body is promoted to orm.Dict/orm.List for provenance; a child body whose signature declares dict/list then received the node itself and stdlib operations crashed (e.g. get_protocol_inputs calling overrides.copy()). Same philosophy as the existing orm.BaseType unwrap for primitive sockets. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Found a casualty of the unwrap change: a graph body can no longer echo one of its own inputs as an output. e.g. for the very natural @task.graph
def RefinementLoop(prev_result: MyOutputs, remaining_steps: int, ...) -> MyOutputs:
if remaining_steps <= 0:
return MyOutputs(result=prev_result) # Invalid graph return payload
...now fails node-graph's return-payload validation: the validator accepts An ugly workaround is an artificial passthrough task i.e. @task
def echo(x)
return xbut obviously this is not what we want long-term. We probably want to preserve enough input-origin tagging through the unwrap that using the variable as an output still satisfies the validator. |
AiidaSerializationAdapter.deserialize unwrapped orm.BaseType/orm.Dict/ orm.List/dataclass-field values by reading straight through node-graph's TaggedValue proxy (e.g. value.value), which dropped the tag node-graph uses to draw a link back to the owning graph-input socket. A @task.graph body whose signature declared a primitive type then fed a sub-task from a brand-new, unlinked node instead of the graph input, leaving an orphan copy in the provenance graph. - Unwrap the value inside the tag: when the incoming value is a TaggedValue, recurse into the wrapped value and rewrap the unwrapped result in a fresh TaggedValue on the same socket. - Apply the same tag preservation to dataclass field replacement, since a structured socket is tagged leaf by leaf and each field carries its own tag independently of the dataclass instance. - Add tests/test_serializer.py coverage: primitive (bare/annotated/ Any), dict/list container, and dataclass-field graph inputs all keep their link to the sub-task bound from them; a negative control reverting deserialize to the pre-fix form reproduces the loss. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
# Conflicts: # tests/test_serializer.py
Problem
The write path auto-serialises primitive task inputs into
orm.Float/orm.Int/orm.Str/orm.Boolfor provenance, but there was no symmetric read path:SerializationAdapter.deserializeexisted in the node-graph base API and was never implemented here. A@task.graphbody whose signature declarednspin: intcould therefore receive anorm.Intafter a round-trip through AiiDA storage, with two failure modes:if nspin == 2:style comparisons against a node objectconv_thr = 1.0e-9 * nelec,QueryBuilder/orm.Dictinterfaces.The same asymmetry bites dataclass-typed sockets crossing a
@task.graphboundary: field values are node-promoted, thencoerce_structured_valuerebuilds the dataclass viacls(**value)with the fields still wrapped, so a field declaredintarrives asorm.Intand e.g.range(self.ntyp)raisesTypeError(orm.Inthas no__index__).Change
Implement
AiidaSerializationAdapter.deserializeas the symmetric counterpart toserialize, invoked just before a@task.graphbody runs:float/int/string/bool): unwraporm.BaseTypeto its.value, so the body sees the primitive its signature declared.orm.BaseType-wrapped field viadataclasses.replace(frozen-safe).workgraph.any, Aiass structures) passes through unchangedCompatibility
Requires the node-graph side actually calling the adapter on the read path: the
materialize_graphdeserialize-on-read hook and routing of structured-namespace values throughadapter.deserialize(today_deserialize_inputsonly recurses into dicts). Both are part of the node-graph dynamic-namespace/deserialize work targeting node-graph main and not yet in a released node-graph, hence draft.