From 45ff365d88f14b1722786f626319c4d15ccc1a51 Mon Sep 17 00:00:00 2001 From: Joe Hildebrand Date: Mon, 16 Dec 2024 11:04:42 -0700 Subject: [PATCH] Day 16 --- day15.ts | 6 +- day16.peggy | 2 + day16.ts | 175 +++++++++++ deno.jsonc | 2 + inputs | 2 +- lib/astar.ts | 244 ++++++++++++++++ lib/graph.ts | 397 +++++++++++++++++++++++++ lib/pool.ts | 66 +++++ lib/rect.ts | 103 +++++++ lib/test/graph.test.ts | 648 +++++++++++++++++++++++++++++++++++++++++ 10 files changed, 1640 insertions(+), 5 deletions(-) create mode 100644 day16.peggy create mode 100644 day16.ts create mode 100644 lib/astar.ts create mode 100644 lib/graph.ts create mode 100644 lib/pool.ts create mode 100644 lib/test/graph.test.ts diff --git a/day15.ts b/day15.ts index 989203f..bb7a931 100644 --- a/day15.ts +++ b/day15.ts @@ -28,10 +28,9 @@ function moveBigBox( moves: Move[], visited: PointSet, ): boolean { - if (visited.has(p)) { + if (!visited.first(p)) { return true; } - visited.add(p); // p is one end of bigBox. if ((d === Dir.E) || (d === Dir.W)) { const skip = p.inDir(d); @@ -52,10 +51,9 @@ function moveBigBox( } else { const pChar = r.get(p); const o = (pChar === '[') ? p.inDir(Dir.E) : p.inDir(Dir.W); - if (visited.has(o)) { + if (!visited.first(o)) { return true; } - visited.add(o); const nP = p.inDir(d); const nO = o.inDir(d); const nPchar = r.get(nP); diff --git a/day16.peggy b/day16.peggy new file mode 100644 index 0000000..4bc6a97 --- /dev/null +++ b/day16.peggy @@ -0,0 +1,2 @@ +board = (@[#.SE]+ "\n")+ + diff --git a/day16.ts b/day16.ts new file mode 100644 index 0000000..8ffc811 --- /dev/null +++ b/day16.ts @@ -0,0 +1,175 @@ +import { assert } from '@std/assert/assert'; +import { AllDirs, Dir, Point, Rect } from './lib/rect.ts'; +import { type MainArgs, mod, parseFile } from './lib/utils.ts'; +import { BinaryHeap } from '@std/data-structures'; +import { PointSet } from './lib/rect.ts'; + +type Parsed = string[][]; + +// Janky Djykstra without looking up the algorithm. Clean this up later +// and try astar, since we've got a good heuristic (manhattan distance to E). +function part1(inp: Parsed): number { + const r = new Rect(inp); + const start = r.indexOf('S'); + assert(start); + const end = r.indexOf('E'); + assert(end); + + interface PointNode { + id: string; + pos: Point; + dir: Dir; + cost: number; + } + + const backlog = new BinaryHeap( + (a, b) => a.cost - b.cost, + ); + backlog.push({ + id: `${start}-${Dir.E}`, + pos: start, + dir: Dir.E, + cost: 0, + }); + const seen = new Set(); + + while (!backlog.isEmpty()) { + const n = backlog.pop(); + assert(n); + const { id, pos, dir, cost } = n; + if (seen.has(id)) { + continue; + } + seen.add(id); + if (pos.equals(end)) { + return cost; + } + + const f = pos.inDir(dir); + if (r.check(f) && r.get(f) !== '#') { + backlog.push({ + id: `${f}-${dir}`, + pos: f, + dir, + cost: cost + 1, + }); + } + const leftDir = mod(dir - 1, 4); + const left = pos.inDir(leftDir); + if (r.check(left) && r.get(left) !== '#') { + backlog.push({ + id: `${left}-${leftDir}`, + pos: left, + dir: leftDir, + cost: cost + 1001, + }); + } + const rightDir = mod(dir + 1, 4); + const right = pos.inDir(rightDir); + if (r.check(right) && r.get(right) !== '#') { + backlog.push({ + id: `${right}-${rightDir}`, + pos: right, + dir: rightDir, + cost: cost + 1001, + }); + } + } + + return NaN; +} + +function part2(inp: Parsed): number { + const r = new Rect(inp); + const start = r.indexOf('S')!; + const end = r.indexOf('E')!; + + interface Node { + pos: Point; + cost: number; + prev: Set; + } + const points = new Map(); + points.set(`${start}-${Dir.E}`, { pos: start, cost: 0, prev: new Set() }); + for (const d of AllDirs) { + points.set(`${end}-${d}`, { pos: end, cost: Infinity, prev: new Set() }); + } + + interface BackNode { + dir: Dir; + pos: Point; + } + const backlog = new BinaryHeap( + (a, b) => + points.get(`${a.pos}-${a.dir}`)!.cost - + points.get(`${b.pos}-${b.dir}`)!.cost, + ); + backlog.push({ + dir: Dir.E, + pos: start, + }); + + function addDir( + prev: Point, + prevDir: Dir, + pos: Point, + dir: Dir, + cost: number, + newCost: number, + ): void { + if (r.check(pos) && r.get(pos) !== '#') { + const id = `${pos}-${dir}`; + const fc = points.get(id); + if (!fc) { + points.set(id, { + pos, + cost: newCost, + prev: new Set([`${prev}-${prevDir}`]), + }); + backlog.push({ dir, pos }); + } else { + if (newCost < fc.cost) { + fc.cost = cost + 1; + fc.prev = new Set([`${prev}-${prevDir}`]); + backlog.push({ + dir, + pos: pos, + }); + } else if (newCost === fc.cost) { + fc.prev.add(`${prev}-${prevDir}`); + backlog.push({ dir, pos }); + } + } + } + } + + while (!backlog.isEmpty()) { + const n = backlog.pop()!; + const { pos, dir } = n; + const { cost } = points.get(`${pos}-${dir}`)!; + + addDir(pos, dir, pos.inDir(dir), dir, cost, cost + 1); + addDir(pos, dir, pos, mod(dir - 1, 4), cost, cost + 1000); + addDir(pos, dir, pos, mod(dir + 1, 4), cost, cost + 1000); + } + + const seen = new PointSet(); + const reconstruct: string[] = []; + for (const d of AllDirs) { + if (isFinite(points.get(`${end}-${d}`)!.cost)) { + reconstruct.push(`${end}-${d}`); + } + } + while (reconstruct.length) { + const id = reconstruct.shift()!; + const { pos, prev } = points.get(id)!; + seen.add(pos); + reconstruct.push(...prev); + } + return seen.size; +} + +export default async function main(args: MainArgs): Promise<[number, number]> { + const inp = await parseFile(args); + return [part1(inp), part2(inp)]; +} diff --git a/deno.jsonc b/deno.jsonc index 13e3aab..221ad0b 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -15,9 +15,11 @@ "$jar": "https://deno.land/x/another_cookiejar@v5.0.7/mod.ts", "@std/assert": "jsr:@std/assert@^1.0.9", "@std/cli": "jsr:@std/cli@^1.0.8", + "@std/data-structures": "jsr:@std/data-structures@^1.0.4", "@std/fmt": "jsr:@std/fmt@^1.0.3", "@std/path": "jsr:@std/path@^1.0.8", "@std/streams": "jsr:@std/streams@^1.0.8", + "@std/testing": "jsr:@std/testing@^1.0.6", "peggy": "npm:peggy@^4.2.0" }, "fmt": { diff --git a/inputs b/inputs index 993d361..28f0da8 160000 --- a/inputs +++ b/inputs @@ -1 +1 @@ -Subproject commit 993d3614391917a93e903276e3e917e5da526fbc +Subproject commit 28f0da86ce9cb270ec57446b61411a3b9382f63b diff --git a/lib/astar.ts b/lib/astar.ts new file mode 100644 index 0000000..2106cdc --- /dev/null +++ b/lib/astar.ts @@ -0,0 +1,244 @@ +/** + * Performs a uni-directional A Star search on graph. + * + * We will try to minimize f(n) = g(n) + h(n), where + * g(n) is actual distance from source node to `n`, and + * h(n) is heuristic distance from `n` to target node. + */ +import { BinaryHeap } from '@std/data-structures'; +import type { Graph, Link, Node } from './graph.ts'; +import { type NodeSearchState, StatePool } from './pool.ts'; + +// var defaultSettings = require('./defaultSettings.js'); + +export const NO_PATH = Symbol('NO_PATH'); +export { type NodeSearchState } from './pool.ts'; + +// module.exports.l2 = heuristics.l2; +// module.exports.l1 = heuristics.l1; + +export interface AstarOptions< + NodeData, + LinkData, + NodeId extends string | number, +> { + blocked?: ( + a: Node, + b: Node, + l: Link, + ) => boolean; + blockedPath?: ( + cameFrom: NodeSearchState, + state: NodeSearchState, + l: Link, + ) => boolean; + heuristic?: ( + a: Node, + b: Node, + ) => number; + distance?: ( + a: Node, + b: Node, + link: Link, + ) => number; + compare?: ( + a: NodeSearchState, + b: NodeSearchState, + ) => number; + oriented?: boolean; +} + +/** + * Creates a new instance of pathfinder. + * + * @param graph instance. See https://github.com/anvaka/ngraph.graph + * @param options configures search + */ +export class Astar { + #opts: Required>; + #graph: Graph; + #pool: StatePool; + + constructor( + graph: Graph, + options: AstarOptions = {}, + ) { + this.#graph = graph; + this.#opts = { + blocked: () => false, + blockedPath: () => false, + heuristic: () => 0, + distance: () => 1, + compare: (a, b) => a.fScore - b.fScore, + oriented: false, + ...options, + }; + this.#pool = new StatePool(); + } + + find( + fromId: NodeId, + toId: NodeId, + ): typeof NO_PATH | Node[] { + const from = this.#graph.getNode(fromId); + if (!from) { + throw new Error(`${fromId} is not defined in this graph`); + } + const to = this.#graph.getNode(toId); + if (!to) { + throw new Error(`${toId} is not defined in this graph`); + } + this.#pool.reset(); + + // Maps nodeId to NodeSearchState. + const nodeState = new Map< + NodeId, + NodeSearchState + >(); + + // the nodes that we still need to evaluate + const openSet = new BinaryHeap(this.#opts.compare); + + const startNode = this.#pool.newState(from); + nodeState.set(fromId, startNode); + + // For the first node, fScore is completely heuristic. + startNode.fScore = this.#opts.heuristic(from, to); + + // The cost of going from start to start is zero. + startNode.distanceToSource = 0; + openSet.push(startNode); + startNode.open = 1; + + let cameFrom: NodeSearchState | undefined = + undefined; + + while (openSet.length > 0) { + // console.log(openSet.toArray().map((o) => o.fScore)); + cameFrom = openSet.pop(); + if (!cameFrom) { + throw new Error('Where we came from?'); + } + + if (goalReached(cameFrom, to)) { + return reconstructPath(cameFrom); + } + + // no need to visit this node anymore + cameFrom.closed = true; + for ( + const [otherNode, link] of this.#graph.linkedNodes( + cameFrom.node.id, + this.#opts.oriented, + ) + ) { + let otherSearchState = nodeState.get(otherNode.id); + if (!otherSearchState) { + otherSearchState = this.#pool.newState(otherNode); + nodeState.set(otherNode.id, otherSearchState); + } + + if (otherSearchState.closed) { + // Already processed this node. + continue; + } + if (this.#opts.blocked(otherNode, cameFrom.node, link)) { + // Path is blocked. Ignore this route + continue; + } + + if (this.#opts.blockedPath(cameFrom, otherSearchState, link)) { + // Search state path is blocked + console.log( + 'blocked', + cameFrom.parent?.node.id, + '->', + cameFrom.node.id, + '->', + otherSearchState.node.id, + ); + continue; + } + + // if (otherSearchState.open === 0) { + // // Remember this node. + // openSet.push(otherSearchState); + // otherSearchState.open = 1; + // } + + const tentativeDistance = cameFrom.distanceToSource + + this.#opts.distance(otherNode, cameFrom.node, link); + if (tentativeDistance >= otherSearchState.distanceToSource) { + // This would only make our path longer. Ignore this route. + continue; + } + + // bingo! we found shorter path: + otherSearchState.parent = cameFrom; + otherSearchState.distanceToSource = tentativeDistance; + otherSearchState.fScore = tentativeDistance + + this.#opts.heuristic(otherSearchState.node, to); + + // console.log( + // cameFrom.node.id, + // '->', + // otherSearchState.node.id, + // tentativeDistance, + // ); + // What's needed is to re-heapify, starting with otherSearchState. + // For now, just re-push this on, and it will end up farther up the + // tree. We may re-processes it later, but IIRC that's ok. + // openSet.updateItem(otherSearchState.heapIndex); + openSet.push(otherSearchState); + } + } + + // If we got here, then there is no path. + return NO_PATH; + } +} + +// export function aStarPathSearch(graph, options) { +// options = options || {}; +// // whether traversal should be considered over oriented graph. +// var oriented = options.oriented; + +// var blocked = options.blocked; +// if (!blocked) blocked = defaultSettings.blocked; + +// var heuristic = options.heuristic; +// if (!heuristic) heuristic = defaultSettings.heuristic; + +// var distance = options.distance; +// if (!distance) distance = defaultSettings.distance; +// var pool = makeSearchStatePool(); + +// return { +// /** +// * Finds a path between node `fromId` and `toId`. +// * @returns {Array} of nodes between `toId` and `fromId`. Empty array is returned +// * if no path is found. +// */ +// find: find, +// }; + +function goalReached( + searchState: NodeSearchState, + targetNode: Node, +): boolean { + return searchState.node === targetNode; +} + +function reconstructPath( + searchState: NodeSearchState, +): Node[] { + const path = [searchState.node]; + let parent = searchState.parent; + + while (parent) { + path.push(parent.node); + parent = parent.parent; + } + + return path; +} diff --git a/lib/graph.ts b/lib/graph.ts new file mode 100644 index 0000000..7affe69 --- /dev/null +++ b/lib/graph.ts @@ -0,0 +1,397 @@ +/** + * @example + * import {Graph} from './graph.ts'; + * graph.addNode(1); // graph has one node. + * graph.addLink(2, 3); // now graph contains three nodes and one link. + */ +import { EventEmitter } from '$event'; + +export interface GraphOptions { + multigraph?: boolean; +} + +type ChangeType = 'add' | 'update' | 'remove'; + +interface NodeChange< + NodeData, + LinkData, + NodeId extends string | number = string, +> { + type: 'node'; + changeType: ChangeType; + node: Node; +} + +interface LinkChange { + type: 'link'; + changeType: ChangeType; + link: Link; +} + +export type Change< + NodeData, + LinkData, + NodeId extends string | number = string, +> = + | NodeChange + | LinkChange; + +type Events = { + changed: [Change[]]; +}; + +export class Node { + id: NodeId; + links: Set> | undefined = undefined; + data: NodeData | undefined; + + constructor(id: NodeId, data?: NodeData) { + this.id = id; + this.data = data; + } + + addLink(link: Link): void { + if (this.links) { + this.links.add(link); + } else { + this.links = new Set([link]); + } + } +} + +export class Link { + fromId: NodeId; + toId: NodeId; + data: LinkData | undefined; + id: string; + + constructor(fromId: NodeId, toId: NodeId, data?: LinkData, id?: string) { + this.fromId = fromId; + this.toId = toId; + this.data = data; + this.id = id ?? Link.makeId(fromId, toId); + } + + static makeId( + fromId: NodeId, + toId: NodeId, + ): string { + return `${fromId}👉${toId}`; + } +} + +type LinkedNodes = + [ + from: Node, + link: Link, + to: Node, + ]; + +export class Graph + extends EventEmitter> { + #opts: Required; + #nodes = new Map>(); + #links = new Map>(); + #multiEdges: Record = {}; + #suspendEvents = 0; + #listening = false; + #changes: Change[] = []; + + constructor(options = {}) { + super(); + this.#opts = { + multigraph: false, + ...options, + }; + } + + #enterModification(): void { + this.#suspendEvents += 1; + } + + #exitModification(): void { + this.#suspendEvents -= 1; + if (this.#suspendEvents === 0 && this.#changes.length > 0) { + this.emit('changed', this.#changes); + this.#changes.length = 0; + } + } + + #recordNodeChange( + node: Node, + changeType: ChangeType, + ): void { + if (this.#listening) { + this.#changes.push({ + type: 'node', + changeType, + node, + }); + } + } + + #recordLinkChange( + link: Link, + changeType: ChangeType, + ): void { + if (this.#listening) { + this.#changes.push({ + type: 'link', + changeType, + link, + }); + } + } + + get nodeCount(): number { + return this.#nodes.size; + } + + get linkCount(): number { + return this.#links.size; + } + + getNode(nodeId: NodeId): Node | undefined { + return this.#nodes.get(nodeId); + } + + addNode(nodeId: NodeId, data?: NodeData): Node { + this.#enterModification(); + + let node = this.getNode(nodeId); + if (node) { + node.data = data; + this.#recordNodeChange(node, 'update'); + } else { + node = new Node(nodeId, data); + this.#recordNodeChange(node, 'add'); + this.#nodes.set(nodeId, node); + } + + this.#exitModification(); + return node; + } + + removeNode(nodeId: NodeId): boolean { + const node = this.getNode(nodeId); + if (!node) { + return false; + } + + this.#enterModification(); + + const prevLinks = node.links; + if (prevLinks) { + prevLinks.forEach((l) => this.removeLinkInstance(l)); + node.links = undefined; + } + + this.#nodes.delete(nodeId); + this.#recordNodeChange(node, 'remove'); + this.#exitModification(); + + return true; + } + + removeLinkInstance(link: Link): boolean { + if (!link || !this.#links.get(link.id)) { + return false; + } + + this.#enterModification(); + + this.#links.delete(link.id); + + this.getNode(link.fromId)?.links?.delete(link); + this.getNode(link.toId)?.links?.delete(link); + + this.#recordLinkChange(link, 'remove'); + this.#exitModification(); + + return true; + } + + addLink( + fromId: NodeId, + toId: NodeId, + data?: LinkData, + ): Link { + this.#enterModification(); + + const fromNode = this.getNode(fromId) ?? this.addNode(fromId); + const toNode = this.getNode(toId) ?? this.addNode(toId); + + const link = this.createLink(fromId, toId, data); + const isUpdate = this.#links.has(link.id); + this.#links.set(link.id, link); + + // TODO(upstream): this is not cool. On large graphs potentially would + // consume more memory. + + // TODO(hildjj) Urgent: links are in set, not IDs + fromNode.addLink(link); + if (fromId !== toId) { + // make sure we are not duplicating links for self-loops + toNode.addLink(link); + } + + this.#recordLinkChange(link, isUpdate ? 'update' : 'add'); + this.#exitModification(); + return link; + } + + #createSingleLink( + fromId: NodeId, + toId: NodeId, + data?: LinkData, + ): Link { + const linkId = Link.makeId(fromId, toId); + const prevLink = this.#links.get(linkId); + if (prevLink) { + prevLink.data = data; + return prevLink; + } + + return new Link(fromId, toId, data, linkId); + } + + #createUniqueLink( + fromId: NodeId, + toId: NodeId, + data?: LinkData, + ): Link { + // TODO(upstream): Find a better/faster way to store multigraphs + let linkId = Link.makeId(fromId, toId); + const isMultiEdge = Object.hasOwn(this.#multiEdges, linkId); + if (isMultiEdge || this.getLink(fromId, toId)) { + if (!isMultiEdge) { + this.#multiEdges[linkId] = 0; + } + const suffix = '@' + (++this.#multiEdges[linkId]); + linkId = Link.makeId(fromId + suffix, toId + suffix); + } + + return new Link(fromId, toId, data, linkId); + } + + getLink( + fromNodeId: NodeId, + toNodeId: NodeId, + ): Link | undefined { + if (!fromNodeId || !toNodeId) { + return undefined; + } + return this.#links.get(Link.makeId(fromNodeId, toNodeId)); + } + + *getLinks( + nodeId: NodeId, + ): Generator, undefined, undefined> { + const vals = this.getNode(nodeId)?.links?.values(); + if (vals) { + yield* vals; + } + } + + removeLink(link: Link): boolean; + removeLink(fromNodeId: NodeId, toNodeId: NodeId): boolean; + removeLink( + fromNodeIdOrLink: NodeId | Link, + toNodeId?: NodeId, + ): boolean { + const link = (fromNodeIdOrLink instanceof Link) + ? fromNodeIdOrLink + : this.getLink(fromNodeIdOrLink, toNodeId!); + if (!link) { + throw new Error('Unknown link'); + } + return this.removeLinkInstance(link); + } + + override on( + eventName: K, + listener: (...args: Events[K]) => void, + ): this; + override on( + eventName: K, + ): AsyncIterableIterator[K]>; + override on>( + eventName: K, + listener?: + | ((...args: Events[K]) => void) + | undefined, + ): this | AsyncIterableIterator[K]> { + this.#listening = true; + if (listener) { + return super.on(eventName, listener); + } + return super.on(eventName); + } + + *nodes(): Generator, undefined, undefined> { + yield* this.#nodes.values(); + } + + *links(): Generator, undefined, undefined> { + yield* this.#links.values(); + } + + *linkedNodes( + nodeId: NodeId | Node, + oriented = false, + ): Generator, undefined, undefined> { + const node = (nodeId instanceof Node) ? nodeId : this.getNode(nodeId); + if (node?.links) { + if (oriented) { + yield* this.#orientedLinks(node); + } else { + yield* this.#nonOrientedLinks(node); + } + } + } + + *#nonOrientedLinks( + node: Node, + ): Generator, undefined, undefined> { + for (const link of node.links!.values()) { + const linkedNodeId = link.fromId === node.id ? link.toId : link.fromId; + const otherNode = this.getNode(linkedNodeId); + if (!otherNode) { + throw new Error('Invalid link'); + } + yield [node, link, otherNode]; + } + } + + *#orientedLinks( + node: Node, + ): Generator, undefined, undefined> { + for (const link of node.links!.values()) { + if (link.fromId === node.id) { + const otherNode = this.getNode(link.toId); + if (!otherNode) { + throw new Error('Invalid node state'); + } + yield [node, link, otherNode]; + } + } + } + + clear(): void { + this.#enterModification(); + for (const node of this.nodes()) { + this.removeNode(node.id); + } + this.#exitModification(); + } + + createLink( + fromId: NodeId, + toId: NodeId, + data?: LinkData, + ): Link { + return this.#opts.multigraph + ? this.#createUniqueLink(fromId, toId, data) + : this.#createSingleLink(fromId, toId, data); + } +} diff --git a/lib/pool.ts b/lib/pool.ts new file mode 100644 index 0000000..4b96818 --- /dev/null +++ b/lib/pool.ts @@ -0,0 +1,66 @@ +import { type Node } from './graph.ts'; + +/** + * This class represents a single search node in the exploration tree for + * A* algorithm. + */ +export class NodeSearchState< + NodeData, + LinkData, + NodeId extends string | number = string, +> { + node: Node; + parent: NodeSearchState | null = null; + closed = false; + open = 0; + distanceToSource = Infinity; + fScore = Infinity; + heapIndex = -1; + + constructor(node: Node) { + this.node = node; + } +} + +export class StatePool< + NodeData, + LinkData, + NodeId extends string | number = string, +> { + #currentInCache = 0; + #nodeCache: NodeSearchState< + NodeData, + LinkData, + NodeId + >[] = []; + + reset(): void { + this.#currentInCache = 0; + } + + newState( + node: Node, + ): NodeSearchState { + let cached = this.#nodeCache[this.#currentInCache]; + if (cached) { + cached.node = node; + // How we came to this node? + cached.parent = null; + + cached.closed = false; + cached.open = 0; + + cached.distanceToSource = Infinity; + // the f(n) = g(n) + h(n) value + cached.fScore = Infinity; + + // used to reconstruct heap when fScore is updated. + cached.heapIndex = -1; + } else { + cached = new NodeSearchState(node); + this.#nodeCache[this.#currentInCache] = cached; + } + this.#currentInCache++; + return cached; + } +} diff --git a/lib/rect.ts b/lib/rect.ts index 7c62d08..74281e8 100644 --- a/lib/rect.ts +++ b/lib/rect.ts @@ -702,6 +702,21 @@ export class PointSet { return this.#set.has(value.toNumber()); } + /** + * Is this sthe first time this point has been seen? + * Adds the point if so. + * + * @param value + */ + first(value: Point): boolean { + const n = value.toNumber(); + if (this.#set.has(n)) { + return false; + } + this.#set.add(n); + return true; + } + /** * @returns the number of (unique) elements in Set. */ @@ -808,6 +823,13 @@ export class PointSet { return this.#set.isDisjointFrom(other.#set); } + withAdded(value: Point): PointSet { + const n = new PointSet(); + n.#set = new Set(this.#set); + n.add(value); + return n; + } + [Symbol.for('Deno.customInspect')](): string { let ret = `PointSet(${this.size}) { `; let first = true; @@ -826,3 +848,84 @@ export class PointSet { return ret; } } + +export class PointMap implements Map { + #map = new Map(); + + constructor(iterable?: Iterable | null) { + if (iterable) { + for (const [p, v] of iterable) { + this.#map.set(p.toNumber(), v); + } + } + } + + set(p: Point, v: T): this { + this.#map.set(p.toNumber(), v); + return this; + } + + get(p: Point): T | undefined { + return this.#map.get(p.toNumber()); + } + + clear(): void { + this.#map.clear(); + } + + forEach( + callbackfn: (value: T, key: Point, map: Map) => void, + thisArg?: unknown, + ): void { + // deno-lint-ignore no-this-alias + const m = this; + this.#map.forEach( + function (v, p): void { + callbackfn(v, Point.fromNumber(p), m as unknown as Map); + }, + thisArg, + ); + } + + /** + * @returns true if an element in the Map existed and has been removed, or + * false if the element does not exist. + */ + delete(key: Point): boolean { + return this.#map.delete(key.toNumber()); + } + + has(key: Point): boolean { + return this.#map.has(key.toNumber()); + } + + get size(): number { + return this.#map.size; + } + + *keys(): MapIterator { + for (const k of this.#map.keys()) { + yield Point.fromNumber(k); + } + } + + *entries(): MapIterator<[Point, T]> { + for (const [k, v] of this.#map.entries()) { + yield [Point.fromNumber(k), v]; + } + } + + values(): MapIterator { + return this.#map.values(); + } + + *[Symbol.iterator](): MapIterator<[Point, T]> { + for (const [k, v] of this.#map) { + yield [Point.fromNumber(k), v]; + } + } + + get [Symbol.toStringTag](): string { + return 'PointSet'; + } +} diff --git a/lib/test/graph.test.ts b/lib/test/graph.test.ts new file mode 100644 index 0000000..c8b3f47 --- /dev/null +++ b/lib/test/graph.test.ts @@ -0,0 +1,648 @@ +import { type Change, Graph, Link, Node } from '../graph.ts'; +import { assert, assertEquals, assertFalse, assertThrows } from '@std/assert'; +import { assertSpyCalls, spy } from '@std/testing/mock'; + +function dfs( + graph: Graph, + startFromNodeId: NodeId, + visitor: (node: Node) => boolean, +): void { + const queue: NodeId[] = [startFromNodeId]; + while (queue.length) { + const nodeId = queue.pop()!; + for (const [_node, _link, otherNode] of graph.linkedNodes(nodeId, true)) { + if (visitor(otherNode)) { + queue.push(otherNode.id); + } else { + break; + } + } + } +} + +function hasCycles( + graph: Graph, +): boolean { + let hasCycle = false; + for (const node of graph.nodes()) { + const visited = new Set(); + + if (hasCycle || visited.has(node.id)) { + break; + } + + dfs(graph, node.id, (otherNode) => { + if (visited.has(otherNode.id)) { + hasCycle = true; + return false; + } + + visited.add(otherNode.id); + return true; + }); + } + + return hasCycle; +} + +Deno.test('Graph', async (t) => { + await t.step('construction', async (t) => { + await t.step('add node adds node', () => { + const graph = new Graph(); + const customData = '31337'; + const node = graph.addNode(1, customData); + + assertEquals(graph.nodeCount, 1); + assertEquals(graph.linkCount, 0); + assertEquals(graph.getNode(1), node); + assertEquals(node.data, customData); + assertEquals(node.id, 1); + }); + + await t.step('hasNode checks node', () => { + const graph = new Graph(); + + graph.addNode(1); + + assert(graph.getNode(1)); + assert(!graph.getNode(2)); + }); + + await t.step('hasLink checks links', () => { + const graph = new Graph(); + graph.addLink(1, 2); + const link12 = graph.getLink(1, 2); + assertEquals(link12?.fromId, 1); + assertEquals(link12?.toId, 2); + + assert(graph.addLink(2, 3)); + + // this is somewhat doubtful... has link will return null, but forEachLinkedNode + // will actually iterate over this link. Not sure how to have consistency here + // for now documenting behavior in the test: + assert(!graph.getLink(2, 1)); + assert(!graph.getLink(1, null!)); + assert(!graph.getLink(null!, 2)); + assert(!graph.getLink(null!, null!)); + }); + + await t.step('it fires update event when node is updated', () => { + function checkChangedEvent( + changes: Change[], + ): void { + assertEquals(changes.length, 1); + const [change] = changes; + assert(change.type === 'node'); // ts doesn't understand assertEquals + assertEquals(change.node.id, 1); + assertEquals(change.node.data, 'world'); + assertEquals(change.changeType, 'update'); + } + + const graph = new Graph(); + graph.addNode(1, 'hello'); + const mockCheck = spy(checkChangedEvent); + graph.on('changed', mockCheck); + graph.addNode(1, 'world'); + assertSpyCalls(mockCheck, 1); + }); + + // await t.step('it does async iteration over events', async () => { + // const graph = new Graph(); + // graph.addNode(1, 'hello'); + + // async function waitOne(): void { + // for await (const changes of graph.on('changed')) { + // assertEquals(changes.length, 1); + // const [change] = changes; + // assert(change.type === 'node'); + // assertEquals(change.node.id, 1); + // assertEquals(change.node.data, 'world'); + // assertEquals(change.changeType, 'update'); + // return; + // } + // } + // const p = new Promise(async (resolve, reject) => { + // }); + // graph.on('changed', mockCheck); + // graph.addNode(1, 'world'); + // assertSpyCalls(mockCheck, 1); + // }); + + await t.step( + 'it can add node with id similar to reserved prototype property', + () => { + const graph = new Graph(); + graph.addNode('constructor'); + graph.addLink('watch', 'constructor'); + + let iterated = 0; + for (const _ of graph.nodes()) { + iterated += 1; + } + + assert(graph.getLink('watch', 'constructor')); + assertEquals(graph.linkCount, 1, 'one link'); + assertEquals(iterated, 2, 'has two nodes'); + }, + ); + + await t.step('add link adds link', () => { + const graph = new Graph(); + + const link = graph.addLink(1, 2); + + assertEquals(graph.nodeCount, 2, 'Two nodes'); + assertEquals(graph.linkCount, 1, 'One link'); + assertEquals( + [...graph.getLinks(1)].length, + 1, + 'number of links of the first node is wrong', + ); + assertEquals( + [...graph.getLinks(2)].length, + 1, + 'number of links of the second node is wrong', + ); + assertEquals( + link, + Array.from(graph.getLinks(1))[0], + 'invalid link in the first node', + ); + assertEquals( + link, + Array.from(graph.getLinks(2))[0], + 'invalid link in the second node', + ); + }); + + await t.step('it can add multi-edges', () => { + const graph = new Graph({ multigraph: true }); + graph.addLink(1, 2, 'first'); + graph.addLink(1, 2, 'second'); + graph.addLink(1, 2, 'third'); + + assertEquals(graph.linkCount, 3, 'three links!'); + assertEquals(graph.nodeCount, 2, 'Two nodes'); + + for (const [_otherNode, link] of graph.linkedNodes(1)) { + assert( + link.data === 'first' || + link.data === 'second' || + link.data === 'third', + 'Link is here', + ); + } + }); + + await t.step('it can produce unique link ids', async (t) => { + // eslint-disable-next-line no-shadow + await t.step('by default links are de-duped', () => { + const seen: Record = {}; + const graph = new Graph(); + graph.addLink(1, 2, 'first'); + graph.addLink(1, 2, 'second'); + graph.addLink(1, 2, 'third'); + + for (const link of graph.links()) { + seen[link.id] = (seen[link.id] || 0) + 1; + } + + const link = graph.getLink(1, 2); + assert(link); + assertEquals(seen[link.id], 1, 'Link 1->2 seen 1 time'); + assertEquals(link.data, 'third', 'Last link wins'); + }); + + await t.step('You can create multigraph', () => { + const graph = new Graph({ + multigraph: true, + }); + + const seen = new Set(); + graph.addLink(1, 2, 'first'); + graph.addLink(1, 2, 'second'); + graph.addLink(1, 2, 'third'); + for (const link of graph.links()) { + assert(!seen.has(link.id), link.id + ' is unique'); + seen.add(link.id); + } + + assertEquals(graph.linkCount, 3, 'All three links are here'); + }); + }); + + await t.step('add one node fires changed event', () => { + const graph = new Graph(); + const testNodeId = 'hello world'; + + function changeEvent(changes: Change[]): void { + assert(changes?.length === 1, 'Only one change should be recorded'); + assertEquals(changes[0].type, 'node'); + if (changes[0].type === 'node') { + assertEquals( + changes[0].node.id, + testNodeId, + 'Wrong node change notification', + ); + } + assertEquals(changes[0].changeType, 'add', 'Add change type expected.'); + } + const changeEventSpy = spy(changeEvent); + + graph.on('changed', changeEventSpy); + + graph.addNode(testNodeId); + assertSpyCalls(changeEventSpy, 1); + }); + + await t.step('add link fires changed event', () => { + const graph = new Graph(); + const fromId = 1; + const toId = 2; + + function changeEvent(changes: Change[]): void { + assert( + changes?.length === 3, + 'Three change should be recorded: node, node and link', + ); + assertEquals(changes[2].type, 'link'); + if (changes[2].type === 'link') { + assertEquals(changes[2].link.fromId, fromId, 'Wrong link from Id'); + assertEquals(changes[2].link.toId, toId, 'Wrong link toId'); + assertEquals( + changes[2].changeType, + 'add', + 'Add change type expected.', + ); + } + } + + const changeEventSpy = spy(changeEvent); + graph.on('changed', changeEventSpy); + + graph.addLink(fromId, toId); + assertSpyCalls(changeEventSpy, 1); + }); + + await t.step('remove isolated node remove it', () => { + const graph = new Graph(); + + graph.addNode(1); + graph.removeNode(1); + + assertEquals(graph.nodeCount, 0, 'Remove operation failed'); + }); + + await t.step('supports plural methods', () => { + const graph = new Graph(); + + graph.addLink(1, 2); + + assertEquals(graph.nodeCount, 2, 'two nodes are there'); + assertEquals(graph.linkCount, 1, 'two nodes are there'); + }); + + await t.step('remove link removes it', () => { + const graph = new Graph(); + const link = graph.addLink(1, 2); + + const linkIsRemoved = graph.removeLink(link); + assert(linkIsRemoved, 'Link removal is successful'); + + assertEquals(graph.nodeCount, 2, 'remove link should not remove nodes'); + assertEquals(graph.linkCount, 0, 'No Links'); + assertEquals( + [...graph.getLinks(1)].length, + 0, + 'link should be removed from the first node', + ); + assertEquals( + [...graph.getLinks(2)].length, + 0, + 'link should be removed from the second node', + ); + + for (const _link of graph.links()) { + assert(false, 'No links should be in graph'); + } + }); + + await t.step('it can remove link by from/to ids', () => { + const graph = new Graph(); + graph.addLink(1, 2); + + const linkIsRemoved = graph.removeLink(1, 2); + assert(linkIsRemoved, 'Link removal is successful'); + + assertEquals(graph.nodeCount, 2, 'remove link should not remove nodes'); + assertEquals(graph.linkCount, 0, 'No Links'); + assertEquals( + [...graph.getLinks(1)].length, + 0, + 'link should be removed from the first node', + ); + assertEquals( + [...graph.getLinks(2)].length, + 0, + 'link should be removed from the second node', + ); + + for (const _link of graph.links()) { + assert(false, 'No links should be in graph'); + } + }); + + await t.step('remove link returns false if no link removed', () => { + const graph = new Graph(); + + graph.addLink(1, 2); + assertThrows(() => graph.removeLink(3, undefined!)); + assertThrows(() => graph.removeLink(undefined!)); + }); + + await t.step('remove isolated node fires changed event', () => { + const graph = new Graph(); + graph.addNode(1); + + function changeEvent(changes: Change[]): void { + assert( + changes?.length === 1, + 'One change should be recorded: node removed', + ); + assertEquals(changes[0].type, 'node'); + if (changes[0].type === 'node') { + assertEquals(changes[0].node.id, 1, 'Wrong node Id'); + assertEquals( + changes[0].changeType, + 'remove', + "'remove' change type expected.", + ); + } + } + const changeEventSpy = spy(changeEvent); + graph.on('changed', changeEventSpy); + + const result = graph.removeNode(1); + assert(result, 'node is removed'); + assertSpyCalls(changeEventSpy, 1); + }); + + await t.step('remove link fires changed event', () => { + const graph = new Graph(); + const link = graph.addLink(1, 2); + + function changeEvent(changes: Change[]): void { + assert( + changes?.length === 1, + 'One change should be recorded: link removed', + ); + assertEquals(changes[0].type, 'link'); + if (changes[0].type === 'link') { + assertEquals(changes[0].link, link, 'Wrong link removed'); + assertEquals( + changes[0].changeType, + 'remove', + "'remove' change type expected.", + ); + } + } + const changeEventSpy = spy(changeEvent); + graph.on('changed', changeEventSpy); + + graph.removeLink(link); + assertSpyCalls(changeEventSpy, 1); + }); + + await t.step('remove linked node fires changed event', () => { + const graph = new Graph(); + const link = graph.addLink(1, 2); + const nodeIdToRemove = 1; + + function changeEvent(changes: Change[]): void { + assert( + changes && changes.length === 2, + 'Two changes should be recorded: link and node removed', + ); + assertEquals(changes[0].type, 'link'); + if (changes[0].type === 'link') { + assertEquals(changes[0].link, link, 'Wrong link removed'); + assertEquals( + changes[0].changeType, + 'remove', + "'remove' change type expected.", + ); + } + assertEquals(changes[1].type, 'node'); + if (changes[1].type === 'node') { + assertEquals( + changes[1].node.id, + nodeIdToRemove, + 'Wrong node removed', + ); + assertEquals( + changes[1].changeType, + 'remove', + "'remove' change type expected.", + ); + } + } + const changeEventSpy = spy(changeEvent); + graph.on('changed', changeEventSpy); + + graph.removeNode(nodeIdToRemove); + assertSpyCalls(changeEventSpy, 1); + }); + + await t.step('remove node with many links removes them all', () => { + const graph = new Graph(); + graph.addLink(1, 2); + graph.addLink(1, 3); + + graph.removeNode(1); + + assertEquals( + graph.nodeCount, + 2, + 'remove link should remove one node only', + ); + assertEquals( + [...graph.getLinks(1)].length, + 0, + 'link should be removed from the first node', + ); + assertEquals( + [...graph.getLinks(2)].length, + 0, + 'link should be removed from the second node', + ); + assertEquals( + [...graph.getLinks(3)].length, + 0, + 'link should be removed from the third node', + ); + for (const _link of graph.links()) { + assert(false, 'No links should be in graph'); + } + }); + + await t.step('remove node returns false when no node removed', () => { + const graph = new Graph(); + graph.addNode('hello'); + const result = graph.removeNode('blah'); + assertFalse(result, 'No "blah" node'); + }); + + await t.step('clearGraph clears graph', () => { + const graph = new Graph(); + /** @ts-ignore */ + graph.addNode('hello'); + graph.addLink(1, 2); + graph.clear(); + + assertEquals(graph.nodeCount, 0, 'No nodes'); + assertEquals(graph.nodeCount, 0, 'No links'); + }); + + // await t.step('beginUpdate holds events', () => { + // const graph = new Graph(); + // let changedCount = 0; + // graph.on('changed', () => { + // changedCount += 1; + // }); + // graph.beginUpdate(); + // graph.addNode(1); + // assertEquals( + // changedCount, + // 0, + // 'Begin update freezes updates until `endUpdate()`', + // ); + // graph.endUpdate(); + // assertEquals(changedCount, 1, 'event is fired only after endUpdate()'); + // }); + }); + + await t.step('hasCycles', async (t) => { + await t.step('can detect cycles loops', () => { + // our graph has three components + const graph = new Graph(); + graph.addLink(1, 2); + graph.addLink(2, 3); + + graph.addLink(5, 6); + graph.addNode(8); + // let's add loop: + graph.addLink(9, 9); + + // lets verify it: + assert(hasCycles(graph), 'cycle found'); + }); + + await t.step('can detect simple cycles', () => { + const graph = new Graph(); + graph.addLink(1, 2); + graph.addLink(2, 3); + graph.addLink(3, 6); + graph.addLink(6, 1); + + // lets verify it: + assert(hasCycles(graph), 'cycle found'); + }); + + await t.step('can detect when no cycles', () => { + const graph = new Graph(); + graph.addLink(1, 2); + graph.addLink(2, 3); + graph.addLink(3, 6); + + assertFalse(hasCycles(graph), 'cycle should not be found'); + }); + }); + + await t.step('iteration', async (t) => { + await t.step('forEachLinkedNode respects orientation', () => { + const graph = new Graph(); + graph.addLink(1, 2); + graph.addLink(2, 3); + const oriented = true; + for (const [_node, link] of graph.linkedNodes(2, oriented)) { + assertEquals( + link.toId, + 3, + 'Only 3 is connected to node 2, when traversal is oriented', + ); + } + for (const [_node, link] of graph.linkedNodes(2, !oriented)) { + assert( + link.toId === 3 || link.toId === 2, + 'both incoming and outgoing links are visited', + ); + } + }); + + await t.step('forEachLinkedNode handles self-loops', () => { + const graph = new Graph(); + graph.addLink(1, 1); + // we should visit exactly one time + for (const [_node, link] of graph.linkedNodes(1)) { + assert(link.fromId === 1 && link.toId === 1, 'Link 1 is visited once'); + } + }); + + await t.step('forEachLinkedNode will not crash on invalid node id', () => { + const graph = new Graph(); + graph.addLink(1, 2); + for (const _ of graph.linkedNodes(3)) { + assert(false, 'Should never be executed'); + } + }); + + await t.step('forEachLinkedNode can quit fast for oriented graphs', () => { + const graph = new Graph(); + const oriented = true; + graph.addLink(1, 2); + graph.addLink(1, 3); + + let visited = 0; + for (const _ of graph.linkedNodes(1, oriented)) { + visited += 1; + break; + } + assertEquals(visited, 1, 'One link is visited'); + }); + + await t.step( + 'forEachLinkedNode can quit fast for non-oriented graphs', + () => { + const graph = new Graph(); + const oriented = false; + graph.addLink(1, 2); + graph.addLink(1, 3); + + let visited = 0; + for (const _ of graph.linkedNodes(1, oriented)) { + visited += 1; + break; + } + assertEquals(visited, 1, 'One link is visited'); + }, + ); + + await t.step('forEachLink visits each link', () => { + const graph = new Graph(); + graph.addLink(1, 2); + for (const link of graph.links()) { + assertEquals(link.fromId, 1); + assertEquals(link.toId, 2); + } + }); + }); + + await t.step('Link', () => { + const link = new Link(1, 2); + assert(link); + + const graph = new Graph(); + assertFalse(graph.removeLinkInstance(undefined!)); + assertFalse(graph.removeLinkInstance(link)); + }); +});