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'
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
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

View file

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

View file

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

View file

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

View file

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

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 './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 * 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)
})
})

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 configuration from './config.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 {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
})
})

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', {
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(NaN, 0))
assert(!exceeds(0, NaN))
assert(!exceeds(NaN, NaN))
assert(!exceeds(2, 2))
})
it('checks for exact equality', () => {

View file

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