Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions src/routes/v2/pages/CompareView/utils/buildMergedGraph.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { describe, expect, test } from "vitest";

import type { ComponentSpec, TaskSpec } from "@/utils/componentSpec";

import { buildMergedGraph } from "./buildMergedGraph";
import { buildPipelineComparison } from "./comparePipelines";

const task = (digest: string, overrides: Partial<TaskSpec> = {}): TaskSpec => ({
componentRef: { name: "comp", digest },
...overrides,
});

const fromOutput = (taskId: string) => ({
taskOutput: { taskId, outputName: "out" },
});

const fromInput = (inputName: string) => ({ graphInput: { inputName } });

const ioSpec = (): ComponentSpec => ({
inputs: [{ name: "data" }],
outputs: [{ name: "model" }],
implementation: {
graph: {
tasks: {
train: task("d1", { arguments: { x: fromInput("data") } }),
},
outputValues: {
model: { taskOutput: { taskId: "train", outputName: "out" } },
},
},
},
});

const graphSpec = (tasks: Record<string, TaskSpec>): ComponentSpec => ({
implementation: { graph: { tasks } },
});

const containerSpec = (): ComponentSpec => ({
implementation: { container: { image: "python:3.11" } },
});

const noStatus = new Map<string, string>();

const merge = (specA: ComponentSpec, specB: ComponentSpec) =>
buildMergedGraph(
buildPipelineComparison(
{ spec: specA, taskStatusMap: noStatus },
{ spec: specB, taskStatusMap: noStatus },
),
);

describe("buildMergedGraph()", () => {
test("unions task ids from both runs into one node each", () => {
const specA = graphSpec({ train: task("d1"), evaluate: task("d2") });
const specB = graphSpec({ train: task("d1"), deploy: task("d3") });

const { nodes } = merge(specA, specB);

expect(nodes.map((n) => n.id).sort()).toEqual([
"deploy",
"evaluate",
"train",
]);
expect(nodes.every((n) => n.type === "mergedTask")).toBe(true);
});

test("marks an edge present in both runs as unchanged", () => {
const tasks = {
gen: task("d0"),
sink: task("d1", { arguments: { x: fromOutput("gen") } }),
};
const { edges } = merge(graphSpec(tasks), graphSpec(tasks));

expect(edges).toHaveLength(1);
expect(edges[0]).toMatchObject({
source: "gen",
target: "sink",
data: { membership: "unchanged" },
});
});

test("marks run-exclusive edges as lost (A-only) and new (B-only)", () => {
const specA = graphSpec({
gen: task("d0"),
sink: task("d1", { arguments: { x: fromOutput("gen") } }),
});
const specB = graphSpec({
gen: task("d0"),
sink: task("d1"),
extra: task("d2", { arguments: { y: fromOutput("gen") } }),
});

const { edges } = merge(specA, specB);
const byId = Object.fromEntries(
edges.map((e) => [e.id, e.data?.membership]),
);

expect(byId["gen->sink"]).toBe("lost");
expect(byId["gen->extra"]).toBe("new");
});

test("drops edges that reference an unknown task", () => {
const specA = graphSpec({
only: task("d0", { arguments: { x: fromOutput("ghost") } }),
});

const { nodes, edges } = merge(specA, specA);

expect(nodes.map((n) => n.id)).toEqual(["only"]);
expect(edges).toEqual([]);
});

test("produces no nodes for container-implementation specs", () => {
const { nodes, edges } = merge(containerSpec(), containerSpec());

expect(nodes).toEqual([]);
expect(edges).toEqual([]);
});

test("adds prefixed nodes for pipeline inputs and outputs", () => {
const { nodes } = merge(ioSpec(), ioSpec());

const byId = Object.fromEntries(nodes.map((n) => [n.id, n.type]));
expect(byId["input:data"]).toBe("mergedIo");
expect(byId["output:model"]).toBe("mergedIo");
expect(byId["train"]).toBe("mergedTask");
});

test("wires input→task and task→output edges", () => {
const { edges } = merge(ioSpec(), ioSpec());
const byId = Object.fromEntries(
edges.map((e) => [e.id, e.data?.membership]),
);

expect(byId["input:data->train"]).toBe("unchanged");
expect(byId["train->output:model"]).toBe("unchanged");
});

test("marks a rewired output edge as run-exclusive", () => {
const specA = ioSpec();
const specB: ComponentSpec = {
inputs: [{ name: "data" }],
outputs: [{ name: "model" }],
implementation: {
graph: {
tasks: {
train: task("d1", { arguments: { x: fromInput("data") } }),
tune: task("d2"),
},
outputValues: {
model: { taskOutput: { taskId: "tune", outputName: "out" } },
},
},
},
};

const { edges } = merge(specA, specB);
const byId = Object.fromEntries(
edges.map((e) => [e.id, e.data?.membership]),
);

expect(byId["train->output:model"]).toBe("lost");
expect(byId["tune->output:model"]).toBe("new");
});
});
145 changes: 145 additions & 0 deletions src/routes/v2/pages/CompareView/utils/buildMergedGraph.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import type { Edge, Node } from "@xyflow/react";

