Repository navigation
Conversation
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>
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 Report❌ Patch coverage is 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. 🚀 New features to boost your workflow:
|
This was referenced Sep 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 typedstr— 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_modelexpands 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 ofph's inputs, and node-graph enforces the model's validators wherever a value arrives atph.@task(output_model=PhOutputs)does the same for whatphreturns.What it looks like
PhInputsdoes two jobs. It declares the sockets — nothing new; other annotations do that. It also carries rules:ecutwfchas a bound, and two of the fourSpinTypemembers are onesph.xcannot run. Those rules travel with the sockets. Putphinside a workflow that accepts every spin treatment:epsdoes not have to know which spinsphsupports, andph's body never ran: the value was refused while the graph was being built, at the line insideepsthat handed it toph, 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)raisesInput should be less than or equal to 200— and a return value the output model rejects fails the task that produced it:What we tried first
This
phexample comes from aiida-koopmans, whose dielectric task runsph.x, which aborts on an electric-field perturbation under noncollinear magnetism, while the workflow around it takes all four of aiida-quantumespresso'sSpinTypes. I tried to impose the spin restriction three ways before this pydantic-based rewrite:DielectricTask, addif 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.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 theLiteralis 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.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.
graph.add_task(...),task.set_inputs(...). Field types, constraints andmode='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')returnsNoneasdict.getalways has). One route bypasses A: assigning to a socket directly (task.inputs.x.value = ...), which the socket layer never checked either.@task.graphis 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.output_modelhere and only here; a missing or mistyped field fails the task that produced it, an undeclared key is refused by name.output_modelis 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_modeladds tofrom_modelwhat 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 throughT), 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 decorationModelContractErrornames 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 aslist[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 isdict[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_validatorunderfrom __future__ import annotationsfails pydantic's cloudpickle round-trip) is refused withModelContractErrortelling the author to move it into an importable module, rather than silently running without checkpoint A.A refusal can be read, not only printed.
TaskInputValidationErrorcarriestask,modelanderrors(pydantic'sloc/type/msglist); a value the socket layer refuses before any model runs raisesSocketValueErrorwithlocandtype, 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 aDecimal; 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: withstr_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, anEnum), 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.gt=0) the socket layer cannot see; field validators are refused at the wiring in theph/epspair above with nophbuilt. Controls: the check stubbed out,add_taskaccepts the value; the same graph withcollinearruns and hands the body the enum member. (reproduced)PlainSerializer— are each refused;str_strip_whitespaceis accepted as coercion while a hand-written validator doing the same is refused. (reproduced)TypedDict; unions in both directions withOptional[int | Marker]as the control. (reproduced){}and the body is still called; a scalar arrives at its default. Control: with default materialization restored the body sees every member. (reproduced)mode='before'normalizer is not honoured at A; the direct socket write stores whatset_inputsrefuses; a rule's ownKeyErrorescapes as a bareKeyError.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 ofSerializationAdapter.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.