feat: more utility functions
All checks were successful
/ test (pull_request) Successful in 17s

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.
This commit is contained in:
Glen Whitney 2025-04-15 16:23:55 -07:00
parent d3f2bc09b7
commit 686cd93927
17 changed files with 181 additions and 53 deletions

View file

@ -1,3 +1,6 @@
import {Type} from '#core/Type.js' 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
})

View file

@ -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 Undefined (the type inhabited only by `undefined`) and TypeOfTypes (the type
inhabited exactly by Type objects). 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 ## Core methods
Similarly, as of this writing the only methods that must be in a TypeDispatcher Similarly, as of this writing the only methods that must be in a TypeDispatcher

View file

@ -2,14 +2,20 @@ export class Type {
constructor(f, options = {}) { constructor(f, options = {}) {
this.test = f this.test = f
this.from = options.from ?? {patterns: []} // mock empty Implementations 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() { toString() {
return this.name || `[Type ${this.test}]` 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 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) export const Returns = (type, f) => (f.returns = type, f)

View file

@ -1,7 +1,7 @@
import ArrayKeyedMap from 'array-keyed-map' import ArrayKeyedMap from 'array-keyed-map'
import { import {
Implementations, ImplementationsGenerator, Implementations, ImplementationsGenerator, ResolutionError,
isPlainFunction, isPlainObject, onType, types isPlainFunction, isPlainObject, onType, types
} from './helpers.js' } from './helpers.js'
import {bootstrapTypes, Returns, whichType, Type} from './Type.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: // Now dispatch on what sort of thing we are supposed to merge:
if (val instanceof Type) { if (val instanceof Type) {
if (val._doNotMerge) {
throw new TypeError(`attempt to merge unusable type '${val}'`)
}
this.types[key] = val this.types[key] = val
val.name = key val.name = key
continue continue
@ -219,22 +222,21 @@ export class TypeDispatcher {
// transforms them per the given template, to massage them into the form // transforms them per the given template, to massage them into the form
// expected by a behavior associated with the TypePattern that produced // expected by a behavior associated with the TypePattern that produced
// the template. // the template.
_generateCollectFunction(template, state = {pos: 0}) { _generateCollectFunction(template, state=false) {
const extractors = [] if (!Array.isArray(template)) {
for (const elt of template) { const from = state ? state.pos++ : 0
if (Array.isArray(elt)) { let extractor = args => args[from]
extractors.push(this._generateCollectFunction(elt, state)) if ('actual' in template) { // incorporate conversion
} else { let convert = template.convertor
const from = state.pos++ // Check if it's a factory:
if ('actual' in elt) { // incorporate conversion if (!convert.returns) convert = convert(this, template.actual)
let convert = elt.convertor extractor = args => convert(args[from])
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])
} }
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)) return args => extractors.map(f => f(args))
} }
@ -284,7 +286,8 @@ export class TypeDispatcher {
template = types template = types
} }
if (needItem) { 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 this key is producing a non-function value, we're done
if (!isPlainFunction(item)) { if (!isPlainFunction(item)) {
@ -316,7 +319,7 @@ export class TypeDispatcher {
matched(template)) matched(template))
} catch (e) { } catch (e) {
e.message = `Error in factory for ${key} on ${types} ` e.message = `Error in factory for ${key} on ${types} `
+ `(match data ${template}): ${e}` + `(match data ${template}): ${e.message}`
throw e throw e
} }
} else theBehavior = item } else theBehavior = item

View file

@ -3,9 +3,9 @@ import {TypeDispatcher} from '../TypeDispatcher.js'
import * as booleans from '#boolean/all.js' import * as booleans from '#boolean/all.js'
import * as generics from '#generic/all.js' import * as generics from '#generic/all.js'
import * as numbers from '#number/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 {Any} from "#core/TypePatterns.js"
import {Returns} from "#core/Type.js" import {Returns, NotAType} from "#core/Type.js"
import {plain} from "#number/helpers.js" import {plain} from "#number/helpers.js"
describe('TypeDispatcher', () => { describe('TypeDispatcher', () => {
@ -17,6 +17,7 @@ describe('TypeDispatcher', () => {
const {NumberT, TypeOfTypes, Undefined} = incremental.types const {NumberT, TypeOfTypes, Undefined} = incremental.types
assert(NumberT.test(7)) assert(NumberT.test(7))
assert.strictEqual(incremental.add(-1.5, 0.5), -1) assert.strictEqual(incremental.add(-1.5, 0.5), -1)
assert.throws(() => incremental.add(7, undefined), ResolutionError)
// Make Undefined act like zero: // Make Undefined act like zero:
incremental.merge({add: onType( incremental.merge({add: onType(
[Undefined, Any], (_m, [_U, T]) => Returns(T, (_a, b) => b), [Undefined, Any], (_m, [_U, T]) => Returns(T, (_a, b) => b),
@ -30,7 +31,7 @@ describe('TypeDispatcher', () => {
assert.strictEqual( assert.strictEqual(
incremental.add.resolve([Undefined, NumberT]).returns, incremental.add.resolve([Undefined, NumberT]).returns,
NumberT) 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) const alwaysNaN = Returns(NumberT, () => NaN)
incremental.merge({add: onType( incremental.merge({add: onType(
[Undefined, NumberT], alwaysNaN, [Undefined, NumberT], alwaysNaN,
@ -63,4 +64,8 @@ describe('TypeDispatcher', () => {
assert(!bgn._behaviors.negate.has([BooleanT])) assert(!bgn._behaviors.negate.has([BooleanT]))
assert.strictEqual(bgn.negate(true), -2) assert.strictEqual(bgn.negate(true), -2)
}) })
it('disallows merging NotAType', () => {
const doomed = new TypeDispatcher()
assert.throws(() => doomed.merge({NaT: NotAType}), TypeError)
})
}) })

View file

@ -36,6 +36,13 @@ export class ImplementationsGenerator {
export const types = new ImplementationsGenerator(() => onType(Passthru, {})) export const types = new ImplementationsGenerator(() => onType(Passthru, {}))
export class ResolutionError extends TypeError {
constructor(...args) {
super(...args)
this.name = 'ResolutionError'
}
}
export const isPlainObject = obj => { export const isPlainObject = obj => {
if (typeof obj !== 'object') return false if (typeof obj !== 'object') return false
if (!obj) return false // excludes null if (!obj) return false // excludes null

View file

@ -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)
})
})

View file

@ -1 +1,2 @@
export * from './relational.js' export * from './relational.js'
export * from './utils.js'

26
src/coretypes/utils.js Normal file
View file

@ -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"`)
})
)

View file

@ -2,6 +2,7 @@ import assert from 'assert'
import math from '#nanomath' import math from '#nanomath'
import * as numbers from '#number/all.js' import * as numbers from '#number/all.js'
import * as generics from '#generic/all.js' import * as generics from '#generic/all.js'
import {ResolutionError} from '#core/helpers.js'
import {TypeDispatcher} from '#core/TypeDispatcher.js' import {TypeDispatcher} from '#core/TypeDispatcher.js'
describe('generic relational functions', () => { 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-15), 1)
assert.strictEqual(equal(0, 1.1e-13), 0) 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)
})
}) })

View file

@ -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))
})
})

