less dot-separated string in the Machinery

This commit is contained in:
lda
2026-05-21 03:41:47 +07:00 Verified
parent 6e768435d8
commit 92d0e366c4
8 changed files with 877 additions and 124 deletions
+19
View File
@@ -153,3 +153,22 @@ JSON/MCP or when path segments contain display punctuation.
Old strings are accepted at parse boundaries for compatibility. Structural Old strings are accepted at parse boundaries for compatibility. Structural
`parts` are literal field names, so a part may contain dots or spaces without `parts` are literal field names, so a part may contain dots or spaces without
being split again. being split again.
## Reducer Refs
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:
```json
{
"name": "wf.std.modulo_add",
"config": {"modulus": 10}
}
```
That future cleanup must not reuse graph path parsing rules. Reducer names live
in the capability/source domain.
@@ -0,0 +1,58 @@
# ReducerRef Structural Capability Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Move reducer references from ambiguous dotted strings toward structural capability refs while preserving string reducer names as parse-only shorthand.
**Architecture:** Reducers are source-owned capabilities, not graph paths. `ReducerRef` should carry a structural `CapabilityRef` plus config, while old `name` strings continue to validate at compatibility boundaries. Artifact dependency extraction should use the structural ref instead of reparsing dotted reducer names.
**Tech Stack:** Python 3.14, Pydantic v2, `wf_platform.refs.CapabilityRef`, `wf_core.models.reducers.ReducerRef`, `wf_artifacts.factory`, pytest, basedpyright, ruff.
---
## Planned Shape
Current compatibility shape:
```json
{"name": "wf.std.add", "config": {}}
```
Future canonical shape:
```json
{
"ref": {"source": "wf.std", "capability_key": "add"},
"config": {}
}
```
String shorthand should continue to parse:
```json
"wf.std.add"
```
or:
```json
{"name": "wf.std.add", "config": {"modulus": 10}}
```
but saved model dumps should prefer `ref`.
## Scope Notes
- Do not treat reducer refs as `StatePath`.
- Do not split reducer names using graph-path helpers.
- Keep reducer config as part of `ReducerRef`; config does not affect the dependency key.
- Update artifact dependency extraction to read `ReducerRef.ref`.
- Keep runtime reducer lookup compatible with existing reducer registries keyed by display name until reducer catalogs are source-keyed.
## First Implementation Tasks
1. Add tests for `ReducerRef.model_validate("wf.std.add")`.
2. Add tests for canonical `{"ref": {"source": "wf.std", "capability_key": "add"}}`.
3. Add a display-name compatibility property if runtime registries still use string keys.
4. Update `_required_reducers_from_plan()` to use structural refs.
5. Update docs and inventory output only after the model is stable.
@@ -0,0 +1,563 @@
# State Schema and Reducer Ref Path Sweep Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Remove remaining dotted-string ambiguity from state field paths, then prepare `ReducerRef` to stop treating reducer capability names as opaque dotted strings.
**Architecture:** Do this in two independent passes. First, make `StateSchema` / `StateFieldDecl` preserve `StatePath` semantics internally and serialize state paths structurally where possible. Second, introduce a structural reducer capability ref while keeping string reducer names as parse-only shorthand. The state-schema pass is the immediate correctness fix; reducer refs are a follow-up because they touch artifacts/source dependencies.
**Tech Stack:** Python 3.14, Pydantic v2, `wf_core.paths.StatePath`, `wf_platform.refs.CapabilityRef`, `wf_core.models.schemas`, pytest, basedpyright, ruff.
---
## Why This Plan Exists
We just moved graph/node bindings toward structural paths:
```json
{"root": "state", "parts": ["person.name"]}
```
But state schema indexing still builds rootless dotted strings in places:
```python
path = f"{prefix}.{name}"
StatePath.of(path)
```
That can corrupt JSON Schema property names containing dots:
```json
{
"type": "object",
"properties": {
"person.name": {"type": "string", "reducer": "wf.std.replace"}
}
}
```
The intended state path is:
```text
state -> "person.name"
```
not:
```text
state -> person -> name
```
Reducer refs have a similar-looking but different issue. `wf.std.add` is not a graph path; it is a capability ref. That cleanup should use `CapabilityRef`, not `StatePath`.
---
## Scope
### In Scope Now
- Keep exact JSON Schema property names as literal `StatePath.parts`.
- Add typed state-field indexing keyed by `StatePath`.
- Keep old string-keyed `field_map()` compatibility.
- Make `StateFieldDecl.path` dumps structural if the rest of the core path dump has already moved structural.
- Add tests proving literal dotted property names are not split.
- Document that state schema paths and reducer refs are different domains.
### Follow-Up Scope
- Change `ReducerRef` to carry a structural `CapabilityRef`.
- Keep `ReducerRef(name="wf.std.add")` shorthand as parse-only compatibility.
- Update artifact dependency extraction to use structural reducer refs.
Do not mix these two passes unless the state schema work forces a reducer model touch.
---
## Current State
Relevant files:
- `src/wf_core/models/schemas.py`
- `StateFieldDecl.path: StatePath`
- `StateFieldDecl._serialize_path()` currently returns `str(path)`
- `StateSchema.field_map()` returns `dict[str, StateFieldDecl]`
- `_iter_state_field_declarations(...)` builds `path` as dotted string
- `_set_state_property_schema(...)` receives `path_parts`
- `src/wf_core/models/reducers.py`
- `ReducerRef.name: str`
- `src/wf_authoring/schemas.py`
- `_iter_model_metadata(...)` builds rootless dotted strings from Pydantic field names
- `_flatten_state_properties(...)` and `_lookup_mutable_property_schema(...)` split strings with `.`
- `src/wf_core/runtime/ops/state.py`
- uses `workflow.state_schema.field_map()` and string keys
Important distinction:
```text
State path: graph data path, should use StatePath
Reducer name: source capability ref, should use CapabilityRef later
```
---
## Task 1: Pin Literal Dotted State Property Behavior
**Files:**
- Test: `tests/core/test_nested_state_paths.py`
- Test: `tests/core/test_schema_validation.py`
- [ ] **Step 1: Add failing StateSchema field-index test**
Add to `tests/core/test_nested_state_paths.py`:
```python
def test_state_schema_preserves_literal_dotted_property_names() -> None:
schema = StateSchema.model_validate({
"type": "object",
"properties": {
"person.name": {"type": "string", "reducer": "wf.std.replace"}
},
})
fields = schema.field_index()
assert set(fields) == {StatePath(("person.name",))}
assert fields[StatePath(("person.name",))].path == StatePath(("person.name",))
```
Expected failure: `field_index()` does not exist, or the path is split as `("person", "name")`.
- [ ] **Step 2: Add compatibility `field_map()` test**
Add:
```python
def test_state_schema_field_map_keeps_display_key_for_literal_dotted_property() -> None:
schema = StateSchema.model_validate({
"type": "object",
"properties": {
"person.name": {"type": "string", "reducer": "wf.std.replace"}
},
})
fields = schema.field_map()
assert set(fields) == {"person.name"}
assert fields["person.name"].path == StatePath(("person.name",))
```
This keeps old callers alive but makes the value typed/correct.
- [ ] **Step 3: Run focused tests to verify red**
```bash
uv run --with pytest pytest tests/core/test_nested_state_paths.py::test_state_schema_preserves_literal_dotted_property_names tests/core/test_nested_state_paths.py::test_state_schema_field_map_keeps_display_key_for_literal_dotted_property -q
```
Expected: fail before implementation.
---
## Task 2: Add Typed State Field Index
**Files:**
- Modify: `src/wf_core/models/schemas.py`
- Test: `tests/core/test_nested_state_paths.py`
- [ ] **Step 1: Add path-parts traversal helper**
In `src/wf_core/models/schemas.py`, replace string-prefix recursion with tuple path parts.
Add helper:
```python
def _append_state_part(prefix: tuple[str, ...], name: str) -> tuple[str, ...]:
"""Append one JSON Schema property name as one literal StatePath segment."""
return (*prefix, name)
```
- [ ] **Step 2: Add `field_index()`**
Add to `StateSchema`:
```python
def field_index(self) -> dict[StatePath, StateFieldDecl]:
"""Return reducer-aware declarations keyed by exact typed state path."""
root_schema = self.model_dump(mode="json", exclude_none=True)
return {
path: field
for path, field in _iter_state_field_declarations(
self.properties,
root_schema,
prefix=(),
)
}
```
- [ ] **Step 3: Make `field_map()` compatibility wrapper**
Change `field_map()` to:
```python
def field_map(self) -> dict[str, StateFieldDecl]:
"""Return reducer-aware declarations keyed by rootless display path."""
return {".".join(path.parts): field for path, field in self.field_index().items()}
```
Note: this display map is ambiguous for literal dotted segments, but values are correct. New runtime code should move to `field_index()`.
- [ ] **Step 4: Update `_iter_state_field_declarations` signature**
Change from string prefix:
```python
prefix: str
) -> Iterator[tuple[str, StateFieldDecl]]:
```
to typed prefix:
```python
prefix: tuple[str, ...]
) -> Iterator[tuple[StatePath, StateFieldDecl]]:
```
Inside loop:
```python
path_parts = _append_state_part(prefix, name)
path = StatePath(path_parts)
display_path = ".".join(path.parts)
```
Use `display_path` only in error messages and reducer validation labels.
- [ ] **Step 5: Update yielded `StateFieldDecl` construction**
Change:
```python
"path": StatePath.of(path),
```
to:
```python
"path": path,
```
- [ ] **Step 6: Update recursive calls**
Pass:
```python
prefix=path.parts
```
not a dotted string.
- [ ] **Step 7: Run focused tests**
```bash
uv run --with pytest pytest tests/core/test_nested_state_paths.py::test_state_schema_preserves_literal_dotted_property_names tests/core/test_nested_state_paths.py::test_state_schema_field_map_keeps_display_key_for_literal_dotted_property -q
```
Expected: pass.
---
## Task 3: Move Runtime Lookup to Typed State Paths
**Files:**
- Modify: `src/wf_core/runtime/ops/state.py`
- Test: `tests/core/test_nested_state_paths.py`
- Test: `tests/core/test_atomic_state_patches.py`
- [ ] **Step 1: Inspect current runtime lookup**
Current likely shape:
```python
state_fields = workflow.state_schema.field_map()
field = state_fields.get(".".join(path.parts))
```
This should move to `field_index()` where available.
- [ ] **Step 2: Update runtime type hints**
Change helpers from:
```python
state_fields: Mapping[str, StateFieldDecl]
```
to:
```python
state_fields: Mapping[StatePath, StateFieldDecl]
```
- [ ] **Step 3: Use typed lookup**
When resolving reducer for a write target:
```python
declared_field = state_fields.get(target)
```
where `target` is already a `StatePath`.
If code currently has only path parts, construct:
```python
target = StatePath(tuple(path_parts))
```
- [ ] **Step 4: Update affected-field overlap helper**
If `_affected_state_fields(...)` compares string prefixes, make it compare tuple parts:
```python
def _is_prefix(prefix: tuple[str, ...], parts: tuple[str, ...]) -> bool:
return parts[: len(prefix)] == prefix
```
This preserves exact path semantics without reparsing dotted display text.
- [ ] **Step 5: Run runtime-focused tests**
```bash
uv run --with pytest pytest tests/core/test_nested_state_paths.py tests/core/test_atomic_state_patches.py -q
```
Expected: pass.
---
## Task 4: Structural `StateFieldDecl.path` Dump
**Files:**
- Modify: `src/wf_core/models/schemas.py`
- Test: `tests/core/test_nested_state_paths.py`
- Test: `tests/core/test_schema_validation.py`
- [ ] **Step 1: Check current expectations**
Existing tests may expect:
```python
{"path": "state.person.name"}
```
Decide based on current path model direction. Since `StatePath` now serializes structurally elsewhere, prefer:
```json
{"path": {"root": "state", "parts": ["person.name"]}}
```
- [ ] **Step 2: Change serializer**
Remove this serializer:
```python
@field_serializer("path")
def _serialize_path(self, path: StatePath) -> str:
return str(path)
```
or change it to:
```python
@field_serializer("path")
def _serialize_path(self, path: StatePath) -> dict[str, str | list[str]]:
return StatePath._serialize(path)
```
Prefer removal if Pydantic uses the existing `StatePath` serializer correctly.
- [ ] **Step 3: Update tests**
Update or add:
```python
def test_state_field_decl_model_dump_serializes_path_structurally() -> None:
field = StateFieldDecl(path=StatePath(("person.name",)), schema={"type": "string"})
dumped = field.model_dump(mode="json")
assert dumped["path"] == {"root": "state", "parts": ["person.name"]}
```
- [ ] **Step 4: Run focused schema tests**
```bash
uv run --with pytest pytest tests/core/test_nested_state_paths.py tests/core/test_schema_validation.py -q
```
Expected: pass after expectation updates.
---
## Task 5: Authoring State Metadata Path Sweep
**Files:**
- Modify: `src/wf_authoring/schemas.py`
- Test: `tests/authoring/test_schemas.py`
- [ ] **Step 1: Add failing authoring test for literal dotted Pydantic field alias if possible**
If Pydantic field aliases are already used in this project, add:
```python
class DotAliasState(BaseModel):
person_name: Annotated[
str,
Field(alias="person.name"),
state_field(reducer="wf.std.replace"),
]
def test_state_schema_from_preserves_literal_dotted_alias_paths() -> None:
schema = state_schema_from(DotAliasState)
fields = schema.field_index()
assert StatePath(("person.name",)) in fields
```
If field aliases are not supported by the current authoring schema flow, document that Python model field names remain Python identifiers and alias path support is out of scope.
- [ ] **Step 2: Replace string path traversal with tuple parts**
In `src/wf_authoring/schemas.py`, update metadata collection helpers:
```python
def _iter_model_metadata(
model_type: type[BaseModel],
*,
prefix: tuple[str, ...] = (),
) -> Iterator[tuple[tuple[str, ...], StateFieldMetadata]]:
```
Use one literal segment per field name or alias:
```python
field_name = field_info.alias or name
path = (*prefix, field_name)
```
- [ ] **Step 3: Update lookup helpers to accept tuple parts**
Change:
```python
_lookup_mutable_property_schema(schema_payload, path)
_state_field_default(value, path, property_schema)
```
to tuple-based forms:
```python
_lookup_mutable_property_schema(schema_payload, path_parts)
_state_field_default(value, path_parts, property_schema)
```
Use field name lookup carefully; default lookup for nested aliases may need to remain conservative.
- [ ] **Step 4: Run authoring schema tests**
```bash
uv run --with pytest pytest tests/authoring/test_schemas.py -q
```
Expected: pass.
---
## Task 6: ReducerRef Capability Ref Plan Stub
**Files:**
- Modify: `docs/structural_refs.md`
- Create: `docs/superpowers/plans/YYYY-MM-DD-reducer-ref-structural-capability.md`
- [ ] **Step 1: Document reducer refs are capability refs**
In `docs/structural_refs.md`, add:
```text
Reducer refs are capability refs, not graph paths. `wf.std.add` is shorthand
for source `wf.std`, capability key `add`. The reducer cleanup should move
ReducerRef toward structural CapabilityRef while keeping string reducer names
as parse-only shorthand.
```
- [ ] **Step 2: Create follow-up plan stub**
Create a separate plan with only the intended boundary:
- `ReducerRef.name: str` remains compatibility display/shorthand for now.
- Add `ReducerRef.ref: CapabilityRef` or replace `name` with a `CapabilityRef` after artifact/source dependency code is ready.
- Update artifact dependency extraction from reducer refs.
- Keep configured reducers as `{ref/name, config}`.
Do not implement reducer structural refs in the state-schema path sweep unless the user explicitly asks to combine them.
---
## Task 7: Verification
**Files:**
- All touched files.
- [ ] **Step 1: Run focused core tests**
```bash
uv run --with pytest pytest tests/core/test_nested_state_paths.py tests/core/test_atomic_state_patches.py tests/core/test_schema_validation.py -q
```
Expected: pass.
- [ ] **Step 2: Run authoring schema tests**
```bash
uv run --with pytest pytest tests/authoring/test_schemas.py -q
```
Expected: pass.
- [ ] **Step 3: Run full tests**
```bash
uv run --with pytest pytest -q
```
Expected: pass.
- [ ] **Step 4: Run lint/type checks**
```bash
uvx ruff check src/wf_core src/wf_authoring tests/core tests/authoring
uvx ruff format --check src/wf_core src/wf_authoring tests/core tests/authoring
uv run basedpyright --level error src/wf_core src/wf_authoring tests/core tests/authoring
```
Expected:
- ruff check passes
- format check passes
- basedpyright reports `0 errors`
---
## Self-Review Checklist
- JSON Schema property names containing dots stay one `StatePath` segment.
- Runtime reducer lookup uses `StatePath`, not rootless dotted strings.
- `field_map()` remains available for compatibility but is not the preferred internal API.
- `StateFieldDecl.path` no longer forces string serialization if the project has moved to structural path JSON.
- Reducer refs are documented as capability refs, not graph paths.
- Reducer structural ref implementation is not accidentally mixed into the state schema path sweep.
+34 -16
View File
@@ -52,9 +52,9 @@ def state_schema_from(value: StateSchemaLike) -> StateSchema:
schema = schema_ref_from(value) schema = schema_ref_from(value)
schema_payload = schema.model_dump(mode="json", exclude_none=True) schema_payload = schema.model_dump(mode="json", exclude_none=True)
metadata_by_name = _state_metadata_by_name(value) metadata_by_path = _state_metadata_by_path(value)
for path, property_schema in _flatten_state_properties(schema): for path, property_schema in _flatten_state_properties(schema):
metadata = metadata_by_name.get(path, StateFieldMetadata()) metadata = metadata_by_path.get(path, StateFieldMetadata())
extension_schema = _lookup_mutable_property_schema(schema_payload, path) extension_schema = _lookup_mutable_property_schema(schema_payload, path)
if extension_schema is None: if extension_schema is None:
extension_schema = property_schema extension_schema = property_schema
@@ -75,7 +75,7 @@ def _reducer_ref_from(value: ReducerLike) -> ReducerRef:
return ReducerRef.model_validate(value) return ReducerRef.model_validate(value)
def _state_metadata_by_name(value: object) -> dict[str, StateFieldMetadata]: def _state_metadata_by_path(value: object) -> dict[tuple[str, ...], StateFieldMetadata]:
if not isinstance(value, type) or not issubclass(value, BaseModel): if not isinstance(value, type) or not issubclass(value, BaseModel):
return {} return {}
@@ -85,10 +85,11 @@ def _state_metadata_by_name(value: object) -> dict[str, StateFieldMetadata]:
def _iter_model_metadata( def _iter_model_metadata(
model_type: type[BaseModel], model_type: type[BaseModel],
*, *,
prefix: str = "", prefix: tuple[str, ...] = (),
) -> Iterator[tuple[str, StateFieldMetadata]]: ) -> Iterator[tuple[tuple[str, ...], StateFieldMetadata]]:
for name, field_info in model_type.model_fields.items(): for name, field_info in model_type.model_fields.items():
path = f"{prefix}.{name}" if prefix else name field_name = field_info.alias or name
path = (*prefix, field_name)
for item in field_info.metadata: for item in field_info.metadata:
if isinstance(item, StateFieldMetadata): if isinstance(item, StateFieldMetadata):
yield path, item yield path, item
@@ -100,24 +101,28 @@ def _iter_model_metadata(
def _flatten_state_properties( def _flatten_state_properties(
schema: SchemaRef, schema: SchemaRef,
) -> Iterator[tuple[str, dict[str, Any]]]: ) -> Iterator[tuple[tuple[str, ...], dict[str, Any]]]:
raw_schema = schema.model_dump(exclude_none=True) raw_schema = schema.model_dump(exclude_none=True)
yield from _iter_state_properties(raw_schema.get("properties", {}), raw_schema) yield from _iter_state_properties(
raw_schema.get("properties", {}),
raw_schema,
prefix=(),
)
def _iter_state_properties( def _iter_state_properties(
properties: object, properties: object,
root_schema: dict[str, Any], root_schema: dict[str, Any],
*, *,
prefix: str = "", prefix: tuple[str, ...],
) -> Iterator[tuple[str, dict[str, Any]]]: ) -> Iterator[tuple[tuple[str, ...], dict[str, Any]]]:
if not isinstance(properties, dict): if not isinstance(properties, dict):
return return
for name, property_schema in properties.items(): for name, property_schema in properties.items():
if not isinstance(property_schema, dict): if not isinstance(property_schema, dict):
continue continue
path = f"{prefix}.{name}" if prefix else name path = (*prefix, name)
resolved_schema = _resolve_property_schema(property_schema, root_schema) resolved_schema = _resolve_property_schema(property_schema, root_schema)
yield path, resolved_schema yield path, resolved_schema
yield from _iter_state_properties( yield from _iter_state_properties(
@@ -150,11 +155,11 @@ def _dump_reducer(reducer: ReducerRef) -> str | dict[str, Any]:
def _lookup_mutable_property_schema( def _lookup_mutable_property_schema(
schema: dict[str, Any], schema: dict[str, Any],
path: str, path: tuple[str, ...],
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
"""Find a property schema, following local Pydantic ``$defs`` references.""" """Find a property schema, following local Pydantic ``$defs`` references."""
current: dict[str, Any] = schema current: dict[str, Any] = schema
for part in path.split("."): for part in path:
properties = current.get("properties") properties = current.get("properties")
if not isinstance(properties, dict): if not isinstance(properties, dict):
return None return None
@@ -181,16 +186,29 @@ def _resolve_mutable_property_schema(
def _state_field_default( def _state_field_default(
value: object, value: object,
field_name: str, field_path: tuple[str, ...],
property_schema: object, property_schema: object,
) -> object: ) -> object:
if ( if (
"." not in field_name len(field_path) == 1
and isinstance(value, type) and isinstance(value, type)
and issubclass(value, BaseModel) and issubclass(value, BaseModel)
): ):
field_info = value.model_fields[field_name] field_info = _model_field_by_schema_name(value, field_path[0])
if field_info is None:
return None
if not field_info.is_required(): if not field_info.is_required():
return field_info.get_default(call_default_factory=True) return field_info.get_default(call_default_factory=True)
return None return None
def _model_field_by_schema_name(model_type: type[BaseModel], name: str) -> Any | None:
"""Return a model field by Python name or serialized alias."""
field_info = model_type.model_fields.get(name)
if field_info is not None:
return field_info
for candidate in model_type.model_fields.values():
if candidate.alias == name:
return candidate
return None
+30 -19
View File
@@ -98,8 +98,8 @@ class StateFieldDecl(BaseModel):
return schema_type if isinstance(schema_type, str) else None return schema_type if isinstance(schema_type, str) else None
@field_serializer("path") @field_serializer("path")
def _serialize_path(self, path: StatePath) -> str: def _serialize_path(self, path: StatePath) -> dict[str, str | list[str]]:
return str(path) return StatePath._serialize(path)
@model_validator(mode="before") @model_validator(mode="before")
@classmethod @classmethod
@@ -147,20 +147,26 @@ class StateSchema(BaseModel):
@property @property
def fields(self) -> list[StateFieldDecl]: def fields(self) -> list[StateFieldDecl]:
"""Return the compiled field declarations for compatibility callers.""" """Return the compiled field declarations for compatibility callers."""
return list(self.field_map().values()) return list(self.field_index().values())
def field_map(self) -> dict[str, StateFieldDecl]: def field_index(self) -> dict[StatePath, StateFieldDecl]:
"""Return reducer-aware declarations keyed by rootless dotted path.""" """Return reducer-aware declarations keyed by exact typed state path."""
root_schema = self.model_dump(mode="json", exclude_none=True) root_schema = self.model_dump(mode="json", exclude_none=True)
return { return {
path: field path: field
for path, field in _iter_state_field_declarations( for path, field in _iter_state_field_declarations(
self.properties, self.properties,
root_schema, root_schema,
prefix="", prefix=(),
) )
} }
def field_map(self) -> dict[str, StateFieldDecl]:
"""Return reducer-aware declarations keyed by rootless dotted path."""
return {
".".join(path.parts): field for path, field in self.field_index().items()
}
def root_fields(self) -> set[str]: def root_fields(self) -> set[str]:
"""Return declared top-level state field names.""" """Return declared top-level state field names."""
return set(self.properties) return set(self.properties)
@@ -245,18 +251,21 @@ def _iter_state_field_declarations(
properties: Mapping[str, Any], properties: Mapping[str, Any],
root_schema: Mapping[str, Any], root_schema: Mapping[str, Any],
*, *,
prefix: str, prefix: tuple[str, ...],
) -> Iterator[tuple[str, StateFieldDecl]]: ) -> Iterator[tuple[StatePath, StateFieldDecl]]:
for name, property_schema in properties.items(): for name, property_schema in properties.items():
if not isinstance(property_schema, Mapping): if not isinstance(property_schema, Mapping):
continue continue
path = f"{prefix}.{name}" if prefix else name path = StatePath((*prefix, name))
display_path = ".".join(path.parts)
resolved_schema = _resolve_local_ref(property_schema, root_schema) resolved_schema = _resolve_local_ref(property_schema, root_schema)
reducer = _reducer_from_property(path, property_schema) reducer = _reducer_from_property(display_path, property_schema)
trace = property_schema.get("trace", True) trace = property_schema.get("trace", True)
default = property_schema.get("default") default = property_schema.get("default")
if not isinstance(trace, bool): if not isinstance(trace, bool):
raise ValueError(f"invalid trace for state field {path!r}: expected bool") raise ValueError(
f"invalid trace for state field {display_path!r}: expected bool"
)
validation_schema = { validation_schema = {
key: value key: value
for key, value in resolved_schema.items() for key, value in resolved_schema.items()
@@ -265,20 +274,22 @@ def _iter_state_field_declarations(
_attach_root_schema_context(validation_schema, root_schema) _attach_root_schema_context(validation_schema, root_schema)
yield ( yield (
path, path,
StateFieldDecl.model_validate({ StateFieldDecl.model_validate(
"path": StatePath.of(path), {
"schema": SchemaRef.model_validate(validation_schema), "path": path,
"reducer": reducer, "schema": SchemaRef.model_validate(validation_schema),
"trace": trace, "reducer": reducer,
"default": default, "trace": trace,
}), "default": default,
}
),
) )
child_properties = resolved_schema.get("properties") child_properties = resolved_schema.get("properties")
if isinstance(child_properties, Mapping): if isinstance(child_properties, Mapping):
yield from _iter_state_field_declarations( yield from _iter_state_field_declarations(
child_properties, child_properties,
root_schema, root_schema,
prefix=path, prefix=path.parts,
) )
+12 -10
View File
@@ -63,7 +63,7 @@ def apply_output_bindings(
"mapped state patch has overlapping destination paths" "mapped state patch has overlapping destination paths"
) )
state_fields = workflow.state_schema.field_map() state_fields = workflow.state_schema.field_index()
resolved_patch: dict[StatePath, Any] = {} resolved_patch: dict[StatePath, Any] = {}
for binding in bindings: for binding in bindings:
try: try:
@@ -139,7 +139,7 @@ def write_state_value(
validate_staged_state_patch( validate_staged_state_patch(
staged_state, staged_state,
{StatePath.parse(destination_path): (key_path, merged_value)}, {StatePath.parse(destination_path): (key_path, merged_value)},
workflow.state_schema.field_map(), workflow.state_schema.field_index(),
) )
state.clear() state.clear()
state.update(staged_state) state.update(staged_state)
@@ -152,7 +152,7 @@ def prepare_state_value(
value: Any, value: Any,
*, *,
reducers: Mapping[str, ReducerDefinition] | None = None, reducers: Mapping[str, ReducerDefinition] | None = None,
state_fields: Mapping[str, StateFieldDecl] | None = None, state_fields: Mapping[StatePath, StateFieldDecl] | None = None,
) -> tuple[list[str], Any]: ) -> tuple[list[str], Any]:
"""Resolve reducer output for a state write without mutating state.""" """Resolve reducer output for a state write without mutating state."""
try: try:
@@ -165,9 +165,11 @@ def prepare_state_value(
f"executor only supports writes into state.*, got {destination_path!r}" f"executor only supports writes into state.*, got {destination_path!r}"
) )
declared_path = ".".join(parts) declared_path = StatePath(tuple(parts))
fields = ( fields = (
state_fields if state_fields is not None else workflow.state_schema.field_map() state_fields
if state_fields is not None
else workflow.state_schema.field_index()
) )
declared_field = fields.get(declared_path) declared_field = fields.get(declared_path)
reducer = ( reducer = (
@@ -194,7 +196,7 @@ def project_output(workflow: Workflow, state: dict[str, Any]) -> dict[str, Any]:
def validate_staged_state_patch( def validate_staged_state_patch(
staged_state: dict[str, Any], staged_state: dict[str, Any],
prepared_patch: Mapping[StatePath, tuple[list[str], Any]], prepared_patch: Mapping[StatePath, tuple[list[str], Any]],
state_fields: Mapping[str, StateFieldDecl], state_fields: Mapping[StatePath, StateFieldDecl],
) -> None: ) -> None:
"""Validate affected declared state schemas before committing a patch. """Validate affected declared state schemas before committing a patch.
@@ -217,18 +219,18 @@ def validate_staged_state_patch(
def _affected_state_fields( def _affected_state_fields(
prepared_patch: Mapping[StatePath, tuple[list[str], Any]], prepared_patch: Mapping[StatePath, tuple[list[str], Any]],
state_fields: Mapping[str, StateFieldDecl], state_fields: Mapping[StatePath, StateFieldDecl],
) -> list[StateFieldDecl]: ) -> list[StateFieldDecl]:
affected: dict[str, StateFieldDecl] = {} affected: dict[StatePath, StateFieldDecl] = {}
for destination_path in prepared_patch: for destination_path in prepared_patch:
destination_parts = destination_path.parts destination_parts = destination_path.parts
for key, field in state_fields.items(): for path, field in state_fields.items():
field_parts = field.path.parts field_parts = field.path.parts
if _is_prefix(field_parts, destination_parts) or _is_prefix( if _is_prefix(field_parts, destination_parts) or _is_prefix(
destination_parts, destination_parts,
field_parts, field_parts,
): ):
affected[key] = field affected[path] = field
return sorted( return sorted(
affected.values(), affected.values(),
key=lambda field: len(field.path.parts), key=lambda field: len(field.path.parts),
+30 -1
View File
@@ -1,6 +1,11 @@
from __future__ import annotations from __future__ import annotations
from wf_authoring import WorkflowBuilder from typing import Annotated
from pydantic import BaseModel, Field
from wf_authoring import WorkflowBuilder, state_field
from wf_core.paths import StatePath
from tests.authoring.helpers import ( from tests.authoring.helpers import (
AppendState, AppendState,
@@ -13,6 +18,14 @@ from tests.authoring.helpers import (
) )
class DotAliasState(BaseModel):
person_tags: Annotated[
list[str],
Field(alias="person.name"),
state_field(reducer="wf.std.append"),
]
def test_builder_accepts_basemodel_classes_for_workflow_schemas() -> None: def test_builder_accepts_basemodel_classes_for_workflow_schemas() -> None:
builder = WorkflowBuilder( builder = WorkflowBuilder(
name="model_schema_demo", name="model_schema_demo",
@@ -102,3 +115,19 @@ def test_nested_state_basemodel_projects_parent_and_child_paths() -> None:
assert fields["person.tags"].type == "array" assert fields["person.tags"].type == "array"
assert fields["person"].reducer.name == "wf.std.replace" assert fields["person"].reducer.name == "wf.std.replace"
assert fields["person.tags"].reducer.name == "wf.std.append" assert fields["person.tags"].reducer.name == "wf.std.append"
def test_state_schema_from_preserves_literal_dotted_alias_paths() -> None:
builder = WorkflowBuilder(
name="dotted_alias_state_schema_demo",
input_schema=WorkflowInput,
state_schema=DotAliasState,
output_schema=WorkflowOutput,
start="start",
)
workflow = builder.compile()
fields = workflow.state_schema.field_index()
assert StatePath(("person.name",)) in fields
assert fields[StatePath(("person.name",))].reducer.name == "wf.std.append"
+131 -78
View File
@@ -31,16 +31,18 @@ def test_exact_nested_state_path_uses_declared_reducer() -> None:
def test_state_schema_accepts_legacy_field_list_and_dumps_json_schema() -> None: def test_state_schema_accepts_legacy_field_list_and_dumps_json_schema() -> None:
schema = StateSchema.model_validate({ schema = StateSchema.model_validate(
"fields": [ {
{"path": "state.person", "type": "object"}, "fields": [
{ {"path": "state.person", "type": "object"},
"path": "state.person.name", {
"type": "string", "path": "state.person.name",
"reducer": "wf.std.replace", "type": "string",
}, "reducer": "wf.std.replace",
] },
}) ]
}
)
assert schema.fields[0].path == StatePath.of("person") assert schema.fields[0].path == StatePath.of("person")
assert schema.field_map()["person.name"].type == "string" assert schema.field_map()["person.name"].type == "string"
@@ -50,22 +52,24 @@ def test_state_schema_accepts_legacy_field_list_and_dumps_json_schema() -> None:
def test_state_schema_uses_json_schema_properties_as_canonical_shape() -> None: def test_state_schema_uses_json_schema_properties_as_canonical_shape() -> None:
schema = StateSchema.model_validate({ schema = StateSchema.model_validate(
"type": "object", {
"properties": { "type": "object",
"person": { "properties": {
"type": "object", "person": {
"properties": { "type": "object",
"name": { "properties": {
"type": "string", "name": {
"description": "Display name", "type": "string",
"reducer": "wf.std.replace", "description": "Display name",
} "reducer": "wf.std.replace",
}
},
}, },
"count": {"type": "integer", "reducer": "wf.std.add"},
}, },
"count": {"type": "integer", "reducer": "wf.std.add"}, }
}, )
})
fields = schema.field_map() fields = schema.field_map()
assert fields["person.name"].validation_schema.type == "string" assert fields["person.name"].validation_schema.type == "string"
@@ -73,14 +77,48 @@ def test_state_schema_uses_json_schema_properties_as_canonical_shape() -> None:
assert fields["count"].reducer == ReducerRef(name="wf.std.add") assert fields["count"].reducer == ReducerRef(name="wf.std.add")
def test_state_schema_rejects_invalid_reducer_extension_keyword() -> None: def test_state_schema_preserves_literal_dotted_property_names() -> None:
try: schema = StateSchema.model_validate(
StateSchema.model_validate({ {
"type": "object", "type": "object",
"properties": { "properties": {
"count": {"type": "integer", "reducer": {"bad": True}}, "person.name": {"type": "string", "reducer": "wf.std.replace"}
}, },
}) }
)
fields = schema.field_index()
assert set(fields) == {StatePath(("person.name",))}
assert fields[StatePath(("person.name",))].path == StatePath(("person.name",))
def test_state_schema_field_map_keeps_display_key_for_literal_dotted_property() -> None:
schema = StateSchema.model_validate(
{
"type": "object",
"properties": {
"person.name": {"type": "string", "reducer": "wf.std.replace"}
},
}
)
fields = schema.field_map()
assert set(fields) == {"person.name"}
assert fields["person.name"].path == StatePath(("person.name",))
def test_state_schema_rejects_invalid_reducer_extension_keyword() -> None:
try:
StateSchema.model_validate(
{
"type": "object",
"properties": {
"count": {"type": "integer", "reducer": {"bad": True}},
},
}
)
except ValueError as exc: except ValueError as exc:
assert "invalid reducer for state field 'count'" in str(exc) assert "invalid reducer for state field 'count'" in str(exc)
else: else:
@@ -88,14 +126,16 @@ def test_state_schema_rejects_invalid_reducer_extension_keyword() -> None:
def test_state_schema_accepts_canonical_schema_field() -> None: def test_state_schema_accepts_canonical_schema_field() -> None:
schema = StateSchema.model_validate({ schema = StateSchema.model_validate(
"fields": [ {
{ "fields": [
"path": "state.person.name", {
"schema": {"type": "string", "title": "Person Name"}, "path": "state.person.name",
} "schema": {"type": "string", "title": "Person Name"},
] }
}) ]
}
)
field = schema.field_map()["person.name"] field = schema.field_map()["person.name"]
assert field.validation_schema.type == "string" assert field.validation_schema.type == "string"
@@ -111,13 +151,15 @@ def test_state_schema_accepts_deprecated_dict_shape_and_dumps_list() -> None:
def test_state_schema_accepts_deprecated_dict_value_with_schema_key() -> None: def test_state_schema_accepts_deprecated_dict_value_with_schema_key() -> None:
schema = StateSchema.model_validate({ schema = StateSchema.model_validate(
"fields": { {
"person.name": { "fields": {
"schema": {"type": "string", "description": "Display name"}, "person.name": {
"schema": {"type": "string", "description": "Display name"},
}
} }
} }
}) )
assert schema.field_map()["person.name"].validation_schema.type == "string" assert schema.field_map()["person.name"].validation_schema.type == "string"
@@ -129,27 +171,32 @@ def test_state_schema_accepts_json_schema_field_without_type() -> None:
def test_state_schema_accepts_deprecated_state_prefixed_dict_keys() -> None: def test_state_schema_accepts_deprecated_state_prefixed_dict_keys() -> None:
schema = StateSchema.model_validate({ schema = StateSchema.model_validate(
"fields": {"state.person.name": {"type": "string"}} {"fields": {"state.person.name": {"type": "string"}}}
}) )
assert schema.field_map()["person.name"].path == StatePath.of("person.name") assert schema.field_map()["person.name"].path == StatePath.of("person.name")
def test_state_field_decl_model_dump_serializes_path_as_string() -> None: def test_state_field_decl_model_dump_serializes_path_structurally() -> None:
field = StateFieldDecl.model_validate({ field = StateFieldDecl.model_validate(
"path": "state.person.name", {
"type": "string", "path": "state.person.name",
}) "type": "string",
}
)
assert field.model_dump()["path"] == "state.person.name" assert field.model_dump()["path"] == {"root": "state", "parts": ["person", "name"]}
assert field.model_dump(mode="json")["path"] == "state.person.name" assert field.model_dump(mode="json")["path"] == {
"root": "state",
"parts": ["person", "name"],
}
def test_state_schema_model_dump_serializes_paths_as_strings() -> None: def test_state_schema_model_dump_serializes_paths_as_strings() -> None:
schema = StateSchema.model_validate({ schema = StateSchema.model_validate(
"fields": [{"path": "state.person.name", "type": "string"}] {"fields": [{"path": "state.person.name", "type": "string"}]}
}) )
dumped = schema.model_dump(mode="json") dumped = schema.model_dump(mode="json")
assert dumped["properties"]["person"]["properties"]["name"]["type"] == "string" assert dumped["properties"]["person"]["properties"]["name"]["type"] == "string"
@@ -158,12 +205,14 @@ def test_state_schema_model_dump_serializes_paths_as_strings() -> None:
def test_state_schema_rejects_duplicate_field_paths() -> None: def test_state_schema_rejects_duplicate_field_paths() -> None:
try: try:
StateSchema.model_validate({ StateSchema.model_validate(
"fields": [ {
{"path": "state.person.name", "type": "string"}, "fields": [
{"path": "state.person.name", "type": "string"}, {"path": "state.person.name", "type": "string"},
] {"path": "state.person.name", "type": "string"},
}) ]
}
)
except ValueError as exc: except ValueError as exc:
assert "duplicate state field path 'person.name'" in str(exc) assert "duplicate state field path 'person.name'" in str(exc)
else: else:
@@ -172,17 +221,19 @@ def test_state_schema_rejects_duplicate_field_paths() -> None:
def test_exact_nested_state_path_uses_reducer_from_json_schema_property() -> None: def test_exact_nested_state_path_uses_reducer_from_json_schema_property() -> None:
workflow = _workflow_from_state_schema( workflow = _workflow_from_state_schema(
StateSchema.model_validate({ StateSchema.model_validate(
"type": "object", {
"properties": { "type": "object",
"person": { "properties": {
"type": "object", "person": {
"properties": { "type": "object",
"tags": {"type": "array", "reducer": "wf.std.append"} "properties": {
}, "tags": {"type": "array", "reducer": "wf.std.append"}
} },
}, }
}) },
}
)
) )
state = {"person": {"tags": ["seed"]}} state = {"person": {"tags": ["seed"]}}
@@ -192,12 +243,14 @@ def test_exact_nested_state_path_uses_reducer_from_json_schema_property() -> Non
def test_state_schema_field_map_uses_rootless_keys() -> None: def test_state_schema_field_map_uses_rootless_keys() -> None:
schema = StateSchema.model_validate({ schema = StateSchema.model_validate(
"fields": [ {
{"path": "state.person.name", "type": "string"}, "fields": [
{"path": "state.person.tags", "type": "array"}, {"path": "state.person.name", "type": "string"},
] {"path": "state.person.tags", "type": "array"},
}) ]
}
)
fields = schema.field_map() fields = schema.field_map()
assert fields["person.name"].path == StatePath.of("person.name") assert fields["person.name"].path == StatePath.of("person.name")