test: ensure that a function requiring conversion depends on converter
All checks were successful
/ test (pull_request) Successful in 17s

This test turned out to be a very discerning one. Adding it uncovered
  numerous bugs, large and small, in the TypeDispatcher. The most major
  one was that a Map distinguishes keys by strict equality, and hence
  since every lookup for a cached behavior was using a newly-generated array
  of types, the cache was never being hit. So it looked like methods were
  being updated as dependencies changed, when what was really happening
  was that the behavior was simply being regenerated from scratch on every
  call, which would not be performant as the prototype scaled.
  This bug is now fixed (by switching to a third-party ArrayKeyedMap), along
  with many smaller bugs too numerous to list.

  It should now be feasible to go through Pocomath and add all of the functions
  that depend on numbers and booleans only.
This commit is contained in:
Glen Whitney 2025-04-12 00:07:18 -07:00
parent bfc64f3789
commit f38a2d5e88
11 changed files with 151 additions and 61 deletions

View file

@ -18,4 +18,7 @@
devDependencies: {
mocha: '^11.1.0',
},
dependencies: {
'array-keyed-map': '^2.1.3',
},
}

9
pnpm-lock.yaml generated
View file

@ -7,6 +7,10 @@ settings:
importers:
.:
dependencies:
array-keyed-map:
specifier: ^2.1.3
version: 2.1.3
devDependencies:
mocha:
specifier: ^11.1.0
@ -49,6 +53,9 @@ packages:
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
array-keyed-map@2.1.3:
resolution: {integrity: sha512-JIUwuFakO+jHjxyp4YgSiKXSZeC0U+R1jR94bXWBcVlFRBycqXlb+kH9JHxBGcxnVuSqx5bnn0Qz9xtSeKOjiA==}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@ -398,6 +405,8 @@ snapshots:
argparse@2.0.1: {}
array-keyed-map@2.1.3: {}
balanced-match@1.0.2: {}
binary-extensions@2.3.0: {}

View file

