misc changes: returns None, root as ., add
This commit is contained in:
@@ -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
|
runtime context. If behavior depends on workflow context, that is business logic
|
||||||
and belongs in nodes or graph structure.
|
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:
|
Examples a future reducer library could support:
|
||||||
|
|
||||||
- `max`
|
- `max`
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ from .ops import (
|
|||||||
)
|
)
|
||||||
from .nodes import (
|
from .nodes import (
|
||||||
AsyncRegistryHandler,
|
AsyncRegistryHandler,
|
||||||
|
NoOutput,
|
||||||
NodeReturn,
|
NodeReturn,
|
||||||
NodeSpec,
|
NodeSpec,
|
||||||
Nothing,
|
Nothing,
|
||||||
@@ -82,6 +83,7 @@ __all__ = [
|
|||||||
"ReducerCatalog",
|
"ReducerCatalog",
|
||||||
"RenameFieldsInput",
|
"RenameFieldsInput",
|
||||||
"RuntimeErrorInput",
|
"RuntimeErrorInput",
|
||||||
|
"NoOutput",
|
||||||
"NodeReturn",
|
"NodeReturn",
|
||||||
"NodeSpec",
|
"NodeSpec",
|
||||||
"Nothing",
|
"Nothing",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from .callables import (
|
|||||||
from .inference import accepts_context, infer_models, is_basemodel_subclass
|
from .inference import accepts_context, infer_models, is_basemodel_subclass
|
||||||
from .decorator import node
|
from .decorator import node
|
||||||
from .registry import build_async_registry, build_registry
|
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 .schema import schema_ref_for
|
||||||
from .spec import NodeSpec
|
from .spec import NodeSpec
|
||||||
|
|
||||||
@@ -25,6 +25,7 @@ __all__ = [
|
|||||||
"ContextNodeCallable",
|
"ContextNodeCallable",
|
||||||
"InputT",
|
"InputT",
|
||||||
"NodeCallable",
|
"NodeCallable",
|
||||||
|
"NoOutput",
|
||||||
"NodeReturn",
|
"NodeReturn",
|
||||||
"NodeSpec",
|
"NodeSpec",
|
||||||
"Nothing",
|
"Nothing",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class ContextNodeCallable(Protocol[InputT_contra, OutputT_co]):
|
|||||||
payload: InputT_contra,
|
payload: InputT_contra,
|
||||||
/,
|
/,
|
||||||
ctx: RuntimeContext,
|
ctx: RuntimeContext,
|
||||||
) -> NodeReturn[OutputT_co] | OutputT_co: ...
|
) -> NodeReturn[OutputT_co] | OutputT_co | None: ...
|
||||||
|
|
||||||
|
|
||||||
class PlainNodeCallable(Protocol[InputT_contra, OutputT_co]):
|
class PlainNodeCallable(Protocol[InputT_contra, OutputT_co]):
|
||||||
@@ -30,7 +30,7 @@ class PlainNodeCallable(Protocol[InputT_contra, OutputT_co]):
|
|||||||
self,
|
self,
|
||||||
payload: InputT_contra,
|
payload: InputT_contra,
|
||||||
/,
|
/,
|
||||||
) -> NodeReturn[OutputT_co] | OutputT_co: ...
|
) -> NodeReturn[OutputT_co] | OutputT_co | None: ...
|
||||||
|
|
||||||
|
|
||||||
NodeCallable = ContextNodeCallable[InputT, OutputT] | PlainNodeCallable[InputT, OutputT]
|
NodeCallable = ContextNodeCallable[InputT, OutputT] | PlainNodeCallable[InputT, OutputT]
|
||||||
@@ -42,7 +42,7 @@ class AsyncContextNodeCallable(Protocol[InputT_contra, OutputT_co]):
|
|||||||
payload: InputT_contra,
|
payload: InputT_contra,
|
||||||
/,
|
/,
|
||||||
ctx: RuntimeContext,
|
ctx: RuntimeContext,
|
||||||
) -> Awaitable[NodeReturn[OutputT_co] | OutputT_co]: ...
|
) -> Awaitable[NodeReturn[OutputT_co] | OutputT_co | None]: ...
|
||||||
|
|
||||||
|
|
||||||
class AsyncPlainNodeCallable(Protocol[InputT_contra, OutputT_co]):
|
class AsyncPlainNodeCallable(Protocol[InputT_contra, OutputT_co]):
|
||||||
@@ -50,7 +50,7 @@ class AsyncPlainNodeCallable(Protocol[InputT_contra, OutputT_co]):
|
|||||||
self,
|
self,
|
||||||
payload: InputT_contra,
|
payload: InputT_contra,
|
||||||
/,
|
/,
|
||||||
) -> Awaitable[NodeReturn[OutputT_co] | OutputT_co]: ...
|
) -> Awaitable[NodeReturn[OutputT_co] | OutputT_co | None]: ...
|
||||||
|
|
||||||
|
|
||||||
AsyncNodeCallable = (
|
AsyncNodeCallable = (
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ from typing import Any, cast, overload
|
|||||||
|
|
||||||
from pydantic import BaseModel
|
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 .inference import accepts_context, infer_models
|
||||||
from .spec import NodeSpec
|
from .spec import NodeSpec
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from wf_core import RuntimeContext
|
from wf_core import RuntimeContext
|
||||||
|
|
||||||
from .result import NodeReturn
|
from .result import NodeReturn, Nothing
|
||||||
|
|
||||||
|
|
||||||
def is_basemodel_subclass(value: object) -> bool:
|
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")
|
return_type = hints.get("return")
|
||||||
if return_type is None:
|
if return_type is None:
|
||||||
raise TypeError("node function must declare a return annotation")
|
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):
|
if is_basemodel_subclass(return_type):
|
||||||
return cast(type[BaseModel], input_model), cast(type[BaseModel], 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)
|
origin = get_origin(return_type)
|
||||||
if origin is NodeReturn:
|
if origin is NodeReturn:
|
||||||
args = get_args(return_type)
|
args = get_args(return_type)
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ class Nothing(BaseModel):
|
|||||||
"""Empty output model for nodes that only choose an outcome."""
|
"""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
|
@overload
|
||||||
def outcome(name: str) -> NodeReturn[Nothing]: ...
|
def outcome(name: str) -> NodeReturn[Nothing]: ...
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from .callables import (
|
|||||||
PlainNodeCallable,
|
PlainNodeCallable,
|
||||||
SyncRegistryHandler,
|
SyncRegistryHandler,
|
||||||
)
|
)
|
||||||
from .result import NodeReturn
|
from .result import NodeReturn, Nothing
|
||||||
from .schema import schema_ref_for
|
from .schema import schema_ref_for
|
||||||
|
|
||||||
|
|
||||||
@@ -33,6 +33,8 @@ def _coerce_registry_result(
|
|||||||
default_outcome: str,
|
default_outcome: str,
|
||||||
raw: NodeReturn[BaseModel] | BaseModel,
|
raw: NodeReturn[BaseModel] | BaseModel,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
if raw is None and output_model is Nothing:
|
||||||
|
return {"outcome": default_outcome, "output": {}}
|
||||||
if isinstance(raw, NodeReturn):
|
if isinstance(raw, NodeReturn):
|
||||||
if not isinstance(raw.output, output_model):
|
if not isinstance(raw.output, output_model):
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
@@ -69,7 +71,12 @@ class NodeSpec(Generic[InputT, OutputT]):
|
|||||||
self,
|
self,
|
||||||
payload: InputT,
|
payload: InputT,
|
||||||
ctx: RuntimeContext | None = None,
|
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 self.accepts_context:
|
||||||
if ctx is None:
|
if ctx is None:
|
||||||
raise TypeError(f"node {self.name!r} requires RuntimeContext")
|
raise TypeError(f"node {self.name!r} requires RuntimeContext")
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ class LocalPathError(ValueError):
|
|||||||
|
|
||||||
def split_local_path(path: str) -> list[str]:
|
def split_local_path(path: str) -> list[str]:
|
||||||
"""Split one dotted node-local path, rejecting empty segments."""
|
"""Split one dotted node-local path, rejecting empty segments."""
|
||||||
|
if path == ".":
|
||||||
|
return []
|
||||||
parts = path.split(".")
|
parts = path.split(".")
|
||||||
if not path or any(not part for part in parts):
|
if not path or any(not part for part in parts):
|
||||||
raise LocalPathError(f"invalid local path {path!r}")
|
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:
|
def get_local_value(payload: Mapping[str, Any], path: str) -> Any:
|
||||||
"""Resolve one node-local path from a nested mapping payload."""
|
"""Resolve one node-local path from a nested mapping payload."""
|
||||||
|
if path == ".":
|
||||||
|
return dict(payload)
|
||||||
current: Any = payload
|
current: Any = payload
|
||||||
for part in split_local_path(path):
|
for part in split_local_path(path):
|
||||||
if not isinstance(current, Mapping) or part not in current:
|
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:
|
def set_local_value(payload: dict[str, Any], path: str, value: Any) -> None:
|
||||||
"""Write one value into a nested node-local mapping payload."""
|
"""Write one value into a nested node-local mapping payload."""
|
||||||
parts = split_local_path(path)
|
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
|
current = payload
|
||||||
for part in parts[:-1]:
|
for part in parts[:-1]:
|
||||||
next_value = current.setdefault(part, {})
|
next_value = current.setdefault(part, {})
|
||||||
|
|||||||
@@ -39,11 +39,10 @@ def validate_node_use(
|
|||||||
input_root_fields = set(workflow.input_schema.properties)
|
input_root_fields = set(workflow.input_schema.properties)
|
||||||
|
|
||||||
for source_path, destination_field in node.in_map.items():
|
for source_path, destination_field in node.in_map.items():
|
||||||
try:
|
destination_root = _local_root(destination_field)
|
||||||
destination_root = split_local_path(destination_field)[0]
|
if destination_root is None or (
|
||||||
except LocalPathError:
|
destination_root != "." and destination_root not in input_fields
|
||||||
destination_root = ""
|
):
|
||||||
if destination_root not in input_fields:
|
|
||||||
report.add(
|
report.add(
|
||||||
ValidationIssueCode.INVALID_NODE_INPUT_FIELD,
|
ValidationIssueCode.INVALID_NODE_INPUT_FIELD,
|
||||||
f"nodes[{index}].in_map[{source_path!r}]",
|
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():
|
for source_field, destination_path in node.out_map.items():
|
||||||
try:
|
source_root = _local_root(source_field)
|
||||||
source_root = split_local_path(source_field)[0]
|
if source_root is None or (
|
||||||
except LocalPathError:
|
source_root != "." and source_root not in output_fields
|
||||||
source_root = ""
|
):
|
||||||
if source_root not in output_fields:
|
|
||||||
report.add(
|
report.add(
|
||||||
ValidationIssueCode.INVALID_NODE_OUTPUT_FIELD,
|
ValidationIssueCode.INVALID_NODE_OUTPUT_FIELD,
|
||||||
f"nodes[{index}].out_map[{source_field!r}]",
|
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(
|
def validate_condition_node(
|
||||||
node: ConditionNode,
|
node: ConditionNode,
|
||||||
index: int,
|
index: int,
|
||||||
|
|||||||
@@ -59,6 +59,24 @@ def test_builder_preserves_explicit_nested_node_local_maps() -> None:
|
|||||||
assert step.out_map == {"payload.text": "state.text"}
|
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:
|
def test_builder_can_auto_id_node_uses_from_spec_name() -> None:
|
||||||
builder = WorkflowBuilder(
|
builder = WorkflowBuilder(
|
||||||
name="auto_id_demo",
|
name="auto_id_demo",
|
||||||
|
|||||||
@@ -2,7 +2,15 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field
|
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
|
from wf_core import RuntimeContext
|
||||||
|
|
||||||
|
|
||||||
@@ -46,6 +54,10 @@ class DocumentedOutput(BaseModel):
|
|||||||
echoed: str = Field(description="Echoed message")
|
echoed: str = Field(description="Echoed message")
|
||||||
|
|
||||||
|
|
||||||
|
class NoOutputInput(BaseModel):
|
||||||
|
value: str
|
||||||
|
|
||||||
|
|
||||||
@node()
|
@node()
|
||||||
def inferred_echo(
|
def inferred_echo(
|
||||||
payload: InferredEchoInput,
|
payload: InferredEchoInput,
|
||||||
@@ -137,6 +149,53 @@ def test_outcome_can_wrap_explicit_output() -> None:
|
|||||||
assert result.output is output
|
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:
|
def test_node_decorator_infers_models_from_annotations() -> None:
|
||||||
assert inferred_echo.input_model is InferredEchoInput
|
assert inferred_echo.input_model is InferredEchoInput
|
||||||
assert inferred_echo.output_model is InferredEchoOutput
|
assert inferred_echo.output_model is InferredEchoOutput
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ def test_state_field_accepts_configured_reducer_reference() -> None:
|
|||||||
),
|
),
|
||||||
] = 0
|
] = 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.name == "wf.std.modulo_add"
|
||||||
assert field.reducer.config["modulus"] == 10
|
assert field.reducer.config["modulus"] == 10
|
||||||
|
|||||||
@@ -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:
|
def _nested_mapping_workflow() -> Workflow:
|
||||||
return Workflow(
|
return Workflow(
|
||||||
name="nested_mapping",
|
name="nested_mapping",
|
||||||
|
|||||||
+25
-19
@@ -1,6 +1,7 @@
|
|||||||
import random
|
import random
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
|
|
||||||
from tests.rewrite.models import (
|
from tests.rewrite.models import (
|
||||||
ContextInput,
|
ContextInput,
|
||||||
Countdown,
|
Countdown,
|
||||||
@@ -15,7 +16,7 @@ from tests.rewrite.models import (
|
|||||||
how_do_i_explain_this,
|
how_do_i_explain_this,
|
||||||
)
|
)
|
||||||
from wf_authoring import NodeReturn, node
|
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
|
from wf_core.tokens import END
|
||||||
|
|
||||||
# to the functions
|
# to the functions
|
||||||
@@ -24,9 +25,9 @@ from wf_core.tokens import END
|
|||||||
|
|
||||||
@node
|
@node
|
||||||
def init(
|
def init(
|
||||||
inp: Input,
|
_inp: Input,
|
||||||
# ) -> NodeReturn[Input | Nothing]: # JUST doesnt work if the types are above
|
# ) -> 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
|
# 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.
|
"""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":
|
# if inp.pity_120_available and inp.context["type"] == "normal":
|
||||||
# "i doesnt care"
|
# "i doesnt care"
|
||||||
return inp
|
# return Nothing()
|
||||||
|
# return None
|
||||||
|
# return inp
|
||||||
|
|
||||||
|
|
||||||
@node(outcomes=("0", "65"))
|
@node(outcomes=("0", "65"))
|
||||||
def rate_booster(c: Counters) -> NodeReturn[Nothing]:
|
def rate_booster(c: Counters) -> NoOutput:
|
||||||
"""This was an edge.
|
"""This was an edge.
|
||||||
|
|
||||||
Should I implement r65 here too... i think not.
|
Should I implement r65 here too... i think not.
|
||||||
@@ -80,7 +83,7 @@ class CountersContextOutputInputAhhModelType(
|
|||||||
|
|
||||||
|
|
||||||
@node(outcomes=("240", "80", "10", "1"))
|
@node(outcomes=("240", "80", "10", "1"))
|
||||||
def pre_roll_router(c: CountersContextOutputInputAhhModelType) -> NodeReturn[Nothing]:
|
def pre_roll_router(c: CountersContextOutputInputAhhModelType) -> NoOutput:
|
||||||
"""another edge.
|
"""another edge.
|
||||||
|
|
||||||
the conditional router calculates which to reset to 0 (guarantee the rest)
|
the conditional router calculates which to reset to 0 (guarantee the rest)
|
||||||
@@ -152,12 +155,13 @@ class RateChange:
|
|||||||
else:
|
else:
|
||||||
r240 = 0
|
r240 = 0
|
||||||
r80 = br["r_80"] * (1 + rpn)
|
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(
|
return Rates.model_validate(
|
||||||
{
|
{
|
||||||
"rates": {
|
"rates": {
|
||||||
"r_1": 1 - r240 - r80 - br["r_10"],
|
"r_1": r1,
|
||||||
"r_10": br["r_10"],
|
"r_10": r10,
|
||||||
"r_80": r80,
|
"r_80": r80,
|
||||||
"r_240": r240,
|
"r_240": r240,
|
||||||
}
|
}
|
||||||
@@ -173,24 +177,27 @@ class RateChange:
|
|||||||
class CounterUp:
|
class CounterUp:
|
||||||
@node(name="counter 6* reset")
|
@node(name="counter 6* reset")
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def c80(c: Counters) -> Counters:
|
def c80(_: Nothing) -> Counters:
|
||||||
return Counters.model_validate(
|
return Counters.model_validate(
|
||||||
{
|
{
|
||||||
"counter": {
|
"counter": {
|
||||||
"c_80": 0,
|
"c_80": 0,
|
||||||
"c_10": 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")
|
@node(name="counter 5* reset")
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def c10(c: Counters) -> Counters:
|
def c10(c: Counters) -> Counters:
|
||||||
|
c80 = c.counter["c_80"]
|
||||||
return Counters.model_validate(
|
return Counters.model_validate(
|
||||||
{
|
{
|
||||||
"counter": {"c_10": 0, "c_80": c.counter["c_80"]}, # merge with or_!
|
"counter": {"c_10": 0, "c_80": c80}, # merge with or_!
|
||||||
"simple_counter": c.simple_counter,
|
"simple_counter": 0,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -198,9 +205,10 @@ class CounterUp:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def c1(state: Counters) -> Counters:
|
def c1(state: Counters) -> Counters:
|
||||||
c = state.counter
|
c = state.counter
|
||||||
|
c10, c80 = c["c_10"], c["c_80"]
|
||||||
return Counters(
|
return Counters(
|
||||||
simple_counter=state.simple_counter + 1,
|
simple_counter=1,
|
||||||
counter={"c_10": (c["c_10"] + 1) % 10, "c_80": (c["c_80"] + 1) % 80},
|
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.
|
@node(outcomes=("240", "80", "10", "1")) # missed this! good job.
|
||||||
def post_roll_router(
|
def post_roll_router(
|
||||||
state: CurrentRoll,
|
state: CurrentRoll,
|
||||||
) -> NodeReturn[
|
) -> NoOutput: # Literal["240", "80", "10", "1"] maybe you need to encode this in node returns. Literal of strings.
|
||||||
Nothing
|
return outcome(state.this["category"])
|
||||||
]: # Literal["240", "80", "10", "1"] maybe you need to encode this in node returns. Literal of strings.
|
|
||||||
return NodeReturn(state.this["category"], Nothing())
|
|
||||||
|
|
||||||
|
|
||||||
@node(name="main")
|
@node(name="main")
|
||||||
|
|||||||
+19
-4
@@ -3,6 +3,7 @@ from typing import Annotated, Literal, TypedDict
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from wf_authoring.schemas import state_field
|
from wf_authoring.schemas import state_field
|
||||||
|
from wf_core.models.reducers import ReducerRef
|
||||||
|
|
||||||
|
|
||||||
class SophisticatedRates(TypedDict):
|
class SophisticatedRates(TypedDict):
|
||||||
@@ -17,8 +18,20 @@ class SophisticatedRates(TypedDict):
|
|||||||
|
|
||||||
|
|
||||||
class SophisticatedCounter(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_10: Annotated[
|
||||||
c_80: Annotated[int, state_field(reducer="wf.std.add")]
|
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):
|
class Counters(BaseModel):
|
||||||
@@ -27,7 +40,7 @@ class Counters(BaseModel):
|
|||||||
default_factory=lambda: SophisticatedCounter(c_10=0, c_80=0)
|
default_factory=lambda: SophisticatedCounter(c_10=0, c_80=0)
|
||||||
) # or, that is because of langgraph limitation,
|
) # or, that is because of langgraph limitation,
|
||||||
# id prefer counter.update with dict.update override (Overwrite, not like that ever worked)
|
# 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):
|
class Countdown(BaseModel):
|
||||||
@@ -120,7 +133,7 @@ class CurrentRoll(BaseModel):
|
|||||||
this: Entity
|
this: Entity
|
||||||
|
|
||||||
|
|
||||||
class PartialRates(SophisticatedRates, total=False):
|
class PartialRates(SophisticatedRates, TypedDict, total=False):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -146,6 +159,8 @@ class State(
|
|||||||
"this forces basemodel, i used typeddict"
|
"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
|
# 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.
|
# simple_counter: int # how to signal that ts adds up? we have that.
|
||||||
# counter: SophisticatedCounter
|
# counter: SophisticatedCounter
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ Constraints:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from itertools import islice
|
from itertools import islice
|
||||||
import json
|
|
||||||
from pprint import pprint
|
from pprint import pprint
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -64,19 +63,31 @@ def test():
|
|||||||
d = gacha.execute(
|
d = gacha.execute(
|
||||||
build_input(context)(
|
build_input(context)(
|
||||||
20, # lets be optimistic
|
20, # lets be optimistic
|
||||||
rolled_previously=240 - 135,
|
rolled_previously=240 - 135, # 15
|
||||||
until_5=5,
|
until_5=5, # 5
|
||||||
until_6=73,
|
until_6=73, # 7
|
||||||
good_stuff=False, # lets pretend
|
good_stuff=False, # lets pretend
|
||||||
).model_dump()
|
).model_dump()
|
||||||
)
|
)
|
||||||
assert d.status == RunStatus.COMPLETED, "oops"
|
assert d.status == RunStatus.COMPLETED, "oops"
|
||||||
state = State.model_validate(d.state)
|
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(
|
assert any(
|
||||||
i["name"] in context["pool"]["n_240"]
|
i["name"] in context["pool"]["n_240"]
|
||||||
for i in islice(state.storage, 120 - (240 - 135))
|
for i in islice(state.storage, 120 - (240 - 135))
|
||||||
), "pity logic failed"
|
), "pity logic failed"
|
||||||
pprint(state.storage)
|
|
||||||
# pprint(gacha.compile().edges)
|
# pprint(gacha.compile().edges)
|
||||||
# pprint(gacha.compile().nodes)
|
# pprint(gacha.compile().nodes)
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from tests.authoring.test_reducers import modulo_add
|
||||||
from tests.rewrite.actions import (
|
from tests.rewrite.actions import (
|
||||||
CounterUp,
|
CounterUp,
|
||||||
RateChange,
|
RateChange,
|
||||||
@@ -12,6 +13,7 @@ from tests.rewrite.actions import (
|
|||||||
from tests.rewrite.models import Input, State, Storage
|
from tests.rewrite.models import Input, State, Storage
|
||||||
from wf_authoring.builder import WorkflowBuilder
|
from wf_authoring.builder import WorkflowBuilder
|
||||||
from wf_authoring.dsl.conditions import expr, state
|
from wf_authoring.dsl.conditions import expr, state
|
||||||
|
from wf_authoring.reducers.catalog import ReducerCatalog
|
||||||
from wf_core.tokens import END
|
from wf_core.tokens import END
|
||||||
|
|
||||||
# alr so we start with workflowbuilder.
|
# alr so we start with workflowbuilder.
|
||||||
@@ -21,6 +23,7 @@ gacha = WorkflowBuilder(
|
|||||||
input_schema=Input,
|
input_schema=Input,
|
||||||
output_schema=Storage, # could be State, since the OG doesnt care, ill probably dump out the list.
|
output_schema=Storage, # could be State, since the OG doesnt care, ill probably dump out the list.
|
||||||
state_schema=State,
|
state_schema=State,
|
||||||
|
reducers=ReducerCatalog.from_reducers(modulo_add),
|
||||||
)
|
)
|
||||||
"example workflow"
|
"example workflow"
|
||||||
|
|
||||||
@@ -53,7 +56,7 @@ rate_route = gacha.route(
|
|||||||
True: rate_up,
|
True: rate_up,
|
||||||
False: rate_same,
|
False: rate_same,
|
||||||
},
|
},
|
||||||
id = "rate_booster"
|
id="rate_booster",
|
||||||
)
|
)
|
||||||
|
|
||||||
gacha.use(pre_roll_router, id="router")
|
gacha.use(pre_roll_router, id="router")
|
||||||
|
|||||||
@@ -111,15 +111,16 @@ def test_service_lists_all_capability_sources_with_owned_capability_names() -> N
|
|||||||
|
|
||||||
std_source = sources_by_id["wf.std"]
|
std_source = sources_by_id["wf.std"]
|
||||||
assert "wf.std.runtime_error" in std_source["capabilities"]["node_specs"]
|
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.append",
|
||||||
"wf.std.max",
|
"wf.std.max",
|
||||||
"wf.std.merge_object",
|
"wf.std.merge_object",
|
||||||
"wf.std.replace",
|
"wf.std.replace",
|
||||||
"wf.std.set_union",
|
"wf.std.set_union",
|
||||||
]
|
"wf.std.add",
|
||||||
|
}
|
||||||
assert std_source["capabilities"]["tools"] == []
|
assert std_source["capabilities"]["tools"] == []
|
||||||
assert std_source["reducer_count"] == 5
|
assert std_source["reducer_count"] == 6
|
||||||
|
|
||||||
mcp_source = sources_by_id["wf.mcp"]
|
mcp_source = sources_by_id["wf.mcp"]
|
||||||
assert mcp_source["capabilities"]["node_specs"] == ["wf.mcp.call_tool"]
|
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.max",
|
||||||
"wf.std.merge_object",
|
"wf.std.merge_object",
|
||||||
"wf.std.set_union",
|
"wf.std.set_union",
|
||||||
|
"wf.std.add",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user