Add graph merge functions (#62)
Summary: Merging graphs will be a common operation. At a per-plugin level, it will often be useful to build up graphs by creating many very small graphs and then merging them together. At a cross-project level, we will need to merge graphs across repositories to gain an understanding of how value flows among these repositories. It’s important that the core graph type provide useful functions for merging; this commit adds them. Test Plan: New unit tests added; run `yarn flow && yarn test`. wchargin-branch: graph-merge
This commit is contained in:
parent
82dbf64a2c
commit
9b203e8489
|
@ -133,6 +133,78 @@ export class Graph {
|
|||
getAllEdges(): Edge<mixed>[] {
|
||||
return Object.keys(this._edges).map((k) => this._edges[k]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two graphs. When two nodes have the same address, a resolver
|
||||
* function will be called with the two nodes; the resolver should
|
||||
* return a new node with the same address, which will take the place
|
||||
* of the two nodes in the new graph. Edges have similar behavior.
|
||||
*
|
||||
* The existing graph objects are not modified.
|
||||
*/
|
||||
static merge(
|
||||
g1: Graph,
|
||||
g2: Graph,
|
||||
nodeResolver: (Node<mixed>, Node<mixed>) => Node<mixed>,
|
||||
edgeResolver: (Edge<mixed>, Edge<mixed>) => Edge<mixed>
|
||||
) {
|
||||
const result = new Graph();
|
||||
g1.getAllNodes().forEach((node) => {
|
||||
if (g2.getNode(node.address) !== undefined) {
|
||||
const resolved = nodeResolver(node, g2.getNode(node.address));
|
||||
result.addNode(resolved);
|
||||
} else {
|
||||
result.addNode(node);
|
||||
}
|
||||
});
|
||||
g2.getAllNodes().forEach((node) => {
|
||||
if (result.getNode(node.address) === undefined) {
|
||||
result.addNode(node);
|
||||
}
|
||||
});
|
||||
g1.getAllEdges().forEach((edge) => {
|
||||
if (g2.getEdge(edge.address) !== undefined) {
|
||||
const resolved = edgeResolver(edge, g2.getEdge(edge.address));
|
||||
result.addEdge(resolved);
|
||||
} else {
|
||||
result.addEdge(edge);
|
||||
}
|
||||
});
|
||||
g2.getAllEdges().forEach((edge) => {
|
||||
if (result.getEdge(edge.address) === undefined) {
|
||||
result.addEdge(edge);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two graphs, assuming that if `g1` and `g2` both have a node
|
||||
* with a given address, then the nodes are deep-equal (and the same
|
||||
* for edges). If this assumption does not hold, this function will
|
||||
* raise an error.
|
||||
*/
|
||||
static mergeConservative(g1: Graph, g2: Graph) {
|
||||
function conservativeReducer<T: {+address: Address}>(
|
||||
kinds: string /* used for an error message on mismatch */,
|
||||
a: T,
|
||||
b: T
|
||||
): T {
|
||||
if (deepEqual(a, b)) {
|
||||
return a;
|
||||
} else {
|
||||
throw new Error(
|
||||
`distinct ${kinds} with address ${addressToString(a.address)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return Graph.merge(
|
||||
g1,
|
||||
g2,
|
||||
(u, v) => conservativeReducer("nodes", u, v),
|
||||
(e, f) => conservativeReducer("edges", e, f)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function addressToString(address: Address) {
|
||||
|
|
|
@ -428,6 +428,141 @@ describe("graph", () => {
|
|||
expect(g2.equals(g1)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("merging", () => {
|
||||
/**
|
||||
* Decompose the given graph into neighborhood graphs: for each
|
||||
* node `u`, create a graph with just that node, its neighbors,
|
||||
* and its incident edges (in both directions).
|
||||
*/
|
||||
function neighborhoodDecomposition(originalGraph: Graph): Graph[] {
|
||||
return originalGraph.getAllNodes().map((node) => {
|
||||
const miniGraph = new Graph();
|
||||
miniGraph.addNode(node);
|
||||
originalGraph.getOutEdges(node.address).forEach((edge) => {
|
||||
if (miniGraph.getNode(edge.dst) === undefined) {
|
||||
miniGraph.addNode(originalGraph.getNode(edge.dst));
|
||||
}
|
||||
miniGraph.addEdge(edge);
|
||||
});
|
||||
originalGraph.getInEdges(node.address).forEach((edge) => {
|
||||
if (miniGraph.getNode(edge.src) === undefined) {
|
||||
miniGraph.addNode(originalGraph.getNode(edge.src));
|
||||
}
|
||||
if (miniGraph.getEdge(edge.address) === undefined) {
|
||||
// This check is necessary to prevent double-adding loops.
|
||||
miniGraph.addEdge(edge);
|
||||
}
|
||||
});
|
||||
return miniGraph;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompose the given graph into edge graphs: for each edge `e`,
|
||||
* create a graph with just that edge and its two endpoints.
|
||||
*/
|
||||
function edgeDecomposition(originalGraph: Graph): Graph[] {
|
||||
return originalGraph.getAllEdges().map((edge) => {
|
||||
const miniGraph = new Graph();
|
||||
miniGraph.addNode(originalGraph.getNode(edge.src));
|
||||
if (miniGraph.getNode(edge.dst) === undefined) {
|
||||
// This check is necessary to prevent double-adding loops.
|
||||
miniGraph.addNode(originalGraph.getNode(edge.dst));
|
||||
}
|
||||
miniGraph.addEdge(edge);
|
||||
return miniGraph;
|
||||
});
|
||||
}
|
||||
|
||||
it("conservatively recomposes a neighborhood decomposition", () => {
|
||||
const result = neighborhoodDecomposition(advancedMealGraph()).reduce(
|
||||
(g1, g2) => Graph.mergeConservative(g1, g2),
|
||||
new Graph()
|
||||
);
|
||||
expect(result.equals(advancedMealGraph())).toBe(true);
|
||||
});
|
||||
|
||||
it("conservatively recomposes an edge decomposition", () => {
|
||||
const result = edgeDecomposition(advancedMealGraph()).reduce(
|
||||
(g1, g2) => Graph.mergeConservative(g1, g2),
|
||||
new Graph()
|
||||
);
|
||||
expect(result.equals(advancedMealGraph())).toBe(true);
|
||||
});
|
||||
|
||||
it("conservatively merges a graph with itself", () => {
|
||||
const result = Graph.mergeConservative(
|
||||
advancedMealGraph(),
|
||||
advancedMealGraph()
|
||||
);
|
||||
expect(result.equals(advancedMealGraph())).toBe(true);
|
||||
});
|
||||
|
||||
it("conservatively rejects a graph with conflicting nodes", () => {
|
||||
const makeGraph: (nodePayload: string) => Graph = (nodePayload) =>
|
||||
new Graph().addNode({
|
||||
address: makeAddress("conflicting-node"),
|
||||
payload: nodePayload,
|
||||
});
|
||||
const g1 = makeGraph("one");
|
||||
const g2 = makeGraph("two");
|
||||
expect(() => {
|
||||
Graph.mergeConservative(g1, g2);
|
||||
}).toThrow(/distinct nodes with address/);
|
||||
});
|
||||
|
||||
it("conservatively rejects a graph with conflicting edges", () => {
|
||||
const srcAddress = makeAddress("src");
|
||||
const dstAddress = makeAddress("dst");
|
||||
const makeGraph: (edgePayload: string) => Graph = (edgePayload) =>
|
||||
new Graph()
|
||||
.addNode({address: srcAddress, payload: {}})
|
||||
.addNode({address: dstAddress, payload: {}})
|
||||
.addEdge({
|
||||
address: makeAddress("conflicting-edge"),
|
||||
src: srcAddress,
|
||||
dst: dstAddress,
|
||||
payload: edgePayload,
|
||||
});
|
||||
const g1 = makeGraph("one");
|
||||
const g2 = makeGraph("two");
|
||||
expect(() => {
|
||||
Graph.mergeConservative(g1, g2);
|
||||
}).toThrow(/distinct edges with address/);
|
||||
});
|
||||
|
||||
function assertNotCalled(...args) {
|
||||
throw new Error(`called with: ${args.join()}`);
|
||||
}
|
||||
it("has the empty graph as a left identity", () => {
|
||||
const merged = Graph.merge(
|
||||
new Graph(),
|
||||
advancedMealGraph(),
|
||||
assertNotCalled,
|
||||
assertNotCalled
|
||||
);
|
||||
expect(merged.equals(advancedMealGraph())).toBe(true);
|
||||
});
|
||||
it("has the empty graph as a right identity", () => {
|
||||
const merged = Graph.merge(
|
||||
advancedMealGraph(),
|
||||
new Graph(),
|
||||
assertNotCalled,
|
||||
assertNotCalled
|
||||
);
|
||||
expect(merged.equals(advancedMealGraph())).toBe(true);
|
||||
});
|
||||
it("trivially merges the empty graph with itself", () => {
|
||||
const merged = Graph.merge(
|
||||
new Graph(),
|
||||
new Graph(),
|
||||
assertNotCalled,
|
||||
assertNotCalled
|
||||
);
|
||||
expect(merged.equals(new Graph())).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("string functions", () => {
|
||||
|
|
Loading…
Reference in New Issue