@ -1,3 +1,5 @@
import {onType} from './helpers.js'
const typeObject = {} // have to make sure there is only one
export const types = () => typeObject
@ -5,7 +7,8 @@ export const types = () => typeObject
export class Type {
constructor(f, options = {}) {
this.test = f
this.from = new Map(options.from ?? [])
this.from = options.from ?? onType() // empty Implementations if no ...
// ... conversions specified
}
toString() {
return this.name || `[Type ${this.test}]`

View file

@ -1,11 +1,10 @@
import {typeOf, Type, bootstrapTypes} from './Type.js'
import ArrayKeyedMap from 'array-keyed-map'
import {
Implementations, isPlainFunction, isPlainObject, onType
} from './helpers.js'
import {
alwaysMatches, matched, needsCollection, Any, Multiple
} from './TypePatterns.js'
import {bootstrapTypes, Returns, typeOf, Type} from './Type.js'
import {matched, needsCollection, Passthru} from './TypePatterns.js'
export class TypeDispatcher {
constructor(...specs) {
@ -37,25 +36,39 @@ export class TypeDispatcher {
continue
}
if (typeof val === 'function') {
val = onType(Multiple(Any), val)
val = onType(Passthru, val)
}
if (val instanceof Implementations) {
if (!(key in this)) {
// need to set up the item
// Need to "bootstrap" the item:
// We initially define it with a temporary getter, only
// because we don't know whether ultimately it will produce
// a function or a non-callable value, and the "permanent"
// getter we want depends on which it turns out to be. This
// situation means it's not supported to replace a key
// corresponding to a method, after it's been fetched once,
// with a definition that produces a non-callable value;
// the TypeDispatcher will go on returning a function, and
// even if you call that function, it will throw an error
// when it tries to call the non-callable value.
// Conversely, if you try to replace a key corresponding
// to a non-callable value, after it's been fetched once,
// with a function, it will work, but it will not be able to
// perform type dispatch on that function.
Object.defineProperty(this, key, {
enumerable: true,
configurable: true,
get: () => {
let tryValue
let tryTypes = []
try {
tryValue = this.resolve(key, [])
} catch {
// Has no value for the empty type list, so
// find a type list it will have a value for
tryTypes =
this._implementations[key][0][0].sampleTypes()
tryValue = this.resolve(key, tryTypes)
// Has no value for the empty type list, so therefore
// it must be a method, as there is no way to supply
// any types for a non-function value. Hence, we can
// just make tryValue any plain function, since it is
// never actually used, just its type analyzed.
tryValue = () => undefined
}
// Redefine the property according to what sort of
// entity it is:
@ -74,7 +87,7 @@ export class TypeDispatcher {
})
return standard
}
if (tryTypes.length) tryValue = undefined
if (typeof tryValue === 'object') {
if (!('resolve' in tryValue)) {
tryValue.resolve = types => this.resolve(key, types)
@ -92,6 +105,7 @@ export class TypeDispatcher {
})
return get()
}
Object.defineProperty(this, key, {
enumerable: true,
configurable: true,
@ -100,13 +114,16 @@ export class TypeDispatcher {
return tryValue
}
})
// Finally, initialize the other data for this key:
this._implementations[key] = []
this._behaviors[key] = new Map()
this._dependencies[key] = new Map()
this._behaviors[key] = new ArrayKeyedMap()
this._dependencies[key] = new ArrayKeyedMap()
}
// Now add all of the patterns of this implementation:
for (const [pattern, result] of val.patterns) {
if (alwaysMatches(pattern)) {
if (pattern === Passthru) {
if (key in this._fallbacks) this._disengageFallback(key)
this._fallbacks[key] = result
} else {
@ -128,7 +145,7 @@ export class TypeDispatcher {
}
// install value as a catchall value
this.merge({[key]: onType(Multiple(Any), val)})
this.merge({[key]: onType(Passthru, val)})
}
}
@ -151,7 +168,10 @@ export class TypeDispatcher {
} else {
const from = state.pos++
if ('actual' in elt) { // incorporate conversion
const convert = elt.matched.from.get(elt.actual)(this)
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])
}
@ -167,14 +187,17 @@ export class TypeDispatcher {
if (this.resolve._genDepsOf?.length) this._addToDeps(key, types)
const behave = this._behaviors[key]
// Return the cached resolution if it's there
if (behave.has(types)) return behave.get(types)
// Otherwise, perform the resolution and cache the result
const imps = this._implementations[key]
let needItem = true
let item
let pattern
let template
if (imps.length) {
for (const options of [{}, {convert: true}]) {
let pattern
for ([pattern, item] of imps) {
let finalIndex
;[finalIndex, template] = pattern.match(types, options)
@ -189,50 +212,59 @@ export class TypeDispatcher {
if (needItem && key in this._fallbacks) {
needItem = false
item = this._fallbacks[key]
template = [types]
template = types
}
if (needItem) {
throw new TypeError(`no matching definition of '${key}' on '${types}'`)
}
if (!isPlainFunction(item) || 'returns' in item) {
// If this key is producing a non-function value, we're done
if (!isPlainFunction(item)) {
behave.set(types, item)
return item
}
// item is a Factory. We have to use it to build the behavior
// item is a function, either a direct behavior or
// a factory. We have to use it to build the final behavior
// First set up to record dependencies
if (!('_genDepsOf' in this.resolve)) {
this.resolve._genDepsOf = []
}
this.resolve._genDepsOf.push([key, types])
let theBehavior = () => undefined
try {
theBehavior = item(
DependencyRecorder(this, [], this), matched(template))
} catch {
// Oops, didn't work as a factory, so guess we were wrong.
// Just make it the direct value for this key on these types:
behave.set(types, item)
return item
} finally {
this.resolve._genDepsOf.pop()
}
if (typeof theBehavior === 'function'
&& theBehavior.length
&& needsCollection(template)
) {
// have to wrap the behavior to collect the actual arguments
// in the way corresponding to the template. That may generate
// more dependencies:
this.resolve._genDepsOf.push([key, types])
this.resolve._genDepsOf.push([key, types]) // Important: make sure
// not to return without popping _genDepsOf
if (!('returns' in item)) {
// looks like a factory
try {
const collectFunction = this._generateCollectFunction(template)
theBehavior = (...args) => theBehavior(...collectFunction(args))
} finally {
this.resolve._genDepsOf.pop()
theBehavior = item(
DependencyRecorder(this, [], this), matched(template))
} catch {
// Oops, didn't work as a factory, so guess we were wrong.
// Just make it the direct value for this key on these types:
theBehavior = item
}
} else theBehavior = item
let finalBehavior = theBehavior
if (typeof theBehavior === 'function') {
const returning = theBehavior.returns
if (!returning) {
throw new TypeError(
`No return type specified for ${key} on ${types}`)
}
if (theBehavior.length && needsCollection(template)) {
// have to wrap the behavior to collect the actual arguments
// in the way corresponding to the template. Generating that
// argument transformer may generate more dependencies.
const morph = this._generateCollectFunction(template)
finalBehavior =
Returns(returning, (...args) => theBehavior(...morph(args)))
}
}
behave.set(types, theBehavior)
return theBehavior
this.resolve._genDepsOf.pop() // OK, now it's safe to return
behave.set(types, finalBehavior)
return finalBehavior
}
// Method called to invalidate a set of behaviors

View file

@ -20,8 +20,13 @@ class MatchTypePattern extends TypePattern {
const actual = typeSequence[position]
if (position < typeSequence.length) {
if (actual === this.type) return [position + 1, actual]
if (options.convert && this.type.from.has(actual)) {
return [position + 1, {actual, matched: this.type}]
if (options.convert) {
for (const [pattern, convertor] of this.type.from.patterns) {
const [pos] = pattern.match([actual])
if (pos === 1) {
return [position + 1, {actual, convertor, matched: this.type}]
}
}
}
}
return [-1, Undefined]
@ -131,8 +136,18 @@ class MultiPattern extends TypePattern {
}
export const Multiple = item => new MultiPattern(item)
export const alwaysMatches =
pat => pat instanceof MultiPattern && pat.pattern instanceof AnyPattern
// Like Multiple(Any) except leaves the argument list alone; it doesn't
// chunk it into a single Array of all arguments
class PassthruPattern extends TypePattern {
match(typeSequence, options={}) {
const position = options.position ?? 0
return [typeSequence.length, typeSequence.slice(position)]
}
sampleTypes() {return []}
}
export const Passthru = new PassthruPattern()
// returns the template just of matched types, dropping any actual types
export const matched = (template) => {

View file

@ -1,7 +1,8 @@
import assert from 'assert'
import {TypeDispatcher} from '../TypeDispatcher.js'
import * as numbers from '#number/all.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 {Any} from "#core/TypePatterns.js"
import {Returns} from "#core/Type.js"
@ -47,4 +48,15 @@ describe('TypeDispatcher', () => {
gnmath.merge({multiply: plain((a,b) => Math.floor(a) * b)})
assert.strictEqual(gnmath.square(-2.5), 7.5)
})
it('detects dependencies on conversion operations', () => {
const bgn = new TypeDispatcher(booleans, generics, numbers)
const {BooleanT, NumberT} = bgn.types
assert(!bgn._behaviors.negate.has([BooleanT]))
assert.strictEqual(bgn.negate(true), -1)
assert(bgn._behaviors.negate.has([BooleanT]))
const deps = bgn._dependencies.negate
bgn.merge({number: onType([BooleanT], Returns(NumberT, b => b ? 2 : 0))})
assert(!bgn._behaviors.negate.has([BooleanT]))
assert.strictEqual(bgn.negate(true), -2)
})
})

View file

@ -1,5 +1,7 @@
import assert from 'assert'
import {pattern, Any, Multiple, Optional} from '../TypePatterns.js'
import {
pattern, Any, Multiple, Optional, needsCollection
} from '../TypePatterns.js'
import {Undefined, TypeOfTypes} from '../Type.js'
describe('Type patterns', () => {
@ -93,4 +95,10 @@ describe('Type patterns', () => {
whyNot.sampleTypes(), [Undefined, TypeOfTypes])
assert(whyNot.equal(whyNot))
})
it('determines whether a template needs a collection function', () => {
assert(!needsCollection([Undefined]))
assert(needsCollection([Undefined, [Undefined, Undefined]]))
assert(needsCollection(
[Undefined, {actual: Undefined, matched: TypeOfTypes}]))
})
})

View file

@ -9,8 +9,8 @@ describe('Core helpers', () => {
it('defines what Implementations are', () => {
const imps = onType(Undefined, 7, [TypeOfTypes, Undefined], -3)
assert(imps instanceof Implementations)
assert(imps.patterns instanceof Map)
assert(imps.patterns.keys().every(k => k instanceof TypePattern))
assert(imps.patterns instanceof Array)
assert(imps.patterns.every(([k]) => k instanceof TypePattern))
})
it('detects plain objects', () => {

View file

@ -2,12 +2,12 @@ import {pattern} from './TypePatterns.js'
export class Implementations {
constructor(imps) {
this.patterns = new Map()
this.patterns = []
this._add(imps)
}
_add(imps) {
for (let i = 0; i < imps.length; ++i) {
this.patterns.set(pattern(imps[i]), imps[++i])
this.patterns.push([pattern(imps[i]), imps[++i]])
}
}
also(...imps) {

View file

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

View file

@ -12,7 +12,14 @@ describe('NumberT Type', () => {
})
it('can convert from BooleanT to NumberT', () => {
const cnvBtoN = NumberT.from.get(BooleanT)(math)
const convertImps = NumberT.from
let cnvBtoN
for (const [pattern, convFactory] of convertImps.patterns) {
if (pattern.match([BooleanT])) {
cnvBtoN = convFactory(math)
break
}
}
assert.strictEqual(cnvBtoN(true), 1)
assert.strictEqual(cnvBtoN(false), 0)
})