less dot-separated string in the Machinery
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user