Skip to content

A Step declares, a Strategy implements, a Runner executes - #4

Open
borisdev wants to merge 12 commits into
mainfrom
step-declares-strategy-implements
Open

A Step declares, a Strategy implements, a Runner executes#4
borisdev wants to merge 12 commits into
mainfrom
step-declares-strategy-implements

Conversation

@borisdev

Copy link
Copy Markdown
Owner

A Step declares, a Strategy implements, a Runner executes

Four ChatGPT handoffs about this repo, consolidated. They converge on one decision, contradict each
other in three places, and one of them contradicts this repo's own vocabulary. This doc is the
decision, the disagreements resolved, and what was measured rather than argued.


The decision, in code

  class Step(Generic[InputT, OutputT]):
      inputs:  ClassVar[tuple[Variable[Any], ...]] = ()
      outputs: ClassVar[tuple[Variable[Any], ...]] = ()

-     def run(self, **values: Any) -> Any:
-         """Execute. Keyword arguments are the input Variables, by name."""
-         raise NotImplementedError
# how it is performed — outside the declaration, chosen per execution
fast    = {MakeOutline: outline_by_sentence, Summarize: summarize_fast}
precise = {MakeOutline: outline_by_sentence, Summarize: summarize_precise}

execute(plan, {"document": doc}, LocalRunner(fast))
execute(plan, {"document": doc}, LocalRunner(precise))

The plan object is not touched between those two lines.


Why — the argument that actually carries it

The weak argument is YAGNI: nothing called run(), so delete it. True, and it sounds like tidying.

The real argument is that the signature was a false claim about the domain:

def run(self, **values: Any) -> Any:   # the implementation is A METHOD ON THIS CLASS
    raise NotImplementedError          # so there is one, and any second is an override

ExtractClaims is one stable operation with several ways to perform it — a cheap model, an
expensive one, an ensemble — and none of them is the real one that the others override. A method
on the class privileges whichever implementation got there first.

The alternative people reach for when the method is in the way is worse:

class ExtractClaimsV1(Step): ...    # not three operations.
class ExtractClaimsV2(Step): ...    # three names for one concept —
class ExtractClaimsEnsemble(Step): ...  # `naming.naming_drift`, committed inside the
                                        # package built to report it.

So the implementation moves out of the declaration, and which implementation runs becomes a
property of an execution rather than of the process.


⚠️ One word was already taken, and three of the handoffs used it wrong

Every handoff calls the Step→implementation mapping "bindings". This repo already has that word:

binding    how VARIABLES connect STEPS      plan_types/plan/bindings.py — producers, consumers,
                                            edges, execution_order
???        which IMPLEMENTATION performs     the new thing
           a Step

Two concepts, one word, in one package. That is naming.ambiguous_referenceone name, many
concepts
— and it would have shipped inside the module written to catch it.

Shipped as Strategy, which is the handoffs' own gloss ("different strategies for performing
the same operation"
) and is what makes plan_types/plan/bindings.py still mean exactly what it
meant. One rename reverses it if you disagree; the thing that must not happen is the collision.

Grounding, stated plainly: P-Plan and PROV-O have no term for "the code that will perform this
Step if it runs."
p-plan:Step is the intended operation, prov:Activity is one execution of it,
and this is neither. So Strategy is ours, deliberately uncited — the same call as Step.uses, and
for the same reason.


Where the four handoffs disagree

question the positions resolution, and why
where the implementation attaches A: Step.run(), "portable domain vocabulary" · B: not on Step, bind() + callable · C: step.impl, called by a Protocol · D: a bindings map, resolved by a runner D. A privileges one implementation. C puts strategy back on the declarative graph, which C's own author warns against three paragraphs later. B and D are the same idea; D is the one with a worked eval-arm example.
sync or async B: "PlanTypes should remain sync/async-neutral" · C, D: async def run(...) in the Protocol Sync. An async def on the Protocol is not neutral — it forces every implementation of every Step to be a coroutine, including lambda x: x + 1. An AsyncStepRunner when a real async implementation exists.
how the implementation is called D: extract_claims_v1(paper: Paper) — positional · D, same page: impl(**inputs) — keyword Keyword, and Variable.name therefore becomes part of the contract. A Step with three inputs called positionally is one reorder away from a mis-wire the types cannot catch when two inputs share a type. check_strategy reports a parameter-name mismatch at declaration time.
repo layout A: top-level plan/ + execution/ Adopted as plan_types/plan/ + plan_types/execution/, which is where plan/ already was.
runtime state and deps A: EvidenceBuildState, NoBSmedDeps injected per execution Not built. No caller. Note that plan_types/plan/service.py is already the plan-time half — what a Step needs reachable — and typing.plan_time_only refuses the runtime half onto plan types by rule.

