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.
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.
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.
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.
Try it yourself
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.
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?
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.
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.
Dice roll parity confirmed
Confirm the ported graph reproduces the original dice logic exactly.
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.