feat(graph): implement fromSpecs, addOperation, addTypedEdge, buildTypeEdges

This commit is contained in:
2026-05-21 21:28:27 +00:00
parent d63ef886d8
commit 5514c5aacc
5 changed files with 368 additions and 13 deletions

View File

@@ -1,5 +1,7 @@
import { describe, it, expect } from "vitest";
import { FlowGraph } from "../../src/graph/construction.js";
import { Type } from "@alkdev/typebox";
import { FlowGraph, buildTypeEdges } from "../../src/graph/construction.js";
import type { OperationSpec } from "../../src/graph/construction.js";
import {
DuplicateNodeError,
DuplicateEdgeError,
@@ -298,10 +300,6 @@ describe("FlowGraph query methods", () => {
});
describe("FlowGraph static stubs", () => {
it("fromSpecs throws not implemented", () => {
expect(() => FlowGraph.fromSpecs([])).toThrow("not implemented");
});
it("fromCallEvents throws not implemented", () => {
expect(() => FlowGraph.fromCallEvents([])).toThrow("not implemented");
});
@@ -311,11 +309,283 @@ describe("FlowGraph static stubs", () => {
});
});
describe("FlowGraph.addOperation", () => {
it("adds an operation node with namespace.name key", () => {
const fg = new FlowGraph();
const spec: OperationSpec = {
name: "classify",
namespace: "task",
version: "1.0.0",
type: "query",
inputSchema: Type.Object({ text: Type.String() }),
outputSchema: Type.Object({ label: Type.String() }),
};
fg.addOperation(spec);
expect(fg.hasNode("task.classify")).toBe(true);
const attrs = fg.getNodeAttributes("task.classify") as Record<string, unknown>;
expect(attrs.name).toBe("classify");
expect(attrs.namespace).toBe("task");
expect(attrs.version).toBe("1.0.0");
expect(attrs.type).toBe("query");
});
it("throws DuplicateNodeError for duplicate operation", () => {
const fg = new FlowGraph();
const spec: OperationSpec = {
name: "classify",
namespace: "task",
version: "1.0.0",
type: "query",
inputSchema: Type.Object({ text: Type.String() }),
outputSchema: Type.Object({ label: Type.String() }),
};
fg.addOperation(spec);
expect(() => fg.addOperation(spec)).toThrow(DuplicateNodeError);
});
it("includes optional description and tags", () => {
const fg = new FlowGraph();
const spec: OperationSpec = {
name: "classify",
namespace: "task",
version: "1.0.0",
type: "query",
inputSchema: Type.Object({ text: Type.String() }),
outputSchema: Type.Object({ label: Type.String() }),
description: "Classifies text",
tags: ["nlp", "ml"],
};
fg.addOperation(spec);
const attrs = fg.getNodeAttributes("task.classify") as Record<string, unknown>;
expect(attrs.description).toBe("Classifies text");
expect(attrs.tags).toEqual(["nlp", "ml"]);
});
});
describe("FlowGraph.addTypedEdge", () => {
it("adds a typed edge with edgeType: 'typed'", () => {
const fg = new FlowGraph();
const spec1: OperationSpec = {
name: "classify",
namespace: "task",
version: "1.0.0",
type: "query",
inputSchema: Type.Object({ text: Type.String() }),
outputSchema: Type.Object({ label: Type.String() }),
};
const spec2: OperationSpec = {
name: "enrich",
namespace: "task",
version: "1.0.0",
type: "query",
inputSchema: Type.Object({ label: Type.String() }),
outputSchema: Type.Object({ enriched: Type.String() }),
};
fg.addOperation(spec1);
fg.addOperation(spec2);
fg.addTypedEdge("task.classify", "task.enrich", { compatible: true });
expect(fg.hasEdge("task.classify", "task.enrich")).toBe(true);
const edgeAttrs = fg.getEdgeAttributes("task.classify", "task.enrich") as Record<string, unknown>;
expect(edgeAttrs.edgeType).toBe("typed");
expect(edgeAttrs.compatible).toBe(true);
});
it("throws NodeNotFoundError if source doesn't exist", () => {
const fg = new FlowGraph();
const spec: OperationSpec = {
name: "enrich",
namespace: "task",
version: "1.0.0",
type: "query",
inputSchema: Type.Object({ label: Type.String() }),
outputSchema: Type.Object({ enriched: Type.String() }),
};
fg.addOperation(spec);
expect(() => fg.addTypedEdge("task.missing", "task.enrich", { compatible: true })).toThrow(NodeNotFoundError);
});
it("throws NodeNotFoundError if target doesn't exist", () => {
const fg = new FlowGraph();
const spec: OperationSpec = {
name: "classify",
namespace: "task",
version: "1.0.0",
type: "query",
inputSchema: Type.Object({ text: Type.String() }),
outputSchema: Type.Object({ label: Type.String() }),
};
fg.addOperation(spec);
expect(() => fg.addTypedEdge("task.classify", "task.missing", { compatible: true })).toThrow(NodeNotFoundError);
});
it("includes detail and mismatches when provided", () => {
const fg = new FlowGraph();
const spec1: OperationSpec = {
name: "a",
namespace: "op",
version: "1.0.0",
type: "query",
inputSchema: Type.Object({ x: Type.String() }),
outputSchema: Type.Object({ y: Type.Number() }),
};
const spec2: OperationSpec = {
name: "b",
namespace: "op",
version: "1.0.0",
type: "query",
inputSchema: Type.Object({ x: Type.String() }),
outputSchema: Type.Object({ z: Type.Boolean() }),
};
fg.addOperation(spec1);
fg.addOperation(spec2);
fg.addTypedEdge("op.a", "op.b", {
compatible: false,
detail: "output mismatch",
mismatches: [{ path: "/y", expected: "string", actual: "number" }],
});
const edgeAttrs = fg.getEdgeAttributes("op.a", "op.b") as Record<string, unknown>;
expect(edgeAttrs.compatible).toBe(false);
expect(edgeAttrs.detail).toBe("output mismatch");
expect(edgeAttrs.mismatches).toEqual([{ path: "/y", expected: "string", actual: "number" }]);
});
it("throws CycleError if edge would create cycle", () => {
const fg = new FlowGraph();
fg.addOperation({
name: "a", namespace: "op", version: "1.0.0", type: "query",
inputSchema: Type.Object({ x: Type.String() }),
outputSchema: Type.Object({ y: Type.String() }),
});
fg.addOperation({
name: "b", namespace: "op", version: "1.0.0", type: "query",
inputSchema: Type.Object({ y: Type.String() }),
outputSchema: Type.Object({ x: Type.String() }),
});
fg.addTypedEdge("op.a", "op.b", { compatible: true });
expect(() => fg.addTypedEdge("op.b", "op.a", { compatible: true })).toThrow(CycleError);
});
});
describe("FlowGraph.fromSpecs", () => {
it("creates operation nodes for each spec", () => {
const specs: OperationSpec[] = [
{ name: "extract", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ raw: Type.String() }), outputSchema: Type.Object({ text: Type.String() }) },
{ name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String(), score: Type.Number() }) },
];
const graph = FlowGraph.fromSpecs(specs);
expect(graph.order).toBe(2);
expect(graph.hasNode("task.extract")).toBe(true);
expect(graph.hasNode("task.classify")).toBe(true);
});
it("adds type-compatibility edges via buildTypeEdges", () => {
const specs: OperationSpec[] = [
{ name: "extract", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ raw: Type.String() }), outputSchema: Type.Object({ text: Type.String() }) },
{ name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String(), score: Type.Number() }) },
];
const graph = FlowGraph.fromSpecs(specs);
expect(graph.size).toBeGreaterThan(0);
});
it("adds compatible edge when output matches input", () => {
const specs: OperationSpec[] = [
{ name: "extract", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ raw: Type.String() }), outputSchema: Type.Object({ text: Type.String() }) },
{ name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String(), score: Type.Number() }) },
];
const graph = FlowGraph.fromSpecs(specs);
expect(graph.hasEdge("task.extract", "task.classify")).toBe(true);
const attrs = graph.getEdgeAttributes("task.extract", "task.classify") as Record<string, unknown>;
expect(attrs.compatible).toBe(true);
});
it("adds incompatible edge when output does not match input", () => {
const specs: OperationSpec[] = [
{ name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String(), score: Type.Number() }) },
{ name: "count", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ count: Type.Number() }), outputSchema: Type.Object({ result: Type.Number() }) },
];
const graph = FlowGraph.fromSpecs(specs);
expect(graph.hasEdge("task.classify", "task.count")).toBe(true);
const attrs = graph.getEdgeAttributes("task.classify", "task.count") as Record<string, unknown>;
expect(attrs.compatible).toBe(false);
});
it("skips edges when target inputSchema is Unknown", () => {
const specs: OperationSpec[] = [
{ name: "source", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ x: Type.String() }), outputSchema: Type.Object({ result: Type.String() }) },
{ name: "unk_input", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Unknown(), outputSchema: Type.Object({ y: Type.String() }) },
];
const graph = FlowGraph.fromSpecs(specs);
expect(graph.hasEdge("op.source", "op.unk_input")).toBe(false);
});
it("skips edges when source outputSchema is Unknown", () => {
const specs: OperationSpec[] = [
{ name: "unk_output", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ x: Type.String() }), outputSchema: Type.Unknown() },
{ name: "target", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ result: Type.String() }), outputSchema: Type.Object({ z: Type.String() }) },
];
const graph = FlowGraph.fromSpecs(specs);
expect(graph.hasEdge("op.unk_output", "op.target")).toBe(false);
});
it("throws DuplicateNodeError for duplicate specs", () => {
const stringOutput = Type.Object({ label: Type.String() });
const specs: OperationSpec[] = [
{ name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: stringOutput },
{ name: "classify", namespace: "task", version: "2.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: stringOutput },
];
expect(() => FlowGraph.fromSpecs(specs)).toThrow(DuplicateNodeError);
});
it("returns empty graph for empty specs", () => {
const graph = FlowGraph.fromSpecs([]);
expect(graph.order).toBe(0);
expect(graph.size).toBe(0);
});
});
describe("buildTypeEdges (standalone)", () => {
it("adds edges to an incrementally constructed graph", () => {
const fg = new FlowGraph();
fg.addOperation({
name: "extract", namespace: "task", version: "1.0.0", type: "query",
inputSchema: Type.Object({ raw: Type.String() }),
outputSchema: Type.Object({ text: Type.String() }),
});
fg.addOperation({
name: "classify", namespace: "task", version: "1.0.0", type: "query",
inputSchema: Type.Object({ text: Type.String() }),
outputSchema: Type.Object({ label: Type.String(), score: Type.Number() }),
});
expect(fg.size).toBe(0);
buildTypeEdges(fg);
expect(fg.size).toBeGreaterThan(0);
expect(fg.hasEdge("task.extract", "task.classify")).toBe(true);
});
it("is callable after adding more operations", () => {
const fg = new FlowGraph();
fg.addOperation({
name: "extract", namespace: "op", version: "1.0.0", type: "query",
inputSchema: Type.Object({ raw: Type.String() }),
outputSchema: Type.Object({ text: Type.String() }),
});
buildTypeEdges(fg);
expect(fg.size).toBe(0);
fg.addOperation({
name: "classify", namespace: "op", version: "1.0.0", type: "query",
inputSchema: Type.Object({ text: Type.String() }),
outputSchema: Type.Object({ label: Type.String(), score: Type.Number() }),
});
buildTypeEdges(fg);
expect(fg.hasEdge("op.extract", "op.classify")).toBe(true);
});
});
describe("FlowGraph re-exports", () => {
it("is re-exported from src/graph/index.ts", async () => {
const mod = await import("../../src/graph/index.js");
expect(mod.FlowGraph).toBeDefined();
expect(mod.FlowGraphOptions).toBeUndefined();
expect(mod.buildTypeEdges).toBeDefined();
});
it("is re-exported from src/index.ts", async () => {