Evidence — measured before the change, not argued after

Nothing called run(). Not in plan_types/, tests/, evals/ or examples/.

The only implementation of it in this repo did not import.
examples/evidence_case_graph/flow.py still declared consumes/produces, retired 2026-08-16:

TypeError: ParseStudyStep.consumes is retired — use `inputs`, a TUPLE of Variables.

It had been dead since the P-Plan DAG correction, and no test imported it, so no check could
have caught that or the second fault on the line below — def run(self, value: Study), positional,
against a base class whose docstring warns positional args silently mis-wire. An example nobody
imports is not an example, it is a claim. tests/test_execution.py::test_examples_import_and_run
now runs both examples end to end.

The type contradicted its own module. plan_types/plan/plan.py has said this the whole time:

⚠️ run() is not the centre of this … An execution adapter wraps Steps from outside. Never the
reverse.

Downstream, 15 overrides that all raise. nobsmed's plans.py, every one
raise NotImplementedError(_DECLARATION_ONLY).


What shipped

plan_types/plan/step.py          run() removed; `run` added to _RETIRED
plan_types/execution/strategy.py Strategy, Implementation, check_strategy
plan_types/execution/runner.py   StepRunner Protocol — sync, runtime_checkable
plan_types/execution/local.py    LocalRunner, execute, ExecutionError
examples/hello/flow.py           NEW — domain-free, two arms, the README's 60 seconds
examples/evidence_case_graph/    rewritten to nobsmed's real fan-in shape, two arms
tests/test_execution.py          NEW — 20 tests
scripts/check_wheel.py           plan_types.execution added to REQUIRED
README.md                        60 seconds, the four-layer diagram, Status

142 tests pass, up from 122.

_RETIRED, not deletion. Deleting run() would let a subclass declare it again and get the
privileged implementation back with nothing to say so. The error names its replacement:

TypeError: AskModelForCausalMap.run is retired — use a Strategy. a Step DECLARES a
transformation; it does not perform one. A method here is one implementation privileged over
every other, so a second way of doing the same operation becomes an override rather than a peer.

⚠️ The old __init_subclass__ built its message with 'Input' if old == 'consumes' else 'Output',
so retiring a third name would have explained it as being about hasOutputVar. Each retired
name now carries its own sentence.

What the runner refuses, loudly, rather than accepting quietly:

why
an async def implementation returns a coroutine — truthy, with a repr, flowing onward as though it were data
a Step with 2 outputs whose impl returns 1 value else one Variable holds the whole tuple and the mismatch surfaces three Steps later
a Step with 0 outputs whose impl returns something discarding it hides a wrong declaration or a wrong implementation
a produced value of the wrong declared type plain classes only — parameterized generics are reported NOT CHECKED, never as a pass
two Variables sharing a name a Variable is (name, type), so two can differ in type and collide in a name-keyed environment

⚠️ Breaking change for nobsmed — measured, and it corrects my own earlier note

I previously wrote that nobsmed's 15 run() overrides "still import and work as ordinary methods."
That is wrong. It described deleting run(); what shipped retires it, which is stricter. Run
against the built wheel, using plans.py's exact class shape:

BREAKS AT IMPORT: TypeError: AskModelForCausalMap.run is retired — use a Strategy...
without run(): Plan('arm', 1 steps, 2 variables)

Contained by the pin. nobsmed has plan-types = { git = ..., tag = "v0.6.0" }
(pyproject.toml:68), so nothing moves until that tag is bumped — deliberately, not by luck.

On bump, one nobsmed commit fixes all of it:

site what happens fix
plans.py — 15 def run(...) overrides ImportError at module import delete all 15
plans.py_DECLARATION_ONLY orphaned delete
test_plans.py:56-65 — asserts run() raises tests a method that no longer exists delete the test
plan_diagram.py:190 — "Every run() raises" stale prose reword
anything calling Step.run none exist — verified by grep

