misc changes: returns None, root as ., add

This commit is contained in:
lda
2026-05-18 00:45:08 +07:00 Verified
parent 6ee1d8f0cf
commit acd2e2ecb4
19 changed files with 274 additions and 53 deletions
+14
View File
@@ -219,6 +219,20 @@ They should not receive node ids, frame ids, loop indexes, timestamps, or other
runtime context. If behavior depends on workflow context, that is business logic
and belongs in nodes or graph structure.
Reducers are meant to remove write boilerplate, not absorb domain decisions.
If a node needs to decide whether to increment two counters, reset both
counters, reset one counter, or preserve one counter, that decision belongs in
the node or in an explicit graph branch. A reducer should only describe how a
declared state path combines the node's write with the current state value.
This distinction matters for fragmented outputs. It is valid for a node to emit
a delta if the node is explicitly a delta-producing node, such as
`countdown_delta = -1` written through `wf.std.add`. It is a smell if a node
returns artificial fragments only to trigger reducer behavior while hiding the
actual domain operation. In that case prefer a clearer node output, an explicit
shaping node, or a default map on the node spec that is still validated at the
use site.
Examples a future reducer library could support:
- `max`
+2
View File
@@ -51,6 +51,7 @@ from .ops import (
)
from .nodes import (
AsyncRegistryHandler,
NoOutput,
NodeReturn,
NodeSpec,
Nothing,
@@ -82,6 +83,7 @@ __all__ = [
"ReducerCatalog",
"RenameFieldsInput",
"RuntimeErrorInput",
"NoOutput",
"NodeReturn",
"NodeSpec",
"Nothing",
+2 -1
View File
@@ -13,7 +13,7 @@ from .callables import (
from .inference import accepts_context, infer_models, is_basemodel_subclass
from .decorator import node
from .registry import build_async_registry, build_registry
from .result import NodeReturn, Nothing, outcome
from .result import NoOutput, NodeReturn, Nothing, outcome
from .schema import schema_ref_for
from .spec import NodeSpec
@@ -25,6 +25,7 @@ __all__ = [
"ContextNodeCallable",
"InputT",
"NodeCallable",
"NoOutput",
"NodeReturn",
"NodeSpec",
"Nothing",
+4 -4
View File
@@ -22,7 +22,7 @@ class ContextNodeCallable(Protocol[InputT_contra, OutputT_co]):
payload: InputT_contra,
/,
ctx: RuntimeContext,
) -> NodeReturn[OutputT_co] | OutputT_co: ...
) -> NodeReturn[OutputT_co] | OutputT_co | None: ...
class PlainNodeCallable(Protocol[InputT_contra, OutputT_co]):
@@ -30,7 +30,7 @@ class PlainNodeCallable(Protocol[InputT_contra, OutputT_co]):
self,
payload: InputT_contra,
/,
) -> NodeReturn[OutputT_co] | OutputT_co: ...
) -> NodeReturn[OutputT_co] | OutputT_co | None: ...
NodeCallable = ContextNodeCallable[InputT, OutputT] | PlainNodeCallable[InputT, OutputT]
@@ -42,7 +42,7 @@ class AsyncContextNodeCallable(Protocol[InputT_contra, OutputT_co]):
payload: InputT_contra,
/,
ctx: RuntimeContext,
) -> Awaitable[NodeReturn[OutputT_co] | OutputT_co]: ...
) -> Awaitable[NodeReturn[OutputT_co] | OutputT_co | None]: ...
class AsyncPlainNodeCallable(Protocol[InputT_contra, OutputT_co]):
@@ -50,7 +50,7 @@ class AsyncPlainNodeCallable(Protocol[InputT_contra, OutputT_co]):
self,
payload: InputT_contra,
/,
) -> Awaitable[NodeReturn[OutputT_co] | OutputT_co]: ...
) -> Awaitable[NodeReturn[OutputT_co] | OutputT_co | None]: ...
AsyncNodeCallable = (
+6 -1
View File
@@ -6,7 +6,12 @@ from typing import Any, cast, overload
from pydantic import BaseModel
from .callables import AsyncNodeCallable, InputT, NodeCallable, OutputT
from .callables import (
AsyncNodeCallable,
InputT,
NodeCallable,
OutputT,
)
from .inference import accepts_context, infer_models
from .spec import NodeSpec
+6 -1
View File
@@ -8,7 +8,7 @@ from pydantic import BaseModel
from wf_core import RuntimeContext
from .result import NodeReturn
from .result import NodeReturn, Nothing
def is_basemodel_subclass(value: object) -> bool:
@@ -47,10 +47,15 @@ def infer_models(fn: Callable[..., object]) -> tuple[type[BaseModel], type[BaseM
return_type = hints.get("return")
if return_type is None:
raise TypeError("node function must declare a return annotation")
if return_type is type(None):
return cast(type[BaseModel], input_model), Nothing
if is_basemodel_subclass(return_type):
return cast(type[BaseModel], input_model), cast(type[BaseModel], return_type)
if return_type is NodeReturn:
return cast(type[BaseModel], input_model), Nothing
origin = get_origin(return_type)
if origin is NodeReturn:
args = get_args(return_type)
+4
View File
@@ -20,6 +20,10 @@ class Nothing(BaseModel):
"""Empty output model for nodes that only choose an outcome."""
NoOutput = NodeReturn[Nothing]
"""Type alias for outcome-only nodes that return no output payload."""
@overload
def outcome(name: str) -> NodeReturn[Nothing]: ...
+9 -2
View File
@@ -18,7 +18,7 @@ from .callables import (
PlainNodeCallable,
SyncRegistryHandler,
)
from .result import NodeReturn
from .result import NodeReturn, Nothing
from .schema import schema_ref_for
@@ -33,6 +33,8 @@ def _coerce_registry_result(
default_outcome: str,
raw: NodeReturn[BaseModel] | BaseModel,
) -> dict[str, Any]:
if raw is None and output_model is Nothing:
return {"outcome": default_outcome, "output": {}}
if isinstance(raw, NodeReturn):
if not isinstance(raw.output, output_model):
raise TypeError(
@@ -69,7 +71,12 @@ class NodeSpec(Generic[InputT, OutputT]):
self,
payload: InputT,
ctx: RuntimeContext | None = None,
) -> NodeReturn[OutputT] | OutputT | Awaitable[NodeReturn[OutputT] | OutputT]:
) -> (
NodeReturn[OutputT]
| OutputT
| None
| Awaitable[NodeReturn[OutputT] | OutputT | None]
):
if self.accepts_context:
if ctx is None:
raise TypeError(f"node {self.name!r} requires RuntimeContext")
+10
View File
@@ -10,6 +10,8 @@ class LocalPathError(ValueError):
def split_local_path(path: str) -> list[str]:
"""Split one dotted node-local path, rejecting empty segments."""
if path == ".":
return []
parts = path.split(".")
if not path or any(not part for part in parts):
raise LocalPathError(f"invalid local path {path!r}")
@@ -18,6 +20,8 @@ def split_local_path(path: str) -> list[str]:
def get_local_value(payload: Mapping[str, Any], path: str) -> Any:
"""Resolve one node-local path from a nested mapping payload."""
if path == ".":
return dict(payload)
current: Any = payload
for part in split_local_path(path):
if not isinstance(current, Mapping) or part not in current:
@@ -29,6 +33,12 @@ def get_local_value(payload: Mapping[str, Any], path: str) -> Any:
def set_local_value(payload: dict[str, Any], path: str, value: Any) -> None:
"""Write one value into a nested node-local mapping payload."""
parts = split_local_path(path)
if not parts:
if not isinstance(value, Mapping):
raise LocalPathError("root local path requires a mapping value")
payload.clear()
payload.update(value)
return
current = payload
for part in parts[:-1]:
next_value = current.setdefault(part, {})
+16 -10
View File
@@ -39,11 +39,10 @@ def validate_node_use(
input_root_fields = set(workflow.input_schema.properties)
for source_path, destination_field in node.in_map.items():
try:
destination_root = split_local_path(destination_field)[0]
except LocalPathError:
destination_root = ""
if destination_root not in input_fields:
destination_root = _local_root(destination_field)
if destination_root is None or (
destination_root != "." and destination_root not in input_fields
):
report.add(
ValidationIssueCode.INVALID_NODE_INPUT_FIELD,
f"nodes[{index}].in_map[{source_path!r}]",
@@ -66,11 +65,10 @@ def validate_node_use(
)
for source_field, destination_path in node.out_map.items():
try:
source_root = split_local_path(source_field)[0]
except LocalPathError:
source_root = ""
if source_root not in output_fields:
source_root = _local_root(source_field)
if source_root is None or (
source_root != "." and source_root not in output_fields
):
report.add(
ValidationIssueCode.INVALID_NODE_OUTPUT_FIELD,
f"nodes[{index}].out_map[{source_field!r}]",
@@ -90,6 +88,14 @@ def validate_node_use(
)
def _local_root(path: str) -> str | None:
try:
parts = split_local_path(path)
except LocalPathError:
return None
return "." if not parts else parts[0]
def validate_condition_node(
node: ConditionNode,
index: int,
+18
View File
@@ -59,6 +59,24 @@ def test_builder_preserves_explicit_nested_node_local_maps() -> None:
assert step.out_map == {"payload.text": "state.text"}
def test_builder_preserves_explicit_root_node_local_maps() -> None:
builder = WorkflowBuilder(
name="root_local_maps",
input_schema=AutoBindInput,
state_schema=AutoBindState,
output_schema=AutoBindOutput,
)
step = builder.use(
auto_bind_node,
in_map={"state.text": "."},
out_map={".": "state.text"},
)
assert step.in_map == {"state.text": "."}
assert step.out_map == {".": "state.text"}
def test_builder_can_auto_id_node_uses_from_spec_name() -> None:
builder = WorkflowBuilder(
name="auto_id_demo",
+60 -1
View File
@@ -2,7 +2,15 @@ from __future__ import annotations
from pydantic import BaseModel, Field
from wf_authoring import NodeCatalog, NodeReturn, Nothing, build_registry, node, outcome
from wf_authoring import (
NoOutput,
NodeCatalog,
NodeReturn,
Nothing,
build_registry,
node,
outcome,
)
from wf_core import RuntimeContext
@@ -46,6 +54,10 @@ class DocumentedOutput(BaseModel):
echoed: str = Field(description="Echoed message")
class NoOutputInput(BaseModel):
value: str
@node()
def inferred_echo(
payload: InferredEchoInput,
@@ -137,6 +149,53 @@ def test_outcome_can_wrap_explicit_output() -> None:
assert result.output is output
def test_node_return_annotation_none_uses_nothing_output() -> None:
@node
def no_output(_: NoOutputInput) -> None:
return None
registry = build_registry(no_output)
result = registry["no_output"](
{"value": "hello"},
RuntimeContext(current_node_id="x"),
)
assert no_output.output_model is Nothing
assert result == {"outcome": "ok", "output": {}}
def test_bare_nodereturn_annotation_defaults_to_nothing_output() -> None:
@node(outcomes=("skip",))
def no_output_with_outcome(
_: NoOutputInput,
) -> NodeReturn: # pyright: ignore[reportMissingTypeArgument]
return outcome("skip")
registry = build_registry(no_output_with_outcome)
result = registry["no_output_with_outcome"](
{"value": "hello"},
RuntimeContext(current_node_id="x"),
)
assert no_output_with_outcome.output_model is Nothing
assert result == {"outcome": "skip", "output": {}}
def test_no_output_alias_annotates_outcome_only_nodes() -> None:
@node(outcomes=("skip",))
def no_output_alias(_: NoOutputInput) -> NoOutput:
return outcome("skip")
registry = build_registry(no_output_alias)
result = registry["no_output_alias"](
{"value": "hello"},
RuntimeContext(current_node_id="x"),
)
assert no_output_alias.output_model is Nothing
assert result == {"outcome": "skip", "output": {}}
def test_node_decorator_infers_models_from_annotations() -> None:
assert inferred_echo.input_model is InferredEchoInput
assert inferred_echo.output_model is InferredEchoOutput
+1 -1
View File
@@ -122,7 +122,7 @@ def test_state_field_accepts_configured_reducer_reference() -> None:
),
] = 0
field = State.model_fields["total"].metadata[0]
field = State.model_fields["total"].metadata[0] # pylint: disable=unsubscriptable-object
assert field.reducer.name == "wf.std.modulo_add"
assert field.reducer.config["modulus"] == 10
+53
View File
@@ -60,6 +60,59 @@ def test_missing_nested_node_output_path_fails() -> None:
)
def test_root_node_local_paths_map_whole_input_and_output_payloads() -> None:
workflow = Workflow(
name="root_mapping",
input_schema=SchemaRef.model_validate(
{
"type": "object",
"properties": {"rates": {"type": "object"}},
}
),
state_schema=StateSchema(fields={"rates": StateField(type="object")}),
output_schema=SchemaRef(type="object", properties={}),
node_defs=[
NodeDef(
name="force_rates",
input_schema=SchemaRef(type="object", properties={}),
output_schema=SchemaRef(
type="object",
properties={
"r_1": {"type": "number"},
"r_10": {"type": "number"},
},
),
outcomes=["ok"],
)
],
start="force",
nodes=[
NodeUse(
id="force",
type="node",
node="force_rates",
in_map={"input.rates": "."},
out_map={".": "state.rates"},
)
],
edges=[Edge.model_validate({"from": "force", "outcome": "ok", "to": END})],
)
run = execute_workflow(
workflow,
{"rates": {"r_1": 0.9, "r_10": 0.1}},
{
"force_rates": lambda payload, _ctx: {
"outcome": "ok",
"output": {"r_1": 0.0, "r_10": payload["r_10"]},
}
},
)
assert run.trace[0].resolved_input == {"r_1": 0.9, "r_10": 0.1}
assert run.state["rates"] == {"r_1": 0.0, "r_10": 0.1}
def _nested_mapping_workflow() -> Workflow:
return Workflow(
name="nested_mapping",
+25 -19
View File
@@ -1,6 +1,7 @@
import random
from typing import Final
from tests.rewrite.models import (
ContextInput,
Countdown,
@@ -15,7 +16,7 @@ from tests.rewrite.models import (
how_do_i_explain_this,
)
from wf_authoring import NodeReturn, node
from wf_authoring.nodes.result import Nothing, outcome
from wf_authoring.nodes.result import NoOutput, Nothing, outcome
from wf_core.tokens import END
# to the functions
@@ -24,9 +25,9 @@ from wf_core.tokens import END
@node
def init(
inp: Input,
_inp: Input,
# ) -> NodeReturn[Input | Nothing]: # JUST doesnt work if the types are above
) -> Input:
) -> None:
# why cant use basemodel? should we convert typeddicts to basemodels? Why cant use
"""i hope that this is done by default, because langgraph DOESNT. why.
@@ -41,11 +42,13 @@ def init(
# )
# if inp.pity_120_available and inp.context["type"] == "normal":
# "i doesnt care"
return inp
# return Nothing()
# return None
# return inp
@node(outcomes=("0", "65"))
def rate_booster(c: Counters) -> NodeReturn[Nothing]:
def rate_booster(c: Counters) -> NoOutput:
"""This was an edge.
Should I implement r65 here too... i think not.
@@ -80,7 +83,7 @@ class CountersContextOutputInputAhhModelType(
@node(outcomes=("240", "80", "10", "1"))
def pre_roll_router(c: CountersContextOutputInputAhhModelType) -> NodeReturn[Nothing]:
def pre_roll_router(c: CountersContextOutputInputAhhModelType) -> NoOutput:
"""another edge.
the conditional router calculates which to reset to 0 (guarantee the rest)
@@ -152,12 +155,13 @@ class RateChange:
else:
r240 = 0
r80 = br["r_80"] * (1 + rpn)
r10 = br["r_10"] # use initial rates because i dont know how this works
r1 = 1 - r240 - r80 - r10
return Rates.model_validate(
{
"rates": {
"r_1": 1 - r240 - r80 - br["r_10"],
"r_10": br["r_10"],
"r_1": r1,
"r_10": r10,
"r_80": r80,
"r_240": r240,
}
@@ -173,24 +177,27 @@ class RateChange:
class CounterUp:
@node(name="counter 6* reset")
@staticmethod
def c80(c: Counters) -> Counters:
def c80(_: Nothing) -> Counters:
return Counters.model_validate(
{
"counter": {
"c_80": 0,
"c_10": 0,
},
"simple_counter": c.simple_counter,
"simple_counter": 0,
# this is influenced by the add reducer.
# its top level. it doesnt reset. its a miracle. i hate this.
}
)
@node(name="counter 5* reset")
@staticmethod
def c10(c: Counters) -> Counters:
c80 = c.counter["c_80"]
return Counters.model_validate(
{
"counter": {"c_10": 0, "c_80": c.counter["c_80"]}, # merge with or_!
"simple_counter": c.simple_counter,
"counter": {"c_10": 0, "c_80": c80}, # merge with or_!
"simple_counter": 0,
}
)
@@ -198,9 +205,10 @@ class CounterUp:
@staticmethod
def c1(state: Counters) -> Counters:
c = state.counter
c10, c80 = c["c_10"], c["c_80"]
return Counters(
simple_counter=state.simple_counter + 1,
counter={"c_10": (c["c_10"] + 1) % 10, "c_80": (c["c_80"] + 1) % 80},
simple_counter=1,
counter={"c_10": (c10 + 1) % 10, "c_80": (c80 + 1) % 80},
)
@@ -241,10 +249,8 @@ def roll(state: CurrentPools) -> ThisStorage:
@node(outcomes=("240", "80", "10", "1")) # missed this! good job.
def post_roll_router(
state: CurrentRoll,
) -> NodeReturn[
Nothing
]: # Literal["240", "80", "10", "1"] maybe you need to encode this in node returns. Literal of strings.
return NodeReturn(state.this["category"], Nothing())
) -> NoOutput: # Literal["240", "80", "10", "1"] maybe you need to encode this in node returns. Literal of strings.
return outcome(state.this["category"])
@node(name="main")
+19 -4
View File
@@ -3,6 +3,7 @@ from typing import Annotated, Literal, TypedDict
from pydantic import BaseModel, Field
from wf_authoring.schemas import state_field
from wf_core.models.reducers import ReducerRef
class SophisticatedRates(TypedDict):
@@ -17,8 +18,20 @@ class SophisticatedRates(TypedDict):
class SophisticatedCounter(TypedDict):
c_10: Annotated[int, state_field(reducer="wf.std.add")] # how do i convey "add" sublevel? can we have plugins for this? should we cover this; since langgraph doesnt.
c_80: Annotated[int, state_field(reducer="wf.std.add")]
c_10: Annotated[
int,
state_field(
reducer=ReducerRef(name="wf.std.modulo_add", config={"modulus": 10})
),
] # add!
c_80: Annotated[
int,
state_field(
reducer=ReducerRef(name="wf.std.modulo_add", config={"modulus": 80})
),
]
# all that reducerref and then nothing touches them. only way you do rn is
# putting in/out maps on graph.use, which is cool ig. i just am not using them.
class Counters(BaseModel):
@@ -27,7 +40,7 @@ class Counters(BaseModel):
default_factory=lambda: SophisticatedCounter(c_10=0, c_80=0)
) # or, that is because of langgraph limitation,
# id prefer counter.update with dict.update override (Overwrite, not like that ever worked)
simple_counter: int # add
simple_counter: Annotated[int, state_field(reducer="wf.std.add")] # add
class Countdown(BaseModel):
@@ -120,7 +133,7 @@ class CurrentRoll(BaseModel):
this: Entity
class PartialRates(SophisticatedRates, total=False):
class PartialRates(SophisticatedRates, TypedDict, total=False):
pass
@@ -146,6 +159,8 @@ class State(
"this forces basemodel, i used typeddict"
# this feels fragmented. it is fragmented
# countdown: int # input carries here, should input have a bound like all(attr(input) in attr(state))? how tf do i even try to type that
# simple_counter: int # how to signal that ts adds up? we have that.
# counter: SophisticatedCounter
+16 -5
View File
@@ -6,7 +6,6 @@ Constraints:
"""
from itertools import islice
import json
from pprint import pprint
from typing import Any
@@ -64,19 +63,31 @@ def test():
d = gacha.execute(
build_input(context)(
20, # lets be optimistic
rolled_previously=240 - 135,
until_5=5,
until_6=73,
rolled_previously=240 - 135, # 15
until_5=5, # 5
until_6=73, # 7
good_stuff=False, # lets pretend
).model_dump()
)
assert d.status == RunStatus.COMPLETED, "oops"
state = State.model_validate(d.state)
pprint(state.storage)
pprint(
[
t
for t in d.trace
if t.node_id
in (
"counter_up",
"tick",
)
]
)
pprint(d.state)
assert any(
i["name"] in context["pool"]["n_240"]
for i in islice(state.storage, 120 - (240 - 135))
), "pity logic failed"
pprint(state.storage)
# pprint(gacha.compile().edges)
# pprint(gacha.compile().nodes)
+4 -1
View File
@@ -1,3 +1,4 @@
from tests.authoring.test_reducers import modulo_add
from tests.rewrite.actions import (
CounterUp,
RateChange,
@@ -12,6 +13,7 @@ from tests.rewrite.actions import (
from tests.rewrite.models import Input, State, Storage
from wf_authoring.builder import WorkflowBuilder
from wf_authoring.dsl.conditions import expr, state
from wf_authoring.reducers.catalog import ReducerCatalog
from wf_core.tokens import END
# alr so we start with workflowbuilder.
@@ -21,6 +23,7 @@ gacha = WorkflowBuilder(
input_schema=Input,
output_schema=Storage, # could be State, since the OG doesnt care, ill probably dump out the list.
state_schema=State,
reducers=ReducerCatalog.from_reducers(modulo_add),
)
"example workflow"
@@ -53,7 +56,7 @@ rate_route = gacha.route(
True: rate_up,
False: rate_same,
},
id = "rate_booster"
id="rate_booster",
)
gacha.use(pre_roll_router, id="router")
+5 -3
View File
@@ -111,15 +111,16 @@ def test_service_lists_all_capability_sources_with_owned_capability_names() -> N
std_source = sources_by_id["wf.std"]
assert "wf.std.runtime_error" in std_source["capabilities"]["node_specs"]
assert std_source["capabilities"]["reducers"] == [
assert set(std_source["capabilities"]["reducers"]) == {
"wf.std.append",
"wf.std.max",
"wf.std.merge_object",
"wf.std.replace",
"wf.std.set_union",
]
"wf.std.add",
}
assert std_source["capabilities"]["tools"] == []
assert std_source["reducer_count"] == 5
assert std_source["reducer_count"] == 6
mcp_source = sources_by_id["wf.mcp"]
assert mcp_source["capabilities"]["node_specs"] == ["wf.mcp.call_tool"]
@@ -164,6 +165,7 @@ def test_wf_std_source_contains_builtin_reducers() -> None:
"wf.std.max",
"wf.std.merge_object",
"wf.std.set_union",
"wf.std.add",
}