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:
William Chargin 2018-03-02 21:35:51 -08:00 committed by GitHub
parent 82dbf64a2c
commit 9b203e8489
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 207 additions and 0 deletions

View File

@ -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) {

View File

@ -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", () => {