diff --git a/.yarnrc.yml b/.yarnrc.yml index 91e03cefdc..ed0f43849b 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -9,6 +9,6 @@ catalogs: eslint-plugin-eslint-comments: ^3.2.0 eslint-plugin-import: ^2.31.0 typescript: ~5.9.2 - "@fast-check/ava": ^1.1.5 + "@fast-check/ava": ^2.0.2 enableScripts: false nodeLinker: node-modules diff --git a/packages/marshal/NEWS.md b/packages/marshal/NEWS.md index fc44a026e4..30d4138875 100644 --- a/packages/marshal/NEWS.md +++ b/packages/marshal/NEWS.md @@ -1,5 +1,10 @@ User-visible changes in `@endo/marshal`: +# Next release + +- `FullRankCover` is deprecated. Instead, use `provideStaticRanks(encodePassable)['*'].cover`. +- `getPassStyleCover(passStyle)` is deprecated. Instead, use `provideStaticRanks(encodePassable)[passStyle].cover`. + # 1.8.0 (2025-07-11) - Introduces an environment variable config option `ENDO_RANK_STRINGS` to change the rank ordering of strings from the current (incorrect) ordering by UTF-16 code unit used by JavaScript's `<` and `.sort()` operations to (correct and OCapN-conformant) ordering by Unicode code point. It currently defaults to "utf16-code-unit-order", matching the previously-unconditional behavior. diff --git a/packages/marshal/index.js b/packages/marshal/index.js index ac2de6a37b..d7516e6580 100644 --- a/packages/marshal/index.js +++ b/packages/marshal/index.js @@ -24,6 +24,7 @@ export { compareAntiRank, makeFullOrderComparatorKit, getPassStyleCover, + provideStaticRanks, intersectRankCovers, unionRankCovers, } from './src/rankOrder.js'; diff --git a/packages/marshal/package.json b/packages/marshal/package.json index c48b9ee3ee..9f0928d33b 100644 --- a/packages/marshal/package.json +++ b/packages/marshal/package.json @@ -48,6 +48,7 @@ "@endo/env-options": "workspace:^", "@endo/errors": "workspace:^", "@endo/eventual-send": "workspace:^", + "@endo/memoize": "workspace:^", "@endo/nat": "workspace:^", "@endo/pass-style": "workspace:^", "@endo/promise-kit": "workspace:^" diff --git a/packages/marshal/src/rankOrder.js b/packages/marshal/src/rankOrder.js index a8d8ed9f44..5613e58d66 100644 --- a/packages/marshal/src/rankOrder.js +++ b/packages/marshal/src/rankOrder.js @@ -1,5 +1,7 @@ +import { objectMap } from '@endo/common/object-map.js'; import { getEnvironmentOption as getenv } from '@endo/env-options'; import { Fail, q } from '@endo/errors'; +import { memoize } from '@endo/memoize'; import { getTag, passStyleOf, nameForPassableSymbol } from '@endo/pass-style'; import { passStylePrefixes, @@ -116,6 +118,7 @@ harden(compareNumerics); /** * @typedef {Record} PassStyleRanksRecord + * @typedef {PassStyleRanksRecord & { '*': { cover: RankCover } }} StaticRanksRecord */ const passStyleRanks = /** @type {PassStyleRanksRecord} */ ( @@ -156,10 +159,42 @@ harden(passStyleRanks); * * @param {PassStyle} passStyle * @returns {RankCover} + * @deprecated Coverage depends upon format; use {@link provideStaticRanks} + * instead. */ export const getPassStyleCover = passStyle => passStyleRanks[passStyle].cover; harden(getPassStyleCover); +// Use singleton null as a sentinel for detecting an encodePassable output +// prefix (e.g., the "~" that starts compactOrdered output). +const { null: nullTypePrefix } = passStylePrefixes; +nullTypePrefix.length === 1 || + Fail`internal: null pass style prefix must be a single character, not ${q(nullTypePrefix)}`; + +export const provideStaticRanks = memoize( + /** @type {(encodePassable: ((p: Passable) => string)) => StaticRanksRecord} */ + encodePassable => { + const encodedNull = encodePassable(null); + encodedNull.endsWith(nullTypePrefix) || + Fail`expected null encoding ${q(nullTypePrefix)} not found in ${q(encodedNull)}`; + const prefix = encodedNull.slice(0, -nullTypePrefix.length); + return harden({ + __proto__: null, + ...objectMap(passStyleRanks, ({ index, cover }) => { + const [lower, upper] = cover; + const prefixedCover = /** @type {RankCover} */ ([ + `${prefix}${lower}`, + `${prefix}${upper}`, + ]); + return { index, cover: prefixedCover }; + }), + // The full upper bound ends with `{` because `|` is reserved to exceed + // all pass style prefixes; @see {@link passStylePrefixes}. + '*': { cover: [prefix, `${prefix}{`] }, + }); + }, +); + /** * @type {WeakMap>} */ @@ -474,7 +509,11 @@ export const getIndexCover = (sorted, compare, [leftKey, rightKey]) => { }; harden(getIndexCover); -/** @type {RankCover} */ +/** + * @type {RankCover} + * @deprecated Coverage depends upon format; use + * `provideStaticRanks(encodePassable)['*'].cover` instead. + */ export const FullRankCover = harden(['', '{']); /** @@ -528,6 +567,7 @@ const minRank = (compare, a, b) => (compare(a, b) <= 0 ? a : b); * @returns {RankCover} */ export const unionRankCovers = (compare, covers) => { + covers.length > 0 || Fail`Cannot union empty covers`; /** * @param {RankCover} a * @param {RankCover} b @@ -537,7 +577,7 @@ export const unionRankCovers = (compare, covers) => { minRank(compare, leftA, leftB), maxRank(compare, rightA, rightB), ]; - return covers.reduce(unionRankCoverPair, ['{', '']); + return covers.reduce(unionRankCoverPair); }; harden(unionRankCovers); @@ -547,6 +587,7 @@ harden(unionRankCovers); * @returns {RankCover} */ export const intersectRankCovers = (compare, covers) => { + covers.length > 0 || Fail`Cannot intersect empty covers`; /** * @param {RankCover} a * @param {RankCover} b @@ -556,7 +597,7 @@ export const intersectRankCovers = (compare, covers) => { maxRank(compare, leftA, leftB), minRank(compare, rightA, rightB), ]; - return covers.reduce(intersectRankCoverPair, ['', '{']); + return covers.reduce(intersectRankCoverPair); }; harden(intersectRankCovers); diff --git a/packages/marshal/test/encodePassable.test.js b/packages/marshal/test/encodePassable.test.js index dc6ec48791..3b702209c2 100644 --- a/packages/marshal/test/encodePassable.test.js +++ b/packages/marshal/test/encodePassable.test.js @@ -15,7 +15,9 @@ import { import { compareRank, makeFullOrderComparatorKit } from '../src/rankOrder.js'; import { unsortedSample } from '../tools/marshal-test-data.js'; -const { arbPassable } = makeArbitraries(fc, ['byteArray']); +const { arbPassable } = makeArbitraries(fc, { + excludePassStyles: ['byteArray'], +}); const statelessEncodePassableLegacy = makeEncodePassable(); diff --git a/packages/pass-style/tools/arb-passable.js b/packages/pass-style/tools/arb-passable.js index 911511e010..869c9cd23e 100644 --- a/packages/pass-style/tools/arb-passable.js +++ b/packages/pass-style/tools/arb-passable.js @@ -1,8 +1,23 @@ // @ts-check +import { objectMap } from '@endo/common/object-map.js'; import '../src/types.js'; import { Far } from '../src/make-far.js'; import { makeTagged } from '../src/makeTagged.js'; -import { passableSymbolForName } from '../src/symbol.js'; +import { nameForPassableSymbol, passableSymbolForName } from '../src/symbol.js'; + +/** + * @import { Arbitrary } from 'fast-check'; + * @import { Key } from '@endo/patterns'; + * @import { Passable, CopyTagged } from '../src/types.js'; + */ + +/** Avoid wasting time on overly large data structures. */ +const maxLength = 100; + +/** @type ((reason: string) => never) */ +const reject = reason => { + throw Error(reason); +}; /** * The only elements with identity. Everything else should be equal @@ -13,12 +28,42 @@ export const exampleBob = Far('bob', {}); export const exampleCarol = Far('carol', {}); /** + * @template {Passable} T + * @template {Passable} U + * @typedef {[T, U?] | [T[], U[]?] | [Record, Record?] | [CopyTagged>, CopyTagged>?]} LiftedInput + */ + +/** + * Make fast-check arbitraries for various kinds of passables. + * The recursive types arbKey and arbPassable also support "lifting" to replace + * each produced value with a [value, lifted] pair, where `lifted` is the output + * of a `lift([leaf], detail)` or `lift([composite, liftedParts], detail)` + * invocation in which `detail` is drawn from `arbLiftingDetail`. + * + * @template {Passable} [Lifted=Passable] + * @template [LiftingDetail=unknown] * @param {typeof import('@fast-check/ava').fc} fc - * @param {Array<'byteArray'>} [exclusions] + * @param {object} [options] + * @param {Array<'byteArray'>} [options.excludePassStyles] + * @param {(input: LiftedInput, detail: LiftingDetail) => T | Lifted} [options.lift] + * @param {Arbitrary} [options.arbLiftingDetail] */ -export const makeArbitraries = (fc, exclusions = []) => { - const arbString = fc.oneof(fc.string(), fc.fullUnicodeString()); +export const makeArbitraries = ( + fc, + { + excludePassStyles = [], + lift = /** @type {any} */ (([x]) => x), + arbLiftingDetail = /** @type {any} */ (fc.constant(undefined)), + } = {}, +) => { + const arbString = fc.oneof( + { withCrossShrink: true }, + fc.string({ unit: 'grapheme-ascii' }), + fc.string({ unit: 'binary' }), + ); + const notThen = arbString.filter(s => s !== 'then'); + /** @type {(Arbitrary)[]} */ const keyableLeaves = [ fc.constantFrom(null, undefined, false, true), arbString, @@ -26,7 +71,12 @@ export const makeArbitraries = (fc, exclusions = []) => { // TODO Once we flip symbol representation, we should revisit everywhere // we make a special case of "@@". It may no longer be appropriate. .filter(s => !s.startsWith('@@')) - .map(s => passableSymbolForName(s)), + .map( + s => passableSymbolForName(s), + v => + nameForPassableSymbol(/** @type {any} */ (v)) ?? + reject('not a passable symbol'), + ), // primordial symbols and registered lookalikes fc.constantFrom( ...Object.getOwnPropertyNames(Symbol).flatMap(k => { @@ -41,12 +91,13 @@ export const makeArbitraries = (fc, exclusions = []) => { // because we may go through a phase where only `sliceToImmutable` is // provided when the shim is run on Hermes. // See https://github.com/endojs/endo/pull/2785 - // @ts-expect-error How can the shim add to the `ArrayBuffer` type? ...[fc.uint8Array().map(arr => arr.buffer.sliceToImmutable())].filter( - () => !exclusions.includes('byteArray'), + () => !excludePassStyles.includes('byteArray'), ), fc.constantFrom(-0, NaN, Infinity, -Infinity), - fc.record({}), + /** @type {Arbitrary>} */ ( + fc.record({}, { noNullPrototype: true }) + ), fc.constantFrom(exampleAlice, exampleBob, exampleCarol), ]; @@ -54,108 +105,180 @@ export const makeArbitraries = (fc, exclusions = []) => { const arbLeaf = fc.oneof( ...keyableLeaves, - arbString.map(s => Error(s)), + arbString.map( + s => Error(s), + v => (v instanceof Error ? v.message : reject('not an Error')), + ), // unresolved promise fc.constant(new Promise(() => {})), ); - const { keyDag } = fc.letrec(tie => { - return { - keyDag: fc.oneof( - { withCrossShrink: true }, - arbKeyLeaf, - fc.array(tie('keyDag')), - fc.dictionary( - arbString.filter(s => s !== 'then'), - tie('keyDag'), + const recoverabilityCache = new WeakMap(); + /** @type {(arb: Arbitrary, mapper: (input: T) => U) => Arbitrary} */ + const recoverableMap = (arb, mapper) => + arb.map( + recoverableInput => { + const result = mapper(recoverableInput); + recoverabilityCache.set(result, recoverableInput); + return result; + }, + v => { + const found = recoverabilityCache.get(/** @type {any} */ (v)); + if (found !== undefined || recoverabilityCache.has(found)) return found; + reject('not a cached output'); + }, + ); + + /** + * @template {Passable} [T=Passable] + * @template {Lifted} [U=Lifted] + * @typedef {[T, U]} LiftedPair + */ + /** @type {(arb: Arbitrary, makeLiftArgs: (input: T) => Parameters[0]) => Arbitrary} */ + const withLiftingDetail = (arb, makeLiftArgs) => + recoverableMap(fc.tuple(arb, arbLiftingDetail), input => { + const [specimen, detail] = input; + const args = makeLiftArgs(specimen); + return /** @type {LiftedPair} */ ([args[0], lift(args, detail)]); + }); + + const recursives = fc.letrec(tie => ({ + liftedKeyDag: fc.oneof( + { withCrossShrink: true }, + // Base case: lift a leaf into a [leaf, lifted] pair. + withLiftingDetail(arbKeyLeaf, leaf => [leaf]), + // Recursive cases: compose lifted pairs, project into an [unlifted, liftedParts] pair, + // and lift that. + withLiftingDetail( + fc.oneof( + // copyArray + recoverableMap( + fc.array(tie('liftedKeyDag'), { maxLength }), + pairsArr => [0, 1].map(i => pairsArr.map(pair => pair[i])), + ), + // copyRecord + recoverableMap( + fc.dictionary(notThen, tie('liftedKeyDag'), { maxKeys: maxLength }), + pairsRec => [0, 1].map(i => objectMap(pairsRec, p => p[i])), + ), ), + ([compositeKey, liftedParts]) => + /** @type {any} */ ([compositeKey, liftedParts]), ), - }; - }); - - const { arbDag } = fc.letrec(tie => { - return { - arbDag: fc.oneof( - { withCrossShrink: true }, - arbLeaf, - fc.array(tie('arbDag')), - fc.dictionary( - arbString.filter(s => s !== 'then'), - tie('arbDag'), - ), - // A promise for a passable. - tie('arbDag').map(v => Promise.resolve(v)), - // A tagged value, either of arbitrary type with arbitrary payload - // or of known type with arbitrary or explicitly valid payload. - // Ordered by increasing complexity. - fc - .oneof( - fc.record({ type: arbString, payload: tie('arbDag') }), - fc.record({ - type: fc.constantFrom('copySet'), - payload: fc.oneof( - tie('arbDag'), - // copySet valid payload is an array of unique passables. - // TODO: A valid copySet payload must be a reverse sorted array, - // so we should generate some of those as well. - fc.uniqueArray(tie('arbDag')), + ), + liftedArbDag: fc.oneof( + { withCrossShrink: true }, + // Base case: lift a leaf into a [leaf, lifted] pair. + withLiftingDetail(arbLeaf, leaf => [leaf]), + // Recursive cases: compose lifted pairs, project into an [unlifted, liftedParts] pair, + // and lift that. + withLiftingDetail( + fc.oneof( + // copyArray + recoverableMap( + fc.array(tie('liftedArbDag'), { maxLength }), + pairsArr => [0, 1].map(i => pairsArr.map(pair => pair[i])), + ), + // copyRecord + recoverableMap( + fc.dictionary(notThen, tie('liftedArbDag'), { maxKeys: maxLength }), + pairsRec => [0, 1].map(i => objectMap(pairsRec, p => p[i])), + ), + // promise + recoverableMap(tie('liftedArbDag'), pair => + [0, 1].map(i => Promise.resolve(pair[i])), + ), + // arbitrary tagged (but maybe using a known tag) + recoverableMap( + fc.tuple( + fc.oneof( + arbString, + fc.constantFrom('copySet', 'copyBag', 'copyMap'), ), + tie('liftedArbDag'), + ), + ([tag, payloadPair]) => + [0, 1].map(i => makeTagged(tag, payloadPair[i])), + ), + // copySet: an array of unique Passables + // TODO: A valid copySet payload must be a reverse sorted array. + recoverableMap( + fc.uniqueArray(tie('liftedKeyDag'), { + maxLength, + selector: pair => pair[0], }), - fc.record({ - type: fc.constantFrom('copyBag'), - payload: fc.oneof( - tie('arbDag'), - // copyBag valid payload is an array of [passable, count] tuples - // in which each passable is unique. - // TODO: A valid copyBag payload must be a reverse sorted array, - // so we should generate some of those as well. - fc.uniqueArray(fc.tuple(tie('arbDag'), fc.bigInt()), { - selector: entry => entry[0], - }), + pairsArr => + [0, 1].map(i => + makeTagged( + 'copySet', + pairsArr.map(pair => pair[i]), + ), ), - }), - fc.record({ - type: fc.constantFrom('copyMap'), - payload: fc.oneof( - tie('arbDag'), - // copyMap valid payload is a - // `{ keys: Passable[], values: Passable[]}` - // record in which keys are unique and both arrays have the - // same length. - // TODO: In a valid copyMap payload, the keys must be a - // reverse sorted array, so we should generate some of - // those as well. - fc - .uniqueArray( - fc.record({ key: tie('arbDag'), value: tie('arbDag') }), - { selector: entry => entry.key }, - ) - .map(entries => ({ - keys: entries.map(({ key }) => key), - values: entries.map(({ value }) => value), - })), + ), + // copyBag: an array of [Passable, count] tuples in which each Passable is unique + // TODO: A valid copyBag payload must be a reverse sorted array. + recoverableMap( + fc.uniqueArray( + fc.tuple(tie('liftedKeyDag'), fc.bigInt({ min: 1n })), + { maxLength, selector: pairKeyedEntry => pairKeyedEntry[0][0] }, + ), + pairKeyedEntries => + [0, 1].map(i => + makeTagged( + 'copyBag', + pairKeyedEntries.map(([pair, value]) => [pair[i], value]), + ), ), + ), + // copyMap: a `{ keys: Passable[], values: Passable[] }` record in which keys are unique and both arrays have the same length + // TODO: A valid copyMap payload must be a reverse sorted array. + recoverableMap( + fc.uniqueArray(fc.tuple(tie('liftedKeyDag'), tie('liftedArbDag')), { + maxLength, + selector: pairKeyedEntry => pairKeyedEntry[0][0], }), - ) - .map(({ type, payload }) => { - const passable = /** @type {import('../src/types.js').Passable} */ ( - payload - ); - return makeTagged(type, passable); - }), + pairKeyedEntries => + [0, 1].map(i => + makeTagged( + 'copyMap', + pairKeyedEntries.map(([keyPair, valuePair]) => [ + keyPair[i], + valuePair[i], + ]), + ), + ), + ), + ), + ([specimen, liftedParts]) => + /** @type {any} */ ([specimen, liftedParts]), ), - }; - }); + ), + })); + const { liftedKeyDag, liftedArbDag } = /** @type {{ + * liftedKeyDag: Arbitrary>, + * liftedArbDag: Arbitrary>, + * }} + */ (recursives); + + /** + * A factory for arbitrary [unliftedKey, liftedKey] pairs. + */ + const arbLiftedKey = liftedKeyDag.map(pair => harden(pair)); + + /** + * A factory for arbitrary [unliftedPassable, liftedPassable] pairs. + */ + const arbLiftedPassable = liftedArbDag.map(pair => harden(pair)); /** - * A factory for arbitrary keys. + * A factory for arbitrary Keys. */ - const arbKey = keyDag.map(x => harden(x)); + const arbKey = arbLiftedKey.map(pair => pair[0]); /** - * A factory for arbitrary passables. + * A factory for arbitrary Passables. */ - const arbPassable = arbDag.map(x => harden(x)); + const arbPassable = arbLiftedPassable.map(pair => pair[0]); return { exampleAlice, @@ -166,5 +289,7 @@ export const makeArbitraries = (fc, exclusions = []) => { arbLeaf, arbKey, arbPassable, + arbLiftedKey, + arbLiftedPassable, }; }; diff --git a/packages/patterns/package.json b/packages/patterns/package.json index 83ddd42fcc..6a2c05ad3c 100644 --- a/packages/patterns/package.json +++ b/packages/patterns/package.json @@ -37,6 +37,7 @@ "@endo/errors": "workspace:^", "@endo/eventual-send": "workspace:^", "@endo/marshal": "workspace:^", + "@endo/memoize": "workspace:^", "@endo/pass-style": "workspace:^", "@endo/promise-kit": "workspace:^" }, diff --git a/packages/patterns/src/patterns/patternMatchers.js b/packages/patterns/src/patterns/patternMatchers.js index 2260045eef..01facb54b1 100644 --- a/packages/patterns/src/patterns/patternMatchers.js +++ b/packages/patterns/src/patterns/patternMatchers.js @@ -21,13 +21,14 @@ import { } from '@endo/pass-style'; import { compareRank, - getPassStyleCover, + provideStaticRanks, intersectRankCovers, unionRankCovers, recordNames, recordValues, qp, } from '@endo/marshal'; +import { memoize } from '@endo/memoize'; import { keyEQ, keyGT, keyGTE, keyLT, keyLTE } from '../keys/compareKeys.js'; import { @@ -51,11 +52,29 @@ import { generateCollectionPairEntries } from '../keys/keycollection-operators.j * @import {CopyArray, CopyRecord, CopyTagged, Passable} from '@endo/pass-style'; * @import {CopySet, CopyBag, ArgGuard, AwaitArgGuard, ConfirmPattern, GetRankCover, InterfaceGuard, MatcherNamespace, MethodGuard, MethodGuardMaker, Pattern, RawGuard, SyncValueGuard, Kind, Limits, AllLimits, Key, DefaultGuardType} from '../types.js'; * @import {MatchHelper, PatternKit} from './types.js'; + * @import {KeyToDBKey} from '../types.js'; */ const { entries, values, hasOwn } = Object; const { ownKeys } = Reflect; +const provideEncodePassableMetadata = memoize( + /** @param {KeyToDBKey} encodePassable */ + encodePassable => { + const staticRanks = provideStaticRanks(encodePassable); + const [encodingPrefix] = staticRanks['*'].cover; + const encodingPrefixLength = encodingPrefix.length; + const inner = harden(['inner']); + const outer = harden(['outer', [inner]]); + const innerEncoded = encodePassable(inner); + const outerEncoded = encodePassable(outer); + const isEmbeddable = outerEncoded.includes( + innerEncoded.slice(encodingPrefixLength), + ); + return { staticRanks, encodingPrefix, encodingPrefixLength, isEmbeddable }; + }, +); + /** @type {WeakSet} */ const patternMemo = new WeakSet(); @@ -75,6 +94,7 @@ let MM; * Exported primarily for testing. */ export const defaultLimits = harden({ + __proto__: null, decimalDigitsLimit: 100, stringLengthLimit: 100_000, symbolNameLengthLimit: 100, @@ -640,27 +660,55 @@ const makePatternKit = () => { // /////////////////////// getRankCover ////////////////////////////////////// + /** @type {(passStyle: PassStyle, encodePassable: KeyToDBKey) => RankCover} */ + const getPassStyleCover = (passStyle, encodePassable) => + provideStaticRanks(encodePassable)[passStyle].cover; + /** @type {GetRankCover} */ const getRankCover = (patt, encodePassable) => { + // This partially validates encodePassable. + const { encodingPrefixLength: epLen, isEmbeddable } = + provideEncodePassableMetadata(encodePassable); + if (isKey(patt)) { const encoded = encodePassable(patt); if (encoded !== undefined) { - return [encoded, `${encoded}~`]; + return [encoded, encoded]; } } + const passStyle = passStyleOf(patt); switch (passStyle) { case 'copyArray': { - // XXX this doesn't get along with the world of cover === pair of - // strings. In the meantime, fall through to the default which - // returns a cover that covers all copyArrays. - // - // const rankCovers = patt.map(p => getRankCover(p, encodePassable)); - // return harden([ - // rankCovers.map(([left, _right]) => left), - // rankCovers.map(([_left, right]) => right), - // ]); - break; + // The fallback below would cover all CopyArrays, but we can do better + // by leveraging a run of initial Keys. + const nonKeyIdx = patt.findIndex(v => !isKey(v)); + nonKeyIdx !== -1 || + Fail`internal: all-Key copyArray ${q(patt)} must itself be a Key`; + if (!isEmbeddable && nonKeyIdx === 0) break; + + // Discover the prefix that will start both bounds by encoding a + // CopyArray consisting of those Keys followed by a null sentinel element. + const sentinel = null; + const embeddedSentinel = encodePassable(sentinel).slice(epLen); + const keyArr = harden([...patt.slice(0, nonKeyIdx), sentinel]); + const encodedKeyArr = encodePassable(keyArr); + const prefixLength = encodedKeyArr.lastIndexOf(embeddedSentinel); + const prefix = encodedKeyArr.slice(0, prefixLength); + + // If encodePassable is not embeddable, just use the key elements. + if (!isEmbeddable) return [`${prefix}`, `${prefix}~`]; + + // Otherwise, combine that prefix with the RankCover of the first + // non-Key element for even tighter bounds. + const [lowerSuffix, upperSuffix] = getRankCover( + patt[nonKeyIdx], + encodePassable, + ); + return [ + `${prefix}${lowerSuffix.slice(epLen)}`, + `${prefix}${upperSuffix.slice(epLen)}`, + ]; } case 'copyRecord': { // XXX this doesn't get along with the world of cover === pair of @@ -670,7 +718,7 @@ const makePatternKit = () => { // const pattKeys = ownKeys(patt); // const pattEntries = harden(pattKeys.map(key => [key, patt[key]])); // const [leftEntriesLimit, rightEntriesLimit] = - // getRankCover(pattEntries); + // getRankCover(pattEntries, encodePassable); // return harden([ // fromUniqueEntries(leftEntriesLimit), // fromUniqueEntries(rightEntriesLimit), @@ -701,6 +749,7 @@ const makePatternKit = () => { // // const [leftElementLimit, rightElementLimit] = getRankCover( // patt.payload[0], + // encodePassable, // ); // return harden([ // makeCopySet([leftElementLimit]), @@ -747,7 +796,7 @@ const makePatternKit = () => { break; // fall through to default } } - return getPassStyleCover(passStyle); + return getPassStyleCover(passStyle, encodePassable); }; /** @@ -778,7 +827,8 @@ const makePatternKit = () => { (reject && reject`match:any payload: ${matcherPayload} - Must be undefined`), - getRankCover: (_matchPayload, _encodePassable) => ['', '{'], + getRankCover: (_matchPayload, encodePassable) => + provideStaticRanks(encodePassable)['*'].cover, }); /** @type {MatchHelper} */ @@ -855,7 +905,8 @@ const makePatternKit = () => { confirmIsWellFormed: confirmPattern, - getRankCover: (_patt, _encodePassable) => ['', '{'], + getRankCover: (_patt, encodePassable) => + matchAnyHelper.getRankCover(undefined, encodePassable), }); /** @type {MatchHelper} */ @@ -897,20 +948,27 @@ const makePatternKit = () => { (reject && reject`match:kind: payload: ${allegedKeyKind} - A kind name must be a string`), - getRankCover: (kind, _encodePassable) => { - let style; - switch (kind) { - case 'copySet': - case 'copyMap': { - style = 'tagged'; - break; - } - default: { - style = kind; - break; - } + getRankCover: (kind, encodePassable) => { + const { staticRanks } = provideEncodePassableMetadata(encodePassable); + + // If `kind` is a pass style, that defines the covering range. + const passStyleCover = kind !== '*' ? staticRanks[kind]?.cover : null; + if (passStyleCover) return passStyleCover; + + // If `kind` is a known {@link Kind}, *that* defines the covering range. + // XXX We really need a registry of known tags to avoid such hard-coding. + if ( + kind === 'copySet' || + kind === 'copyBag' || + kind === 'copyMap' || + kind.startsWith('match:') || + kind.startsWith('guard:') + ) { + return staticRanks.tagged.cover; } - return getPassStyleCover(style); + + // To support future evolution, assume `kind` is an unknown pass style. + return staticRanks['*'].cover; }, }); @@ -939,7 +997,8 @@ const makePatternKit = () => { reject, ), - getRankCover: (_kind, _encodePassable) => getPassStyleCover('tagged'), + getRankCover: (_kind, encodePassable) => + getPassStyleCover('tagged', encodePassable), }); /** @type {MatchHelper} */ @@ -960,8 +1019,8 @@ const makePatternKit = () => { reject, ), - getRankCover: (_matchPayload, _encodePassable) => - getPassStyleCover('bigint'), + getRankCover: (_matchPayload, encodePassable) => + getPassStyleCover('bigint', encodePassable), }); /** @type {MatchHelper} */ @@ -985,9 +1044,9 @@ const makePatternKit = () => { reject, ), - getRankCover: (_matchPayload, _encodePassable) => - // TODO Could be more precise - getPassStyleCover('bigint'), + getRankCover: (_matchPayload, encodePassable) => + // eslint-disable-next-line no-use-before-define + matchGTEHelper.getRankCover(0n, encodePassable), }); /** @type {MatchHelper} */ @@ -1011,8 +1070,8 @@ const makePatternKit = () => { reject, ), - getRankCover: (_matchPayload, _encodePassable) => - getPassStyleCover('string'), + getRankCover: (_matchPayload, encodePassable) => + getPassStyleCover('string', encodePassable), }); /** @type {MatchHelper} */ @@ -1044,8 +1103,8 @@ const makePatternKit = () => { reject, ), - getRankCover: (_matchPayload, _encodePassable) => - getPassStyleCover('symbol'), + getRankCover: (_matchPayload, encodePassable) => + getPassStyleCover('symbol', encodePassable), }); /** @type {MatchHelper} */ @@ -1080,8 +1139,8 @@ const makePatternKit = () => { reject, ), - getRankCover: (_remotableDesc, _encodePassable) => - getPassStyleCover('remotable'), + getRankCover: (_remotableDesc, encodePassable) => + getPassStyleCover('remotable', encodePassable), }); /** @type {MatchHelper} */ @@ -1094,15 +1153,8 @@ const makePatternKit = () => { getRankCover: (rightOperand, encodePassable) => { const passStyle = passStyleOf(rightOperand); - // The prefer-const makes no sense when some of the variables need - // to be `let` - // eslint-disable-next-line prefer-const - let [leftBound, rightBound] = getPassStyleCover(passStyle); - const newRightBound = `${encodePassable(rightOperand)}~`; - if (newRightBound !== undefined) { - rightBound = newRightBound; - } - return [leftBound, rightBound]; + const [lowerBound] = getPassStyleCover(passStyle, encodePassable); + return [lowerBound, encodePassable(rightOperand)]; }, }); @@ -1127,15 +1179,8 @@ const makePatternKit = () => { getRankCover: (rightOperand, encodePassable) => { const passStyle = passStyleOf(rightOperand); - // The prefer-const makes no sense when some of the variables need - // to be `let` - // eslint-disable-next-line prefer-const - let [leftBound, rightBound] = getPassStyleCover(passStyle); - const newLeftBound = encodePassable(rightOperand); - if (newLeftBound !== undefined) { - leftBound = newLeftBound; - } - return [leftBound, rightBound]; + const [, upperBound] = getPassStyleCover(passStyle, encodePassable); + return [encodePassable(rightOperand), upperBound]; }, }); @@ -1195,7 +1240,8 @@ const makePatternKit = () => { reject, ), - getRankCover: _entryPatt => getPassStyleCover('copyRecord'), + getRankCover: (_matchPayload, encodePassable) => + getPassStyleCover('copyRecord', encodePassable), }); /** @type {MatchHelper} */ @@ -1219,7 +1265,8 @@ const makePatternKit = () => { reject, ), - getRankCover: () => getPassStyleCover('copyArray'), + getRankCover: (_matchPayload, encodePassable) => + getPassStyleCover('copyArray', encodePassable), }); /** @type {MatchHelper} */ @@ -1242,8 +1289,8 @@ const makePatternKit = () => { reject, ), - getRankCover: (_matchPayload, _encodePassable) => - getPassStyleCover('byteArray'), + getRankCover: (_matchPayload, encodePassable) => + getPassStyleCover('byteArray', encodePassable), }); /** @type {MatchHelper} */ @@ -1275,7 +1322,8 @@ const makePatternKit = () => { reject, ), - getRankCover: () => getPassStyleCover('tagged'), + getRankCover: (_matchPayload, encodePassable) => + getPassStyleCover('tagged', encodePassable), }); /** @type {MatchHelper} */ @@ -1316,7 +1364,8 @@ const makePatternKit = () => { reject, ), - getRankCover: () => getPassStyleCover('tagged'), + getRankCover: (_matchPayload, encodePassable) => + getPassStyleCover('tagged', encodePassable), }); /** @@ -1540,7 +1589,8 @@ const makePatternKit = () => { reject, ), - getRankCover: () => getPassStyleCover('tagged'), + getRankCover: (_matchPayload, encodePassable) => + getPassStyleCover('tagged', encodePassable), }); /** @type {MatchHelper} */ @@ -1582,7 +1632,8 @@ const makePatternKit = () => { reject, ), - getRankCover: _entryPatt => getPassStyleCover('tagged'), + getRankCover: (_matchPayload, encodePassable) => + getPassStyleCover('tagged', encodePassable), }); /** @@ -1694,11 +1745,10 @@ const makePatternKit = () => { ); }, - getRankCover: ([ - _requiredPatt, - _optionalPatt = undefined, - _restPatt = undefined, - ]) => getPassStyleCover('copyArray'), + getRankCover: ( + [_requiredPatt, _optionalPatt = undefined, _restPatt = undefined], + encodePassable, + ) => getPassStyleCover('copyArray', encodePassable), }); /** @@ -1806,15 +1856,16 @@ const makePatternKit = () => { ); }, - getRankCover: ([ - requiredPatt, - _optionalPatt = undefined, - _restPatt = undefined, - ]) => getPassStyleCover(passStyleOf(requiredPatt)), + getRankCover: ( + [requiredPatt, _optionalPatt = undefined, _restPatt = undefined], + encodePassable, + ) => getPassStyleCover(passStyleOf(requiredPatt), encodePassable), }); /** @type {Record} */ const HelpersByMatchTag = harden({ + __proto__: null, + 'match:any': matchAnyHelper, 'match:and': matchAndHelper, 'match:or': matchOrHelper, @@ -2291,6 +2342,7 @@ const makeInterfaceGuard = (interfaceName, methodGuards, options = {}) => { }; const GuardPayloadShapes = harden({ + __proto__: null, 'guard:awaitArgGuard': AwaitArgGuardPayloadShape, 'guard:rawGuard': RawGuardPayloadShape, 'guard:methodGuard': MethodGuardPayloadShape, diff --git a/packages/patterns/test/rankCover.test.js b/packages/patterns/test/rankCover.test.js new file mode 100644 index 0000000000..0af1604f58 --- /dev/null +++ b/packages/patterns/test/rankCover.test.js @@ -0,0 +1,170 @@ +// @ts-nocheck +import test from '@endo/ses-ava/test.js'; + +import { fc } from '@fast-check/ava'; +import { makeArbitraries } from '@endo/pass-style/tools.js'; + +import { q } from '@endo/errors'; +import { + compareRank, + makePassableKit, + provideStaticRanks, +} from '@endo/marshal'; + +import { isKey } from '../src/keys/checkKey.js'; +import { getRankCover, kindOf, M } from '../src/patterns/patternMatchers.js'; + +/** @import {Implementation} from 'ava'; */ + +/** Avoid wasting time on overly large data structures. */ +const maxLength = 100; + +const formats = ['legacyOrdered', 'compactOrdered']; + +const isInteriorRange = (inner, outer) => + compareRank(outer[0], inner[0]) <= 0 && compareRank(inner[1], outer[1]) <= 0; + +const { + arbKey, + arbPassable, + arbLiftedPassable: arbPassableAndPattern, +} = makeArbitraries(fc, { + excludePassStyles: ['byteArray'], + arbLiftingDetail: fc.oneof( + { withCrossShrink: true }, + { arbitrary: fc.constant(false), weight: 80 }, + { arbitrary: fc.constant(true), weight: 20 }, + ), + lift: ([x, patt = x], shouldMakeMatcher) => { + try { + const matchKind = shouldMakeMatcher ? kindOf(harden(x)) : undefined; + if (matchKind) return M.kind(matchKind); + } catch (_err) { + // eslint-disable-next-line no-empty + } + return patt; + }, +}); + +/** + * @param {string} titlePrefix + * @param {Implementation<[encodePassable: (val: Passable) => string]>} testFn + */ +const testAcrossFormats = (titlePrefix, testFn) => { + for (const format of formats) { + /** @type {Map } */ + const ids = new Map(); + const encodePrefixed = (prefix, val) => { + const foundId = ids.get(val); + if (foundId) return foundId; + const n = Math.floor(Math.random() * 10_000); + const newId = `${prefix}${n.toString().padStart(4, '0')}`; + ids.set(val, newId); + return newId; + }; + const { encodePassable } = makePassableKit({ + format, + encodeRemotable: r => encodePrefixed('r', r), + encodePromise: p => encodePrefixed('?', p), + encodeError: e => encodePrefixed('!', e), + }); + test(`${titlePrefix} - ${format}`, async t => testFn(t, encodePassable)); + } +}; + +testAcrossFormats('static ranks', async (t, encodePassable) => { + t.snapshot(provideStaticRanks(encodePassable)); +}); + +testAcrossFormats( + 'getRankCover for Keys is tight', + async (t, encodePassable) => { + await fc.assert( + fc.property(arbKey, x => { + const encoded = encodePassable(x); + return t.deepEqual(getRankCover(x, encodePassable), [encoded, encoded]); + }), + ); + }, +); + +testAcrossFormats( + 'getRankCover(pattern, encodePassable) covers matching specimens', + async (t, encodePassable) => { + await fc.assert( + fc.property(arbPassableAndPattern, ([specimen, patt]) => { + const encoded = encodePassable(specimen); + const [lower, upper] = getRankCover(patt, encodePassable); + const lowerOk = compareRank(lower, encoded) <= 0; + const upperOk = compareRank(encoded, upper) <= 0; + if (lowerOk && upperOk) { + t.pass(); + return; + } + + const boundsRepr = `[${q(lower)}, ${q(upper)}]`; + // eslint-disable-next-line no-nested-ternary + const failedBounds = lowerOk ? 'UPPER' : upperOk ? 'LOWER' : 'BOTH'; + t.fail( + `***SPECIMEN*** ${q(specimen)} ***as*** ${q(encoded)} failed ${failedBounds} bound(s) of ***PATTERN*** ${q(patt)} ***as*** ${boundsRepr}`, + ); + }), + ); + }, +); + +testAcrossFormats( + 'getRankCover(arrayWithInitialKey, encodePassable) is tighter than "any copyArray"', + async (t, encodePassable) => { + const coverAnyCopyArray = + provideStaticRanks(encodePassable).copyArray.cover; + t.true( + Array.isArray(coverAnyCopyArray) && + coverAnyCopyArray.length === 2 && + coverAnyCopyArray.every(el => typeof el === 'string') && + compareRank(coverAnyCopyArray[0], coverAnyCopyArray[1]) < 0, + `precondition: CopyArray static coverage ${q(coverAnyCopyArray)} is a valid range`, + ); + + let foundNotCovered = false; + await fc.assert( + fc.property( + arbKey, + fc.array(arbPassable, { minLength: 1, maxLength }), + fc.array(arbPassable, { maxLength }), + fc.constantFrom( + x => new Error(String(x)), + x => Promise.resolve(x), + ), + (key, rest, other, makeNonKey) => { + if (rest.every(x => isKey(x))) { + // We need a non-Key, so to avoid wasting the work with fast-check + // `.filter` (which can lead to https://crbug.com/1201626 crashes), + // we just transform the first element as directed. + rest[0] = makeNonKey(rest[0]); + } + const cover = getRankCover(harden([key, ...rest]), encodePassable); + t.true( + isInteriorRange(cover, coverAnyCopyArray), + `leading-Key CopyArray coverage is a subset of any-CopyArray coverage: ${q(cover)} ⊆ ${q(coverAnyCopyArray)}`, + ); + t.true( + coverAnyCopyArray[0] < cover[0] || cover[1] < coverAnyCopyArray[1], + `leading-Key CopyArray coverage is tighter than any-CopyArray coverage: ${q(cover)} ⊊ ${q(coverAnyCopyArray)}`, + ); + + if (!foundNotCovered) { + const encodedOther = encodePassable(harden(other)); + foundNotCovered = + compareRank(encodedOther, cover[0]) < 0 || + compareRank(cover[1], encodedOther) < 0; + } + }, + ), + ); + t.true( + foundNotCovered, + 'at least one CopyArray must be outside of leading-Key coverage', + ); + }, +); diff --git a/packages/patterns/test/snapshots/rankCover.test.js.md b/packages/patterns/test/snapshots/rankCover.test.js.md new file mode 100644 index 0000000000..750a8cdba2 --- /dev/null +++ b/packages/patterns/test/snapshots/rankCover.test.js.md @@ -0,0 +1,227 @@ +# Snapshot report for `test/rankCover.test.js` + +The actual snapshot is saved in `rankCover.test.js.snap`. + +Generated by [AVA](https://avajs.dev). + +## static ranks - legacyOrdered + +> Snapshot 1 + + { + '*': { + cover: [ + '', + '{', + ], + }, + bigint: { + cover: [ + 'n', + 'q', + ], + index: 8, + }, + boolean: { + cover: [ + 'b', + 'c', + ], + index: 6, + }, + byteArray: { + cover: [ + 'a', + 'b', + ], + index: 5, + }, + copyArray: { + cover: [ + '[', + '_', + ], + index: 4, + }, + copyRecord: { + cover: [ + '(', + ')', + ], + index: 1, + }, + error: { + cover: [ + '!', + '"', + ], + index: 0, + }, + null: { + cover: [ + 'v', + 'w', + ], + index: 11, + }, + number: { + cover: [ + 'f', + 'g', + ], + index: 7, + }, + promise: { + cover: [ + '?', + '@', + ], + index: 3, + }, + remotable: { + cover: [ + 'r', + 's', + ], + index: 9, + }, + string: { + cover: [ + 's', + 't', + ], + index: 10, + }, + symbol: { + cover: [ + 'y', + 'z', + ], + index: 12, + }, + tagged: { + cover: [ + ':', + ';', + ], + index: 2, + }, + undefined: { + cover: [ + 'z', + '{', + ], + index: 13, + }, + } + +## static ranks - compactOrdered + +> Snapshot 1 + + { + '*': { + cover: [ + '~', + '~{', + ], + }, + bigint: { + cover: [ + '~n', + '~q', + ], + index: 8, + }, + boolean: { + cover: [ + '~b', + '~c', + ], + index: 6, + }, + byteArray: { + cover: [ + '~a', + '~b', + ], + index: 5, + }, + copyArray: { + cover: [ + '~[', + '~_', + ], + index: 4, + }, + copyRecord: { + cover: [ + '~(', + '~)', + ], + index: 1, + }, + error: { + cover: [ + '~!', + '~"', + ], + index: 0, + }, + null: { + cover: [ + '~v', + '~w', + ], + index: 11, + }, + number: { + cover: [ + '~f', + '~g', + ], + index: 7, + }, + promise: { + cover: [ + '~?', + '~@', + ], + index: 3, + }, + remotable: { + cover: [ + '~r', + '~s', + ], + index: 9, + }, + string: { + cover: [ + '~s', + '~t', + ], + index: 10, + }, + symbol: { + cover: [ + '~y', + '~z', + ], + index: 12, + }, + tagged: { + cover: [ + '~:', + '~;', + ], + index: 2, + }, + undefined: { + cover: [ + '~z', + '~{', + ], + index: 13, + }, + } diff --git a/packages/patterns/test/snapshots/rankCover.test.js.snap b/packages/patterns/test/snapshots/rankCover.test.js.snap new file mode 100644 index 0000000000..5f216ee09a Binary files /dev/null and b/packages/patterns/test/snapshots/rankCover.test.js.snap differ diff --git a/yarn.lock b/yarn.lock index 9782b31e35..40758e7db7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -545,6 +545,7 @@ __metadata: "@endo/eventual-send": "workspace:^" "@endo/init": "workspace:^" "@endo/lockdown": "workspace:^" + "@endo/memoize": "workspace:^" "@endo/nat": "workspace:^" "@endo/pass-style": "workspace:^" "@endo/promise-kit": "workspace:^" @@ -557,7 +558,7 @@ __metadata: languageName: unknown linkType: soft -"@endo/memoize@workspace:packages/memoize": +"@endo/memoize@workspace:^, @endo/memoize@workspace:packages/memoize": version: 0.0.0-use.local resolution: "@endo/memoize@workspace:packages/memoize" dependencies: @@ -709,6 +710,7 @@ __metadata: "@endo/eventual-send": "workspace:^" "@endo/init": "workspace:^" "@endo/marshal": "workspace:^" + "@endo/memoize": "workspace:^" "@endo/pass-style": "workspace:^" "@endo/promise-kit": "workspace:^" "@endo/ses-ava": "workspace:^" @@ -915,14 +917,14 @@ __metadata: languageName: node linkType: hard -"@fast-check/ava@npm:^1.1.5": - version: 1.1.5 - resolution: "@fast-check/ava@npm:1.1.5" +"@fast-check/ava@npm:^2.0.2": + version: 2.0.2 + resolution: "@fast-check/ava@npm:2.0.2" dependencies: - fast-check: "npm:^3.0.0" + fast-check: "npm:^3.0.0 || ^4.0.0" peerDependencies: - ava: ">=4.0.0" - checksum: 10c0/0bbde9806b60098ad493b0df13e87e713ad63890c299bd6cb2ff96ba6b2eb19473b78956def72a806ead36dc88c02756c14ce42e4b18596ad7857a751b1e77ea + ava: ^4 || ^5 || ^6 + checksum: 10c0/bace1eb6c45f4a30208866d17762a22cc2c61f906aa6c4df9b5bc0b1c9f823ea0027bbf9acdcb3e6bb93ac101d4fa606a178395c673973414220ba2148b2bac6 languageName: node linkType: hard @@ -5312,12 +5314,12 @@ __metadata: languageName: node linkType: hard -"fast-check@npm:^3.0.0": - version: 3.1.1 - resolution: "fast-check@npm:3.1.1" +"fast-check@npm:^3.0.0 || ^4.0.0": + version: 4.5.3 + resolution: "fast-check@npm:4.5.3" dependencies: - pure-rand: "npm:^5.0.1" - checksum: 10c0/90804b41e296102de5e0648c880655b6a51d7b8623c12681120fde16fab8bf4ff72b3d0db32e738d89d322614c8547207d9931a4093425d134fe9f9c4acf117e + pure-rand: "npm:^7.0.0" + checksum: 10c0/e50846538de208756ecc3fab9d8bdc5d9677d2e27c611ccbbb3269c44635e819eb0a2323fcf4bf9aae648f7066cc5d7bda339549b25b8e35139831adf4975329 languageName: node linkType: hard @@ -9308,10 +9310,10 @@ __metadata: languageName: node linkType: hard -"pure-rand@npm:^5.0.1": - version: 5.0.1 - resolution: "pure-rand@npm:5.0.1" - checksum: 10c0/56fb43edf336ac939564d60535597a4182a6310627c4c7c54ef22499223a3d967bfa6d05a9c95c45ed5324bb7167de57a25e3fac5495dd4409672c08166d7973 +"pure-rand@npm:^7.0.0": + version: 7.0.1 + resolution: "pure-rand@npm:7.0.1" + checksum: 10c0/9cade41030f5ec95f5d55a11a71404cd6f46b69becaad892097cd7f58e2c6248cd0a933349ca7d21336ab629f1da42ffe899699b671bc4651600eaf6e57f837e languageName: node linkType: hard