{
  "$type": "site.standard.document",
  "bskyPostRef": {
    "cid": "bafyreidcpxvjtrggruelcrtm33lsx4zra5o2jnkuvoaqlxnmczoqlkywqy",
    "uri": "at://did:plc:f53svxxkx4s6ql3ccvavlvh5/app.bsky.feed.post/3mkib3ynquju2"
  },
  "coverImage": {
    "$type": "blob",
    "ref": {
      "$link": "bafkreifsnkxfncm6hejk2gl5jbg2mflhdh5per35vlltmgg7fns5wpt3ly"
    },
    "mimeType": "image/jpeg",
    "size": 199924
  },
  "description": "The cycle expressed in LangGraph: each phase a node, the edges S→G→Q→P→V, the validator the terminal check. Execution interrupts at attestation points so a human fills the slots no model can fill for them. The graph carries the discipline by construction.",
  "path": "/5-python-the-cycle-as-a-langgraph/",
  "publishedAt": "2026-04-27T14:16:41.000Z",
  "site": "https://www.5qln.com",
  "tags": [
    "Python: Phases as Tools (Anthropic Agent SDK)"
  ],
  "textContent": "## Context\n\nUp to this point the surfaces in the series have held the grammar without running it. S2 made the spec a build target. S3 made it a type system. S4 made it a guardrail. None of them executed the cycle. This article does.\n\nLangGraph is the LangChain ecosystem's stateful graph framework. State is typed (Pydantic models work natively). Nodes read and write state. Edges are explicit. Interrupts pause execution and route to a human — the response resumes the graph at the same point with the human's input bound. Checkpointers persist state across interrupts so a paused cycle can be resumed minutes or days later. Each property maps to a piece of the spec: typed state matches the `Cycle` from S3, explicit edges match the cycle order from §1.2, interrupts match the receptive criterion at S and the holding of φ at Q.\n\nThe architecture this article ports: five phase nodes wired linearly, **receptive moments interrupt inside the relevant node** rather than between nodes, and the validator from S4 runs at the end of the graph. The Cycle from S3 is the state object — no new type. What S3 made constructible, S5 makes executable, with the asymmetry from S1 enforced at runtime by the structure of the graph itself.\n\n* * *\n\n## Why LangGraph\n\nThe alternatives. A bare LangChain runnable chain composes well but has no native state object — you carry context as a dict, and the contract is whatever convention you adopt. LangGraph adds typed state and explicit interrupts. CrewAI and AutoGen are agent-team frameworks that subordinate the cycle structure to the agents — backwards for our purpose. Anthropic's Agent SDK (S6 in this series) takes a different idiom (phases as tools the agent calls); valid, different shape, gets its own article.\n\nLangGraph fits 5QLN because state, edges, and interrupts are all first-class. The state IS the Cycle. The edges ARE the cycle order. The interrupts ARE the receptive criteria the spec names. Nothing has to be smuggled through prose conventions.\n\n* * *\n\n## State is the Cycle from S3\n\n\n    # fivqln/langgraph/state.py\n    \"\"\"\n    The graph's state IS the Cycle from S3. No wrapper, no auxiliary type.\n    LangGraph supports Pydantic models as state natively.\n    \"\"\"\n\n    from fivqln.types import Cycle\n\n    # Re-export for clarity. The graph state is exactly what S3 defined.\n    GraphState = Cycle\n\n\nThree properties follow from this choice. First, anything produced by the graph is already a valid `Cycle` — it can be passed to `validate()` from S4 directly, persisted to JSON, sent across an MCP boundary in S7, or consumed by any other surface. Second, the type contract from S3 is enforced at every node return: returning a state that violates the Completion Rule fails Pydantic validation before it ever reaches the next edge. Third, drift between S3 and S5 is impossible by construction — there is no second type to drift.\n\n* * *\n\n## The receptive interrupts — never an LLM call\n\n\n    # fivqln/langgraph/nodes.py\n    \"\"\"\n    Phase nodes. Each implements one phase's decoding from D1.\n\n    Receptive moments (S, φ within Q, ∞0' confirmation within V) use\n    LangGraph's `interrupt()` to pause the graph and route to the human.\n    The LLM is never present at receptive moments — those nodes do not\n    even take an llm parameter.\n    \"\"\"\n\n    from datetime import datetime, timezone\n    from typing import Optional\n\n    from langchain_core.language_models import BaseChatModel\n    from langgraph.types import interrupt\n    from pydantic import BaseModel, Field\n\n    from fivqln.types import (\n        Cycle, ValidatedSpark, ValidatedPattern, ResonantKey, Flow,\n        Benefit, FractalSeed, EnrichedReturn, FormationEntry,\n        FormationTrail, Phase,\n    )\n    from fivqln.symbols import (\n        CoreEssence, SelfNature, UniversalPotential,\n        NaturalIntersection, NaturalGradient,\n    )\n\n\n    def _append_to_trail(state: Cycle, entry: FormationEntry) -> FormationTrail:\n        \"\"\"Functional append — returns a new trail with the entry added.\"\"\"\n        return FormationTrail(entries=list(state.trail.entries) + [entry])\n\n\n    def start_node(state: Cycle) -> dict:\n        \"\"\"S = ∞0 → ?\n\n        Per D1 §2.1: X must arrive from ∞0, not be generated from K. This\n        node CANNOT call an LLM — its signature contains no llm parameter.\n        L2 corruption (Generating) is structurally forbidden by the absence\n        of the LLM, not by discipline.\n\n        The interrupt() call halts the graph. The host application receives\n        the payload, presents it to the human, and resumes the graph by\n        passing the human's response back. The response binds to the\n        interrupt() return value.\n        \"\"\"\n        if state.spark is not None:\n            return {}  # already received in a prior turn\n\n        response = interrupt({\n            \"phase\": \"S\",\n            \"spec_ref\": \"§2.1\",\n            \"instruction\": (\n                \"Hold ∞0. Resist closing the space. Nothing is sought. \"\n                \"When something stirs from the open space, name what arrived \"\n                \"as a question.\"\n            ),\n            \"fields_required\": [\"question\", \"held_by\"],\n        })\n\n        received_at = datetime.now(timezone.utc)\n        spark = ValidatedSpark(\n            question=response[\"question\"],\n            received_at=received_at,\n            held_by=response[\"held_by\"],\n        )\n\n        new_trail = _append_to_trail(state, FormationEntry(\n            timestamp=received_at,\n            phase=Phase.S,\n            operation=\"received question from ∞0 via human attestation\",\n            output_excerpt=spark.question,\n        ))\n\n        return {\"spark\": spark, \"trail\": new_trail}\n\n\nThe node has no `llm` parameter. This is not documentation — it is the architecture refusing the corruption. A developer reading the function cannot accidentally call an LLM here because there is no LLM in scope. The receptive criterion from §2.1 is enforced by Python's name resolution.\n\n* * *\n\n## The LLM-driven nodes\n\n\n    class _GResponse(BaseModel):\n        \"\"\"Structured output schema for G's LLM call.\"\"\"\n        alpha_description: str = Field(\n            description=\"The irreducible core (α) found within X. Removing α \"\n                        \"should make X collapse.\"\n        )\n        expressions: list[str] = Field(\n            min_length=1,\n            description=\"Self-similar expressions {α'} of α at other scales \"\n                        \"or in other domains. Each must be self-similar to α, \"\n                        \"not merely topically related.\",\n        )\n        pattern_description: str = Field(\n            description=\"The validated pattern Y — α named, ≡ tested, \"\n                        \"{α'} confirmed across scales.\"\n        )\n\n\n    def growth_node(state: Cycle, llm: BaseChatModel, lens: Optional[str] = None) -> dict:\n        \"\"\"G = α ≡ {α'}\n\n        Per D1 §2.2: find α within X, test ≡, find {α'}. This is pattern\n        recognition over symbolic content — work the LLM does well. The\n        optional lens shapes the prompt; e.g. lens='GQ' applies Q-quality\n        (resonance) to G's decoding, asking 'which echoes carry authentic\n        signature vs. mere resemblance?'\n        \"\"\"\n        assert state.spark is not None, \"G requires X (D1 §2.6, §3.3)\"\n\n        prompt = _build_growth_prompt(spark=state.spark, lens=lens)\n        structured_llm = llm.with_structured_output(_GResponse)\n        response: _GResponse = structured_llm.invoke(prompt)\n\n        alpha = CoreEssence(\n            description=response.alpha_description,\n            expressions=tuple(response.expressions),\n        )\n        pattern = ValidatedPattern(\n            alpha=alpha,\n            pattern_description=response.pattern_description,\n        )\n\n        new_trail = _append_to_trail(state, FormationEntry(\n            timestamp=datetime.now(timezone.utc),\n            phase=Phase.G,\n            lens=lens,\n            operation=f\"found α and {{α'}} via LLM (lens={lens or 'none'})\",\n            output_excerpt=alpha.description,\n        ))\n\n        return {\"pattern\": pattern, \"trail\": new_trail}\n\n\n    def _build_growth_prompt(spark: ValidatedSpark, lens: Optional[str]) -> str:\n        \"\"\"Compose the G-phase prompt with the equation, X, and optional lens.\n\n        The prompt always contains:\n          - The phase equation (G = α ≡ {α'}) as the operation specification\n          - The input X (the spark question)\n          - The decoding operation from D1 §2.2 as procedural guidance\n        Optional:\n          - Lens refinement, when applicable\n        \"\"\"\n        base = f\"\"\"You are decoding G = α ≡ {{α'}} per 5QLN D1 §2.2.\n\n    Input X (Validated Spark):\n      Question: {spark.question}\n\n    Decoding operation:\n      1. SEEK α — within X, what is the irreducible core? What pattern,\n         if removed, makes X collapse?\n      2. TEST ≡ — does α remain unchanged across expressions?\n      3. FIND {{α'}} — where does α echo at other scales? Each echo must\n         be self-similar to α, not merely topically related.\n      4. VALIDATE Y — α named, ≡ holds, {{α'}} confirm across scales.\n    \"\"\"\n        if lens:\n            refinements = {\n                \"GS\": \"Through openness: what unknown still lives in the pattern?\",\n                \"GG\": \"Through pattern: how does α express at deeper scales?\",\n                \"GQ\": \"Through resonance: which echoes carry authentic signature \"\n                      \"vs. mere resemblance?\",\n                \"GP\": \"Through flow: where does the pattern want to unfold next?\",\n                \"GV\": \"Through benefit: how is naming α itself already a gift?\",\n            }\n            if lens in refinements:\n                base += f\"\\nLens {lens} refinement: {refinements[lens]}\"\n        return base\n\n\nThe prompt construction is mechanical: the equation is the spec, the input is the prior phase's output, the decoding operation is D1 verbatim, and the lens (if active) appends a refinement borrowed from another phase's quality. None of this paraphrases the spec — every part is named and traceable. C1 §3.6 (\"every emitted surface carry the active phase's compiled form WITH decoding operation\") is satisfied at prompt-build time, not at write-up time.\n\n* * *\n\n## The mixed node — Q\n\n\n    class _QResponse(BaseModel):\n        \"\"\"Structured output schema for Q's LLM step (Ω + ⋂ recognition).\"\"\"\n        omega_context: str = Field(\n            description=\"What the larger context (Ω) makes possible — beyond \"\n                        \"the individual, the field around the inquiry.\"\n        )\n        landing: str = Field(\n            description=\"What φ and Ω together revealed that neither contained \"\n                        \"alone — what turned the lock at ⋂.\"\n        )\n        key_description: str = Field(\n            description=\"The validated Resonant Key Z — what was confirmed \"\n                        \"at the moment ⋂ landed.\"\n        )\n\n\n    def quality_node(state: Cycle, llm: BaseChatModel) -> dict:\n        \"\"\"Q = φ ⋂ Ω\n\n        Per D1 §2.3, Q has two halves. The first holds φ — direct perception\n        by the inquirer. This cannot be supplied by an LLM (L4 corruption if\n        attempted); the node interrupts and routes to the human. Once φ is\n        held, the LLM holds Ω, watches for ⋂, and names what landed.\n\n        The interrupt happens INSIDE this node. When the human responds, the\n        node continues to the LLM call.\n        \"\"\"\n        assert state.pattern is not None, \"Q requires Y (D1 §2.6, §3.3)\"\n\n        # Step 1 — receive φ from the human\n        phi_response = interrupt({\n            \"phase\": \"Q\",\n            \"spec_ref\": \"§2.3 (φ)\",\n            \"instruction\": (\n                \"Look at Y without theory. What do you directly perceive? \"\n                \"Not what you think. Not what data says. What lands.\"\n            ),\n            \"context\": {\n                \"alpha\": state.pattern.alpha.description,\n                \"pattern\": state.pattern.pattern_description,\n            },\n            \"fields_required\": [\"perception\", \"held_by\"],\n        })\n        phi = SelfNature(\n            perception=phi_response[\"perception\"],\n            held_by=phi_response[\"held_by\"],\n        )\n\n        # Step 2 — LLM holds Ω, watches for ⋂\n        prompt = _build_quality_prompt(state, phi)\n        structured_llm = llm.with_structured_output(_QResponse)\n        response: _QResponse = structured_llm.invoke(prompt)\n\n        omega = UniversalPotential(context=response.omega_context)\n        intersection = NaturalIntersection(\n            phi=phi, omega=omega, landing=response.landing,\n        )\n        resonance = ResonantKey(\n            intersection=intersection,\n            key_description=response.key_description,\n        )\n\n        new_trail = _append_to_trail(state, FormationEntry(\n            timestamp=datetime.now(timezone.utc),\n            phase=Phase.Q,\n            operation=\"held φ via human; LLM held Ω; named ⋂ landing\",\n            output_excerpt=intersection.landing,\n        ))\n\n        return {\"resonance\": resonance, \"trail\": new_trail}\n\n\nThe asymmetry from S1 made executable: φ comes from `interrupt()`, Ω comes from the LLM, and ⋂ is named by the LLM but cannot be certified by it (S4's `require_l3_attestation_at_quality` will surface this requirement on the final report). The node carries the structure; the human carries what only the human can carry; the LLM carries what the LLM can carry. None of the three pretends to be one of the others.\n\n* * *\n\n## P and V\n\nP (`P = δE/δV → ∇`) is pure-LLM, structurally identical to G — map δE, map δV, compute the ratio, name what ∇ revealed, validate Flow A. V (`V = (L ∩ G → B'') → ∞0'`) is mixed: the LLM does the two-pass composition (R7 — analysis extracts α-thread and turning points, then composition produces the artifact), then interrupts to let the inquirer confirm or refine the proposed ∞0'. Both follow the same pattern as G and Q above and are not reproduced here in full.\n\n* * *\n\n## The graph wiring\n\n\n    # fivqln/langgraph/graph.py\n    \"\"\"\n    Compose the phase nodes into a runnable graph. Linear edges S → G → Q → P → V.\n    Receptive interrupts live inside the relevant nodes, not as separate edges.\n    The validator from S4 runs as a final node and attaches its report.\n    \"\"\"\n\n    from functools import partial\n\n    from langchain_core.language_models import BaseChatModel\n    from langgraph.checkpoint.memory import InMemorySaver\n    from langgraph.graph import StateGraph, START, END\n\n    from fivqln.types import Cycle\n    from fivqln.validator import validate, Severity\n    from fivqln.langgraph.nodes import (\n        start_node, growth_node, quality_node, power_node, value_node,\n    )\n\n\n    def _validation_node(state: Cycle) -> dict:\n        \"\"\"Final node: run the C1 validator and attach the report.\n\n        The graph does not halt on HEURISTIC or ATTESTATION_REQUIRED findings —\n        only on DEFINITE violations. The host application chooses whether to\n        treat ATTESTATION_REQUIRED as blocking (strict mode) or informational.\n        \"\"\"\n        report = validate(state)\n        definite = [v for v in report.violations if v.severity == Severity.DEFINITE]\n        if definite:\n            raise RuntimeError(\n                f\"C1 validation failed: {[v.message for v in definite]}\"\n            )\n        # We don't mutate state with the report here — the host calls validate()\n        # again on the final state to get the report. This keeps Cycle clean.\n        return {}\n\n\n    def build_cycle_graph(\n        llm: BaseChatModel,\n        *,\n        checkpointer=None,\n    ):\n        \"\"\"Construct the runnable graph.\n\n        The checkpointer is required for interrupt/resume semantics. Default\n        is in-memory; production callers should pass a persistent checkpointer\n        (Postgres, Redis, etc.) so paused cycles survive process restarts.\n        \"\"\"\n        workflow = StateGraph(Cycle)\n\n        workflow.add_node(\"S\", start_node)\n        workflow.add_node(\"G\", partial(growth_node, llm=llm))\n        workflow.add_node(\"Q\", partial(quality_node, llm=llm))\n        workflow.add_node(\"P\", partial(power_node, llm=llm))\n        workflow.add_node(\"V\", partial(value_node, llm=llm))\n        workflow.add_node(\"validate\", _validation_node)\n\n        workflow.add_edge(START, \"S\")\n        workflow.add_edge(\"S\", \"G\")\n        workflow.add_edge(\"G\", \"Q\")\n        workflow.add_edge(\"Q\", \"P\")\n        workflow.add_edge(\"P\", \"V\")\n        workflow.add_edge(\"V\", \"validate\")\n        workflow.add_edge(\"validate\", END)\n\n        return workflow.compile(checkpointer=checkpointer or InMemorySaver())\n\n\nThe graph is short. Five phase nodes, one validation node, eight edges. Every edge corresponds to a transition the spec names. The graph topology IS the cycle from §1.2.\n\n* * *\n\n## Running it\n\n\n    from langchain_anthropic import ChatAnthropic\n    from langgraph.types import Command\n\n    from fivqln.langgraph import build_cycle_graph\n    from fivqln.types import Cycle\n    from fivqln.validator import validate\n\n\n    llm = ChatAnthropic(model=\"claude-opus-4-7\")\n    graph = build_cycle_graph(llm)\n\n    config = {\"configurable\": {\"thread_id\": \"session-2026-04-27\"}}\n\n    # Invoke. The graph runs until it hits the first interrupt (inside start_node).\n    result = graph.invoke(Cycle(), config=config)\n    # `result` here is the partial state with __interrupt__ metadata attached.\n\n    # The host application reads the interrupt payload, asks the human, gets the\n    # response, and resumes the graph with Command(resume=...).\n    spark_response = {\n        \"question\": \"What grammar does the substrate need to carry the spec faithfully?\",\n        \"held_by\": \"amihai\",\n    }\n    result = graph.invoke(Command(resume=spark_response), config=config)\n    # Graph runs S → G → Q's first interrupt. Pauses again.\n\n    phi_response = {\n        \"perception\": (\n            \"Each substrate I've tried — reST, Pydantic, the validator — holds \"\n            \"the grammar without paraphrasing it. The grammar is what propagates.\"\n        ),\n        \"held_by\": \"amihai\",\n    }\n    result = graph.invoke(Command(resume=phi_response), config=config)\n    # Graph runs Q's LLM step → P → V's first interrupt (∞0' confirmation). Pauses.\n\n    infinity_zero_prime_response = {\n        \"question\": (\n            \"If the grammar propagates across substrates without paraphrase, \"\n            \"what is the substrate-class for which it does NOT propagate?\"\n        ),\n    }\n    final = graph.invoke(Command(resume=infinity_zero_prime_response), config=config)\n    # Graph runs V's composition → validate → END.\n\n    # Final state is a complete Cycle. Pass it to the validator for the full report.\n    report = validate(final)\n    print(f\"is_clean: {report.is_clean}, is_certified: {report.is_certified}\")\n    for v in report.violations:\n        print(f\"  [{v.severity}] {v.message}\")\n\n\nThree interrupts in a single cycle. Three places where the human side of the Membrane is the only valid source. Between them, the LLM does the work it's good at — pattern recognition, gradient mapping, composition. The graph's structure makes it impossible to confuse one for the other. A developer modifying the graph cannot accidentally route the receptive moments to the LLM, because the receptive nodes have no `llm` in scope.\n\n* * *\n\n## What this surface enables\n\nThe cycle now runs end-to-end. A user can produce a complete `Cycle` artifact — spark, pattern, resonance, flow, benefit, seed, enriched return, formation trail — through structured interaction with a real LLM and a real human, with the graph enforcing who provides what.\n\nFor the rest of the series, S5 establishes the runtime baseline. S6 (Anthropic Agent SDK) will take a different idiom — phases as tools the agent calls — but the type contract, the validator, and the discipline about receptive moments remain identical. S7 (MCP) will expose pieces of this graph as MCP tools so any MCP-aware client can run a cycle without re-implementing it. S8 (TypeScript) will mirror the state contract in Zod so the same cycle artifact can travel across language boundaries.\n\nWhat the graph does not provide — and what no graph could provide — is certainty that any given cycle was honest. The validator's `is_certified` will return False until the human attestations have been answered, and the graph cannot answer them on the human's behalf. The architecture preserves this distinction. The discipline propagates because the structure refuses to launder it.\n\n* * *\n\n## Closing\n\nThe cycle runs. The grammar holds at every node. The receptive moments interrupt. The LLM works only where the LLM should work. The validator stands at the end and reports, honestly, what it could and could not certify.\n\nAhead: **S6 —** Python: Phases as Tools (Anthropic Agent SDK)**.** A different runtime idiom — the cycle as a tool palette an autonomous agent calls, with schemas that force the correct prior outputs as inputs. Same type contract. Same validator. Different shape of the same grammar.\n\n* * *\n\n_5QLN © 2026 Amihai Loven. Open under the 5QLN Open Source License._",
  "title": "5-Python: The Cycle as a LangGraph",
  "updatedAt": "2026-04-28T01:58:48.780Z"
}