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>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #178 +/- ##
==========================================
+ Coverage 89.68% 90.07% +0.38%
==========================================
Files 81 82 +1
Lines 8984 9487 +503
==========================================
+ Hits 8057 8545 +488
- Misses 927 942 +15 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
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.
Problem
A
Literal-typed socket's structured-type info dropped its alternatives entirely, so no value assigned to it was ever rejected, whatever it held (see #175 for more details).The same code path also served
Enum-typed sockets, and there a membership check did run, but on two different representations that could disagree with each other.At build time,
check_socket_matchcompared nominal identity — is the source's own enum class a subset of the target's — so passingSpin.NONE(a foreign enum whose value matchesNarrow.NONE) was rejected. At run time, the value-reconstruction path compared by value only, so the same foreign-but-matching member was silently accepted once execution reached it. A value that failed one gate could still pass the other, and which gate ran first depended on how the value reached the socket (literal, link, default).Serialization had a matching gap on the read side: an
Enum-typed parameter's serialized form is its bare member value, and nothing rebuilt the member before a task body ran —color.nameresolved tostr.nameinstead of raising on a value with no member.Changes
Read side
Folding in the enum handling from #151 (the earlier enum PR this one supersedes),
structured_type_info/coerce_structured_valuegrow an"enum"kind — serialize to the bare value, rebuild via the class on the way back — so a@task/@task.graphbody annotated with anEnumreceives the member its signature declares.materialize_graphnow also runs the adapter'sdeserializeover a@task.graphbody's resolved inputs before calling the body, so a value round-tripped through an engine-typed wrapper (e.g. aiida-workgraph'sorm.Int) arrives as the primitive the signature declares, not a database node.Write side: one decision point
TaskSocket._set_socket_valueis 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 instead of being duplicated — the rule lives in one place (canonical_socket_value/value_is_allowed), applied at assignment, defaults, link checks and run-time coercion, by value, never by nominal class. 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 on both sides of a round trip. ForLiteral, this is what #175 asked for:For an
Enum, deciding by value means a foreign enum whose value matches is a member, and a foreign enum whose only match is its name is not:Accepting
Spin.NONEfor aNarrowsocket is deliberate: after serialization,Narrow.NONE,Spin.NONEand"none"are the same"none", and the socket must take the bare form back from storage — so by value is the only rule a round trip preserves (rejecting at build what run accepted was #175's asymmetry). Static typing is unchanged: mypy still flagsspin=Spin.NONEat the call site. The payoff is that a wide enum's member can go straight into a socket declared with a narrower enum of the same values.Membership by value follows the rule
Literalalready applies, so anIntEnumno longer takes the values Python merely compares equal to its members:Links
A typed source's declared alternatives must fit the target's, checked at link time;
link.pygainscheck_static_source_valueso an untyped source that already holds a literal is checked at build too. A value an untyped task only produces at run cannot be checked at build, so the run-time coercion still raises — the socket names itself in the message, without leaking theTaggedValuewrapper or its uuid.This closes #175 and answers #176 ("what should a graph body receive") for the case the contract can reach: an annotated socket. A body annotated
spin: Narrowreceives a value that compares asNarrow.NONE, links onward as it, and lands in the receiving task as the member. What it holds may still be the socket-tagged wrapper — an enum value assigned directly as a graph input stays tagged so the body wires tograph_inputsinstead of copying a literal, andTaggedValue(Narrow.NONE) is Narrow.NONEis unavoidablyFalse— but nothing an annotated body needs to do exposes that. An unannotated input is anAnysocket: no declared alternatives, so nothing to decide, and whatever the caller passed arrives as-is. Rebuilding a member by hand there was never covered by any contract, and is the one way to see the tag:pick_naive— an unannotated graph input passed toNarrow(...)inside the body — is one of the cases #176 tabulates as silently wrong on main: it builds, but the member it constructs comparesFalseagainst every realNarrowmember. On this branch it raises at build. That is deliberate, not a regression: deciding membership by value in one place means a value that names no member fails loudly wherever it appears. The fix is the annotation, as inpick, not a workaround. See the paired aiidateam/aiida-workgraph#800 for the same rule applied on its side of the boundary.Testing
tests/test_enum_literal_sockets.py(new, 53 tests) plus 2 intests/test_engine_local.py: membership decided once — a foreign member with a matching value is accepted at every entry point (literal, link, default, two-hop) and a foreign member with a matching name only is rejected everywhere;Literalof enum members, of strings, of mixed types; requiredness follows the default, not a bare overlay; the run-time message names the socket and never leaksTaggedValue/uuid.