From 686cd93927922ce5a7fa8e68556f0faf1bcd01c3 Mon Sep 17 00:00:00 2001 From: Glen Whitney Date: Tue, 15 Apr 2025 16:23:55 -0700 Subject: [PATCH] feat: more utility functions Adds type constants zero and one, and allows you to obtain them directly from a type object. This facility creates a behavior with a parametric type: the type of `math.zero(T)` where `T` is a Type object (i.e., has type `TypeOfTypes`) depends not just on that type TypeOfTypes, but instead on the _value_ of the argument `T`. Since nanomath is not (yet?) equipped to handle typing such a method, we just set its return type to a new constant NotAType that (hopefully) does not work with the rest of the type system. Also allows you to compute `zero` and `one` from an example value, rather than from the type object itself. Adds utility function `isZero` to test if a value is zero. As usual so far, the additions uncovered some remaining bugs, which this PR fixes. For example, there was a problem in that resolution of the `one` method was failing because the `Any` pattern was blocking matching of the `TypeOfTypes` pattern. Although we may eventually need to sort the patterns for a given method to maintain a reasonable matching order, for now the solution was just to move the two patterns into the same source file and explicitly order them. (With the way onType and Implementations are currently implemented, the proper ordering is more general to more specific, i.e. later implementations supersede earlier ones. Adds many new tests, as always. --- src/boolean/BooleanT.js | 5 +- src/core/README.md | 7 +++ src/core/Type.js | 8 ++- src/core/TypeDispatcher.js | 37 ++++++------ src/core/__test__/TypeDispatcher.spec.js | 11 +++- src/core/helpers.js | 7 +++ src/coretypes/__test__/utils.spec.js | 14 +++++ src/coretypes/all.js | 1 + src/coretypes/utils.js | 26 ++++++++ src/generic/__test__/relational.spec.js | 12 ++++ src/generic/__test__/utils.spec.js | 13 ++++ src/generic/all.js | 1 + src/generic/relational.js | 76 ++++++++++++++---------- src/generic/utils.js | 10 ++++ src/number/NumberT.js | 2 + src/number/__test__/relational.spec.js | 1 + src/number/type.js | 3 +- 17 files changed, 181 insertions(+), 53 deletions(-) create mode 100644 src/coretypes/__test__/utils.spec.js create mode 100644 src/coretypes/utils.js create mode 100644 src/generic/__test__/utils.spec.js create mode 100644 src/generic/utils.js diff --git a/src/boolean/BooleanT.js b/src/boolean/BooleanT.js index 8d26974..f2792ed 100644 --- a/src/boolean/BooleanT.js +++ b/src/boolean/BooleanT.js @@ -1,3 +1,6 @@ import {Type} from '#core/Type.js' -export const BooleanT = new Type(n => typeof n === 'boolean') +export const BooleanT = new Type(n => typeof n === 'boolean', { + zero: false, + one: true +}) diff --git a/src/core/README.md b/src/core/README.md index 10d3401..0cdfd69 100644 --- a/src/core/README.md +++ b/src/core/README.md @@ -13,6 +13,13 @@ As of this writing, the only two types required to be in a TypeDispatcher are Undefined (the type inhabited only by `undefined`) and TypeOfTypes (the type inhabited exactly by Type objects). +There is also a constant NotAType which is the type-world analogue of NaN for +numbers. It is occasionally used for the rare behavior that truly does not +return any particular type, such as the method `zero` that takes a Type and +returns its zero element. However, it does not really work as a Type, and in +particular, do _not_ merge it into any TypeDispatcher -- it will disrupt the +type and method resolution process. + ## Core methods Similarly, as of this writing the only methods that must be in a TypeDispatcher diff --git a/src/core/Type.js b/src/core/Type.js index 7c728a2..d95e5b7 100644 --- a/src/core/Type.js +++ b/src/core/Type.js @@ -2,14 +2,20 @@ export class Type { constructor(f, options = {}) { this.test = f this.from = options.from ?? {patterns: []} // mock empty Implementations + if ('zero' in options) this.zero = options.zero + if ('one' in options) this.one = options.one } toString() { return this.name || `[Type ${this.test}]` } } -export const Undefined = new Type(t => typeof t === 'undefined') +export const Undefined = new Type( + t => typeof t === 'undefined', + {zero: undefined, one: undefined}) export const TypeOfTypes = new Type(t => t instanceof Type) +export const NotAType = new Type(t => true) // Danger, do not merge! +NotAType._doNotMerge = true export const Returns = (type, f) => (f.returns = type, f) diff --git a/src/core/TypeDispatcher.js b/src/core/TypeDispatcher.js index 503b577..fdcad33 100644 --- a/src/core/TypeDispatcher.js +++ b/src/core/TypeDispatcher.js @@ -1,7 +1,7 @@ import ArrayKeyedMap from 'array-keyed-map' import { - Implementations, ImplementationsGenerator, + Implementations, ImplementationsGenerator, ResolutionError, isPlainFunction, isPlainObject, onType, types } from './helpers.js' import {bootstrapTypes, Returns, whichType, Type} from './Type.js' @@ -80,6 +80,9 @@ export class TypeDispatcher { // Now dispatch on what sort of thing we are supposed to merge: if (val instanceof Type) { + if (val._doNotMerge) { + throw new TypeError(`attempt to merge unusable type '${val}'`) + } this.types[key] = val val.name = key continue @@ -219,22 +222,21 @@ export class TypeDispatcher { // transforms them per the given template, to massage them into the form // expected by a behavior associated with the TypePattern that produced // the template. - _generateCollectFunction(template, state = {pos: 0}) { - const extractors = [] - for (const elt of template) { - if (Array.isArray(elt)) { - extractors.push(this._generateCollectFunction(elt, state)) - } else { - const from = state.pos++ - if ('actual' in elt) { // incorporate conversion - let convert = elt.convertor - if (!convert.returns) { // it's a factory that produces convert - convert = convert(this, elt.actual) - } - extractors.push(args => convert(args[from])) - } else extractors.push(args => args[from]) + _generateCollectFunction(template, state=false) { + if (!Array.isArray(template)) { + const from = state ? state.pos++ : 0 + let extractor = args => args[from] + if ('actual' in template) { // incorporate conversion + let convert = template.convertor + // Check if it's a factory: + if (!convert.returns) convert = convert(this, template.actual) + extractor = args => convert(args[from]) } + return state ? extractor : args => [extractor(args)] } + state ||= {pos: 0} + const extractors = template.map( + item => this._generateCollectFunction(item, state)) return args => extractors.map(f => f(args)) } @@ -284,7 +286,8 @@ export class TypeDispatcher { template = types } if (needItem) { - throw new TypeError(`no matching definition of '${key}' on '${types}'`) + throw new ResolutionError( + `no matching definition of '${key}' on '${types}'`) } // If this key is producing a non-function value, we're done if (!isPlainFunction(item)) { @@ -316,7 +319,7 @@ export class TypeDispatcher { matched(template)) } catch (e) { e.message = `Error in factory for ${key} on ${types} ` - + `(match data ${template}): ${e}` + + `(match data ${template}): ${e.message}` throw e } } else theBehavior = item diff --git a/src/core/__test__/TypeDispatcher.spec.js b/src/core/__test__/TypeDispatcher.spec.js index 81d91a3..25ed721 100644 --- a/src/core/__test__/TypeDispatcher.spec.js +++ b/src/core/__test__/TypeDispatcher.spec.js @@ -3,9 +3,9 @@ import {TypeDispatcher} from '../TypeDispatcher.js' import * as booleans from '#boolean/all.js' import * as generics from '#generic/all.js' import * as numbers from '#number/all.js' -import {onType} from "#core/helpers.js" +import {onType, ResolutionError} from "#core/helpers.js" import {Any} from "#core/TypePatterns.js" -import {Returns} from "#core/Type.js" +import {Returns, NotAType} from "#core/Type.js" import {plain} from "#number/helpers.js" describe('TypeDispatcher', () => { @@ -17,6 +17,7 @@ describe('TypeDispatcher', () => { const {NumberT, TypeOfTypes, Undefined} = incremental.types assert(NumberT.test(7)) assert.strictEqual(incremental.add(-1.5, 0.5), -1) + assert.throws(() => incremental.add(7, undefined), ResolutionError) // Make Undefined act like zero: incremental.merge({add: onType( [Undefined, Any], (_m, [_U, T]) => Returns(T, (_a, b) => b), @@ -30,7 +31,7 @@ describe('TypeDispatcher', () => { assert.strictEqual( incremental.add.resolve([Undefined, NumberT]).returns, NumberT) - // Oops, changed my mind, make it work like NaN with numbers: + // Oops, changed my mind ;-), make it work like NaN with numbers: const alwaysNaN = Returns(NumberT, () => NaN) incremental.merge({add: onType( [Undefined, NumberT], alwaysNaN, @@ -63,4 +64,8 @@ describe('TypeDispatcher', () => { assert(!bgn._behaviors.negate.has([BooleanT])) assert.strictEqual(bgn.negate(true), -2) }) + it('disallows merging NotAType', () => { + const doomed = new TypeDispatcher() + assert.throws(() => doomed.merge({NaT: NotAType}), TypeError) + }) }) diff --git a/src/core/helpers.js b/src/core/helpers.js index 8ecded5..f741859 100644 --- a/src/core/helpers.js +++ b/src/core/helpers.js @@ -36,6 +36,13 @@ export class ImplementationsGenerator { export const types = new ImplementationsGenerator(() => onType(Passthru, {})) +export class ResolutionError extends TypeError { + constructor(...args) { + super(...args) + this.name = 'ResolutionError' + } +} + export const isPlainObject = obj => { if (typeof obj !== 'object') return false if (!obj) return false // excludes null diff --git a/src/coretypes/__test__/utils.spec.js b/src/coretypes/__test__/utils.spec.js new file mode 100644 index 0000000..732bf1f --- /dev/null +++ b/src/coretypes/__test__/utils.spec.js @@ -0,0 +1,14 @@ +import assert from 'assert' +import math from '#nanomath' + +describe('core type utility functions', () => { + it('identifies zero and one elements in most types', () => { + assert.strictEqual(math.zero(math.types.NumberT), 0) + assert.strictEqual(math.zero(math.types.Undefined), undefined) + assert.strictEqual(math.one(math.types.BooleanT), true) + assert.throws(() => math.one(math.types.TypeOfTypes), RangeError) + assert.strictEqual(math.one(-7.5), 1) + assert.strictEqual(math.one(undefined), undefined) + assert.strictEqual(math.zero(true), false) + }) +}) diff --git a/src/coretypes/all.js b/src/coretypes/all.js index ff64f94..18b23af 100644 --- a/src/coretypes/all.js +++ b/src/coretypes/all.js @@ -1 +1,2 @@ export * from './relational.js' +export * from './utils.js' diff --git a/src/coretypes/utils.js b/src/coretypes/utils.js new file mode 100644 index 0000000..6bc35f0 --- /dev/null +++ b/src/coretypes/utils.js @@ -0,0 +1,26 @@ +import {onType} from '#core/helpers.js' +import {NotAType, Returns, TypeOfTypes} from '#core/Type.js' +import {Any} from "#core/TypePatterns.js" + +export const zero = onType( + Any, (math, T) => { + const z = math.zero(T) + return Returns(T, () => z) + }, + TypeOfTypes, Returns(NotAType, t => { + if ('zero' in t) return t.zero + throw new RangeError(`type '${t}' has no zero element`) + }) +) + +export const one = onType( + Any, (math, T) => { + const unit = math.one(T) + return Returns(T, () => unit) + }, + TypeOfTypes, Returns(NotAType, t => { + if ('one' in t) return t.one + throw new RangeError( + `type '${t}' has no unit element designated as "one"`) + }) +) diff --git a/src/generic/__test__/relational.spec.js b/src/generic/__test__/relational.spec.js index 5d61b76..6506f6a 100644 --- a/src/generic/__test__/relational.spec.js +++ b/src/generic/__test__/relational.spec.js @@ -2,6 +2,7 @@ import assert from 'assert' import math from '#nanomath' import * as numbers from '#number/all.js' import * as generics from '#generic/all.js' +import {ResolutionError} from '#core/helpers.js' import {TypeDispatcher} from '#core/TypeDispatcher.js' describe('generic relational functions', () => { @@ -38,4 +39,15 @@ describe('generic relational functions', () => { assert.strictEqual(equal(0, 1.1e-15), 1) assert.strictEqual(equal(0, 1.1e-13), 0) }) + it('performs three-way comparison', () => { + const {compare} = math + assert.strictEqual(compare(-0.4e-15, +0.4e-15), 0) + assert.strictEqual(compare(2.2, true), 1) + assert.strictEqual(compare(-Infinity, 7), -1) + assert(isNaN(compare(NaN, 0))) + assert(isNaN(compare(false, NaN))) + assert(isNaN(compare(NaN, NaN))) + assert.strictEqual(compare(true, false), 1) + assert.throws(() => compare(undefined, -1), ResolutionError) + }) }) diff --git a/src/generic/__test__/utils.spec.js b/src/generic/__test__/utils.spec.js new file mode 100644 index 0000000..e178a2d --- /dev/null +++ b/src/generic/__test__/utils.spec.js @@ -0,0 +1,13 @@ +import assert from 'assert' +import math from '#nanomath' + +describe('generic utility functions', () => { + it('tests whether an element is zero', () => { + const {isZero} = math + assert(!isZero(3)) + assert(isZero(3e-16)) + assert(isZero(false)) + assert(!isZero(true)) + assert(isZero(undefined)) + }) +}) diff --git a/src/generic/all.js b/src/generic/all.js index 951d1f6..4dea4d2 100644 --- a/src/generic/all.js +++ b/src/generic/all.js @@ -1,3 +1,4 @@ export * as arithmetic from './arithmetic.js' export * as configuration from './config.js' export * as relational from './relational.js' +export * as utilities from './utils.js' diff --git a/src/generic/relational.js b/src/generic/relational.js index 7e7f92f..acade73 100644 --- a/src/generic/relational.js +++ b/src/generic/relational.js @@ -1,36 +1,52 @@ import {ReturnsAs} from './helpers.js' import {onType} from '#core/helpers.js' +import {Returns} from '#core/Type.js' import {Any, matched} from '#core/TypePatterns.js' import {boolnum} from '#number/helpers.js' +import {NumberT} from '#number/NumberT.js' -export const equal = onType( - [Any, Any], (math, [T, U]) => { - // Finding the correct signature of `indistinguishable` to use for - // testing (approximate) equality is tricky, because T or U might - // need to be converted for the sake of comparison, and some types - // allow tolerances for equality and others don't. So the plan is - // we first look up without tolerances, then we check the config for - // the matching type, and then we look up with tolerances. - let exactChecker +export const equal = onType([Any, Any], (math, [T, U]) => { + // Finding the correct signature of `indistinguishable` to use for + // testing (approximate) equality is tricky, because T or U might + // need to be converted for the sake of comparison, and some types + // allow tolerances for equality and others don't. So the plan is + // we first look up without tolerances, then we check the config for + // the matching type, and then we look up with tolerances. + let exactChecker + try { + exactChecker = math.indistinguishable.resolve([T, U]) + } catch { // can't compare, so no way they can be equal + return boolnum(() => false)(math) + } + // Get the type of the first argument to the matching checker: + const ByType = matched(exactChecker.template).flat()[0] + // Now see if there are tolerances for that type: + const typeConfig = math.resolve('config', [ByType]) + if ('relTol' in typeConfig) { try { - exactChecker = math.indistinguishable.resolve([T, U]) - } catch { // can't compare, so no way they can be equal - return boolnum(() => false)(math) - } - // Get the type of the first argument to the matching checker: - const ByType = matched(exactChecker.template).flat()[0] - // Now see if there are tolerances for that type: - const typeConfig = math.resolve('config', [ByType]) - if ('relTol' in typeConfig) { - try { - const {relTol, absTol} = typeConfig - const RT = math.typeOf(relTol) - const AT = math.typeOf(absTol) - const approx = math.indistinguishable.resolve([T, U, RT, AT]) - return ReturnsAs( - approx, (t, u) => approx(t, u, relTol, absTol)) - } catch {} // fall through to case with no tolerances - } - // either no tolerances or no matching signature for indistinguishable - return exactChecker - }) + const {relTol, absTol} = typeConfig + const RT = math.typeOf(relTol) + const AT = math.typeOf(absTol) + const approx = math.indistinguishable.resolve([T, U, RT, AT]) + return ReturnsAs( + approx, (t, u) => approx(t, u, relTol, absTol)) + } catch {} // fall through to case with no tolerances + } + // either no tolerances or no matching signature for indistinguishable + return exactChecker +}) + +// now that we have `equal` and `exceeds`, pretty much everything else should +// be easy: + +export const compare = onType([Any, Any], (math, [T, U]) => { + const eq = math.equal.resolve([T, U]) + const gt = math.exceeds.resolve([T, U]) + const isTnan = math.isnan.resolve([T]) + const isUnan = math.isnan.resolve([U]) + return Returns(NumberT, (t, u) => { + if (isTnan(t) || isUnan(u)) return NaN + if (eq(t,u)) return 0 + return gt(t, u) ? 1 : -1 + }) +}) diff --git a/src/generic/utils.js b/src/generic/utils.js new file mode 100644 index 0000000..fc78903 --- /dev/null +++ b/src/generic/utils.js @@ -0,0 +1,10 @@ +import {ReturnsAs} from './helpers.js' +import {onType} from '#core/helpers.js' +import {Returns} from '#core/Type.js' +import {Any} from "#core/TypePatterns.js" + +export const isZero = (math, [T]) => { + const z = math.zero(T) + const eq = math.equal.resolve([T, T]) + return ReturnsAs(eq, x => eq(z, x)) +} diff --git a/src/number/NumberT.js b/src/number/NumberT.js index 99c53ff..332fab6 100644 --- a/src/number/NumberT.js +++ b/src/number/NumberT.js @@ -4,4 +4,6 @@ import {BooleanT} from '#boolean/BooleanT.js' export const NumberT = new Type(n => typeof n === 'number', { from: onType(BooleanT, math => math.number.resolve([BooleanT])), + one: 1, + zero: 0 }) diff --git a/src/number/__test__/relational.spec.js b/src/number/__test__/relational.spec.js index 9908482..ce5d8eb 100644 --- a/src/number/__test__/relational.spec.js +++ b/src/number/__test__/relational.spec.js @@ -12,6 +12,7 @@ describe('number relational functions', () => { assert(exceeds(-1e101, -Infinity)) assert(!exceeds(NaN, 0)) assert(!exceeds(0, NaN)) + assert(!exceeds(NaN, NaN)) assert(!exceeds(2, 2)) }) it('checks for exact equality', () => { diff --git a/src/number/type.js b/src/number/type.js index 3a9ebfc..9ec8fed 100644 --- a/src/number/type.js +++ b/src/number/type.js @@ -7,6 +7,7 @@ const num = f => Returns(NumberT, f) export const number = plain(a => a) number.also( - BooleanT, num(p => p ? 1 : 0), + // conversions from Boolean should be consistent with one and zero: + BooleanT, num(p => p ? NumberT.one : NumberT.zero), [], num(() => 0) )