Use indexed edges in graph internals (#295)

Summary:
This is an implementation-only, API-preserving change to the `Graph`
class. Edges’ `src` and `dst` attributes are now internally represented
as integer indices into a fixed ordering of nodes, which may depend on
non-logical properties such as insertion order. The graph’s serialized
form also now stores edges with integer `src`/`dst` keys, but the node
ordering is canonicalized so that two graphs are logically equal if and
only if their serialized forms are equal. This change substantially
reduces the rest storage space for graphs: the `sourcecred/sourcecred`
graph drops from 39MB to 30MB.

Currently, the graph will have to translate between integer indices and
full addresses at each client operation. This is not actually a big
performance regression, because it is just one more integer-index
dereference over the previous behavior, but it does indicate that the
optimization is not living up to its full potential. In subsequent
changes, the `NodeReference` class will be outfitted with facilities to
take advantage of the internal indexing; a long-term goal is that
roughly all operations should be able to be performed within the indexed
layer, and that translating between integers and addresses should only
happen at non-hot-path API boundaries.

This diff is considerably smaller and easier to read with `-w`.

Paired with @decentralion.

Test Plan:
I inspected the snapshots for general form, and manually verified that
the indices for one edge were correct (the MERGED_AS edge for the head
commit of the example repo). Other than that, existing unit tests mostly
suffice; one new unit test added.

wchargin-branch: graph-indexed-edges
This commit is contained in:
William Chargin 2018-05-22 13:15:39 -07:00 committed by GitHub
parent 5a40bb0a30
commit 2b301f9159
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 2055 additions and 3110 deletions

View File

@ -4,7 +4,7 @@ exports[`graph #Graph JSON functions should serialize a simple graph 1`] = `
Array [ Array [
Object { Object {
"type": "sourcecred/sourcecred/Graph", "type": "sourcecred/sourcecred/Graph",
"version": "0.1.0", "version": "0.2.0",
}, },
Object { Object {
"edges": Array [ "edges": Array [
@ -14,134 +14,85 @@ Array [
}, },
Object { Object {
"{\\"id\\":\\"crab-self-assessment\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"type\\":\\"SILLY\\"}": Object { "{\\"id\\":\\"crab-self-assessment\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"type\\":\\"SILLY\\"}": Object {
"dst": Object { "dstIndex": 2,
"id": "razorclaw_crab#2",
"pluginName": "hill_cooking_pot",
"type": "FOOD",
},
"payload": Object { "payload": Object {
"evaluation": "not effective at avoiding hero", "evaluation": "not effective at avoiding hero",
}, },
"src": Object { "srcIndex": 2,
"id": "razorclaw_crab#2",
"pluginName": "hill_cooking_pot",
"type": "FOOD",
},
}, },
"{\\"id\\":\\"hero_of_time#0@again_cooks@seafood_fruit_mix#3\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"type\\":\\"ACTION\\"}": Object { "{\\"id\\":\\"hero_of_time#0@again_cooks@seafood_fruit_mix#3\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"type\\":\\"ACTION\\"}": Object {
"dst": Object { "dstIndex": 0,
"id": "hero_of_time#0",
"pluginName": "hill_cooking_pot",
"type": "PC",
},
"payload": Object { "payload": Object {
"crit": true, "crit": true,
"saveScummed": true, "saveScummed": true,
}, },
"src": Object { "srcIndex": 3,
"id": "seafood_fruit_mix#3",
"pluginName": "hill_cooking_pot",
"type": "FOOD",
},
}, },
"{\\"id\\":\\"hero_of_time#0@cooks@seafood_fruit_mix#3\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"type\\":\\"ACTION\\"}": Object { "{\\"id\\":\\"hero_of_time#0@cooks@seafood_fruit_mix#3\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"type\\":\\"ACTION\\"}": Object {
"dst": Object { "dstIndex": 0,
"id": "hero_of_time#0",
"pluginName": "hill_cooking_pot",
"type": "PC",
},
"payload": Object { "payload": Object {
"crit": false, "crit": false,
}, },
"src": Object { "srcIndex": 3,
"id": "seafood_fruit_mix#3",
"pluginName": "hill_cooking_pot",
"type": "FOOD",
},
}, },
"{\\"id\\":\\"hero_of_time#0@eats@seafood_fruit_mix#3\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"type\\":\\"ACTION\\"}": Object { "{\\"id\\":\\"hero_of_time#0@eats@seafood_fruit_mix#3\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"type\\":\\"ACTION\\"}": Object {
"dst": Object { "dstIndex": 3,
"id": "seafood_fruit_mix#3",
"pluginName": "hill_cooking_pot",
"type": "FOOD",
},
"payload": Object {}, "payload": Object {},
"src": Object { "srcIndex": 0,
"id": "hero_of_time#0",
"pluginName": "hill_cooking_pot",
"type": "PC",
},
}, },
"{\\"id\\":\\"hero_of_time#0@grabs@razorclaw_crab#2\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"type\\":\\"ACTION\\"}": Object { "{\\"id\\":\\"hero_of_time#0@grabs@razorclaw_crab#2\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"type\\":\\"ACTION\\"}": Object {
"dst": Object { "dstIndex": 0,
"id": "hero_of_time#0",
"pluginName": "hill_cooking_pot",
"type": "PC",
},
"payload": Object {}, "payload": Object {},
"src": Object { "srcIndex": 2,
"id": "razorclaw_crab#2",
"pluginName": "hill_cooking_pot",
"type": "FOOD",
},
}, },
"{\\"id\\":\\"hero_of_time#0@picks@mighty_bananas#1\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"type\\":\\"ACTION\\"}": Object { "{\\"id\\":\\"hero_of_time#0@picks@mighty_bananas#1\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"type\\":\\"ACTION\\"}": Object {
"dst": Object { "dstIndex": 0,
"id": "hero_of_time#0",
"pluginName": "hill_cooking_pot",
"type": "PC",
},
"payload": Object {}, "payload": Object {},
"src": Object { "srcIndex": 1,
"id": "mighty_bananas#1",
"pluginName": "hill_cooking_pot",
"type": "FOOD",
},
}, },
"{\\"id\\":\\"mighty_bananas#1@included_in@seafood_fruit_mix#3\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"type\\":\\"INGREDIENT\\"}": Object { "{\\"id\\":\\"mighty_bananas#1@included_in@seafood_fruit_mix#3\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"type\\":\\"INGREDIENT\\"}": Object {
"dst": Object { "dstIndex": 1,
"id": "mighty_bananas#1",
"pluginName": "hill_cooking_pot",
"type": "FOOD",
},
"payload": Object {}, "payload": Object {},
"src": Object { "srcIndex": 3,
"id": "seafood_fruit_mix#3",
"pluginName": "hill_cooking_pot",
"type": "FOOD",
},
}, },
"{\\"id\\":\\"razorclaw_crab#2@included_in@seafood_fruit_mix#3\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"type\\":\\"INGREDIENT\\"}": Object { "{\\"id\\":\\"razorclaw_crab#2@included_in@seafood_fruit_mix#3\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"type\\":\\"INGREDIENT\\"}": Object {
"dst": Object { "dstIndex": 2,
"id": "razorclaw_crab#2",
"pluginName": "hill_cooking_pot",
"type": "FOOD",
},
"payload": Object {}, "payload": Object {},
"src": Object { "srcIndex": 3,
"id": "seafood_fruit_mix#3",
"pluginName": "hill_cooking_pot",
"type": "FOOD",
},
}, },
}, },
], ],
"nodes": Array [ "nodes": Array [
Object { Object {
"type": "sourcecred/sourcecred/AddressMap", "address": Object {
"version": "0.1.0", "id": "hero_of_time#0",
"pluginName": "hill_cooking_pot",
"type": "PC",
},
"payload": Object {},
}, },
Object { Object {
"{\\"id\\":\\"hero_of_time#0\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"type\\":\\"PC\\"}": Object { "address": Object {
"id": "mighty_bananas#1",
"pluginName": "hill_cooking_pot",
"type": "FOOD",
},
"payload": Object {}, "payload": Object {},
}, },
"{\\"id\\":\\"mighty_bananas#1\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"type\\":\\"FOOD\\"}": Object { Object {
"address": Object {
"id": "razorclaw_crab#2",
"pluginName": "hill_cooking_pot",
"type": "FOOD",
},
"payload": Object {}, "payload": Object {},
}, },
"{\\"id\\":\\"razorclaw_crab#2\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"type\\":\\"FOOD\\"}": Object { Object {
"payload": Object {}, "address": Object {
"id": "seafood_fruit_mix#3",
"pluginName": "hill_cooking_pot",
"type": "FOOD",
}, },
"{\\"id\\":\\"seafood_fruit_mix#3\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"type\\":\\"FOOD\\"}": Object {
"payload": Object { "payload": Object {
"effect": Array [ "effect": Array [
"attack_power", "attack_power",
@ -149,6 +100,13 @@ Array [
], ],
}, },
}, },
Object {
"address": Object {
"id": "~000#missingno",
"pluginName": "hill_cooking_pot",
"type": "EXPERIMENT",
},
"payload": null,
}, },
], ],
}, },

View File

@ -7,6 +7,8 @@ import {AddressMap} from "./address";
import {toCompat, fromCompat} from "../util/compat"; import {toCompat, fromCompat} from "../util/compat";
import type {Compatible} from "../util/compat"; import type {Compatible} from "../util/compat";
type Integer = number;
export type Node<+T> = {| export type Node<+T> = {|
+address: Address, +address: Address,
+payload: T, +payload: T,
@ -19,31 +21,46 @@ export type Edge<+T> = {|
+payload: T, +payload: T,
|}; |};
const COMPAT_TYPE = "sourcecred/sourcecred/Graph"; type IndexedEdge<+T> = {|
const COMPAT_VERSION = "0.1.0"; +address: Address,
+srcIndex: Integer,
+dstIndex: Integer,
+payload: T,
|};
export type GraphJSON<NP, EP> = Compatible<{| const COMPAT_TYPE = "sourcecred/sourcecred/Graph";
+nodes: AddressMapJSON<Node<NP>>, const COMPAT_VERSION = "0.2.0";
+edges: AddressMapJSON<Edge<EP>>,
|}>; type NodesSortedByStringifiedAddress<NP> = {|
+address: Address,
+payload?: NP,
|}[];
export type GraphJSON<NP, EP> = {|
+nodes: NodesSortedByStringifiedAddress<NP>,
+edges: AddressMapJSON<IndexedEdge<EP>>,
|};
type MaybeNode<+NP> = {|+address: Address, +node: Node<NP> | void|};
export class Graph<NP, EP> { export class Graph<NP, EP> {
_nodes: AddressMap<Node<NP>>; // Invariant: sizes of `_nodeIndices`, `_nodes`, `_outEdges`, and
_edges: AddressMap<Edge<EP>>; // `_inEdges` are all equal.
_nodeIndices: AddressMap<{|+address: Address, +index: Integer|}>;
_nodes: MaybeNode<NP>[];
_edges: AddressMap<IndexedEdge<EP>>;
// The keyset of each of the following fields should equal the keyset // If `idx` is the index of a node `v`, then `_outEdges[idx]` is the
// of `_nodes`. If `e` is an edge from `u` to `v`, then `e.address` // list of `e.address` for all edges `e` whose source is `v`.
// should appear exactly once in `_outEdges[u.address]` and exactly // Likewise, `_inEdges[idx]` has the addresses of all in-edges to `v`.
// once in `_inEdges[v.address]` (and every entry in `_inEdges` and _outEdges: Address[][];
// `_outEdges` should be of this form). _inEdges: Address[][];
_outEdges: AddressMap<{|+address: Address, +edges: Address[]|}>;
_inEdges: AddressMap<{|+address: Address, +edges: Address[]|}>;
constructor() { constructor() {
this._nodes = new AddressMap(); this._nodeIndices = new AddressMap();
this._nodes = [];
this._edges = new AddressMap(); this._edges = new AddressMap();
this._outEdges = new AddressMap(); this._outEdges = [];
this._inEdges = new AddressMap(); this._inEdges = [];
} }
copy(): Graph<$Supertype<NP>, $Supertype<EP>> { copy(): Graph<$Supertype<NP>, $Supertype<EP>> {
@ -51,21 +68,102 @@ export class Graph<NP, EP> {
} }
equals(that: Graph<NP, EP>): boolean { equals(that: Graph<NP, EP>): boolean {
return this._nodes.equals(that._nodes) && this._edges.equals(that._edges); const theseNodes = this.nodes();
const thoseNodes = that.nodes();
if (theseNodes.length !== thoseNodes.length) {
return false;
} }
toJSON(): GraphJSON<NP, EP> { const theseEdges = this.edges();
const thoseEdges = that.edges();
if (theseEdges.length !== thoseEdges.length) {
return false;
}
for (const node of theseNodes) {
if (!deepEqual(node, that.node(node.address))) {
return false;
}
}
for (const edge of theseEdges) {
if (!deepEqual(edge, that.edge(edge.address))) {
return false;
}
}
return true;
}
toJSON(): Compatible<GraphJSON<NP, EP>> {
const partialNodes: {|
key: string,
oldIndex: Integer,
data: {|
+address: Address,
+payload?: NP,
|},
|}[] = this._nodes
.map((maybeNode, oldIndex) => {
const key = stringify(maybeNode.address);
const data = maybeNode.node || {address: maybeNode.address};
return {key, oldIndex, data};
})
.filter(({oldIndex: idx}) => {
// Say that a node is a "phantom node" if its address appears in
// the graph, but the node does not, and no edge in the graph is
// incident to the node. (For instance, if `v` is any node, then
// `new Graph().addNode(v).removeNode(v.address)` has `v` as a
// phantom node.) The existence of phantom nodes is part of the
// internal state but not the logical state, so we remove these
// nodes before serializing the graph to ensure logical
// canonicity.
return (
this._nodes[idx].node !== undefined ||
this._outEdges[idx].length > 0 ||
this._inEdges[idx].length > 0
);
});
partialNodes.sort((a, b) => {
const ka = a.key;
const kb = b.key;
return ka < kb ? -1 : ka > kb ? +1 : 0;
});
// Let `v` be a node that appears at index `i` in the internal
// representation of this graph. If `v` appears at index `j` of the
// output, then the following array `arr` has `arr[i] = j`.
// Otherwise, `v` is a phantom node. In this case, `arr[i]` is not
// defined and should not be accessed.
const oldIndexToNewIndex = new Uint32Array(this._nodes.length);
partialNodes.forEach(({oldIndex}, newIndex) => {
oldIndexToNewIndex[oldIndex] = newIndex;
});
const edges = new AddressMap();
this._edges.getAll().forEach((oldIndexedEdge) => {
// Here, we know that the old edge's `srcIndex` and `dstIndex`
// indices are in the domain of `oldIndexToNewIndex`, because the
// corresponding nodes are not phantom, because `oldIndexedEdge`
// is incident to them.
const newIndexedEdge = {
address: oldIndexedEdge.address,
payload: oldIndexedEdge.payload,
srcIndex: oldIndexToNewIndex[oldIndexedEdge.srcIndex],
dstIndex: oldIndexToNewIndex[oldIndexedEdge.dstIndex],
};
edges.add(newIndexedEdge);
});
return toCompat( return toCompat(
{type: COMPAT_TYPE, version: COMPAT_VERSION}, {type: COMPAT_TYPE, version: COMPAT_VERSION},
{ {
nodes: this._nodes.toJSON(), nodes: partialNodes.map((x) => x.data),
edges: this._edges.toJSON(), edges: edges.toJSON(),
} }
); );
} }
static fromJSON<NP, EP>(json: GraphJSON<NP, EP>): Graph<NP, EP> { static fromJSON<NP, EP>(json: Compatible<GraphJSON<NP, EP>>): Graph<NP, EP> {
const compatJson = fromCompat( const compatJson: GraphJSON<NP, EP> = fromCompat(
{ {
type: COMPAT_TYPE, type: COMPAT_TYPE,
version: COMPAT_VERSION, version: COMPAT_VERSION,
@ -73,34 +171,44 @@ export class Graph<NP, EP> {
json json
); );
const result = new Graph(); const result = new Graph();
AddressMap.fromJSON(compatJson.nodes) compatJson.nodes.forEach((partialNode) => {
.getAll() if ("payload" in partialNode) {
.forEach((node) => { const node: Node<NP> = (partialNode: any);
result.addNode(node); result.addNode(node);
} else {
result._addNodeAddress(partialNode.address);
}
}); });
AddressMap.fromJSON(compatJson.edges) AddressMap.fromJSON(compatJson.edges)
.getAll() .getAll()
.forEach((edge) => { .forEach((indexedEdge) => {
result.addEdge(edge); result._addIndexedEdge(indexedEdge);
}); });
return result; return result;
} }
_lookupEdges( _addNodeAddress(address: Address): Integer {
map: AddressMap<{|+address: Address, +edges: Address[]|}>, const indexDatum = this._nodeIndices.get(address);
key: Address if (indexDatum != null) {
): Address[] { return indexDatum.index;
const result = map.get(key); } else {
return result ? result.edges : []; const index = this._nodes.length;
this._nodeIndices.add({address, index});
this._nodes.push({address, node: undefined});
this._outEdges.push([]);
this._inEdges.push([]);
return index;
}
} }
addNode(node: Node<NP>): Graph<NP, EP> { addNode(node: Node<NP>): this {
if (node == null) { if (node == null) {
throw new Error(`node is ${String(node)}`); throw new Error(`node is ${String(node)}`);
} }
const existingNode = this.node(node.address); const index = this._addNodeAddress(node.address);
if (existingNode !== undefined) { const maybeNode = this._nodes[index];
if (deepEqual(existingNode, node)) { if (maybeNode.node !== undefined) {
if (deepEqual(maybeNode.node, node)) {
return this; return this;
} else { } else {
throw new Error( throw new Error(
@ -110,60 +218,61 @@ export class Graph<NP, EP> {
); );
} }
} }
this._nodes.add(node); this._nodes[index] = {address: maybeNode.address, node};
this._outEdges.add({
address: node.address,
edges: this._lookupEdges(this._outEdges, node.address),
});
this._inEdges.add({
address: node.address,
edges: this._lookupEdges(this._inEdges, node.address),
});
return this; return this;
} }
removeNode(address: Address): this { removeNode(address: Address): this {
this._nodes.remove(address); const indexDatum = this._nodeIndices.get(address);
if (indexDatum != null) {
this._nodes[indexDatum.index] = {address, node: undefined};
}
return this; return this;
} }
addEdge(edge: Edge<EP>): Graph<NP, EP> { addEdge(edge: Edge<EP>): this {
if (edge == null) { if (edge == null) {
throw new Error(`edge is ${String(edge)}`); throw new Error(`edge is ${String(edge)}`);
} }
const existingEdge = this.edge(edge.address); const srcIndex = this._addNodeAddress(edge.src);
if (existingEdge !== undefined) { const dstIndex = this._addNodeAddress(edge.dst);
if (deepEqual(existingEdge, edge)) { const indexedEdge = {
address: edge.address,
srcIndex,
dstIndex,
payload: edge.payload,
};
return this._addIndexedEdge(indexedEdge);
}
_addIndexedEdge(indexedEdge: IndexedEdge<EP>): this {
const existingIndexedEdge = this._edges.get(indexedEdge.address);
if (existingIndexedEdge !== undefined) {
if (deepEqual(existingIndexedEdge, indexedEdge)) {
return this; return this;
} else { } else {
throw new Error( throw new Error(
`edge at address ${JSON.stringify( `edge at address ${JSON.stringify(
edge.address indexedEdge.address
)} exists with distinct contents` )} exists with distinct contents`
); );
} }
} }
this._edges.add(edge); this._edges.add(indexedEdge);
this._outEdges[indexedEdge.srcIndex].push(indexedEdge.address);
const theseOutEdges = this._lookupEdges(this._outEdges, edge.src); this._inEdges[indexedEdge.dstIndex].push(indexedEdge.address);
theseOutEdges.push(edge.address);
this._outEdges.add({address: edge.src, edges: theseOutEdges});
const theseInEdges = this._lookupEdges(this._inEdges, edge.dst);
theseInEdges.push(edge.address);
this._inEdges.add({address: edge.dst, edges: theseInEdges});
return this; return this;
} }
removeEdge(address: Address): this { removeEdge(address: Address): this {
// TODO(perf): This is linear in the degree of the endpoints of the // TODO(perf): This is linear in the degree of the endpoints of the
// edge. Consider storing in non-list form. // edge. Consider storing in non-list form.
const edge = this.edge(address); const indexedEdge = this._edges.get(address);
if (edge) { if (indexedEdge) {
this._edges.remove(address);
[ [
this._lookupEdges(this._inEdges, edge.dst), this._outEdges[indexedEdge.srcIndex],
this._lookupEdges(this._outEdges, edge.src), this._inEdges[indexedEdge.dstIndex],
].forEach((edges) => { ].forEach((edges) => {
const index = edges.findIndex((ea) => deepEqual(ea, address)); const index = edges.findIndex((ea) => deepEqual(ea, address));
if (index !== -1) { if (index !== -1) {
@ -171,16 +280,32 @@ export class Graph<NP, EP> {
} }
}); });
} }
this._edges.remove(address);
return this; return this;
} }
node(address: Address): Node<NP> { node(address: Address): Node<NP> {
return this._nodes.get(address); const indexDatum = this._nodeIndices.get(address);
if (indexDatum == null) {
// We've never heard of this node.
return (undefined: any);
} else {
const node: Node<NP> | void = this._nodes[indexDatum.index].node;
return ((node: any): Node<NP>);
}
} }
edge(address: Address): Edge<EP> { edge(address: Address): Edge<EP> {
return this._edges.get(address); const indexedEdge = this._edges.get(address);
if (!indexedEdge) {
// Lie.
return (undefined: any);
}
return {
address: indexedEdge.address,
src: this._nodes[indexedEdge.srcIndex].address,
dst: this._nodes[indexedEdge.dstIndex].address,
payload: indexedEdge.payload,
};
} }
/** /**
@ -202,33 +327,33 @@ export class Graph<NP, EP> {
+edgeType?: string, +edgeType?: string,
+direction?: "IN" | "OUT" | "ANY", +direction?: "IN" | "OUT" | "ANY",
|} |}
): {+edge: Edge<EP>, +neighbor: Address}[] { ): {|+edge: Edge<EP>, +neighbor: Address|}[] {
if (nodeAddress == null) { if (nodeAddress == null) {
throw new Error(`address is ${String(nodeAddress)}`); throw new Error(`address is ${String(nodeAddress)}`);
} }
let result: {+edge: Edge<EP>, +neighbor: Address}[] = []; const indexDatum = this._nodeIndices.get(nodeAddress);
if (indexDatum == null) {
return [];
}
const nodeIndex = indexDatum.index;
let result: {|+edge: Edge<EP>, +neighbor: Address|}[] = [];
const direction = (options != null && options.direction) || "ANY"; const direction = (options != null && options.direction) || "ANY";
if (direction === "ANY" || direction === "IN") { if (direction === "ANY" || direction === "IN") {
let inNeighbors = this._lookupEdges(this._inEdges, nodeAddress).map( let inNeighbors = this._inEdges[nodeIndex].map((edgeAddress) => {
(e) => { const edge = this.edge(edgeAddress);
const edge = this.edge(e);
return {edge, neighbor: edge.src}; return {edge, neighbor: edge.src};
} });
);
result = result.concat(inNeighbors); result = result.concat(inNeighbors);
} }
if (direction === "ANY" || direction === "OUT") { if (direction === "ANY" || direction === "OUT") {
let outNeighbors = this._lookupEdges(this._outEdges, nodeAddress).map( let outNeighbors = this._outEdges[nodeIndex].map((edgeAddress) => {
(e) => { const edge = this.edge(edgeAddress);
const edge = this.edge(e);
return {edge, neighbor: edge.dst}; return {edge, neighbor: edge.dst};
} });
);
if (direction === "ANY") { if (direction === "ANY") {
// If direction is ANY, we already counted self-referencing edges as // If direction is ANY, we already counted self-referencing edges as
// an inNeighbor // an inNeighbor
@ -236,9 +361,9 @@ export class Graph<NP, EP> {
({edge}) => !deepEqual(edge.src, edge.dst) ({edge}) => !deepEqual(edge.src, edge.dst)
); );
} }
result = result.concat(outNeighbors); result = result.concat(outNeighbors);
} }
if (options != null && options.edgeType != null) { if (options != null && options.edgeType != null) {
const edgeType = options.edgeType; const edgeType = options.edgeType;
result = result.filter(({edge}) => edge.address.type === edgeType); result = result.filter(({edge}) => edge.address.type === edgeType);
@ -256,7 +381,9 @@ export class Graph<NP, EP> {
* If filter is provided, it will return only nodes with the requested type. * If filter is provided, it will return only nodes with the requested type.
*/ */
nodes(filter?: {type?: string}): Node<NP>[] { nodes(filter?: {type?: string}): Node<NP>[] {
let nodes = this._nodes.getAll(); /*:: declare function nonNulls<T>(x: (T | void)[]): T[]; */
let nodes = this._nodes.map((x) => x.node).filter((x) => Boolean(x));
/*:: nodes = nonNulls(nodes); */
if (filter != null && filter.type != null) { if (filter != null && filter.type != null) {
const typeFilter = filter.type; const typeFilter = filter.type;
nodes = nodes.filter((n) => n.address.type === typeFilter); nodes = nodes.filter((n) => n.address.type === typeFilter);
@ -270,7 +397,12 @@ export class Graph<NP, EP> {
* If filter is provided, it will return only edges with the requested type. * If filter is provided, it will return only edges with the requested type.
*/ */
edges(filter?: {type?: string}): Edge<EP>[] { edges(filter?: {type?: string}): Edge<EP>[] {
let edges = this._edges.getAll(); let edges = this._edges.getAll().map((indexedEdge) => ({
address: indexedEdge.address,
src: this._nodes[indexedEdge.srcIndex].address,
dst: this._nodes[indexedEdge.dstIndex].address,
payload: indexedEdge.payload,
}));
if (filter != null && filter.type != null) { if (filter != null && filter.type != null) {
const typeFilter = filter.type; const typeFilter = filter.type;
edges = edges.filter((e) => e.address.type === typeFilter); edges = edges.filter((e) => e.address.type === typeFilter);

View File

@ -155,6 +155,7 @@ describe("graph", () => {
demoData.bananasNode(), demoData.bananasNode(),
demoData.crabNode(), demoData.crabNode(),
demoData.mealNode(), demoData.mealNode(),
demoData.nullPayloadNode(),
]; ];
const actual = demoData.advancedMealGraph().nodes(); const actual = demoData.advancedMealGraph().nodes();
expectSameSorted(expected, actual); expectSameSorted(expected, actual);
@ -921,12 +922,13 @@ describe("graph", () => {
/** /**
* Decompose the given graph into edge graphs: for each edge `e`, * Decompose the given graph into edge graphs: for each edge `e`,
* create a graph with just that edge and its two endpoints. * create a graph with just that edge and its two endpoints, and
* for each isolated node createa graph with just that node.
*/ */
function edgeDecomposition<NP, EP>( function edgeDecomposition<NP, EP>(
originalGraph: Graph<NP, EP> originalGraph: Graph<NP, EP>
): Graph<NP, EP>[] { ): Graph<NP, EP>[] {
return originalGraph.edges().map((edge) => { const edgeGraphs = originalGraph.edges().map((edge) => {
const miniGraph = new Graph(); const miniGraph = new Graph();
miniGraph.addNode(originalGraph.node(edge.src)); miniGraph.addNode(originalGraph.node(edge.src));
if (miniGraph.node(edge.dst) === undefined) { if (miniGraph.node(edge.dst) === undefined) {
@ -936,6 +938,13 @@ describe("graph", () => {
miniGraph.addEdge(edge); miniGraph.addEdge(edge);
return miniGraph; return miniGraph;
}); });
const nodeGraphs = originalGraph
.nodes()
.filter(
(node) => originalGraph.neighborhood(node.address).length === 0
)
.map((node) => new Graph().addNode(node));
return [].concat(edgeGraphs, nodeGraphs);
} }
it("conservatively recomposes a neighborhood decomposition", () => { it("conservatively recomposes a neighborhood decomposition", () => {
@ -1088,6 +1097,15 @@ describe("graph", () => {
JSON.stringify(demoData.advancedMealGraph().toJSON()) JSON.stringify(demoData.advancedMealGraph().toJSON())
); );
}); });
it("should canonicalize away phantom nodes", () => {
const g1 = new Graph().addNode(demoData.heroNode());
const g2 = new Graph()
.addNode(demoData.heroNode())
.addNode(demoData.mealNode())
.removeNode(demoData.mealNode().address);
expect(g1.equals(g2)).toBe(true);
expect(g2.toJSON()).toEqual(g1.toJSON());
});
it("should canonicalize away node insertion order", () => { it("should canonicalize away node insertion order", () => {
const g1 = new Graph() const g1 = new Graph()
.addNode(demoData.heroNode()) .addNode(demoData.heroNode())

View File

@ -109,7 +109,23 @@ export const duplicateCookEdge = () => ({
}, },
}); });
// This node is added to and then removed from the advanced meal graph.
export const phantomNode = () => ({
address: makeAddress("restless_cricket#9", "EXPERIMENT"),
payload: {},
});
// This node's payload is literally `null`; it should not be confused
// with a nonexistent node.
export const nullPayloadNode = () => ({
address: makeAddress("~000#missingno", "EXPERIMENT"),
payload: null,
});
export const advancedMealGraph = () => export const advancedMealGraph = () =>
simpleMealGraph() simpleMealGraph()
.addEdge(crabLoopEdge()) .addEdge(crabLoopEdge())
.addEdge(duplicateCookEdge()); .addEdge(duplicateCookEdge())
.addNode(phantomNode())
.removeNode(phantomNode().address)
.addNode(nullPayloadNode());

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff