reducer policy

This commit is contained in:
lda
2026-05-22 20:54:03 +07:00 Verified
parent 70f3b19bc5
commit adbad56c80
13 changed files with 166 additions and 31 deletions
+7 -1
View File
@@ -6,7 +6,7 @@ from typing import Any, TypeVar, cast, overload
from pydantic import BaseModel
from wf_core import ReducerSpec
from wf_core import ReducerSpec, SiblingWritePolicy
from wf_core.runtime.ops.merges import ReducerDefinition
from .callables import ConfigReducerCallable, ConfigT, PlainReducerCallable
@@ -28,6 +28,7 @@ def reducer(
*,
name: str | None = None,
description: str | None = None,
sibling_write_policy: SiblingWritePolicy = SiblingWritePolicy.MERGEABLE,
) -> AuthoredReducer: ...
@@ -36,6 +37,7 @@ def reducer(
*,
name: str,
description: str | None = None,
sibling_write_policy: SiblingWritePolicy = SiblingWritePolicy.MERGEABLE,
) -> Callable[[Callable[..., Any]], AuthoredReducer]: ...
@@ -45,6 +47,7 @@ def reducer(
name: str,
config_model: type[ConfigT],
description: str | None = None,
sibling_write_policy: SiblingWritePolicy = SiblingWritePolicy.MERGEABLE,
) -> Callable[[Callable[..., Any]], AuthoredReducer]: ...
@@ -55,6 +58,7 @@ def reducer(
name: str | None = None,
config_model: type[BaseModel] | None = None,
description: str | None = None,
sibling_write_policy: SiblingWritePolicy = SiblingWritePolicy.MERGEABLE,
) -> AuthoredReducer | Callable[[Callable[..., Any]], AuthoredReducer]:
"""Wrap a Python reducer function as a runtime reducer definition."""
@@ -67,6 +71,7 @@ def reducer(
spec=ReducerSpec(
name=reducer_name,
description=_clean_doc(reducer_description),
sibling_write_policy=sibling_write_policy,
),
fn=raw,
)
@@ -92,6 +97,7 @@ def reducer(
name=reducer_name,
description=_clean_doc(reducer_description),
config_schema=config_model.model_json_schema(),
sibling_write_policy=sibling_write_policy,
),
fn=runtime_fn,
accepts_config=True,
+2
View File
@@ -12,6 +12,7 @@ from .models import (
ReducerRef,
ReducerSpec,
SchemaRef,
SiblingWritePolicy,
StateField,
StateSchema,
Workflow,
@@ -60,6 +61,7 @@ __all__ = [
"ReducerRef",
"ReducerSpec",
"SchemaRef",
"SiblingWritePolicy",
"StateField",
"StateSchema",
"AsyncNodeHandler",
+2 -1
View File
@@ -9,7 +9,7 @@ from wf_core.models.conditions import (
VariadicCondition,
)
from wf_core.models.results import NodeResult
from wf_core.models.reducers import ReducerRef, ReducerSpec
from wf_core.models.reducers import ReducerRef, ReducerSpec, SiblingWritePolicy
from wf_core.models.schemas import NodeDef, SchemaRef, StateField, StateSchema
from wf_core.models.steps import (
ConditionNode,
@@ -44,6 +44,7 @@ __all__ = [
"Operand",
"PathOperand",
"SchemaRef",
"SiblingWritePolicy",
"StateField",
"StateSchema",
"Step",
+16
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from collections.abc import Mapping
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, model_validator
@@ -64,6 +65,13 @@ class ReducerRef(BaseModel):
return str(self.ref)
class SiblingWritePolicy(StrEnum):
"""Whether a reducer is safe for sibling foreach lineages at a barrier."""
EXCLUSIVE = "exclusive"
MERGEABLE = "mergeable"
class ReducerSpec(BaseModel):
"""Inspectable metadata for one named pure state reducer."""
@@ -78,3 +86,11 @@ class ReducerSpec(BaseModel):
"additionalProperties": False,
}
)
sibling_write_policy: SiblingWritePolicy = Field(
default=SiblingWritePolicy.MERGEABLE,
description=(
"Whether sibling foreach item lineages may write this state path "
"at one barrier. Exclusive reducers are valid for ordinary writes "
"but ambiguous for sibling barrier commits."
),
)
+30 -6
View File
@@ -5,7 +5,7 @@ from dataclasses import dataclass
from typing import Any, cast
from wf_core.errors import WorkflowExecutionError
from wf_core.models.reducers import ReducerRef, ReducerSpec
from wf_core.models.reducers import ReducerRef, ReducerSpec, SiblingWritePolicy
from wf_core.runtime.ops.schemas import validate_payload_against_schema
PlainReducer = Callable[[Any, Any], Any]
@@ -116,6 +116,7 @@ DEFAULT_REDUCER_DEFINITIONS: Mapping[str, ReducerDefinition] = {
spec=ReducerSpec(
name="wf.std.replace",
description="Replace the current state value with the incoming value.",
sibling_write_policy=SiblingWritePolicy.EXCLUSIVE,
),
fn=replace_reducer,
),
@@ -157,6 +158,33 @@ DEFAULT_REDUCER_DEFINITIONS: Mapping[str, ReducerDefinition] = {
}
def get_reducer_definition(
reducer: ReducerRef,
reducers: Mapping[str, ReducerDefinition] | None = None,
) -> ReducerDefinition:
"""Resolve a reducer from injected definitions plus built-ins."""
definition = None if reducers is None else reducers.get(reducer.name)
if definition is None:
definition = DEFAULT_REDUCER_DEFINITIONS.get(reducer.name)
if definition is None:
raise WorkflowExecutionError(f"unknown reducer {reducer.name!r}")
return definition
def reducer_allows_sibling_writes(
reducer: ReducerRef,
reducers: Mapping[str, ReducerDefinition] | None = None,
) -> bool:
"""Return whether a reducer can merge sibling foreach item writes.
Reducers remain pure value functions. Barrier conflict rules live here as
metadata so `replace` can be exclusive without teaching the reducer about
foreach, frames, or item ordering.
"""
definition = get_reducer_definition(reducer, reducers)
return definition.spec.sibling_write_policy is SiblingWritePolicy.MERGEABLE
def apply_reducer(
*,
reducer: ReducerRef,
@@ -171,11 +199,7 @@ def apply_reducer(
tests and local packages can provide custom reducers without re-registering
every `wf.std.*` reducer.
"""
definition = None if reducers is None else reducers.get(reducer.name)
if definition is None:
definition = DEFAULT_REDUCER_DEFINITIONS.get(reducer.name)
if definition is None:
raise WorkflowExecutionError(f"unknown reducer {reducer.name!r}")
definition = get_reducer_definition(reducer, reducers)
return definition.apply(
reducer=reducer,
current_value=current_value,
+13 -6
View File
@@ -19,7 +19,11 @@ from wf_core.paths import (
set_nested_value,
split_graph_path,
)
from wf_core.runtime.ops.merges import ReducerDefinition, apply_reducer
from wf_core.runtime.ops.merges import (
ReducerDefinition,
apply_reducer,
reducer_allows_sibling_writes,
)
from wf_core.runtime.ops.schemas import validate_payload_against_schema
_MISSING = object()
@@ -175,7 +179,7 @@ def build_barrier_patch(
values would hide what actually landed in `RunState.state`.
"""
state_fields = workflow.state_schema.field_index()
validate_barrier_writes(item_patches, state_fields)
validate_barrier_writes(item_patches, state_fields, reducers=reducers)
staged_state = deepcopy(state)
prepared_patch: dict[StatePath, tuple[list[str], Any]] = {}
committed_changes: dict[str, Any] = {}
@@ -204,6 +208,8 @@ def build_barrier_patch(
def validate_barrier_writes(
item_patches: Sequence[StatePatch],
state_fields: Mapping[StatePath, StateFieldDecl],
*,
reducers: Mapping[str, ReducerDefinition] | None = None,
) -> None:
"""Reject ambiguous sibling writes before replaying a foreach barrier.
@@ -216,11 +222,11 @@ def validate_barrier_writes(
if left.item_index == right.item_index:
continue
if left.path == right.path:
if _has_explicit_non_replace_reducer(left.path, state_fields):
if _allows_sibling_writes(left.path, state_fields, reducers):
continue
raise WorkflowExecutionError(
"multiple sibling writes to "
f"{left.source_key!r} require an explicit reducer"
f"{left.source_key!r} require a mergeable reducer"
)
if _state_paths_overlap(left.path, right.path):
raise WorkflowExecutionError(
@@ -244,14 +250,15 @@ def _barrier_writes(item_patches: Sequence[StatePatch]) -> list[_BarrierWrite]:
return writes
def _has_explicit_non_replace_reducer(
def _allows_sibling_writes(
path: StatePath,
state_fields: Mapping[StatePath, StateFieldDecl],
reducers: Mapping[str, ReducerDefinition] | None,
) -> bool:
field = state_fields.get(path)
if field is None or field.reducer is None:
return False
return field.reducer.name != "wf.std.replace"
return reducer_allows_sibling_writes(field.reducer, reducers)
def _state_paths_overlap(left: StatePath, right: StatePath) -> bool: