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:
parent
5a40bb0a30
commit
2b301f9159
|
@ -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,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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())
|
||||||
|
|
|
@ -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
Loading…
Reference in New Issue