import type { TaskSpec } from "@/utils/componentSpec";
import {
isGraphInputArgument,
isTaskOutputArgument,
} from "@/utils/componentSpec";

import type {
DiffStatus,
IoDiff,
PipelineComparison,
TaskDiff,
} from "./comparePipelines";

export type SpotlightMode = "both" | "a" | "b";

export interface MergedTaskNodeData extends Record<string, unknown> {
diff: TaskDiff;
spotlight: SpotlightMode;
singleRun?: boolean;
}

export interface MergedIoNodeData extends Record<string, unknown> {
diff: IoDiff;
spotlight: SpotlightMode;
}

type MergedTaskNode = Node<MergedTaskNodeData, "mergedTask">;
type MergedIoNode = Node<MergedIoNodeData, "mergedIo">;
export type MergedNode = MergedTaskNode | MergedIoNode;

interface MergedEdgeData extends Record<string, unknown> {
membership: DiffStatus;
}

type MergedEdge = Edge<MergedEdgeData>;

export interface MergedGraphModel {
nodes: MergedNode[];
edges: MergedEdge[];
}

const inputNodeId = (name: string) => `input:${name}`;
const outputNodeId = (name: string) => `output:${name}`;

interface EdgeSides {
source: string;
target: string;
inA: boolean;
inB: boolean;
}

export function buildMergedGraph(
comparison: PipelineComparison,
): MergedGraphModel {
const { taskDiffs, inputDiffs, outputDiffs } = comparison;

const taskIds = new Set(taskDiffs.map((diff) => diff.taskId));
const inputIds = new Set(inputDiffs.map((diff) => inputNodeId(diff.name)));

const inputNodes: MergedNode[] = inputDiffs.map((diff) => ({
id: inputNodeId(diff.name),
type: "mergedIo",
position: { x: 0, y: 0 },
data: { diff, spotlight: "both" },
}));

const taskNodes: MergedNode[] = taskDiffs.map((diff) => ({
id: diff.taskId,
type: "mergedTask",
position: { x: 0, y: 0 },
data: { diff, spotlight: "both" },
}));

const outputNodes: MergedNode[] = outputDiffs.map((diff) => ({
id: outputNodeId(diff.name),
type: "mergedIo",
position: { x: 0, y: 0 },
data: { diff, spotlight: "both" },
}));

const edgeSides = new Map<string, EdgeSides>();

const addEdge = (source: string, target: string, side: "a" | "b") => {
const key = `${source}->${target}`;
const existing = edgeSides.get(key) ?? {
source,
target,
inA: false,
inB: false,
};
if (side === "a") existing.inA = true;
else existing.inB = true;
edgeSides.set(key, existing);
};

const collectTaskInputs = (
targetId: string,
taskSpec: TaskSpec | undefined,
side: "a" | "b",
) => {
if (!taskSpec?.arguments) return;

for (const argument of Object.values(taskSpec.arguments)) {
if (isTaskOutputArgument(argument)) {
const source = argument.taskOutput.taskId;
if (taskIds.has(source)) addEdge(source, targetId, side);
} else if (isGraphInputArgument(argument)) {
const source = inputNodeId(argument.graphInput.inputName);
if (inputIds.has(source)) addEdge(source, targetId, side);
}
}
};

for (const diff of taskDiffs) {
collectTaskInputs(diff.taskId, diff.a, "a");
collectTaskInputs(diff.taskId, diff.b, "b");
}

for (const diff of outputDiffs) {
const target = outputNodeId(diff.name);
if (diff.sourceTaskIdA && taskIds.has(diff.sourceTaskIdA)) {
addEdge(diff.sourceTaskIdA, target, "a");
}
if (diff.sourceTaskIdB && taskIds.has(diff.sourceTaskIdB)) {
addEdge(diff.sourceTaskIdB, target, "b");
}
}

const edges: MergedEdge[] = Array.from(edgeSides.values()).map(
({ source, target, inA, inB }) => {
const membership: DiffStatus =
inA && inB ? "unchanged" : inA ? "lost" : "new";
return {
id: `${source}->${target}`,
source,
target,
data: { membership },
};
},
);

return { nodes: [...inputNodes, ...taskNodes, ...outputNodes], edges };
}
Loading
Loading