Skip to content

✨ Take a task's input and output contracts from Pydantic models - #182

Draft
elinscott wants to merge 50 commits into
scinode:mainfrom
elinscott:input-model
Draft

elinscott wants to merge 50 commits into
scinode:mainfrom
elinscott:input-model

Conversation

@elinscott

@elinscott elinscott commented Aug 27, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #178 (enum and literal sockets). The commits to review here are the ones after it; #178's own commits are in the diff because this branch builds on them.

The idea

Today a task declares its sockets in one of two ways: from its signature — def ph(spin: str, structure: str) gives two input sockets typed str — or explicitly, @task(inputs=namespace(spin=str, structure=str)), which can also nest, mark a socket required, and give it a default. Either way a socket carries a type and a default and nothing else, which is not expressive enough for some things we want to do (see below).

Pydantic has much richer validation vocabulary. node-graph already reads models — SocketSpec.from_model expands pydantic models into sockets — but it keeps only the field names, types and defaults and throws the rules away.

This branch keeps the rules. @task(input_model=PhInputs) makes the model the declaration of ph's inputs, and node-graph enforces the model's validators wherever a value arrives at ph. @task(output_model=PhOutputs) does the same for what ph returns.

What it looks like

from enum import Enum
from pydantic import BaseModel, Field, field_validator
from node_graph import task

class SpinType(str, Enum):
    NONE = "none"
    COLLINEAR = "collinear"
    NON_COLLINEAR = "non_collinear"
    SPIN_ORBIT = "spin_orbit"

class PhInputs(BaseModel):
    spin: SpinType = SpinType.NONE
    structure: str
    ecutwfc: float = Field(default=60.0, le=200)

    @field_validator("spin")
    @classmethod
    def _supported(cls, value):
        if value in (SpinType.NON_COLLINEAR, SpinType.SPIN_ORBIT):
            raise ValueError("ph.x has no electric-field perturbation for noncollinear magnetism")
        return value

class PhOutputs(BaseModel):
    dielectric: float

@task(input_model=PhInputs, output_model=PhOutputs)
def ph(spin, structure, ecutwfc):
    ...
    return {"dielectric": dielectric}

PhInputs does two jobs. It declares the sockets — nothing new; other annotations do that. It also carries rules: ecutwfc has a bound, and two of the four SpinType members are ones ph.x cannot run. Those rules travel with the sockets. Put ph inside a workflow that accepts every spin treatment:

class WorkflowInputs(BaseModel):
    spin: SpinType = SpinType.NONE
    structure: str

@task.graph(input_model=WorkflowInputs)
def eps(spin, structure):
    return ph(spin=spin, structure=structure).dielectric

eps.build(spin=SpinType.NON_COLLINEAR, structure="si")
node_graph.input_model.TaskInputValidationError: Task 'ph' got inputs PhInputs rejects:
1 validation error for PhInputs__Rules
spin
  Value error, ph.x has no electric-field perturbation for noncollinear magnetism [type=value_error, input_value=<SpinType.NON_COLLINEAR: 'non_collinear'>, input_type=SpinType]

eps does not have to know which spins ph supports, and ph's body never ran: the value was refused while the graph was being built, at the line inside eps that handed it to ph, before anything was stored or submitted. A bad value written directly fails at that line too — graph.add_task(ph, "ph", structure="si", ecutwfc=500) raises Input should be less than or equal to 200 — and a return value the output model rejects fails the task that produced it:

node_graph.input_model.TaskOutputValidationError: Task 'ph_forgets' returned outputs PhOutputs rejects:
1 validation error for PhOutputs
dielectric
  Field required [type=missing, input_value={}, input_type=dict]

What we tried first

