diff --git a/packages/alfa-network/package.json b/packages/alfa-network/package.json new file mode 100644 index 0000000000..cab354fd55 --- /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 that allows for multiple, 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..29cf6f7716 --- /dev/null +++ b/packages/alfa-network/src/network.ts @@ -0,0 +1,324 @@ +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, ...rest: Array): Network; + + public connect(from: N, to: N, ...edges: Array): 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((existing) => + edges.reduce((edges, edge) => edges.add(edge), existing) + ) + .getOrElse(() => Set.from(edges)) + ) + ) + .get() + ) + ); + } + + public disconnect(from: N, to: N): Network; + + public disconnect(from: N, to: N, edge: E, ...rest: Array): Network; + + public disconnect(from: N, to: N, ...edges: Array): 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 (edges.length === 0) { + return from.delete(to); + } + + for (let existing of from.get(to)) { + existing = edges.reduce( + (edges, edge) => edges.delete(edge), + existing + ); + + if (existing.size === 0) { + return from.delete(to); + } + + return from.set(to, existing); + } + + 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, neighbors]) => { + const entries = neighbors + .map(([node, edges]) => { + const entries = edges.join(", "); + + return `${node}${entries === "" ? "" : ` (${entries})`}`; + }) + .join(", "); + + return `${node}${entries === "" ? "" : ` => [ ${entries} ]`}`; + }) + .join(", "); + + return `Network {${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, neighbors]) => [ + node, + Map.from( + Iterable.flatMap(neighbors, function* ([node, edges]) { + const set = Set.from(edges); + + if (set.size > 0) { + yield [node, set]; + } + }) + ), + ]) + ) + ); + } + + 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/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 new file mode 100644 index 0000000000..c4fb59ef86 --- /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", "test/network.spec.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 b5f1397b9c..3449bc36f7 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" },