Implement `Graph.neighbors` (#368)

This is the change that puts the Graph into `Graph` :) We add `_inEdges`
and `_outEdges`, and use them to identify the neighbors of a given
`node`.

The API is implemented pretty uncontroversially. (We've done this a few
times before: see #319, #162). As with other iterators, we check for
comodification and error if this has occurred.

The tests cover some interesting cases like absent nodes, loops, and
multiple edges with the same src and dst.

Test plan:
Unit tests have been added. Run `yarn travis`.

Paired with: @wchargin
This commit is contained in:
Dandelion Mané 2018-06-08 13:34:43 -07:00 committed by GitHub
parent 25df874db7
commit 4a441eb287
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 351 additions and 13 deletions

View File

@ -52,6 +52,11 @@ export class Graph {
//
// Invariant: If an edge `e` is in the graph, then `e.src` and `e.dst`
// are both in the graph.
//
// Invariant: For an edge `e`, the following are equivalent:
// - `e` is in the graph;
// - `_inEdges.get(e.dst)` contains `e` exactly once;
// - `_outEdges.get(e.src)` contains `e` exactly once.
_nodes: Set<NodeAddressT>;
_edges: Map<EdgeAddressT, Edge>;
_inEdges: Map<NodeAddressT, Edge[]>;
@ -97,21 +102,31 @@ export class Graph {
addNode(a: NodeAddressT): this {
NodeAddress.assertValid(a);
this._nodes.add(a);
if (!this._nodes.has(a)) {
this._nodes.add(a);
this._inEdges.set(a, []);
this._outEdges.set(a, []);
}
this._markModification();
return this;
}
removeNode(a: NodeAddressT): this {
NodeAddress.assertValid(a);
for (const e of this.edges()) {
if (e.src === a || e.dst === a) {
const srcOrDst = e.src === a ? "src" : "dst";
throw new Error(
`Attempted to remove ${srcOrDst} of ${edgeToString(e)}`
);
}
const existingInEdges = this._inEdges.get(a) || [];
const existingOutEdges = this._outEdges.get(a) || [];
const existingEdges = existingInEdges.concat(existingOutEdges);
if (existingEdges.length > 0) {
const strAddress = NodeAddress.toString(a);
const strExampleEdge = edgeToString(existingEdges[0]);
throw new Error(
`Attempted to remove ${strAddress}, which is incident to ${
existingEdges.length
} edge(s), e.g.: ${strExampleEdge}`
);
}
this._inEdges.delete(a);
this._outEdges.delete(a);
this._nodes.delete(a);
this._markModification();
return this;
@ -160,6 +175,15 @@ export class Graph {
`conflict between new edge ${strEdge} and existing ${strExisting}`
);
}
} else {
this._edges.set(edge.address, edge);
const inEdges = this._inEdges.get(edge.dst);
const outEdges = this._outEdges.get(edge.src);
if (inEdges == null || outEdges == null) {
throw new Error(`Invariant violation on edge ${edgeToString(edge)}`);
}
inEdges.push(edge);
outEdges.push(edge);
}
this._edges.set(edge.address, edge);
this._markModification();
@ -168,7 +192,28 @@ export class Graph {
removeEdge(address: EdgeAddressT): this {
EdgeAddress.assertValid(address);
this._edges.delete(address);
const edge = this._edges.get(address);
if (edge != null) {
this._edges.delete(address);
const inEdges = this._inEdges.get(edge.dst);
const outEdges = this._outEdges.get(edge.src);
if (inEdges == null || outEdges == null) {
throw new Error(`Invariant violation on ${edgeToString(edge)}`);
}
// TODO(perf): This is linear in the degree of the endpoints of the
// edge. Consider storing in non-list form (e.g., `_inEdges` and
// `_outEdges` could be `Map<NodeAddressT, Set<EdgeAddressT>>`).
[inEdges, outEdges].forEach((edges) => {
const index = edges.findIndex((edge) => edge.address === address);
if (index === -1) {
const strAddress = EdgeAddress.toString(address);
throw new Error(
`Invariant violation when removing edge@${strAddress}`
);
}
edges.splice(index, 1);
});
}
this._markModification();
return this;
}
@ -196,8 +241,55 @@ export class Graph {
}
neighbors(node: NodeAddressT, options: NeighborsOptions): Iterator<Neighbor> {
const _ = {node, options};
throw new Error("neighbors");
if (!this.hasNode(node)) {
throw new Error(`Node does not exist: ${NodeAddress.toString(node)}`);
}
return this._neighbors(node, options, this._modificationCount);
}
*_neighbors(
node: NodeAddressT,
options: NeighborsOptions,
initialModificationCount: ModificationCount
): Iterator<Neighbor> {
const nodeFilter = (n) => NodeAddress.hasPrefix(n, options.nodePrefix);
const edgeFilter = (e) => EdgeAddress.hasPrefix(e, options.edgePrefix);
const direction = options.direction;
const adjacencies: {edges: Edge[], direction: string}[] = [];
if (direction === Direction.IN || direction === Direction.ANY) {
const inEdges = this._inEdges.get(node);
if (inEdges == null) {
throw new Error(
`Invariant violation: No inEdges for ${NodeAddress.toString(node)}`
);
}
adjacencies.push({edges: inEdges, direction: "IN"});
}
if (direction === Direction.OUT || direction === Direction.ANY) {
const outEdges = this._outEdges.get(node);
if (outEdges == null) {
throw new Error(
`Invariant violation: No outEdges for ${NodeAddress.toString(node)}`
);
}
adjacencies.push({edges: outEdges, direction: "OUT"});
}
for (const adjacency of adjacencies) {
for (const edge of adjacency.edges) {
if (direction === Direction.ANY && adjacency.direction === "IN") {
if (edge.src === edge.dst) {
continue; // don't yield loop edges twice.
}
}
const neighborNode = adjacency.direction === "IN" ? edge.src : edge.dst;
if (nodeFilter(neighborNode) && edgeFilter(edge.address)) {
this._checkForComodification(initialModificationCount);
yield {edge, node: neighborNode};
}
}
}
this._checkForComodification(initialModificationCount);
}
copy(): Graph {

View File

@ -1,7 +1,11 @@
// @flow
import sortBy from "lodash.sortby";
import {
type EdgeAddressT,
type Neighbor,
type NeighborsOptions,
type NodeAddressT,
Direction,
EdgeAddress,
@ -94,12 +98,12 @@ describe("core/graph", () => {
.addEdge(edge());
it("src", () => {
expect(() => graph().removeNode(src)).toThrow(
"Attempted to remove src of"
"Attempted to remove"
);
});
it("dst", () => {
expect(() => graph().removeNode(dst)).toThrow(
"Attempted to remove dst of"
"Attempted to remove"
);
});
});
@ -410,6 +414,15 @@ describe("core/graph", () => {
.addEdge({src, dst, address})
.addEdge({src, dst, address});
expect(edgeArray(g)).toEqual([{src, dst, address}]);
expect(
Array.from(
g.neighbors(src, {
direction: Direction.ANY,
nodePrefix: NodeAddress.fromParts([]),
edgePrefix: EdgeAddress.fromParts([]),
})
)
).toHaveLength(1);
});
it("`removeEdge`", () => {
const g = new Graph()
@ -457,6 +470,239 @@ describe("core/graph", () => {
expect(g._modificationCount).not.toEqual(before);
});
});
describe("neighbors", () => {
const foo = NodeAddress.fromParts(["foo", "suffix"]);
const loop = NodeAddress.fromParts(["loop"]);
const isolated = NodeAddress.fromParts(["isolated"]);
const foo_loop = {
src: foo,
dst: loop,
address: EdgeAddress.fromParts(["foo", "1"]),
};
const loop_foo = {
src: loop,
dst: foo,
address: EdgeAddress.fromParts(["foo", "2"]),
};
const loop_loop = {
src: loop,
dst: loop,
address: EdgeAddress.fromParts(["loop"]),
};
const repeated_loop_foo = {
src: loop,
dst: foo,
address: EdgeAddress.fromParts(["repeated", "foo"]),
};
function quiver() {
return new Graph()
.addNode(foo)
.addNode(loop)
.addNode(isolated)
.addEdge(foo_loop)
.addEdge(loop_foo)
.addEdge(loop_loop)
.addEdge(repeated_loop_foo);
}
function expectNeighbors(
node: NodeAddressT,
options: NeighborsOptions,
expected: Neighbor[]
) {
const g = quiver();
const actual = Array.from(g.neighbors(node, options));
const sorter = (arr) =>
sortBy(arr, (neighbor) => neighbor.edge.address);
expect(sorter(actual)).toEqual(sorter(expected));
}
it("re-adding a node does not suppress its edges", () => {
const graph = quiver().addNode(foo);
expect(
Array.from(
graph.neighbors(foo, {
direction: Direction.ANY,
nodePrefix: NodeAddress.fromParts([]),
edgePrefix: EdgeAddress.fromParts([]),
})
)
).not.toHaveLength(0);
});
it("isolated node has no neighbors", () => {
expectNeighbors(
isolated,
{
direction: Direction.ANY,
nodePrefix: NodeAddress.fromParts([]),
edgePrefix: EdgeAddress.fromParts([]),
},
[]
);
});
function expectLoopNeighbors(dir, nodeParts, edgeParts, expected) {
const options = {
direction: dir,
nodePrefix: NodeAddress.fromParts(nodeParts),
edgePrefix: EdgeAddress.fromParts(edgeParts),
};
expectNeighbors(loop, options, expected);
}
describe("direction filtering", () => {
it("IN", () => {
expectLoopNeighbors(
Direction.IN,
[],
[],
[{node: loop, edge: loop_loop}, {node: foo, edge: foo_loop}]
);
});
it("OUT", () => {
expectLoopNeighbors(
Direction.OUT,
[],
[],
[
{node: loop, edge: loop_loop},
{node: foo, edge: repeated_loop_foo},
{node: foo, edge: loop_foo},
]
);
});
// verifies that the loop edge is not double-counted.
it("ANY", () => {
expectLoopNeighbors(
Direction.ANY,
[],
[],
[
{node: loop, edge: loop_loop},
{node: foo, edge: repeated_loop_foo},
{node: foo, edge: loop_foo},
{node: foo, edge: foo_loop},
]
);
});
});
describe("node prefix filtering", () => {
function nodeExpectNeighbors(parts, expected) {
expectNeighbors(
loop,
{
direction: Direction.ANY,
nodePrefix: NodeAddress.fromParts(parts),
edgePrefix: EdgeAddress.fromParts([]),
},
expected
);
}
it("returns nodes exactly matching prefix", () => {
nodeExpectNeighbors(["loop"], [{node: loop, edge: loop_loop}]);
});
it("returns nodes inexactly matching prefix", () => {
nodeExpectNeighbors(
["foo"],
[
{node: foo, edge: loop_foo},
{node: foo, edge: foo_loop},
{node: foo, edge: repeated_loop_foo},
]
);
});
it("returns empty for non-existent prefix", () => {
nodeExpectNeighbors(["qux"], []);
});
});
describe("edge prefix filtering", () => {
function edgeExpectNeighbors(parts, expected) {
expectNeighbors(
loop,
{
direction: Direction.ANY,
nodePrefix: NodeAddress.fromParts([]),
edgePrefix: EdgeAddress.fromParts(parts),
},
expected
);
}
it("works for an exact address match", () => {
edgeExpectNeighbors(
["repeated", "foo"],
[{node: foo, edge: repeated_loop_foo}]
);
});
it("works for a proper prefix match", () => {
edgeExpectNeighbors(
["foo"],
[{node: foo, edge: foo_loop}, {node: foo, edge: loop_foo}]
);
});
it("works when there are no matching edges", () => {
edgeExpectNeighbors(["wat"], []);
});
});
it("works for node and edge filter combined", () => {
expectNeighbors(
loop,
{
direction: Direction.ANY,
nodePrefix: NodeAddress.fromParts(["foo"]),
edgePrefix: EdgeAddress.fromParts(["repeated"]),
},
[{node: foo, edge: repeated_loop_foo}]
);
});
describe("errors on", () => {
const defaultOptions = () => ({
direction: Direction.ANY,
edgePrefix: EdgeAddress.fromParts([]),
nodePrefix: NodeAddress.fromParts([]),
});
function throwsWith(node, options, message) {
// $ExpectFlowError
expect(() => new Graph().neighbors(node, options)).toThrow(message);
}
it("invalid address", () => {
// This is a proxy for testing that NodeAddress.assertValid is called.
// Thus we don't need to exhaustively test every bad case.
throwsWith(
EdgeAddress.fromParts([]),
defaultOptions(),
"NodeAddress"
);
});
it("absent node", () => {
throwsWith(
NodeAddress.fromParts([]),
defaultOptions(),
"Node does not exist"
);
});
describe("concurrent modification", () => {
it("while in the middle of iteration", () => {
const g = quiver();
const iterator = g.neighbors(loop, defaultOptions());
g._modificationCount++;
expect(() => iterator.next()).toThrow("Concurrent modification");
});
it("at exhaustion", () => {
const g = quiver();
const iterator = g.neighbors(isolated, defaultOptions());
g._modificationCount++;
expect(() => iterator.next()).toThrow("Concurrent modification");
});
});
});
});
});
describe("edgeToString", () => {