reducer path to strengthen too
This commit is contained in:
@@ -249,14 +249,17 @@ Reducers are a capability family, similar to reusable node specs:
|
||||
- dependency-trackable
|
||||
|
||||
State fields reference reducers declaratively. String reducer names are accepted
|
||||
as shorthand for unconfigured reducers; configured reducers use a `name` plus
|
||||
JSON-compatible `config`. Workflow artifacts do not embed arbitrary Python
|
||||
callables.
|
||||
as shorthand for unconfigured reducers; configured reducers use a structural
|
||||
`ref` plus JSON-compatible `config`. Workflow artifacts do not embed arbitrary
|
||||
Python callables.
|
||||
|
||||
```python
|
||||
StateField(
|
||||
type="integer",
|
||||
reducer={"name": "wf.std.modulo_add", "config": {"modulus": 10}},
|
||||
reducer={
|
||||
"ref": {"source": "wf.std", "capability_key": "modulo_add"},
|
||||
"config": {"modulus": 10},
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -159,16 +159,16 @@ being split again.
|
||||
Reducer refs are capability refs, not graph paths. `wf.std.add` is shorthand for
|
||||
source `wf.std` and capability key `add`.
|
||||
|
||||
The reducer cleanup should move `ReducerRef` toward structural `CapabilityRef`
|
||||
while keeping string reducer names as parse-only shorthand. Reducer config stays
|
||||
part of the reducer reference payload:
|
||||
Configured reducer refs now use a structural `CapabilityRef` while keeping
|
||||
string reducer names as parse-only shorthand and display keys. Reducer config
|
||||
stays part of the reducer reference payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "wf.std.modulo_add",
|
||||
"ref": {"source": "wf.std", "capability_key": "modulo_add"},
|
||||
"config": {"modulus": 10}
|
||||
}
|
||||
```
|
||||
|
||||
That future cleanup must not reuse graph path parsing rules. Reducer names live
|
||||
in the capability/source domain.
|
||||
That shape must not reuse graph path parsing rules. Reducer names live in the
|
||||
capability/source domain.
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from wf_authoring import WorkflowBuilder
|
||||
from wf_authoring.dsl import PathExpr
|
||||
from wf_core import JoinNode, Workflow
|
||||
from wf_core.paths import GraphSourcePath
|
||||
|
||||
from .models import (
|
||||
DraftChooseStep,
|
||||
@@ -81,7 +82,7 @@ def _add_step(builder: WorkflowBuilder, step_id: str, step: DraftStep):
|
||||
).entry
|
||||
if isinstance(step, DraftMatchStep):
|
||||
return builder.match(
|
||||
PathExpr(step.match.value),
|
||||
PathExpr(GraphSourcePath.parse(step.match.value)),
|
||||
{case.equals: case.then for case in step.match.cases},
|
||||
id=step_id,
|
||||
default=step.match.default,
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from collections.abc import Mapping
|
||||
|
||||
from wf_core import ReducerRef, Workflow
|
||||
from wf_platform import CapabilityRef, NodeSpecInventory
|
||||
from wf_platform import NodeSpecInventory
|
||||
|
||||
from .models import ArtifactKind, JsonObject, RequiredCapability, WorkflowArtifact
|
||||
from .references import normalize_plan_node_refs
|
||||
@@ -99,12 +99,8 @@ def _required_reducers_from_plan(plan: JsonObject) -> dict[str, RequiredCapabili
|
||||
reducer = ReducerRef.model_validate(reducer_payload)
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
reducer_ref = CapabilityRef.parse(reducer.name)
|
||||
except ValueError:
|
||||
continue
|
||||
requirements[reducer.name] = RequiredCapability(
|
||||
ref=reducer_ref,
|
||||
ref=reducer.ref,
|
||||
kind="reducer",
|
||||
)
|
||||
return requirements
|
||||
|
||||
@@ -1,16 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from wf_platform.refs import CapabilityRef
|
||||
|
||||
|
||||
class ReducerRef(BaseModel):
|
||||
"""Reference to one reducer plus JSON-compatible configuration."""
|
||||
"""Reference to one reducer capability plus JSON-compatible configuration."""
|
||||
|
||||
name: str
|
||||
ref: CapabilityRef
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
ref: CapabilityRef | str | Mapping[str, Any] | None = None,
|
||||
name: str | None = None,
|
||||
config: dict[str, Any] | None = None,
|
||||
**extra: object,
|
||||
) -> None:
|
||||
"""Accept legacy `name=` construction while storing canonical `ref`.
|
||||
|
||||
Existing authoring and runtime code still constructs reducers with
|
||||
`ReducerRef(name="wf.std.add")`. That remains source-compatible, but
|
||||
the model state is now the structural capability reference.
|
||||
"""
|
||||
payload: dict[str, object] = {"config": config or {}}
|
||||
if ref is not None:
|
||||
payload["ref"] = ref
|
||||
if name is not None:
|
||||
payload["name"] = name
|
||||
payload.update(extra)
|
||||
super().__init__(**payload)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _coerce_legacy_shapes(cls, value: object) -> object:
|
||||
"""Accept old reducer strings/`name` objects as parse-only shorthand."""
|
||||
if isinstance(value, str):
|
||||
return {"ref": CapabilityRef.parse(value)}
|
||||
if not isinstance(value, Mapping):
|
||||
return value
|
||||
data = dict(value)
|
||||
if "ref" not in data and "name" in data:
|
||||
data["ref"] = CapabilityRef.parse(str(data.pop("name")))
|
||||
return data
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Display/registry compatibility key for existing reducer catalogs."""
|
||||
return str(self.ref)
|
||||
|
||||
|
||||
class ReducerSpec(BaseModel):
|
||||
"""Inspectable metadata for one named pure state reducer."""
|
||||
|
||||
+59
-15
@@ -1,21 +1,47 @@
|
||||
from .docs import DocumentationPrompt, DocumentationResource, build_documentation_source
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .refs import CapabilityRef, SourceRef
|
||||
from .paging import Page, page_items
|
||||
from .schema_hashes import hash_json_schema
|
||||
from .sources import (
|
||||
CapabilityBuckets,
|
||||
CapabilitySource,
|
||||
NodeSpecInventory,
|
||||
ReducerInventory,
|
||||
SourceCapabilityInventory,
|
||||
SourceInventory,
|
||||
SourceKind,
|
||||
SourcePermissions,
|
||||
SourcePermissionsSnapshot,
|
||||
SourceStatus,
|
||||
SourceVisibility,
|
||||
SourceVisibilitySnapshot,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .docs import (
|
||||
DocumentationPrompt,
|
||||
DocumentationResource,
|
||||
build_documentation_source,
|
||||
)
|
||||
from .sources import (
|
||||
CapabilityBuckets,
|
||||
CapabilitySource,
|
||||
NodeSpecInventory,
|
||||
ReducerInventory,
|
||||
SourceCapabilityInventory,
|
||||
SourceInventory,
|
||||
SourceKind,
|
||||
SourcePermissions,
|
||||
SourcePermissionsSnapshot,
|
||||
SourceStatus,
|
||||
SourceVisibility,
|
||||
SourceVisibilitySnapshot,
|
||||
)
|
||||
|
||||
_LAZY_EXPORTS = {
|
||||
"CapabilityBuckets": ".sources",
|
||||
"CapabilitySource": ".sources",
|
||||
"DocumentationPrompt": ".docs",
|
||||
"DocumentationResource": ".docs",
|
||||
"NodeSpecInventory": ".sources",
|
||||
"ReducerInventory": ".sources",
|
||||
"SourceCapabilityInventory": ".sources",
|
||||
"SourceInventory": ".sources",
|
||||
"SourceKind": ".sources",
|
||||
"SourcePermissions": ".sources",
|
||||
"SourcePermissionsSnapshot": ".sources",
|
||||
"SourceStatus": ".sources",
|
||||
"SourceVisibility": ".sources",
|
||||
"SourceVisibilitySnapshot": ".sources",
|
||||
"build_documentation_source": ".docs",
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"CapabilityBuckets",
|
||||
@@ -39,3 +65,21 @@ __all__ = [
|
||||
"hash_json_schema",
|
||||
"page_items",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> object:
|
||||
"""Load platform inventory exports lazily to keep core-safe refs importable.
|
||||
|
||||
`wf_core` depends on `wf_platform.refs` for structural reducer references.
|
||||
Eagerly importing source inventory here would pull in authoring/core again
|
||||
and create an import cycle, so only foundational refs/helpers are eager.
|
||||
"""
|
||||
import importlib
|
||||
|
||||
module_name = _LAZY_EXPORTS.get(name)
|
||||
if module_name is None:
|
||||
raise AttributeError(f"module 'wf_platform' has no attribute {name!r}")
|
||||
module = importlib.import_module(module_name, __name__)
|
||||
value = getattr(module, name)
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
@@ -46,6 +46,12 @@ class SourceRef:
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return cls.parse(value)
|
||||
if isinstance(value, Mapping):
|
||||
parts = value.get("parts")
|
||||
if isinstance(parts, list | tuple) and all(
|
||||
isinstance(part, str) for part in parts
|
||||
):
|
||||
return cls(tuple(parts))
|
||||
raise TypeError("source ref must be a string")
|
||||
|
||||
|
||||
@@ -101,9 +107,9 @@ class CapabilityRef:
|
||||
return cls.parse(value)
|
||||
if isinstance(value, dict):
|
||||
source = value.get("source")
|
||||
name = value.get("capability_key")
|
||||
if isinstance(source, str) and isinstance(name, str):
|
||||
return cls(source=SourceRef.parse(source), name=name)
|
||||
name = value.get("capability_key", value.get("name"))
|
||||
if isinstance(name, str):
|
||||
return cls(source=SourceRef._validate(source), name=name)
|
||||
raise TypeError(
|
||||
"capability ref must be a string or {'source': str, 'capability_key': str}"
|
||||
)
|
||||
|
||||
@@ -50,6 +50,37 @@ def test_create_workflow_artifact_from_plan_adds_reducer_dependencies() -> None:
|
||||
outcomes=("done",),
|
||||
)
|
||||
|
||||
reducer = artifact.required_capability_map()["wf.std.max"]
|
||||
assert str(reducer.capability_ref().source) == "wf.std"
|
||||
assert reducer.capability_ref().name == "max"
|
||||
assert reducer.logical_source == "wf.std"
|
||||
assert reducer.capability_name == "max"
|
||||
assert reducer.kind == "reducer"
|
||||
|
||||
|
||||
def test_create_workflow_artifact_from_plan_accepts_structural_reducer_ref() -> None:
|
||||
plan = _plan()
|
||||
plan["state_schema"] = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"score": {
|
||||
"type": "integer",
|
||||
"reducer": {
|
||||
"ref": {"source": "wf.std", "capability_key": "max"},
|
||||
"config": {},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
artifact = create_workflow_artifact_from_plan(
|
||||
artifact_id="score",
|
||||
version=1,
|
||||
title="Score",
|
||||
plan=plan,
|
||||
outcomes=("done",),
|
||||
)
|
||||
|
||||
reducer = artifact.required_capability_map()["wf.std.max"]
|
||||
assert reducer.logical_source == "wf.std"
|
||||
assert reducer.capability_name == "max"
|
||||
|
||||
@@ -13,6 +13,7 @@ from wf_core.paths import StatePath
|
||||
from wf_core.runtime.ops.merges import ReducerDefinition, apply_reducer
|
||||
from wf_core.runtime.ops.runs import create_run_state
|
||||
from wf_core.runtime.ops.state import write_state_value
|
||||
from wf_platform import CapabilityRef
|
||||
|
||||
|
||||
def test_exact_nested_state_path_uses_declared_reducer() -> None:
|
||||
@@ -313,6 +314,44 @@ def test_state_field_accepts_configured_reducer_reference() -> None:
|
||||
# PYLINT!!!! what is u on ts is so clear
|
||||
assert field.reducer.name == "wf.std.max" # pylint: disable=no-member
|
||||
assert field.reducer.config == {"sample": True} # pylint: disable=no-member
|
||||
assert field.model_dump(mode="json")["reducer"] == {
|
||||
"ref": {"source": "wf.std", "capability_key": "max"},
|
||||
"config": {"sample": True},
|
||||
}
|
||||
|
||||
|
||||
def test_reducer_ref_accepts_string_shorthand_and_dumps_structural_ref() -> None:
|
||||
reducer = ReducerRef.model_validate("wf.std.add")
|
||||
|
||||
assert reducer.ref == CapabilityRef.parse("wf.std.add")
|
||||
assert reducer.name == "wf.std.add"
|
||||
assert reducer.model_dump(mode="json") == {
|
||||
"ref": {"source": "wf.std", "capability_key": "add"},
|
||||
"config": {},
|
||||
}
|
||||
|
||||
|
||||
def test_reducer_ref_accepts_legacy_name_object_with_config() -> None:
|
||||
reducer = ReducerRef.model_validate(
|
||||
{
|
||||
"name": "wf.std.modulo_add",
|
||||
"config": {"modulus": 10},
|
||||
}
|
||||
)
|
||||
|
||||
assert reducer.ref == CapabilityRef.parse("wf.std.modulo_add")
|
||||
assert reducer.name == "wf.std.modulo_add"
|
||||
assert reducer.config == {"modulus": 10}
|
||||
|
||||
|
||||
def test_reducer_ref_accepts_canonical_ref_object() -> None:
|
||||
reducer = ReducerRef.model_validate(
|
||||
{
|
||||
"ref": {"source": "wf.std", "capability_key": "append"},
|
||||
}
|
||||
)
|
||||
|
||||
assert reducer.name == "wf.std.append"
|
||||
|
||||
|
||||
def test_unknown_state_reducer_fails_clearly() -> None:
|
||||
|
||||
Reference in New Issue
Block a user