diff --git a/apps/synth_desktop/src/renderer/src/lineage/LineageCanvas.tsx b/apps/synth_desktop/src/renderer/src/lineage/LineageCanvas.tsx index a00060d2..19e70052 100644 --- a/apps/synth_desktop/src/renderer/src/lineage/LineageCanvas.tsx +++ b/apps/synth_desktop/src/renderer/src/lineage/LineageCanvas.tsx @@ -26,6 +26,11 @@ export function LineageCanvas({ label: string; }) { const ranked = useMemo(() => rankDag(nodes, edges), [nodes, edges]); + const rankedById = useMemo(() => { + const byId = new Map(); + for (const node of ranked) if (!byId.has(node.id)) byId.set(node.id, node); + return byId; + }, [ranked]); const ordered = useMemo(() => rankedOrder(ranked), [ranked]); const [view, setView] = useState({ x: 0, y: 0, scale: 1 }); const drag = useRef<{ x: number; y: number; originX: number; originY: number } | null>(null); @@ -141,8 +146,8 @@ export function LineageCanvas({ > {edges.map((edge) => { - const source = ranked.find((node) => node.id === edge.sourceId); - const target = ranked.find((node) => node.id === edge.targetId); + const source = rankedById.get(edge.sourceId); + const target = rankedById.get(edge.targetId); if (!source || !target) return null; const x1 = source.x + NODE_WIDTH / 2; const y1 = source.y + NODE_HEIGHT; diff --git a/apps/synth_desktop/src/renderer/src/lineage/orderLineageNodes.ts b/apps/synth_desktop/src/renderer/src/lineage/orderLineageNodes.ts index 78ac1148..bff5eb17 100644 --- a/apps/synth_desktop/src/renderer/src/lineage/orderLineageNodes.ts +++ b/apps/synth_desktop/src/renderer/src/lineage/orderLineageNodes.ts @@ -7,11 +7,15 @@ export function orderLineageNodes( const byId = new Map(nodes.map((node) => [node.id, node])); const incoming = new Set(edges.map((edge) => edge.targetNodeId)); const ordered: ExperimentNode[] = []; + const visited = new Set(); + const firstEdge = new Map(); + for (const edge of edges) if (!firstEdge.has(edge.sourceNodeId)) firstEdge.set(edge.sourceNodeId, edge); let current: ExperimentNode | undefined = nodes.find((node) => !incoming.has(node.id)) ?? nodes[0]; - while (current && !ordered.some((node) => node.id === current?.id)) { + while (current && !visited.has(current.id)) { ordered.push(current); - const edge = edges.find((item) => item.sourceNodeId === current?.id); + visited.add(current.id); + const edge = firstEdge.get(current.id); current = edge ? byId.get(edge.targetNodeId) : undefined; } - return [...ordered, ...nodes.filter((node) => !ordered.some((item) => item.id === node.id))]; + return [...ordered, ...nodes.filter((node) => !visited.has(node.id))]; } diff --git a/apps/synth_desktop/src/renderer/src/runtime/traceInspector.ts b/apps/synth_desktop/src/renderer/src/runtime/traceInspector.ts index 151c25a9..fd554936 100644 --- a/apps/synth_desktop/src/renderer/src/runtime/traceInspector.ts +++ b/apps/synth_desktop/src/renderer/src/runtime/traceInspector.ts @@ -42,11 +42,15 @@ export function traceInspectability(trace: TraceV5Record): TraceInspectability { return { eligible: true, label: "Inspect" }; } -/** The digest a visual's projection slot is bound to, or null if it is not a trace inspector. */ +/** The digest bound to the projection input (or legacy slot), or null for other visuals. */ export function traceDigestBinding(visual: VisualRecord): string | null { if (visual.templateId !== TRACE_INSPECTOR_TEMPLATE) return null; - const bindings = visual.bindings as { slots?: Array<{ slot?: string; kind?: string; source?: string }> }; - const projection = bindings?.slots?.find((slot) => slot.slot === "projection" && slot.kind === "trace_v5"); + const bindings = visual.bindings as { + inputs?: Array<{ input?: string; kind?: string; source?: string }>; + slots?: Array<{ slot?: string; kind?: string; source?: string }>; + }; + const projection = bindings?.inputs?.find((binding) => binding.input === "projection" && binding.kind === "trace_v5") + ?? bindings?.slots?.find((binding) => binding.slot === "projection" && binding.kind === "trace_v5"); return typeof projection?.source === "string" ? projection.source : null; } diff --git a/apps/synth_desktop/tests/layout_dag.test.mjs b/apps/synth_desktop/tests/layout_dag.test.mjs index 0130d2c5..c77e0f17 100644 --- a/apps/synth_desktop/tests/layout_dag.test.mjs +++ b/apps/synth_desktop/tests/layout_dag.test.mjs @@ -14,6 +14,28 @@ const compiled = join(compiledDir, "layoutDag.mjs"); writeFileSync(compiled, transformSync(readFileSync(source, "utf8"), { loader: "ts", format: "esm", target: "es2022", sourcefile: source }).code); const { NODE_HEIGHT, NODE_WIDTH, fitRankedToViewport, rankDag } = await import(pathToFileURL(compiled).href); +const orderSource = join(renderer, "lineage/orderLineageNodes.ts"); +const orderCompiled = join(compiledDir, "orderLineageNodes.mjs"); +writeFileSync(orderCompiled, transformSync(readFileSync(orderSource, "utf8"), { loader: "ts", format: "esm", target: "es2022" }).code); +const { orderLineageNodes } = await import(pathToFileURL(orderCompiled).href); + +test("indexed lineage ordering preserves branches, cycles, missing targets and disconnected nodes", () => { + const nodes = ["a", "b", "c", "d"].map(id => ({ id })); + const edge = (sourceNodeId, targetNodeId) => ({ sourceNodeId, targetNodeId }); + for (const [edges, expected] of [ + [[], ["a", "b", "c", "d"]], + [[edge("a", "c"), edge("a", "b")], ["a", "c", "b", "d"]], + [[edge("a", "b"), edge("b", "a"), edge("c", "d"), edge("d", "c")], ["a", "b", "c", "d"]], + [[edge("a", "missing")], ["a", "b", "c", "d"]], + [[edge("d", "c"), edge("c", "b"), edge("b", "a")], ["d", "c", "b", "a"]], + ]) { + const result = orderLineageNodes(nodes, edges); + assert.deepEqual(result.map(node => node.id), expected); + for (const node of result) assert.equal(node, nodes.find(item => item.id === node.id)); + } + assert.deepEqual(orderLineageNodes([], []), []); +}); + const read = (rel) => readFileSync(join(renderer, rel), "utf8"); function assertInside(ranked, view, viewport) { diff --git a/apps/synth_desktop/tests/trace_inspector_identity.test.mjs b/apps/synth_desktop/tests/trace_inspector_identity.test.mjs index 11083c09..213bbf12 100644 --- a/apps/synth_desktop/tests/trace_inspector_identity.test.mjs +++ b/apps/synth_desktop/tests/trace_inspector_identity.test.mjs @@ -116,3 +116,15 @@ test("each unavailable trace keeps an honest, visible label", () => { { eligible: false, label: "Unsupported" } ); }); + +test("persisted input bindings resolve the same sealed trace as legacy slots", () => { + const visual = inspectorVisual("sha256:aaaa1111", { + bindings: { + schemaVersion: "synth.visual-bindings.v1", + inputs: [{ input: "projection", kind: "trace_v5", source: "sha256:aaaa1111" }], + }, + }); + assert.equal(traceDigestBinding(visual), "sha256:aaaa1111"); + assert.equal(findTraceInspectorVisual([visual], trace()), visual); + assert.equal(findTraceInspectorVisual([visual], trace({ digest: "sha256:different" })), undefined); +});