From 1980b0875a678c04c61c9cb69061ff26e1b8223e Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 3 May 2023 11:52:06 -0400 Subject: [PATCH] Introduce `@wry/key-set-map` package. This package provides a `KeySetMap` class similar to the `Trie` API provided by the `@wry/trie` package, except the keys passed to `lookup` or `peek` have _set_ semantics rather than _sequence_ semantics. --- packages/key-set-map/.gitignore | 43 ++++ packages/key-set-map/.npmignore | 3 + packages/key-set-map/README.md | 54 +++++ packages/key-set-map/package-lock.json | 31 +++ packages/key-set-map/package.json | 37 +++ packages/key-set-map/rollup.config.js | 22 ++ packages/key-set-map/src/helpers.ts | 48 ++++ packages/key-set-map/src/index.ts | 312 +++++++++++++++++++++++++ packages/key-set-map/src/tests/main.ts | 272 +++++++++++++++++++++ packages/key-set-map/src/types.ts | 15 ++ packages/key-set-map/tsconfig.json | 9 + 11 files changed, 846 insertions(+) create mode 100644 packages/key-set-map/.gitignore create mode 100644 packages/key-set-map/.npmignore create mode 100644 packages/key-set-map/README.md create mode 100644 packages/key-set-map/package-lock.json create mode 100644 packages/key-set-map/package.json create mode 100644 packages/key-set-map/rollup.config.js create mode 100644 packages/key-set-map/src/helpers.ts create mode 100644 packages/key-set-map/src/index.ts create mode 100644 packages/key-set-map/src/tests/main.ts create mode 100644 packages/key-set-map/src/types.ts create mode 100644 packages/key-set-map/tsconfig.json diff --git a/packages/key-set-map/.gitignore b/packages/key-set-map/.gitignore new file mode 100644 index 00000000..af98a5ea --- /dev/null +++ b/packages/key-set-map/.gitignore @@ -0,0 +1,43 @@ +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules +jspm_packages + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + +# Ignore generated TypeScript files. +lib + +# Cache for rollup-plugin-typescript2 +.rpt2_cache diff --git a/packages/key-set-map/.npmignore b/packages/key-set-map/.npmignore new file mode 100644 index 00000000..d2404814 --- /dev/null +++ b/packages/key-set-map/.npmignore @@ -0,0 +1,3 @@ +/node_modules +/lib/tests +tsconfig.json diff --git a/packages/key-set-map/README.md b/packages/key-set-map/README.md new file mode 100644 index 00000000..67bb528d --- /dev/null +++ b/packages/key-set-map/README.md @@ -0,0 +1,54 @@ +# @wry/key-set-map + +Whereas the `@wry/trie` package associates values with _sequences_ of keys, the +`@wry/key-set-map` package and its `KeySetMap` class provide a similar +capability for _sets_ of keys, so the order of the input keys is no longer +important. + +As with a traditional [Trie](https://en.wikipedia.org/wiki/Trie), lookups and +insertions take linear time in the size of the input set, and peek-like +operations can often bail out much more quickly, without having to look at all +the input set elements. + +Since JavaScript `Set` and `Map` containers maintain insertion order, two +equivalent sets (containing identical elements) can nevertheless be detectably +different if their keys were inserted in a different order. Deciding which of +the orders is "correct" or "canonical" is a fool's errand, possible only when +there is an inherent total ordering among the elements, suggesting a +sorting-based strategy. + +Because sorting is tempting as a strategy for turning sets into +canonically-ordered sequences, it's important to stress: this implementation +works without sorting set elements, and without requiring the elements to be +comparable. In fact, the lookup algorithm is asymptotically faster than it would +be if the keys had to be sorted before lookup. + +Finally, to avoid taking any position on which ordering of elements is +canonical, this implementation never grants direct access to any previously +provided sets. Instead of attempting to return a canonical `Set`, the keys of +the set are associated with an arbitrary `TData` value, which is all you get +when you look up a set of keys. + +## Memory management + +When `WeakRef` and `FinalizationRegistry` are available, the `KeySetMap` class +automatically reclaims internal memory associated with sets containing keys that +have been garbage collected. + +To that end, when keys can be garbage collected, the `KeySetMap` takes care not +to retain them strongly, acting like a `WeakMap` for object keys and like a +`Map` for non-object keys. In other words, `KeySetMap` does not prevent its +(object) keys from being garbage collected, if they are otherwise eligible. + +By passing `false` for the `weakness` parameter to the `KeySetMap` constructor, +you can disable weak-key-related functionality, so the `KeySetMap` will behave +like a `Map` for all keys, regardless of whether they are objects or primitive. +This mode is not encouraged for production, but may be useful for testing, +debugging, or other diagnostic purposes. + +Any `TData` objects allocated by the `KeySetMap` may outlive their associated +sets of keys, and retaining a strong reference to the `TData` object by itself +does not prevent garbage collection and removal of object keys. However, as long +as all keys remain reachable and are not removed from the `KeySetMap` with +`remove` or `removeSet`, the set of keys will remain in the `KeySetMap` and thus +retain a reference to the associated `TData`. diff --git a/packages/key-set-map/package-lock.json b/packages/key-set-map/package-lock.json new file mode 100644 index 00000000..5dc141da --- /dev/null +++ b/packages/key-set-map/package-lock.json @@ -0,0 +1,31 @@ +{ + "name": "@wry/key-set-map", + "version": "0.1.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "@wry/key-set-map", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" + } + }, + "dependencies": { + "tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" + } + } +} diff --git a/packages/key-set-map/package.json b/packages/key-set-map/package.json new file mode 100644 index 00000000..ceadf96b --- /dev/null +++ b/packages/key-set-map/package.json @@ -0,0 +1,37 @@ +{ + "name": "@wry/key-set-map", + "private": true, + "version": "0.1.0", + "author": "Ben Newman ", + "description": "Trie-like data structure using sets rather than sequences of keys", + "license": "MIT", + "type": "module", + "main": "lib/bundle.cjs", + "module": "lib/index.js", + "types": "lib/index.d.ts", + "keywords": [], + "homepage": "https://github.com/benjamn/wryware", + "repository": { + "type": "git", + "url": "git+https://github.com/benjamn/wryware.git" + }, + "bugs": { + "url": "https://github.com/benjamn/wryware/issues" + }, + "scripts": { + "build": "npm run clean && npm run tsc && npm run rollup", + "clean": "rimraf lib", + "tsc": "tsc -p tsconfig.json", + "rollup": "rollup -c rollup.config.js", + "prepare": "npm run build", + "test:cjs": "../../shared/test.sh lib/tests/bundle.cjs", + "test:esm": "../../shared/test.sh lib/tests/bundle.js", + "test": "npm run test:esm && npm run test:cjs" + }, + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } +} diff --git a/packages/key-set-map/rollup.config.js b/packages/key-set-map/rollup.config.js new file mode 100644 index 00000000..417c7819 --- /dev/null +++ b/packages/key-set-map/rollup.config.js @@ -0,0 +1,22 @@ +import { build } from "../../shared/rollup.config.js"; + +// This package doesn't use the lib/es5 directory, so we need to override the +// default export from ../../shared/rollup.config.js (as we also do in the +// @wry/equality package). +export default [ + build( + "lib/index.js", + "lib/bundle.cjs", + "cjs" + ), + build( + "lib/tests/main.js", + "lib/tests/bundle.js", + "esm" + ), + build( + "lib/tests/main.js", + "lib/tests/bundle.cjs", + "cjs" + ), +]; diff --git a/packages/key-set-map/src/helpers.ts b/packages/key-set-map/src/helpers.ts new file mode 100644 index 00000000..5681ef2e --- /dev/null +++ b/packages/key-set-map/src/helpers.ts @@ -0,0 +1,48 @@ +export const arrayForEach = Array.prototype.forEach; + +export const { + prototype: { + hasOwnProperty, + toString: objectToString, + }, +} = Object; + +// If no makeData function is supplied, the looked-up data will be an empty, +// null-prototype Object. +export function defaultMakeData(): any { + return Object.create(null); +} + +export function isObjRef(value: any): value is object { + if (value) { + switch (typeof value) { + case "object": + case "function": + return true; + } + } + return false; +} + +const SET_TO_STRING_TAG = objectToString.call(new Set); + +export function assertSet(set: any): asserts set is Set { + const toStringTag = objectToString.call(set); + if (toStringTag !== SET_TO_STRING_TAG) { + throw new TypeError(`Not a Set: ${toStringTag}`); + } +} + +const KNOWN: unique symbol = Symbol("KeySetMap.KNOWN"); + +export function makeKnownWeakRef(key: T): WeakRef { + return Object.assign(new WeakRef(key), { [KNOWN]: true }); +} + +export function isKnownWeakRef(ref: unknown): ref is WeakRef { + return ( + ref instanceof WeakRef && + KNOWN in ref && + ref[KNOWN] === true + ); +} diff --git a/packages/key-set-map/src/index.ts b/packages/key-set-map/src/index.ts new file mode 100644 index 00000000..98bcde35 --- /dev/null +++ b/packages/key-set-map/src/index.ts @@ -0,0 +1,312 @@ +import { + isObjRef, + defaultMakeData, + hasOwnProperty, + assertSet, + makeKnownWeakRef, + isKnownWeakRef, +} from "./helpers"; + +import type { + CanonicalKeys, + SizeIndexedSetsOfCanonicalKeys +} from "./types"; + +export class KeySetMap< + TData = Record, + TKey = any +> { + constructor( + // For diagnostic purposes, or in cases where usage of WeakMap, WeakRef, and + // FinalizationRegistry is not desired, this usually-true parameter allows + // disabling all weak-key-related features of the KeySetMap. The logical + // behavior of the KeySetMap should be the same with weakness disabled, but + // performance and memory usage may be worse. + private weakness = true, + + // The optional makeData function allows customizing the TData entry stored + // for each canonical set of keys. It receives an iterator over the keys of + // the set (rather than a reference to the set itself), which it may either + // ignore or use in its construction of the TData object/value. + private makeData: (keysIterator: Iterator) => TData = defaultMakeData, + ) {} + + // The lookup and lookupSet methods return the TData associated with the given + // sequence of keys, interpreted as a set. If no TData has been associated + // with this set of keys yet, one will be created by this.makeData, allowing + // lookup always to return TData and never undefined. + public lookup(keys: Iterable): TData { + return this.lookupSet(new Set(keys)); + } + + public lookupSet(set: Set): TData { + return (this.findCanonicalKeys(set) || this.recordCanonicalKeys(set)).data!; + } + + // The peek and peekSet methods return the TData associated with the given + // sequence of keys, interpreted as a set, if any. Unlike lookup and + // lookupSet, they do not create a new TData if none has been associated with + // the given set of keys, but instead return undefined. + public peek(keys: Iterable): TData | undefined { + return this.peekSet(new Set(keys)); + } + + public peekSet(set: Set): TData | undefined { + return this.findCanonicalKeys(set)?.data; + } + + // The remove and removeSet methods remove the CanonicalKeys entry associated + // with this set of keys, returning the removed TData, if any. + public remove(keys: Iterable): TData | undefined { + return this.removeSet(new Set(keys)); + } + + public removeSet(set: Set): TData | undefined { + const cks = this.findCanonicalKeys(set); + if (cks) { + this.purgeCanonicalKeys(cks); + return cks.data; + } + } + + /////////////////////////////////// + // Private API below this point. // + /////////////////////////////////// + + // To support fast lookup of CanonicalKeys given any sequence of input keys, + // we index every set that has been added to the KeySetMap by each of its + // elements (keys) and by the size of the set. This index structure allows + // findCanonicalKeys to decide quickly whether a given set of keys has been + // added already. + private strong = new Map>(); + private weak: WeakMap> = + // If weakness has been disabled, we still maintain a distinction between + // strong and weak keys, but we use an ordinary Map for this.weak. + this.weakness ? new WeakMap : new Map; + + private mapFor(key: TKey): KeySetMap["weak" | "strong"] { + return isObjRef(key) ? this.weak : this.strong; + } + + // Since the empty set does not have any elements to keep it in the + // weak/strong maps, we have to handle it specially. + private empty: CanonicalKeys | undefined; + + // Using FinalizationRegistry (when available) allows active removal of + // resources related to sets with elements that have become unreachable, since + // the TData associated with such sets can never be accessed again. + private registry = ( + this.weakness && + typeof FinalizationRegistry === "function" + ) ? new FinalizationRegistry>( + cks => this.purgeCanonicalKeys(cks) + ) : null; + + private findCanonicalKeys(set: Set): CanonicalKeys | undefined { + // It's all too easy, in plain JavaScript, to be passed something other than + // a Set here, with unpredictable consequences. Instead we throw. + assertSet(set); + + if (set.size === 0) { + return this.empty; + } + + const allSetsOfCanonicalKeys: Set>[] = []; + let smallestSetOfCanonicalKeys: Set> | undefined; + + for (const key of set) { + const setsOfSameSizeContainingKey = + this.mapFor(key).get(key as any)?.get(set.size); + + // If any key is in zero sets, then the intersection will be empty, so we + // can return immediately to avoid extra work. + if (!setsOfSameSizeContainingKey || setsOfSameSizeContainingKey.size === 0) { + return; + } + + // We can save some work by iterating over allSetsOfCanonicalKeys later, + // rather than iterating over the set of keys again. + allSetsOfCanonicalKeys.push(setsOfSameSizeContainingKey); + + if ( + !smallestSetOfCanonicalKeys || + setsOfSameSizeContainingKey.size < smallestSetOfCanonicalKeys.size + ) { + smallestSetOfCanonicalKeys = setsOfSameSizeContainingKey; + } + } + + // At this point, we know every key is in at least one set, so we intersect + // the sets of CanonicalKeys to find the canonical entry for these keys, if + // one has already been created. + if (smallestSetOfCanonicalKeys) { + // Although the result should be the same in any order (set intersection + // being commutative and associative), we begin the intersection of + // allSetsOfCanonicalKeys with the smallestSetOfCanonicalKeys, so we never + // have to consider elements not in that set. + const intersection = new Set(smallestSetOfCanonicalKeys); + + // Now remove from intersection any CanonicalKeys that are not in every + // other set as well, so the only remaining CanonicalKeys is the one + // representing the keys of the original set. + for ( + let i = 0, len = allSetsOfCanonicalKeys.length; + // Including this intersection.size check in the for-loop condition + // allows control to break from the loop immediately if/when the + // intersection becomes empty, indicating there is no CanonicalKeys + // entry for these keys (yet). + i < len && intersection.size > 0; + ++i + ) { + const setOfCanonicalKeys = allSetsOfCanonicalKeys[i]; + + if (setOfCanonicalKeys !== smallestSetOfCanonicalKeys) { + // The setOfCanonicalKeys set may be much larger than the current + // intersection, so it's important for performance to iterate over the + // shrinking intersection, checking setOfCanonicalKeys.has, instead of + // the other way around. + // + // To avoid allocating any functions during findCanonicalKeys, we pass + // Set.prototype.forEach a static callback function (with this === + // setOfCanonicalKeys) rather than a freshly allocated function. + intersection.forEach(removeIfThisDoesNotContain, setOfCanonicalKeys); + } + } + + // The intersection set will often be empty here, so the return does not + // execute, leaving findCanonicalKeys to return undefined. When a single + // CanonicalKeys is found, intersection will contain exactly that one set, + // and it will be returned here. I leave it as an exercise for the reader + // to prove that intersection.size can never be greater than 1. Hint: + // consider what could happen here if we did not index sets by size, but + // only by their elements. + for (const lastRemainingCanonicalKeys of intersection) { + return lastRemainingCanonicalKeys; + } + } + } + + private recordCanonicalKeys(keys: Iterable): CanonicalKeys { + const newSet = new Set(keys); + const cks: CanonicalKeys = newSet.size + ? { size: newSet.size, keysOrRefs: new Set } + : this.empty || (this.empty = { size: 0, keysOrRefs: new Set }); + + for (const key of newSet) { + // To make looking up canonical sets fast, we index each canonical newSet by + // each of its keys and also by its size. This loop populates that index. + const map = this.mapFor(key); + let sizeIndex = map.get(key as any); + if (!sizeIndex) map.set(key as any, sizeIndex = new Map); + let sets = sizeIndex.get(newSet.size); + if (!sets) sizeIndex.set(newSet.size, sets = new Set); + sets.add(cks); + + if (map === this.weak) { + // By storing only a WeakRef wrapping any object keys, we prevent the + // KeySetMap from retaining references to garbage-collectible keys. + cks.keysOrRefs.add( + this.weakness ? makeKnownWeakRef(key as object) : key + ); + + if (this.registry) { + // Whenever any weakly-held key is garbage collected, this.registry + // purges the CanonicalKeys entry for this set from the KeySetMap, + // removing it from each of the SizeIndexedSetsOfSets associated with + // the keys of the original set (see purgeCanonicalKeys method below). + this.registry.register(key as object, cks, cks); + } + } else if (map === this.strong) { + cks.keysOrRefs.add(key); + } + } + + if (!hasOwnProperty.call(cks, "data")) { + cks.data = this.makeData(newSet.keys()); + } + + return cks; + } + + private purgeCanonicalKeys(cks: CanonicalKeys): boolean { + if (cks && this.registry) { + // Since we register keys with this.registry.register(key, cks, cks), we + // can unregister all keys at once by calling this.registry.unregister + // with the same cks reference. Calling purgeCanonicalKeys for every key + // in the set should be idempotent/safe, but this active unregistration + // should prevent unnecessary work, in theory. + this.registry.unregister(cks); + } + + if (cks.size === 0 && this.empty === cks) { + this.empty = void 0; + return true; + } + + let modified = false; + + function removeCanonicalKeysForKey( + map: KeySetMap["strong" | "weak"], + key: any + ) { + const index = map.get(key); + if (index && cks) { + const setOfSets = index.get(cks.size); + if (setOfSets && setOfSets.delete(cks)) { + modified = true; + if ( + setOfSets.size === 0 && + index.delete(cks.size) && + index.size === 0 + ) { + map.delete(key); + } + } + } + } + + cks.keysOrRefs.forEach(keyOrRef => { + if (isKnownWeakRef(keyOrRef)) { + const key = keyOrRef.deref(); + if (key) { + removeCanonicalKeysForKey(this.weak, key); + } + } else { + removeCanonicalKeysForKey(this.mapFor(keyOrRef), keyOrRef); + } + }); + + // Ensure idempotence by emptying the keysOrRefs Set. + cks.keysOrRefs.clear(); + + return modified; + } +} + +// Helper callback function for use with Set.prototype.forEach, allowing +// +// intersection.forEach(cks => { +// if (!setOfCanonicalKeys.has(cks)) { +// intersection.delete(cks); +// } +// }); +// +// to be written as +// +// intersection.forEach( +// removeIfThisDoesNotContain, +// setOfCanonicalKeys, +// ); +// +// Though this code is somewhat less idiomatic, it avoids allocating a new +// function for every call to forEach. +function removeIfThisDoesNotContain( + this: Set, + key: T, + _key: T, + set: Set, +) { + if (!this.has(key)) { + set.delete(key); + } +} diff --git a/packages/key-set-map/src/tests/main.ts b/packages/key-set-map/src/tests/main.ts new file mode 100644 index 00000000..662022cb --- /dev/null +++ b/packages/key-set-map/src/tests/main.ts @@ -0,0 +1,272 @@ +import * as assert from "assert"; +import { KeySetMap } from "../index.js"; + +describe("@wry/key-set-map", () => { + it("should be importable/constructable/etc", () => { + assert.strictEqual(typeof KeySetMap, "function"); + const ksm = new KeySetMap(); + assert.strictEqual(typeof ksm, "object"); + assert.strictEqual(ksm instanceof KeySetMap, true); + assert.strictEqual(ksm.constructor, KeySetMap); + assert.strictEqual(Object.getPrototypeOf(ksm), KeySetMap.prototype); + }); + + it("lookupSet should reject non-Set objects", () => { + const ksm = new KeySetMap(); + // @ts-expect-error + assert.throws(() => ksm.lookupSet({}), TypeError, "Not a Set: [object Object]"); + // @ts-expect-error + assert.throws(() => ksm.lookupSet([]), TypeError, "Not a Set: [object Array]"); + // @ts-expect-error + assert.throws(() => ksm.lookupSet(new Map()), TypeError, "Not a Set: [object Map]"); + // @ts-expect-error + assert.throws(() => ksm.lookupSet(new WeakMap()), TypeError, "Not a Set: [object WeakMap]"); + // @ts-expect-error + assert.throws(() => ksm.lookupSet(new WeakSet()), TypeError, "Not a Set: [object WeakSet]"); + }); + + it("should look up keys with set semantics", () => { + const ksm = new KeySetMap(); + + const setOfEmptyLookups = new Set([ + ksm.lookup([]), + ksm.lookup([]), + ksm.lookup([]), + ]); + assert.strictEqual(setOfEmptyLookups.size, 1); + + const abData = ksm.lookup(["a", "b"]); + const baData = ksm.lookup(["b", "a"]); + assert.strictEqual(abData, baData); + + const abcDataSet = new Set([ + ksm.lookup(["a", "b", "c"]), + ksm.lookup(["a", "c", "b"]), + ksm.lookup(["b", "a", "c"]), + ksm.lookup(["b", "c", "a"]), + ksm.lookup(["c", "a", "b"]), + ksm.lookup(["c", "b", "a"]), + ]); + assert.strictEqual(abcDataSet.size, 1); + abcDataSet.forEach(abcData => { + assert.strictEqual(abcData, ksm.lookup(["a", "b", "c"])); + }); + }); + + it("should peek keys with set semantics", () => { + const ksm = new KeySetMap; + + assert.strictEqual(ksm.peek([]), void 0); + assert.strictEqual(ksm.peek([]), void 0); + assert.strictEqual(ksm.peek([]), void 0); + + const emptyData = ksm.lookup([]); + assert.strictEqual(emptyData && typeof emptyData, "object"); + assert.strictEqual(ksm.peek([]), emptyData); + + assert.strictEqual(ksm.peek(["a", "b"]), void 0); + assert.strictEqual(ksm.peek(["b", "a"]), void 0); + const abData = ksm.lookup(["a", "b"]); + assert.strictEqual(ksm.peek(["a", "b"]), abData); + assert.strictEqual(ksm.peek(["b", "a"]), abData); + const baData = ksm.lookup(["b", "a"]); + assert.strictEqual(ksm.peek(["a", "b"]), baData); + assert.strictEqual(ksm.peek(["b", "a"]), baData); + assert.strictEqual(abData, baData); + + function checkABC(data: T): T { + assert.strictEqual(ksm.peek(["a", "b", "c"]), data); + assert.strictEqual(ksm.peek(["a", "c", "b"]), data); + assert.strictEqual(ksm.peek(["b", "a", "c"]), data); + assert.strictEqual(ksm.peek(["b", "c", "a"]), data); + assert.strictEqual(ksm.peek(["c", "a", "b"]), data); + assert.strictEqual(ksm.peek(["c", "b", "a"]), data); + return data; + } + checkABC(void 0); + const abcDataSet = new Set([ + checkABC(ksm.lookup(["a", "b", "c"])), + checkABC(ksm.lookup(["a", "c", "b"])), + checkABC(ksm.lookup(["b", "a", "c"])), + checkABC(ksm.lookup(["b", "c", "a"])), + checkABC(ksm.lookup(["c", "a", "b"])), + checkABC(ksm.lookup(["c", "b", "a"])), + ]); + assert.strictEqual(abcDataSet.size, 1); + }); + + function eachPermutation( + items: T[], + callback: (permutation: T[]) => void, + ) { + return (function recurse(n: number) { + if (n === 1) { + callback(items.slice()); + } else { + for (let i = 0; i < n; ++i) { + recurse(n - 1); + const j = n % 2 ? 0 : i; + const item = items[n - 1]; + items[n - 1] = items[j]; + items[j] = item; + } + } + })(items.length); + } + + const factorial = (n: number): number => n <= 1 ? 1 : n * factorial(n - 1); + + it("should look up object reference keys with set semantics", () => { + const ksm = new KeySetMap(); + + const objects = [ + { a: 1 }, + { b: 2 }, + { c: 3 }, + { d: 4 }, + { e: 5 }, + ]; + + assert.strictEqual(ksm.peek(objects), void 0); + const objectsData = ksm.lookup(objects); + assert.strictEqual(ksm.peek(objects), objectsData); + + let permutationCount = 0; + eachPermutation(objects, permutation => { + const data = ksm.lookup(permutation); + assert.strictEqual(data, objectsData); + ++permutationCount; + }); + const expectedPermutationCount = factorial(objects.length); + assert.strictEqual(permutationCount, expectedPermutationCount); + }); + + describe("custom makeData functions", () => { + it("object-returning makeData", () => { + const ksm = new KeySetMap(true, it => { + const array: any[] = []; + for (let item = it.next(); !item.done; item = it.next()) { + array.push(item.value); + } + return { + count: array.length, + array, + }; + }); + + const emptyData = ksm.lookup([]); + assert.strictEqual(emptyData.count, 0); + assert.deepStrictEqual(emptyData.array, []); + + const abcData1 = ksm.lookup(["b", "a", "b", "c"]); + const abcData2 = ksm.lookup(["c", "b", "a", "b", "c"]); + const abcData3 = ksm.lookup(["a", "b", "c"]); + + assert.strictEqual(abcData1.count, 3); + assert.deepStrictEqual(abcData1.array, ["b", "a", "c"]); + + assert.strictEqual(abcData2.count, 3); + assert.strictEqual(abcData2.array, abcData1.array); + + assert.strictEqual(abcData3.count, 3); + assert.strictEqual(abcData3.array, abcData1.array); + }); + + it("primitive-returning makeData", () => { + const ksm = new KeySetMap(true, it => { + const array: string[] = []; + for (let item = it.next(); !item.done; item = it.next()) { + array.push(item.value); + } + return array.join(","); + }); + + const emptyData = ksm.lookup([]); + assert.strictEqual(emptyData, ""); + + const abcData2 = ksm.lookup(["c", "c", "b", "a", "b", "c"]); + const abcData1 = ksm.lookup(["b", "a", "b", "c"]); + const abcData3 = ksm.lookup(["a", "c", "a", "b"]); + + assert.strictEqual(abcData1, "c,b,a"); + assert.strictEqual(abcData2, abcData1); + assert.strictEqual(abcData3, abcData1); + }); + }); + + it("should not be confused by supersets of keys", () => { + const ksm = new KeySetMap(); + + const emptyData = ksm.lookup([]); + const aData = ksm.lookup(["a"]); + const abData = ksm.lookup(["a", "b"]); + const abVoidData = ksm.lookup(["a", "b", void 0]); + const abcData = ksm.lookup(["a", "b", "c"]); + + assert.strictEqual(new Set([ + emptyData, + aData, + abData, + abVoidData, + abcData, + ]).size, 5); + + const abcdDataObjects: object[] = []; + const supersetDataObjects: object[] = []; + + eachPermutation(["a", "b", "c", "d"], permutation => { + // Put a subset of this permutation into the ksm. + const sliced = permutation.slice(1); + const slicedData = ksm.lookup(sliced); + assert.strictEqual(ksm.peek(sliced), slicedData); + + // Put a superset of this permutation into the ksm. + const rogue = Symbol(); + assert.strictEqual(ksm.peek([...permutation, rogue]), void 0); + const abcdeData = ksm.lookup([...permutation, rogue]); + assert.strictEqual(ksm.peek([rogue, ...permutation]), abcdeData); + supersetDataObjects.push(abcdeData); + + // Now put the permutation itself into the ksm, expecting no + // interference from the previous lookups. + abcdDataObjects.push(ksm.lookup(permutation)); + }); + + // All the Data objects for the a,b,c,d permutations should be the same. + assert.strictEqual(new Set(abcdDataObjects).size, 1); + + // There should only be as many superset Data objects as there are + // permutations of the original four keys, but the extra rogue element makes + // them all distinct rather than collapsing down to 1. + assert.strictEqual(new Set(supersetDataObjects).size, factorial(4)); + }); + + it("removing sets of keys", () => { + const ksm = new KeySetMap(); + + const abcData = ksm.lookup(["a", "b", "c"]); + const abData = ksm.lookup(["a", "b"]); + const emptyData = ksm.lookup([]); + + assert.strictEqual(ksm.peek(["a", "b", "c"]), abcData); + assert.strictEqual(ksm.peek(["a", "b"]), abData); + assert.strictEqual(ksm.peek([]), emptyData); + + assert.strictEqual(ksm.remove(["b", "a", "b", "c", "b"]), abcData); + assert.strictEqual(ksm.remove(["b", "c", "a"]), void 0); + assert.strictEqual(ksm.peek(["a", "b", "c"]), void 0); + + assert.strictEqual(ksm.remove([]), emptyData); + assert.strictEqual(ksm.remove([]), void 0); + assert.strictEqual(ksm.peek([]), void 0); + + assert.strictEqual(ksm.peek(["b", "a", "a"]), abData); + assert.strictEqual(ksm.peek(["a", "b"]), abData); + assert.strictEqual(ksm.remove(["a", "b"]), abData); + assert.strictEqual(ksm.remove(["b", "a", "b"]), void 0); + + assert.notStrictEqual(ksm.lookup([]), emptyData); + assert.notStrictEqual(ksm.lookup(["a", "b"]), abData); + assert.notStrictEqual(ksm.lookup(["a", "b", "c"]), abcData); + }); +}); diff --git a/packages/key-set-map/src/types.ts b/packages/key-set-map/src/types.ts new file mode 100644 index 00000000..cecc1f84 --- /dev/null +++ b/packages/key-set-map/src/types.ts @@ -0,0 +1,15 @@ +export interface CanonicalKeys { + // Number of unique keys in the set, equal to the sum of the lengths of the + // sKeys and wKeys arrays. + size: number; + // A mixture of strongly-held keys and WeakRef objects wrapping weakly-held + // keys. + keysOrRefs: Set>; + // User-typed/configurable data associated with this set of keys. + data?: TData; +} + +// The numeric Map keys correspond to the sizes of the +// CanonicalKeys entries. +export type SizeIndexedSetsOfCanonicalKeys = + Map>>; diff --git a/packages/key-set-map/tsconfig.json b/packages/key-set-map/tsconfig.json new file mode 100644 index 00000000..e9faaa8e --- /dev/null +++ b/packages/key-set-map/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../shared/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext"], + "rootDir": "./src", + "outDir": "./lib", + "removeComments": true, + } +}