Merge branch 'feat/analysis-build-type-edges'
# Conflicts: # src/analysis/index.ts # src/graph/construction.ts # src/index.ts
This commit is contained in:
@@ -4,7 +4,7 @@ export {
|
|||||||
defaultEdgeType,
|
defaultEdgeType,
|
||||||
resolveDefaultNodeAttrs,
|
resolveDefaultNodeAttrs,
|
||||||
} from "./defaults.js";
|
} from "./defaults.js";
|
||||||
export { typeCompat, type TypeCompatResult, type TypeMismatch } from "./type-compat.js";
|
export { typeCompat, buildTypeEdges, type TypeCompatResult, type TypeMismatch } from "./type-compat.js";
|
||||||
export {
|
export {
|
||||||
topologicalOrder,
|
topologicalOrder,
|
||||||
parallelGroups,
|
parallelGroups,
|
||||||
@@ -13,7 +13,6 @@ export {
|
|||||||
ancestors,
|
ancestors,
|
||||||
descendants,
|
descendants,
|
||||||
} from "./ordering.js";
|
} from "./ordering.js";
|
||||||
export { buildTypeEdges } from "../graph/construction.js";
|
|
||||||
export {
|
export {
|
||||||
validateSchema,
|
validateSchema,
|
||||||
validateGraph,
|
validateGraph,
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { KindGuard, Kind, type TSchema } from "@alkdev/typebox";
|
import { KindGuard, Kind, type TSchema } from "@alkdev/typebox";
|
||||||
|
import { willCreateCycle } from "graphology-dag";
|
||||||
|
import type { FlowGraph } from "../graph/construction.js";
|
||||||
|
import {
|
||||||
|
OperationNodeAttrs as OperationNodeAttrsSchema,
|
||||||
|
OperationEdgeAttrs as OperationEdgeAttrsSchema,
|
||||||
|
} from "../schema/index.js";
|
||||||
|
import type { OperationNodeAttrs } from "../schema/index.js";
|
||||||
|
|
||||||
export interface TypeMismatch {
|
export interface TypeMismatch {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -275,4 +282,25 @@ export function typeCompat(outputSchema: TSchema, inputSchema: TSchema): TypeCom
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { compatible: false, mismatches };
|
return { compatible: false, mismatches };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTypeEdges(graph: FlowGraph<typeof OperationNodeAttrsSchema, typeof OperationEdgeAttrsSchema>): void {
|
||||||
|
const nodeKeys = graph.nodes();
|
||||||
|
for (const source of nodeKeys) {
|
||||||
|
for (const target of nodeKeys) {
|
||||||
|
if (source === target) continue;
|
||||||
|
const sourceAttrs = graph.getNodeAttributes(source as never) as unknown as OperationNodeAttrs;
|
||||||
|
const targetAttrs = graph.getNodeAttributes(target as never) as unknown as OperationNodeAttrs;
|
||||||
|
const result = typeCompat(sourceAttrs.outputSchema as TSchema, targetAttrs.inputSchema as TSchema);
|
||||||
|
if (result === undefined) continue;
|
||||||
|
if (graph.hasEdge(source, target)) continue;
|
||||||
|
if (willCreateCycle(graph.graph, source, target)) continue;
|
||||||
|
const detail = result.detail ?? `${sourceAttrs.namespace}.${sourceAttrs.name}.output → ${targetAttrs.namespace}.${targetAttrs.name}.input`;
|
||||||
|
graph.addTypedEdge(source, target, {
|
||||||
|
compatible: result.compatible,
|
||||||
|
detail,
|
||||||
|
...(result.mismatches !== undefined ? { mismatches: result.mismatches } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -24,8 +24,10 @@ import {
|
|||||||
CallNodeAttrs as CallNodeAttrsSchema,
|
CallNodeAttrs as CallNodeAttrsSchema,
|
||||||
CallEdgeAttrs as CallEdgeAttrsSchema,
|
CallEdgeAttrs as CallEdgeAttrsSchema,
|
||||||
} from "../schema/index.js";
|
} from "../schema/index.js";
|
||||||
import type { OperationNodeAttrs, FlowGraphSerialized, CallNodeAttrs } from "../schema/index.js";
|
import type { FlowGraphSerialized, CallNodeAttrs } from "../schema/index.js";
|
||||||
import { typeCompat, type TypeCompatResult } from "../analysis/type-compat.js";
|
import { buildTypeEdges, type TypeCompatResult } from "../analysis/type-compat.js";
|
||||||
|
|
||||||
|
export { buildTypeEdges } from "../analysis/type-compat.js";
|
||||||
|
|
||||||
export interface FlowGraphOptions {
|
export interface FlowGraphOptions {
|
||||||
type?: "directed";
|
type?: "directed";
|
||||||
@@ -654,25 +656,4 @@ export class FlowGraph<
|
|||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export function buildTypeEdges(graph: OperationGraph): void {
|
|
||||||
const nodeKeys = graph.nodes();
|
|
||||||
for (const source of nodeKeys) {
|
|
||||||
for (const target of nodeKeys) {
|
|
||||||
if (source === target) continue;
|
|
||||||
const sourceAttrs = graph.getNodeAttributes(source as never) as unknown as OperationNodeAttrs;
|
|
||||||
const targetAttrs = graph.getNodeAttributes(target as never) as unknown as OperationNodeAttrs;
|
|
||||||
const result = typeCompat(sourceAttrs.outputSchema as TSchema, targetAttrs.inputSchema as TSchema);
|
|
||||||
if (result === undefined) continue;
|
|
||||||
if (graph.hasEdge(source, target)) continue;
|
|
||||||
if (willCreateCycle(graph.graph, source, target)) continue;
|
|
||||||
const detail = result.detail ?? `${sourceAttrs.namespace}.${sourceAttrs.name}.output → ${targetAttrs.namespace}.${targetAttrs.name}.input`;
|
|
||||||
graph.addTypedEdge(source, target, {
|
|
||||||
compatible: result.compatible,
|
|
||||||
detail,
|
|
||||||
...(result.mismatches !== undefined ? { mismatches: result.mismatches } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export * from "./error/index.js";
|
export * from "./error/index.js";
|
||||||
|
|
||||||
export { FlowGraph, buildTypeEdges, type FlowGraphOptions, type OperationSpec, type CallEventMapValue, type CallRequestedEvent, type CallRespondedEvent, type CallErrorEvent, type CallAbortedEvent, type CallCompletedEvent } from "./graph/index.js";
|
export { FlowGraph, buildTypeEdges, type FlowGraphOptions, type OperationSpec, type CallEventMapValue, type CallRequestedEvent, type CallRespondedEvent, type CallErrorEvent, type CallAbortedEvent, type CallCompletedEvent } from "./graph/index.js";
|
||||||
|
export { typeCompat, type TypeCompatResult, type TypeMismatch } from "./analysis/type-compat.js";
|
||||||
export {
|
export {
|
||||||
validateSchema,
|
validateSchema,
|
||||||
validateGraph,
|
validateGraph,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: analysis/template-validation
|
id: analysis/template-validation
|
||||||
name: Implement validateTemplate and validatePreconditions
|
name: Implement validateTemplate and validatePreconditions
|
||||||
status: pending
|
status: completed
|
||||||
depends_on:
|
depends_on:
|
||||||
- analysis/type-compat
|
- analysis/type-compat
|
||||||
- graph/queries
|
- graph/queries
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: graph/construction-call
|
id: graph/construction-call
|
||||||
name: Implement call graph construction (fromCallEvents, updateFromEvent, addCall, addDependency, updateStatus)
|
name: Implement call graph construction (fromCallEvents, updateFromEvent, addCall, addDependency, updateStatus)
|
||||||
status: pending
|
status: completed
|
||||||
depends_on:
|
depends_on:
|
||||||
- graph/flowgraph-class
|
- graph/flowgraph-class
|
||||||
- schema/enums
|
- schema/enums
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { Type, type TSchema } from "@alkdev/typebox";
|
import { Type, type TSchema } from "@alkdev/typebox";
|
||||||
import { typeCompat, type TypeCompatResult, type TypeMismatch } from "../../src/analysis/type-compat.js";
|
import { typeCompat, buildTypeEdges, type TypeCompatResult, type TypeMismatch } from "../../src/analysis/type-compat.js";
|
||||||
|
import { FlowGraph } from "../../src/graph/construction.js";
|
||||||
|
import type { OperationSpec } from "../../src/graph/construction.js";
|
||||||
|
|
||||||
describe("typeCompat", () => {
|
describe("typeCompat", () => {
|
||||||
describe("exact match", () => {
|
describe("exact match", () => {
|
||||||
@@ -411,4 +413,111 @@ describe("typeCompat", () => {
|
|||||||
expect(typeof mismatch.actual).toBe("string");
|
expect(typeof mismatch.actual).toBe("string");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildTypeEdges", () => {
|
||||||
|
it("adds compatible edges for matching output→input schemas", () => {
|
||||||
|
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() }) });
|
||||||
|
buildTypeEdges(fg);
|
||||||
|
expect(fg.hasEdge("task.extract", "task.classify")).toBe(true);
|
||||||
|
const attrs = fg.getEdgeAttributes("task.extract", "task.classify") as Record<string, unknown>;
|
||||||
|
expect(attrs.edgeType).toBe("typed");
|
||||||
|
expect(attrs.compatible).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds incompatible edges when schemas mismatch", () => {
|
||||||
|
const fg = new FlowGraph();
|
||||||
|
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() }) });
|
||||||
|
fg.addOperation({ name: "count", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ count: Type.Number() }), outputSchema: Type.Object({ result: Type.Number() }) });
|
||||||
|
buildTypeEdges(fg);
|
||||||
|
expect(fg.hasEdge("task.classify", "task.count")).toBe(true);
|
||||||
|
const attrs = fg.getEdgeAttributes("task.classify", "task.count") as Record<string, unknown>;
|
||||||
|
expect(attrs.edgeType).toBe("typed");
|
||||||
|
expect(attrs.compatible).toBe(false);
|
||||||
|
expect(attrs.mismatches).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("incompatible edges include mismatches array", () => {
|
||||||
|
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({ value: Type.String() }) });
|
||||||
|
fg.addOperation({ name: "b", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ value: Type.Number() }), outputSchema: Type.Object({ z: Type.Boolean() }) });
|
||||||
|
buildTypeEdges(fg);
|
||||||
|
const attrs = fg.getEdgeAttributes("op.a", "op.b") as Record<string, unknown>;
|
||||||
|
expect(attrs.compatible).toBe(false);
|
||||||
|
expect(Array.isArray(attrs.mismatches)).toBe(true);
|
||||||
|
expect((attrs.mismatches as Array<unknown>).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not add edges when either schema is Unknown", () => {
|
||||||
|
const fg = new FlowGraph();
|
||||||
|
fg.addOperation({ name: "unk_out", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ x: Type.String() }), outputSchema: Type.Unknown() });
|
||||||
|
fg.addOperation({ name: "unk_in", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Unknown(), outputSchema: Type.Object({ y: Type.String() }) });
|
||||||
|
fg.addOperation({ name: "normal", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ y: Type.String() }), outputSchema: Type.Object({ x: Type.String() }) });
|
||||||
|
buildTypeEdges(fg);
|
||||||
|
expect(fg.hasEdge("op.unk_out", "op.unk_in")).toBe(false);
|
||||||
|
expect(fg.hasEdge("op.unk_out", "op.normal")).toBe(false);
|
||||||
|
expect(fg.hasEdge("op.normal", "op.unk_in")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets detail to namespace.name.output → namespace.name.input for compatible edges", () => {
|
||||||
|
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() }) });
|
||||||
|
buildTypeEdges(fg);
|
||||||
|
const attrs = fg.getEdgeAttributes("task.extract", "task.classify") as Record<string, unknown>;
|
||||||
|
expect(attrs.detail).toContain("task.extract.output → task.classify.input");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is callable after incremental addOperation calls", () => {
|
||||||
|
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() }) });
|
||||||
|
buildTypeEdges(fg);
|
||||||
|
expect(fg.hasEdge("op.extract", "op.classify")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces edges for three operations in a pipeline", () => {
|
||||||
|
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() }) });
|
||||||
|
fg.addOperation({ name: "enrich", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ label: Type.String() }), outputSchema: Type.Object({ enriched: Type.String() }) });
|
||||||
|
buildTypeEdges(fg);
|
||||||
|
expect(fg.hasEdge("task.extract", "task.classify")).toBe(true);
|
||||||
|
expect(fg.hasEdge("task.classify", "task.enrich")).toBe(true);
|
||||||
|
expect(fg.hasEdge("task.extract", "task.enrich")).toBe(true);
|
||||||
|
const e2c = fg.getEdgeAttributes("task.extract", "task.classify") as Record<string, unknown>;
|
||||||
|
const c2e = fg.getEdgeAttributes("task.classify", "task.enrich") as Record<string, unknown>;
|
||||||
|
const e2e = fg.getEdgeAttributes("task.extract", "task.enrich") as Record<string, unknown>;
|
||||||
|
expect(e2c.compatible).toBe(true);
|
||||||
|
expect(c2e.compatible).toBe(true);
|
||||||
|
expect(e2e.compatible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not add self-loops", () => {
|
||||||
|
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({ x: Type.String() }) });
|
||||||
|
buildTypeEdges(fg);
|
||||||
|
expect(fg.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty graph with no edges for empty graph", () => {
|
||||||
|
const fg = new FlowGraph();
|
||||||
|
buildTypeEdges(fg);
|
||||||
|
expect(fg.order).toBe(0);
|
||||||
|
expect(fg.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips edges that would already exist", () => {
|
||||||
|
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({ z: Type.String() }) });
|
||||||
|
buildTypeEdges(fg);
|
||||||
|
const sizeAfterFirst = fg.size;
|
||||||
|
buildTypeEdges(fg);
|
||||||
|
expect(fg.size).toBe(sizeAfterFirst);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user