From f0269349d31ecf07d59b75881f40dd7f83bf5f48 Mon Sep 17 00:00:00 2001 From: "Mark S. Miller" Date: Mon, 3 Feb 2025 18:56:16 -0800 Subject: [PATCH] feat(patterns): M.has(el,n) to support want patterns --- packages/nat/src/index.js | 2 +- packages/pass-style/src/makeTagged.js | 8 +- packages/patterns/NEWS.md | 5 + packages/patterns/index.js | 1 + packages/patterns/package.json | 1 + .../patterns/src/keys/merge-bag-operators.js | 1 - .../patterns/src/patterns/patternMatchers.js | 248 +++++++++++++++++- packages/patterns/src/types.js | 7 + packages/patterns/test/patterns.test.js | 51 +++- 9 files changed, 306 insertions(+), 18 deletions(-) diff --git a/packages/nat/src/index.js b/packages/nat/src/index.js index 0668a5feab..1fa289fd19 100644 --- a/packages/nat/src/index.js +++ b/packages/nat/src/index.js @@ -55,7 +55,7 @@ function isNat(allegedNum) { */ function Nat(allegedNum) { if (typeof allegedNum === 'bigint') { - if (allegedNum < 0) { + if (allegedNum < 0n) { throw RangeError(`${allegedNum} is negative`); } return allegedNum; diff --git a/packages/pass-style/src/makeTagged.js b/packages/pass-style/src/makeTagged.js index bfe1f2e805..00e13a126f 100644 --- a/packages/pass-style/src/makeTagged.js +++ b/packages/pass-style/src/makeTagged.js @@ -4,14 +4,18 @@ import { Fail } from '@endo/errors'; import { PASS_STYLE } from './passStyle-helpers.js'; import { assertPassable } from './passStyleOf.js'; +/** + * @import {Passable,CopyTagged} from './types.js' + */ + const { create, prototype: objectPrototype } = Object; /** * @template {string} T - * @template {import('./types.js').Passable} P + * @template {Passable} P * @param {T} tag * @param {P} payload - * @returns {import('./types.js').CopyTagged} + * @returns {CopyTagged} */ export const makeTagged = (tag, payload) => { typeof tag === 'string' || diff --git a/packages/patterns/NEWS.md b/packages/patterns/NEWS.md index b644bb765a..56e4fa5a6b 100644 --- a/packages/patterns/NEWS.md +++ b/packages/patterns/NEWS.md @@ -1,5 +1,10 @@ User-visible changes in `@endo/patterns`: +# Next release + +- New pattern: `M.containerHas(elementPatt, bound = 1n)` motivated to support want patterns in Zoe, to pull out only `bound` number of elements that match `elementPatt`. `bound` must be a positive bigint. +- Closely related, `@endo/patterns` now exports `containerHasSplit` to support ERTP's use of `M.containerHas` on non-fungible (`set`, `copySet`) and semifungible (`copyBag`) assets, respectively. See https://github.com/Agoric/agoric-sdk/pull/10952 . + # v1.4.0 (2024-05-06) - `Passable` is now an accurate type instead of `any`. Downstream type checking may require changes ([example](https://github.com/Agoric/agoric-sdk/pull/8774)) diff --git a/packages/patterns/index.js b/packages/patterns/index.js index b32e6ea809..16d3741b2f 100644 --- a/packages/patterns/index.js +++ b/packages/patterns/index.js @@ -65,6 +65,7 @@ export { assertMethodGuard, assertInterfaceGuard, kindOf, + containerHasSplit, } from './src/patterns/patternMatchers.js'; export { diff --git a/packages/patterns/package.json b/packages/patterns/package.json index 144c09a760..cb0a9bf0ca 100644 --- a/packages/patterns/package.json +++ b/packages/patterns/package.json @@ -36,6 +36,7 @@ "@endo/errors": "workspace:^", "@endo/eventual-send": "workspace:^", "@endo/marshal": "workspace:^", + "@endo/pass-style": "workspace:^", "@endo/promise-kit": "workspace:^" }, "devDependencies": { diff --git a/packages/patterns/src/keys/merge-bag-operators.js b/packages/patterns/src/keys/merge-bag-operators.js index 1cede58f6e..2f10fefe06 100644 --- a/packages/patterns/src/keys/merge-bag-operators.js +++ b/packages/patterns/src/keys/merge-bag-operators.js @@ -8,7 +8,6 @@ import { q, Fail } from '@endo/errors'; import { assertNoDuplicateKeys, makeBagOfEntries } from './copyBag.js'; /** - * @import {Passable} from '@endo/pass-style'; * @import {FullCompare, RankCompare} from '@endo/marshal' * @import {Key} from '../types.js' */ diff --git a/packages/patterns/src/patterns/patternMatchers.js b/packages/patterns/src/patterns/patternMatchers.js index 8cc6815f7f..a64e6845f8 100644 --- a/packages/patterns/src/patterns/patternMatchers.js +++ b/packages/patterns/src/patterns/patternMatchers.js @@ -1,5 +1,11 @@ +/* eslint-disable @endo/no-optional-chaining */ // @ts-nocheck So many errors that the suppressions hamper readability. // TODO parameterize MatchHelper which will solve most of them +import { q, b, X, Fail, makeError, annotateError } from '@endo/errors'; +import { identChecker } from '@endo/common/ident-checker.js'; +import { applyLabelingError } from '@endo/common/apply-labeling-error.js'; +import { fromUniqueEntries } from '@endo/common/from-unique-entries.js'; +import { listDifference } from '@endo/common/list-difference.js'; import { assertChecker, Far, @@ -8,6 +14,8 @@ import { passStyleOf, hasOwnPropertyOf, nameForPassableSymbol, +} from '@endo/pass-style'; +import { compareRank, getPassStyleCover, intersectRankCovers, @@ -15,12 +23,7 @@ import { recordNames, recordValues, } from '@endo/marshal'; -import { identChecker } from '@endo/common/ident-checker.js'; -import { applyLabelingError } from '@endo/common/apply-labeling-error.js'; -import { fromUniqueEntries } from '@endo/common/from-unique-entries.js'; -import { listDifference } from '@endo/common/list-difference.js'; -import { q, b, X, Fail, makeError, annotateError } from '@endo/errors'; import { keyEQ, keyGT, keyGTE, keyLT, keyLTE } from '../keys/compareKeys.js'; import { assertKey, @@ -33,12 +36,14 @@ import { checkCopyBag, getCopyMapEntryArray, makeCopyMap, + makeCopySet, + makeCopyBag, } from '../keys/checkKey.js'; import { generateCollectionPairEntries } from '../keys/keycollection-operators.js'; /** - * @import {Checker, CopyRecord, CopyTagged, Passable} from '@endo/pass-style' - * @import {ArgGuard, AwaitArgGuard, CheckPattern, GetRankCover, InterfaceGuard, MatcherNamespace, MethodGuard, MethodGuardMaker, Pattern, RawGuard, SyncValueGuard, Kind, Limits, AllLimits, Key, DefaultGuardType} from '../types.js' + * @import {Checker, CopyArray, CopyRecord, CopyTagged, Passable} from '@endo/pass-style' + * @import {CopySet, CopyBag, ArgGuard, AwaitArgGuard, CheckPattern, GetRankCover, InterfaceGuard, MatcherNamespace, MethodGuard, MethodGuardMaker, Pattern, RawGuard, SyncValueGuard, Kind, Limits, AllLimits, Key, DefaultGuardType} from '../types.js' * @import {MatchHelper, PatternKit} from './types.js' */ @@ -1258,6 +1263,230 @@ const makePatternKit = () => { getRankCover: () => getPassStyleCover('tagged'), }); + /** + * @param {CopyArray} elements + * @param {Pattern} elementPatt + * @param {bigint} bound Must be >= 1n + * @param {CopyArray} [inResults] + * @param {CopyArray} [outResults] + * @param {Checker} [check] + * @returns {boolean} + */ + const elementsHasSplit = ( + elements, + elementPatt, + bound, + inResults = undefined, + outResults = undefined, + check = identChecker, + ) => { + let count = 0n; + // Since this feature is motivated by ERTP's use on + // non-fungible (`set`, `copySet`) amounts, + // their arrays store their elements in decending lexicographic order. + // But this function has to make some choice amoung equally good minimal + // results. It is more intuitive for the choice to be the first `bound` + // matching elements in ascending lexicigraphic order, rather than + // decending. Thus we iterate `elements` in reverse order. + for (let i = elements.length - 1; i >= 0; i -= 1) { + const element = elements[i]; + if (count < bound) { + if (matches(element, elementPatt)) { + count += 1n; + inResults?.push(element); + } else { + outResults?.push(element); + } + } else if (outResults === undefined) { + break; + } else { + outResults.push(element); + } + } + return check( + count >= bound, + X`Has only ${q(count)} matches, but needs ${q(bound)}`, + ); + }; + + /** + * @param {CopyArray<[Key, bigint]>} pairs + * @param {Pattern} elementPatt + * @param {bigint} bound Must be >= 1n + * @param {CopyArray<[Key, bigint]>} [inResults] + * @param {CopyArray<[Key, bigint]>} [outResults] + * @param {Checker} [check] + * @returns {boolean} + */ + const pairsHasSplit = ( + pairs, + elementPatt, + bound, + inResults = undefined, + outResults = undefined, + check = identChecker, + ) => { + let count = 0n; + // Since this feature is motivated by ERTP's use on + // semi-fungible (`copyBag`) amounts, + // their arrays store their elements in decending lexicographic order. + // But this function has to make some choice amoung equally good minimal + // results. It is more intuitive for the choice to be the first `bound` + // matching elements in ascending lexicigraphic order, rather than + // decending. Thus we iterate `pairs` in reverse order. + for (let i = pairs.length - 1; i >= 0; i -= 1) { + const [element, num] = pairs[i]; + const numRest = bound - count; + if (numRest >= 1n) { + if (matches(element, elementPatt)) { + if (num <= numRest) { + count += num; + inResults?.push([element, num]); + } else { + const numIn = numRest; + count += numIn; + inResults?.push([element, numRest]); + outResults?.push([element, num - numRest]); + } + } else { + outResults?.push([element, num]); + } + } else if (outResults === undefined) { + break; + } else { + outResults.push([element, num]); + } + } + return check( + count >= bound, + X`Has only ${q(count)} matches, but needs ${q(bound)}`, + ); + }; + + /** + * @typedef {CopyArray | CopySet | CopyBag} Container + * @param {Container} specimen + * @param {Pattern} elementPatt + * @param {bigint} bound Must be >= 1n + * @param {boolean} [needInResults] + * @param {boolean} [needOutResults] + * @param {Checker} [check] + * @returns {[Container | undefined, Container | undefined] | false} + */ + const containerHasSplit = ( + specimen, + elementPatt, + bound, + needInResults = false, + needOutResults = false, + check = identChecker, + ) => { + const inResults = needInResults ? [] : undefined; + const outResults = needOutResults ? [] : undefined; + const kind = kindOf(specimen); + switch (kind) { + case 'copyArray': { + if ( + !elementsHasSplit( + specimen, + elementPatt, + bound, + inResults, + outResults, + check, + ) + ) { + // check logic already performed by elementsHasSplit + return false; + } + return [inResults, outResults]; + } + case 'copySet': { + if ( + !elementsHasSplit( + specimen.payload, + elementPatt, + bound, + inResults, + outResults, + check, + ) + ) { + return false; + } + return [ + inResults && makeCopySet(inResults), + outResults && makeCopySet(outResults), + ]; + } + case 'copyBag': { + if ( + !pairsHasSplit( + specimen.payload, + elementPatt, + bound, + inResults, + outResults, + check, + ) + ) { + return false; + } + return [ + inResults && makeCopyBag(inResults), + outResults && makeCopyBag(outResults), + ]; + } + default: { + return check(false, X`unexpected ${q(kind)}`); + } + } + }; + + /** @type {MatchHelper} */ + const matchContainerHasHelper = Far('M.containerHas helper', { + /** + * @param {CopyArray | CopySet | CopyBag} specimen + * @param {[Pattern, bigint, Limits?]} payload + * @param {Checker} check + */ + checkMatches: ( + specimen, + [elementPatt, bound, limits = undefined], + check, + ) => { + const kind = kindOf(specimen, check); + const { decimalDigitsLimit } = limit(limits); + if ( + !applyLabelingError( + checkDecimalDigitsLimit, + [bound, decimalDigitsLimit, check], + `${kind} matches`, + ) + ) { + return false; + } + return !!containerHasSplit( + specimen, + elementPatt, + bound, + false, + false, + check, + ); + }, + + checkIsWellFormed: (payload, check) => + checkIsWellFormedWithLimit( + payload, + harden([MM.pattern(), MM.gte(1n)]), + check, + 'M.containerHas payload', + ), + + getRankCover: () => getPassStyleCover('tagged'), + }); + /** @type {MatchHelper} */ const matchMapOfHelper = Far('match:mapOf helper', { checkMatches: ( @@ -1548,6 +1777,7 @@ const makePatternKit = () => { 'match:recordOf': matchRecordOfHelper, 'match:setOf': matchSetOfHelper, 'match:bagOf': matchBagOfHelper, + 'match:containerHas': matchContainerHasHelper, 'match:mapOf': matchMapOfHelper, 'match:splitArray': matchSplitArrayHelper, 'match:splitRecord': matchSplitRecordHelper, @@ -1702,6 +1932,8 @@ const makePatternKit = () => { makeLimitsMatcher('match:setOf', [keyPatt, limits]), bagOf: (keyPatt = M.any(), countPatt = M.any(), limits = undefined) => makeLimitsMatcher('match:bagOf', [keyPatt, countPatt, limits]), + containerHas: (elementPatt = M.any(), countPatt = 1n, limits = undefined) => + makeLimitsMatcher('match:containerHas', [elementPatt, countPatt, limits]), mapOf: (keyPatt = M.any(), valuePatt = M.any(), limits = undefined) => makeLimitsMatcher('match:mapOf', [keyPatt, valuePatt, limits]), splitArray: (base, optional = undefined, rest = undefined) => @@ -1763,6 +1995,7 @@ const makePatternKit = () => { getRankCover, M, kindOf, + containerHasSplit, }); }; @@ -1781,6 +2014,7 @@ export const { getRankCover, M, kindOf, + containerHasSplit, } = makePatternKit(); MM = M; diff --git a/packages/patterns/src/types.js b/packages/patterns/src/types.js index 99a12be127..7244f52314 100644 --- a/packages/patterns/src/types.js +++ b/packages/patterns/src/types.js @@ -380,6 +380,13 @@ export {}; * `countPatt` is expected to rarely be useful, * but is provided to minimize surprise. * + * @property {(elementPatt?: Pattern, + * bound?: bigint, + * limits?: Limits + * ) => Matcher} containerHas + * Matches any array, CopySet, or CopyBag in which the bigint number of + * elements that match `elementPatt` is >= `bound` (which defaults to `1n`). + * * @property {(keyPatt?: Pattern, * valuePatt?: Pattern, * limits?: Limits diff --git a/packages/patterns/test/patterns.test.js b/packages/patterns/test/patterns.test.js index f5279579df..c63b94eb0d 100644 --- a/packages/patterns/test/patterns.test.js +++ b/packages/patterns/test/patterns.test.js @@ -199,6 +199,10 @@ const runTests = (t, successCase, failCase) => { successCase(specimen, M.arrayOf(M.number())); + successCase(specimen, M.containerHas(3)); + successCase(specimen, M.containerHas(3, 1n)); + successCase(specimen, M.containerHas(M.number(), 2n)); + failCase(specimen, [4, 3], '[3,4] - Must be: [4,3]'); failCase(specimen, [3], '[3,4] - Must be: [3]'); failCase( @@ -227,6 +231,12 @@ const runTests = (t, successCase, failCase) => { M.arrayOf(M.string()), '[0]: number 3 - Must be a string', ); + + failCase( + specimen, + M.containerHas('c'), + 'Has only "[0n]" matches, but needs "[1n]"', + ); } { const specimen = { foo: 3, bar: 4 }; @@ -419,6 +429,10 @@ const runTests = (t, successCase, failCase) => { successCase(specimen, M.lte(makeCopySet([3, 4, 5]))); successCase(specimen, M.setOf(M.number())); + successCase(specimen, M.containerHas(3)); + successCase(specimen, M.containerHas(3, 1n)); + successCase(specimen, M.containerHas(M.number(), 2n)); + failCase(specimen, makeCopySet([]), '"[copySet]" - Must be: "[copySet]"'); failCase( specimen, @@ -440,6 +454,12 @@ const runTests = (t, successCase, failCase) => { M.setOf(M.string()), 'set elements[0]: number 4 - Must be a string', ); + + failCase( + specimen, + M.containerHas('c'), + 'Has only "[0n]" matches, but needs "[1n]"', + ); } { const specimen = makeCopyBag([ @@ -472,6 +492,14 @@ const runTests = (t, successCase, failCase) => { ); successCase(specimen, M.bagOf(M.string())); successCase(specimen, M.bagOf(M.string(), M.lt(5n))); + successCase(specimen, M.bagOf(M.string(), M.gte(2n))); + + successCase(specimen, M.containerHas('a')); + successCase(specimen, M.containerHas('a', 2n)); + successCase(specimen, M.containerHas(M.string(), 5n)); + successCase(specimen, M.containerHas('a', 1n)); + successCase(specimen, M.containerHas('a')); + successCase(specimen, M.containerHas('b', 2n)); failCase( specimen, @@ -499,15 +527,11 @@ const runTests = (t, successCase, failCase) => { 'bag keys[0]: string "b" - Must be a boolean', ); failCase(specimen, M.bagOf('b'), 'bag keys[1]: "a" - Must be: "b"'); + failCase( specimen, - M.bagOf(M.any(), M.gt(5n)), - 'bag counts[0]: "[3n]" - Must be > "[5n]"', - ); - failCase( - specimen, - M.bagOf(M.any(), M.gt(2n)), - 'bag counts[1]: "[2n]" - Must be > "[2n]"', + M.containerHas('c'), + 'Has only "[0n]" matches, but needs "[1n]"', ); } { @@ -867,4 +891,17 @@ test('well formed patterns', t => { t.throws(() => M.remotable(88), { message: 'match:remotable payload: label: number 88 - Must be a string', }); + + t.throws(() => M.containerHas('c', 0n), { + message: 'M.containerHas payload: [1]: "[0n]" - Must be >= "[1n]"', + }); + // @ts-expect-error purposeful type violation for testing + t.throws(() => M.containerHas('c', M.nat()), { + message: + 'M.containerHas payload: [1]: A passable tagged "match:nat" is not a key: "[match:nat]"', + }); + // @ts-expect-error purposeful type violation for testing + t.throws(() => M.containerHas(3, 1), { + message: 'M.containerHas payload: [1]: 1 - Must be >= "[1n]"', + }); });