feat: config and approximate equality #19

Merged
glen merged 4 commits from config into main 2025-04-16 04:23:49 +00:00
17 changed files with 181 additions and 53 deletions
Showing only changes of commit 686cd93927 - Show all commits

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