Skip to content

🐛 Deserialize objects in the body of a task.graph - #799

Draft
elinscott wants to merge 4 commits into
aiidateam:mainfrom
elinscott:serialization-deserialize
Draft

🐛 Deserialize objects in the body of a task.graph#799
elinscott wants to merge 4 commits into
aiidateam:mainfrom
elinscott:serialization-deserialize

Conversation

@elinscott

@elinscott elinscott commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Problem

The write path auto-serialises primitive task inputs into orm.Float / orm.Int / orm.Str / orm.Bool for provenance, but there was no symmetric read path: SerializationAdapter.deserialize existed in the node-graph base API and was never implemented here. A @task.graph body whose signature declared nspin: int could therefore receive an orm.Int after a round-trip through AiiDA storage, with two failure modes:

  • silently wrong logic: if nspin == 2: style comparisons against a node object
  • hard crashes at stdlib or AiiDA boundaries: conv_thr = 1.0e-9 * nelec, QueryBuilder / orm.Dict interfaces.

The same asymmetry bites dataclass-typed sockets crossing a @task.graph boundary: field values are node-promoted, then coerce_structured_value rebuilds the dataclass via cls(**value) with the fields still wrapped, so a field declared int arrives as orm.Int and e.g. range(self.ntyp) raises TypeError (orm.Int has no __index__).

Change

Implement AiidaSerializationAdapter.deserialize as the symmetric counterpart to serialize, invoked just before a @task.graph body runs:

  • For sockets whose declared type is a float / int / string / bool): unwrap orm.BaseType to its .value, so the body sees the primitive its signature declared.
  • Container-typed sockets get the same treatment: values persisted as orm.Dict/orm.List are unwrapped to plain dict/list on deserialize, so a @task.graph body that declared dict/list receives the Python container rather than the ORM node.
  • For dataclass instances: unwrap any orm.BaseType-wrapped field via dataclasses.replace (frozen-safe).
  • Everything else (workgraph.any, Aiass structures) passes through unchanged

Compatibility

Requires the node-graph side actually calling the adapter on the read path: the materialize_graph deserialize-on-read hook and routing of structured-namespace values through adapter.deserialize (today _deserialize_inputs only 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.

elinscott and others added 2 commits July 2, 2026 16:42
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).
@elinscott elinscott changed the title 🐛 Implement AiidaSerializationAdapter.deserialize (primitive sockets and dataclass fields) 🐛 Deserialize objects in the body of a task Jul 2, 2026
@elinscott elinscott changed the title 🐛 Deserialize objects in the body of a task 🐛 Deserialize objects in the body of a task.graph Jul 2, 2026
@elinscott

Copy link
Copy Markdown
Collaborator Author

@edan-bainglass this PR ties in with our discussion yesterday about whether regular Python is guaranteed to work inside a @task and/or a @task.graph body. Turns out the code currently implies some level of guarantee inside a @task.graph, too. See e.g. docs/source/overview.rst:92 ("Parallel tasks")

@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 results

or also docs/source/overview.rst:130 ("Dynamic workflow")

@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).result

Obviously 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>
@elinscott

elinscott commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

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 BaseSocket | TaggedValue, and with containers unwrapped in the body, nothing tagged survives to the return. (Against main the echo passes, because the input arrives TaggedValue-wrapped.)

An ugly workaround is an artificial passthrough task i.e.

@task
def echo(x)
    return x

but 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.

elinscott added a commit to elinscott/aiida-workgraph that referenced this pull request Aug 14, 2026
elinscott added a commit to elinscott/aiida-workgraph that referenced this pull request Aug 17, 2026
elinscott added a commit to elinscott/aiida-workgraph that referenced this pull request Aug 17, 2026
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>
@coderabbitai

coderabbitai Bot commented Aug 26, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

elinscott added a commit to elinscott/aiida-workgraph that referenced this pull request Aug 27, 2026
# Conflicts:
#	tests/test_serializer.py
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant