You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Three states, not two: when a stage should become a nested graph #12
The proposal was: "If you cannot say what a good implementation looks like, it is not a step — it is a sub-design. Give it a boundary so it can be battled on its own."
⛔ ChatGPT's handoff rejects that slogan, and is right. It collapses two different failures:
reading
does a boundary help?
"I don't know which algorithm wins"
yes — battle them
"I don't know what result I want"
no. Worse: a boundary plus an eval suite produces a number over an unstated objective, and a number reads as signal
The second is the failure this library exists to catch. examples/contestable.py already has a test forbidding exactly that shape.
The replacement
objective stated?
settled by reading the code?
→
no
—
not ready to be anything. Go write the objective.
yes
yes
a step. A function; review is the oracle.
yes
no
a sub-design. Give it a boundary and battle it.
And the mechanism for row 1 already exists: StepSpec.problem. Non-empty = objective stated = you have left row 1. That is why empty must never read as "easy."
Keep these narrowings
A step dataset is not impossible — the current API just has no step runner. Verified: none exists.
A pattern, never a requirement._port_type refuses a multi-port boundary, so some stages cannot be promoted at all, and a rule that sometimes cannot be followed gets routed around.
A child's score is local. It is not evidence the enclosing workflow improved.
⚠️ Correction to the recorded proposal
It claims this closes three open decisions. It closes two — a node rubric, and per-stage scores. The third, an advisory blocking state, was wanted for near-duplicate name smells and has nothing to do with evaluation boundaries. Right answer there is a separate function with its own exit code, not a third state: coherence_check() keeps "every finding is a provable defect."
Measured: a step can be battled today, with zero library change
The objection on record was "only a GraphSpec has a public boundary you can build a dataset
against." I ran it instead of re-reasoning about it. A StepSpec wrapped in a one-node GraphSpec — START -> step -> END — checks clean, renders, runs, draws, diffs and battles:
coherence_check(), nothing implemented: []
labels: trim vs trim_and_collapse | spec_name: step:normalize
deltas: {'ExactMatch': 0.5}
replicate: True {'ExactMatch': 0.0}
diff_diagram() draws the single node highlighted, varies() reports it, per_case_spread()
takes a floor. Nothing in the library had to change — which settles that the limit was an
API we never wrote, not a property of a step. Boris's position stands; mine is withdrawn.
Four narrowings, all from running it
1. The input side is unambiguous, and that is enforced.check_step_arity refuses >1 input,
so every step the library admits has exactly one or zero.
2. The OUTPUT side is not, and this is the part I had wrong too. A 2-output step wraps
cleanly, coherence_check() returns [], and the run hands back ('x', 1) while the wrapper
declared one variable's type. The rule already exists — _port_type refuses a multi-output
boundary — it is just not on this path:
node 'fork' declares 2 outputs (clean_name, n), and a subgraph binding needs ONE — there is
no way to say which of them the child graph's output_type should match.
So a step battle must call _port_type rather than derive the boundary itself. A wrapper
that computes step.outputs[0].type inline is the repo's 7-for-7 defect again: a second,
narrower copy of a rule, written beside the thing it guards.
⚠️ That message says "a subgraph binding", which would no longer be the only caller. Rewording
it is a vocabulary change — flagging it, not making it.
3. A step with no output, or no input, cannot be wrapped — correctly. The existing checks
already refuse with the right sentences (no edge reaches END — this design produces no output.
/ no edge leaves START — nothing in this design can ever run.). A step with no output has
nothing to score, so this is the honest boundary of the feature rather than a gap to close.
4. ⛔ The synthesized input_type/output_type would be unverifiable. Nothing in the
library checks a graph's declared boundary types against the edges at that boundary — filed as #21, because it is a defect independent of this issue and check_subgraphs already consumes
that unchecked field as its oracle. A step-battle wrapper synthesizes exactly those two fields,
so #21 should land first or alongside.
What this does and does not settle
Three states, not two: when a stage should become a nested graph #12: the "sub-design" row of the three-state table loses its API justification. Promoting a
stage to a nested graph is no longer what buys you a battle — you can battle the step. What a
boundary still buys is reuse and a child with its own internal structure, which is a
weaker and more honest claim than the one in the table.
What distinguishes a pattern from an example here? #13: mostly untouched. The definition there requires a GraphImplementation with a Dataset and ≥2 strategies; a step battle changes the granularity at which that could be
stated, not whether the repo has any patterns. It still has zero.
Naming is not mine to call.eval_battle(step, ...) overloading on the first argument
versus a separate step_battle is a vocabulary decision — the house rule is propose, don't
rename. The one ergonomic fact worth having: a one-node battle has exactly one role to bind,
so the caller can hand over two implementations and the arm labels fall out of fn.__name__ (verified above — trim / trim_and_collapse came from nowhere else).
Repro — paste into the repo root as probe_step_battle.py and uv run python3 probe_step_battle.py
frompydantic_evalsimportCase, Datasetfrompydantic_evals.evaluatorsimportEvaluator, EvaluatorContextfromworkflow_workbenchimport (END, START, EdgeSpec, GraphSpec, StepSpec, StrategySpec,
VariableSpec)
fromworkflow_workbench.checksimport_port_typefromworkflow_workbench.evalsimporteval_battleraw=VariableSpec("raw_name", str)
clean=VariableSpec("clean_name", str)
normalize=StepSpec("normalize", inputs=(raw,), outputs=(clean,))
defstep_graph(step: StepSpec) ->GraphSpec:
"""A one-node design around `step`. Boundary types taken from the step's own variables."""edges= (EdgeSpec(source=START, target=step, carries=step.inputs[0]),
EdgeSpec(source=step, target=END, carries=step.outputs[0]))
returntype("StepGraph", (GraphSpec,), {
"name": f"step:{step.name}", "nodes": (step,), "edges": edges,
"input_type": step.inputs[0].type, "output_type": step.outputs[0].type})()
asyncdeftrim(ctx) ->str:
returnctx.inputs.strip()
asyncdeftrim_and_collapse(ctx) ->str:
return" ".join(ctx.inputs.split())
classExactMatch(Evaluator):
defevaluate(self, ctx: EvaluatorContext) ->float:
return1.0ifctx.output==ctx.expected_outputelse0.0spec=step_graph(normalize)
arms= [StrategySpec(fn.__name__, {normalize: fn}) forfnin (trim, trim_and_collapse)]
ds=Dataset(name="normalize", evaluators=[ExactMatch()], cases=[
Case(name="padded", inputs=" Ada Lovelace ", expected_output="Ada Lovelace"),
Case(name="inner_run", inputs="Ada Lovelace", expected_output="Ada Lovelace")])
res=eval_battle(spec, *arms, ds)
print("labels:", res.label_a, "vs", res.label_b, "| spec_name:", res.spec_name)
print("deltas:", res.deltas())
floor=eval_battle(spec, arms[0], arms[0], ds)
print("replicate:", floor.is_replicate, floor.per_case_spread())
print(spec.diff_diagram(*arms))
# narrowing 2: the rule exists, it is just not on this pathfork=StepSpec("fork", inputs=(raw,), outputs=(clean, VariableSpec("n", int)))
print("_port_type on a 2-output step:", _port_type(spec, fork, "output")[1])
Measured 2026-10-06 against 0643547, pydantic-graph 2.35.1.
Activity
borisdev commentedon Oct 6, 2026
Measured: a step can be battled today, with zero library change
The objection on record was "only a
GraphSpechas a public boundary you can build a datasetagainst." I ran it instead of re-reasoning about it. A
StepSpecwrapped in a one-nodeGraphSpec—START -> step -> END— checks clean, renders, runs, draws, diffs and battles:diff_diagram()draws the single node highlighted,varies()reports it,per_case_spread()takes a floor. Nothing in the library had to change — which settles that the limit was an
API we never wrote, not a property of a step. Boris's position stands; mine is withdrawn.
Four narrowings, all from running it
1. The input side is unambiguous, and that is enforced.
check_step_arityrefuses >1 input,so every step the library admits has exactly one or zero.
2. The OUTPUT side is not, and this is the part I had wrong too. A 2-output step wraps
cleanly,
coherence_check()returns[], and the run hands back('x', 1)while the wrapperdeclared one variable's type. The rule already exists —
_port_typerefuses a multi-outputboundary — it is just not on this path:
So a step battle must call
_port_typerather than derive the boundary itself. A wrapperthat computes
step.outputs[0].typeinline is the repo's 7-for-7 defect again: a second,narrower copy of a rule, written beside the thing it guards.
it is a vocabulary change — flagging it, not making it.
3. A step with no output, or no input, cannot be wrapped — correctly. The existing checks
already refuse with the right sentences (
no edge reaches END — this design produces no output./
no edge leaves START — nothing in this design can ever run.). A step with no output hasnothing to score, so this is the honest boundary of the feature rather than a gap to close.
4. ⛔ The synthesized
input_type/output_typewould be unverifiable. Nothing in thelibrary checks a graph's declared boundary types against the edges at that boundary — filed as
#21, because it is a defect independent of this issue and
check_subgraphsalready consumesthat unchecked field as its oracle. A step-battle wrapper synthesizes exactly those two fields,
so #21 should land first or alongside.
What this does and does not settle
stage to a nested graph is no longer what buys you a battle — you can battle the step. What a
boundary still buys is reuse and a child with its own internal structure, which is a
weaker and more honest claim than the one in the table.
GraphImplementationwith aDatasetand ≥2 strategies; a step battle changes the granularity at which that could bestated, not whether the repo has any patterns. It still has zero.
eval_battle(step, ...)overloading on the first argumentversus a separate
step_battleis a vocabulary decision — the house rule is propose, don'trename. The one ergonomic fact worth having: a one-node battle has exactly one role to bind,
so the caller can hand over two implementations and the arm labels fall out of
fn.__name__(verified above —trim/trim_and_collapsecame from nowhere else).Repro — paste into the repo root as
probe_step_battle.pyanduv run python3 probe_step_battle.pyMeasured 2026-10-06 against
0643547, pydantic-graph 2.35.1.