This ph example comes from aiida-koopmans, whose dielectric task runs ph.x, which aborts on an electric-field perturbation under noncollinear magnetism, while the workflow around it takes all four of aiida-quantumespresso's SpinTypes. I tried to impose the spin restriction three ways before this pydantic-based rewrite:

  • A raise in the body. Inside DielectricTask, add if spin in (NON_COLLINEAR, SPIN_ORBIT): raise NotImplementedError(...). This only fires when the body runs, so inside a deferred @task.graph, that raises mid-run in the daemon, after the value was stored and the process submitted. It also means that we are describing ph-specific restrictions outside of the body of ph.
  • Narrowing the annotation to Literal[SpinType.NONE, SpinType.COLLINEAR] fell victim to the missing Literal support (Literal[...] not supported #175). 🐛 Decide enum/Literal socket membership once, at assignment #178 fixes that at _set_socket_value, and what it gives is membership: a value outside the Literal is refused at the socket, with a message that names the socket. Nevertheless, every other kind of rule a pydantic model can state stays out of reach.
  • Restating the rule at the caller. I already use pydantic to parse input yaml files that parametrise the workflow to run. This started to grow route-conditional validators (e.g. "if requesting eps to be calculated, then you can't have non-collinear spin"). This is a re-implementation of knowledge that should be local to the ph graph, not the input file parser, and is prone to drift.

When a rule is checked

A value can arrive at a task at three moments, and the model is consulted at each. The rest of this description calls them checkpoints A, B and C.

  • A — when an input is written: calling the task, graph.add_task(...), task.set_inputs(...). Field types, constraints and mode='after' field validators run on every field whose value is present. A field holding a socket or a task gets its type checked only; model validators wait, since the other fields may not exist yet. A rule that reads a sibling nobody has written yet waits alone; the other rules in the same write still run (info.data['x'] waits, info.data.get('x') returns None as dict.get always has). One route bypasses A: assigning to a socket directly (task.inputs.x.value = ...), which the socket layer never checked either.
  • B — when a @task.graph is expanded: its inputs are values by then, so the full model runs, model validators included, on an untagged copy that is then discarded; the body keeps its tagged originals, which it turns into links.
  • C — when a leaf task runs: the assembled inputs are validated once more and the body receives the result. The return value is validated against output_model here and only here; a missing or mistyped field fails the task that produced it, an undeclared key is refused by name.

output_model is refused on a @task.graph: a graph returns socket references, values that do not exist yet. Put it on the function tasks whose outputs the graph returns.

Changes

The sockets come from the model. spec_from_model adds to from_model what the model knows and the sockets did not: which fields are required (a field with a default is optional), the default itself in JSON form, dict[str, T] fields as dynamic namespaces (one socket per key, validated through T), and, per socket, whether the body receives plain Python or the engine's stored node. A socket is named after its field, whatever alias the field carries. What a body receives for a field the caller did not write depends on the field's shape: a scalar arrives at the model's default; a nested model, or a mapping of models, arrives as the members that were written — {} when the field itself was omitted — so a namelist a caller wrote three keywords into reaches the body as those three, not as the model's hundred defaults. A write may fill a nested model a few members at a time; the missing ones may come by link. (list[Model] items arrive as model instances; the rule stops at mappings.)

Two descriptions of one task, checked against each other. With input_model=, the model and the function signature both describe the inputs. The model is authoritative; the signature is still what the body is called with, so at decoration ModelContractError names any disagreement: a field with no parameter, a parameter with no field, an annotation that contradicts its field (annotations may repeat the model, never differ from it), *args/**kwargs, or a default written in the signature ("defaults live in the model — move it").

Two model shapes are refused at the same point. We refuse extra='allow' (at any depth) because a model that accepts undeclared fields would accept inputs that have no socket; declare every field. We refuse a list of engine objects, such as list[orm.StructureData], because a list is stored as one plain-data node and its members come back as plain data, not as the objects that went in; the fix is dict[str, orm.StructureData], which stores each member as its own node. A model that names itself in a field is refused too, naming the cycle, rather than exhausting the stack.

The checks travel with the task, not with its name. The validated callable is bound in the task's module under a name of its own and the executor records that name, so a task rebuilt from its stored form in another process — or a handle bound to a name other than the function's — still enforces its models. A model this process cannot rebuild (a __main__ model whose nested model carries a @model_validator under from __future__ import annotations fails pydantic's cloudpickle round-trip) is refused with ModelContractError telling the author to move it into an importable module, rather than silently running without checkpoint A.

A refusal can be read, not only printed. TaskInputValidationError carries task, model and errors (pydantic's loc/type/msg list); a value the socket layer refuses before any model runs raises SocketValueError with loc and type, so a consumer translating either into its own vocabulary never parses text.

Validation may change how a value is spelled, never what it says. '60' may become a Decimal; a validator that derives or rewrites an input is refused, because the body would run on a value that never reached storage. The written value is rendered to JSON before the model runs and compared afterwards, so in-place mutation, nested-instance edits and serializer tricks are all caught. The one permitted difference is the model's own config coercion: with str_strip_whitespace, storage keeps ' silicon ' and the body gets 'silicon'.

What a body receives is decided per socket from the field's type: plain Python where pydantic can rebuild the type from plain data (str, int, Decimal, an Enum), the engine's stored node otherwise. A union whose arms disagree is refused at decoration. The mechanism and its alternatives are discussed in the paired aiida-workgraph pull request (see Open decision).

Without either keyword nothing changes. A task declared the usual way takes exactly the path it took before.

Testing

tests/test_input_model.py, 201 collected tests; every checkpoint's test is paired with a control that fails without it.

  • A. The same bad value is refused by all three write routes, and so is a constraint (gt=0) the socket layer cannot see; field validators are refused at the wiring in the ph/eps pair above with no ph built. Controls: the check stubbed out, add_task accepts the value; the same graph with collinear runs and hands the body the enum member. (reproduced)
  • B and C. Model validators run at expansion on a copy and the body keeps its tagged originals; the leaf run edge refuses a missing, mistyped or undeclared output. (reproduced)
  • Content invariance. Four ways to rewrite a value under validation — in-place list mutation, replacement, nested-instance edit, a PlainSerializer — are each refused; str_strip_whitespace is accepted as coercion while a hand-written validator doing the same is refused. (reproduced)
  • What a body receives, per field kind and per member of a TypedDict; unions in both directions with Optional[int | Marker] as the control. (reproduced)
  • What an unwritten field is worth. A nested model written with one member reaches the body as that one member; an omitted nested or mapping field arrives as {} and the body is still called; a scalar arrives at its default. Control: with default materialization restored the body sees every member. (reproduced)
  • The checks survive a round trip. A spec dumped in one process and rebuilt in another still refuses a bad write; a handle bound to another name refuses like the decorator spelling. Control: at the base the rebuilt spec carries no model and accepts the write. (reproduced)
  • Documented limits are pinned: a field validator does not fire at A on a linked field; a model validator waits for B, on nested models too; a mode='before' normalizer is not honoured at A; the direct socket write stores what set_inputs refuses; a rule's own KeyError escapes as a bare KeyError.

Full suite: 564 passed, 1 failed on this tip (363 passed, same failure, at the tip of #178). The failure is tests/test_socket.py::test_await_forbidden, which needs an async pytest plugin this interpreter lacks.

Open decision

The read-edge mechanism — body_receives, the per-socket mark saying whether a body gets plain Python or the engine's stored node, plus the removal of SerializationAdapter.to_python — sits in one commit and can be dropped without touching the rest. It is one of several ways to give a body the form its field declares; the alternatives and their costs are in the paired aiida-workgraph pull request.

elinscott and others added 28 commits August 20, 2026 14:02
An Enum-typed socket spec was tracked identically to any other
structured type, so its serialized form (the bare member value) never
got rebuilt into the member: a @task body annotated with an Enum
received a plain str/int, and color.name resolved to str.name instead
of raising on a missing member.

- structured_type_info/coerce_structured_value grow an "enum" kind:
  serialize to the bare value, rebuild via cls(value) on the way back.
- materialize_graph now runs adapter.deserialize over a @task.graph
  body's resolved inputs before calling the body, so a value round-
  tripped through an engine-typed wrapper (e.g. aiida-workgraph's
  orm.Int) arrives as the primitive the signature declares. Recurses
  into already-materialized dataclass/Pydantic namespace instances,
  not just dicts, since coerce_inputs_from_spec runs first.
- An Enum-typed parameter with a default came out required regardless,
  since the structured_type overlay was a bare SocketMeta (required
  defaults to True) and merge_meta prefers any non-None overlay value;
  pass required=None so the default's own computed requiredness wins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
scinode#175: an Enum or Literal socket's allowed-member
check ran twice, on two different representations. At build time,
check_socket_match compared nominal identity (is the source's own
enum class a subset of the target's), so a foreign enum whose values
happened to match was rejected. At run time, coerce_structured_value
compared by value only, so the same foreign-but-matching member was
accepted. A value that passed one gate could still fail the other.

TaskSocket._set_socket_value is the one point every graph shape
(direct assignment, link, default, two-hop, namespace) passes through
before a value reaches storage, so canonicalization and the allowed-
values check now live there, decided once, by value. A structured-type
socket's default goes through the same check where its spec is built,
so a defaulted socket reads the same as an assigned one.

Adds link.py's check_static_source_value (an untyped or two-hop link
source's value can't be checked at build; the run-time coercion still
raises) and value_is_allowed's typed-numeric comparison (an IntEnum
whose members are 1 and 2 no longer takes True or 1.0, matching
Literal's own rule).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`@task(input_model=M)` / `output_model=M` builds a task's sockets from a
model and holds every execution to it. Without either keyword nothing
changes.

- The spec comes from the model: field types, defaults and requiredness,
  with `dict[str, T]` read as a typed dynamic namespace and the dotted
  class path `from_model` records left out.
- Model and signature are checked against each other at decoration; a
  disagreement raises `ModelContractError` naming the offender.
- The run edge validates the assembled inputs and hands the body the
  objects the model declares; the return value is validated against the
  output model. A model that derives or rewrites an input, rather than
  only respelling it, is refused.
- `FunctionTask.build` now takes the callable to run separately from the
  one to infer from, so a wrapped executor leaves the spec's signature,
  source and return annotation as they were.
- `@task.graph(output_model=...)` is refused: a graph returns socket
  references, which stand for values that do not exist yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A `@task.graph` under a model contract had nowhere for a cross-field rule
to run: its body receives socket references at build time, and validating
them would replace the references with values, losing the links.

- `materialize_graph` validates an untagged copy of the resolved inputs
  and passes the originals to the body, so the links it draws survive.
- Every expansion path funnels through `materialize_graph`, so a handle's
  `build()`, the engine's subgraph and a workgraph's graph task are all
  covered by the one hook.
- `untagged_copy` reads tagged values without stripping them, which
  `resolve_tagged_values` does in place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A literal written into a socket the type map reads as `any` -- a Decimal,
a fixed-length tuple, a constrained number -- reached storage unchecked
and failed only once the task ran.

- Every call through a task handle validates its literals against the
  input model, so the failure names the line that wrote the value.
- A socket reference passes untouched: it stands for a value nobody has
  yet.
- The validated instance is discarded and the original values are passed
  on, because pydantic strips the proxy a tagged value wears and a
  stripped value is a literal, not a link.
- The model runs as a flat shadow, so field and model validators are not
  judged against a placeholder; they still run at the two later
  checkpoints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Each checkpoint has a test that fails with its hook disabled: a value
  typed only by the model at the call, a cross-field rule at graph
  expansion, a rule the socket layer cannot see at the run edge.
- The wiring checks are shown to leave a graph's links exactly as they
  were, and to hand back the very objects they were given.
- The boundary is pinned too: a field validator does not fire at the
  call, and a before-validator's normalization is not honoured there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- flake8 read two deliberate identity comparisons as type comparisons;
  the tag check now uses isinstance, and NoneType is named once.
- black reformatted the new module, the call hook and the tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The body recorded that it ran by appending to a list the test held, and a
locally defined task is pickled by value, so the test read its own copy of
that list and would have passed whether the body ran or not.

- The body raises instead, which survives the round trip: with the model
  the caller sees the refusal, and the control task with the same sockets
  and no model sees the body's own error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A graph task under an engine that wraps its values in storage nodes could
not carry a model with a `str` field: the body is handed the node, and the
contract was checked against the node.

- `SerializationAdapter.to_python` returns what a wrapped value holds, and
  is the identity for an adapter that wraps nothing.
- The graph checkpoint asks the graph's own adapter before validating, so
  what is checked is what the values say rather than how they are stored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A `FolderData` in a model-declared socket, or any object under `Any`, failed
the whole task with a raw `PydanticSerializationError` from inside the
invariance check, naming neither the field nor the task.

- Read the reference field by field and keep the object itself where the
  model has no JSON form for it, so it is compared as it stands.
- Return such a value unchanged from `dump_model_field` as well, for the
  engine's own serialization to store or to refuse in its own words.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A `@task.graph` body whose model declared `label: str` was handed the
`orm.Str` holding it, and the graph refused its own input at expansion.

- Mark every leaf socket a model owns with what the body receives for it,
  `python` or `node`, decided by whether pydantic can rebuild the declared
  type from plain data.
- Take the mark from the stored spec rather than from the task, so the read
  edge can answer for a socket on its own.
- Read a union one arm at a time and refuse one whose arms disagree, naming
  the field and both sides; `X | None` is read as `X`.
- Drop `SerializationAdapter.to_python`, and with it the adapter the graph
  checkpoint had to be handed: its inputs already arrive as the body sees
  them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A model setting `arbitrary_types_allowed` could not be used at all: building
the models behind the checkpoints raised a schema error for the very field
the config exists to permit. One setting `str_strip_whitespace` was refused
at the run edge, its own coercion read as a derivation.

- Carry `model_config` into both models built from a model's fields, so each
  accepts and coerces a value exactly as the model it stands for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A validator rewriting a `Field(exclude=True)` field passed unnoticed: the
body ran on the rewritten value while storage kept the one the caller wrote.

- Clear `exclude` on the fields of the model the comparison reads through.
  Exclusion says what is rendered, not what may be rewritten unseen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A validator that appended to a list in place, or reached into a nested model
instance, rewrote the very values the check compares against: storage kept
`['a']`, the body ran on `['a', 'INJECTED']`, and the task exited zero.

- Take the reference first, rendered to JSON, so it survives whatever the
  model does to the values afterwards.
- Say in the docstring that a validator written into an annotation runs on
  both sides of the comparison and so has to be idempotent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Writing `f(inner=Inner(x=1))` was refused with "expected an instance of
`Inner__Wiring`" -- the very class the field declares, in the plain form and
inside a `list[Inner]` or `dict[str, Inner]` alike.

- Let an instance of the declared model through the wiring check, which
  compares against a shadow of that model and so is a different class.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Only calling a task ran its model's check. `add_task(t, "p", amount="bad")`
and `task.set_inputs(...)` wrote the same value unchecked, and `add_task` is
the primary API, so a model constraint the socket layer cannot see reached
storage and failed at the run edge instead.

- Check the write itself, which all three routes go through, so the value is
  refused at the line that wrote it.
- Check only the fields a write names: inputs may be written a few at a time
  and a link may supply the rest.
- Treat a task written into an input as a reference, since writing one links
  its output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The description was tracked, so it appeared in the diff a reviewer reads and
would have landed upstream as a file of its own.

- Remove it from the repository; it stays in the worktree, untracked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An input holding a numpy array raised "The truth value of an array with
more than one element is ambiguous" at graph expansion and at the run
edge, before the model's own rules could be reported. Five shapes were
accepted at decoration and refused later, or not at all.

- Compare a field with no bool answer by identity, by a whole-array
  comparison, or by what the two sides render as.
- Refuse a list of values only an instance of a class satisfies, and
  read a TypedDict or a nested model through its own members.
- Accept a field under the name that names its socket, so a model whose
  field carries an alias is not refused the keys the graph delivers.
- Refuse an open-topped model wherever it is nested, not only at the top.
- Drop an annotation's serializer from the content comparison, so a
  renderer mapping every value to one output cannot hide a rewrite.
- Render a return value field by field, and refuse a returned key the
  output model does not declare.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A task whose model refuses a value with a `@field_validator` accepted that
value at every write and reported it only at the run edge: inside a
`@task.graph` the graph had expanded and the task was submitted by then,
so the refusal arrived after the work had started.

- Run a model's `mode='after'` field validators at the write, on the
  fields whose value is resolved, so the refusal names the line that
  wired the task.
- Leave a field written as a socket reference checked for its shape
  alone, and leave `@model_validator`s to the later checkpoints.
- Leave a rule that raises anything other than a validation failure to
  the later checkpoints, so a field rule reading a sibling nobody has
  written yet does not fail a partial write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rule shadow is built from `model.__pydantic_decorators__`, a name
pydantic does not document, and nothing said so where a reader would look.

- Assert the record still names each field validator, its mode and the
  fields it covers, and that the shadow reads the same set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`add_task` and `set_inputs` write inputs too, and nothing pinned that a
model's field rule is held to on those routes -- the primary API for
building a graph by hand.

- Refuse a value breaking a field rule at `add_task` and at `set_inputs`.
- Pair them with the control that stubs the write check out and watches
  `add_task` take the same value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`test_an_any_arm_is_named_for_what_it_declares` failed on this
interpreter: the error refusing a union whose arms disagree left out the
note saying `Any` declares nothing to rebuild.

- Decide the note from the annotation rather than from its rendered
  name, at both call sites, so a `TypedDict` member declaring `Any` is
  named the same way a union arm is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A rule reading a sibling nobody wrote was handed a placeholder object
rather than nothing, so `add_task(flagged, value=99)` was refused where
`Flagged(value=99)` is valid, and a rule on a nested field was handed
the wiring shadow of the field's class: `isinstance(value, Inner)`
refused a good value, and `value.helper()` raised past the rule, letting
a value the model refuses through.

- Build the rule shadow from the fields a write names, each keeping the
  annotation its model declares, so an unwritten field is absent from
  `info.data` and a nested field arrives as the class it names.
- Take a write naming a field by its alias as naming that field.
- Refuse what the rule refuses, and wait only on the `KeyError` an
  unwritten sibling raises, where before every error the rule raised was
  swallowed.
- Wait on a value nested deeper than the walk looks, where before an
  unexamined depth counted as resolved and the rule ran on a raw socket.
- Say in the module docstring that a tagged value is judged by the value
  it carries, and that a socket or a task is what waits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The docstring claimed the check is reached by every route that writes an
input. `task.inputs.rating.value = 9` reaches neither it nor the socket
layer's own check: with `rating: int = Field(gt=0)` the socket takes 9
and -1 alike, while `set_inputs` refuses both.

- Name the two routes that do reach it, and say that assigning to a
  socket's `value` writes past it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With `amount: int = Field(gt=0)`, `node.inputs.amount.value = -1` is stored
while `set_inputs({"amount": -1})` refuses it. The branch says so in its
description; nothing held it to that.

- Assert both halves in one test, so the documented limit cannot rot
  silently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`Field(le=200)` was not enforced on a value a graph body passed on: the
type pass let a tag with a socket attached through untouched, while the
rule pass unwrapped the same tag and judged it. A model with a field rule
refused the write; the same model without one accepted it.

- Read through the tag in `is_socket_reference`, so both passes wait on
  exactly what a link has yet to deliver: a bare socket or a task.
- Test the graph-body route, the unit behind it, and the link the accepted
  write still draws.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
elinscott and others added 21 commits August 28, 2026 11:34
The control test asserted `graph_inputs.outputs.ecutwfc`, and the link is
drawn from `graph_inputs.inputs.ecutwfc`; the test failed on the branch it
was added to.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A write naming `high=5, cores=-4` was accepted whole: `high`'s rule read
an unwritten `low`, the lookup raised, and the whole rule pass returned --
so `cores`'s own rule never ran. A `KeyError` a rule raised from its own
logic was swallowed the same way, on a value the model itself refuses.

- Hand a rule that reads `info.data` a mapping that refuses a field the
  write left out, and let that one rule wait on it.
- Drop the blanket catch, so every other error a rule raises refuses the
  write as the model would.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Whether a nested model's `@model_validator` ran at a write was decided by
whatever else the outer model happened to declare: with one unrelated field
rule on the outer model, `pair={'n': 1, 'm': 2}` was refused; without it,
the same write was accepted. A rule on a field of the nested model was
decided the same way, and ran in neither case when the outer model carried
no rule of its own.

- Judge a nested model through a twin that subclasses it, so a rule reading
  the field still gets the class the field names, with the cross-field rules
  left out at every depth.
- Build the rule shadow whenever any model in the tree declares a field
  rule, not only when the outermost one does.
- Test both models accept the write and both refuse it at the run edge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reading a task's model was wrapped in a catch-all, so a callable that would
not rebuild left the task with no model and every write to it unchecked.
Under `from __future__ import annotations`, a script-level model declaring
a nested model with a `@model_validator` reaches that route: its writes
were accepted in silence while the model itself refuses them.

- Swallow only what the comment described, a callable that is not here to
  be imported; anything else is a `ModelContractError` naming the task and
  what to change.
- Test the branch directly, and the script that reaches it, with the
  unresolvable-executor case as the control.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A value written into a graph's own input is refused by the socket layer,
and that refusal named the path in prose alone: a caller turning refusals
into advice could read `loc` and the error type off a model's refusal and
had nothing to read off a socket's, in either shape it takes.

- Raise a `SocketValueError` carrying `loc` and `type`, spelled as
  pydantic's `errors()` spells them.
- Give both shapes the carrier: a value outside its socket's literal or
  enum, and a key written into a namespace that has no field for it.
- Test the two shapes against the paths the leaf's own model reports for
  the same two writes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`black --check` reformatted the call added in the previous commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A `@task.graph(input_model=M)` body was handed the stored form of each
input while a body that declared the same field by annotation was handed
the type: `spin` arrived as `'collinear'` under a model and as
`Spin.COLLINEAR` without one, so `spin is Spin.COLLINEAR` and `spin in
(...)` answered differently on the two routes.

- Return the values the model made of the graph's inputs from checkpoint B,
  each under the tag and uuid the caller wrote it with.
- Walk into a namespace rather than replacing it, since its members carry
  tags of their own.
- Test the member, the `Decimal`, the tag that survives, and the same for a
  member below a namespace.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The module's own summary still described the two checkpoints this branch
changed as they behaved before: a graph body keeping untouched originals,
and one rule's wait taken as every rule's.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A model with a field of its own type exhausted the stack rather than being
answered: the walk that builds the sockets, and each rebuild the checkpoints
make, followed the chain forever, and the user saw a bare `RecursionError`
at the line that decorated the task.

- Refuse such a model where it is declared, naming the cycle and what to do
  instead: a namespace holds one socket per field and this one has no
  bottom.
- Count the models each rebuild has walked through, so no other entry point
  into the shadows can hang either.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A task decorated under one name and bound to another --
`leaf = task(input_model=M)(_body)` -- ran its body with no checks at all: a
field validator refusing a negative band count let `run(nbnd=-5)` finish and
return -5, and the same task submitted through aiida-workgraph finished with
-5 as well. The spelling that leaves the decorated name bound to the handle
refused both writes.

- Bind the wrapper enforcing the models in its own module, under a name of
  its own, and record that name as the executor's.
- Leave the wrapper's `__name__` as it was, so a process keeps the label it
  had.
- Test both spellings at the write and at the run edge, with the by-value
  route and a second task off the same function beside them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`TaskInputValidationError` put the task, the model and pydantic's report in
its message and nowhere else, so a caller wanting the path a value was
refused at had to parse the text or reach for `__cause__`.

- Carry `task`, `model` and `errors` on the refusal, `errors` being
  pydantic's own list of `loc`, `type` and `msg`.
- Answer with an empty list where no report stands behind the refusal.
- Test that a model's refusal and a socket's are read the same way, each
  giving a `loc` and a `type` without a word of prose.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Nothing said what a field the caller never wrote is by the time the body
reads it, and the answer depends on how the field is declared.

- Pin that a member nobody wrote reaches a graph body at the model's
  default, and that one declared `T | None = None` does not reach it at all.
- Pin the run edge beside it, where the same member arrives as the `None` it
  defaults to.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Writing part of a nested namespace -- `pair={'n': 2}`, with `m` to arrive by
a link -- was refused with `Field required`, while the same partial write of
the outer fields was accepted.

- Make the partial wiring shadow optional at every depth, not the outermost
  alone, bounded where the other rebuilds are bounded.
- Test the nested write against the top-level one, with a mistyped member
  and a complete call as the controls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Drop the account of what a task's executor used to resolve to, and say
  what the binding is for.
- Name what the attribute on the executor's side holds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Pin that a spec rebuilt from the dict it is stored as still refuses what
  its model refuses, which is what the stored module path has to reach.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A write naming one member of a nested namespace reached the body as the whole
namespace: `system={"nbnd": 20}` against a five-member model arrived as five
keys, four of them the model's own defaults, and a body could not tell what
its caller had asked for.

- Leave a nested model's members without a socket default, so nothing is
  collected for a member nobody wrote; the member stays optional, so a write
  may still name any subset of them.
- Hand the body each nested-model field as the members that were written, in
  the types the model made of them, and every other field as before, its
  default included.
- Test the rule at both bodies, with a required member, a rule reading an
  unwritten member, and a member whose serializer would render it as the
  controls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The test read `type(low).__name__` and compared it to `TaggedValue`, which
names one of the tagging layer's classes rather than the property the test is
about; against a tree where a scalar is tagged by a subclass it reads as a
failure with nothing wrong.

- Ask whether the body's value is a tagged value, and keep the class name in
  the message when it is not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A `dict[str, Model]` field kept the older rule: writing one member of one key
reached the body as that key's whole item model, the other members filled in
from their defaults, while a nested model beside it handed over the written
members alone.

- Leave an item model's members without a socket default too, and hand each
  item of a mapping to the body as the members written into it.
- Test one written member of one key, with a required item member and an
  item's own cross-field rule as the controls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A body reached through an engine that passes its arguments positionally --
AiiDA's process functions do -- met a wrapper taking keywords only and failed
with `takes 0 positional arguments` before the model ever ran.

- Bind whatever arrives positionally onto the parameter names the model
  addresses, and check it as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An engine that decorates a task's body after the models are applied -- AiiDA's
`calcfunction` over the validated callable -- left the executor's stored name
pointing at the inner callable, so resolving it returned a plain function
where the engine needed its own process function.

- Move the bound name onto the outer callable, so the executor resolves to
  what the engine has to run and the models go on being enforced inside it.
- Test that the name follows the wrapper and that the write it wraps is still
  refused.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Once a nested model's members stopped carrying socket defaults, a top-level
field declaring one -- or a `dict[str, T]` -- that the caller left out reached
no body at all: a graph failed with `g_nested() missing 1 required positional
argument: 'control'` and a leaf with `KeyError: 'control'` while it collected
its inputs.

- Key the graph checkpoint's return off the model's own fields, so a field
  nobody wrote is handed over as the members that were written, which is
  nothing at all for a field left out.
- Let a task whose socket collected no value call its body by name, rather
  than leave a hole in a positional call.
- Test both checkpoints, with a nested model and with a mapping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.14340% with 103 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.90%. Comparing base (8d85e61) to head (2fc6dc9).

Files with missing lines Patch % Lines
src/node_graph/input_model.py 89.81% 76 Missing ⚠️
tests/test_enum_literal_sockets.py 95.29% 12 Missing ⚠️
src/node_graph/link.py 92.18% 5 Missing ⚠️
src/node_graph/utils/struct_utils.py 94.87% 4 Missing ⚠️
src/node_graph/task.py 77.77% 2 Missing ⚠️
src/node_graph/utils/graph.py 92.59% 2 Missing ⚠️
src/node_graph/socket.py 95.00% 1 Missing ⚠️
src/node_graph/socket_spec.py 98.11% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #182      +/-   ##
==========================================
+ Coverage   89.68%   90.90%   +1.22%     
==========================================
  Files          81       84       +3     
  Lines        8984    11881    +2897     
==========================================
+ Hits         8057    10801    +2744     
- Misses        927     1080     +153     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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