A loud break is the right one here. The quiet alternative leaves 15 methods in the file that a
reader reasonably takes as evidence that Steps execute — which is the thing that was false. And the
fix is mechanical: delete, do not rewrite.


Advice — where I would push back on the handoffs

"A dictionary plus a LocalRunner is sufficient to prove the architecture." It proves the
architecture; it does not give it a consumer. nobsmed's builders are not decomposed into Steps
(plans.py says so and calls it separate work with its own risk), so nothing in production binds
a Strategy yet.
The examples and tests are real; the production caller is not there. Worth
knowing before this reads as load-bearing.

Handoff D §1 asks to edit nobsmed's plans.py. Different repo, different PR, and gated on the
tag bump above.

Step(Generic[InputT, OutputT]) is still declared and is now entirely vestigial. InputT and
OutputT are unused — the README already says that one-in-one-out signature "died to a real
builder." Left alone here to keep this diff about one thing; it should go.

Do not add the Temporal / LangGraph / Pydantic Graph adapters yet. Each is a real dependency
bought to serve zero callers. The Protocol is three lines; an adapter can be written the day someone
has a workflow that needs durability.


Not built, and what would justify each

  • AsyncStepRunner — one real async implementation to run. Today the honest state is that async
    is refused, not supported, and it says so at the call site.
  • Strategy per Step instance — a Plan holding two instances of one Step class that need
    different implementations. Keyed on the class today.
  • Return-type checking for genericslist[Finding] cannot be tested by isinstance. Reported
    NOT CHECKED. A real mis-wire that slipped through would justify a deeper check.
  • Runtime state / dependency injection (EvidenceBuildState, NoBSmedDeps) — a Step
    implementation that genuinely needs per-execution context beyond its closure.

Deliberately out of scope

The A/B counterfactual experiment from the process-spec handoff: fork an official Pydantic Graph
example, evolve it with Claude Code alone vs Claude Code + PlanTypes, same requirement changes to
both arms, and measure. That is GTM evidence, not this decision, and it deserves its own issue.

docs/counterfactual.md's rule applies to it in advance: no_effect and refutes are results.
The claim to test is does a separate typed process specification help a coding agent preserve
coherence as requirements evolve
— not PlanTypes makes Claude better.

Ubuntu and others added 12 commits August 20, 2026 20:44
`Step.run()` asserted that a Step has ONE implementation and that it is a method
on the class. It does not. `ExtractClaims` is a stable operation with several ways
to perform it, and none of them is the real one the others override.

Removed, and RETIRED rather than deleted — a subclass declaring `run` again would
get the privileged implementation back with nothing to say so.

Measured before the change, not argued after:
  - nothing called it: not in plan_types/, tests/, evals/ or examples/
  - examples/evidence_case_graph/flow.py, its only implementation here, did not
    import AT ALL — it still declared `consumes`, retired 2026-08-16 — and its
    run() took a POSITIONAL arg against the keywords-only contract. No test
    imported it, so no check could have caught either fault. Both examples now
    run in tests/test_execution.py.
  - plan_types/plan/plan.py has said "run() is not the centre of this" and
    "an execution adapter wraps Steps from outside, never the reverse" throughout.

New: plan_types/execution/ — Strategy (a mapping), check_strategy (static, and it
says NOT CHECKED when it cannot read a signature), the StepRunner Protocol, and
LocalRunner. Sequential, in-process, no retries. It refuses an async
implementation rather than returning an un-awaited coroutine that flows onward as
though it were data.

⚠️ Named Strategy, not `bindings`, which the four handoffs all used: this repo
already spends that word on how VARIABLES connect STEPS
(plan_types/plan/bindings.py). One name, two concepts is the
naming.ambiguous_reference this package exists to report.

⚠️ BREAKING for nobsmed on a tag bump — measured against the built wheel, not
inferred: plans.py fails at IMPORT, because its 15 declaration-only run()
overrides now hit the retirement guard. Contained by the v0.6.0 pin. The fix is
one commit there: delete the 15 methods, _DECLARATION_ONLY, and
test_plans.py:56-65. A loud break is right — the quiet alternative leaves 15
methods that read as evidence that Steps execute.

