Skip to content

Three states, not two: when a stage should become a nested graph #12

Open

Description

@borisdev

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."

Relates to #10.

Activity

  1. borisdev commented on Oct 6, 2026

    @borisdev
    OwnerAuthor

    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
    from pydantic_evals import Case, Dataset
    from pydantic_evals.evaluators import Evaluator, EvaluatorContext
    
    from workflow_workbench import (END, START, EdgeSpec, GraphSpec, StepSpec, StrategySpec,
                                    VariableSpec)
    from workflow_workbench.checks import _port_type
    from workflow_workbench.evals import eval_battle
    
    raw = VariableSpec("raw_name", str)
    clean = VariableSpec("clean_name", str)
    normalize = StepSpec("normalize", inputs=(raw,), outputs=(clean,))
    
    
    def step_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]))
        return type("StepGraph", (GraphSpec,), {
            "name": f"step:{step.name}", "nodes": (step,), "edges": edges,
            "input_type": step.inputs[0].type, "output_type": step.outputs[0].type})()
    
    
    async def trim(ctx) -> str:
        return ctx.inputs.strip()
    
    
    async def trim_and_collapse(ctx) -> str:
        return " ".join(ctx.inputs.split())
    
    
    class ExactMatch(Evaluator):
        def evaluate(self, ctx: EvaluatorContext) -> float:
            return 1.0 if ctx.output == ctx.expected_output else 0.0
    
    
    spec = step_graph(normalize)
    arms = [StrategySpec(fn.__name__, {normalize: fn}) for fn in (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 path
    fork = 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions