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
|
- dependency-trackable
|
||||||
|
|
||||||
State fields reference reducers declaratively. String reducer names are accepted
|
State fields reference reducers declaratively. String reducer names are accepted
|
||||||
as shorthand for unconfigured reducers; configured reducers use a `name` plus
|
as shorthand for unconfigured reducers; configured reducers use a structural
|
||||||
JSON-compatible `config`. Workflow artifacts do not embed arbitrary Python
|
`ref` plus JSON-compatible `config`. Workflow artifacts do not embed arbitrary
|
||||||
callables.
|
Python callables.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
StateField(
|
StateField(
|
||||||
type="integer",
|
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
|
Reducer refs are capability refs, not graph paths. `wf.std.add` is shorthand for
|
||||||
source `wf.std` and capability key `add`.
|
source `wf.std` and capability key `add`.
|
||||||
|
|
||||||
The reducer cleanup should move `ReducerRef` toward structural `CapabilityRef`
|
Configured reducer refs now use a structural `CapabilityRef` while keeping
|
||||||
while keeping string reducer names as parse-only shorthand. Reducer config stays
|
string reducer names as parse-only shorthand and display keys. Reducer config
|
||||||
part of the reducer reference payload:
|
stays part of the reducer reference payload:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "wf.std.modulo_add",
|
"ref": {"source": "wf.std", "capability_key": "modulo_add"},
|
||||||
"config": {"modulus": 10}
|
"config": {"modulus": 10}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
That future cleanup must not reuse graph path parsing rules. Reducer names live
|
That shape must not reuse graph path parsing rules. Reducer names live in the
|
||||||
in the capability/source domain.
|
capability/source domain.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from wf_authoring import WorkflowBuilder
|
from wf_authoring import WorkflowBuilder
|
||||||
from wf_authoring.dsl import PathExpr
|
from wf_authoring.dsl import PathExpr
|
||||||
from wf_core import JoinNode, Workflow
|
from wf_core import JoinNode, Workflow
|
||||||
|
from wf_core.paths import GraphSourcePath
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
DraftChooseStep,
|
DraftChooseStep,
|
||||||
@@ -81,7 +82,7 @@ def _add_step(builder: WorkflowBuilder, step_id: str, step: DraftStep):
|
|||||||
).entry
|
).entry
|
||||||
if isinstance(step, DraftMatchStep):
|
if isinstance(step, DraftMatchStep):
|
||||||
return builder.match(
|
return builder.match(
|
||||||
PathExpr(step.match.value),
|
PathExpr(GraphSourcePath.parse(step.match.value)),
|
||||||
{case.equals: case.then for case in step.match.cases},
|
{case.equals: case.then for case in step.match.cases},
|
||||||
id=step_id,
|
id=step_id,
|
||||||
default=step.match.default,
|
default=step.match.default,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
|
||||||
from wf_core import ReducerRef, Workflow
|
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 .models import ArtifactKind, JsonObject, RequiredCapability, WorkflowArtifact
|
||||||
from .references import normalize_plan_node_refs
|
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)
|
reducer = ReducerRef.model_validate(reducer_payload)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
try:
|
|
||||||
reducer_ref = CapabilityRef.parse(reducer.name)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
requirements[reducer.name] = RequiredCapability(
|
requirements[reducer.name] = RequiredCapability(
|
||||||
ref=reducer_ref,
|
ref=reducer.ref,
|
||||||
kind="reducer",
|
kind="reducer",
|
||||||
)
|
)
|
||||||
return requirements
|
return requirements
|
||||||
|
|||||||
@@ -1,16 +1,59 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
from typing import Any
|
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):
|
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)
|
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):
|
class ReducerSpec(BaseModel):
|
||||||
"""Inspectable metadata for one named pure state reducer."""
|
"""Inspectable metadata for one named pure state reducer."""
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
from .docs import DocumentationPrompt, DocumentationResource, build_documentation_source
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from .refs import CapabilityRef, SourceRef
|
from .refs import CapabilityRef, SourceRef
|
||||||
from .paging import Page, page_items
|
from .paging import Page, page_items
|
||||||
from .schema_hashes import hash_json_schema
|
from .schema_hashes import hash_json_schema
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .docs import (
|
||||||
|
DocumentationPrompt,
|
||||||
|
DocumentationResource,
|
||||||
|
build_documentation_source,
|
||||||
|
)
|
||||||
from .sources import (
|
from .sources import (
|
||||||
CapabilityBuckets,
|
CapabilityBuckets,
|
||||||
CapabilitySource,
|
CapabilitySource,
|
||||||
@@ -17,6 +25,24 @@ from .sources import (
|
|||||||
SourceVisibilitySnapshot,
|
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__ = [
|
__all__ = [
|
||||||
"CapabilityBuckets",
|
"CapabilityBuckets",
|
||||||
"CapabilitySource",
|
"CapabilitySource",
|
||||||
@@ -39,3 +65,21 @@ __all__ = [
|
|||||||
"hash_json_schema",
|
"hash_json_schema",
|
||||||
"page_items",
|
"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
|
return value
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
return cls.parse(value)
|
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")
|
raise TypeError("source ref must be a string")
|
||||||
|
|
||||||
|
|
||||||
@@ -101,9 +107,9 @@ class CapabilityRef:
|
|||||||
return cls.parse(value)
|
return cls.parse(value)
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
source = value.get("source")
|
source = value.get("source")
|
||||||
name = value.get("capability_key")
|
name = value.get("capability_key", value.get("name"))
|
||||||
if isinstance(source, str) and isinstance(name, str):
|
if isinstance(name, str):
|
||||||
return cls(source=SourceRef.parse(source), name=name)
|
return cls(source=SourceRef._validate(source), name=name)
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
"capability ref must be a string or {'source': str, 'capability_key': str}"
|
"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",),
|
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"]
|
reducer = artifact.required_capability_map()["wf.std.max"]
|
||||||
assert reducer.logical_source == "wf.std"
|
assert reducer.logical_source == "wf.std"
|
||||||
assert reducer.capability_name == "max"
|
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.merges import ReducerDefinition, apply_reducer
|
||||||
from wf_core.runtime.ops.runs import create_run_state
|
from wf_core.runtime.ops.runs import create_run_state
|
||||||
from wf_core.runtime.ops.state import write_state_value
|
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:
|
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
|
# PYLINT!!!! what is u on ts is so clear
|
||||||
assert field.reducer.name == "wf.std.max" # pylint: disable=no-member
|
assert field.reducer.name == "wf.std.max" # pylint: disable=no-member
|
||||||
assert field.reducer.config == {"sample": True} # 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:
|
def test_unknown_state_reducer_fails_clearly() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user