diff --git a/.vscode/settings.json b/.vscode/settings.json index be7dc6d7..0a81bd25 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,7 +6,7 @@ It represents the closest reasonable ESLint configuration to this project's original TSLint configuration. We recommend eventually switching this configuration to extend from -the recommended rulesets in typescript-eslint. +the recommended rulesets in typescript-eslint. https://github.com/typescript-eslint/tslint-to-eslint-config/blob/master/docs/FAQs.md Happy linting! 💖 @@ -29,26 +29,8 @@ Happy linting! 💖 }, "cSpell.ignoreWords": ["editor", "format", "on", "save"], "cSpell.words": [ - "Algos", - "Conda", - "Customizer", - "Databricks", - "Dataset", - "Datasets", - "Datastore", - "Datastores", - "Dismissable", - "Dropdown", - "Edat", - "Ensembling", - "Interop", - "Prefetcher", - "Resizable", - "SKUs", - "Serializers", - "Subsampling", - "Timespan", "abortcontroller", + "Algos", "appinsights", "automl", "azureml", @@ -58,12 +40,23 @@ Happy linting! 💖 "buildscripts", "callout", "cobertura", + "Conda", "continuationtoken", "cudatoolkit", + "Customizer", "cyclomatic", + "Databricks", "dataprep", + "Dataset", + "Datasets", + "Datastore", + "Datastores", "dcid", + "Dismissable", + "Dropdown", "eastus", + "Edat", + "Ensembling", "esnext", "etag", "experimentrun", @@ -75,6 +68,7 @@ Happy linting! 💖 "generageresult", "generatebuildresult", "hyperdrive", + "Interop", "jsnext", "jszip", "junit", @@ -84,6 +78,7 @@ Happy linting! 💖 "locstrings", "machinelearningservices", "managedenv", + "mcmf", "mlworkspace", "mockdate", "msal", @@ -96,8 +91,10 @@ Happy linting! 💖 "papaparse", "plotly", "polyfill", + "Prefetcher", "pytorch", "quickprofile", + "Resizable", "resjson", "rollup", "runhistory", @@ -105,8 +102,10 @@ Happy linting! 💖 "scriptrun", "scrollable", "serializer", + "Serializers", "setuptools", "sklearn", + "SKUs", "sourcemap", "spacy", "storyshots", @@ -114,10 +113,12 @@ Happy linting! 💖 "stylelint", "stylelintrc", "submodule", + "Subsampling", "svgr", "taskkill", "theming", "timeseries", + "Timespan", "treeshake", "tslib", "uifabric", diff --git a/package.json b/package.json index 739f95a8..3b0c44f4 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ }, "private": true, "devDependencies": { + "@algorithm.ts/mcmf": "^2.0.14", "@babel/core": "7.12.13", "@babel/preset-typescript": "7.12.13", "@fluentui/merge-styles": "^8.2.0", diff --git a/packages/react-dag-editor/package.json b/packages/react-dag-editor/package.json index a8801241..5cb5f8d9 100644 --- a/packages/react-dag-editor/package.json +++ b/packages/react-dag-editor/package.json @@ -6,6 +6,7 @@ }, "version": "0.3.5", "dependencies": { + "@algorithm.ts/mcmf": "^2.0.14", "@fluentui/merge-styles": "^8.2.0", "eventemitter3": "^4.0.7", "react-jss": "~10.2.0", diff --git a/packages/react-dag-editor/src/lib/utils/graphDiff/core/README.md b/packages/react-dag-editor/src/lib/utils/graphDiff/core/README.md new file mode 100644 index 00000000..f41237c0 --- /dev/null +++ b/packages/react-dag-editor/src/lib/utils/graphDiff/core/README.md @@ -0,0 +1,22 @@ +This algorithm is used to find the minimum-cost-maximum-matching in a bipartite graph. + +## Usage + +1. implement the [IGraphDiffContext](./context/GraphDiffContext.ts) +2. implement the [IGraphDiffResolver](./resolver/BaseGraphDiffResolver.ts) + + +```typescript + +const context: IGraphDiffContext; +const resolver: IGraphDiffResolver; + +// Find the best bipartite graph matching. +const candidateMappings = resolver.findCandidateMapping(context); +const mappings = findBipartiteGraphMaxMapping(candidateMappings); +// You can perform additional matches and modify mappings. + +// Build diff graph. +context.buildDiffGraph(mappings, abOnlyNodes,) + +``` diff --git a/packages/react-dag-editor/src/lib/utils/graphDiff/core/context/GraphDiffContext.ts b/packages/react-dag-editor/src/lib/utils/graphDiff/core/context/GraphDiffContext.ts new file mode 100644 index 00000000..81294e6e --- /dev/null +++ b/packages/react-dag-editor/src/lib/utils/graphDiff/core/context/GraphDiffContext.ts @@ -0,0 +1,101 @@ +import { + IGraph, + IGraphDiffContext, + IGraphEdge, + IGraphNode, + IGraphNodeOutgoingEdge, + IGraphNodeWithStructure, +} from "../types"; + +export interface IGraphDiffContextProps< + Node extends IGraphNode, + Edge extends IGraphEdge +> { + readonly lGraph: IGraph; + readonly rGraph: IGraph; +} + +export class GraphDiffContext + implements IGraphDiffContext +{ + public readonly ABOnlyCostThreshold: number = 100000; + public readonly PropertyDiffCostRate: number = 0.2; + public readonly StructureDiffCostRate: number = 1; + + public readonly lGraph: IGraph; + public readonly rGraph: IGraph; + + public readonly lNodesMap: ReadonlyMap< + string, + IGraphNodeWithStructure + >; + public readonly rNodesMap: ReadonlyMap< + string, + IGraphNodeWithStructure + >; + + public constructor(props: IGraphDiffContextProps) { + this.lGraph = props.lGraph; + this.rGraph = props.rGraph; + this.lNodesMap = this.buildNodeMap(props.lGraph); + this.rNodesMap = this.buildNodeMap(props.rGraph); + } + + protected buildNodeMap( + graph: IGraph + ): ReadonlyMap> { + const nodeMap: Map> = new Map(); + const idToNodeMap = new Map(); + for (const node of graph.nodes) { + idToNodeMap.set(node.id, node); + } + + for (const edge of graph.edges) { + const sourceNode = idToNodeMap.get(edge.source); + const targetNode = idToNodeMap.get(edge.target); + if (sourceNode && targetNode) { + { + const outEdge: IGraphNodeOutgoingEdge = { + portId: edge.sourcePort, + targetNode, + targetPortId: edge.targetPort, + originalEdge: edge, + }; + + const sourceNodeWithStructure = nodeMap.get(edge.source); + if (sourceNodeWithStructure) { + sourceNodeWithStructure.outEdges.push(outEdge); + } else { + nodeMap.set(edge.source, { + node: sourceNode, + inEdges: [], + outEdges: [outEdge], + }); + } + } + + { + const inEdge: IGraphNodeOutgoingEdge = { + portId: edge.targetPort, + targetNode: sourceNode, + targetPortId: edge.sourcePort, + originalEdge: edge, + }; + + const targetNodeWithStructure = nodeMap.get(edge.target); + if (targetNodeWithStructure) { + targetNodeWithStructure.inEdges.push(inEdge); + } else { + nodeMap.set(edge.target, { + node: targetNode, + inEdges: [inEdge], + outEdges: [], + }); + } + } + } + } + + return nodeMap; + } +} diff --git a/packages/react-dag-editor/src/lib/utils/graphDiff/core/index.ts b/packages/react-dag-editor/src/lib/utils/graphDiff/core/index.ts new file mode 100644 index 00000000..849812c3 --- /dev/null +++ b/packages/react-dag-editor/src/lib/utils/graphDiff/core/index.ts @@ -0,0 +1,10 @@ +export * from "./context/GraphDiffContext"; +export * from "./resolver/BaseGraphDiffResolver"; +export * from "./resolver/defaultBuildCandidateMapping"; +export * from "./resolver/defaultBuildDiffEdge"; +export * from "./resolver/defaultBuildDiffGraph"; +export * from "./resolver/defaultBuildDiffNode"; +export * from "./resolver/defaultCalcStructureDiffCost"; +export * from "./util/findBipartiteGraphMaxMapping"; +export * from "./util/genAutoIncrementId"; +export * from "./types"; diff --git a/packages/react-dag-editor/src/lib/utils/graphDiff/core/resolver/BaseGraphDiffResolver.ts b/packages/react-dag-editor/src/lib/utils/graphDiff/core/resolver/BaseGraphDiffResolver.ts new file mode 100644 index 00000000..d64387fe --- /dev/null +++ b/packages/react-dag-editor/src/lib/utils/graphDiff/core/resolver/BaseGraphDiffResolver.ts @@ -0,0 +1,86 @@ +import { + IABOnlyNode, + IDiffGraph, + IDiffGraphEdge, + IDiffGraphNode, + IDiffGraphNodeSearcher, + IGraphDiffContext, + IGraphDiffResolver, + IGraphEdge, + IGraphNode, + IGraphNodeDiffResult, + IMapping, +} from "../types"; +import { defaultBuildCandidateMapping } from "./defaultBuildCandidateMapping"; +import { defaultBuildDiffEdges } from "./defaultBuildDiffEdge"; +import { defaultBuildDiffNodes } from "./defaultBuildDiffNode"; +import { defaultBuildDiffGraph } from "./defaultBuildDiffGraph"; +import { defaultCalcStructureDiffCost } from "./defaultCalcStructureDiffCost"; + +export abstract class BaseGraphDiffResolver< + Node extends IGraphNode, + Edge extends IGraphEdge +> implements IGraphDiffResolver +{ + public abstract areSameNodes(lNode: Node, rNode: Node): IGraphNodeDiffResult; + + public buildCandidateMapping( + context: IGraphDiffContext + ): IMapping[] { + return defaultBuildCandidateMapping(context, this); + } + + public buildDiffEdges( + diffNodeSearcher: IDiffGraphNodeSearcher, + context: IGraphDiffContext + ): { diffEdges: IDiffGraphEdge[] } { + return defaultBuildDiffEdges(diffNodeSearcher, context); + } + + buildDiffGraph( + mappings: IMapping[], + abOnlyNodes: IABOnlyNode[], + context: IGraphDiffContext + ): IDiffGraph { + return defaultBuildDiffGraph(mappings, abOnlyNodes, context, this); + } + + public buildDiffNodes( + mappings: IMapping[], + abOnlyNodes: IABOnlyNode[], + _context: IGraphDiffContext + ): { + diffNodes: IDiffGraphNode[]; + diffNodeSearcher: IDiffGraphNodeSearcher; + } { + return defaultBuildDiffNodes(mappings, abOnlyNodes, this); + } + + public calcStructureDiffCost( + lNode: Node, + rNode: Node, + context: IGraphDiffContext + ): number { + return defaultCalcStructureDiffCost( + lNode, + rNode, + context, + this + ); + } + + public abstract calcPropertyDiffCost( + lNode: Node, + rNode: Node, + context: IGraphDiffContext + ): number; + + public hasSamePorts(lNode: Node, rNode: Node): boolean { + if (lNode.ports.length !== rNode.ports.length) { + return false; + } + + const lPortSet: Set = new Set(lNode.ports); + return rNode.ports.every((port) => lPortSet.has(port)); + } +} diff --git a/packages/react-dag-editor/src/lib/utils/graphDiff/core/resolver/defaultBuildCandidateMapping.ts b/packages/react-dag-editor/src/lib/utils/graphDiff/core/resolver/defaultBuildCandidateMapping.ts new file mode 100644 index 00000000..0742e5b2 --- /dev/null +++ b/packages/react-dag-editor/src/lib/utils/graphDiff/core/resolver/defaultBuildCandidateMapping.ts @@ -0,0 +1,52 @@ +import { + IGraphDiffContext, + IGraphDiffResolver, + IGraphEdge, + IGraphNode, + IMapping, +} from "../types"; + +export function defaultBuildCandidateMapping< + Node extends IGraphNode, + Edge extends IGraphEdge +>( + context: IGraphDiffContext, + resolver: IGraphDiffResolver +): IMapping[] { + const { ABOnlyCostThreshold, lGraph, rGraph } = context; + const lNodes: Node[] = lGraph.nodes; + const rNodes: Node[] = rGraph.nodes; + const candidates: IMapping[] = []; + + for (let i = 0; i < lNodes.length; ++i) { + const lNode = lNodes[i]; + for (let j = 0; j < rNodes.length; ++j) { + const rNode = rNodes[j]; + if (rNode && resolver.areSameNodes(lNode, rNode).same) { + const structureDiffCost: number = resolver.calcStructureDiffCost( + lNode, + rNode, + context + ); + const propertyDiffCost: number = resolver.calcPropertyDiffCost( + lNode, + rNode, + context + ); + const diffCost: number = structureDiffCost + propertyDiffCost; + if (diffCost < ABOnlyCostThreshold) { + candidates.push({ + lNode, + rNode, + cost: { + total: diffCost, + property: propertyDiffCost, + structure: structureDiffCost, + }, + }); + } + } + } + } + return candidates; +} diff --git a/packages/react-dag-editor/src/lib/utils/graphDiff/core/resolver/defaultBuildDiffEdge.ts b/packages/react-dag-editor/src/lib/utils/graphDiff/core/resolver/defaultBuildDiffEdge.ts new file mode 100644 index 00000000..2e79d195 --- /dev/null +++ b/packages/react-dag-editor/src/lib/utils/graphDiff/core/resolver/defaultBuildDiffEdge.ts @@ -0,0 +1,92 @@ +import { IGraphDiffContext, IGraphEdge, IGraphNode } from "../types"; +import { + GraphEdgeDiffType, + IDiffGraphEdge, + IDiffGraphNode, + IDiffGraphNodeSearcher, +} from "../types/diffGraph"; + +export function defaultBuildDiffEdges< + Node extends IGraphNode, + Edge extends IGraphEdge +>( + diffNodeSearcher: IDiffGraphNodeSearcher, + context: IGraphDiffContext +): { + diffEdges: IDiffGraphEdge[]; +} { + const genDiffEdgeId = ( + sourceDiffNode: IDiffGraphNode, + targetDiffNode: IDiffGraphNode, + sourcePort: string, + targetPort: string + ): string => + `${sourceDiffNode.id}:${sourcePort}#${targetDiffNode.id}:${targetPort}`; + + const diffGraphEdgeMap: Map> = new Map(); + + for (const edge of context.lGraph.edges) { + const sourceDiffNode = diffNodeSearcher.lNodeId2diffNodeMap.get( + edge.source + ); + const targetDiffNode = diffNodeSearcher.rNodeId2diffNodeMap.get( + edge.target + ); + + if (sourceDiffNode && targetDiffNode) { + const diffEdgeKey: string = genDiffEdgeId( + sourceDiffNode, + targetDiffNode, + edge.sourcePort, + edge.targetPort + ); + + const diffEdge: IDiffGraphEdge = { + id: diffEdgeKey, + diffType: GraphEdgeDiffType.AOnly, + sourceDiffNode, + targetDiffNode, + lEdge: edge, + rEdge: undefined, + }; + diffGraphEdgeMap.set(diffEdgeKey, diffEdge); + } + } + + for (const edge of context.rGraph.edges) { + const sourceDiffNode = diffNodeSearcher.rNodeId2diffNodeMap.get( + edge.source + ); + const targetDiffNode = diffNodeSearcher.rNodeId2diffNodeMap.get( + edge.target + ); + + if (sourceDiffNode && targetDiffNode) { + const diffEdgeKey: string = genDiffEdgeId( + sourceDiffNode, + targetDiffNode, + edge.sourcePort, + edge.targetPort + ); + + const existedDiffEdge = diffGraphEdgeMap.get(diffEdgeKey); + if (existedDiffEdge) { + existedDiffEdge.diffType = GraphEdgeDiffType.equal; + existedDiffEdge.rEdge = edge; + } else { + const diffEdge: IDiffGraphEdge = { + id: diffEdgeKey, + diffType: GraphEdgeDiffType.BOnly, + sourceDiffNode, + targetDiffNode, + lEdge: undefined, + rEdge: edge, + }; + diffGraphEdgeMap.set(diffEdgeKey, diffEdge); + } + } + } + return { + diffEdges: Array.from(diffGraphEdgeMap.values()), + }; +} diff --git a/packages/react-dag-editor/src/lib/utils/graphDiff/core/resolver/defaultBuildDiffGraph.ts b/packages/react-dag-editor/src/lib/utils/graphDiff/core/resolver/defaultBuildDiffGraph.ts new file mode 100644 index 00000000..0dc5688a --- /dev/null +++ b/packages/react-dag-editor/src/lib/utils/graphDiff/core/resolver/defaultBuildDiffGraph.ts @@ -0,0 +1,27 @@ +import { + IABOnlyNode, + IGraphDiffContext, + IGraphDiffResolver, + IGraphEdge, + IGraphNode, + IMapping, +} from "../types"; +import { IDiffGraph } from "../types/diffGraph"; + +export function defaultBuildDiffGraph< + Node extends IGraphNode, + Edge extends IGraphEdge +>( + mappings: IMapping[], + abOnlyNodes: IABOnlyNode[], + context: IGraphDiffContext, + resolver: IGraphDiffResolver +): IDiffGraph { + const { diffNodes, diffNodeSearcher } = resolver.buildDiffNodes( + mappings, + abOnlyNodes, + context + ); + const { diffEdges } = resolver.buildDiffEdges(diffNodeSearcher, context); + return { diffNodes, diffEdges }; +} diff --git a/packages/react-dag-editor/src/lib/utils/graphDiff/core/resolver/defaultBuildDiffNode.ts b/packages/react-dag-editor/src/lib/utils/graphDiff/core/resolver/defaultBuildDiffNode.ts new file mode 100644 index 00000000..4c9b76ae --- /dev/null +++ b/packages/react-dag-editor/src/lib/utils/graphDiff/core/resolver/defaultBuildDiffNode.ts @@ -0,0 +1,111 @@ +import { + GraphSource, + IABOnlyNode, + IGraphDiffResolver, + IGraphEdge, + IGraphNode, + IMapping, +} from "../types"; +import { + GraphNodeDiffType, + IDiffGraphNode, + IDiffGraphNodeSearcher, +} from "../types/diffGraph"; +import { genAutoIncrementId } from "../util/genAutoIncrementId"; + +export function defaultBuildDiffNodes< + Node extends IGraphNode, + Edge extends IGraphEdge +>( + mappings: IMapping[], + abOnlyNodes: IABOnlyNode[], + resolver: IGraphDiffResolver +): { + diffNodes: IDiffGraphNode[]; + diffNodeSearcher: IDiffGraphNodeSearcher; +} { + const genDiffNodeId = genAutoIncrementId(); + const diffNodes: IDiffGraphNode[] = []; + const diffNodeMap = new Map>(); + const lNodeId2diffNodeMap = new Map>(); + const rNodeId2diffNodeMap = new Map>(); + + const addDiffNode = (diffNode: IDiffGraphNode): void => { + diffNodes.push(diffNode); + diffNodeMap.set(diffNode.id, diffNode); + if (diffNode.lNode) { + lNodeId2diffNodeMap.set(diffNode.lNode.id, diffNode); + } + if (diffNode.rNode) { + rNodeId2diffNodeMap.set(diffNode.rNode.id, diffNode); + } + }; + + const addAOnlyNode = (node: Node): void => { + const diffNode: IDiffGraphNode = { + id: genDiffNodeId.next().value, + diffType: GraphNodeDiffType.AOnly, + lNode: node, + rNode: undefined, + }; + addDiffNode(diffNode); + }; + + const addBOnlyNode = (node: Node): void => { + const diffNode: IDiffGraphNode = { + id: genDiffNodeId.next().value, + diffType: GraphNodeDiffType.BOnly, + lNode: undefined, + rNode: node, + }; + addDiffNode(diffNode); + }; + + // A only / B only nodes. + abOnlyNodes.forEach((item) => { + if (item.active) { + switch (item.fromGraph) { + case GraphSource.A: + addAOnlyNode(item.node); + break; + case GraphSource.B: + addBOnlyNode(item.node); + break; + default: + } + } + }); + + // Paired nodes. + for (const mapping of mappings) { + const { lNode, rNode } = mapping; + const diffNode: IDiffGraphNode = { + id: genDiffNodeId.next().value, + diffType: GraphNodeDiffType.equal, + lNode, + rNode, + }; + + if (!resolver.hasSamePorts(lNode, rNode)) { + diffNode.diffType = GraphNodeDiffType.portChanged; + addDiffNode(diffNode); + continue; + } + + if (mapping.cost.property !== 0) { + diffNode.diffType = GraphNodeDiffType.propertyChanged; + addDiffNode(diffNode); + continue; + } + + addDiffNode(diffNode); + } + return { + diffNodes, + diffNodeSearcher: { + diffNodeMap, + lNodeId2diffNodeMap, + rNodeId2diffNodeMap, + }, + }; +} diff --git a/packages/react-dag-editor/src/lib/utils/graphDiff/core/resolver/defaultCalcStructureDiffCost.ts b/packages/react-dag-editor/src/lib/utils/graphDiff/core/resolver/defaultCalcStructureDiffCost.ts new file mode 100644 index 00000000..7160d512 --- /dev/null +++ b/packages/react-dag-editor/src/lib/utils/graphDiff/core/resolver/defaultCalcStructureDiffCost.ts @@ -0,0 +1,104 @@ +import { + IGraphDiffContext, + IGraphDiffResolver, + IGraphEdge, + IGraphNode, + IGraphNodeOutgoingEdge, +} from "../types"; + +export function defaultCalcStructureDiffCost< + Node extends IGraphNode, + Edge extends IGraphEdge +>( + lNode: Node, + rNode: Node, + context: IGraphDiffContext, + resolver: IGraphDiffResolver +): number { + const lNodeWithStructure = context.lNodesMap.get(lNode.id); + const rNodeWithStructure = context.rNodesMap.get(rNode.id); + + const ancestralDiffCost: number = structureDiffCost( + lNodeWithStructure?.inEdges ?? [], + rNodeWithStructure?.inEdges ?? [] + ); + const descendantDiffCost: number = structureDiffCost( + lNodeWithStructure?.outEdges ?? [], + rNodeWithStructure?.outEdges ?? [] + ); + return ancestralDiffCost + descendantDiffCost; + + function structureDiffCost( + lOutgoingEdges: IGraphNodeOutgoingEdge[], + rOutgoingEdges: IGraphNodeOutgoingEdge[] + ): number { + const totalOutgoingEdges: number = + lOutgoingEdges.length + rOutgoingEdges.length; + if (totalOutgoingEdges === 0) { + return 0; + } + + const lPairedSet: Set = new Set(); + const rPairedSet: Set = new Set(); + + // Find exact same outgoing edge pairs. + for (let i = 0; i < lOutgoingEdges.length; i += 1) { + const loe = lOutgoingEdges[i]; + const j: number = rOutgoingEdges.findIndex( + (roe, idx) => + !rPairedSet.has(idx) && + loe.portId === roe.portId && + loe.targetPortId === roe.targetPortId && + loe.targetNode.hash && + loe.targetNode.hash === roe.targetNode.hash + ); + + if (j > -1) { + lPairedSet.add(i); + rPairedSet.add(j); + } + } + const countOfExactSame: number = lPairedSet.size; + + // Find strong similar outgoing edge pairs. + for (let i = 0; i < lOutgoingEdges.length; i += 1) { + const loe = lOutgoingEdges[i]; + if (!lPairedSet.has(i)) { + const j: number = rOutgoingEdges.findIndex( + (roe, idx) => + !rPairedSet.has(idx) && + resolver.areSameNodes(loe.targetNode, roe.targetNode).same + ); + if (j > -1) { + lPairedSet.add(i); + rPairedSet.add(j); + } + } + } + const countOfStrongSimilar: number = lPairedSet.size - countOfExactSame; + + // Find weak similar node resource pairs. + for (let i = 0; i < lOutgoingEdges.length; i += 1) { + const loe = lOutgoingEdges[i]; + if (!lPairedSet.has(i)) { + const j: number = rOutgoingEdges.findIndex( + (roe, idx) => + !rPairedSet.has(idx) && + resolver.areSameNodes(loe.targetNode, roe.targetNode).same + ); + if (j > -1) { + lPairedSet.add(i); + rPairedSet.add(j); + } + } + } + const countOfWeakSimilar: number = + lPairedSet.size - countOfExactSame - countOfStrongSimilar; + + const countOfNotSimilar: number = totalOutgoingEdges - lPairedSet.size * 2; + const totalDiff: number = + countOfNotSimilar * 5 + countOfWeakSimilar * 2 + countOfStrongSimilar; + const cost: number = totalDiff * context.StructureDiffCostRate; + return cost; + } +} diff --git a/packages/react-dag-editor/src/lib/utils/graphDiff/core/types/context.ts b/packages/react-dag-editor/src/lib/utils/graphDiff/core/types/context.ts new file mode 100644 index 00000000..99426a29 --- /dev/null +++ b/packages/react-dag-editor/src/lib/utils/graphDiff/core/types/context.ts @@ -0,0 +1,23 @@ +import { + IGraph, + IGraphEdge, + IGraphNode, + IGraphNodeWithStructure, +} from "./graph"; + +export interface IGraphDIffEnums { + ABOnlyCostThreshold: number; + PropertyDiffCostRate: number; + StructureDiffCostRate: number; +} + +export interface IGraphDiffContext< + Node extends IGraphNode, + Edge extends IGraphEdge +> extends IGraphDIffEnums { + readonly lGraph: IGraph; + readonly rGraph: IGraph; + + readonly lNodesMap: ReadonlyMap>; + readonly rNodesMap: ReadonlyMap>; +} diff --git a/packages/react-dag-editor/src/lib/utils/graphDiff/core/types/diffGraph.ts b/packages/react-dag-editor/src/lib/utils/graphDiff/core/types/diffGraph.ts new file mode 100644 index 00000000..85de44e1 --- /dev/null +++ b/packages/react-dag-editor/src/lib/utils/graphDiff/core/types/diffGraph.ts @@ -0,0 +1,51 @@ +import { GraphSource, IGraphEdge, IGraphNode } from "./graph"; + +export enum GraphNodeDiffType { + equal = "equal", + AOnly = "AOnly", // A only. + BOnly = "BOnly", // B only. + portChanged = "portChanged", + propertyChanged = "propertyChanged", +} + +export enum GraphEdgeDiffType { + equal = "equal", + AOnly = "AOnly ", // A only. + BOnly = "BOnly", // B only. +} + +export interface IDiffGraphNode { + id: number; // DiffGraph node id. + diffType: GraphNodeDiffType; + lNode: Node | undefined; // Node from GraphSource.A + rNode: Node | undefined; // Node from GraphSource.B +} + +export interface IDiffGraphEdge< + Node extends IGraphNode, + Edge extends IGraphEdge +> { + id: string; // DiffGraph edge id. + diffType: GraphEdgeDiffType; + sourceDiffNode: IDiffGraphNode; + targetDiffNode: IDiffGraphNode; + lEdge: Edge | undefined; // Edge from GraphSource.A + rEdge: Edge | undefined; // Edge from GraphSource.B +} + +export interface IDiffGraph { + diffNodes: IDiffGraphNode[]; + diffEdges: IDiffGraphEdge[]; +} + +export interface IDiffGraphNodeSearcher { + diffNodeMap: Map>; + lNodeId2diffNodeMap: Map>; + rNodeId2diffNodeMap: Map>; +} + +export interface IABOnlyNode { + fromGraph: GraphSource; + node: Node; + active: boolean; +} diff --git a/packages/react-dag-editor/src/lib/utils/graphDiff/core/types/graph.ts b/packages/react-dag-editor/src/lib/utils/graphDiff/core/types/graph.ts new file mode 100644 index 00000000..eb7f8fa0 --- /dev/null +++ b/packages/react-dag-editor/src/lib/utils/graphDiff/core/types/graph.ts @@ -0,0 +1,60 @@ +export enum GraphSource { + A = "A", + B = "B", +} + +export interface IGraphNode { + id: string; + ports: string[]; + hash: string | undefined; // For quickly comparing. +} + +export interface IGraphEdge { + source: string; + sourcePort: string; + target: string; + targetPort: string; +} + +export interface IGraph { + nodes: Node[]; + edges: Edge[]; +} + +export interface IGraphNodeOutgoingEdge< + Node extends IGraphNode, + Edge extends IGraphEdge +> { + portId: string; + targetNode: Node; + targetPortId: string; + originalEdge: Edge; +} + +export interface IGraphNodeWithStructure< + Node extends IGraphNode, + Edge extends IGraphEdge +> { + node: Node; + inEdges: IGraphNodeOutgoingEdge[]; + outEdges: IGraphNodeOutgoingEdge[]; +} + +export interface IGraphNodeDiffCost { + /** + * Total diff cost of the two nodes linked by the edge. + * + * !!!NOTICE + * There may be other type of cost, so the value of `.total` may not equal to + * the sum of `.property` and `.structure`. + */ + total: number; + /** + * Diff cost of the properties between the two nodes linked by the edge. + */ + property: number; + /** + * Diff cost of the structure between the two nodes linked by the edge. + */ + structure: number; +} diff --git a/packages/react-dag-editor/src/lib/utils/graphDiff/core/types/index.ts b/packages/react-dag-editor/src/lib/utils/graphDiff/core/types/index.ts new file mode 100644 index 00000000..c0dfff33 --- /dev/null +++ b/packages/react-dag-editor/src/lib/utils/graphDiff/core/types/index.ts @@ -0,0 +1,4 @@ +export * from "./context"; +export * from "./diffGraph"; +export * from "./graph"; +export * from "./resolver"; diff --git a/packages/react-dag-editor/src/lib/utils/graphDiff/core/types/resolver.ts b/packages/react-dag-editor/src/lib/utils/graphDiff/core/types/resolver.ts new file mode 100644 index 00000000..3982988c --- /dev/null +++ b/packages/react-dag-editor/src/lib/utils/graphDiff/core/types/resolver.ts @@ -0,0 +1,75 @@ +import { IGraphDiffContext } from "./context"; +import { + IABOnlyNode, + IDiffGraph, + IDiffGraphEdge, + IDiffGraphNode, + IDiffGraphNodeSearcher, +} from "./diffGraph"; +import { IGraphEdge, IGraphNode, IGraphNodeDiffCost } from "./graph"; + +export interface IMapping { + lNode: Node; + rNode: Node; + cost: IGraphNodeDiffCost; +} + +export interface IGraphNodeDiffResult { + same: boolean; + /** + * Why are the two nodes considered different. + */ + reason?: string; + /** + * Additional debug data. + */ + details?: unknown; +} + +export interface IGraphDiffResolver< + Node extends IGraphNode, + Edge extends IGraphEdge +> { + areSameNodes(lNode: Node, rNode: Node): IGraphNodeDiffResult; + + buildCandidateMapping( + context: IGraphDiffContext + ): IMapping[]; + + buildDiffEdges( + diffNodeSearcher: IDiffGraphNodeSearcher, + context: IGraphDiffContext + ): { + diffEdges: IDiffGraphEdge[]; + }; + + buildDiffGraph( + mappings: IMapping[], + abOnlyNodes: IABOnlyNode[], + context: IGraphDiffContext + ): IDiffGraph; + + buildDiffNodes( + mappings: IMapping[], + abOnlyNodes: IABOnlyNode[], + context: IGraphDiffContext + ): { + diffNodes: IDiffGraphNode[]; + diffNodeSearcher: IDiffGraphNodeSearcher; + }; + + calcStructureDiffCost( + lNode: Node, + rNode: Node, + context: IGraphDiffContext + ): number; + + calcPropertyDiffCost( + lNode: Node, + rNode: Node, + context: IGraphDiffContext + ): number; + + // Check if two nodes have same ports. + hasSamePorts(lNode: Node, rNode: Node): boolean; +} diff --git a/packages/react-dag-editor/src/lib/utils/graphDiff/core/util/findBipartiteGraphMaxMapping.ts b/packages/react-dag-editor/src/lib/utils/graphDiff/core/util/findBipartiteGraphMaxMapping.ts new file mode 100644 index 00000000..8268b4a5 --- /dev/null +++ b/packages/react-dag-editor/src/lib/utils/graphDiff/core/util/findBipartiteGraphMaxMapping.ts @@ -0,0 +1,62 @@ +import { createMcmf } from "@algorithm.ts/mcmf"; +import { IGraphNode, IMapping } from "../types"; + +const mcmf = createMcmf(); + +/** + * Find the maximum matching with minimum cost in bipartite graph. + * + * @returns + */ +export const findBipartiteGraphMaxMapping = ( + candidates: IMapping[] +): IMapping[] => { + const sourceId = 0; + const sinkId = sourceId + 1; + mcmf.init(sourceId, sinkId, 2 + candidates.length * 2); + + let nextFlowNodeId = sinkId + 1; + const fromId2Mapping: Map> = new Map(); + + // Link flow source to the left graph node. + for (const mapping of candidates) { + const leftFlowNodeId = nextFlowNodeId++; + const rightFlowNodeId = nextFlowNodeId++; + + fromId2Mapping.set(leftFlowNodeId, mapping); + + // Link flow source to the left graph node. + mcmf.addEdge(sourceId, leftFlowNodeId, 1, 0); + + // Link left graph node to right graph node. + mcmf.addEdge(leftFlowNodeId, rightFlowNodeId, 1, mapping.cost.total); + + // Link right graph node to flow sink. + mcmf.addEdge(rightFlowNodeId, sinkId, 1, 0); + } + + // Run Min Cost Max Flow. + mcmf.minCostMaxFlow(); + + const filteredMappings: IMapping[] = []; + mcmf.solve(({ edges, edgeTot }) => { + for (let i = 0; i < edgeTot; i += 1) { + const edge = edges[i]; + + if ( + edge.cap > 0 && + edge.flow === edge.cap && + edge.from !== sourceId && + edge.to !== sinkId + ) { + const mapping = fromId2Mapping.get(edge.from); + if (mapping) { + filteredMappings.push(mapping); + } + } + } + }); + + fromId2Mapping.clear(); + return filteredMappings; +}; diff --git a/packages/react-dag-editor/src/lib/utils/graphDiff/core/util/genAutoIncrementId.ts b/packages/react-dag-editor/src/lib/utils/graphDiff/core/util/genAutoIncrementId.ts new file mode 100644 index 00000000..22eadc8f --- /dev/null +++ b/packages/react-dag-editor/src/lib/utils/graphDiff/core/util/genAutoIncrementId.ts @@ -0,0 +1,8 @@ +export function* genAutoIncrementId( + start = 0 +): Iterator { + let id: number = start; + for (; ; ++id) { + yield id; + } +} diff --git a/yarn.lock b/yarn.lock index fe37494b..67ca9aee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,18 @@ # yarn lockfile v1 +"@algorithm.ts/circular-queue@^2.0.14": + version "2.0.14" + resolved "https://registry.yarnpkg.com/@algorithm.ts/circular-queue/-/circular-queue-2.0.14.tgz#1403a07cf800c8cdd461d97fcc9ff0d318ae2c29" + integrity sha512-lzwTHH7FNRLI1ze0S67pdc+QegetqvDTNdLVz28CaIuYM6wlFgdN1KVflayJ3O58nDriOL4Rgj0v5vNE6pRo1g== + +"@algorithm.ts/mcmf@^2.0.14": + version "2.0.14" + resolved "https://registry.yarnpkg.com/@algorithm.ts/mcmf/-/mcmf-2.0.14.tgz#b0e207f965efb6992b162561d9f0a85965a54bb0" + integrity sha512-mTJtZ9YAvcuILDs7VjH2Wh7iqZhbU6v0bXE/bkM5IdReDMlDwcbq1xyuHg/EmQ2OAjZU7Da3oPYOni3sRk7v6A== + dependencies: + "@algorithm.ts/circular-queue" "^2.0.14" + "@babel/code-frame@7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz#168da1a36e90da68ae8d49c0f1b48c7c6249213a"