diff --git a/packages/patterns/index.js b/packages/patterns/index.js index e3a313a049..a00153950d 100644 --- a/packages/patterns/index.js +++ b/packages/patterns/index.js @@ -54,6 +54,8 @@ export { mustMatch, } from './src/patterns/patternMatchers.js'; +export { mustCompress, mustDecompress } from './src/patterns/compress.js'; + // ////////////////// Temporary, until these find their proper home //////////// export { listDifference, objectMap } from './src/utils.js'; diff --git a/packages/patterns/src/keys/checkKey.js b/packages/patterns/src/keys/checkKey.js index f46b667a2a..46fbc6a950 100644 --- a/packages/patterns/src/keys/checkKey.js +++ b/packages/patterns/src/keys/checkKey.js @@ -575,7 +575,7 @@ const checkKeyInternal = (val, check) => { } case 'error': case 'promise': { - return check(false, X`A ${q(passStyle)} cannot be a key`); + return check(false, X`A ${q(passStyle)} cannot be a key: ${val}`); } default: { // Unexpected tags are just non-keys, but an unexpected passStyle diff --git a/packages/patterns/src/patterns/compress.js b/packages/patterns/src/patterns/compress.js new file mode 100644 index 0000000000..746017aee7 --- /dev/null +++ b/packages/patterns/src/patterns/compress.js @@ -0,0 +1,292 @@ +// @ts-check +import { assertChecker, makeTagged, passStyleOf } from '@endo/marshal'; +import { recordNames, recordValues } from '@endo/marshal/src/encodePassable.js'; + +import { + kindOf, + assertPattern, + maybeMatchHelper, + matches, + checkMatches, + mustMatch, +} from './patternMatchers.js'; +import { isKey } from '../keys/checkKey.js'; +import { keyEQ } from '../keys/compareKeys.js'; + +/** @typedef {import('@endo/pass-style').Passable} Passable */ +/** @typedef {import('../types.js').Compress} Compress */ +/** @typedef {import('../types.js').MustCompress} MustCompress */ +/** @typedef {import('../types.js').Decompress} Decompress */ +/** @typedef {import('../types.js').MustDecompress} MustDecompress */ +/** @typedef {import('../types.js').Pattern} Pattern */ + +const { fromEntries } = Object; +const { Fail, quote: q } = assert; + +const isNonCompressingMatcher = pattern => { + const patternKind = kindOf(pattern); + if (patternKind === undefined) { + return false; + } + const matchHelper = maybeMatchHelper(patternKind); + return matchHelper && matchHelper.compress === undefined; +}; + +/** + * When, for example, all the specimens in a given store match a + * specific pattern, then each of those specimens must contain the same + * literal superstructure as their one shared pattern. Therefore, storing + * that literal superstructure would be redumdant. If `specimen` does + * match `pattern`, then `compress(specimen, pattern)` will return a bindings + * array which is hopefully more compact than `specimen` as a whole, but + * carries all the information from specimen that cannot be derived just + * from knowledge that it matches this `pattern`. + * + * @type {Compress} + */ +const compress = (specimen, pattern) => { + if (isNonCompressingMatcher(pattern)) { + if (matches(specimen, pattern)) { + return harden({ compressed: specimen }); + } + return undefined; + } + + // Not yet frozen! Used to accumulate bindings + const bindings = []; + const emitBinding = binding => { + bindings.push(binding); + }; + harden(emitBinding); + + /** + * @param {Passable} innerSpecimen + * @param {Pattern} innerPattern + * @returns {boolean} + */ + const compressRecur = (innerSpecimen, innerPattern) => { + assertPattern(innerPattern); + if (isKey(innerPattern)) { + return keyEQ(innerSpecimen, innerPattern); + } + const patternKind = kindOf(innerPattern); + const specimenKind = kindOf(innerSpecimen); + switch (patternKind) { + case undefined: { + return false; + } + case 'copyArray': { + if ( + specimenKind !== 'copyArray' || + innerSpecimen.length !== innerPattern.length + ) { + return false; + } + return innerPattern.every((p, i) => compressRecur(innerSpecimen[i], p)); + } + case 'copyRecord': { + if (specimenKind !== 'copyRecord') { + return false; + } + const specimenNames = recordNames(innerSpecimen); + const pattNames = recordNames(innerPattern); + + if (specimenNames.length !== pattNames.length) { + return false; + } + const specimenValues = recordValues(innerSpecimen, specimenNames); + const pattValues = recordValues(innerPattern, pattNames); + + return pattNames.every( + (name, i) => + specimenNames[i] === name && + compressRecur(specimenValues[i], pattValues[i]), + ); + } + case 'copyMap': { + if (specimenKind !== 'copyMap') { + return false; + } + const { + payload: { keys: pattKeys, values: valuePatts }, + } = innerPattern; + const { + payload: { keys: specimenKeys, values: specimenValues }, + } = innerSpecimen; + // TODO BUG: this assumes that the keys appear in the + // same order, so we can compare values in that order. + // However, we're only guaranteed that they appear in + // the same rankOrder. Thus we must search one of these + // in the other's rankOrder. + if (!keyEQ(specimenKeys, pattKeys)) { + return false; + } + return compressRecur(specimenValues, valuePatts); + } + default: + { + const matchHelper = maybeMatchHelper(patternKind); + if (matchHelper) { + if (matchHelper.compress) { + const subCompressedRecord = matchHelper.compress( + innerSpecimen, + innerPattern.payload, + compress, + ); + if (subCompressedRecord === undefined) { + return false; + } else { + emitBinding(subCompressedRecord.compressed); + return true; + } + } else if (matches(innerSpecimen, innerPattern)) { + assert(isNonCompressingMatcher(innerPattern)); + emitBinding(innerSpecimen); + return true; + } else { + return false; + } + } + } + throw Fail`unrecognized kind: ${q(patternKind)}`; + } + }; + + if (compressRecur(specimen, pattern)) { + return harden({ compressed: bindings }); + } else { + return undefined; + } +}; +harden(compress); + +/** + * `mustCompress` is to `compress` approximately as `fit` is to `matches`. + * Where `compress` indicates pattern match failure by returning `undefined`, + * `mustCompress` indicates pattern match failure by throwing an error + * with a good pattern-match-failure diagnostic. Thus, like `fit`, + * `mustCompress` has an additional optional `label` parameter to be used on + * the outside of that diagnostic if needed. If `mustCompress` does return + * normally, then the pattern match succeeded and `mustCompress` returns a + * valid compressed value. + * + * @type {MustCompress} + */ +export const mustCompress = (specimen, pattern, label = undefined) => { + const compressedRecord = compress(specimen, pattern); + if (compressedRecord !== undefined) { + return compressedRecord.compressed; + } + // `compress` is validating, so we don't need to redo all of `mustMatch`. + // We use it only to generate the error. + // Should only throw + checkMatches(specimen, pattern, assertChecker, label); + throw Fail`internal: ${label}: inconsistent pattern match: ${q(pattern)}`; +}; +harden(mustCompress); + +/** + * `decompress` reverses the compression performed by `compress` + * or `mustCompress`, in order to recover the equivalent + * of the original specimen from the `bindings` array and the `pattern`. + * + * @type {Decompress} + */ +const decompress = (compressed, pattern) => { + if (isNonCompressingMatcher(pattern)) { + return compressed; + } + + assert(Array.isArray(compressed)); + passStyleOf(compressed) === 'copyArray' || + Fail`Pattern ${pattern} expected bindings array: ${compressed}`; + let i = 0; + const takeBinding = () => { + i < compressed.length || + Fail`Pattern ${q(pattern)} expects more than ${q( + compressed.length, + )} bindings: ${compressed}`; + const binding = compressed[i]; + i += 1; + return binding; + }; + harden(takeBinding); + + const decompressRecur = innerPattern => { + assertPattern(innerPattern); + if (isKey(innerPattern)) { + return innerPattern; + } + const patternKind = kindOf(innerPattern); + switch (patternKind) { + case undefined: { + throw Fail`decompress expected a pattern: ${q(innerPattern)}`; + } + case 'copyArray': { + return harden(innerPattern.map(p => decompressRecur(p))); + } + case 'copyRecord': { + const pattNames = recordNames(innerPattern); + const pattValues = recordValues(innerPattern, pattNames); + const entries = pattNames.map((name, j) => [ + name, + decompressRecur(pattValues[j]), + ]); + // Reverse so printed form looks less surprising, + // with ascenting rather than descending property names. + return harden(fromEntries(entries.reverse())); + } + case 'copyMap': { + const { + payload: { keys: pattKeys, values: valuePatts }, + } = innerPattern; + return makeTagged( + 'copyMap', + harden({ + keys: pattKeys, + values: valuePatts.map(p => decompressRecur(p)), + }), + ); + } + default: + { + const matchHelper = maybeMatchHelper(patternKind); + if (matchHelper) { + if (matchHelper.decompress) { + const subCompressed = takeBinding(); + return matchHelper.decompress( + subCompressed, + innerPattern.payload, + decompress, + ); + } else { + assert(isNonCompressingMatcher(innerPattern)); + return takeBinding(); + } + } + } + throw Fail`unrecognized pattern kind: ${q(patternKind)} ${q( + innerPattern, + )}`; + } + }; + + return decompressRecur(pattern); +}; +harden(decompress); + +/** + * `decompress` reverses the compression performed by `compress` + * or `mustCompress`, in order to recover the equivalent + * of the original specimen from `compressed` and `pattern`. + * + * @type {MustDecompress} + */ +export const mustDecompress = (compressed, pattern, label = undefined) => { + const value = decompress(compressed, pattern); + // `decompress` does some checking, but is not validating, so we + // need to do the full `mustMatch` here to validate as well as to generate + // the error if invalid. + mustMatch(value, pattern, label); + return value; +}; diff --git a/packages/patterns/src/patterns/internal-types.js b/packages/patterns/src/patterns/internal-types.js index c354f879b1..3773de187e 100644 --- a/packages/patterns/src/patterns/internal-types.js +++ b/packages/patterns/src/patterns/internal-types.js @@ -1,11 +1,11 @@ /// -/** @typedef {import('@endo/marshal').Passable} Passable */ -/** @typedef {import('@endo/marshal').PassStyle} PassStyle */ -/** @typedef {import('@endo/marshal').CopyTagged} CopyTagged */ -/** @template T @typedef {import('@endo/marshal').CopyRecord} CopyRecord */ -/** @template T @typedef {import('@endo/marshal').CopyArray} CopyArray */ -/** @typedef {import('@endo/marshal').Checker} Checker */ +/** @typedef {import('@endo/pass-style').Passable} Passable */ +/** @typedef {import('@endo/pass-style').PassStyle} PassStyle */ +/** @typedef {import('@endo/pass-style').CopyTagged} CopyTagged */ +/** @template T @typedef {import('@endo/pass-style').CopyRecord} CopyRecord */ +/** @template T @typedef {import('@endo/pass-style').CopyArray} CopyArray */ +/** @typedef {import('@endo/pass-style').Checker} Checker */ /** @typedef {import('@endo/marshal').RankCompare} RankCompare */ /** @typedef {import('@endo/marshal').RankCover} RankCover */ @@ -15,6 +15,7 @@ /** @typedef {import('../types.js').InterfaceGuard} InterfaceGuard */ /** @typedef {import('../types.js').MethodGuardMaker0} MethodGuardMaker0 */ +/** @typedef {import('../types.js').Kind} Kind */ /** @typedef {import('../types').MatcherNamespace} MatcherNamespace */ /** @typedef {import('../types').Key} Key */ /** @typedef {import('../types').Pattern} Pattern */ @@ -23,12 +24,20 @@ /** @typedef {import('../types').AllLimits} AllLimits */ /** @typedef {import('../types').GetRankCover} GetRankCover */ +/** @typedef {import('../types.js').CompressedRecord} CompressedRecord */ +/** @typedef {import('../types.js').Compress} Compress */ +/** @typedef {import('../types.js').MustCompress} MustCompress */ +/** @typedef {import('../types.js').Decompress} Decompress */ +/** @typedef {import('../types.js').MustDecompress} MustDecompress */ + /** * @typedef {object} MatchHelper * This factors out only the parts specific to each kind of Matcher. It is * encapsulated, and its methods can make the stated unchecked assumptions * enforced by the common calling logic. * + * @property {string} tag + * * @property {(allegedPayload: Passable, * check: Checker * ) => boolean} checkIsWellFormed @@ -42,6 +51,27 @@ * Assuming validity of `matcherPayload` as the payload of a Matcher corresponding * with this MatchHelper, reports whether `specimen` is matched by that Matcher. * + * @property {(specimen: Passable, + * matcherPayload: Passable, + * compress: Compress + * ) => (CompressedRecord | undefined)} [compress] + * Assuming a valid Matcher of this type with `matcherPayload` as its + * payload, if this specimen matches this matcher, then return a + * CompressedRecord that represents this specimen, + * perhaps more compactly, given the knowledge that it matches this matcher. + * If the specimen does not match the matcher, return undefined. + * If this matcher has a `compress` method, then it must have a matching + * `decompress` method. + * + * @property {(compressed: Passable, + * matcherPayload: Passable, + * decompress: Decompress + * ) => Passable} [decompress] + * If `compressed` is the result of a successful `compress` with this matcher, + * then `decompress` must return a Passable equivalent to the original specimen. + * If this matcher has an `decompress` method, then it must have a matching + * `compress` method. + * * @property {import('../types').GetRankCover} getRankCover * Assumes this is the payload of a CopyTagged with the corresponding * matchTag. Return a RankCover to bound from below and above, @@ -63,5 +93,7 @@ * @property {(patt: Pattern) => void} assertPattern * @property {(patt: Passable) => boolean} isPattern * @property {GetRankCover} getRankCover + * @property {(passable: Passable, check?: Checker) => (Kind | undefined)} kindOf + * @property {(tag: string) => (MatchHelper | undefined)} maybeMatchHelper * @property {MatcherNamespace} M */ diff --git a/packages/patterns/src/patterns/patternMatchers.js b/packages/patterns/src/patterns/patternMatchers.js index 79173f6e82..21b9bc5de6 100644 --- a/packages/patterns/src/patterns/patternMatchers.js +++ b/packages/patterns/src/patterns/patternMatchers.js @@ -1,6 +1,5 @@ import { assertChecker, - Far, getTag, makeTagged, passStyleOf, @@ -18,6 +17,7 @@ import { applyLabelingError, fromUniqueEntries, listDifference, + objectMap, } from '../utils.js'; import { keyEQ, keyGT, keyGTE, keyLT, keyLTE } from '../keys/compareKeys.js'; @@ -30,6 +30,8 @@ import { checkCopyMap, copyMapKeySet, checkCopyBag, + makeCopySet, + makeCopyBag, } from '../keys/checkKey.js'; import './internal-types.js'; @@ -38,7 +40,13 @@ import './internal-types.js'; const { quote: q, details: X, Fail } = assert; const { entries, values } = Object; -const { ownKeys } = Reflect; +const { ownKeys, apply } = Reflect; + +// TODO simplify once we can assume Object.hasOwn everywhere. This probably +// means, when we stop supporting Node 14. +const { hasOwnProperty } = Object.prototype; +const hasOwn = + Object.hasOwn || ((obj, name) => apply(hasOwnProperty, obj, [name])); /** @type {WeakSet} */ const patternMemo = new WeakSet(); @@ -81,50 +89,6 @@ export const defaultLimits = harden({ const limit = (limits = {}) => /** @type {AllLimits} */ (harden({ __proto__: defaultLimits, ...limits })); -const checkIsWellFormedWithLimit = ( - payload, - mainPayloadShape, - check, - label, -) => { - assert(Array.isArray(mainPayloadShape)); - if (!Array.isArray(payload)) { - return check(false, X`${q(label)} payload must be an array: ${payload}`); - } - - // Was the following, but its overuse of patterns caused an infinite regress - // const payloadLimitShape = harden( - // M.split( - // mainPayloadShape, - // M.partial(harden([M.recordOf(M.string(), M.number())]), harden([])), - // ), - // ); - // return checkMatches(payload, payloadLimitShape, check, label); - - const mainLength = mainPayloadShape.length; - if (!(payload.length === mainLength || payload.length === mainLength + 1)) { - return check(false, X`${q(label)} payload unexpected size: ${payload}`); - } - const limits = payload[mainLength]; - payload = harden(payload.slice(0, mainLength)); - // eslint-disable-next-line no-use-before-define - if (!checkMatches(payload, mainPayloadShape, check, label)) { - return false; - } - if (limits === undefined) { - return true; - } - return ( - (passStyleOf(limits) === 'copyRecord' || - check(false, X`Limits must be a record: ${q(limits)}`)) && - entries(limits).every( - ([key, value]) => - passStyleOf(value) === 'number' || - check(false, X`Value of limit ${q(key)} but be a number: ${q(value)}`), - ) - ); -}; - /** * @param {unknown} specimen * @param {number} decimalDigitsLimit @@ -147,6 +111,57 @@ const checkDecimalDigitsLimit = (specimen, decimalDigitsLimit, check) => { * @returns {PatternKit} */ const makePatternKit = () => { + // Define early to break a circularity is use of checkIsWellFormedWithLimit + const PatternShape = makeTagged('match:pattern', undefined); + + // Define within makePatternKit so can use checkMatches early. + const checkIsWellFormedWithLimit = ( + payload, + mainPayloadShape, + check, + label, + ) => { + assert(Array.isArray(mainPayloadShape)); + if (!Array.isArray(payload)) { + return check(false, X`${q(label)} payload must be an array: ${payload}`); + } + + // Was the following, but its overuse of patterns caused an infinite regress + // const payloadLimitShape = harden( + // M.split( + // mainPayloadShape, + // M.partial(harden([M.recordOf(M.string(), M.number())]), harden([])), + // ), + // ); + // return checkMatches(payload, payloadLimitShape, check, label); + + const mainLength = mainPayloadShape.length; + if (!(payload.length === mainLength || payload.length === mainLength + 1)) { + return check(false, X`${q(label)} payload unexpected size: ${payload}`); + } + const limits = payload[mainLength]; + payload = harden(payload.slice(0, mainLength)); + // eslint-disable-next-line no-use-before-define + if (!checkMatches(payload, mainPayloadShape, check, label)) { + return false; + } + if (limits === undefined) { + return true; + } + return ( + (passStyleOf(limits) === 'copyRecord' || + check(false, X`Limits must be a record: ${q(limits)}`)) && + entries(limits).every( + ([key, value]) => + passStyleOf(value) === 'number' || + check( + false, + X`Value of limit ${q(key)} but be a number: ${q(value)}`, + ), + ) + ); + }; + /** * If this is a recognized match tag, return the MatchHelper. * Otherwise result undefined. @@ -170,15 +185,6 @@ const makePatternKit = () => { // eslint-disable-next-line no-use-before-define GuardPayloadShapes[tag]; - /** - * @typedef {string} Kind - * It is either a PassStyle other than 'tagged', or, if the underlying - * PassStyle is 'tagged', then the `getTag` value for tags that are - * recognized at the store level of abstraction. For each of those - * tags, a tagged record only has that kind if it satisfies the invariants - * that the store level associates with that kind. - */ - /** * @type {WeakMap} * Only for tagged records of recognized kinds whose store-level invariants @@ -259,6 +265,17 @@ const makePatternKit = () => { }; harden(kindOf); + const matchHelperTagRE = harden(/^match:(\w+)(:\w+)?$/); + + const getMatchSubTag = tag => { + const parts = matchHelperTagRE.exec(tag); + if (parts && parts[2] !== undefined) { + return `match:${parts[1]}`; + } else { + return undefined; + } + }; + /** * Checks only recognized kinds, and only if the specimen * passes the invariants associated with that recognition. @@ -270,8 +287,14 @@ const makePatternKit = () => { */ const checkKind = (specimen, kind, check) => { const realKind = kindOf(specimen, check); - if (kind === realKind) { - return true; + if (realKind !== undefined) { + if (kind === realKind) { + return true; + } + const subTag = getMatchSubTag(realKind); + if (subTag !== undefined && kind === subTag) { + return true; + } } if (check !== identChecker) { // quoting without quotes @@ -436,6 +459,15 @@ const makePatternKit = () => { case 'copySet': case 'copyBag': case 'remotable': { + if (!isKey(specimen)) { + assert(specimenKind !== patternKind); + return check( + false, + X`${specimen} - Must be a ${patternKind} to match a ${patternKind} pattern: ${q( + patt, + )}`, + ); + } // These kinds are necessarily keys return checkAsKeyPatt(specimen, patt, check); } @@ -703,10 +735,50 @@ const makePatternKit = () => { ); }; + /** + * @param { Passable[] } array + * @param { Pattern } patt + * @param {Compress} compress + * @returns {Passable[] | undefined} + */ + const arrayCompressMatchPattern = (array, patt, compress) => { + if (isKind(patt, 'match:any')) { + return array; + } + const bindings = []; + for (const el of array) { + const subCompressedRecord = compress(el, patt); + if (subCompressedRecord) { + bindings.push(subCompressedRecord.compressed); + } else { + return undefined; + } + } + return harden(bindings); + }; + + /** + * @param {Passable} compressed + * @param {Pattern} patt + * @param {Decompress} decompress + * @returns {Passable[]} + */ + const arrayDecompressMatchPattern = (compressed, patt, decompress) => { + if (!Array.isArray(compressed)) { + throw Fail`Compressed array must be an array: ${compressed}`; + } + if (isKind(patt, 'match:any')) { + return compressed; + } + return harden(compressed.map(subBindings => decompress(subBindings, patt))); + }; + // /////////////////////// Match Helpers ///////////////////////////////////// /** @type {MatchHelper} */ - const matchAnyHelper = Far('match:any helper', { + const matchAnyHelper = harden({ + tag: 'match:any', + checkMatches: (_specimen, _matcherPayload, _check) => true, checkIsWellFormed: (matcherPayload, check) => @@ -717,16 +789,40 @@ const makePatternKit = () => { }); /** @type {MatchHelper} */ - const matchAndHelper = Far('match:and helper', { + const matchAndHelper = harden({ + tag: 'match:and:1', + checkMatches: (specimen, patts, check) => { return patts.every(patt => checkMatches(specimen, patt, check)); }, + // Compress only according to the last conjunct + compress: (specimen, patts, compress) => { + const { length } = patts; + // We know there are at least two patts + const lastPatt = patts[length - 1]; + const allButLast = patts.slice(0, length - 1); + if ( + !allButLast.every(patt => checkMatches(specimen, patt, identChecker)) + ) { + return undefined; + } + return compress(specimen, lastPatt); + }, + + decompress: (compressed, patts, decompress) => { + const lastPatt = patts[patts.length - 1]; + return decompress(compressed, lastPatt); + }, + checkIsWellFormed: (allegedPatts, check) => { const checkIt = patt => checkPattern(patt, check); return ( (passStyleOf(allegedPatts) === 'copyArray' || - check(false, X`Needs array of sub-patterns: ${q(allegedPatts)}`)) && + check(false, X`Needs array of sub-patterns: ${allegedPatts}`)) && + Array.isArray(allegedPatts) && // redundant. just for type checker + (allegedPatts.length >= 2 || + check(false, X`Must have at least two sub-patterns`)) && allegedPatts.every(checkIt) ); }, @@ -739,7 +835,9 @@ const makePatternKit = () => { }); /** @type {MatchHelper} */ - const matchOrHelper = Far('match:or helper', { + const matchOrHelper = harden({ + tag: 'match:or:1', + checkMatches: (specimen, patts, check) => { const { length } = patts; if (length === 0) { @@ -750,9 +848,8 @@ const makePatternKit = () => { } if ( patts.length === 2 && - !matches(specimen, patts[0]) && - isKind(patts[0], 'match:kind') && - patts[0].payload === 'undefined' + patts[0] === undefined && + !matches(specimen, undefined) ) { // Worth special casing the optional pattern for // better error messages. @@ -764,6 +861,31 @@ const makePatternKit = () => { return check(false, X`${specimen} - Must match one of ${q(patts)}`); }, + // Compress to an array pair of the index of the + // first disjunct that succeeded, and the compressed according to + // that disjunct. + compress: (specimen, patts, compress) => { + assert(Array.isArray(patts)); // redundant. Just for type checker. + const { length } = patts; + if (length === 0) { + return undefined; + } + for (let i = 0; i < length; i += 1) { + const subCompressedRecord = compress(specimen, patts[i]); + if (subCompressedRecord !== undefined) { + return harden({ compressed: [i, subCompressedRecord.compressed] }); + } + } + return undefined; + }, + + decompress: (compressed, patts, decompress) => { + (Array.isArray(compressed) && compressed.length === 2) || + Fail`Or compression must be a case index and a compression by that case: ${compressed}`; + const [i, subCompressed] = compressed; + return decompress(harden(subCompressed), patts[i]); + }, + checkIsWellFormed: matchAndHelper.checkIsWellFormed, getRankCover: (patts, encodePassable) => @@ -774,7 +896,9 @@ const makePatternKit = () => { }); /** @type {MatchHelper} */ - const matchNotHelper = Far('match:not helper', { + const matchNotHelper = harden({ + tag: 'match:not', + checkMatches: (specimen, patt, check) => { if (matches(specimen, patt)) { return check( @@ -792,7 +916,9 @@ const makePatternKit = () => { }); /** @type {MatchHelper} */ - const matchScalarHelper = Far('match:scalar helper', { + const matchScalarHelper = harden({ + tag: 'match:scalar', + checkMatches: (specimen, _matcherPayload, check) => checkScalarKey(specimen, check), @@ -802,7 +928,9 @@ const makePatternKit = () => { }); /** @type {MatchHelper} */ - const matchKeyHelper = Far('match:key helper', { + const matchKeyHelper = harden({ + tag: `match:key`, + checkMatches: (specimen, _matcherPayload, check) => checkKey(specimen, check), @@ -812,7 +940,9 @@ const makePatternKit = () => { }); /** @type {MatchHelper} */ - const matchPatternHelper = Far('match:pattern helper', { + const matchPatternHelper = harden({ + tag: `match:pattern`, + checkMatches: (specimen, _matcherPayload, check) => checkPattern(specimen, check), @@ -822,7 +952,9 @@ const makePatternKit = () => { }); /** @type {MatchHelper} */ - const matchKindHelper = Far('match:kind helper', { + const matchKindHelper = harden({ + tag: `match:kind`, + checkMatches: checkKind, checkIsWellFormed: (allegedKeyKind, check) => @@ -850,7 +982,9 @@ const makePatternKit = () => { }); /** @type {MatchHelper} */ - const matchBigintHelper = Far('match:bigint helper', { + const matchBigintHelper = harden({ + tag: `match:bigint`, + checkMatches: (specimen, [limits = undefined], check) => { const { decimalDigitsLimit } = limit(limits); return ( @@ -872,7 +1006,9 @@ const makePatternKit = () => { }); /** @type {MatchHelper} */ - const matchNatHelper = Far('match:nat helper', { + const matchNatHelper = harden({ + tag: `match:nat`, + checkMatches: (specimen, [limits = undefined], check) => { const { decimalDigitsLimit } = limit(limits); return ( @@ -899,7 +1035,9 @@ const makePatternKit = () => { }); /** @type {MatchHelper} */ - const matchStringHelper = Far('match:string helper', { + const matchStringHelper = harden({ + tag: `match:string`, + checkMatches: (specimen, [limits = undefined], check) => { const { stringLengthLimit } = limit(limits); // prettier-ignore @@ -927,7 +1065,9 @@ const makePatternKit = () => { }); /** @type {MatchHelper} */ - const matchSymbolHelper = Far('match:symbol helper', { + const matchSymbolHelper = harden({ + tag: `match:symbol`, + checkMatches: (specimen, [limits = undefined], check) => { const { symbolNameLengthLimit } = limit(limits); if (!checkKind(specimen, 'symbol', check)) { @@ -959,7 +1099,9 @@ const makePatternKit = () => { }); /** @type {MatchHelper} */ - const matchRemotableHelper = Far('match:remotable helper', { + const matchRemotableHelper = harden({ + tag: `match:remotable`, + checkMatches: (specimen, remotableDesc, check) => { // Unfortunate duplication of checkKind logic, but no better choices. if (isKind(specimen, 'remotable')) { @@ -995,7 +1137,9 @@ const makePatternKit = () => { }); /** @type {MatchHelper} */ - const matchLTEHelper = Far('match:lte helper', { + const matchLTEHelper = harden({ + tag: `match:lte`, + checkMatches: (specimen, rightOperand, check) => keyLTE(specimen, rightOperand) || check(false, X`${specimen} - Must be <= ${rightOperand}`), @@ -1017,7 +1161,9 @@ const makePatternKit = () => { }); /** @type {MatchHelper} */ - const matchLTHelper = Far('match:lt helper', { + const matchLTHelper = harden({ + tag: `match:lt`, + checkMatches: (specimen, rightOperand, check) => keyLT(specimen, rightOperand) || check(false, X`${specimen} - Must be < ${rightOperand}`), @@ -1028,7 +1174,9 @@ const makePatternKit = () => { }); /** @type {MatchHelper} */ - const matchGTEHelper = Far('match:gte helper', { + const matchGTEHelper = harden({ + tag: `match:gte`, + checkMatches: (specimen, rightOperand, check) => keyGTE(specimen, rightOperand) || check(false, X`${specimen} - Must be >= ${rightOperand}`), @@ -1050,7 +1198,9 @@ const makePatternKit = () => { }); /** @type {MatchHelper} */ - const matchGTHelper = Far('match:gt helper', { + const matchGTHelper = harden({ + tag: `match:gt`, + checkMatches: (specimen, rightOperand, check) => keyGT(specimen, rightOperand) || check(false, X`${specimen} - Must be > ${rightOperand}`), @@ -1061,7 +1211,9 @@ const makePatternKit = () => { }); /** @type {MatchHelper} */ - const matchRecordOfHelper = Far('match:recordOf helper', { + const matchRecordOfHelper = harden({ + tag: `match:recordOf`, + checkMatches: ( specimen, [keyPatt, valuePatt, limits = undefined], @@ -1101,7 +1253,7 @@ const makePatternKit = () => { checkIsWellFormed: (payload, check) => checkIsWellFormedWithLimit( payload, - harden([MM.pattern(), MM.pattern()]), + harden([PatternShape, PatternShape]), check, 'match:recordOf payload', ), @@ -1110,7 +1262,9 @@ const makePatternKit = () => { }); /** @type {MatchHelper} */ - const matchArrayOfHelper = Far('match:arrayOf helper', { + const matchArrayOfHelper = harden({ + tag: `match:arrayOf:1`, + checkMatches: (specimen, [subPatt, limits = undefined], check) => { const { arrayLengthLimit } = limit(limits); // prettier-ignore @@ -1125,10 +1279,33 @@ const makePatternKit = () => { ); }, + // Compress to an array of corresponding bindings arrays + compress: (specimen, [subPatt, limits = undefined], compress) => { + const { arrayLengthLimit } = limit(limits); + if ( + isKind(specimen, 'copyArray') && + Array.isArray(specimen) && // redundant. just for type checker. + specimen.length <= arrayLengthLimit + ) { + const compressed = arrayCompressMatchPattern( + specimen, + subPatt, + compress, + ); + if (compressed) { + return harden({ compressed }); + } + } + return undefined; + }, + + decompress: (compressed, [subPatt, _limits = undefined], decompress) => + arrayDecompressMatchPattern(compressed, subPatt, decompress), + checkIsWellFormed: (payload, check) => checkIsWellFormedWithLimit( payload, - harden([MM.pattern()]), + harden([PatternShape]), check, 'match:arrayOf payload', ), @@ -1137,7 +1314,9 @@ const makePatternKit = () => { }); /** @type {MatchHelper} */ - const matchSetOfHelper = Far('match:setOf helper', { + const matchSetOfHelper = harden({ + tag: `match:setOf:1`, + checkMatches: (specimen, [keyPatt, limits = undefined], check) => { const { numSetElementsLimit } = limit(limits); return ( @@ -1152,10 +1331,32 @@ const makePatternKit = () => { ); }, + // Compress to an array of corresponding bindings arrays + compress: (specimen, [keyPatt, limits = undefined], compress) => { + const { numSetElementsLimit } = limit(limits); + if ( + isKind(specimen, 'copySet') && + /** @type {Array} */ (specimen.payload).length <= numSetElementsLimit + ) { + const compressed = arrayCompressMatchPattern( + specimen.payload, + keyPatt, + compress, + ); + if (compressed) { + return harden({ compressed }); + } + } + return undefined; + }, + + decompress: (compressed, [keyPatt, _limits = undefined], decompress) => + makeCopySet(arrayDecompressMatchPattern(compressed, keyPatt, decompress)), + checkIsWellFormed: (payload, check) => checkIsWellFormedWithLimit( payload, - harden([MM.pattern()]), + harden([PatternShape]), check, 'match:setOf payload', ), @@ -1164,7 +1365,9 @@ const makePatternKit = () => { }); /** @type {MatchHelper} */ - const matchBagOfHelper = Far('match:bagOf helper', { + const matchBagOfHelper = harden({ + tag: `match:bagOf:1`, + checkMatches: ( specimen, [keyPatt, countPatt, limits = undefined], @@ -1193,10 +1396,50 @@ const makePatternKit = () => { ); }, + // Compress to an array of corresponding bindings arrays + compress: ( + specimen, + [keyPatt, countPatt, limits = undefined], + compress, + ) => { + const { numUniqueBagElementsLimit, decimalDigitsLimit } = limit(limits); + if ( + isKind(specimen, 'copyBag') && + /** @type {Array} */ (specimen.payload).length <= + numUniqueBagElementsLimit && + specimen.payload.every(([_key, count]) => + checkDecimalDigitsLimit(count, decimalDigitsLimit, identChecker), + ) + ) { + const compressed = arrayCompressMatchPattern( + specimen.payload, + harden([keyPatt, countPatt]), + compress, + ); + if (compressed) { + return harden({ compressed }); + } + } + return undefined; + }, + + decompress: ( + compressed, + [keyPatt, countPatt, _limits = undefined], + decompress, + ) => + makeCopyBag( + arrayDecompressMatchPattern( + compressed, + harden([keyPatt, countPatt]), + decompress, + ), + ), + checkIsWellFormed: (payload, check) => checkIsWellFormedWithLimit( payload, - harden([MM.pattern(), MM.pattern()]), + harden([PatternShape, PatternShape]), check, 'match:bagOf payload', ), @@ -1205,7 +1448,9 @@ const makePatternKit = () => { }); /** @type {MatchHelper} */ - const matchMapOfHelper = Far('match:mapOf helper', { + const matchMapOfHelper = harden({ + tag: `match:mapOf:1`, + checkMatches: ( specimen, [keyPatt, valuePatt, limits = undefined], @@ -1236,10 +1481,69 @@ const makePatternKit = () => { ); }, + // Compress to a pair of bindings arrays, one for the keys + // and a matching one for the values. + compress: ( + specimen, + [keyPatt, valuePatt, limits = undefined], + compress, + ) => { + const { numMapEntriesLimit } = limit(limits); + if ( + isKind(specimen, 'copyMap') && + /** @type {Array} */ (specimen.payload.keys).length <= + numMapEntriesLimit + ) { + const compressedKeys = arrayCompressMatchPattern( + specimen.payload.keys, + keyPatt, + compress, + ); + if (compressedKeys) { + const compressedValues = arrayCompressMatchPattern( + specimen.payload.values, + valuePatt, + compress, + ); + if (compressedValues) { + return harden({ + compressed: [compressedKeys, compressedValues], + }); + } + } + } + return undefined; + }, + + decompress: ( + compressed, + [keyPatt, valuePatt, _limits = undefined], + decompress, + ) => { + (Array.isArray(compressed) && compressed.length === 2) || + Fail`Compressed map should be a pair of compressed keys and compressed values ${compressed}`; + const [compressedKeys, compressedvalues] = compressed; + return makeTagged( + 'copyMap', + harden({ + keys: arrayDecompressMatchPattern( + compressedKeys, + keyPatt, + decompress, + ), + values: arrayDecompressMatchPattern( + compressedvalues, + valuePatt, + decompress, + ), + }), + ); + }, + checkIsWellFormed: (payload, check) => checkIsWellFormedWithLimit( payload, - harden([MM.pattern(), MM.pattern()]), + harden([PatternShape, PatternShape]), check, 'match:mapOf payload', ), @@ -1282,7 +1586,9 @@ const makePatternKit = () => { harden(optionalPatt.slice(0, length).map(patt => MM.opt(patt))); /** @type {MatchHelper} */ - const matchSplitArrayHelper = Far('match:splitArray helper', { + const matchSplitArrayHelper = harden({ + tag: `match:splitArray:1`, + checkMatches: ( specimen, [requiredPatt, optionalPatt = [], restPatt = MM.any()], @@ -1318,6 +1624,70 @@ const makePatternKit = () => { ); }, + compress: ( + specimen, + [requiredPatt, optionalPatt = [], restPatt = MM.any()], + compress, + ) => { + if (!checkKind(specimen, 'copyArray', identChecker)) { + return undefined; + } + const { requiredSpecimen, optionalSpecimen, restSpecimen } = + splitArrayParts(specimen, requiredPatt, optionalPatt); + const partialPatt = adaptArrayPattern( + optionalPatt, + optionalSpecimen.length, + ); + const compressedRequired = compress(requiredSpecimen, requiredPatt); + if (!compressedRequired) { + return undefined; + } + const compressedPartial = []; + for (const [i, p] of entries(partialPatt)) { + const compressedField = compress(optionalSpecimen[i], p); + if (!compressedField) { + // imperative loop so can escape early + return undefined; + } + compressedPartial.push(compressedField.compressed[0]); + } + const compressedRest = compress(restSpecimen, restPatt); + if (!compressedRest) { + return undefined; + } + return harden({ + compressed: [ + compressedRequired.compressed, + compressedPartial, + compressedRest.compressed, + ], + }); + }, + + decompress: ( + compressed, + [requiredPatt, optionalPatt = [], restPatt = MM.any()], + decompress, + ) => { + (Array.isArray(compressed) && compressed.length === 3) || + Fail`splitArray compression must be a triple ${compressed}`; + const [compressRequired, compressPartial, compressedRest] = compressed; + const partialPatt = adaptArrayPattern( + optionalPatt, + compressPartial.length, + ); + const requiredParts = decompress(compressRequired, requiredPatt); + // const optionalParts = decompress(compressPartial, partialPatt); + const optionalParts = []; + for (const [i, p] of entries(partialPatt)) { + // imperative loop just for similarity to compression code + const optionalField = decompress(harden([compressPartial[i]]), p); + optionalParts.push(optionalField); + } + const restParts = decompress(compressedRest, restPatt); + return harden([...requiredParts, ...optionalParts, ...restParts]); + }, + /** * @param {Array} splitArray * @param {Checker} check @@ -1395,14 +1765,15 @@ const makePatternKit = () => { * compression distinguishing `undefined` from absence. * * @param {CopyRecord} optionalPatt - * @param {string[]} names * @returns {CopyRecord} The partialPatt */ - const adaptRecordPattern = (optionalPatt, names) => - fromUniqueEntries(names.map(name => [name, MM.opt(optionalPatt[name])])); + const adaptRecordPattern = optionalPatt => + objectMap(optionalPatt, p => MM.opt(p)); /** @type {MatchHelper} */ - const matchSplitRecordHelper = Far('match:splitRecord helper', { + const matchSplitRecordHelper = harden({ + tag: `match:splitRecord:1`, + checkMatches: ( specimen, [requiredPatt, optionalPatt = {}, restPatt = MM.any()], @@ -1414,8 +1785,8 @@ const makePatternKit = () => { const { requiredSpecimen, optionalSpecimen, restSpecimen } = splitRecordParts(specimen, requiredPatt, optionalPatt); - const partialNames = /** @type {string[]} */ (ownKeys(optionalSpecimen)); - const partialPatt = adaptRecordPattern(optionalPatt, partialNames); + const partialNames = recordNames(optionalSpecimen); + const partialPatt = adaptRecordPattern(optionalPatt); return ( checkMatches(requiredSpecimen, requiredPatt, check) && partialNames.every(name => @@ -1430,6 +1801,87 @@ const makePatternKit = () => { ); }, + compress: ( + specimen, + [requiredPatt, optionalPatt = {}, restPatt = MM.any()], + compress, + ) => { + if (!checkKind(specimen, 'copyRecord', identChecker)) { + return undefined; + } + const { requiredSpecimen, optionalSpecimen, restSpecimen } = + splitRecordParts(specimen, requiredPatt, optionalPatt); + const partialPatt = adaptRecordPattern(optionalPatt); + + const compressedRequired = compress(requiredSpecimen, requiredPatt); + if (!compressedRequired) { + return undefined; + } + const optionalNames = recordNames(partialPatt); + const compressedPartial = []; + for (const name of optionalNames) { + if (hasOwn(optionalSpecimen, name)) { + const compressedField = compress( + optionalSpecimen[name], + partialPatt[name], + ); + if (!compressedField) { + return undefined; + } + compressedPartial.push(compressedField.compressed[0]); + } else { + compressedPartial.push(null); + } + } + const compressedRest = compress(restSpecimen, restPatt); + if (!compressedRest) { + return undefined; + } + return harden({ + compressed: [ + compressedRequired.compressed, + compressedPartial, + compressedRest.compressed, + ], + }); + }, + + decompress: ( + compressed, + [requiredPatt, optionalPatt = {}, restPatt = MM.any()], + decompress, + ) => { + (Array.isArray(compressed) && compressed.length === 3) || + Fail`splitRecord compression must be a triple ${compressed}`; + const [compressedRequired, compressedPartial, compressedRest] = + compressed; + const partialPatt = adaptRecordPattern(optionalPatt); + const requiredEntries = entries( + decompress(compressedRequired, requiredPatt), + ); + const optionalNames = recordNames(partialPatt); + compressedPartial.length === optionalNames.length || + Fail`compression or patterns must preserve cardinality: ${compressedPartial}`; + /** @type {[string, Passable][]} */ + const optionalEntries = []; + for (const [i, name] of entries(optionalNames)) { + const p = partialPatt[name]; + const c = compressedPartial[i]; + if (c !== null) { + const u = decompress(harden([c]), p); + optionalEntries.push([name, u]); + } + } + const restEntries = entries(decompress(compressedRest, restPatt)); + + const allEntries = [ + ...requiredEntries, + ...optionalEntries, + ...restEntries, + ]; + return fromUniqueEntries(allEntries); + }, + /** * @param {Array} splitArray * @param {Checker} check @@ -1467,60 +1919,93 @@ const makePatternKit = () => { ]) => getPassStyleCover(passStyleOf(requiredPatt)), }); + const makeHelpersTable = () => { + const helpers = harden([ + matchAnyHelper, + matchAndHelper, + matchOrHelper, + matchNotHelper, + + matchScalarHelper, + matchKeyHelper, + matchPatternHelper, + matchKindHelper, + matchBigintHelper, + matchNatHelper, + matchStringHelper, + matchSymbolHelper, + matchRemotableHelper, + + matchLTHelper, + matchLTEHelper, + matchGTEHelper, + matchGTHelper, + + matchArrayOfHelper, + matchRecordOfHelper, + matchSetOfHelper, + matchBagOfHelper, + matchMapOfHelper, + matchSplitArrayHelper, + matchSplitRecordHelper, + ]); + + /** @type {Record} */ + // don't freeze yet + const helpersByMatchTag = {}; + + for (const helper of helpers) { + const { tag, compress, decompress, ...rest } = helper; + if (!matchHelperTagRE.test(tag)) { + throw Fail`malformed matcher tag ${q(tag)}`; + } + const subTag = getMatchSubTag(tag); + if (subTag === undefined) { + (compress === undefined && decompress === undefined) || + Fail`internal: compressing helper must have compression version ${q( + tag, + )}`; + } else { + (typeof compress === 'function' && typeof decompress === 'function') || + Fail`internal: expected compression methods ${q(tag)})`; + helpersByMatchTag[subTag] = { tag: subTag, ...rest }; + } + helpersByMatchTag[tag] = helper; + } + return harden(helpersByMatchTag); + }; + /** @type {Record} */ - const HelpersByMatchTag = harden({ - 'match:any': matchAnyHelper, - 'match:and': matchAndHelper, - 'match:or': matchOrHelper, - 'match:not': matchNotHelper, - - 'match:scalar': matchScalarHelper, - 'match:key': matchKeyHelper, - 'match:pattern': matchPatternHelper, - 'match:kind': matchKindHelper, - 'match:bigint': matchBigintHelper, - 'match:nat': matchNatHelper, - 'match:string': matchStringHelper, - 'match:symbol': matchSymbolHelper, - 'match:remotable': matchRemotableHelper, - - 'match:lt': matchLTHelper, - 'match:lte': matchLTEHelper, - 'match:gte': matchGTEHelper, - 'match:gt': matchGTHelper, - - 'match:arrayOf': matchArrayOfHelper, - 'match:recordOf': matchRecordOfHelper, - 'match:setOf': matchSetOfHelper, - 'match:bagOf': matchBagOfHelper, - 'match:mapOf': matchMapOfHelper, - 'match:splitArray': matchSplitArrayHelper, - 'match:splitRecord': matchSplitRecordHelper, - }); + const HelpersByMatchTag = makeHelpersTable(); - const makeMatcher = (tag, payload) => { - const matcher = makeTagged(tag, payload); + /** + * @param {MatchHelper} matchHelper + * @param {Passable} payload + */ + const makeMatcher = (matchHelper, payload) => { + const matcher = makeTagged(matchHelper.tag, payload); assertPattern(matcher); return matcher; }; - const makeKindMatcher = kind => makeMatcher('match:kind', kind); + const makeKindMatcher = kind => makeMatcher(matchKindHelper, kind); + + // Note that PatternShape was defined above to break a circularity. - const AnyShape = makeMatcher('match:any', undefined); - const ScalarShape = makeMatcher('match:scalar', undefined); - const KeyShape = makeMatcher('match:key', undefined); - const PatternShape = makeMatcher('match:pattern', undefined); + const AnyShape = makeMatcher(matchAnyHelper, undefined); + const ScalarShape = makeMatcher(matchScalarHelper, undefined); + const KeyShape = makeMatcher(matchKeyHelper, undefined); const BooleanShape = makeKindMatcher('boolean'); const NumberShape = makeKindMatcher('number'); - const BigIntShape = makeTagged('match:bigint', []); - const NatShape = makeTagged('match:nat', []); - const StringShape = makeTagged('match:string', []); - const SymbolShape = makeTagged('match:symbol', []); - const RecordShape = makeTagged('match:recordOf', [AnyShape, AnyShape]); - const ArrayShape = makeTagged('match:arrayOf', [AnyShape]); - const SetShape = makeTagged('match:setOf', [AnyShape]); - const BagShape = makeTagged('match:bagOf', [AnyShape, AnyShape]); - const MapShape = makeTagged('match:mapOf', [AnyShape, AnyShape]); + const BigIntShape = makeMatcher(matchBigintHelper, []); + const NatShape = makeMatcher(matchNatHelper, []); + const StringShape = makeMatcher(matchStringHelper, []); + const SymbolShape = makeMatcher(matchSymbolHelper, []); + const RecordShape = makeMatcher(matchRecordOfHelper, [AnyShape, AnyShape]); + const ArrayShape = makeMatcher(matchArrayOfHelper, [AnyShape]); + const SetShape = makeMatcher(matchSetOfHelper, [AnyShape]); + const BagShape = makeMatcher(matchBagOfHelper, [AnyShape, AnyShape]); + const MapShape = makeMatcher(matchMapOfHelper, [AnyShape, AnyShape]); const RemotableShape = makeKindMatcher('remotable'); const ErrorShape = makeKindMatcher('error'); const PromiseShape = makeKindMatcher('promise'); @@ -1531,20 +2016,20 @@ const makePatternKit = () => { * so that when it is `undefined` it is dropped from the end of the * payloads array. * - * @param {string} tag + * @param {MatchHelper} matchHelper * @param {Passable[]} payload */ - const makeLimitsMatcher = (tag, payload) => { + const makeLimitsMatcher = (matchHelper, payload) => { if (payload[payload.length - 1] === undefined) { payload = harden(payload.slice(0, payload.length - 1)); } - return makeMatcher(tag, payload); + return makeMatcher(matchHelper, payload); }; const makeRemotableMatcher = (label = undefined) => label === undefined ? RemotableShape - : makeMatcher('match:remotable', harden({ label })); + : makeMatcher(matchRemotableHelper, harden({ label })); /** * @template T @@ -1574,9 +2059,21 @@ const makePatternKit = () => { /** @type {MatcherNamespace} */ const M = harden({ any: () => AnyShape, - and: (...patts) => makeMatcher('match:and', patts), - or: (...patts) => makeMatcher('match:or', patts), - not: subPatt => makeMatcher('match:not', subPatt), + and: (...patts) => + // eslint-disable-next-line no-nested-ternary + patts.length === 0 + ? M.any() + : patts.length === 1 + ? patts[0] + : makeMatcher(matchAndHelper, patts), + or: (...patts) => + // eslint-disable-next-line no-nested-ternary + patts.length === 0 + ? M.not(M.any()) + : patts.length === 1 + ? patts[0] + : makeMatcher(matchOrHelper, patts), + not: subPatt => makeMatcher(matchNotHelper, subPatt), scalar: () => ScalarShape, key: () => KeyShape, @@ -1585,13 +2082,13 @@ const makePatternKit = () => { boolean: () => BooleanShape, number: () => NumberShape, bigint: (limits = undefined) => - limits ? makeLimitsMatcher('match:bigint', [limits]) : BigIntShape, + limits ? makeLimitsMatcher(matchBigintHelper, [limits]) : BigIntShape, nat: (limits = undefined) => - limits ? makeLimitsMatcher('match:nat', [limits]) : NatShape, + limits ? makeLimitsMatcher(matchNatHelper, [limits]) : NatShape, string: (limits = undefined) => - limits ? makeLimitsMatcher('match:string', [limits]) : StringShape, + limits ? makeLimitsMatcher(matchStringHelper, [limits]) : StringShape, symbol: (limits = undefined) => - limits ? makeLimitsMatcher('match:symbol', [limits]) : SymbolShape, + limits ? makeLimitsMatcher(matchSymbolHelper, [limits]) : SymbolShape, record: (limits = undefined) => limits ? M.recordOf(M.any(), M.any(), limits) : RecordShape, array: (limits = undefined) => @@ -1607,39 +2104,39 @@ const makePatternKit = () => { undefined: () => UndefinedShape, null: () => null, - lt: rightOperand => makeMatcher('match:lt', rightOperand), - lte: rightOperand => makeMatcher('match:lte', rightOperand), + lt: rightOperand => makeMatcher(matchLTHelper, rightOperand), + lte: rightOperand => makeMatcher(matchLTEHelper, rightOperand), eq: key => { assertKey(key); return key === undefined ? M.undefined() : key; }, neq: key => M.not(M.eq(key)), - gte: rightOperand => makeMatcher('match:gte', rightOperand), - gt: rightOperand => makeMatcher('match:gt', rightOperand), + gte: rightOperand => makeMatcher(matchGTEHelper, rightOperand), + gt: rightOperand => makeMatcher(matchGTHelper, rightOperand), recordOf: (keyPatt = M.any(), valuePatt = M.any(), limits = undefined) => - makeLimitsMatcher('match:recordOf', [keyPatt, valuePatt, limits]), + makeLimitsMatcher(matchRecordOfHelper, [keyPatt, valuePatt, limits]), arrayOf: (subPatt = M.any(), limits = undefined) => - makeLimitsMatcher('match:arrayOf', [subPatt, limits]), + makeLimitsMatcher(matchArrayOfHelper, [subPatt, limits]), setOf: (keyPatt = M.any(), limits = undefined) => - makeLimitsMatcher('match:setOf', [keyPatt, limits]), + makeLimitsMatcher(matchSetOfHelper, [keyPatt, limits]), bagOf: (keyPatt = M.any(), countPatt = M.any(), limits = undefined) => - makeLimitsMatcher('match:bagOf', [keyPatt, countPatt, limits]), + makeLimitsMatcher(matchBagOfHelper, [keyPatt, countPatt, limits]), mapOf: (keyPatt = M.any(), valuePatt = M.any(), limits = undefined) => - makeLimitsMatcher('match:mapOf', [keyPatt, valuePatt, limits]), + makeLimitsMatcher(matchMapOfHelper, [keyPatt, valuePatt, limits]), splitArray: (base, optional = undefined, rest = undefined) => makeMatcher( - 'match:splitArray', + matchSplitArrayHelper, makeSplitPayload([], base, optional, rest), ), splitRecord: (base, optional = undefined, rest = undefined) => makeMatcher( - 'match:splitRecord', + matchSplitRecordHelper, makeSplitPayload({}, base, optional, rest), ), split: (base, rest = undefined) => { if (passStyleOf(harden(base)) === 'copyArray') { - // @ts-expect-error We know it should be an array + // @ts-ignore We know `base` should be an array return M.splitArray(base, rest && [], rest); } else { return M.splitRecord(base, rest && {}, rest); @@ -1647,7 +2144,7 @@ const makePatternKit = () => { }, partial: (base, rest = undefined) => { if (passStyleOf(harden(base)) === 'copyArray') { - // @ts-expect-error We know it should be an array + // @ts-ignore We know `base` should be an array return M.splitArray([], base, rest); } else { return M.splitRecord({}, base, rest); @@ -1655,7 +2152,8 @@ const makePatternKit = () => { }, eref: t => M.or(t, M.promise()), - opt: t => M.or(M.undefined(), t), + // `undefined` compresses better than `M.undefined()` + opt: t => M.or(undefined, t), interface: (interfaceName, methodGuards, options) => // eslint-disable-next-line no-use-before-define @@ -1678,6 +2176,8 @@ const makePatternKit = () => { assertPattern, isPattern, getRankCover, + kindOf, + maybeMatchHelper, M, }); }; @@ -1695,6 +2195,8 @@ export const { assertPattern, isPattern, getRankCover, + kindOf, + maybeMatchHelper, M, } = makePatternKit(); diff --git a/packages/patterns/src/types.js b/packages/patterns/src/types.js index e2a0512eb3..92f94ecea3 100644 --- a/packages/patterns/src/types.js +++ b/packages/patterns/src/types.js @@ -2,12 +2,12 @@ export {}; -/** @typedef {import('@endo/marshal').Passable} Passable */ -/** @typedef {import('@endo/marshal').PassStyle} PassStyle */ -/** @typedef {import('@endo/marshal').CopyTagged} CopyTagged */ -/** @template T @typedef {import('@endo/marshal').CopyRecord} CopyRecord */ -/** @template T @typedef {import('@endo/marshal').CopyArray} CopyArray */ -/** @typedef {import('@endo/marshal').Checker} Checker */ +/** @typedef {import('@endo/pass-style').Passable} Passable */ +/** @typedef {import('@endo/pass-style').PassStyle} PassStyle */ +/** @typedef {import('@endo/pass-style').CopyTagged} CopyTagged */ +/** @template T @typedef {import('@endo/pass-style').CopyRecord} CopyRecord */ +/** @template T @typedef {import('@endo/pass-style').CopyArray} CopyArray */ +/** @typedef {import('@endo/pass-style').Checker} Checker */ /** @typedef {import('@endo/marshal').RankCompare} RankCompare */ /** @typedef {import('@endo/marshal').RankCover} RankCover */ @@ -253,6 +253,50 @@ export {}; * @typedef {Partial} Limits */ +/** + * @typedef {string} Kind + * It is either a PassStyle other than 'tagged', or, if the underlying + * PassStyle is 'tagged', then the `getTag` value for tags that are + * recognized at the store level of abstraction. For each of those + * tags, a tagged record only has that kind if it satisfies the invariants + * that the store level associates with that kind. + */ + +/** + * @typedef {object} CompressedRecord + * @property {Passable} compressed + */ + +/** + * @callback Compress + * @param {Passable} specimen + * @param {Pattern} pattern + * @returns {CompressedRecord | undefined} + */ + +/** + * @callback MustCompress + * @param {Passable} specimen + * @param {Pattern} pattern + * @param {string|number} [label] + * @returns {Passable} + */ + +/** + * @callback Decompress + * @param {Passable} compressed + * @param {Pattern} pattern + * @returns {Passable} + */ + +/** + * @callback MustDecompress + * @param {Passable} compressed + * @param {Pattern} pattern + * @param {string|number} [label] + * @returns {Passable} + */ + /** * @typedef {object} PatternMatchers * diff --git a/packages/patterns/test/test-compress.js b/packages/patterns/test/test-compress.js new file mode 100644 index 0000000000..d81e4f5eb9 --- /dev/null +++ b/packages/patterns/test/test-compress.js @@ -0,0 +1,269 @@ +// @ts-check + +import { test } from './prepare-test-env-ava.js'; + +// eslint-disable-next-line import/order +import { Far, makeTagged, makeMarshal } from '@endo/marshal'; +import { + makeCopyBagFromElements, + makeCopyMap, + makeCopySet, +} from '../src/keys/checkKey.js'; +import { mustCompress, mustDecompress } from '../src/patterns/compress.js'; +import { M } from '../src/patterns/patternMatchers.js'; + +const runTests = testTriple => { + const brand = Far('simoleans', {}); + const moolaBrand = Far('moola', {}); + const timer = Far('timer', {}); + + testTriple({ brand, value: 37n }, M.any(), { brand, value: 37n }); + testTriple({ brand, value: 37n }, { brand, value: M.bigint() }, [37n]); + testTriple( + { brand, value: 37n }, + { brand: M.remotable(), value: M.bigint() }, + [37n, brand], + ); + testTriple( + { brand, value: 37n }, + { brand: M.bigint(), value: M.bigint() }, + undefined, + 'test mustCompress: brand: remotable "[Alleged: simoleans]" - Must be a bigint', + ); + testTriple({ brand, value: 37n }, M.recordOf(M.string(), M.scalar()), { + brand, + value: 37n, + }); + testTriple( + [{ foo: 'a' }, { foo: 'b' }, { foo: 'c' }], + M.arrayOf(harden({ foo: M.string() })), + [[['a'], ['b'], ['c']]], + ); + testTriple( + [{ foo: 'a' }, { foo: 'b' }, { foo: 'c' }], + // Test that without the compression version tag, there is no + // non -default compression or decompression + makeTagged('match:arrayOf', harden([{ foo: M.string() }])), + [{ foo: 'a' }, { foo: 'b' }, { foo: 'c' }], + ); + testTriple( + makeCopySet([{ foo: 'a' }, { foo: 'b' }, { foo: 'c' }]), + M.setOf(harden({ foo: M.string() })), + [[['c'], ['b'], ['a']]], + ); + testTriple( + makeCopyBagFromElements([{ foo: 'a' }, { foo: 'a' }, { foo: 'c' }]), + M.bagOf(harden({ foo: M.string() })), + [ + [ + ['c', 1n], + ['a', 2n], + ], + ], + ); + testTriple( + makeCopyBagFromElements([{ foo: 'a' }, { foo: 'a' }, { foo: 'c' }]), + M.bagOf(harden({ foo: M.string() }), 1n), + undefined, + 'test mustCompress: bag counts[1]: "[2n]" - Must be: "[1n]"', + ); + testTriple( + makeCopyBagFromElements([{ foo: 'a' }, { foo: 'b' }, { foo: 'c' }]), + M.bagOf(harden({ foo: M.string() }), 1n), + [[['c'], ['b'], ['a']]], + ); + testTriple( + makeCopyMap([ + [{ foo: 'a' }, { bar: 1 }], + [{ foo: 'b' }, { bar: 2 }], + [{ foo: 'c' }, { bar: 3 }], + ]), + M.mapOf(harden({ foo: M.string() }), harden({ bar: M.number() })), + [ + [ + [['c'], ['b'], ['a']], + [[3], [2], [1]], + ], + ], + ); + testTriple( + makeCopyMap([ + [{ foo: 'c' }, { bar: 3 }], + [{ foo: 'b' }, { bar: 2 }], + [{ foo: 'a' }, { bar: 1 }], + ]), + // TODO Add a test case where the keys are in the same rankOrder but not + // the same order. + makeCopyMap([ + [{ foo: 'c' }, M.any()], + // @ts-expect-error The array need not be generic + [{ foo: 'b' }, { bar: M.number() }], + [{ foo: 'a' }, { bar: 1 }], + ]), + [{ bar: 3 }, 2], + ); + testTriple( + { + want: { Winnings: { brand: moolaBrand, value: ['x', 'y'] } }, + give: { Bid: { brand, value: 37n } }, + exit: { afterDeadline: { deadline: 11n, timer } }, + }, + { + want: { Winnings: { brand: moolaBrand, value: M.array() } }, + give: { Bid: { brand, value: M.nat() } }, + exit: { afterDeadline: { deadline: M.gte(10n), timer } }, + }, + [['x', 'y'], 37n, 11n], + ); + testTriple( + { + want: { + Winnings: { + brand: moolaBrand, + value: makeCopyBagFromElements([ + { foo: 'a' }, + { foo: 'b' }, + { foo: 'c' }, + ]), + }, + }, + give: { Bid: { brand, value: 37n } }, + exit: { afterDeadline: { deadline: 11n, timer } }, + }, + { + want: { + Winnings: { + brand: moolaBrand, + value: M.bagOf(harden({ foo: M.string() }), 1n), + }, + }, + give: { Bid: { brand, value: M.nat() } }, + exit: { afterDeadline: { deadline: M.gte(10n), timer } }, + }, + [[['c'], ['b'], ['a']], 37n, 11n], + ); + testTriple( + 'orange', + M.or('red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet'), + [[1, []]], + ); + testTriple( + { x: 3, y: 5 }, + M.or(harden({ x: M.number(), y: M.number() }), M.bigint(), M.record()), + [[0, [5, 3]]], + ); + testTriple( + [5n], + M.or(harden({ x: M.number(), y: M.number() }), [M.bigint()], M.record()), + [[1, [5n]]], + ); + testTriple( + { x: 3, y: 5, z: 9 }, + M.or(harden({ x: M.number(), y: M.number() }), M.bigint(), M.record()), + [[2, { x: 3, y: 5, z: 9 }]], + ); + testTriple( + { + brand, + value: [{ bar: 2 }, { bar: 1 }], + }, + { + brand, + value: M.arrayOf(M.and(M.key(), { bar: M.number() })), + }, + [[[[2]], [[1]]]], + ); + testTriple( + ['a', 'b', 'c', 'd', 'e'], + M.splitArray(['a', M.string()], [M.any()], M.any()), + [[['b'], [[1, 'c']], ['d', 'e']]], + ); + testTriple( + ['a', 'b', undefined, 'd'], + M.splitArray(['a', M.string()], ['c', 'd', 'e'], M.any()), + [ + [ + ['b'], + [ + [0, []], + [1, []], + ], + [], + ], + ], + ); + testTriple( + { a: 1, b: 2, c: undefined, d: 4, e: 5 }, + M.splitRecord({ a: 1, b: M.number() }, { c: M.any(), d: 4, f: 6 }, M.any()), + [[[2], [null, [1, []], [0, []]], { e: 5 }]], + ); +}; + +test('compression', t => { + const testCompress = (specimen, pattern, compressed, message = undefined) => { + if (!message) { + t.deepEqual( + mustCompress(harden(specimen), harden(pattern)), + harden(compressed), + ); + } + }; + runTests(testCompress); +}); + +test('test mustCompress', t => { + const testCompress = (specimen, pattern, compressed, message = undefined) => { + if (message === undefined) { + t.deepEqual( + mustCompress(harden(specimen), harden(pattern), 'test mustCompress'), + harden(compressed), + ); + } else { + t.throws( + () => + mustCompress(harden(specimen), harden(pattern), 'test mustCompress'), + { message }, + ); + } + }; + runTests(testCompress); +}); + +test('decompression', t => { + const testDecompress = ( + specimen, + pattern, + compressed, + message = undefined, + ) => { + if (message === undefined) { + t.deepEqual( + mustDecompress(harden(compressed), harden(pattern)), + harden(specimen), + ); + } + }; + runTests(testDecompress); +}); + +test('demo compression ratio', t => { + const { toCapData } = makeMarshal(() => 's', undefined, { + serializeBodyFormat: 'smallcaps', + }); + + const testCompress = (specimen, pattern, compressed, message = undefined) => { + harden(specimen); + harden(pattern); + harden(compressed); + if (message === undefined) { + const { body: big } = toCapData(specimen); + const { body: small } = toCapData(compressed); + const ratio = small.length / big.length; + console.log('\n', big, '\n', small, '\n', ratio); + const { body: patt } = toCapData(pattern); + console.log('Pattern: ', patt); + t.assert(ratio <= 2.0); + } + }; + runTests(testCompress); +}); diff --git a/packages/patterns/test/test-patterns.js b/packages/patterns/test/test-patterns.js index bb76225edc..c1f6127ead 100644 --- a/packages/patterns/test/test-patterns.js +++ b/packages/patterns/test/test-patterns.js @@ -39,7 +39,7 @@ const runTests = (successCase, failCase) => { failCase(specimen, M.gte('x'), '3 - Must be >= "x"'); failCase(specimen, M.and(3, 4), '3 - Must be: 4'); failCase(specimen, M.or(4, 4), '3 - Must match one of [4,4]'); - failCase(specimen, M.or(), '3 - no pattern disjuncts to match: []'); + failCase(specimen, M.or(), '3 - Must fail negated pattern: "[match:any]"'); } { const specimen = [3, 4];