Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,65 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.3.0] — 2026-10-01

### ⛔ Breaking — `check()` is renamed to `coherence_check()`

**Step-by-step upgrade: [`docs/migration-0.3.md`](docs/migration-0.3.md).** One line:

```python
spec.check(strategy) # 0.2.0
spec.coherence_check(strategy) # 0.3.0
```

No alias. A missed call site is an `AttributeError` at the call, not a silent change of
behaviour — same choice 0.2.0 made when `NodeSpec(...)` became a `TypeError`.

**Why.** `check()` did not say what it checks, and the type it returns says `Coherence` — a word
that appeared nowhere else in the API. One concept was wearing two names, which is the thing the
house naming rule exists to prevent. `coherence_check()` grounds it.

`design_check()` was considered and rejected: `spec` *is* the design, so `spec.design_check()`
restates its own receiver.

**Unchanged:** the eleven `check_*` functions, and the `check` field on a finding — both name an
individual check, which is what they still are.

### Added — `coherence_check()` returns `CoherenceFinding`, not a bare `str`

**Backward compatible. No call site needs editing** — `CoherenceFinding` is a `str` subclass, so
`"x" in f`, `f.startswith(...)`, `"\n".join(findings)`, `f == "the message"`, sorting, hashing
and `repr()` in a printed list all behave exactly as before. Verified byte-for-byte across all 64
findings the test designs produce: nothing in the text moved.

```python
f = spec.coherence_check(strategy)[0]
f.check # 'check_bindings' — the function that produced it
f.about # 'compose' — a node name; 'source->target' for an edge; '' for the whole design
f.blocking # True — False only for a `NOT CHECKED — …` stated gap

from workflow_workbench import blocking
blocking(findings) # the filter `render()` uses; replaces startswith("NOT CHECKED")
```

**Why.** The findings were sentences, so the structure a caller needs was encoded in the prose.
`[f for f in findings if not f.startswith("NOT CHECKED")]` was load-bearing control flow in three
production call sites here and in both downstream repos — two different kinds of finding wearing
one type, told apart by a prefix match. `.claude/rules/checks.md`: *NOT CHECKED and 0 FOUND must
never render the same.* An agent using `coherence_check()` as its acceptance test could only regex it.

A frozen dataclass is tidier and costs a second breaking migration one release after `StepSpec`;
that is why the subclass wins. `blocking` is a bool rather than a severity enum — two states, and
no third has been observed.

- `CoherenceFinding`, `blocking()` and `NOT_CHECKED` are exported from the package root.
- `coherence_check()` and every `check_*` function are now annotated `list[CoherenceFinding]`.

### Upgrading

Nothing to do. `uv lock --upgrade-package workflow-workbench` when you want the fields; until
then a pinned consumer is unaffected.

## [0.2.0] — 2026-09-30

### ⛔ Breaking — `NodeSpec` is renamed to `StepSpec`
Expand Down
28 changes: 23 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ uv run python3 -m examples.greeting
Everything it produces goes to the terminal; no files are written. Excerpt:

```
1. check() with nothing implemented: clean
1. coherence_check() with nothing implemented: clean
...
3. what varies between the two strategies: {'normalize': ('trim', 'trim_and_collapse')}
...
Expand All @@ -98,7 +98,7 @@ viewer is available as a separate process — `uv run python3 -m workflow_workbe
| | step | what you can inspect |
|---|---|---|
| 1 | specify the workflow | the nodes, named values and edges, as data |
| 2 | check and draw it | `check()` findings and `diagram()` mermaid, with nothing implemented |
| 2 | check and draw it | `coherence_check()` findings and `diagram()` mermaid, with nothing implemented |
| 3 | implement the steps | ordinary Pydantic Graph step bodies |
| 4 | bind a named strategy | `diagram(strategy)` — the design with each role's implementation named |
| 5 | check the strategy | missing bindings, wrong return types, and `render()` refusing outright |
Expand Down Expand Up @@ -138,7 +138,7 @@ slots are one transposition away from a graph that is wrong and runs.

```python
spec = Greeting()
spec.check() # -> [] — no strategy, no implementations, no engine
spec.coherence_check() # -> [] — no strategy, no implementations, no engine
spec.diagram() # -> mermaid for the specification
```

Expand Down Expand Up @@ -170,13 +170,31 @@ says so; `render()` refuses rather than building a graph with a hole in it:

```python
unfinished = StrategySpec("unfinished", {normalize: trim_and_collapse})
spec.check(unfinished)
spec.coherence_check(unfinished)
# ["strategy 'unfinished' does not bind node 'compose'. Every one is bound explicitly,
# including unchanged ones — a partial strategy makes 'what varies between these arms'
# unanswerable without reading both files."]
spec.render(unfinished) # raises SpecError with the same finding
```

A finding is a sentence, and it is also **structured**. `CoherenceFinding` is a `str` subclass, so
everything above reads exactly as it looks — and an agent driving this as its acceptance test can
branch on fields instead of matching on prose:

```python
f = spec.coherence_check(unfinished)[0]
f.check # 'check_bindings' — which check produced it
f.about # 'compose' — the node; 'source->target' for an edge; '' for the design
f.blocking # True — False only for a `NOT CHECKED — …` stated gap

from workflow_workbench import blocking
blocking(spec.coherence_check(unfinished)) # what `render()` refuses on, gaps excluded
```

`blocking` is a bool rather than a severity enum because there are two states and no third has
turned up. A stated gap and a clean pass must never read the same — that is the one distinction
`coherence_check()` has always made, and it used to be recoverable only with `startswith("NOT CHECKED")`.

Which is what makes growing a workflow safe: add a node and every existing strategy fails loudly
rather than skipping a step it never heard of
([`stage3_new_node.py`](examples/ladder/stage3_new_node.py)).
Expand Down Expand Up @@ -228,7 +246,7 @@ specification guarantees; behaviour is what the battle is for.

The specification is the reviewable artifact. Review the diagram and the contracts, and the
agent's job narrows to step bodies satisfying a declared input and output type for a named role,
with `check()` as the acceptance test.
with `coherence_check()` as the acceptance test.

A proposed change to the workflow itself is then a diff to `nodes` and `edges` — one small place,
reviewed on its own, not a behaviour change buried in a function body.
Expand Down
4 changes: 2 additions & 2 deletions docs/ladder.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,14 @@ class HelloWorld(GraphSpec):
```

Both print `'Hello, Ada!'` and both have the node ids `pick`, `compose`. On this rung the
declaration buys you `check()` and `diagram()` before any implementation exists, and nothing
declaration buys you `coherence_check()` and `diagram()` before any implementation exists, and nothing
else — it starts paying on rung 2, when `pick` has two implementations and something has to hold
them to one shape.

| rung | adds | source |
|---|---|---|
| 0 | nothing — Pydantic Graph alone, the control | [`their_hello.py`](../examples/ladder/their_hello.py) |
| 1 | the design as data; `check()` and `diagram()` with nothing implemented | [`stage1_bare.py`](../examples/ladder/stage1_bare.py) |
| 1 | the design as data; `coherence_check()` and `diagram()` with nothing implemented | [`stage1_bare.py`](../examples/ladder/stage1_bare.py) |
| 2 | **two strategies over one design**, with identical node ids | [`stage2_strategies.py`](../examples/ladder/stage2_strategies.py) |
| 3 | a new node — and a strategy that predates it is refused | [`stage3_new_node.py`](../examples/ladder/stage3_new_node.py) |
| 4 | one node implemented by a **whole child design** | [`stage4_subgraph.py`](../examples/ladder/stage4_subgraph.py) |
Expand Down
65 changes: 65 additions & 0 deletions docs/migration-0.3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Upgrading to 0.3.0

Two changes. One is a rename you must make; the other needs nothing from you.

## 1. `check()` → `coherence_check()` — required

```python
spec.check() # 0.2.0
spec.coherence_check() # 0.3.0

spec.check(strategy) # 0.2.0
spec.coherence_check(strategy) # 0.3.0
```

**There is no alias.** A missed call site raises `AttributeError: 'YourSpec' object has no
attribute 'check'` at the call — loud, and at the line that needs editing. 0.2.0 made the same
choice when `NodeSpec(...)` became a `TypeError`: a silent narrowing would be worse than a stop.

```bash
grep -rn "\.check(" --include=*.py . # every site, and there is nothing else named .check(
sed -i 's/\.check(/.coherence_check(/g' <files>
pytest -q
```

**Unchanged, and deliberately so:**

| | |
|---|---|
| `check_names`, `check_reachable`, … the eleven functions | unchanged — each *is* one check |
| `CoherenceFinding.check` | unchanged — it names which of those eleven produced the finding |
| `render()`, `diagram()`, `diff_diagram()`, `varies()`, `eval_battle()` | unchanged |

### Why

`check()` did not say what it checks, and the type it returns said `Coherence` — a word that
appeared nowhere else in the API. One concept, two names.

`design_check()` was considered and rejected: `spec` *is* the design, so `spec.design_check()`
restates its own receiver, the way `file.file_close()` would.

## 2. `coherence_check()` returns `CoherenceFinding` — nothing to do

`CoherenceFinding` is a `str` subclass, so every string operation on a finding behaves exactly as
it did. Verified byte-for-byte across all 64 findings this repo's designs produce.

```python
f = spec.coherence_check(strategy)[0]

