Skip to content
Open
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
23 changes: 12 additions & 11 deletions .conceptlint-baseline
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
# Findings in plan_types/ — the package this repo now ships. CI fails only if this rises.
# Findings across the WHOLE REPO. CI fails only if this rises.
#
# ⚠️ WAS 4, POINTED AT THE WRONG DIRECTORY. The baseline counted duplicates between
# `conceptlint/dataflow/` and `conceptlint/ontologies/pplan/concepts.py` — Step, Plan, Activity and
# Entity each declared twice, once as a P-Plan Concept and once as the implementation.
# ⚠️ WAS `plan_types/` ONLY, AND THAT IS HOW A DUPLICATE SHIPPED (2026-08-20).
# `Square` was declared twice with different shapes, in examples/pydantic_graph_docs/ — outside the
# gate — and sat there unreported until someone asked whether the linter was earning its place. The
# narrowing was deliberate, to dodge the intentional before/after pairs in evals/minimal/, and it
# silently took examples/ and tests/ with it.
#
# The PlanTypes refactor RESOLVED all four: `Concept` is gone, and Plan/Step/Variable exist once,
# in plan_types/plan/. The duplicate the tool reported about itself is the duplicate the redesign
# removed — which is the outcome, arriving quietly as a number going down.
# This file already carried that 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.
#
# But CI was still linting `conceptlint/`, now a near-empty shell, and would have reported 0
# forever whatever landed in plan_types/. A gate pointed at the wrong directory reads exactly like
# a clean codebase.
0
# So: lint `.`, and carry the known-deliberate duplicates in the number rather than by excluding a
# path. The six are 4 ontology-vs-implementation pairs (Plan, Step, Activity, Entity) and the 2
# before/after pairs in evals/minimal/sibling_refinement, which exist to BE duplicates.
6
20 changes: 14 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,13 @@ jobs:
# Dogfood. If this tool cannot survive being pointed at its own source, it has no business
# being pointed at anyone else's.
#
# `conceptlint/` and not `.` on purpose: evals/minimal/ holds deliberate before/after pairs,
# so linting them measures the fixtures rather than the code. Measured — 5 findings against
# `.`, 3 against the package, and both extras were eval twins.
- name: lint plan_types with itself
# `.` — the WHOLE repo. This said "`conceptlint/` and not `.` on purpose" until 2026-08-20,
# narrowed so evals/minimal/'s deliberate before/after pairs would not be counted. That
# narrowing also excluded examples/ and tests/, and a duplicate `Square` sat in
# examples/pydantic_graph_docs/ unreported until someone asked whether the linter earned its
# place. The intentional pairs are carried IN the baseline number instead, where they are
# visible, rather than by excluding a path, where the exclusion is invisible.
- name: lint the repo with itself
run: |
BASELINE=$(grep -vE '^\s*(#|$)' .conceptlint-baseline | head -1)

Expand All @@ -50,9 +53,14 @@ jobs:
# --import, or `declared()` returns [] and grounded-citation checks NOTHING while
# reporting green. Registration is subclassing, so a Concept in an unimported
# module does not exist — measured: without this the concept list was empty.
OUT=$(uv run conceptlint plan_types/ 2>&1); RC=$?
set -e
OUT=$(uv run conceptlint . 2>&1); RC=$?
# ⚠️ `set -e` stays OFF across the count. `grep -c` exits 1 when it counts ZERO, so with
# `bash -e` this line killed the job before its first echo — the gate went red precisely
# when plan_types/ was clean, and had done since the baseline reached 0 on 2026-08-17.
# Three red runs on main, none of them about the code. Still not `|| true`: that is the
# nobsmed failure the comment above names, where a usage message counted as 0 and passed.
FOUND=$(printf '%s\n' "$OUT" | grep -cE '^(naming|typing|topology|provenance)\.[a-z_]+:')
set -e
if [ "$RC" -ne 0 ] && [ "$FOUND" -eq 0 ]; then
echo "::error::conceptlint failed to run — this gate was measuring nothing."
printf '%s\n' "$OUT" | tail -20
Expand Down
250 changes: 180 additions & 70 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,66 +1,130 @@
# PlanTypes

**From Claude Code Plan Mode to Plan Types.**
**Declarative, typed workflow plans, separated from execution.**

Turn an agent's plan into a typed process specification — one you can validate, draw, and hold it to.
LangGraph, Temporal and [Pydantic Graph](https://pydantic.dev/docs/ai/graph/builder/) *execute* a
workflow, and each is good at it. This is the layer above: the workflow plan you settle — and can
validate, draw and argue about — **before** you pick an engine, or instead of picking one, since
plenty of workflows never need retries or durability.

Claude Code's Plan Mode says *"here's what I intend to do."* It's prose, it's gone when the conversation moves on,
and nothing checks that the code matches it. PlanTypes makes the same intent an artifact:
## The plan exists before the code does

That is the whole claim, and it is one snippet:

```python
document = Variable("document", Document)
outline = Variable("outline", Outline)
summary = Variable("summary", Summary)

class MakeOutline(Step):
inputs, outputs = (document,), (outline,)

class Summarize(Step):
inputs, outputs = (document, outline), (summary,) # fans in — needs both

plan = Plan(name="summarize_document", steps=(MakeOutline(), Summarize()),
declared_inputs=(document,))

validate(plan, [*topology.ALL, *typing.ALL]) # → []
print(render_mermaid(plan))
print(plan.shape()) # → ((Document,), (Summary,))
```
Plan
├── typed Steps
├── typed Variables
├── explicit dependencies
├── invariants
└── visualization

```mermaid
flowchart TD
IN_document(["document: Document"])
summarize_document_0["Make Outline"]
summarize_document_1["Summarize"]
OUT_summary(["summary: Summary"])
IN_document -- document --> summarize_document_0
IN_document -- document --> summarize_document_1
summarize_document_0 -- outline --> summarize_document_1
summarize_document_1 --> OUT_summary
classDef port fill:#fff,stroke:#333,stroke-width:1px,color:#333;
class IN_document,OUT_summary port;
```

> **Plan Mode describes intent. PlanTypes makes the plan typed, inspectable, and testable.**
**Not one line of that workflow is implemented.** No prompt, no model, no function body, no runtime.
The process is checked, drawn and type-checked anyway — because a Step declares *what a
transformation is*, and nothing more.

This is not a replacement for Claude Code or Cursor. It's the thing their plans should produce.
Every arrow is read from the bindings, so adding an input changes the picture with no edit to the
renderer. A hand-drawn diagram is a claim about the code that stops being true the moment a Step
moves, and nothing tells you.

## 60 seconds
## How it is performed comes later, and is chosen per run

```python
from plan_types import Plan, Step, Variable, render_mermaid, validate
from plan_types.invariants import topology, typing
def summarize_fast(document: Document, outline: Outline) -> Summary: ...
def summarize_precise(document: Document, outline: Outline) -> Summary: ...

fast = {MakeOutline: outline_by_sentence, Summarize: summarize_fast}
precise = {MakeOutline: outline_by_sentence, Summarize: summarize_precise}

PAPER = Variable("paper", ClinicalStudy)
FINDINGS = Variable("findings", list[Finding])
SUMMARY = Variable("summary", str)
execute(plan, {"document": doc}, LocalRunner(fast))
execute(plan, {"document": doc}, LocalRunner(precise))
```

class Extract(Step):
inputs, outputs = (PAPER,), (FINDINGS,)
**The `plan` object is not touched between those two lines.** An experiment can therefore say *the
logical process was held constant, only the implementation of `Summarize` changed* — and mean it,
because the same declaration served both arms.

class Summarize(Step):
inputs, outputs = (PAPER, FINDINGS), (SUMMARY,) # fans in — needs both
The alternative is to declare `SummarizeV1` and `SummarizeV2` as separate Steps, and that is not a
workaround: it is two names for one concept, the `naming.naming_drift` this package reports.

plan = Plan(
name="extract_and_summarize",
steps=(Extract(), Summarize()),
declared_inputs=(PAPER,), # what the Plan expects to be handed
)
## How is this different from Pydantic Graph?

It is not a competitor and does not want to be one. Pydantic Graph already owns steps, typed
execution, edges, branching, `map`, `join`, reducers, state, dependency injection, execution and
rendering — all of it well. `plan_types.execution.pydantic_graph` **compiles a Plan onto it**, using
its real primitives, and `tests/test_pydantic_graph.py` asserts both runtimes return the same
answer.

The difference is what a Step *is*. Theirs is executable — the function is both the node and the
implementation. Ours is a declaration, so several implementations are peers rather than one being
privileged and the rest overrides.

That shows up in the diagram. Same workflow — their `parallel_processing.py`, `map` → `square` →
join → total — rendered by each:

validate(plan, [*topology.ALL, *typing.ALL]) # → []
print(render_mermaid(plan))
```
THEIRS — graph.render() OURS — render_mermaid(plan)

stateDiagram-v2 flowchart TD
state map <<fork>> IN_numbers(["numbers: list"])
square Square
state reduce_list_append <<join>> Total
total OUT_total(["total: int"])

[*] --> map IN_numbers -- numbers --> Square
map --> square Square -- squares --> Total
square --> reduce_list_append Total --> OUT_total
reduce_list_append --> total
total --> [*]
```

```mermaid
flowchart TD
IN_paper(["paper: ClinicalStudy"])
s0["Extract"]
s1["Summarize"]
OUT_summary(["summary: str"])
IN_paper -- paper --> s0
IN_paper -- paper --> s1
s0 -- findings --> s1
s1 --> OUT_summary
```

Every arrow is read from the bindings. Add an input and the picture changes with no edit to the
renderer — a hand-drawn diagram is a claim about the code that stops being true the moment a Step
moves, and nothing tells you.
Two real differences, and neither is a rendering gap:

- **Theirs has no types on its edges.** It cannot: an edge carries whatever the function returned,
and there is no name for it. Ours labels every edge with the Variable that flows.
- **Theirs draws the machinery** — `map <<fork>>` and `reduce_list_append <<join>>` are nodes. Ours
draws the process; the fan-out is a property of `Square` and does not appear. Their picture
answers *how will this execute*, ours answers *what is this workflow*. Both are correct.

And the fan-out is one declaration on the Step, in their vocabulary:

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

Under `LocalRunner` that is a sequential loop. Compiled, it is their real `.map()` and
`join(reduce_list_append)`. **Same declaration, nothing edited.** Their documented output,
`[1, 4, 9, 16, 25]`, is asserted on both.

**→ [`examples/pydantic_graph_docs/`](examples/pydantic_graph_docs/)** — their own docs examples,
copied verbatim, run through this layer, with a control arm that uses no Plan at all.

## Why this exists

Expand Down Expand Up @@ -135,22 +199,40 @@ hidden the fact that the signature was wrong.

## The logical process first, the runtime later — or never

Four layers, and each one only knows about the one above it:

```
domain types
Plan / Step / Variable
invariants
visualization
optional execution adapters ── plain Python │ Temporal │ LangGraph
WHAT EXISTS Variable ── typed slot ┐
Step ── one operation │ plan-time
Plan ── how Steps compose │ imports nothing else
Service ── what must be up ┘

HOW IT IS PERFORMED Strategy ── {Step: implementation} chosen per execution
several per Step, none privileged

HOW IT IS RUN StepRunner ── Protocol LocalRunner ── here
Pydantic Graph ── here, optional extra
Temporal ── not built
LangGraph ── not built

WHAT ACTUALLY RAN prov:Activity, prov:Entity ⚠️ NOT modelled by this package
```

Most workflow systems fuse process design with orchestration semantics from the first line. PlanTypes
separates them, and the separation is the point: simpler debugging, fewer irrelevant runtime
concerns, and a specification a coding agent can change safely. Plenty of processes never need
retries or durability at all.
The arrows only point down. `plan_types.plan` imports nothing from `plan_types.execution`, which is
what lets a specification be read, validated and drawn with no execution backend in the room — and
`scripts/check_wheel.py` imports both from a built wheel outside the source tree, because a layering
claim that only holds in an editable install is not a layering claim.

⚠️ **The bottom row is deliberately absent.** `p-plan:Step` is the intended operation;
`prov:Activity` is one execution of it. A runtime may map them one to one, and the moment code
believes that, *"the definition is wrong"* and *"that run failed"* become the same sentence with
opposite fixes.

Most workflow systems fuse process design with orchestration semantics from the first line.
PlanTypes separates them, and the separation is the point: simpler debugging, fewer irrelevant
runtime concerns, and a specification a coding agent can change safely. Plenty of processes never
need retries or durability at all — `LocalRunner` is sequential, in-process, and has no retries by
decision, not by omission.

⚠️ **A Plan is not a DAG.** A Plan *may* be acyclic — that's `topology.acyclic`, an invariant you
opt into. Building it into the type would rule out iterative processes before anyone asked for one,
Expand Down Expand Up @@ -208,26 +290,54 @@ cited IRI names a real term. That rule exists because this package once cited `p
memory and implemented something P-Plan doesn't describe. A citation nobody can follow is
decoration with the authority of a fact.

### A borrowed vocabulary, so a Plan can be handed over

`p-plan:Step` has a definition we did not write, at an address anyone can resolve — so the Plan a
product owner argues about in a PRD is the same object an engineer compiles onto Pydantic Graph, and
anything else speaking P-Plan (or a model asked for one) means the same thing by `Step`.

⚠️ A door, not a feature: there is **no RDF import or export** yet.

## Status — honest

**Works today:** typed Plans, the four invariant categories, `render_mermaid`, P-Plan/PROV-O
grounding with vendored ontologies, 116 tests.
**Works today:** typed Plans, the four invariant categories, `render_mermaid` (with an optional
Strategy overlay), P-Plan/PROV-O grounding with vendored ontologies, `Strategy` + `check_strategy`,
`LocalRunner`, `map_over` fan-out, and `to_pydantic_graph` — which emits Pydantic Graph's real
`.map()` and `join`, not a loop wearing the name. 158 tests.

**Not built:** execution adapters (Temporal, LangGraph), persistence, retries, scheduling, embedding
based similarity, and agent-hook integration. The pattern above describes what the artifact is
*for*; the hooks that would put it in an agent's loop are not wired yet.
### What it does NOT do, stated plainly

Not on PyPI. From source:
These are the first four objections anyone will raise, so they are answered here rather than waited
for:

```bash
git clone https://github.com/borisdev/plan-types && cd plan-types && uv sync
uv run pytest -q
```
| | |
|---|---|
| **cycles do not run** | A Plan may BE cyclic — it constructs, wires, renders, and `topology.acyclic` reports it. But it cannot execute, and the reason is not the toposort: **a Plan carries no termination predicate**. Their `Feedback.run()` returns `WriteEmail \| End[Email]` and the predicate lives in the node body, which is the right place for it and is theirs. `MultiStep.until` declares an iterative region; collapsing a strongly connected component into one is designed, not built. |
| **`Fork` / `Join` are not first-class** | Only `map_over`. Pydantic Graph 2.x has both as concepts; a Plan can currently declare a fan-out over a list and nothing else. |
| **no async runner** | `LocalRunner` is synchronous by decision and REFUSES an `async def` rather than returning an un-awaited coroutine. A compiled graph awaits it fine. |
| **at one arm, this is overhead** | Measured, not conceded reluctantly: with a single implementation and two steps, a Plan costs a declaration and buys nothing. It starts paying when there is a second arm, or a second reader. |

**Not built:** RDF import/export (no Turtle, no JSON-LD, no `rdflib` — the P-Plan grounding is a
checked vocabulary, not a serialization format), Temporal and LangGraph adapters, persistence,
retries, scheduling, concurrency, embedding-based similarity, agent-hook integration.

## Open question, deliberately
⚠️ **And the honest scale caveat.** Everything demonstrable here is small. The failure this is built
for shows up at volume: one codebase downstream has **18 variants of one extraction pipeline, 23
distinct step names, 47% of them used exactly once**, with `enrich` and `enrich_one_finding`
coexisting in the same file. A toy cannot show that, and this README will not pretend the toy does.

## Settled: PlanTypes is the product

Are semantic invariants the general product, with PlanTypes as the first ontology-grounded use
case — or is PlanTypes the product, with invariants as a subsystem inside it?

Not decided. The code is arranged so invariants *could* split into a standalone package later, and
that split has not been made. Worth arguing about in the open rather than settling early.
**Decided 2026-08-20: PlanTypes is the product.**

Not because the first framing is wrong, but because it cannot be argued yet. A general
semantic-invariant product needs a corpus of trial and error that does not exist, and it is hard to
grasp before it is demonstrated — which is a bad combination for the thing you lead with. *"Settle
the workflow plan before the execution details"* is one sentence, and it works today.

So `conceptlint` stays, as **a supporting semantic linter rather than a second product**. The code
is still arranged so it could split into its own package, and that option is worth keeping. What
changes is which one gets explained first, and which one the repo is named after.
Loading
Loading