graph: add support for dangling edges

This commit modifies the Graph class so that it permits dangling edges;
that is to say, edges whose src or dst are not present in the graph.
Dangling edges may be directly added to the graph, or existing edges may
become dangling if their src or dst is removed.

This change is prerequisite to #1136; if we require that nodes have
metadata, we should also make it possible to add edges to nodes that
don't yet exist, as the plugin creating an edge may not have access to
the full metadata needed to add the node.

To support this change, there is now an `isDanglingEdge` method on the
graph, which reports whether or not the edge is dangling. Also,
`Graph.edges` requires that the client make an explicit choice on
whether dangling edges are desired. This ensures that we do not
accidentally include dangling edges in a case where they are
inappropriate (e.g. creating a Markov chain) or accidentally discard
dangling edges when they are needed (e.g. when merging or serializing).

The Graph's invariant checker has been updated to reflect the new
semantics.

The Graph compat version has been bumped, since this is a break in
backwards compatibility.

Note that this commit does not change the behavior of any plugins; that
is to say, no plugins create dangling edges (yet).

Test plan: The advanced graph test case has been updated to include
dangling edges. The tests for Graph, PagerankGraph, and
GraphToMarkovChain have been updated. `yarn test --full` passes.
This commit is contained in:
Dandelion Mané 2019-07-03 13:08:22 +01:00
parent f934735afc
commit 02a8e02922
14 changed files with 870 additions and 299 deletions

View File

@ -1 +1 @@
[{"type":"sourcecred/graph","version":"0.4.0"},{"edges":[{"address":["sourcecred","git","HAS_PARENT","2","COMMIT","0a223346b4e6dec0127b1e6aa892c4ee0424b66a","2","COMMIT","ec91adb718a6045b492303f00d8e8beb957dc780"],"dstIndex":4,"srcIndex":0},{"address":["sourcecred","git","HAS_PARENT","2","COMMIT","0a223346b4e6dec0127b1e6aa892c4ee0424b66a","2","COMMIT","ecc889dc94cf6da17ae6eab5bb7b7155f577519d"],"dstIndex":5,"srcIndex":0},{"address":["sourcecred","git","HAS_PARENT","2","COMMIT","6bd1b4c0b719c22c688a74863be07a699b7b9b34","2","COMMIT","c430bd74455105f77215ece51945094ceeee6c86"],"dstIndex":3,"srcIndex":1},{"address":["sourcecred","git","HAS_PARENT","2","COMMIT","6d5b3aa31ebb68a06ceb46bbd6cf49b6ccd6f5e6","2","COMMIT","0a223346b4e6dec0127b1e6aa892c4ee0424b66a"],"dstIndex":0,"srcIndex":2},{"address":["sourcecred","git","HAS_PARENT","2","COMMIT","c430bd74455105f77215ece51945094ceeee6c86","2","COMMIT","6d5b3aa31ebb68a06ceb46bbd6cf49b6ccd6f5e6"],"dstIndex":2,"srcIndex":3},{"address":["sourcecred","git","HAS_PARENT","2","COMMIT","ecc889dc94cf6da17ae6eab5bb7b7155f577519d","2","COMMIT","ec91adb718a6045b492303f00d8e8beb957dc780"],"dstIndex":4,"srcIndex":5}],"nodes":[["sourcecred","git","COMMIT","0a223346b4e6dec0127b1e6aa892c4ee0424b66a"],["sourcecred","git","COMMIT","6bd1b4c0b719c22c688a74863be07a699b7b9b34"],["sourcecred","git","COMMIT","6d5b3aa31ebb68a06ceb46bbd6cf49b6ccd6f5e6"],["sourcecred","git","COMMIT","c430bd74455105f77215ece51945094ceeee6c86"],["sourcecred","git","COMMIT","ec91adb718a6045b492303f00d8e8beb957dc780"],["sourcecred","git","COMMIT","ecc889dc94cf6da17ae6eab5bb7b7155f577519d"]]}]
[{"type":"sourcecred/graph","version":"0.5.0"},{"edges":[{"address":["sourcecred","git","HAS_PARENT","2","COMMIT","0a223346b4e6dec0127b1e6aa892c4ee0424b66a","2","COMMIT","ec91adb718a6045b492303f00d8e8beb957dc780"],"dstIndex":4,"srcIndex":0},{"address":["sourcecred","git","HAS_PARENT","2","COMMIT","0a223346b4e6dec0127b1e6aa892c4ee0424b66a","2","COMMIT","ecc889dc94cf6da17ae6eab5bb7b7155f577519d"],"dstIndex":5,"srcIndex":0},{"address":["sourcecred","git","HAS_PARENT","2","COMMIT","6bd1b4c0b719c22c688a74863be07a699b7b9b34","2","COMMIT","c430bd74455105f77215ece51945094ceeee6c86"],"dstIndex":3,"srcIndex":1},{"address":["sourcecred","git","HAS_PARENT","2","COMMIT","6d5b3aa31ebb68a06ceb46bbd6cf49b6ccd6f5e6","2","COMMIT","0a223346b4e6dec0127b1e6aa892c4ee0424b66a"],"dstIndex":0,"srcIndex":2},{"address":["sourcecred","git","HAS_PARENT","2","COMMIT","c430bd74455105f77215ece51945094ceeee6c86","2","COMMIT","6d5b3aa31ebb68a06ceb46bbd6cf49b6ccd6f5e6"],"dstIndex":2,"srcIndex":3},{"address":["sourcecred","git","HAS_PARENT","2","COMMIT","ecc889dc94cf6da17ae6eab5bb7b7155f577519d","2","COMMIT","ec91adb718a6045b492303f00d8e8beb957dc780"],"dstIndex":4,"srcIndex":5}],"nodes":[{"index":0},{"index":1},{"index":2},{"index":3},{"index":4},{"index":5}],"sortedNodeAddresses":[["sourcecred","git","COMMIT","0a223346b4e6dec0127b1e6aa892c4ee0424b66a"],["sourcecred","git","COMMIT","6bd1b4c0b719c22c688a74863be07a699b7b9b34"],["sourcecred","git","COMMIT","6d5b3aa31ebb68a06ceb46bbd6cf49b6ccd6f5e6"],["sourcecred","git","COMMIT","c430bd74455105f77215ece51945094ceeee6c86"],["sourcecred","git","COMMIT","ec91adb718a6045b492303f00d8e8beb957dc780"],["sourcecred","git","COMMIT","ecc889dc94cf6da17ae6eab5bb7b7155f577519d"]]}]

File diff suppressed because one or more lines are too long

View File