f == "the raw message" # True, as before
"unreachable" in f # as before
"\n".join(findings) # as before
f.startswith("NOT CHECKED") # as before — and `f.blocking` now says the same thing as a field

f.check # 'check_bindings' — which check produced it
f.about # 'compose'; 'source->target' for an edge; '' for the whole design
f.blocking # False only for a `NOT CHECKED — …` stated gap
```

The prefix match is still correct and still supported. `blocking(findings)` is the shared filter
`render()` uses, if you would rather not spell it out:

```python
from workflow_workbench import blocking
blocking(spec.coherence_check(strategy))
```
2 changes: 1 addition & 1 deletion docs/parity.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# What a `GraphSpec` can express — every Pydantic Graph builder feature, enumerated

`GraphSpec` declares a workflow as DATA, because data is the only form `check()` and `diagram()`
`GraphSpec` declares a workflow as DATA, because data is the only form `coherence_check()` and `diagram()`
can read before any implementation exists. That buys the checks and the diagrams, and it costs
expressiveness: a few things Pydantic Graph lets you write in code cannot be written down.

Expand Down
6 changes: 3 additions & 3 deletions docs/probe_builder_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
does it RUN? can the feature be reached at all from a GraphSpec, if necessary through
`build_pydantic_structure()`
is it DECLARED? is it in `nodes`/`joins`/`decisions`/`edges` as DATA — which is the only
form `check()`, `diagram()`, `diff_diagram()` and `varies()` can read
form `coherence_check()`, `diagram()`, `diff_diagram()` and `varies()` can read

⚠️ The second is the whole product. An escape-hatch topology runs perfectly and is invisible to
every check this library exists to provide — `check()` says so out loud (`NOT CHECKED — ...
every check this library exists to provide — `coherence_check()` says so out loud (`NOT CHECKED — ...
overrides build_pydantic_structure()`), and the middle section measures exactly that.

⛔ THE TABLE IS `workflow_workbench/parity.py`, AND IT IS CHECKED AGAINST THE REAL API. It was
Expand Down Expand Up @@ -224,7 +224,7 @@ async def dbl(ctx) -> int:


only = StrategySpec("only", {double: dbl})
print(f"check() -> {Declarative().check(only) or 'clean, reachability VERIFIED'}")
print(f"coherence_check() -> {Declarative().coherence_check(only) or 'clean, reachability VERIFIED'}")
print(f"hook to override the wiring? "
f"{hasattr(GraphSpec, 'build_pydantic_structure')}")
print(" ⛔ There was one. It was the ONLY way a built graph could differ from its declaration,")
Expand Down
4 changes: 2 additions & 2 deletions examples/counter.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ async def times_three(ctx) -> int:
def main() -> None:
spec = Counter()

findings = spec.check()
print(f"check() with no strategy at all: {findings or 'clean'}")
findings = spec.coherence_check()
print(f"coherence_check() with no strategy at all: {findings or 'clean'}")

for strategy in (modest, aggressive):
graph = spec.render(strategy)
Expand Down
4 changes: 2 additions & 2 deletions examples/greeting.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ def evaluate(self, ctx: EvaluatorContext) -> float:
def main() -> None:
spec = Greeting()

print("1. check() with nothing implemented:", spec.check() or "clean")
print("1. coherence_check() with nothing implemented:", spec.coherence_check() or "clean")
print("\n2. the specification, drawn from the declaration:")
print(spec.diagram())

Expand All @@ -127,7 +127,7 @@ def main() -> None:

print("\n4. an incomplete strategy — `compose` left unbound:")
unfinished = StrategySpec("unfinished", {normalize: trim_and_collapse})
for finding in spec.check(unfinished):
for finding in spec.coherence_check(unfinished):
print(f" check finding: {finding}")
try:
spec.render(unfinished)
Expand Down
2 changes: 1 addition & 1 deletion examples/ladder/stage10_no_basenode.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ async def do_triage_permissive(ctx) -> object:

def main() -> None:
spec = Intake()
print(f"check(): {spec.check(careful) or 'clean — gate, loop and dispatch, all declared'}\n")
print(f"coherence_check(): {spec.coherence_check(careful) or 'clean — gate, loop and dispatch, all declared'}\n")

graph = spec.render(careful)
for text in ("my cat is unwell", "metformin 1000 mg daily"):
Expand Down
4 changes: 2 additions & 2 deletions examples/ladder/stage1_bare.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

What you get already, and cannot get from a built Graph:

HelloWorld().check() runs with NO strategy and NO implementations
HelloWorld().coherence_check() runs with NO strategy and NO implementations
HelloWorld().diagram() draws the design before anything is written

uv run python3 -m examples.ladder.stage1_bare
Expand Down Expand Up @@ -88,7 +88,7 @@ def main() -> None:

# ⚠️ No strategy, no implementations, no engine. This is the thing a built Graph cannot do,
# because a built Graph cannot exist until every function is written.
print(f"check() with nothing implemented: {spec.check() or 'clean'}")
print(f"coherence_check() with nothing implemented: {spec.coherence_check() or 'clean'}")

graph = spec.render(formal)
state = Guest()
Expand Down
4 changes: 2 additions & 2 deletions examples/ladder/stage8_join.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ class BrokenGreetings(Greetings):

def main() -> None:
spec = Greetings()
print(f"check(): {spec.check(greet) or 'clean'}")
print(f"coherence_check(): {spec.coherence_check(greet) or 'clean'}")

state = Guest()
print(f"run('Ada') -> {spec.render(greet).run_sync(inputs='Ada', state=state)!r}")
Expand All @@ -132,7 +132,7 @@ async def collect_step(ctx) -> list:

broken = StrategySpec("broken", {say_formal: formal, say_casual: casual,
collect_as_step: collect_step, announce: announce_both})
for finding in BrokenGreetings().check(broken):
for finding in BrokenGreetings().coherence_check(broken):
print(f" refused: {finding[:110]}...")
try:
BrokenGreetings().render(broken)
Expand Down
6 changes: 3 additions & 3 deletions examples/ladder/stage9_decision.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ async def do_report(ctx) -> str:

def main() -> None:
spec = Triage()
print(f"check(): {spec.check(careful) or 'clean — including reachability, through branches'}\n")
print(f"coherence_check(): {spec.coherence_check(careful) or 'clean — including reachability, through branches'}\n")

graph = spec.render(careful)
for text in ("chest pain since this morning", "dry skin on my elbow"):
Expand Down Expand Up @@ -150,7 +150,7 @@ class NoWhen(Triage):
EdgeSpec(source=research, target=report, carries=handled),
EdgeSpec(source=report, target=END, carries=report_out))

for finding in NoWhen().check(careful):
for finding in NoWhen().coherence_check(careful):
print(f" {finding[:118]}...")

class StrayWhen(Triage):
Expand All @@ -163,7 +163,7 @@ class StrayWhen(Triage):
EdgeSpec(source=research, target=report, carries=handled),
EdgeSpec(source=report, target=END, carries=report_out))

for finding in StrayWhen().check(careful):
for finding in StrayWhen().coherence_check(careful):
print(f" {finding[:118]}...")

try:
Expand Down
2 changes: 1 addition & 1 deletion examples/local/extraction.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ async def keep_confident(ctx) -> list[Fact]:

def main() -> None:
spec = Extraction()
print(f"check(): {spec.check(greedy) or 'clean'}")
print(f"coherence_check(): {spec.coherence_check(greedy) or 'clean'}")

for strategy in (greedy, strict):
graph = spec.render(strategy)
Expand Down
4 changes: 2 additions & 2 deletions examples/parallel.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ async def cube(ctx) -> int:
def main() -> None:
spec = ParallelProcessing()

print(f"check() with no strategy: {spec.check() or 'clean'}")
print(f"check(squares): {spec.check(squares) or 'clean'}")
print(f"coherence_check() with no strategy: {spec.coherence_check() or 'clean'}")
print(f"check(squares): {spec.coherence_check(squares) or 'clean'}")
print(" ⚠️ neither says NOT CHECKED. A fan-out design is now checked like any other.\n")

for strategy in (squares, cubes):
Expand Down
2 changes: 1 addition & 1 deletion examples/subgraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ def main() -> None:
deps = ExtractionDeps()

print(f"design checks clean with no strategy at all: "
f"{ExtractionWorkflow().check() or 'yes'}")
f"{ExtractionWorkflow().coherence_check() or 'yes'}")

# The child stands on its own. If it did not, it would be a fragment, not a design.
child_state = ExtractionState()
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "workflow-workbench"
version = "0.2.0"
version = "0.3.0"
description = "One fixed graph design, many competing implementations — checked, diagrammed, and battled on Pydantic Evals"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
Loading
Loading