View file

@ -1,3 +1,4 @@
export * as arithmetic from './arithmetic.js' export * as arithmetic from './arithmetic.js'
export * as configuration from './config.js' export * as configuration from './config.js'
export * as relational from './relational.js' export * as relational from './relational.js'
export * as utilities from './utils.js'

View file

@ -1,36 +1,52 @@
import {ReturnsAs} from './helpers.js' import {ReturnsAs} from './helpers.js'
import {onType} from '#core/helpers.js' import {onType} from '#core/helpers.js'
import {Returns} from '#core/Type.js'
import {Any, matched} from '#core/TypePatterns.js' import {Any, matched} from '#core/TypePatterns.js'
import {boolnum} from '#number/helpers.js' import {boolnum} from '#number/helpers.js'
import {NumberT} from '#number/NumberT.js'
export const equal = onType( export const equal = onType([Any, Any], (math, [T, U]) => {
[Any, Any], (math, [T, U]) => { // Finding the correct signature of `indistinguishable` to use for
// Finding the correct signature of `indistinguishable` to use for // testing (approximate) equality is tricky, because T or U might
// testing (approximate) equality is tricky, because T or U might // need to be converted for the sake of comparison, and some types
// need to be converted for the sake of comparison, and some types // allow tolerances for equality and others don't. So the plan is
// allow tolerances for equality and others don't. So the plan is // we first look up without tolerances, then we check the config for
// we first look up without tolerances, then we check the config for // the matching type, and then we look up with tolerances.
// the matching type, and then we look up with tolerances. let exactChecker
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 { try {
exactChecker = math.indistinguishable.resolve([T, U]) const {relTol, absTol} = typeConfig
} catch { // can't compare, so no way they can be equal const RT = math.typeOf(relTol)
return boolnum(() => false)(math) const AT = math.typeOf(absTol)
} const approx = math.indistinguishable.resolve([T, U, RT, AT])
// Get the type of the first argument to the matching checker: return ReturnsAs(
const ByType = matched(exactChecker.template).flat()[0] approx, (t, u) => approx(t, u, relTol, absTol))
// Now see if there are tolerances for that type: } catch {} // fall through to case with no tolerances
const typeConfig = math.resolve('config', [ByType]) }
if ('relTol' in typeConfig) { // either no tolerances or no matching signature for indistinguishable
try { return exactChecker
const {relTol, absTol} = typeConfig })
const RT = math.typeOf(relTol)
const AT = math.typeOf(absTol) // now that we have `equal` and `exceeds`, pretty much everything else should
const approx = math.indistinguishable.resolve([T, U, RT, AT]) // be easy:
return ReturnsAs(
approx, (t, u) => approx(t, u, relTol, absTol)) export const compare = onType([Any, Any], (math, [T, U]) => {
} catch {} // fall through to case with no tolerances const eq = math.equal.resolve([T, U])
} const gt = math.exceeds.resolve([T, U])
// either no tolerances or no matching signature for indistinguishable const isTnan = math.isnan.resolve([T])
return exactChecker 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
})
})

10
src/generic/utils.js Normal file
View file

@ -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))
}

View file

@ -4,4 +4,6 @@ import {BooleanT} from '#boolean/BooleanT.js'
export const NumberT = new Type(n => typeof n === 'number', { export const NumberT = new Type(n => typeof n === 'number', {
from: onType(BooleanT, math => math.number.resolve([BooleanT])), from: onType(BooleanT, math => math.number.resolve([BooleanT])),
one: 1,
zero: 0
}) })

View file

@ -12,6 +12,7 @@ describe('number relational functions', () => {
assert(exceeds(-1e101, -Infinity)) assert(exceeds(-1e101, -Infinity))
assert(!exceeds(NaN, 0)) assert(!exceeds(NaN, 0))
assert(!exceeds(0, NaN)) assert(!exceeds(0, NaN))
assert(!exceeds(NaN, NaN))
assert(!exceeds(2, 2)) assert(!exceeds(2, 2))
}) })
it('checks for exact equality', () => { it('checks for exact equality', () => {

View file

@ -7,6 +7,7 @@ const num = f => Returns(NumberT, f)
export const number = plain(a => a) export const number = plain(a => a)
number.also( 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) [], num(() => 0)
) )