first reducer sweep of failure

i will put second reducer sweep of logic error or second of success
This commit is contained in:
lda
2026-05-17 19:10:21 +07:00 Verified
parent 4339b8aa74
commit 23dc684c23
20 changed files with 364 additions and 43 deletions
+2
View File
@@ -12,6 +12,7 @@ from .dsl import (
input,
input_path,
merge_maps,
not_,
state,
state_path,
)
@@ -119,6 +120,7 @@ __all__ = [
"rename_fields",
"runtime_error",
"node",
"not_",
"outcome",
"reducer",
"state",
+74 -9
View File
@@ -5,6 +5,7 @@ from dataclasses import dataclass, field
from typing import Any, Literal, cast
import warnings
from wf_authoring.ops.values import runtime_error
from wf_core import (
ConditionNode,
Edge,
@@ -19,10 +20,12 @@ from wf_core import (
)
from wf_core.errors import WorkflowExecutionError
from wf_core.models.conditions import Condition as CoreCondition
from wf_core.runtime.ops.merges import ReducerDefinition
from ..dsl import Expr, PathArg, PathExpr, compile_condition
from ..nodes.callables import SyncRegistryHandler
from ..nodes.registry import build_registry
from ..reducers import ReducerCatalog
from ..schemas import SchemaLike, StateSchemaLike, schema_ref_from, state_schema_from
from ..nodes import NodeSpec
from .ids import next_step_id, slug_id
@@ -43,6 +46,7 @@ class WorkflowBuilder:
state_schema: StateSchemaLike
output_schema: SchemaLike
start: str | None = None
reducers: ReducerCatalog | Mapping[str, ReducerDefinition] | None = None
node_specs: dict[str, NodeSpec[Any, Any]] = field(default_factory=dict)
nodes: list[Any] = field(default_factory=list)
edges: list[Edge] = field(default_factory=list)
@@ -100,6 +104,19 @@ class WorkflowBuilder:
"""Export handlers for all node specs used by this builder."""
return build_registry(*self.node_specs.values())
def reducer_registry(self) -> dict[str, ReducerDefinition]:
"""Export custom reducers attached to this builder.
The core runtime always falls back to built-in reducers. This method
returns only the authored additions/overrides so callers can pass the
same runtime environment that `execute()` uses.
"""
if self.reducers is None:
return {}
if isinstance(self.reducers, ReducerCatalog):
return dict(self.reducers.definitions)
return dict(self.reducers)
def execute(self, workflow_input: dict[str, Any]) -> RunState:
"""Compile and execute this workflow with its used node registry.
@@ -107,7 +124,12 @@ class WorkflowBuilder:
callers that need custom registries, persistence, or resume behavior should
call wf_core execution functions directly.
"""
return execute_workflow(self.compile(), workflow_input, self.registry())
return execute_workflow(
self.compile(),
workflow_input,
self.registry(),
reducers=self.reducer_registry(),
)
def condition(
self, *, id: str | None = None, check: CoreCondition | Expr
@@ -211,24 +233,43 @@ class WorkflowBuilder:
return resolved_targets
def route(
self,
value: PathExpr | Expr,
cases: Mapping[object, BranchRef],
*,
id: str | None = None,
default: BranchRef = runtime_error,
) -> dict[object, StepRef]:
"""Route graph data by equality checks or one boolean condition.
`branch()` wires outcomes already produced by a node. `route()` is the
companion for ordinary graph data: with a path, it compares that path
against case values; with a condition expression, it expects boolean
`True`/`False` cases and emits one condition node.
"""
if isinstance(value, Expr):
return self._route_condition(value, cases, id=id, default=default)
return self._route_value(value, cases, id=id, default=default)
def _route_value(
self,
value: PathExpr,
cases: Mapping[object, BranchRef],
*,
id: str | None,
default: BranchRef,
) -> dict[object, StepRef]:
"""Route graph data by equality checks compiled to condition nodes.
`branch()` wires outcomes already produced by a node. `route()` is the
companion for ordinary graph data: it compares one path against case
values, expands those checks into a condition chain, and wires the first
matching target plus a required fallback.
"""
"""Expand value cases into an ordered chain of equality checks."""
resolved_targets: dict[object, StepRef] = {}
default_target = self.use(default) if is_node_spec(default) else default
previous_condition: ConditionNode | None = None
condition_base = id or "condition"
for case_value, target in cases.items():
condition = self.condition(check=value.eq(case_value))
condition = self.condition(
id=self._next_step_id(condition_base),
check=value.eq(case_value),
)
resolved = self.use(target) if is_node_spec(target) else target
if previous_condition is not None:
self.connect(previous_condition, "false", condition)
@@ -241,6 +282,30 @@ class WorkflowBuilder:
resolved_targets["default"] = cast(StepRef, default_target)
return resolved_targets
def _route_condition(
self,
condition: Expr,
cases: Mapping[object, BranchRef],
*,
id: str | None,
default: BranchRef,
) -> dict[object, StepRef]:
"""Route a boolean condition expression through true/false outcomes."""
invalid_cases = [case for case in cases if not isinstance(case, bool)]
if invalid_cases:
raise ValueError("condition route cases must be boolean True/False keys")
if not cases:
raise ValueError("WorkflowBuilder.route requires at least one case")
condition_node = self.condition(id=id, check=condition)
resolved_targets: dict[object, StepRef] = {}
for case_value, outcome in ((True, "true"), (False, "false")):
target = cases.get(case_value, default)
resolved = self.use(target) if is_node_spec(target) else target
self.connect(condition_node, outcome, cast(StepRef, resolved))
resolved_targets[case_value] = cast(StepRef, resolved)
return resolved_targets
def compile(self) -> Workflow:
if self.start is None:
raise WorkflowExecutionError(
+2
View File
@@ -6,6 +6,7 @@ from .conditions import (
exists,
expr,
input,
not_,
state,
)
from .mapping import PathArg, bind_fields, bind_state, merge_maps, normalize_path
@@ -28,6 +29,7 @@ __all__ = [
"input_path",
"merge_maps",
"normalize_path",
"not_",
"state",
"state_path",
]
+20 -1
View File
@@ -55,7 +55,9 @@ class Expr:
class PathExpr:
path: str
def _binary(self, op: Literal["eq", "ne", "gt", "lt"], other: object) -> Expr:
def _binary(
self, op: Literal["eq", "ne", "gt", "ge", "lt", "le"], other: object
) -> Expr:
return Expr(
BinaryCondition(
op=op,
@@ -73,9 +75,15 @@ class PathExpr:
def gt(self, other: object) -> Expr:
return self._binary("gt", other)
def ge(self, other: object) -> Expr:
return self._binary("ge", other)
def lt(self, other: object) -> Expr:
return self._binary("lt", other)
def le(self, other: object) -> Expr:
return self._binary("le", other)
def __eq__(self, other: object) -> Expr: # pyright: ignore[reportIncompatibleMethodOverride] # type: ignore[override] # ty: ignore[invalid-method-override]
return self._binary("eq", other)
@@ -85,9 +93,15 @@ class PathExpr:
def __gt__(self, other: object) -> Expr:
return self.gt(other)
def __ge__(self, other: object) -> Expr:
return self.ge(other)
def __lt__(self, other: object) -> Expr:
return self.lt(other)
def __le__(self, other: object) -> Expr:
return self.le(other)
def expr(value: PathExpr | GraphPath) -> PathExpr:
if isinstance(value, PathExpr):
@@ -111,6 +125,11 @@ def exists(value: PathExpr | GraphPath) -> Expr:
return Expr(ExistsCondition(op="exists", path=_path_str(value)))
def not_(value: Condition | Expr) -> Expr:
"""Negate a condition without relying on Python's visually subtle `~expr`."""
return Expr(NotCondition(op="not", arg=compile_condition(value)))
def compile_condition(value: Condition | Expr) -> Condition:
if isinstance(value, Expr):
return value.to_condition()
+2 -5
View File
@@ -18,13 +18,10 @@ class ReducerCatalog:
def from_reducers(cls, *reducers: AuthoredReducer) -> "ReducerCatalog":
return cls(
definitions={
reducer.definition.spec.name: reducer.definition
for reducer in reducers
reducer.definition.spec.name: reducer.definition for reducer in reducers
}
)
@property
def specs(self) -> dict[str, ReducerSpec]:
return {
name: definition.spec for name, definition in self.definitions.items()
}
return {name: definition.spec for name, definition in self.definitions.items()}
+17 -7
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any, Iterator
from pydantic import BaseModel, TypeAdapter
@@ -9,23 +10,26 @@ from wf_core import ReducerRef, SchemaRef, StateField, StateSchema
SchemaLike = SchemaRef | type[BaseModel] | type[Any] | dict[str, Any]
StateSchemaLike = StateSchema | type[BaseModel] | type[Any] | dict[str, Any]
ReducerLike = str | ReducerRef | Mapping[str, Any]
@dataclass(frozen=True, slots=True)
class StateFieldMetadata:
"""Authoring metadata attached to BaseModel state fields."""
reducer: str = "wf.std.replace"
reducer: ReducerRef = field(
default_factory=lambda: ReducerRef(name="wf.std.replace")
)
trace: bool = True
def state_field(
*,
reducer: str = "wf.std.replace",
reducer: ReducerLike = "wf.std.replace",
trace: bool = True,
) -> StateFieldMetadata:
"""Declare workflow state behavior for an Annotated BaseModel field."""
return StateFieldMetadata(reducer=reducer, trace=trace)
return StateFieldMetadata(reducer=_reducer_ref_from(reducer), trace=trace)
def schema_ref_from(value: SchemaLike) -> SchemaRef:
@@ -51,9 +55,7 @@ def state_schema_from(value: StateSchemaLike) -> StateSchema:
fields = {
path: StateField(
type=_state_field_type(property_schema),
reducer=ReducerRef(
name=metadata_by_name.get(path, StateFieldMetadata()).reducer
),
reducer=metadata_by_name.get(path, StateFieldMetadata()).reducer,
trace=metadata_by_name.get(path, StateFieldMetadata()).trace,
default=_state_field_default(value, path, property_schema),
)
@@ -62,6 +64,14 @@ def state_schema_from(value: StateSchemaLike) -> StateSchema:
return StateSchema(fields=fields)
def _reducer_ref_from(value: ReducerLike) -> ReducerRef:
if isinstance(value, ReducerRef):
return value
if isinstance(value, str):
return ReducerRef(name=value)
return ReducerRef.model_validate(value)
def _state_metadata_by_name(value: object) -> dict[str, StateFieldMetadata]:
if not isinstance(value, type) or not issubclass(value, BaseModel):
return {}