Also: the README's mermaid block was hand-edited (node ids `s0`/`s1`, which this
renderer has never emitted) and the test guarding it rebuilt an equivalent Plan
from local fixtures and grepped for three edges. It now asserts the block is
byte-identical to render_mermaid(examples.hello.flow.plan).

Full rationale, the four handoffs' three contradictions, and the nobsmed
migration: docs/handoffs-consolidated.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`grep -c` exits 1 when it counts ZERO. `set -e` was restored one line above the
count, and the step runs under `bash -e {0}`, so the job died before its first
echo — no findings printed, no "findings: N baseline: M", just exit 1.

main has been red since 2026-08-17, three runs, none of them about the code. The
baseline reaching 0 is what triggered it: the gate was green only while something
was wrong.

Reproduced under `bash -e` before changing anything:

    old   (silence)                     exit=1
    new   findings: 0   baseline: 0     exit=0

Fixed by leaving `set -e` off across the count rather than adding `|| true` — the
comment directly above explains why `|| true` is wrong here, and it is: it is how
the same gate in nobsmed counted a usage message as 0 findings and went green.
The RC check that comment protects is unchanged.

`.claude/rules/checks.md`: a check that goes red when everything is fine is one
nobody reads when it is right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two changes, both positioning.

**"Workflow" is the category word**, and the README never said it. LangGraph,
Temporal and Pydantic Graph all execute a workflow; this is the layer above them.
A cold reader could not place the package, because the one word that would have
placed it was missing — P-Plan is the grounding, not the pitch (docs/gtm.md
already says do not lead with the ontology).

Also states the reason for the split in the terms that motivate it: cognitive
load. Working inside an execution framework means reasoning about domain
concepts, schemas, step boundaries, dependencies, async, retries, timeouts,
workers and serialization at once, and half of those have nothing to do with
whether the process is right.

**"Open question, deliberately" is now answered.** Semantic invariants as the
general product needs a corpus of trial and error that does not exist, and is
hard to grasp before it is demonstrated. PlanTypes is the product; conceptlint is
a supporting linter. The code stays arranged so it could still split out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ntimes

The claim this package makes is that the process specification and the execution
machinery are separable. Until now that was prose. `to_pydantic_graph(plan,
strategy)` makes it executable: the same Plan and the same Strategy run under
LocalRunner and under Pydantic Graph, and tests/test_pydantic_graph.py asserts
the two answers are identical.

That is also the pitch to anyone who already has a workflow engine: this does not
compete with one. Decide whether the process is RIGHT — before retries, workers,
state and serialization are in the room — then hand it over unchanged.

The compile is honest about its limits, in the docstring and in tests:
  - acyclic only. execution_order raises on a cycle rather than inventing an
    order, and a cyclic plan is exactly where their engine is the right answer.
    Pydantic Graph's own three examples are all cyclic state machines.
  - no Fork/Join yet. Pydantic Graph 2.x has both, and a Plan's bindings already
    say which Steps are independent, so parallelising is DERIVABLE rather than
    declarable — which is the version worth building, and it is not built.
  - no state/deps. A Step implementation's dependencies live in its closure.

pydantic-graph is an OPTIONAL extra. plan_types/plan/ imports no execution
framework and a test now asserts that by reading the source, rather than trusting
that nobody added one.

The worked example is Pydantic Graph's own email-feedback workflow, in its acyclic
core, plus the one thing none of their three examples has: a fan-in. `Revise`
needs the draft AND the critique of it. In a node-returns-next-node model the
draft has to be carried in mutable state to still be there two hops later; as an
edge it is visible in the declaration and topology.bound_inputs can check it.

README's "Not built: ... Pydantic Graph" line was false the moment this landed, so
it moved to "Also works today". A status section that overstates is the failure
this package exists to report.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Working against Pydantic Graph's own documented examples, copied verbatim, so
the comparison is against what they actually ship rather than a strawman.

STAGE 1 — examples/pydantic_graph_docs/stage1_counter.py
Their `simple_counter.py` from the Quick Start, unchanged, is in the file next to
ours. Three results asserted equal: their hand-wired GraphBuilder, our Plan on
LocalRunner, and our Plan compiled onto their runtime. All 2.

The one substantive difference is not cosmetic and is worth the whole stage:
their `increment` declares its input as None and threads the counter through
`ctx.state.value += 1`. Ours makes it an EDGE, so the dependency is in the
diagram rather than in a function body. Same answer either way.

Note the render shows `topology.bound_inputs: NOT CHECKED` on their counter,
because that Plan genuinely has no inputs. It reports what it did not check
rather than a green tick.

STAGE 2 — examples/pydantic_graph_docs/stage2_strategies.py
Their `parallel_processing.py` domain. One Plan, constructed once, three
implementations of ONE Step, and an eval table:

  input         expected     exact    repeated_addition   cheap_approximation
  [12]               144       144 ok           144 ok            120 WRONG
  [3, 20]            409       409 ok           409 ok            209 WRONG

The rows are comparable because the Plan is the same OBJECT across arms — a test
asserts that by identity, not equality, since a Plan rebuilt per arm could drift
a Step and still compare equal under a weaker check.

render_mermaid(plan, strategy) — new, and the point of it is visual: the three
arms render with identical topology and different implementation labels, so
"the logical process was held constant, only the implementation changed" is
something a reader can check by looking. A test strips the labels and asserts the
three renders are byte-identical underneath.

An unbound Step gets NO label rather than "unbound" or "?" — a diagram that
invents a label is asserting something about a Strategy nobody wrote.

Still to come: stage 3 (variants that add nodes while sharing most), then the
control arm — the same three stages written directly against GraphBuilder with no
Plan layer, which is the only way to answer whether any of this is easier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The old message was "there is no execution order to return", which reads as a
limitation of Kahn's algorithm. It is not, and believing it sends the next person
to write a cyclic scheduler.

Given a perfect cyclic scheduler this would still loop forever, because a Plan
does not carry a TERMINATION PREDICATE. pydantic-graph puts that in the node body
(`-> Next | End[T]`), which is the right place for it and is theirs. Adding
Decision/branch/End here would be rebuilding what GraphBuilder already owns.

Verified what a cyclic Plan can already do, since the refusal is narrower than it
sounded: construction, edges, render_mermaid and topology.acyclic ALL work on one
today. Only execution refuses. `Plan` was never a DAG and this does not change
that.

The error now names the route that would work instead of only saying no: collapse
each strongly connected component into a MultiStep (p-plan:MultiStep, a Plan that
appears as a Step). An SCC condensation is always a DAG, so the outer Plan
compiles and the loop inside goes to their state machine. Not implemented —
named, so the refusal points somewhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`class MultiStep(Plan)` — one parent. `Plan.__post_init__` requires every entry
in `steps` to be a Step, so nesting a Plan inside a Plan raised. The docstring
cited p-plan:MultiStep as rdfs:subClassOf BOTH Plan and Step; the code
implemented one of the two. Nothing caught it because nothing had ever nested a
Plan — the exact shape of failure provenance.grounded_citation was written for,
in the class that carries the IRI.

Now `class MultiStep(Plan, Step)`. Its ports are DERIVED: Plan.inputs and
Plan.outputs already mean free and terminal Variables, which is what this node
consumes and produces. Declaring them again would be a second source of truth.
Step.__init_subclass__ now skips a port that is a descriptor rather than a tuple,
because "derived" is a legal answer there.

Adds `until` — the termination predicate, plan-time only. A Plan that says
WriteEmail -> Feedback -> WriteEmail never says when to stop, so no scheduler
could run it however good; `until` names the Variable whose value decides. The
TEST is a function and therefore an implementation, so it lives in the Strategy,
same split as Step. That keeps Decision/branching/End out of the Plan layer where
pydantic-graph and LangGraph already own them.

`until` is ours, deliberately uncited: P-Plan's 18 terms are Plan/Step/Variable
structure, PROV-O describes executions, and neither has a word for "this planned
region repeats until".

Deferred deliberately: SCC condensation as an analysis, the
terminating_iteration invariant, and compiling an iterative MultiStep. None is
needed for the MVP — both docs examples we run are acyclic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three variants that ADD nodes while sharing most, against the same three written
directly in GraphBuilder with no Plan layer. Both arms produce identical numbers,
asserted in a test — the comparison is worthless otherwise.

  input              baseline   filtered   weighted
  [1, 2, 50]             2505          5         10

