// Deterministic dataflow layout for Mode B. NO model. Pure geometry over the // already-decided provenance the engine emitted on each ToolCall. // // Edge taxonomy (NON-NEGOTIABLE #4 — proven vs hypothesis ALWAYS separated): // - "spine" : sequence edge query->t0->t1->... (structural, neutral grey) // - "proven" : value-flow cross-link. A verbatim flowValue from an earlier // tool_result reappeared in this tool's input. SOLID, orange. // Asserted by the engine (provenance:'indirect' + flowValue). // - "hypo" : proximity-only link (no verbatim value pinned). DOTTED, muted. // A hypothesis the human judges; never rendered as fact. // // We resolve a proven cross-link to a concrete *source node* by walking backward // to the most recent earlier tool whose name == sourceTool. If none is found we // downgrade to a proximity hypothesis edge (dotted) rather than invent a source. export function buildDataflow(turn) { const tools = turn.tools || []; // Node 0 is always the user query (the root of the per-turn graph). const nodes = [ { key: "q", kind: "query", idx: -1, label: turn.origin === "system" ? "Agent work (system turn)" : "Your query", sub: (turn.prompt || "").replace(/\s+/g, " ").trim().slice(0, 90), }, ...tools.map((tl, idx) => ({ key: "t" + idx, kind: "tool", idx, tool: tl, name: tl.mcp ? `${tl.mcp.server}:${tl.mcp.tool}` : tl.name, rawName: tl.name, indirect: tl.provenance === "indirect", errored: !!tl.errored, })), ]; const edges = []; // Spine: sequence backbone. query -> first tool, then tool i -> tool i+1. for (let i = 0; i < tools.length; i++) { edges.push({ from: i === 0 ? "q" : "t" + (i - 1), to: "t" + i, type: "spine" }); } // Cross-links for indirect tools. These are DRAWN ON DEMAND (per selected/ // hovered node) so a 49-edge turn doesn't render as a permanent tangle — the // UI-SPEC asks for "the highlighted causal path", i.e. focus-driven, not a // hairball. Each link records its consumer (`to`) and, when we can pin the // source within THIS turn, its producer (`from`). Cross-turn sources stay // `from:null` (external) — the node still shows its provenance, but we never // draw a misleading full-height arc back to the query root. for (let i = 0; i < tools.length; i++) { const tl = tools[i]; if (tl.provenance !== "indirect") continue; // Walk backward to the most recent earlier tool matching sourceTool name. let srcIdx = -1; if (tl.sourceTool) { for (let j = i - 1; j >= 0; j--) { if (tools[j].name === tl.sourceTool) { srcIdx = j; break; } } } if (tl.flowValue && srcIdx >= 0) { // PROVEN value-flow: a verbatim value crossed from srcIdx's result to i's // input, both inside this turn. Drawable solid orange arc. edges.push({ from: "t" + srcIdx, to: "t" + i, type: "proven", flowValue: tl.flowValue, sourceTool: tl.sourceTool, }); } else if (tl.flowValue) { // Proven by the engine, but the producing tool_result lives in an EARLIER // turn (or no same-name node here). Record it for the detail panel/legend // count, but mark it external so it is NOT drawn as a long arc. edges.push({ from: null, to: "t" + i, type: "proven", flowValue: tl.flowValue, sourceTool: tl.sourceTool, external: true, }); } else { // No verbatim value pinned -> proximity HYPOTHESIS only. Dotted, and only // drawn when its consumer node is focused. edges.push({ from: srcIdx >= 0 ? "t" + srcIdx : null, to: "t" + i, type: "hypo", sourceTool: tl.sourceTool, external: srcIdx < 0 }); } } return { nodes, edges }; } // Stats for the legend / header strip of a turn graph. export function turnEntityCounts(turn) { const tools = turn.tools || []; const proven = tools.filter((t) => t.provenance === "indirect" && t.flowValue).length; const direct = tools.filter((t) => t.provenance === "direct").length; const errored = tools.filter((t) => t.errored).length; return { proven, direct, errored, total: tools.length }; }