@ -1,10 +1,39 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`core/graph toJSON / fromJSON snapshot testing a graph with a dangling edge 1`] = `
Array [
Object {
"type": "sourcecred/graph",
"version": "0.5.0",
},
Object {
"edges": Array [
Object {
"address": Array [
"edge",
],
"dstIndex": 0,
"srcIndex": 1,
},
],
"nodes": Array [],
"sortedNodeAddresses": Array [
Array [
"dst",
],
Array [
"src",
],
],
},
]
`;
exports[`core/graph toJSON / fromJSON snapshot testing a trivial graph 1`] = `
Array [
Object {
"type": "sourcecred/graph",
"version": "0.4.0",
"version": "0.5.0",
},
Object {
"edges": Array [
@ -24,6 +53,14 @@ Array [
},
],
"nodes": Array [
Object {
"index": 0,
},
Object {
"index": 1,
},
],
"sortedNodeAddresses": Array [
Array [
"dst",
],
@ -39,17 +76,31 @@ exports[`core/graph toJSON / fromJSON snapshot testing an advanced graph 1`] = `
Array [
Object {
"type": "sourcecred/graph",
"version": "0.4.0",
"version": "0.5.0",
},
Object {
"edges": Array [
Object {
"address": Array [
"full-dangling",
],
"dstIndex": 4,
"srcIndex": 4,
},
Object {
"address": Array [
"half-dangling",
],
"dstIndex": 4,
"srcIndex": 1,
},
Object {
"address": Array [
"hom",
"1",
],
"dstIndex": 0,
"srcIndex": 3,
"srcIndex": 5,
},
Object {
"address": Array [
@ -57,26 +108,49 @@ Array [
"2",
],
"dstIndex": 0,
"srcIndex": 3,
"srcIndex": 5,
},
Object {
"address": Array [
"loop",
],
"dstIndex": 2,
"srcIndex": 2,
"dstIndex": 3,
"srcIndex": 3,
},
],
"nodes": Array [
Object {
"index": 0,
},
Object {
"index": 1,
},
Object {
"index": 2,
},
Object {
"index": 3,
},
Object {
"index": 5,
},
],
"sortedNodeAddresses": Array [
Array [
"dst",
],
Array [
"halfIsolated",
],
Array [
"isolated",
],
Array [
"loop",
],
Array [
"phantom",
],
Array [
"src",
],

View File

@ -15,17 +15,31 @@ Array [
"graphJSON": Array [
Object {
"type": "sourcecred/graph",
"version": "0.4.0",
"version": "0.5.0",
},
Object {
"edges": Array [
Object {
"address": Array [
"full-dangling",
],
"dstIndex": 4,
"srcIndex": 4,
},
Object {
"address": Array [
"half-dangling",
],
"dstIndex": 4,
"srcIndex": 1,
},
Object {
"address": Array [
"hom",
"1",
],
"dstIndex": 0,
"srcIndex": 3,
"srcIndex": 5,
},
Object {
"address": Array [
@ -33,26 +47,49 @@ Array [
"2",
],
"dstIndex": 0,
"srcIndex": 3,
"srcIndex": 5,
},
Object {
"address": Array [
"loop",
],
"dstIndex": 2,
"srcIndex": 2,
"dstIndex": 3,
"srcIndex": 3,
},
],
"nodes": Array [
Object {
"index": 0,
},
Object {
"index": 1,
},
Object {
"index": 2,
},
Object {
"index": 3,
},
Object {
"index": 5,
},
],
"sortedNodeAddresses": Array [
Array [
"dst",
],
Array [
"halfIsolated",
],
Array [
"isolated",
],
Array [
"loop",
],
Array [
"phantom",
],
Array [
"src",
],
@ -60,10 +97,11 @@ Array [
},
],
"scores": Array [
0.25,
0.25,
0.25,
0.25,
0.2,
0.2,
0.2,
0.2,
0.2,
],
"syntheticLoopWeight": 0.001,
"toWeights": Array [

View File

@ -82,7 +82,7 @@ export function createConnections(
}
// Process edges.
for (const edge of graph.edges()) {
for (const edge of graph.edges({showDangling: false})) {
const {toWeight, froWeight} = edgeWeight(edge);
const {src, dst} = edge;
processConnection(dst, {

View File

@ -2,7 +2,7 @@
import sortBy from "lodash.sortby";
import {Graph} from "../graph";
import {Graph, EdgeAddress} from "../graph";
import {
createConnections,
createOrderedSparseMarkovChain,
@ -142,6 +142,28 @@ describe("core/attribution/graphToMarkovChain", () => {
expect(normalize(osmc)).toEqual(normalize(expected));
});
it("ignores dangling edges", () => {
const e1 = {
src: n1.address,
dst: n2.address,
address: EdgeAddress.fromParts(["e1"]),
};
const g = new Graph().addNode(n1).addEdge(e1);
const edgeWeight = (_unused_edge) => {
throw new Error("Don't even look at me");
};
const osmc = createOrderedSparseMarkovChain(
createConnections(g, edgeWeight, 1e-3)
);
const expected = {
nodeOrder: [n1.address],
chain: [
{neighbor: new Uint32Array([0]), weight: new Float64Array([1.0])},
],
};
expect(normalize(osmc)).toEqual(normalize(expected));
});
it("works on a simple asymmetric chain", () => {
const e1 = edge("e1", n1, n2);
const e2 = edge("e2", n2, n3);
@ -248,6 +270,7 @@ describe("core/attribution/graphToMarkovChain", () => {
ag.nodes.dst.address,
ag.nodes.loop.address,
ag.nodes.isolated.address,
ag.nodes.halfIsolated.address,
],
chain: [
{
@ -260,6 +283,7 @@ describe("core/attribution/graphToMarkovChain", () => {
},
{neighbor: new Uint32Array([2]), weight: new Float64Array([1])},
{neighbor: new Uint32Array([3]), weight: new Float64Array([1])},
{neighbor: new Uint32Array([4]), weight: new Float64Array([1])},
],
};
expect(normalize(osmc)).toEqual(normalize(expected));

View File

@ -63,6 +63,16 @@ import * as NullUtil from "../util/null";
* edge respectively, and both fields contain `NodeAddressT`s. The edge also
* has its own address, which is an `EdgeAddressT`.
*
* Graphs are allowed to contain Edges whose `src` or `dst` are not present.
* Such edges are called 'Dangling Edges'. An edge may convert from dangling to
* non-dangling (if it is added before its src or dst), and it may convert from
* non-dangling to dangling (if its src or dst are removed).
*
* Supporting dangling edges is important, because it means that we can require
* metadata be present for a Node (e.g. its creation timestamp), and still
* allow graph creators that do not know a node's metadata to create references
* to it. (Of course, they still need to know the node's address).
*
* Here's a toy example of creating a graph:
*
* ```js
@ -84,6 +94,7 @@ import * as NullUtil from "../util/null";
* - `node` to retrieve a node by its address
* - `nodes` to iterate over the nodes in the graph
* - `hasEdge` to check if an edge address is in the Graph
* - `isDanglingEdge` to check if an edge is dangling
* - `edge` to retrieve an edge by its address
* - `edges` to iterate over the edges in the graph
* - `neighbors` to find all the edges and nodes adjacent to a node
@ -121,7 +132,7 @@ export type Edge = {|
+dst: NodeAddressT,
|};
const COMPAT_INFO = {type: "sourcecred/graph", version: "0.4.0"};
const COMPAT_INFO = {type: "sourcecred/graph", version: "0.5.0"};
export type Neighbor = {|+node: Node, +edge: Edge|};
@ -143,13 +154,23 @@ export type NeighborsOptions = {|
|};
export type EdgesOptions = {|
+addressPrefix: EdgeAddressT,
+srcPrefix: NodeAddressT,
+dstPrefix: NodeAddressT,
// An edge address prefix. Only show edges whose addresses match this prefix.
+addressPrefix?: EdgeAddressT,
// A node address prefix. Only show edges whose src matches
// this prefix.
+srcPrefix?: NodeAddressT,
// A node address prefix. Only show edges whose dst matches
// this prefix.
+dstPrefix?: NodeAddressT,
// Determines whether dangling edges should be included in the results.
+showDangling: boolean,
|};
type AddressJSON = string[]; // Result of calling {Node,Edge}Address.toParts
type Integer = number;
type IndexedNodeJSON = {|
+index: Integer,
|};
type IndexedEdgeJSON = {|
+address: AddressJSON,
+srcIndex: Integer,
@ -157,7 +178,10 @@ type IndexedEdgeJSON = {|
|};
export opaque type GraphJSON = Compatible<{|
+nodes: AddressJSON[],
// A node address can be present because it corresponds to a node, or because
// it is referenced by a dangling edge.
+sortedNodeAddresses: AddressJSON[],
+nodes: IndexedNodeJSON[],
+edges: IndexedEdgeJSON[],
|}>;
@ -217,6 +241,41 @@ export class Graph {
this._maybeCheckInvariants();
}
/**
* A node address is 'referenced' if it is either present in the graph, or is
* the src or dst of some edge.
*
* Referenced nodes always have an entry in this._incidentEdges (regardless
* of whether they are incident to any edges).
*
* This method ensures that a given node address has a reference.
*/
_reference(n: NodeAddressT) {
if (!this._incidentEdges.has(n)) {
this._incidentEdges.set(n, {inEdges: [], outEdges: []});
}
}
/**
* A node stops being referenced as soon as it is both not in the graph, and is
* not incident to any edge. This method must be called after any operation which
* might cause a node address to no longer be referenced, so that the node can
* be unreferenced if appropriate.
*/
_unreference(n: NodeAddressT) {
const incidence = this._incidentEdges.get(n);
if (incidence != null) {
const {inEdges, outEdges} = incidence;
if (
!this._nodes.has(n) &&
inEdges.length === 0 &&
outEdges.length === 0
) {
this._incidentEdges.delete(n);
}
}
}
/**
* Returns how many times the graph has been modified.
*
@ -248,10 +307,10 @@ export class Graph {
addNode(node: Node): this {
const {address} = node;
NodeAddress.assertValid(address);
this._reference(address);
const existingNode = this._nodes.get(address);
if (existingNode == null) {
this._nodes.set(address, node);
this._incidentEdges.set(address, {inEdges: [], outEdges: []});
} else {
if (!deepEqual(node, existingNode)) {
const strNode = nodeToString(node);
@ -272,37 +331,26 @@ export class Graph {
* If the node does not exist in the graph, no action is taken and no error
* is thrown. (This operation is idempotent.)
*
* If the node is incident to any edges, those edges must be removed
* before removing the node. Attempting to remove a node that is incident
* to some edges will throw an error.
* Removing a node which is incident to some edges is allowed; such edges will
* become dangling edges. See the discussion of 'Dangling Edges' in the module docstring
* for details.
*
* Returns `this` for chaining.
*/
removeNode(a: NodeAddressT): this {
NodeAddress.assertValid(a);
const {inEdges, outEdges} = this._incidentEdges.get(a) || {
inEdges: [],
outEdges: [],
};
const existingEdges = inEdges.concat(outEdges);
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._incidentEdges.delete(a);
this._nodes.delete(a);
this._unreference(a);
this._markModification();
this._maybeCheckInvariants();
return this;
}
/**
* Test whether a given node is present in the graph.
* Test whether there exists a Node corresponding to the given NodeAddress.
*
* This will return false for node addresses which are referenced by some
* edge, but not actually present in the graph.
*/
hasNode(a: NodeAddressT): boolean {
NodeAddress.assertValid(a);
@ -363,8 +411,8 @@ export class Graph {
/**
* Add an edge to the graph.
*
* It is an error to add an edge whose source and destination nodes
* are not already present in the graph.
* It is permitted to add an edge if its src or dst are not in the graph. See
* the discussion of 'Dangling Edges' in the module docstring for semantics.
*
* It is an error to add an edge if a distinct edge with the same address
* already exists in the graph (i.e., if the source or destination are
@ -380,12 +428,8 @@ export class Graph {
NodeAddress.assertValid(edge.dst, "edge.dst");
EdgeAddress.assertValid(edge.address, "edge.address");
const srcMissing = !this._nodes.has(edge.src);
const dstMissing = !this._nodes.has(edge.dst);
if (srcMissing || dstMissing) {
const missingThing = srcMissing ? "src" : "dst";
throw new Error(`Missing ${missingThing} on edge: ${edgeToString(edge)}`);
}
this._reference(edge.src);
this._reference(edge.dst);
const existingEdge = this._edges.get(edge.address);
if (existingEdge != null) {
if (
@ -440,6 +484,8 @@ export class Graph {
}
edges.splice(index, 1);
});
this._unreference(edge.src);
this._unreference(edge.dst);
}
this._markModification();
this._maybeCheckInvariants();
@ -456,6 +502,27 @@ export class Graph {
return result;
}
/**
* Test whether there is a dangling edge at the given address.
*
* Returns true if the edge is present, and is dangling.
* Returns false if the edge is present, and is not dangling.
* Returns undefined if the edge is not present.
*
* See the module docstring for more details on dangling edges.
*/
isDanglingEdge(address: EdgeAddressT): boolean | typeof undefined {
EdgeAddress.assertValid(address);
const edge = this.edge(address);
let result: boolean | typeof undefined;
if (edge != null) {
const {src, dst} = edge;
result = !this.hasNode(src) || !this.hasNode(dst);
}
this._maybeCheckInvariants();
return result;
}
/**
* Returns the Edge matching a given EdgeAddressT, if such an edge exists, or
* null otherwise.
@ -471,58 +538,65 @@ export class Graph {
* Returns an iterator over edges in the graph, optionally filtered by edge
* address prefix, source address prefix, and/or destination address prefix.
*
* The caller may pass optional arguments to filter by the
* address prefixes for the edge address, the edge src, or the edge dst.
* The caller must pass an options object with a boolean field `showDangling`,
* which determines whether dangling edges will be included in the results.
* The caller may also pass fields `addressPrefix`, `srcPrefix`, and `dstPrefix`
* to perform prefix-based address filtering of edges that are returned.
* (See the module docstring for more context on dangling edges.)
*
* Suppose that you want to find every edge that represents authorship by a
* user. If all authorship edges have the `AUTHORS_EDGE_PREFIX` prefix, and
* all user nodes have the `USER_NODE_PREFIX` prefix, then you could call:
*
* graph.edges({
* showDangling: true, // or false, irrelevant for this example
* addressPrefix: AUTHORS_EDGE_PREFIX,
* srcPrefix: USER_NODE_PREFIX,
* dstPrefix: NodeAddress.empty,
* });
*
* Note that `NodeAddress.empty` is a prefix of every node address.
* In this example, as `dstPrefix` was left unset, it will default to
* `NodeAddress.empty`, which is a prefix of every node address.
*
* Clients must not modify the graph during iteration. If they do so, an
* error may be thrown at the iteration call site. The iteration order is
* undefined.
*/
edges(options?: EdgesOptions): Iterator<Edge> {
edges(options: EdgesOptions): Iterator<Edge> {
if (options == null) {
options = {
addressPrefix: EdgeAddress.empty,
srcPrefix: NodeAddress.empty,
dstPrefix: NodeAddress.empty,
};
throw new Error("Options are required for Graph.edges");
}
if (options.addressPrefix == null) {
throw new Error(
`Invalid address prefix: ${String(options.addressPrefix)}`
);
}
if (options.srcPrefix == null) {
throw new Error(`Invalid src prefix: ${String(options.srcPrefix)}`);
}
if (options.dstPrefix == null) {
throw new Error(`Invalid dst prefix: ${String(options.dstPrefix)}`);
}
const result = this._edgesIterator(this._modificationCount, options);
const {showDangling} = options;
const addressPrefix = NullUtil.orElse(
options.addressPrefix,
EdgeAddress.empty
);
const srcPrefix = NullUtil.orElse(options.srcPrefix, NodeAddress.empty);
const dstPrefix = NullUtil.orElse(options.dstPrefix, NodeAddress.empty);
const result = this._edgesIterator(
this._modificationCount,
showDangling,
addressPrefix,
srcPrefix,
dstPrefix
);
this._maybeCheckInvariants();
return result;
}
*_edgesIterator(
initialModificationCount: ModificationCount,
options: EdgesOptions
showDangling: boolean,
addressPrefix: EdgeAddressT,
srcPrefix: NodeAddressT,
dstPrefix: NodeAddressT
): Iterator<Edge> {
for (const edge of this._edges.values()) {
if (
EdgeAddress.hasPrefix(edge.address, options.addressPrefix) &&
NodeAddress.hasPrefix(edge.src, options.srcPrefix) &&
NodeAddress.hasPrefix(edge.dst, options.dstPrefix)
(showDangling || this.isDanglingEdge(edge.address) === false) &&
EdgeAddress.hasPrefix(edge.address, addressPrefix) &&
NodeAddress.hasPrefix(edge.src, srcPrefix) &&
NodeAddress.hasPrefix(edge.dst, dstPrefix)
) {
this._checkForComodification(initialModificationCount);
this._maybeCheckInvariants();
@ -543,9 +617,9 @@ export class Graph {
* convenience, a `Neighbor` is thus an object that includes both the edge
* and the adjacent node.
*
* Every edge incident to the root corresponds to exactly one neighbor, but
* note that multiple neighbors may have the same `node` in the case that
* there are multiple edges with the same source and destination.
* Every non-dangling edge incident to the root corresponds to exactly one
* neighbor, but note that multiple neighbors may have the same `node` in the
* case that there are multiple edges with the same source and destination.
*
* Callers to `neighbors` must provide `NeighborsOptions` as follows:
*
@ -568,6 +642,9 @@ export class Graph {
* destination (a loop edge), there will be one `Neighbor` with the root node
* and the loop edge.
*
* No `Neighbors` will be created for dangling edges, as such edges do not
* correspond to any Node in the graph.
*
* Clients must not modify the graph during iteration. If they do so, an
* error may be thrown at the iteration call site. The iteration order is
* undefined.
@ -663,22 +740,33 @@ export class Graph {
* the number of edges.
*/
toJSON(): GraphJSON {
const sortedNodeAddresses = Array.from(this.nodes())
.map((x) => x.address)
.sort();
const nodeToSortedIndex = new Map();
const sortedNodeAddresses = Array.from(this._incidentEdges.keys()).sort();
const nodeAddressToSortedIndex = new Map();
sortedNodeAddresses.forEach((address, i) => {
nodeToSortedIndex.set(address, i);
nodeAddressToSortedIndex.set(address, i);
});
const sortedEdges = sortBy(Array.from(this.edges()), (x) => x.address);
const indexedEdges = sortedEdges.map(({src, dst, address}) => {
const srcIndex = NullUtil.get(nodeToSortedIndex.get(src));
const dstIndex = NullUtil.get(nodeToSortedIndex.get(dst));
return {srcIndex, dstIndex, address: EdgeAddress.toParts(address)};
const sortedEdges = sortBy(
Array.from(this.edges({showDangling: true})),
(x) => x.address
);
const indexedEdges: IndexedEdgeJSON[] = sortedEdges.map(
({src, dst, address}) => {
const srcIndex = NullUtil.get(nodeAddressToSortedIndex.get(src));
const dstIndex = NullUtil.get(nodeAddressToSortedIndex.get(dst));
return {srcIndex, dstIndex, address: EdgeAddress.toParts(address)};
}
);
const sortedNodes = sortBy(Array.from(this.nodes()), (x) => x.address);
const indexedNodes: IndexedNodeJSON[] = sortedNodes.map(({address}) => {
const index = NullUtil.get(nodeAddressToSortedIndex.get(address));
return {index};
});
const rawJSON = {
nodes: sortedNodeAddresses.map((x) => NodeAddress.toParts(x)),
sortedNodeAddresses: sortedNodeAddresses.map((x) =>
NodeAddress.toParts(x)
),
edges: indexedEdges,
nodes: indexedNodes,
};
const result = toCompat(COMPAT_INFO, rawJSON);
this._maybeCheckInvariants();
@ -689,21 +777,26 @@ export class Graph {
* Deserializes a GraphJSON into a new Graph.
*/
static fromJSON(compatJson: GraphJSON): Graph {
const json = fromCompat(COMPAT_INFO, compatJson);
const nodesJSON: AddressJSON[] = json.nodes;
const edgesJSON: IndexedEdgeJSON[] = json.edges;
const {
nodes: nodesJSON,
edges: edgesJSON,
sortedNodeAddresses: sortedNodeAddressesJSON,
} = fromCompat(COMPAT_INFO, compatJson);
const sortedNodeAddresses = sortedNodeAddressesJSON.map(
NodeAddress.fromParts
);
const result = new Graph();
const nodes: Node[] = nodesJSON.map((x) => ({
address: NodeAddress.fromParts(x),
}));
nodes.forEach((n) => result.addNode(n));
nodesJSON.forEach((j: IndexedNodeJSON) => {
const n: Node = {address: sortedNodeAddresses[j.index]};
result.addNode(n);
});
edgesJSON.forEach(({address, srcIndex, dstIndex}) => {
const src = nodes[srcIndex];
const dst = nodes[dstIndex];
const src = sortedNodeAddresses[srcIndex];
const dst = sortedNodeAddresses[dstIndex];
result.addEdge({
address: EdgeAddress.fromParts(address),
src: src.address,
dst: dst.address,
src: src,
dst: dst,
});
});
return result;
@ -737,7 +830,7 @@ export class Graph {
for (const node of graph.nodes()) {
result.addNode(node);
}
for (const edge of graph.edges()) {
for (const edge of graph.edges({showDangling: true})) {
result.addEdge(edge);
}
}
@ -774,58 +867,74 @@ export class Graph {
// values modulo deep equality (or, from context, an element of such a
// class).
// Invariant 1. For a node address `a`, if `_nodes.has(a)` and
// `_nodes.get(a) === n`, then:
// 1. `n.address` equals `a`;
// 2. `_incidentEdges.has(n)`;
// Invariant 1. A node address `na` is 'referenced' if `_incidentEdges.has(na)`.
// 1.1 If a node is in the graph, then it is referenced by its address.
// 1.2 If a node has any incident edge, then it is referenced.
// 1.3 If a node is not in the graph and does not have incident edges, then
// it is not referenced.
const referencedNodesEncountered = new Set();
// 1.1
for (const [address, node] of this._nodes) {
if (node.address !== address) {
throw new Error(
`bad node address: ${nodeToString(node)} does not match ${address}`
`bad node address for ${NodeAddress.toString(address)}`
);
}
if (!this._incidentEdges.has(address)) {
throw new Error(
`missing incident-edges for ${NodeAddress.toString(address)}`
);
}
referencedNodesEncountered.add(address);
}
// 1.2
for (const edge of this._edges.values()) {
if (!this._incidentEdges.has(edge.src)) {
throw new Error(
`missing incident-edges for src of: ${edgeToString(edge)}`
);
}
referencedNodesEncountered.add(edge.src);
if (!this._incidentEdges.has(edge.dst)) {
throw new Error(
`missing incident-edges for dst of: ${edgeToString(edge)}`
);
}
referencedNodesEncountered.add(edge.dst);
}
// Check 1.3 by implication: for every address in
// referencedNodesEncountered, we've explicitly checked that it is present
// in _incidentEdges.
//
// Therefore, if the number of keys in _incidentEdges differs from the
// number of elements in referencedNodesEncountered, it must be because
// some elements in _incidentEdges were not present in
// referencedNodesEncountered, which means that they did not correspond to
// a node in the graph and did not have incident edges.
const numIncidentEntries = Array.from(this._incidentEdges.keys()).length;
if (numIncidentEntries !== referencedNodesEncountered.size) {
throw new Error("extra addresses in incident-edges");
}
// Invariant 2. For an edge address `a`, if `_edges.has(a)` and
// `_edges.get(a) === e`, then:
// 1. `e.address` equals `a`;
// 2. `e.src` is in the graph;
// 3. `e.dst` is in the graph;
// 2. `e.src` is referenced;
// 3. `e.dst` is referenced;
// 4. `_incidentEdges.get(e.dst).inEdges` contains `e`; and
// 5. `_incidentEdges.get(e.src).outEdges` contains `e`.
//
// We check 2.1, 2.2, and 2.3 here, and check 2.4 and 2.5 later for
// improved performance.
// 2.2 and 2.3 are implied by 2.4 and 2.5 respectively (as a node's address
// being available in _incidentEdges means that it is referenced). So we may
// ignore them.
//
// We check 2.1 here, and check 2.4 and 2.5 later for improved performance.
for (const [address, edge] of this._edges.entries()) {
if (edge.address !== address) {
throw new Error(
`bad edge address: ${edgeToString(edge)} does not match ${address}`
);
}
if (!this._nodes.has(edge.src)) {
throw new Error(`missing src for edge: ${edgeToString(edge)}`);
}
if (!this._nodes.has(edge.dst)) {
throw new Error(`missing dst for edge: ${edgeToString(edge)}`);
}
}
// Temporary Invariant
// Suppose that `_incidentEdges.has(n)`. Then `n` is in the graph.
// The {inEdges, outEdges} => incidentEdges refactor necessitated pulling
// this invariant out of invariants 3 and 4. However, the purpose of this
// refactor is actually to remove this invariant. So, there is no need to
// re-enumerate the invariants as this one will disappear shortly.
for (const addr of this._incidentEdges.keys()) {
if (!this._nodes.has(addr)) {
throw new Error(`spurious incident-edges`);
}
}
// Invariant 3. Suppose that `_incidentEdges.has(n)` and, let `es` be
@ -1005,6 +1114,6 @@ export function sortedEdgeAddressesFromJSON(
export function sortedNodeAddressesFromJSON(
json: GraphJSON
): $ReadOnlyArray<NodeAddressT> {
const {nodes} = fromCompat(COMPAT_INFO, json);
return nodes.map((x) => NodeAddress.fromParts(x));
const {sortedNodeAddresses} = fromCompat(COMPAT_INFO, json);
return sortedNodeAddresses.map((x) => NodeAddress.fromParts(x));
}

View File

@ -150,13 +150,28 @@ describe("core/graph", () => {
g._nodes.set(dst.address, src);
expect(() => g._checkInvariants()).toThrow("bad node address");
});
// Invariant 1.2
it("detects missing incident edges", () => {
it("detects missing incident edges corresponding to a node", () => {
const g = new Graph().addNode(src);
g._incidentEdges.delete(src.address);
expect(() => g._checkInvariants()).toThrow("missing incident-edges");
});
// Invariant 1.2
it("detects missing incident edges corresponding to a dangling edge", () => {
const g = new Graph().addEdge(simpleEdge);
g._incidentEdges.delete(src.address);
expect(() => g._checkInvariants()).toThrow("missing incident-edges");
});
// Invariant 1.3
it("detects extra incident edges not corresponding to anything", () => {
const g = new Graph().addNode(src);
g._incidentEdges.set(dst.address, {inEdges: [], outEdges: []});
expect(() => g._checkInvariants()).toThrow(
"extra addresses in incident-edges"
);
});
// Invariant 2.1
it("detects when an edge has bad address", () => {
const g = simpleGraph();
@ -167,20 +182,6 @@ describe("core/graph", () => {
g._incidentEdges.get(src.address).outEdges = [differentAddressEdge];
expect(() => g._checkInvariants()).toThrow("bad edge address");
});
// Invariant 2.2
it("detects when an edge has missing src", () => {
const g = simpleGraph();
g._nodes.delete(src.address);
g._incidentEdges.delete(src.address);
expect(() => g._checkInvariants()).toThrow("missing src");
});
// Invariant 2.3
it("detects when an edge has missing dst", () => {
const g = simpleGraph();
g._nodes.delete(dst.address);
g._incidentEdges.delete(dst.address);
expect(() => g._checkInvariants()).toThrow("missing dst");
});
// Invariant 2.4
it("detects when an edge is missing in `_inEdges`", () => {
const g = simpleGraph();
@ -196,13 +197,6 @@ describe("core/graph", () => {
expect(() => g._checkInvariants()).toThrow("missing out-edge");
});
// Temporary invariant
it("detects spurious incident-edges", () => {
const g = new Graph();
g._incidentEdges.set(src.address, {inEdges: [], outEdges: []});
expect(() => g._checkInvariants()).toThrow("spurious incident-edges");
});
// Invariant 3.1
it("detects when an edge is duplicated in `_inEdges`", () => {
const g = simpleGraph();
@ -297,18 +291,6 @@ describe("core/graph", () => {
expect(() => new Graph().addNode(n).toThrow("got EdgeAddress"));
});
});
describe("remove a node that is some edge's", () => {
it("src", () => {
expect(() => simpleGraph().removeNode(src.address)).toThrow(
"Attempted to remove"
);
});
it("dst", () => {
expect(() => simpleGraph().removeNode(dst.address)).toThrow(
"Attempted to remove"
);
});
});
it("distinct nodes with the same address", () => {
const g = new Graph();
@ -348,6 +330,29 @@ describe("core/graph", () => {
expect(Array.from(graph.nodes())).toEqual([src]);
expect(graph.node(src.address)).toEqual(src);
});
it("a graph with a node reference", () => {
// The dangling edge creates a node reference.
// This reference should not be discoverable via
// any of the node methods.
const graph = new Graph().addEdge(simpleEdge);
expect(graph.hasNode(src.address)).toBe(false);
expect(graph.hasNode(dst.address)).toBe(false);
expect(Array.from(graph.nodes())).toEqual([]);
expect(graph.node(src.address)).toEqual(undefined);
expect(graph.node(dst.address)).toEqual(undefined);
});
it("a graph with a node node that downgrades to a reference", () => {
const graph = new Graph()
.addNode(src)
.addNode(dst)
.addEdge(simpleEdge)
.removeNode(src.address);
expect(graph.hasNode(src.address)).toBe(false);
expect(graph.hasNode(dst.address)).toBe(true);
expect(Array.from(graph.nodes())).toEqual([dst]);
expect(graph.node(src.address)).toEqual(undefined);
expect(graph.node(dst.address)).toEqual(dst);
});
it("a graph with the same node added twice", () => {
const graph = new Graph().addNode(src).addNode(src);
expect(graph.hasNode(src.address)).toBe(true);
@ -450,7 +455,6 @@ describe("core/graph", () => {
});
describe("edge methods", () => {
const edgeArray = (g: Graph) => Array.from(g.edges());
describe("error on", () => {
const p = Graph.prototype;
const edgeAddrMethods = [p.removeEdge, p.hasEdge, p.edge];
@ -470,19 +474,6 @@ describe("core/graph", () => {
});
describe("addEdge edge validation", () => {
describe("throws on absent", () => {
it("src", () => {
expect(() =>
new Graph().addNode(dst).addEdge(simpleEdge)
).toThrow("Missing src");
});
it("dst", () => {
expect(() =>
new Graph().addNode(src).addEdge(simpleEdge)
).toThrow("Missing dst");
});
});
it("throws on conflicting edge", () => {
const e1 = edge("1", src, dst);
const e2 = edge("1", src, src);
@ -544,13 +535,13 @@ describe("core/graph", () => {
describe("concurrent modification in `edges`", () => {
it("while in the middle of iteration", () => {
const g = simpleGraph();
const iterator = g.edges();
const iterator = g.edges({showDangling: true});
g._modificationCount++;
expect(() => iterator.next()).toThrow("Concurrent modification");
});
it("at exhaustion", () => {
const g = new Graph();
const iterator = g.edges();
const iterator = g.edges({showDangling: true});
g._modificationCount++;
expect(() => iterator.next()).toThrow("Concurrent modification");
});
@ -566,13 +557,14 @@ describe("core/graph", () => {
const e12 = partsEdge(["e", "1", "2"], src1, dst2);
const e21 = partsEdge(["e", "2", "1"], src2, dst1);
const e22 = partsEdge(["e", "2", "2"], src2, dst2);
const eDangling = partsEdge(["e", "2", "NaN"], src2, node("nope"));
const graph = () =>
[e11, e12, e21, e22].reduce(
[e11, e12, e21, e22, eDangling].reduce(
(g, e) => g.addEdge(e),
[src1, src2, dst1, dst2].reduce((g, n) => g.addNode(n), new Graph())
);
function expectEdges(
options: EdgesOptions | void,
options: EdgesOptions,
expected: $ReadOnlyArray<Edge>
) {
const sort = (es) => sortBy(es, (e) => e.address);
@ -580,46 +572,28 @@ describe("core/graph", () => {
sort(expected.slice())
);
}
it("finds all edges when no options are specified", () => {
expectEdges(undefined, [e11, e12, e21, e22]);
it("finds all edges when only showDangling:true is provided", () => {
expectEdges({showDangling: true}, [e11, e12, e21, e22, eDangling]);
});
it("finds all non-dangling edges when only showDangling:false is provided", () => {
expectEdges({showDangling: false}, [e11, e12, e21, e22]);
});
it("finds all edges when universal filters are specified", () => {
expectEdges(
{
showDangling: true,
addressPrefix: EdgeAddress.fromParts(["e"]),
srcPrefix: NodeAddress.fromParts(["src"]),
dstPrefix: NodeAddress.fromParts(["dst"]),
dstPrefix: NodeAddress.empty,
},
[e11, e12, e21, e22]
[e11, e12, e21, e22, eDangling]
);
});
it("requires `addressPrefix` to be present in provided options", () => {
expect(() => {
graph()
// $ExpectFlowError
.edges({srcPrefix: src1, dstPrefix: dst1});
}).toThrow("Invalid address prefix: undefined");
});
it("requires `srcPrefix` to be present in provided options", () => {
expect(() => {
graph()
// $ExpectFlowError
.edges({addressPrefix: e11, dstPrefix: dst1});
}).toThrow("Invalid src prefix: undefined");
});
it("requires `dstPrefix` to be present in provided options", () => {
expect(() => {
graph()
// $ExpectFlowError
.edges({addressPrefix: e11, srcPrefix: dst1});
}).toThrow("Invalid dst prefix: undefined");
});
it("finds edges by address prefix", () => {
expectEdges(
{
showDangling: true,
addressPrefix: EdgeAddress.fromParts(["e", "1"]),
srcPrefix: NodeAddress.empty,
dstPrefix: NodeAddress.empty,
},
[e11, e12]
);
@ -627,9 +601,8 @@ describe("core/graph", () => {
it("finds edges by src prefix", () => {
expectEdges(
{
addressPrefix: EdgeAddress.empty,
showDangling: true,
srcPrefix: NodeAddress.fromParts(["src", "1"]),
dstPrefix: NodeAddress.empty,
},
[e11, e12]
);
@ -637,8 +610,7 @@ describe("core/graph", () => {
it("finds edges by dst prefix", () => {
expectEdges(
{
addressPrefix: EdgeAddress.empty,
srcPrefix: NodeAddress.empty,
showDangling: true,
dstPrefix: NodeAddress.fromParts(["dst", "1"]),
},
[e11, e21]
@ -647,6 +619,7 @@ describe("core/graph", () => {
it("yields nothing for disjoint filters", () => {
expectEdges(
{
showDangling: true,
addressPrefix: EdgeAddress.fromParts(["e", "1"]),
srcPrefix: NodeAddress.fromParts(["src", "2"]),
dstPrefix: NodeAddress.empty,
@ -654,14 +627,32 @@ describe("core/graph", () => {
[]
);
});
it("yields appropriate filter intersection", () => {
it("yields appropriate filter intersection for srcPrefix and dstPrefix", () => {
expectEdges(
{
addressPrefix: EdgeAddress.empty,
srcPrefix: NodeAddress.fromParts(["src", "1"]),
showDangling: true,
srcPrefix: NodeAddress.fromParts(["src", "2"]),
dstPrefix: NodeAddress.fromParts(["dst", "2"]),
},
[e12]
[e22]
);
});
it("yields appropriate filter intersection with showDangling: false", () => {
expectEdges(
{
showDangling: false,
srcPrefix: NodeAddress.fromParts(["src", "2"]),
},
[e21, e22]
);
});
it("yields appropriate filter intersection with showDangling: true", () => {
expectEdges(
{
showDangling: true,
srcPrefix: NodeAddress.fromParts(["src", "2"]),
},
[e21, e22, eDangling]
);
});
});
@ -675,7 +666,13 @@ describe("core/graph", () => {
expect(new Graph().edge(simpleEdge.address)).toBe(undefined);
});
it("`edges` is empty", () => {
expect(edgeArray(new Graph())).toHaveLength(0);
const edges = Array.from(new Graph().edges({showDangling: true}));
expect(edges).toHaveLength(0);
});
it("`isDanglingEdge` is undefined", () => {
expect(new Graph().isDanglingEdge(simpleEdge.address)).toBe(
undefined
);
});
});
@ -687,9 +684,15 @@ describe("core/graph", () => {
expect(simpleGraph().edge(simpleEdge.address)).toEqual(simpleEdge);
});
it("`edges` contains the edge", () => {
const edgeArray = (g: Graph) => Array.from(g.edges());
const edgeArray = (g: Graph) =>
Array.from(g.edges({showDangling: true}));
expect(edgeArray(simpleGraph())).toEqual([simpleEdge]);
});
it("`isDanglingEdge` is false", () => {
expect(simpleGraph().isDanglingEdge(simpleEdge.address)).toBe(
false
);
});
});
describe("with edge added and removed", () => {
@ -701,8 +704,16 @@ describe("core/graph", () => {
it("`edge` returns undefined", () => {
expect(removedGraph().edge(simpleEdge.address)).toBe(undefined);
});
it("`isDanglingEdge` returns undefined", () => {
expect(removedGraph().isDanglingEdge(simpleEdge.address)).toBe(
undefined
);
});
it("`edges` is empty", () => {
expect(edgeArray(removedGraph())).toHaveLength(0);
const edges = Array.from(
removedGraph().edges({showDangling: true})
);
expect(edges).toHaveLength(0);
});
it("nodes were not removed", () => {
expect(removedGraph().hasNode(src.address)).toBe(true);
@ -731,7 +742,50 @@ describe("core/graph", () => {
expect(quiver().edge(e2.address)).toEqual(e2);
});
it("both edges are retrievable from `edges`", () => {
expect(edgeArray(quiver()).sort()).toEqual([e1, e2].sort());
const edges = Array.from(quiver().edges({showDangling: true}));
expect(edges).toEqual([e1, e2]);
});
});
describe("with dangling edges", () => {
it("in the case where an edge was always dangling", () => {
const g = new Graph().addEdge(simpleEdge);
expect(g.hasEdge(simpleEdge.address)).toBe(true);
expect(Array.from(g.edges({showDangling: true}))).toEqual([
simpleEdge,
]);
expect(Array.from(g.edges({showDangling: false}))).toEqual([]);
expect(g.edge(simpleEdge.address)).toEqual(simpleEdge);
expect(g.isDanglingEdge(simpleEdge.address)).toBe(true);
});
it("in the case where an edge became dangling after being added", () => {
const g = new Graph()
.addNode(src)
.addNode(dst)
.addEdge(simpleEdge)
.removeNode(src.address);
expect(g.hasEdge(simpleEdge.address)).toBe(true);
expect(Array.from(g.edges({showDangling: true}))).toEqual([
simpleEdge,
]);
expect(Array.from(g.edges({showDangling: false}))).toEqual([]);
expect(g.edge(simpleEdge.address)).toEqual(simpleEdge);
expect(g.isDanglingEdge(simpleEdge.address)).toBe(true);
});
it("in the case where an edge ceased being dangling after being added", () => {
const g = new Graph()
.addEdge(simpleEdge)
.addNode(src)
.addNode(dst);
expect(g.hasEdge(simpleEdge.address)).toBe(true);
expect(Array.from(g.edges({showDangling: true}))).toEqual([
simpleEdge,
]);
expect(Array.from(g.edges({showDangling: false}))).toEqual([
simpleEdge,
]);
expect(g.edge(simpleEdge.address)).toEqual(simpleEdge);
expect(g.isDanglingEdge(simpleEdge.address)).toBe(false);
});
});
});
@ -739,7 +793,9 @@ describe("core/graph", () => {
describe("idempotency of", () => {
it("`addEdge`", () => {
const g = simpleGraph().addEdge(simpleEdge);
expect(edgeArray(g)).toEqual([simpleEdge]);
expect(Array.from(g.edges({showDangling: true}))).toEqual([
simpleEdge,
]);
expect(
Array.from(
g.neighbors(src.address, {
@ -754,7 +810,7 @@ describe("core/graph", () => {
const g = simpleGraph()
.removeEdge(simpleEdge.address)
.removeEdge(simpleEdge.address);
expect(edgeArray(g)).toHaveLength(0);
expect(Array.from(g.edges({showDangling: true}))).toHaveLength(0);
});
});
});
@ -791,20 +847,32 @@ describe("core/graph", () => {
const foo = partsNode(["foo", "suffix"]);
const loop = partsNode(["loop"]);
const isolated = partsNode(["isolated"]);
// halfIsolated is the src of one edge, but that edge
// is a dangling edge (its dst is not in the graph).
// It's included so we can verify it has no neighbors.
const halfIsolated = partsNode(["halfIsolated"]);
const foo_loop = partsEdge(["foo", "1"], foo, loop);
const loop_foo = partsEdge(["foo", "2"], loop, foo);
const loop_loop = partsEdge(["loop"], loop, loop);
const repeated_loop_foo = partsEdge(["repeated", "foo"], loop, foo);
const dangling = partsEdge(
["dangling"],
halfIsolated,
node("nonexistent")
);
function quiver() {
return new Graph()
.addNode(foo)
.addNode(loop)
.addNode(isolated)
.addNode(halfIsolated)
.addEdge(foo_loop)
.addEdge(loop_foo)
.addEdge(loop_loop)
.addEdge(repeated_loop_foo);
.addEdge(repeated_loop_foo)
.addEdge(dangling);
}
function expectNeighbors(
@ -844,6 +912,19 @@ describe("core/graph", () => {
);
});
it("half-isolated node has no neighbors", () => {
// Verifies that dangling edges are not included in neighbors.
expectNeighbors(
halfIsolated.address,
{
direction: Direction.ANY,
nodePrefix: NodeAddress.empty,
edgePrefix: EdgeAddress.empty,
},
[]
);
});
function expectLoopNeighbors(dir, nodeParts, edgeParts, expected) {
const options = {
direction: dir,
@ -1200,6 +1281,16 @@ describe("core/graph", () => {
expect(graph1().equals(graph3)).toBe(true);
expect(graph2().equals(graph3)).toBe(true);
});
it("merges a dangling edge with its src and dst", () => {
const g1 = new Graph().addEdge(simpleEdge);
const g2 = new Graph().addNode(src).addNode(dst);
const expected = new Graph()
.addNode(src)
.addNode(dst)
.addEdge(simpleEdge);
const merged = Graph.merge([g1, g2]);
expect(merged.equals(expected)).toBe(true);
});
it("rejects graphs with conflicting edges", () => {
const g1 = new Graph()
.addNode(foo)
@ -1223,6 +1314,10 @@ describe("core/graph", () => {
.addEdge(differentAddressEdge);
expect(graph.toJSON()).toMatchSnapshot();
});
it("a graph with a dangling edge", () => {
const graph = new Graph().addEdge(simpleEdge);
expect(graph.toJSON()).toMatchSnapshot();
});
it("an advanced graph", () => {
const graph = advancedGraph().graph1();
expect(graph.toJSON()).toMatchSnapshot();
@ -1244,6 +1339,10 @@ describe("core/graph", () => {
const g = new Graph().addNode(src).addNode(dst);
expectCompose(g);
});
it("for a graph with a dangling edge", () => {
const g = new Graph().addEdge(simpleEdge);
expectCompose(g);
});
it("for a graph with nodes added and removed", () => {
const g = new Graph()
.addNode(src)
@ -1251,6 +1350,12 @@ describe("core/graph", () => {
.removeNode(src.address);
expectCompose(g);
});
it("a graph with a dangling edge added and removed", () => {
const g = new Graph()
.addEdge(simpleEdge)
.removeEdge(simpleEdge.address);
expectCompose(g);
});
it("for a graph with nodes and edges", () => {
const g = new Graph()
.addNode(src)

View File

@ -71,6 +71,12 @@ export function advancedGraph() {
const hom2 = partsEdge(["hom", "2"], src, dst);
const loop = node("loop");
const loop_loop = edge("loop", loop, loop);
const halfIsolated = node("halfIsolated");
const phantomNode = node("phantom");
const halfDanglingEdge = edge("half-dangling", halfIsolated, phantomNode);
const fullDanglingEdge = edge("full-dangling", phantomNode, phantomNode);
const isolated = node("isolated");
const graph1 = () =>
new Graph()
@ -80,13 +86,15 @@ export function advancedGraph() {
.addNode(isolated)
.addEdge(hom1)
.addEdge(hom2)
.addEdge(loop_loop);
.addEdge(loop_loop)
.addNode(halfIsolated)
.addEdge(halfDanglingEdge)
.addEdge(fullDanglingEdge);
// graph2 is logically equivalent to graph1, but is constructed with very
// different history.
// Use this to check that logically equivalent graphs are treated
// equivalently, regardless of their history.
const phantomNode = node("phantom");
const phantomEdge1 = edge("phantom", src, phantomNode);
const phantomEdge2 = edge("not-so-isolated", src, isolated);
@ -103,33 +111,63 @@ export function advancedGraph() {
// N: [phantomNode, src], E: [phantomEdge1]
.addNode(isolated)
// N: [phantomNode, src, isolated], E: [phantomEdge1]
.addNode(halfIsolated)
// N: [phantomNode, src, isolated, halfIsolated]
// E: [phantomEdge1]
.addEdge(halfDanglingEdge)
// N: [phantomNode, src, isolated, halfIsolated]
// E: [phantomEdge1, halfDanglingEdge]
.addEdge(fullDanglingEdge)
// N: [phantomNode, src, isolated, halfIsolated]
// E: [phantomEdge1, halfDanglingEdge, fullDanglingEdge]
.removeEdge(phantomEdge1.address)
// N: [phantomNode, src, isolated], E: []
// N: [phantomNode, src, isolated, halfIsolated]
// E: [halfDanglingEdge, fullDanglingEdge]
.addNode(dst)
// N: [phantomNode, src, isolated, dst], E: []
// N: [phantomNode, src, isolated, halfIsolated, dst]
// E: [halfDanglingEdge, fullDanglingEdge]
.addEdge(hom1)
// N: [phantomNode, src, isolated, dst], E: [hom1]
// N: [phantomNode, src, isolated, halfIsolated, dst]
// E: [halfDanglingEdge, fullDanglingEdge, hom1]
.addEdge(phantomEdge2)
// N: [phantomNode, src, isolated, dst], E: [hom1, phantomEdge2]
// N: [phantomNode, src, isolated, halfIsolated, dst]
// E: [halfDanglingEdge, fullDanglingEdge, hom1, phantomEdge2]
.addEdge(hom2)
// N: [phantomNode, src, isolated, dst], E: [hom1, phantomEdge2, hom2]
// N: [phantomNode, src, isolated, halfIsolated, dst]
// E: [halfDanglingEdge, fullDanglingEdge, hom1, phantomEdge2, hom2]
.removeEdge(hom1.address)
// N: [phantomNode, src, isolated, dst], E: [phantomEdge2, hom2]
// N: [phantomNode, src, isolated, halfIsolated, dst]
// E: [halfDanglingEdge, fullDanglingEdge, phantomEdge2, hom2]
.removeNode(phantomNode.address)
// N: [src, isolated, dst], E: [phantomEdge2, hom2]
// N: [src, isolated, halfIsolated, dst]
// E: [halfDanglingEdge, fullDanglingEdge, phantomEdge2, hom2]
.removeEdge(phantomEdge2.address)
// N: [src, isolated, dst], E: [hom2]
// N: [src, isolated, halfIsolated, dst]
// E: [halfDanglingEdge, fullDanglingEdge, hom2]
.removeNode(isolated.address)
// N: [src, dst], E: [hom2]
// N: [src, halfIsolated, dst]
// E: [halfDanglingEdge, fullDanglingEdge, hom2]
.addNode(isolated)
// N: [src, dst, isolated], E: [hom2]
// N: [src, halfIsolated, dst, isolated]
// E: [halfDanglingEdge, fullDanglingEdge, hom2]
.addNode(loop)
// N: [src, dst, isolated, loop], E: [hom2]
// N: [src, halfIsolated, dst, isolated, loop]
// E: [halfDanglingEdge, fullDanglingEdge, hom2]
.addEdge(loop_loop)
// N: [src, dst, isolated, loop], E: [hom2, loop_loop]
// N: [src, halfIsolated, dst, isolated, loop]
// E: [halfDanglingEdge, fullDanglingEdge, hom2, loop_loop]
.addEdge(hom1);
// N: [src, dst, isolated, loop], E: [hom2, loop_loop, hom1]
const nodes = {src, dst, loop, isolated, phantomNode};
const edges = {hom1, hom2, loop_loop, phantomEdge1, phantomEdge2};
// N: [src, halfIsolated, dst, isolated, loop]
// E: [halfDanglingEdge, fullDanglingEdge, hom2, loop_loop, hom1]
const nodes = {src, dst, loop, isolated, phantomNode, halfIsolated};
const edges = {
hom1,
hom2,
loop_loop,
phantomEdge1,
phantomEdge2,
halfDanglingEdge,
fullDanglingEdge,
};
return {nodes, edges, graph1, graph2};
}

View File

@ -7,7 +7,6 @@ import {
Graph,
type Node,
type Edge,
type EdgesOptions,
type NodeAddressT,
type EdgeAddressT,
type GraphJSON,
@ -47,6 +46,12 @@ export type WeightedEdge = {|
+weight: EdgeWeight,
|};
export type PagerankGraphEdgesOptions = {|
+addressPrefix?: EdgeAddressT,
+srcPrefix?: NodeAddressT,
+dstPrefix?: NodeAddressT,
|};
export type ScoredNeighbor = {|
// The neighbor node, with its score
+scoredNode: ScoredNode,
@ -236,7 +241,7 @@ export class PagerankGraph {
const newWeight = previousWeight + weight;
this._totalOutWeight.set(node, newWeight);
};
for (const edge of this._graph.edges()) {
for (const edge of this._graph.edges({showDangling: false})) {
const weights = edgeEvaluator(edge);
this._edgeWeights.set(edge.address, weights);
addOutWeight(edge.src, weights.toWeight);
@ -310,10 +315,24 @@ export class PagerankGraph {
* Optionally, provide an EdgesOptions parameter to return an
* iterator containing edges matching the EdgesOptions prefix
* filter parameters. See Graph.edges for details.
*
* In contrast to Graph.edges, dangling edges will never be included,
* as we do not assign weights to danging edges.
*/
edges(options?: EdgesOptions): Iterator<WeightedEdge> {
edges(options?: PagerankGraphEdgesOptions): Iterator<WeightedEdge> {
this._verifyGraphNotModified();
const iterator = this._graph.edges(options);
const graphOptions = {
showDangling: false,
addressPrefix: undefined,
srcPrefix: undefined,
dstPrefix: undefined,
};
if (options != null) {
graphOptions.addressPrefix = options.addressPrefix;
graphOptions.srcPrefix = options.srcPrefix;
graphOptions.dstPrefix = options.dstPrefix;
}
const iterator = this._graph.edges(graphOptions);
return this._edgesIterator(iterator);
}
@ -332,7 +351,7 @@ export class PagerankGraph {
edge(a: EdgeAddressT): ?WeightedEdge {
this._verifyGraphNotModified();
const edge = this._graph.edge(a);
if (edge != null) {
if (edge != null && this._graph.isDanglingEdge(a) === false) {
const weight = NullUtil.get(this._edgeWeights.get(edge.address));
return {edge, weight};
}
@ -548,12 +567,16 @@ export class PagerankGraph {
this._verifyGraphNotModified();
const graphJSON = this.graph().toJSON();
const nodes = sortedNodeAddressesFromJSON(graphJSON);
const nodes = sortedNodeAddressesFromJSON(graphJSON).filter((x) =>
this.graph().hasNode(x)
);
const scores: number[] = nodes.map((x) =>
NullUtil.get(this._scores.get(x))
);
const edgeAddresses = sortedEdgeAddressesFromJSON(graphJSON);
const edgeAddresses = sortedEdgeAddressesFromJSON(graphJSON).filter(
(a) => this.graph().isDanglingEdge(a) === false
);
const edgeWeights: EdgeWeight[] = edgeAddresses.map((x) =>
NullUtil.get(this._edgeWeights.get(x))
);
@ -581,18 +604,22 @@ export class PagerankGraph {
} = fromCompat(COMPAT_INFO, json);
const graph = Graph.fromJSON(graphJSON);
const nodes = sortedNodeAddressesFromJSON(graphJSON);
const nodeAddresses = sortedNodeAddressesFromJSON(graphJSON).filter((x) =>
graph.hasNode(x)
);
const scoreMap: Map<NodeAddressT, number> = new Map();
for (let i = 0; i < nodes.length; i++) {
scoreMap.set(nodes[i], scores[i]);
for (let i = 0; i < nodeAddresses.length; i++) {
scoreMap.set(nodeAddresses[i], scores[i]);
}
const edges = sortedEdgeAddressesFromJSON(graphJSON);
const edgeAddresses = sortedEdgeAddressesFromJSON(graphJSON).filter(
(x) => graph.isDanglingEdge(x) === false
);
const edgeWeights: Map<EdgeAddressT, EdgeWeight> = new Map();
for (let i = 0; i < edges.length; i++) {
for (let i = 0; i < edgeAddresses.length; i++) {
const toWeight = toWeights[i];
const froWeight = froWeights[i];
edgeWeights.set(edges[i], {toWeight, froWeight});
edgeWeights.set(edgeAddresses[i], {toWeight, froWeight});
}
function evaluator(e: Edge): EdgeWeight {

View File

@ -16,6 +16,7 @@ import {
DEFAULT_CONVERGENCE_THRESHOLD,
DEFAULT_ALPHA,
DEFAULT_SEED,
type PagerankGraphEdgesOptions,
} from "./pagerankGraph";
import {advancedGraph, node, partsNode, partsEdge} from "./graphTestUtil";
import * as NullUtil from "../util/null";
@ -162,10 +163,10 @@ describe("core/pagerankGraph", () => {
});
describe("edge/edges", () => {
it("edges returns the same edges as are in the graph", () => {
it("edges returns the non-dangling edges in the base graph", () => {
const g = advancedGraph().graph1();
const pg = new PagerankGraph(g, defaultEvaluator);
const graphEdges = Array.from(g.edges());
const graphEdges = Array.from(g.edges({showDangling: false}));
const pgEdges = Array.from(pg.edges()).map((x) => x.edge);
expect(graphEdges.length).toEqual(pgEdges.length);
const addressAccessor = (x: Edge) => x.address;
@ -194,6 +195,12 @@ describe("core/pagerankGraph", () => {
expect(pg.edge(EdgeAddress.empty)).toBe(undefined);
});
it("edge returns null for dangling edge", () => {
const {graph1, edges} = advancedGraph();
const pg = new PagerankGraph(graph1(), defaultEvaluator);
expect(pg.edge(edges.halfDanglingEdge.address)).toEqual(undefined);
});
it("edge and edges both throw an error if underlying graph is modified", () => {
const pg = new PagerankGraph(nonEmptyGraph(), defaultEvaluator);
pg.graph().addNode(node("foo"));
@ -286,13 +293,17 @@ describe("core/pagerankGraph", () => {
};
const pagerankGraph = () => new PagerankGraph(graph(), defaultEvaluator);
function expectConsistentEdges(options: EdgesOptions | void) {
function expectConsistentEdges(options: PagerankGraphEdgesOptions | void) {
const pagerankGraphEdges = Array.from(pagerankGraph().edges(options));
pagerankGraphEdges.forEach((e) => {
expect(e.weight.froWeight).toBe(0);
expect(e.weight.toWeight).toBe(1);
});
const graphEdges = Array.from(graph().edges(options));
const graphOptions: EdgesOptions =
options == null
? {showDangling: false}
: {...options, showDangling: false};
const graphEdges = Array.from(graph().edges(graphOptions));
expect(pagerankGraphEdges.map((e) => e.edge)).toEqual(graphEdges);
}
@ -310,21 +321,15 @@ describe("core/pagerankGraph", () => {
it("finds edges by address prefix", () => {
expectConsistentEdges({
addressPrefix: EdgeAddress.fromParts(["e", "1"]),
srcPrefix: NodeAddress.empty,
dstPrefix: NodeAddress.empty,
});
});
it("finds edges by src prefix", () => {
expectConsistentEdges({
addressPrefix: EdgeAddress.empty,
srcPrefix: NodeAddress.fromParts(["src", "1"]),
dstPrefix: NodeAddress.empty,
});
});
it("finds edges by dst prefix", () => {
expectConsistentEdges({
addressPrefix: EdgeAddress.empty,
srcPrefix: NodeAddress.empty,
dstPrefix: NodeAddress.fromParts(["dst", "1"]),
});
});
@ -332,42 +337,15 @@ describe("core/pagerankGraph", () => {
expectConsistentEdges({
addressPrefix: EdgeAddress.fromParts(["e", "1"]),
srcPrefix: NodeAddress.fromParts(["src", "2"]),
dstPrefix: NodeAddress.empty,
});
});
it("yields appropriate filter intersection", () => {
expectConsistentEdges({
addressPrefix: EdgeAddress.empty,
srcPrefix: NodeAddress.fromParts(["src", "1"]),
dstPrefix: NodeAddress.fromParts(["dst", "2"]),
});
});
});
describe("edge filter options", () => {
it("requires `addressPrefix` to be present in provided options", () => {
expect(() => {
pagerankGraph()
// $ExpectFlowError
.edges({srcPrefix: src1, dstPrefix: dst1});
}).toThrow("Invalid address prefix: undefined");
});
it("requires `srcPrefix` to be present in provided options", () => {
expect(() => {
pagerankGraph()
// $ExpectFlowError
.edges({addressPrefix: e11, dstPrefix: dst1});
}).toThrow("Invalid src prefix: undefined");
});
it("requires `dstPrefix` to be present in provided options", () => {
expect(() => {
pagerankGraph()
// $ExpectFlowError
.edges({addressPrefix: e11, srcPrefix: dst1});
}).toThrow("Invalid dst prefix: undefined");
});
});
});
describe("neighbors", () => {

View File

@ -4,7 +4,7 @@ exports[`plugins/git/createGraph createGraph processes a simple repository 1`] =
Array [
Object {
"type": "sourcecred/graph",
"version": "0.4.0",
"version": "0.5.0",
},
Object {
"edges": Array [
@ -115,6 +115,32 @@ Array [
},
],
"nodes": Array [
Object {
"index": 0,
},
Object {
"index": 1,
},
Object {
"index": 2,
},
Object {
"index": 3,
},
Object {
"index": 4,
},
Object {
"index": 5,
},
Object {
"index": 6,
},
Object {
"index": 7,
},
],
"sortedNodeAddresses": Array [
Array [
"sourcecred",
"git",

View File

@ -22,7 +22,7 @@ describe("plugins/git/createGraph", () => {
throw new Error("Found non-commit node");
}
}
for (const {address} of graph.edges()) {
for (const {address} of graph.edges({showDangling: true})) {
if (!EdgeAddress.hasPrefix(address, EdgePrefix.hasParent)) {
throw new Error("Found non-has-parent edge");
}

View File

@ -4,7 +4,7 @@ exports[`plugins/github/createGraph example graph matches snapshot 1`] = `
Array [
Object {
"type": "sourcecred/graph",
"version": "0.4.0",
"version": "0.5.0",
},
Object {
"edges": Array [
@ -2560,6 +2560,158 @@ Array [
},
],
"nodes": Array [
Object {
"index": 0,
},
Object {
"index": 1,
},
Object {
"index": 2,
},
Object {
"index": 3,
},
Object {
"index": 4,
},
Object {
"index": 5,
},
Object {
"index": 6,
},
Object {
"index": 7,
},
Object {
"index": 8,
},
Object {
"index": 9,
},
Object {
"index": 10,
},
Object {
"index": 11,
},
Object {
"index": 12,
},
Object {
"index": 13,
},
Object {
"index": 14,
},
Object {
"index": 15,
},
Object {
"index": 16,
},
Object {
"index": 17,
},
Object {
"index": 18,
},
Object {
"index": 19,
},
Object {
"index": 20,
},
Object {
"index": 21,
},
Object {
"index": 22,
},
Object {
"index": 23,
},
Object {
"index": 24,
},
Object {
"index": 25,
},
Object {
"index": 26,
},
Object {
"index": 27,
},
Object {
"index": 28,
},
Object {
"index": 29,
},
Object {
"index": 30,
},
Object {
"index": 31,
},
Object {
"index": 32,
},
Object {
"index": 33,
},
Object {
"index": 34,
},
Object {
"index": 35,
},
Object {
"index": 36,
},
Object {
"index": 37,
},
Object {
"index": 38,
},
Object {
"index": 39,
},
Object {
"index": 40,
},
Object {
"index": 41,
},
Object {
"index": 42,
},
Object {
"index": 43,
},
Object {
"index": 44,
},
Object {
"index": 45,
},
Object {
"index": 46,
},
Object {
"index": 47,
},
Object {
"index": 48,
},
Object {
"index": 49,
},
],
"sortedNodeAddresses": Array [
Array [
"sourcecred",
"git",