From 337de0b375500ef51f30dfd1c5fb07c43a794b26 Mon Sep 17 00:00:00 2001 From: Kasper Isager Date: Mon, 8 Feb 2021 20:18:54 +0100 Subject: [PATCH 1/5] Initial draft of network package --- packages/alfa-network/package.json | 37 ++++ packages/alfa-network/src/index.ts | 1 + packages/alfa-network/src/network.ts | 301 +++++++++++++++++++++++++++ packages/alfa-network/tsconfig.json | 34 +++ packages/tsconfig.json | 1 + 5 files changed, 374 insertions(+) create mode 100644 packages/alfa-network/package.json create mode 100644 packages/alfa-network/src/index.ts create mode 100644 packages/alfa-network/src/network.ts create mode 100644 packages/alfa-network/tsconfig.json diff --git a/packages/alfa-network/package.json b/packages/alfa-network/package.json new file mode 100644 index 0000000000..69f03b8eaa --- /dev/null +++ b/packages/alfa-network/package.json @@ -0,0 +1,37 @@ +{ + "$schema": "http://json.schemastore.org/package", + "name": "@siteimprove/alfa-network", + "homepage": "https://siteimprove.com", + "version": "0.10.0", + "license": "MIT", + "description": "An implementation of an immutable, directed graph with unique edges", + "repository": { + "type": "git", + "url": "https://github.com/siteimprove/alfa.git", + "directory": "packages/alfa-network" + }, + "bugs": "https://github.com/siteimprove/alfa/issues", + "main": "src/index.js", + "types": "src/index.d.ts", + "files": [ + "src/**/*.js", + "src/**/*.d.ts" + ], + "dependencies": { + "@siteimprove/alfa-equatable": "^0.10.0", + "@siteimprove/alfa-graph": "^0.10.0", + "@siteimprove/alfa-hash": "^0.10.0", + "@siteimprove/alfa-iterable": "^0.10.0", + "@siteimprove/alfa-json": "^0.10.0", + "@siteimprove/alfa-map": "^0.10.0", + "@siteimprove/alfa-sequence": "^0.10.0", + "@siteimprove/alfa-set": "^0.10.0" + }, + "devDependencies": { + "@siteimprove/alfa-test": "^0.10.0" + }, + "publishConfig": { + "access": "public", + "registry": "https://npm.pkg.github.com/" + } +} diff --git a/packages/alfa-network/src/index.ts b/packages/alfa-network/src/index.ts new file mode 100644 index 0000000000..364667989a --- /dev/null +++ b/packages/alfa-network/src/index.ts @@ -0,0 +1 @@ +export * from "./network"; diff --git a/packages/alfa-network/src/network.ts b/packages/alfa-network/src/network.ts new file mode 100644 index 0000000000..288191590d --- /dev/null +++ b/packages/alfa-network/src/network.ts @@ -0,0 +1,301 @@ +import { Equatable } from "@siteimprove/alfa-equatable"; +import { Graph } from "@siteimprove/alfa-graph"; +import { Hashable, Hash } from "@siteimprove/alfa-hash"; +import { Iterable } from "@siteimprove/alfa-iterable"; +import { Serializable } from "@siteimprove/alfa-json"; +import { Map } from "@siteimprove/alfa-map"; +import { Sequence } from "@siteimprove/alfa-sequence"; +import { Set } from "@siteimprove/alfa-set"; + +export class Network + implements + Iterable<[N, Iterable<[N, Iterable]>]>, + Equatable, + Hashable, + Serializable> { + public static of(nodes: Map>>): Network { + return new Network(nodes); + } + + private static _empty = new Network(Map.empty()); + + public static empty(): Network { + return this._empty; + } + + private readonly _nodes: Map>>; + + private constructor(nodes: Map>>) { + this._nodes = nodes; + } + + public get size(): number { + return this._nodes.size; + } + + public nodes(): Iterable { + return this._nodes.keys(); + } + + public neighbors(node: N): Iterable<[N, Iterable]> { + return this._nodes.get(node).getOr([]); + } + + public has(node: N): boolean { + return this._nodes.has(node); + } + + public add(node: N): Network { + if (this.has(node)) { + return this; + } + + return new Network(this._nodes.set(node, Map.empty())); + } + + public delete(node: N): Network { + let nodes = this._nodes; + + if (!nodes.has(node)) { + return this; + } + + return new Network( + nodes.delete(node).map((neighbors) => neighbors.delete(node)) + ); + } + + public connect(from: N, to: N, edge: E): Network { + let nodes = this._nodes; + + if (!nodes.has(from)) { + nodes = nodes.set(from, Map.empty()); + } + + if (!nodes.has(to)) { + nodes = nodes.set(to, Map.empty()); + } + + return new Network( + nodes.set( + from, + nodes + .get(from) + .map((from) => + from.set( + to, + from + .get(to) + .map((edges) => edges.add(edge)) + .getOrElse(() => Set.of(edge)) + ) + ) + .get() + ) + ); + } + + public disconnect(from: N, to: N, edge?: E): Network { + if (!this.has(from) || !this.has(to)) { + return this; + } + + const nodes = this._nodes; + + return new Network( + nodes.set( + from, + nodes + .get(from) + .map((from) => { + if (edge === undefined) { + return from.delete(to); + } + + for (let edges of from.get(to)) { + edges = edges.delete(edge); + + if (edges.size === 0) { + return from.delete(to); + } + + return from.set(to, edges); + } + + return from; + }) + .get() + ) + ); + } + + public traverse( + root: N, + traversal: Network.Traversal = Network.DepthFirst + ): Sequence { + return Sequence.from(traversal(this, root)); + } + + public hasPath(from: N, to: N): boolean { + if (!this.has(from) || !this.has(to)) { + return false; + } + + return this.traverse(from).includes(to); + } + + public equals(value: Network): boolean; + + public equals(value: unknown): value is this; + + public equals(value: unknown): boolean { + return value instanceof Network && value._nodes.equals(this._nodes); + } + + public hash(hash: Hash): void { + this._nodes.hash(hash); + } + + public *iterator(): Iterator<[N, Iterable<[N, Iterable]>]> { + yield* this._nodes; + } + + public [Symbol.iterator](): Iterator<[N, Iterable<[N, Iterable]>]> { + return this.iterator(); + } + + public toArray(): Array<[N, Array<[N, Array]>]> { + return [...this].map(([node, neighbors]) => [ + node, + [...neighbors].map(([node, edges]) => [node, [...edges]]), + ]); + } + + public toGraph(): Graph { + return Graph.from( + Iterable.map(this, ([node, neighbors]) => [ + node, + Iterable.map(neighbors, ([node]) => node), + ]) + ); + } + + public toJSON(): Network.JSON { + return this.toArray().map(([node, neighbors]) => [ + Serializable.toJSON(node), + neighbors.map(([node, edges]) => [ + Serializable.toJSON(node), + edges.map((edge) => Serializable.toJSON(edge)), + ]), + ]); + } + + public toString(): string { + const entries = this.toArray() + .map(([node, edges]) => { + const entries = edges.join(", "); + + return `${node}${entries === "" ? "" : ` => [ ${entries} ]`}`; + }) + .join(", "); + + return `Graph {${entries === "" ? "" : ` ${entries} `}}`; + } +} + +export namespace Network { + export type JSON = Array< + [ + Serializable.ToJSON, + Array<[Serializable.ToJSON, Array>]> + ] + >; + + export function isNetwork( + value: Iterable]>]> + ): value is Network; + + export function isNetwork(value: unknown): value is Network; + + export function isNetwork(value: unknown): value is Network { + return value instanceof Network; + } + + export function from( + iterable: Iterable]>]> + ): Network { + if (isNetwork(iterable)) { + return iterable; + } + + return Network.of( + Map.from( + Iterable.map(iterable, ([node, neighbours]) => [ + node, + Map.from( + Iterable.map(neighbours, ([node, edges]) => [node, Set.from(edges)]) + ), + ]) + ) + ); + } + + export interface Traversal { + (network: Network, root: N): Iterable; + } + + /** + * @see https://en.wikipedia.org/wiki/Depth-first_search + */ + export const DepthFirst: Traversal = function* ( + graph: Network, + root: N + ) { + const stack = [root]; + + let seen = Set.empty(); + + while (stack.length > 0) { + const next = stack.pop()!; + + if (seen.has(next)) { + continue; + } + + yield next; + + seen = seen.add(next); + + for (const [neighbor] of graph.neighbors(next)) { + stack.push(neighbor); + } + } + }; + + /** + * @see https://en.wikipedia.org/wiki/Breadth-first_search + */ + export const BreadthFirst: Traversal = function* ( + graph: Network, + root: N + ) { + const queue = [root]; + + let seen = Set.of(root); + + while (queue.length > 0) { + const next = queue.shift()!; + + yield next; + + for (const [neighbor] of graph.neighbors(next)) { + if (seen.has(neighbor)) { + continue; + } + + seen = seen.add(neighbor); + queue.push(neighbor); + } + } + }; +} diff --git a/packages/alfa-network/tsconfig.json b/packages/alfa-network/tsconfig.json new file mode 100644 index 0000000000..eefcfd5461 --- /dev/null +++ b/packages/alfa-network/tsconfig.json @@ -0,0 +1,34 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "../tsconfig.json", + "files": ["src/index.ts", "src/network.ts"], + "references": [ + { + "path": "../alfa-equatable" + }, + { + "path": "../alfa-graph" + }, + { + "path": "../alfa-hash" + }, + { + "path": "../alfa-iterable" + }, + { + "path": "../alfa-json" + }, + { + "path": "../alfa-map" + }, + { + "path": "../alfa-sequence" + }, + { + "path": "../alfa-set" + }, + { + "path": "../alfa-test" + } + ] +} diff --git a/packages/tsconfig.json b/packages/tsconfig.json index 5462b3039f..a1f5c5f2ce 100644 --- a/packages/tsconfig.json +++ b/packages/tsconfig.json @@ -63,6 +63,7 @@ { "path": "alfa-math" }, { "path": "alfa-media" }, { "path": "alfa-monad" }, + { "path": "alfa-network" }, { "path": "alfa-option" }, { "path": "alfa-parser" }, { "path": "alfa-performance" }, From a2e8d8ff40e11b3a1ddee83d54f334cb69de83c1 Mon Sep 17 00:00:00 2001 From: Kasper Isager Date: Tue, 9 Feb 2021 12:32:31 +0100 Subject: [PATCH 2/5] Add overloads for `Network#disconnect()` --- packages/alfa-network/src/network.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/alfa-network/src/network.ts b/packages/alfa-network/src/network.ts index 288191590d..46491712b7 100644 --- a/packages/alfa-network/src/network.ts +++ b/packages/alfa-network/src/network.ts @@ -95,6 +95,10 @@ export class Network ); } + public disconnect(from: N, to: N): Network; + + public disconnect(from: N, to: N, edge: E): Network; + public disconnect(from: N, to: N, edge?: E): Network { if (!this.has(from) || !this.has(to)) { return this; From 38e28d708e0820903f82c12b08aa57d8e6075d77 Mon Sep 17 00:00:00 2001 From: Kasper Isager Date: Tue, 9 Feb 2021 12:39:42 +0100 Subject: [PATCH 3/5] Correct `Network#toString()` --- packages/alfa-network/src/network.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/alfa-network/src/network.ts b/packages/alfa-network/src/network.ts index 46491712b7..c73cf3b58f 100644 --- a/packages/alfa-network/src/network.ts +++ b/packages/alfa-network/src/network.ts @@ -196,14 +196,20 @@ export class Network public toString(): string { const entries = this.toArray() - .map(([node, edges]) => { - const entries = edges.join(", "); + .map(([node, neighbors]) => { + const entries = neighbors + .map(([node, edges]) => { + const entries = edges.join(", "); + + return `${node}${entries === "" ? "" : ` (${entries})`}`; + }) + .join(", "); return `${node}${entries === "" ? "" : ` => [ ${entries} ]`}`; }) .join(", "); - return `Graph {${entries === "" ? "" : ` ${entries} `}}`; + return `Network {${entries === "" ? "" : ` ${entries} `}}`; } } @@ -234,10 +240,10 @@ export namespace Network { return Network.of( Map.from( - Iterable.map(iterable, ([node, neighbours]) => [ + Iterable.map(iterable, ([node, neighbors]) => [ node, Map.from( - Iterable.map(neighbours, ([node, edges]) => [node, Set.from(edges)]) + Iterable.map(neighbors, ([node, edges]) => [node, Set.from(edges)]) ), ]) ) From cf7ca0d0a27096808fe33550b8fa39cef6352bd3 Mon Sep 17 00:00:00 2001 From: Kasper Isager Date: Tue, 9 Feb 2021 13:23:54 +0100 Subject: [PATCH 4/5] Add tests --- packages/alfa-network/package.json | 2 +- packages/alfa-network/src/network.ts | 8 +- packages/alfa-network/test/network.spec.ts | 241 +++++++++++++++++++++ packages/alfa-network/tsconfig.json | 2 +- 4 files changed, 250 insertions(+), 3 deletions(-) create mode 100644 packages/alfa-network/test/network.spec.ts diff --git a/packages/alfa-network/package.json b/packages/alfa-network/package.json index 69f03b8eaa..cab354fd55 100644 --- a/packages/alfa-network/package.json +++ b/packages/alfa-network/package.json @@ -4,7 +4,7 @@ "homepage": "https://siteimprove.com", "version": "0.10.0", "license": "MIT", - "description": "An implementation of an immutable, directed graph with unique edges", + "description": "An implementation of an immutable, directed graph that allows for multiple, unique edges", "repository": { "type": "git", "url": "https://github.com/siteimprove/alfa.git", diff --git a/packages/alfa-network/src/network.ts b/packages/alfa-network/src/network.ts index c73cf3b58f..5a71c1fe6a 100644 --- a/packages/alfa-network/src/network.ts +++ b/packages/alfa-network/src/network.ts @@ -243,7 +243,13 @@ export namespace Network { Iterable.map(iterable, ([node, neighbors]) => [ node, Map.from( - Iterable.map(neighbors, ([node, edges]) => [node, Set.from(edges)]) + Iterable.flatMap(neighbors, function* ([node, edges]) { + const set = Set.from(edges); + + if (set.size > 0) { + yield [node, set]; + } + }) ), ]) ) diff --git a/packages/alfa-network/test/network.spec.ts b/packages/alfa-network/test/network.spec.ts new file mode 100644 index 0000000000..92dde68817 --- /dev/null +++ b/packages/alfa-network/test/network.spec.ts @@ -0,0 +1,241 @@ +import { test } from "@siteimprove/alfa-test"; + +import { Network } from "../src/network"; + +// foo +// |- 1 => bar +// |- 2 => baz +// |- 3 => foo +// |- ... +const network = Network.from([ + [ + "foo", + [ + ["bar", [1]], + ["baz", [2]], + ], + ], + ["bar", []], + ["baz", [["foo", [3]]]], +]); + +test(".from() excludes connections with no edges", (t) => { + t.deepEqual( + Network.from([["foo", [["bar", []]]]]).toArray(), + [["foo", []]] + ); +}); + +test("#connect() connects two nodes in a network", (t) => { + // foo + // |- 1 => bar + // |- 4 => baz + // |- ... + // |- 2 => baz + // |- 3 => foo + // |- ... + t.deepEqual(network.connect("bar", "baz", 4).toArray(), [ + ["baz", [["foo", [3]]]], + [ + "foo", + [ + ["baz", [2]], + ["bar", [1]], + ], + ], + ["bar", [["baz", [4]]]], + ]); + + // foo + // |- 1 => bar + // |- 2 => baz + // |- 3 => foo + // |- ... + // |- 4 => baz + // |- ... + t.deepEqual(network.connect("foo", "baz", 4).toArray(), [ + ["baz", [["foo", [3]]]], + [ + "foo", + [ + ["baz", [4, 2]], + ["bar", [1]], + ], + ], + ["bar", []], + ]); +}); + +test("#disconnect() disconnects two nodes in a network", (t) => { + // foo + // |- 2 => baz + // |- 3 => foo + // |- ... + // bar + t.deepEqual(network.disconnect("foo", "bar").toArray(), [ + ["baz", [["foo", [3]]]], + ["foo", [["baz", [2]]]], + ["bar", []], + ]); + + // foo + // |- 1 => bar + // + // baz + // |- 3 => foo + // |- ... + t.deepEqual( + network.connect("foo", "baz", 4).disconnect("foo", "baz").toArray(), + [ + ["baz", [["foo", [3]]]], + ["foo", [["bar", [1]]]], + ["bar", []], + ] + ); + + // foo + // |- 1 => bar + // |- 4 => baz + // |- 3 => foo + // |- ... + t.deepEqual( + network.connect("foo", "baz", 4).disconnect("foo", "baz", 2).toArray(), + [ + ["baz", [["foo", [3]]]], + [ + "foo", + [ + ["baz", [4]], + ["bar", [1]], + ], + ], + ["bar", []], + ] + ); +}); + +test("#delete() removes a node from a network", (t) => { + // foo + // |- 2 => baz + // |- 1 => foo + // |- ... + t.deepEqual(network.delete("bar").toArray(), [ + ["baz", [["foo", [3]]]], + ["foo", [["baz", [2]]]], + ]); +}); + +test("#traverse() traverses the subnetwork rooted at a node", (t) => { + t.deepEqual( + [...network.traverse("baz")].sort(), + ["baz", "foo", "bar"].sort() + ); +}); + +test("#traverse() traverses the subnetwork rooted at a node depth-first", (t) => { + // 1 + // |- 2 + // |- 3 + // |- 4 + // |- 5 + // |- 6 + // |- 7 + const network = Network.from([ + [ + 1, + [ + [2, [true]], + [5, [true]], + ], + ], + [ + 2, + [ + [3, [true]], + [4, [true]], + ], + ], + [ + 5, + [ + [6, [true]], + [7, [true]], + ], + ], + ]); + + t.deepEqual( + [...network.traverse(1, Network.DepthFirst)], + [ + 1, // 1 + 5, // |- 5 + 7, // |- 7 + 6, // |- 6 + 2, // |- 2 + 3, // |- 3 + 4, // |- 4 + ] + ); +}); + +test("#traverse() traverses the subnetwork rooted at a node breadth-first", (t) => { + // 1 + // |- 2 + // |- 3 + // |- 4 + // |- 5 + // |- 6 + // |- 7 + const network = Network.from([ + [ + 1, + [ + [2, [true]], + [5, [true]], + ], + ], + [ + 2, + [ + [3, [true]], + [4, [true]], + ], + ], + [ + 5, + [ + [6, [true]], + [7, [true]], + ], + ], + ]); + + t.deepEqual( + [...network.traverse(1, Network.BreadthFirst)], + [ + 1, // 1 + 2, // |- 2 + 5, // |- 5 + 4, // |- 4 + 3, // |- 3 + 6, // |- 6 + 7, // |- 7 + ] + ); +}); + +test("#hasPath() checks if there's a path from one node to another", (t) => { + // foo -> bar + t.equal(network.hasPath("foo", "bar"), true); + + // baz -> foo -> bar + t.equal(network.hasPath("baz", "bar"), true); + + // bar has no neighbors + t.equal(network.hasPath("bar", "baz"), false); + + // A node always has a path to itself + for (const node of ["foo", "bar", "baz"]) { + t.equal(network.hasPath(node, node), true); + } +}); diff --git a/packages/alfa-network/tsconfig.json b/packages/alfa-network/tsconfig.json index eefcfd5461..c4fb59ef86 100644 --- a/packages/alfa-network/tsconfig.json +++ b/packages/alfa-network/tsconfig.json @@ -1,7 +1,7 @@ { "$schema": "http://json.schemastore.org/tsconfig", "extends": "../tsconfig.json", - "files": ["src/index.ts", "src/network.ts"], + "files": ["src/index.ts", "src/network.ts", "test/network.spec.ts"], "references": [ { "path": "../alfa-equatable" From b7e49e43162cdc018265667fb26401d338317e9d Mon Sep 17 00:00:00 2001 From: Kasper Isager Date: Tue, 9 Feb 2021 14:44:23 +0100 Subject: [PATCH 5/5] Allow connecting and disconnecting multiple edges at a time --- packages/alfa-network/src/network.ts | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/alfa-network/src/network.ts b/packages/alfa-network/src/network.ts index 5a71c1fe6a..29cf6f7716 100644 --- a/packages/alfa-network/src/network.ts +++ b/packages/alfa-network/src/network.ts @@ -65,7 +65,9 @@ export class Network ); } - public connect(from: N, to: N, edge: E): Network { + public connect(from: N, to: N, edge: E, ...rest: Array): Network; + + public connect(from: N, to: N, ...edges: Array): Network { let nodes = this._nodes; if (!nodes.has(from)) { @@ -86,8 +88,10 @@ export class Network to, from .get(to) - .map((edges) => edges.add(edge)) - .getOrElse(() => Set.of(edge)) + .map((existing) => + edges.reduce((edges, edge) => edges.add(edge), existing) + ) + .getOrElse(() => Set.from(edges)) ) ) .get() @@ -97,9 +101,9 @@ export class Network public disconnect(from: N, to: N): Network; - public disconnect(from: N, to: N, edge: E): Network; + public disconnect(from: N, to: N, edge: E, ...rest: Array): Network; - public disconnect(from: N, to: N, edge?: E): Network { + public disconnect(from: N, to: N, ...edges: Array): Network { if (!this.has(from) || !this.has(to)) { return this; } @@ -112,18 +116,21 @@ export class Network nodes .get(from) .map((from) => { - if (edge === undefined) { + if (edges.length === 0) { return from.delete(to); } - for (let edges of from.get(to)) { - edges = edges.delete(edge); + for (let existing of from.get(to)) { + existing = edges.reduce( + (edges, edge) => edges.delete(edge), + existing + ); - if (edges.size === 0) { + if (existing.size === 0) { return from.delete(to); } - return from.set(to, edges); + return from.set(to, existing); } return from;