MEASURED, wiring and declarations only, shared implementations excluded from both:

  with a Plan      12 lines   6 Step classes + 3 Plan constructions
  control          43 lines   3 build_* functions

  `async def sq`    declared 3x in the control, once with a Plan
  `async def total` declared 3x in the control, once with a Plan

At three variants the Plan layer is ~3.5x less wiring, and the duplication in the
control is the interesting part rather than the line count: three `sq` functions
that nothing says are the same operation, so a change to one is silent in the
other two.

What the control cannot do, and this is not a strawman — it is short, readable
and idiomatic:
  - answer "do these three share a contract?" without reading three functions.
    check_arms answers it by type.
  - produce a structural diff. `filtered -> weighted` reports shared
    {DropOutliers, Square}, added {Weight, TotalWeighted} off the declarations;
    the control's only diff is a diff of wiring code, where a renamed local looks
    like a changed process.

⚠️ AND WHERE THE PLAN LAYER LOSES, which belongs in the same commit:

`Total`, `TotalKept` and `TotalWeighted` are three Step classes doing one job —
sum a list — differing only in WHICH Variable they consume. That is three names
for one concept, the naming.naming_drift this package reports, caused by our own
design: a Step's port is bound to a Variable's identity, not its type.

The control has no such problem: its `total` takes whatever arrives. So explicit
typed dataflow bought the diff and the shared declaration, and charged a Step
class per wiring position. Whether a port should be declarable by TYPE is a real
design question and is not answered here.

Stage 1 remains the other honest data point: at one arm and two steps the Plan
layer was pure overhead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… actual docs

Boris was right and the first stage 3 was a strawman I built for myself. It
invented three topology variants (DropOutliers, Weight — neither in their docs)
to demonstrate structural diffing, and produced Total/TotalKept/TotalWeighted:
three Step classes doing one job. I read that as a design flaw and drafted a fix.

It was not a design flaw. All three were ONE Plan —

    numbers -> [transform] -> transformed -> [sum] -> total

— with three implementations of the transform. Two Step classes, not six. The
duplication was the naming rule REPORTING that I had drawn the Step boundaries in
the wrong place. The proposed fix is cancelled: its motivating case evaporated.

FAN-OUT, which was the real gap:

    class Square(Step):
        inputs, outputs = (number,), (squared,)   # int -> int, exactly their `square`
        map_over = (numbers, squares)             # list[int] in, list[int] out

Their vocabulary — map, join, reducer — because a Plan that fans out is
describing what pydantic-graph and LangGraph already have words for, and a third
word for it would be the drift this package reports.

It is declared on the Step because our edges are DERIVED: two Steps are connected
when they share a Variable, so there is no edge object to hang `.map()` on.

`wired_inputs`/`wired_outputs` in step.py is what keeps this from spreading: the
Plan sees `numbers -> Square -> squares` and only the runner sees the per-item
call, so no invariant had to learn about mapping and none of them could learn it
slightly differently. bindings.py, plan.py and the renderer all read it.

The compiler now emits their REAL primitives rather than a loop wearing the name:

    emit --.map()--> per-item step --> join(reduce_list_append) --> store

The Plan's environment moved into their `state`, which is what state is for and
how their own examples carry values across edges. So one declaration is a
sequential loop under LocalRunner and an actual parallel map on their engine,
with no edit. Their documented output, [1, 4, 9, 16, 25], is asserted on both.

