Session 8. Loops and graphs — Wed 23 Sep

Typed transitions, and the edge you refuse to follow

Five states and six edges

graph LR
    P[planning] -->|plan_ready| R[retrieving]
    P -->|out_of_scope| F[refusing]
    R -->|hits| A[answering]
    R -->|no_hits| F
    A -->|answered| D[done]
    F -->|refused| D
STATES = ("planning", "retrieving", "answering", "refusing", "done")

TRANSITIONS = {
    ("planning", "plan_ready"): "retrieving",
    ("planning", "out_of_scope"): "refusing",
    ("retrieving", "hits"): "answering",
    ("retrieving", "no_hits"): "refusing",
    ("answering", "answered"): "done",
    ("refusing", "refused"): "done",
}

The drawing and the dict say the same thing, and the dict is the one that runs. Read two facts straight off it. There is no key whose value is answering except from retrieving, so an answer can only follow a retrieval that found something. And no key starts with done, which is the entire implementation of "terminal".

step is a lookup, and then a decision

def step(state: dict, event: dict) -> dict:
    current, name = state["state"], event["name"]
    target = TRANSITIONS.get((current, name))
    if target is None:
        return {
            "state": current,
            "visited": list(state["visited"]),
            "rejected": f"{name!r} is not a declared event in state {current!r}",
        }
    return {"state": target, "visited": [*state["visited"], target], "rejected": None}

Ten lines, no model, no network, and the same verdict on every machine. The state is a dict with three keys:

Key What it holds
state one of the five names, never anything else
visited the states entered, in order, starting at planning — it only grows
rejected None after a legal move, a sentence after a refused one

Refuse, do not raise, and do not move

An illegal event has three possible answers and only one of them is any good.

Response What it does to the caller
Move anyway the run continues in a state the design never allowed; the bug surfaces three steps later, somewhere else
Raise every caller has to remember a try, and the one that forgets takes the process down over a routine event
Refuse the state stays put, the reason is in the return value, and the run decides what to do next

An out-of-order event is not an exception. It is an ordinary thing that happens when a retry lands late, a queue delivers twice, or a tool answers after you gave up on it. Ordinary things belong in the return value.

The refusal is written for a reader

rejected = f"{name!r} is not a declared event in state {current!r}"

Name the event and name the state it was refused from. "Invalid transition" tells a colleague at 3am that something is wrong and nothing about what. The same sentence with two names in it tells them which producer sent the wrong event and where the run had actually got to.

ch08-e3 looks for both names in the string for exactly that reason.

done is terminal because nothing points out of it

There is no special case for done, no if current == "done". It is terminal because the table has no row starting there, so every event that arrives falls into the same refusal branch as any other undeclared pair.

This matters more than it looks. A special case is code somebody can delete during a refactor. A missing row is a fact about the data, and adding one is a deliberate act you can see in a diff.

visited only grows, and that is what pays for it

Append, never rebuild. A list that only grows is the run's history, and history is what caps a loop.

STORM = {**TRANSITIONS, ("retrieving", "retry"): "retrieving"}

One extra edge, perfectly legal, and the graph now has a run that never ends. A retry storm is not a bug in the retry — it is an edge with nothing counting on it. The count is already in your hand:

MAX_VISITS = 3  # the first pass, plus two retries
if state["visited"].count("retrieving") >= MAX_VISITS:
    state = {**state, "rejected": f"'retrieving' entered {MAX_VISITS} times; retries spent"}

Same rule as session 5's budget, one level up: checked before the move, counted in moves that happened, set by the application. A machine that rewrote visited to [current] on every step could not express that cap at all. That is why the check fails a visited that does not grow, even when every transition is right.

A partial tool failure is a decision, not an accident

Two retrieval sources, one down, half the passages back. The graph will not let you leave that ambiguous: something has to emit either hits or no_hits, and that choice is yours to make in the open.

found, failed = gather(QUESTION)
event = {"name": "hits" if found else "no_hits", "partial": bool(failed)}

The answer that follows is drafted from half a corpus. The flag has to travel with it — this is what needs_human_review was built for in session 3 — and failed names the source that went away, so the reader knows which half is missing. What the state machine contributes is that the decision has a place to live and a record it happened.

What ch08-e3 judges

Scenario What it drives Fails when
the answer path plan_ready, hits, answered a move lands in the wrong state, or visited gains none or two entries
the out-of-scope path out_of_scope, refused the run passes through answering, or does not end refusing then done
the empty-retrieval path plan_ready, no_hits, refused same, from the other refusal edge
the terminal state every event fired at done anything moves, or moves quietly
every undeclared pair all five states × every event the state changes, visited changes, step raises, or the reason names neither the event nor the state
the same run twice the answer path, again the two runs differ, which means a counter or a global is deciding

No model is involved in any of it. The check writes the event sequences itself, so every edge is exercised on purpose rather than when a model happens to pick it — the same trick as session 5's scripted plan.

Recap

Lesson One line
The ladder climb a rung only when a measured failure justifies it
The table two columns are counted facts; only the failure modes are judgement
The graph declared states and declared edges, which a dict already is
Refusal an illegal event does not move you, does not raise, and says why
Terminal done has no outgoing row, so no special case can be deleted by accident
visited the history only grows, and a cap is a rule about the history