Whetstone.
StateGraph and the Dice RollNodes, edges, and Command routing
Module 3, Lesson 220 min

Nodes, edges, and Command routing

A node is a function of state that returns a partial update. Edges wire the flow. START and END are the sentinels.

A basic graph with static edges
const graph = new StateGraph(State)
  .addNode("sense", senseNode)
  .addNode("modulate", modulateNode)
  .addNode("roll", rollNode)
  .addEdge(START, "sense")
  .addEdge("sense", "modulate")
  .addEdge("modulate", "roll")
  .compile();

Command lets a node decide where to go next and what to write, together, instead of a static edge.

Command: update and route in one return
const roll: GraphNode<{
  InputSchema: typeof State;
  OutputSchema: typeof State;
  Nodes: "continueNode" | "wildNode" | "personalNode" | "restNode";
}> = (state) => {
  const face = rollDice(state.weights, state.random);
  return new Command({ update: { face }, goto: `${face}Node` });
};

A node that returns a Command must also declare its possible destinations in the ends array when you add it to the graph. This is separate from the type-level Nodes union, and it is a runtime requirement, not a style preference.

Wiring a Command node: ends is required
const graph = new StateGraph(State)
  .addNode("roll", roll, { ends: ["continueNode", "wildNode", "personalNode", "restNode"] })
  .addNode("continueNode", continueNode)
  // …the other three face nodes…
  .addEdge(START, "roll")
  .compile();

Your graph: a sense node (read the two sensor levels), a modulate node (modulateWeights), a roll node (rollDice returning a Command that goes to the face node), and four terminal nodes: continue, wild, personal, rest. The injectable random is your parity hook.

Practice

Try it yourself

Recall

Why injectable random matters

The point is the parity test against src/strategy/dice.ts, not general testability.

What would break about testing your graph's roll node if it called Math.random() directly instead of taking an injected random function?

Reveal answer

With Math.random() called directly, you can't reproduce a specific roll outcome deterministically -- every test run would be non-deterministic. An injectable random lets you pass a seeded/fake random function into both the graph node and the original src/strategy/dice.ts implementation, so the same seed produces the same face in both, and you can assert they match.

Quiz

Command vs addConditionalEdges for the roll node

Both Command and addConditionalEdges can route dynamically. Why is Command the better fit specifically for the roll node?

  1. AaddConditionalEdges is deprecated and no longer works
  2. BCommand colocates the state write with the routing choice, matching the dice roll's shape
  3. CCommand is faster at runtime
  4. DThere's no real difference, it's just a matter of style preference
Show answer

Correct answer: B — Command colocates the state write with the routing choice, matching the dice roll's shape

The roll node's job is simultaneously to decide the outcome AND write that outcome as state AND route based on it. Command does update and goto in a single return, matching that shape directly. addConditionalEdges works but requires a separate function to inspect state after the write to decide routing. The one extra obligation Command carries is the ends array on addNode, naming every node it can route to -- omit it and compile() throws UnreachableNodeError.

Do

Port Impulse's cold-mode path into a StateGraph

Port the sense -> modulateWeights -> rollDice -> route path into an explicit, parity-tested StateGraph.

  • Create the build under ~/dev/groundschool/builds/m3-impulse-graph.
  • Build a sense node that reads the two sensor levels (temperature, quota).
  • Build a modulate node that calls modulateWeights exactly as src/strategy/dice.ts does.
  • Build a roll node that calls rollDice with an injectable random and returns a Command routing to the matching face node.
  • Add the roll node with its ends array listing all four face nodes: without ends, compile() throws UnreachableNodeError before the graph runs.
  • Add four terminal nodes: continue, wild, personal, rest.
  • Write a parity test: the same injected random seeds must produce the same faces as src/strategy/dice.ts.
  • Assert the modulated weight vectors match exactly for a quota-warning case and for hot/warm temperature cases.
  • Render the graph structure (mermaid or LangGraph Studio) and save it.
Done whenThe parity test passes against src/strategy/dice.ts for injected seeds, the modulated weight assertions match exactly, and a rendered graph diagram is saved in the build directory.
Check

Dice roll parity confirmed

Confirm the ported graph reproduces the original dice logic exactly.

You should see

Same injected random seeds produce the same faces as src/strategy/dice.ts, and quota-warning / hot / warm modulation matches the original weight vectors exactly.

Sign in to track your progress →