MEASURED on their parallel_processing.py, three implementations of one Step:

    with a Plan    5 lines    2 Step classes; 3 arms are 3 dict entries
    control       37 lines    3 build_* functions
                              square 3x, total 3x, g.join( 3x, .map() 4x

Stage 1 remains the honest other end: one arm, two steps, the Plan layer is pure
overhead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
⚠️ RETRACTED: "5 lines with a Plan, 37 for the control". The 37 was three
copy-pasted builders. The obvious control parameterises, handles all three arms
in twelve lines, and produces identical results — verified. Measuring against
code nobody would write is not a measurement, and a reader who spotted it would
have been right to discard everything near it. control_no_plan.py is now the fair
version and there is no line-count claim anywhere.

What survives, and is now what the README leads with:

  THE PLAN EXISTS BEFORE THE CODE DOES. validate() and render_mermaid() run with
  nothing implemented — no prompt, no model, no function body. Their build()
  cannot produce a graph, a diagram or a type check until an implementation
  exists, because their node IS the implementation.

Adds a "How is this different from Pydantic Graph?" section that concedes first:
they own steps, typed execution, edges, branching, map, join, reducers, state,
DI, execution and rendering, and this compiles ONTO them. Then the two diagrams
of the same workflow, side by side, showing the two differences that are
modelling rather than rendering:

  - their edges carry no types, and cannot: an edge holds whatever the function
    returned and there is no name for it. Ours labels every edge with its Variable.
  - `map <<fork>>` and `reduce_list_append <<join>>` are NODES in their diagram.
    Theirs draws the machinery; ours draws the process.

Status now states the four objections up front — cycles do not run and why,
Fork/Join are not first-class, no async runner, and at one arm this is pure
overhead — plus the scale caveat: everything demonstrable here is small, and the
failure it is built for shows up at 18 pipeline variants, not at 2 steps.

CONCEPTLINT CAUGHT SOMETHING OF MINE, which is the first time this session:

  naming.ambiguous_reference: Square is declared twice with different shapes
    Square (stage2_strategies.py:34), Square (stage3_map_join.py:43)

Two Step classes with one name and different shapes, written an hour apart, in
the examples FOR this package. Fixed the way the finding says: one declaration in
their_example.py, imported by both stages — which is also the reuse the package
claims you get. Repo is back to its 6 pre-existing findings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…other

`uv run conceptlint plan_types/`. I put a duplicate `Square` in
examples/pydantic_graph_docs/ and nothing said a word — not CI, not me — until
Boris asked whether the semantic invariants were earning their place and I ran
the tool to answer.

Where it was reported: nowhere. It was never emitted.

The narrowing was deliberate and the reason was good — evals/minimal/ holds
before/after pairs that exist to BE duplicates, so linting them measures the
fixtures. But excluding a path excludes everything in it, and this one quietly
took examples/ and tests/ with it.

⚠️ `.conceptlint-baseline` already carried this exact warning about a DIFFERENT
directory: "a gate pointed at the wrong directory reads exactly like a clean
codebase." It happened again, one directory over, in the file that warns about it.

Now lints `.` with a baseline of 6, carrying the known-deliberate duplicates IN
the number where they are visible rather than behind a path exclusion where they
are not. Simulated before committing: findings 6, baseline 6, passes; it would
have gone red at 8 when my duplicate landed.

The step comment claimed "`conceptlint/` and not `.` on purpose" and is corrected
rather than left — a note that outlives its fix is how the last three of these
were read as current.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The P-Plan section explained the grounding as correctness discipline — we cite
IRIs and we check them. That is true and it was not the interesting half.

The interesting half is that a Plan is meant to be HANDED OVER, and a private
schema cannot be. `p-plan:Step` has a definition we did not write, at an address
anyone can resolve, so one artifact serves three audiences who never read each
other's code:

    owns WHAT THE PROCESS IS    writes and validates the Plan — no prompt, no
                                model, no function body, no runtime
    owns HOW IT IS PERFORMED    binds a Strategy
    owns HOW IT RUNS            picks the engine, concurrency, retries

The Plan a product owner argues about in a PRD is the same object an engineer
compiles onto Pydantic Graph. What makes it arguable — typed Steps, named edges,
no runtime detail — is what makes it compilable.

And standard-rather-than-ours is what leaves the door open past this package:
another tool that speaks P-Plan means the same thing by `Step`, and a model asked
for a `p-plan:Plan` is asked for a term with a published definition it can be held
to rather than a private shape inferred from examples.

⚠️ A DOOR, NOT A FEATURE, and the README says so in the same breath. There is no
RDF import or export — no Turtle, no JSON-LD, no rdflib anywhere in the package.
Checked before writing the paragraph, not assumed.

tests/test_plan_types.py now guards it in BOTH directions: the disclaimer must be
present, AND no RDF machinery may quietly appear that would make the disclaimer
stale by understating what ships. A claim about what is missing goes stale exactly
as silently as a claim about what exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant