diff --git a/package-lock.json b/package-lock.json index 1280d76..dfc2059 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "xen-dev-utils", "version": "0.2.9", "license": "MIT", + "dependencies": { + "ts-essentials": "^9.4.2" + }, "devDependencies": { "@types/benchmark": "^2.1.5", "@types/node": "^20.10.1", @@ -4241,6 +4244,19 @@ "node": ">=8" } }, + "node_modules/ts-essentials": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-9.4.2.tgz", + "integrity": "sha512-mB/cDhOvD7pg3YCLk2rOtejHjjdSi9in/IBYE13S+8WA5FBSraYf4V/ws55uvs0IvQ/l0wBOlXy5yBNZ9Bl8ZQ==", + "peerDependencies": { + "typescript": ">=4.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", @@ -4344,7 +4360,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz", "integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==", - "dev": true, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 48b2b3b..bf802c9 100644 --- a/package.json +++ b/package.json @@ -54,5 +54,8 @@ }, "engines": { "node": ">=10.6.0" + }, + "dependencies": { + "ts-essentials": "^9.4.2" } } diff --git a/src/__tests__/hashable.spec.ts b/src/__tests__/hashable.spec.ts new file mode 100644 index 0000000..2bcb41d --- /dev/null +++ b/src/__tests__/hashable.spec.ts @@ -0,0 +1,73 @@ +import {Primitive} from 'ts-essentials'; +import {HashMap, Hashable} from '../hashable'; +import {PRIMES} from '../primes'; +import {Monzo, monzosEqual} from '../monzo'; +import {describe, it, expect} from 'vitest'; + +/** + * Partial implementation of a rational number capable of holding very large values with a limited number of prime factors. + */ +class ImmutableMonzo extends Hashable { + vector: Readonly; + + constructor(vector: Readonly) { + super(); + this.vector = vector; + } + + valueOf(): number { + let value = 1; + for (let i = 0; i < this.vector.length; ++i) { + value *= PRIMES[i] ** this.vector[i]; + } + return value; + } + + strictEquals(other: Hashable | Primitive): boolean { + if (other instanceof ImmutableMonzo) { + return monzosEqual(this.vector, other.vector); + } + return false; + } +} + +function M(vector: Readonly) { + return Object.freeze(new ImmutableMonzo(Object.freeze(vector))); +} + +describe('Hash-map / dictionary', () => { + it('can set / get keys that hash to the same value', () => { + const thirty = M([1, 1, 1]); + const big = M([100000]); + const alsoBig = M([-1, 1000]); + expect(thirty.valueOf()).toBe(30); + expect(big.valueOf()).toBe(alsoBig.valueOf()); + + const map = new HashMap([ + [30, 'number'], + [thirty, 'thirty'], + [big, 'biig'], // Oops typo. To be overriden. + ]); + + map.set(alsoBig, 'also big'); + map.set(M([100000]), 'big'); + + expect(map.get(30)).toBe('number'); + expect(map.get(M([1, 1, 1]))).toBe('thirty'); + expect(map.get(M([100000]))).toBe('big'); + expect(map.get(M([-1, 1000]))).toBe('also big'); + + expect(map.size).toBe(4); + }); + + it('can be cleared', () => { + const map = new HashMap(); + map.set(3, 4); + expect(map.size).toBe(1); + map.clear(); + expect(map.size).toBe(0); + expect(map.get(3)).toBe(undefined); + }); + + // TODO: Rest of the methods of HashMap +}); diff --git a/src/__tests__/index.spec.ts b/src/__tests__/index.spec.ts index 1ebb389..7f8bdee 100644 --- a/src/__tests__/index.spec.ts +++ b/src/__tests__/index.spec.ts @@ -1,7 +1,6 @@ import {describe, it, expect} from 'vitest'; import { Fraction, - FractionSet, arraysEqual, binomial, ceilPow2, @@ -19,7 +18,10 @@ import { iteratedEuclid, norm, valueToCents, + F, } from '../index'; +import {HashMap, HashSet, Hashable} from '../hashable'; +import {DeepReadonly, Primitive} from 'ts-essentials'; describe('Array equality tester', () => { it('works on integer arrays', () => { @@ -207,11 +209,11 @@ describe('Farey sequence generator', () => { }); it('agrees with the brute force method', () => { - const everything = new FractionSet(); + const everything = new HashSet>(); const N = Math.floor(Math.random() * 50) + 1; for (let d = 1; d <= N; ++d) { for (let n = 0; n <= d; ++n) { - everything.add(new Fraction(n, d)); + everything.add(F(n, d)); } } const brute = Array.from(everything); @@ -254,11 +256,11 @@ describe('Farey interior generator', () => { }); it('agrees with the brute force method', () => { - const everything = new FractionSet(); + const everything = new HashSet>(); const N = Math.floor(Math.random() * 50) + 1; for (let d = 1; d <= N; ++d) { for (let n = 1; n < d; ++n) { - everything.add(new Fraction(n, d)); + everything.add(F(n, d)); } } const brute = Array.from(everything); @@ -382,3 +384,52 @@ describe('Constant structure checker with a margin of equivalence', () => { expect(hasMarginConstantStructure([1199, 1200], 2)).toBe(false); }); }); + +describe('Fractions as hashmap keys', () => { + it('can override values using equivalent keys', () => { + const hashMap = new HashMap([ + [F(3, 2), 'fif'], + [F(2), 'octave'], + ]); + hashMap.set(F('5/4'), 'third'); + hashMap.set(F(1.5), 'fifth'); + const entries = Array.from(hashMap).sort((a, b) => a[0].compare(b[0])); + expect(entries).toEqual([ + [{s: 1, n: 5, d: 4}, 'third'], + [{s: 1, n: 3, d: 2}, 'fifth'], + [{s: 1, n: 2, d: 1}, 'octave'], + ]); + }); + + it('plays nicely with mixed keys and mixed data', () => { + class OtherHashable extends Hashable { + name: string; + constructor(name: string) { + super(); + this.name = name; + } + valueOf(): string { + return this.name; + } + strictEquals(other: Hashable | Primitive): boolean { + if (other instanceof OtherHashable) { + return this.name === other.name; + } + return false; + } + } + const hashMap = new HashMap< + number | string | Fraction | OtherHashable, + string | number | boolean + >([ + [1, 'one'], + [F(1), 'other one'], + ['1', 1], + [Object.freeze(new OtherHashable('1')), true], + ]); + expect(hashMap.get(1)).toBe('one'); + expect(hashMap.get(F('7/7'))).toBe('other one'); + expect(hashMap.get('1')).toBe(1); + expect(hashMap.get(Object.freeze(new OtherHashable('1')))).toBe(true); + }); +}); diff --git a/src/fraction.ts b/src/fraction.ts index 8500f8c..1ac1599 100644 --- a/src/fraction.ts +++ b/src/fraction.ts @@ -3,6 +3,8 @@ import {valueToCents} from './conversion'; import {PRIMES} from './primes'; +import {Hashable} from './hashable'; +import {DeepReadonly, Primitive} from 'ts-essentials'; export type UnsignedFraction = {n: number; d: number}; @@ -76,7 +78,7 @@ export function mmod(a: number, b: number) { * new Fraction("13e-3"); // scientific notation * ``` */ -export class Fraction { +export class Fraction extends Hashable { /** Sign: +1, 0 or -1 */ s: number; /** Numerator */ @@ -85,6 +87,7 @@ export class Fraction { d: number; constructor(numerator: FractionValue, denominator?: number) { + super(); if (denominator !== undefined) { if (typeof numerator !== 'number') { throw new Error('Numerator must be a number when denominator is given'); @@ -831,6 +834,24 @@ export class Fraction { } } + /** + * Check if two {@link Fraction} instances represent the same rational number. + * Returns `false` for other {@link Hashable} objects. + * Examples: + * ```ts + * new Fraction("19.7").strictEquals(new Fraction("98/5")) // false + * new Fraction("19.6").strictEquals(new Fraction("98/5")) // true + * new Fraction("19.5").strictEquals(new Fraction("98/5")) // false + * ``` + **/ + strictEquals(other: Hashable | Primitive) { + if (other instanceof Fraction) { + const {s, n, d} = other; + return this.s === s && this.n === n && this.d === d; + } + return false; + } + /** * Check if two rational numbers are divisible * (i.e. this is an integer multiple of other) @@ -1114,3 +1135,16 @@ export class Fraction { return other_.pow(exponent); } } + +/** + * Construct an immutable {@link Fraction} instance suitable for a key in a {@link HashMap} or a value in {@link HashSet}. + * @param numerator Integer numerator, floating point value, string or another {@link Fraction} to convert. + * @param denominator Integer denominator (defaults to `1`). + * @returns An immutable rational number. + */ +export function F( + numerator: FractionValue, + denominator?: number +): DeepReadonly { + return Object.freeze(new Fraction(numerator, denominator)); +} diff --git a/src/hashable.ts b/src/hashable.ts new file mode 100644 index 0000000..c8d0f8f --- /dev/null +++ b/src/hashable.ts @@ -0,0 +1,261 @@ +import {DeepReadonly, Primitive} from 'ts-essentials'; + +/** + * A hashable type expected to remain immutable so that it can be used as a dictionary / hash map key. + */ +export abstract class Hashable { + abstract strictEquals(other: Hashable | Primitive): boolean; + + abstract valueOf(): Primitive; +} + +/** + * Python style dictionary implementation using hashable keys. + */ +export class HashMap | Primitive, Value> + implements Map +{ + private map: Map; + + constructor(entries?: Iterable<[Key, Value]>) { + this.map = new Map(); + if (entries === undefined) { + return; + } + for (const [key, value] of entries) { + this.set(key, value); + } + } + + set(key: Key, value: Value) { + if (typeof key !== 'object' || key === null) { + const subEntries = this.map.get(key) ?? []; + for (let i = 0; i < subEntries.length; ++i) { + const [existing] = subEntries[i]; + if (key === existing) { + subEntries[i] = [key, value]; + return this; + } + } + subEntries.push([key, value]); + this.map.set(key, subEntries); + return this; + } + const hash = key.valueOf(); + const subEntries = this.map.get(hash) ?? []; + for (let i = 0; i < subEntries.length; ++i) { + const [existing] = subEntries[i]; + if (key.strictEquals(existing)) { + subEntries[i] = [key, value]; + return this; + } + } + subEntries.push([key, value]); + this.map.set(hash, subEntries); + return this; + } + + get(key: Key) { + if (typeof key !== 'object' || key === null) { + const subEntries = this.map.get(key); + if (subEntries === undefined) { + return undefined; + } + for (const [existing, value] of subEntries) { + if (key === existing) { + return value; + } + } + return undefined; + } + const hash = key.valueOf(); + const subEntries = this.map.get(hash); + if (subEntries === undefined) { + return undefined; + } + for (const [existing, value] of subEntries) { + if (key.strictEquals(existing)) { + return value; + } + } + return undefined; + } + + clear(): void { + this.map.clear(); + } + + delete(key: Key): boolean { + if (typeof key !== 'object' || key === null) { + const subEntries = this.map.get(key); + if (subEntries === undefined) { + return false; + } + for (let i = 0; i < subEntries.length; ++i) { + const [existing] = subEntries[i]; + if (key === existing) { + subEntries.splice(i, 1); + if (!subEntries.length) { + this.map.delete(key); + } + return true; + } + } + return false; + } + const hash = key.valueOf(); + const subEntries = this.map.get(hash); + if (subEntries === undefined) { + return false; + } + for (let i = 0; i < subEntries.length; ++i) { + const [existing] = subEntries[i]; + if (key.strictEquals(existing)) { + subEntries.splice(i, 1); + if (!subEntries.length) { + this.map.delete(hash); + } + return true; + } + } + return false; + } + + has(key: Key): boolean { + if (typeof key !== 'object' || key === null) { + const subEntries = this.map.get(key); + if (subEntries === undefined) { + return false; + } + for (const [existing] of subEntries) { + if (key === existing) { + return true; + } + } + return false; + } + const hash = key.valueOf(); + const subEntries = this.map.get(hash); + if (subEntries === undefined) { + return false; + } + for (const [existing] of subEntries) { + if (key.strictEquals(existing)) { + return true; + } + } + return false; + } + + get size(): number { + let total = 0; + for (const subEntries of this.map.values()) { + total += subEntries.length; + } + return total; + } + + *entries(): IterableIterator<[Key, Value]> { + for (const subEntries of this.map.values()) { + for (const entry of subEntries) { + yield entry; + } + } + } + + *keys(): IterableIterator { + for (const subEntries of this.map.values()) { + for (const [key] of subEntries) { + yield key; + } + } + } + + *values(): IterableIterator { + for (const subEntries of this.map.values()) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const [_, value] of subEntries) { + yield value; + } + } + } + + *[Symbol.iterator]() { + yield* this.entries(); + } + + get [Symbol.toStringTag]() { + return 'HashMap'; + } + + forEach( + callbackfn: (value: Value, key: Key, map: HashMap) => void, + thisArg?: any + ): void { + callbackfn = callbackfn.bind(thisArg); + for (const subEntries of this.map.values()) { + for (const [key, value] of subEntries) { + callbackfn(value, key, this); + } + } + } +} + +/** + * Python style set implementation using hashable values. + */ +export class HashSet | Primitive> + implements Set +{ + private hashmap: HashMap; + constructor(values?: Iterable) { + this.hashmap = new HashMap(); + if (values === undefined) { + return; + } + for (const value of values) { + this.hashmap.set(value, null); + } + } + add(value: T) { + this.hashmap.set(value, null); + return this; + } + clear(): void { + this.hashmap.clear(); + } + delete(value: T): boolean { + return this.hashmap.delete(value); + } + has(value: T): boolean { + return this.hashmap.has(value); + } + get size() { + return this.hashmap.size; + } + *entries(): IterableIterator<[T, T]> { + for (const key of this.hashmap.keys()) { + yield [key, key]; + } + } + *keys(): IterableIterator { + yield* this.hashmap.keys(); + } + *values(): IterableIterator { + yield* this.hashmap.keys(); + } + *[Symbol.iterator]() { + yield* this.hashmap.keys(); + } + + get [Symbol.toStringTag]() { + return 'HashSet'; + } + forEach( + callbackfn: (value: T, value2: T, set: Set) => void, + thisArg?: any + ): void { + callbackfn = callbackfn.bind(thisArg); + this.hashmap.forEach((_, key) => callbackfn(key, key, this)); + } +} diff --git a/src/index.ts b/src/index.ts index 2e914f8..908f5e1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ export * from './conversion'; export * from './combinations'; export * from './monzo'; export * from './approximation'; +export * from './hashable'; export interface AnyArray { [key: number]: any; @@ -124,52 +125,6 @@ export function iteratedEuclid(params: Iterable) { return coefs; } -/** - * Collection of unique fractions. - */ -export class FractionSet extends Set { - /** - * Check `value` membership. - * @param value Value to check for membership. - * @returns A boolean asserting whether an element is present with the given value in the `FractionSet` object or not. - */ - has(value: Fraction) { - for (const other of this) { - if (other.equals(value)) { - return true; - } - } - return false; - } - - /** - * Appends `value` to the `FractionSet` object. - * @param value Value to append. - * @returns The `FractionSet` object with added value. - */ - add(value: Fraction) { - if (this.has(value)) { - return this; - } - super.add(value); - return this; - } - - /** - * Removes the element associated to the `value`. - * @param value Value to remove. - * @returns A boolean asserting whether an element was successfully removed or not. `FractionSet.prototype.has(value)` will return `false` afterwards. - */ - delete(value: Fraction) { - for (const other of this) { - if (other.equals(value)) { - return super.delete(other); - } - } - return false; - } -} - // https://stackoverflow.com/a/37716142 // step 1: a basic LUT with a few steps of Pascal's triangle const BINOMIALS = [ diff --git a/src/monzo.ts b/src/monzo.ts index cce980e..a8a01be 100644 --- a/src/monzo.ts +++ b/src/monzo.ts @@ -12,7 +12,7 @@ export type Monzo = number[]; * @param b The second monzo. * @returns `true` if the two values are equal when interpreted as fractions. */ -export function monzosEqual(a: Monzo, b: Monzo) { +export function monzosEqual(a: Readonly, b: Readonly) { if (a === b) { return true; } @@ -38,6 +38,8 @@ export function monzosEqual(a: Monzo, b: Monzo) { return true; } +// TODO: Make types readonly where applicable. + /** * Add two monzos. * @param a The first monzo.