From fb89bab1928c07afa0c496d9558485f1ceb194b1 Mon Sep 17 00:00:00 2001 From: "Mark S. Miller" Date: Sun, 16 Jan 2022 00:25:21 -0800 Subject: [PATCH] fix: fullOrder leak. Semi-fungibles via CopyBags --- packages/ERTP/package.json | 3 +- packages/ERTP/src/amountMath.js | 23 +- .../src/mathHelpers/copyBagMathHelpers.js | 32 ++ .../src/mathHelpers/copySetMathHelpers.js | 18 +- .../ERTP/src/mathHelpers/setMathHelpers.js | 44 +- packages/ERTP/src/typeGuards.js | 40 +- packages/ERTP/src/types.js | 14 +- .../mathHelpers/test-copyBagMathHelpers.js | 213 ++++++++ .../mathHelpers/test-copySetMathHelpers.js | 5 +- .../mathHelpers/test-natMathHelpers.js | 13 +- .../mathHelpers/test-setMathHelpers.js | 85 +-- .../mathHelpers/test-strSetMathHelpers.js | 5 +- .../test/unitTests/test-amountProperties.js | 86 +++ .../test/unitTests/test-inputValidation.js | 2 +- packages/store/src/index.js | 48 +- packages/store/src/keys/checkKey.js | 500 +++++++++++++++--- packages/store/src/keys/compareKeys.js | 78 +-- packages/store/src/keys/copyBag.js | 123 +++++ packages/store/src/keys/copyMap.js | 185 ------- packages/store/src/keys/copySet.js | 156 ++---- .../store/src/keys/merge-bag-operators.js | 294 ++++++++++ .../store/src/keys/merge-set-operators.js | 317 ++++++----- .../store/src/patterns/patternMatchers.js | 43 +- packages/store/src/stores/scalarMapStore.js | 3 +- packages/store/src/stores/scalarSetStore.js | 3 +- packages/store/src/types.js | 25 +- packages/store/test/test-copyMap.js | 2 +- packages/store/test/test-copySet.js | 50 ++ packages/store/test/test-patterns.js | 2 +- .../unitTests/contractSupport/test-ratio.js | 27 +- .../zoe/test/unitTests/test-testHelpers.js | 5 +- packages/zoe/test/unitTests/zcf/test-zcf.js | 2 +- packages/zoe/test/zoeTestHelpers.js | 37 +- yarn.lock | 12 + 34 files changed, 1794 insertions(+), 701 deletions(-) create mode 100644 packages/ERTP/src/mathHelpers/copyBagMathHelpers.js create mode 100644 packages/ERTP/test/unitTests/mathHelpers/test-copyBagMathHelpers.js create mode 100644 packages/ERTP/test/unitTests/test-amountProperties.js create mode 100644 packages/store/src/keys/copyBag.js delete mode 100644 packages/store/src/keys/copyMap.js create mode 100644 packages/store/src/keys/merge-bag-operators.js create mode 100644 packages/store/test/test-copySet.js diff --git a/packages/ERTP/package.json b/packages/ERTP/package.json index 12e0d1a2761..9133733f5b7 100644 --- a/packages/ERTP/package.json +++ b/packages/ERTP/package.json @@ -48,7 +48,8 @@ "devDependencies": { "@agoric/bundle-source": "^2.0.1", "@agoric/swingset-vat": "^0.24.1", - "ava": "^3.12.1" + "ava": "^3.12.1", + "fast-check": "^2.21.0" }, "files": [ "src", diff --git a/packages/ERTP/src/amountMath.js b/packages/ERTP/src/amountMath.js index ab3d6cb50ba..244467f06f2 100644 --- a/packages/ERTP/src/amountMath.js +++ b/packages/ERTP/src/amountMath.js @@ -7,18 +7,20 @@ import { M, matches } from '@agoric/store'; import { natMathHelpers } from './mathHelpers/natMathHelpers.js'; import { setMathHelpers } from './mathHelpers/setMathHelpers.js'; import { copySetMathHelpers } from './mathHelpers/copySetMathHelpers.js'; +import { copyBagMathHelpers } from './mathHelpers/copyBagMathHelpers.js'; const { details: X, quote: q } = assert; /** * Constants for the kinds of assets we support. * - * @type {{ NAT: 'nat', SET: 'set', COPY_SET: 'copySet' }} + * @type {{ NAT: 'nat', SET: 'set', COPY_SET: 'copySet', COPY_BAG: 'copyBag' }} */ const AssetKind = harden({ NAT: 'nat', SET: 'set', COPY_SET: 'copySet', + COPY_BAG: 'copyBag', }); const assetKindNames = harden(Object.values(AssetKind).sort()); @@ -72,12 +74,14 @@ harden(assertAssetKind); /** @type {{ * nat: NatMathHelpers, * set: SetMathHelpers, - * copySet: CopySetMathHelpers } - * } */ + * copySet: CopySetMathHelpers, + * copyBag: CopyBagMathHelpers + * }} */ const helpers = { nat: natMathHelpers, set: setMathHelpers, copySet: copySetMathHelpers, + copyBag: copyBagMathHelpers, }; /** @type {(value: AmountValue) => AssetKind} */ @@ -92,14 +96,16 @@ const assertValueGetAssetKind = value => { if (matches(value, M.set())) { return 'copySet'; } + if (matches(value, M.bag())) { + return 'copyBag'; + } assert.fail( // TODO This isn't quite the right error message, in case valuePassStyle // is 'tagged'. We would need to distinguish what kind of tagged // object it is. // Also, this kind of manual listing is a maintenance hazard we - // (TODO) will encounter when we extend the math helpers to - // include CopyMaps. - X`value ${value} must be a bigint, copySet, or an array, not ${passStyle}`, + // (TODO) will encounter when we extend the math helpers further. + X`value ${value} must be a bigint, copySet, copyBag, or an array, not ${passStyle}`, ); }; @@ -107,10 +113,13 @@ const assertValueGetAssetKind = value => { * * Asserts that value is a valid AmountMath and returns the appropriate helpers. * + * Made available only for testing, but it is harmless for other uses. + * * @param {AmountValue} value * @returns {MathHelpers<*>} */ -const assertValueGetHelpers = value => helpers[assertValueGetAssetKind(value)]; +export const assertValueGetHelpers = value => + helpers[assertValueGetAssetKind(value)]; /** @type {(allegedBrand: Brand, brand?: Brand) => void} */ const optionalBrandCheck = (allegedBrand, brand) => { diff --git a/packages/ERTP/src/mathHelpers/copyBagMathHelpers.js b/packages/ERTP/src/mathHelpers/copyBagMathHelpers.js new file mode 100644 index 00000000000..3536cebb653 --- /dev/null +++ b/packages/ERTP/src/mathHelpers/copyBagMathHelpers.js @@ -0,0 +1,32 @@ +// @ts-check + +import { + keyEQ, + makeCopyBag, + fit, + M, + getCopyBagEntries, + bagIsSuperbag, + bagUnion, + bagDisjointSubtract, +} from '@agoric/store'; +import '../types.js'; + +/** @type {CopyBagValue} */ +const empty = makeCopyBag([]); + +/** + * @type {CopyBagMathHelpers} + */ +export const copyBagMathHelpers = harden({ + doCoerce: bag => { + fit(bag, M.bag()); + return bag; + }, + doMakeEmpty: () => empty, + doIsEmpty: bag => getCopyBagEntries(bag).length === 0, + doIsGTE: bagIsSuperbag, + doIsEqual: keyEQ, + doAdd: bagUnion, + doSubtract: bagDisjointSubtract, +}); diff --git a/packages/ERTP/src/mathHelpers/copySetMathHelpers.js b/packages/ERTP/src/mathHelpers/copySetMathHelpers.js index 869cc07f87f..c7def72df40 100644 --- a/packages/ERTP/src/mathHelpers/copySetMathHelpers.js +++ b/packages/ERTP/src/mathHelpers/copySetMathHelpers.js @@ -1,30 +1,20 @@ // @ts-check import { - makeSetOps, keyEQ, makeCopySet, fit, M, getCopySetKeys, - makeFullOrderComparatorKit, + setIsSuperset, + setDisjointUnion, + setDisjointSubtract, } from '@agoric/store'; import '../types.js'; /** @type {CopySetValue} */ const empty = makeCopySet([]); -/** - * TODO SECURITY BUG: https://github.com/Agoric/agoric-sdk/issues/4261 - * This creates observable mutable static state, in the - * history-based ordering of remotables. - */ -const fullCompare = makeFullOrderComparatorKit(true).antiComparator; - -const { isSetSuperset, setDisjointUnion, setDisjointSubtract } = makeSetOps( - fullCompare, -); - /** * @type {CopySetMathHelpers} */ @@ -35,7 +25,7 @@ export const copySetMathHelpers = harden({ }, doMakeEmpty: () => empty, doIsEmpty: set => getCopySetKeys(set).length === 0, - doIsGTE: isSetSuperset, + doIsGTE: setIsSuperset, doIsEqual: keyEQ, doAdd: setDisjointUnion, doSubtract: setDisjointSubtract, diff --git a/packages/ERTP/src/mathHelpers/setMathHelpers.js b/packages/ERTP/src/mathHelpers/setMathHelpers.js index cfa84458803..5547d413803 100644 --- a/packages/ERTP/src/mathHelpers/setMathHelpers.js +++ b/packages/ERTP/src/mathHelpers/setMathHelpers.js @@ -1,12 +1,13 @@ // @ts-check -import { assertChecker, passStyleOf } from '@agoric/marshal'; +import { passStyleOf } from '@agoric/marshal'; import { assertKey, - makeSetOps, - sortByRank, - keyEQ, - makeFullOrderComparatorKit, + elementsIsSuperset, + elementsDisjointUnion, + elementsDisjointSubtract, + coerceToElements, + elementsCompare, } from '@agoric/store'; import '../types.js'; @@ -15,47 +16,24 @@ import '../types.js'; /** @type {SetValue} */ const empty = harden([]); -/** - * TODO SECURITY BUG: https://github.com/Agoric/agoric-sdk/issues/4261 - * This creates observable mutable static state, in the - * history-based ordering of remotables. - */ -const fullCompare = makeFullOrderComparatorKit(true).antiComparator; - -const { - checkNoDuplicates, - isListSuperset, - listDisjointUnion, - listDisjointSubtract, -} = makeSetOps(fullCompare); - -const assertNoDuplicates = list => checkNoDuplicates(list, assertChecker); - /** * @deprecated Replace array-based SetMath with CopySet-based CopySetMath * @type {SetMathHelpers} */ export const setMathHelpers = harden({ doCoerce: list => { - assert( - passStyleOf(list) === 'copyArray', - `The value of a non-fungible token must be an array but was ${list}`, - ); + list = coerceToElements(list); // Assert that list contains only // * pass-by-copy primitives, // * pass-by-copy containers containing keys, // * remotables. assertKey(list); - list = sortByRank(list, fullCompare); - // As a coercion, should we also deduplicate here? Might mask bugs - // elsewhere though, so probably not. - assertNoDuplicates(list); return list; }, doMakeEmpty: () => empty, doIsEmpty: list => passStyleOf(list) === 'copyArray' && list.length === 0, - doIsGTE: isListSuperset, - doIsEqual: keyEQ, - doAdd: listDisjointUnion, - doSubtract: listDisjointSubtract, + doIsGTE: elementsIsSuperset, + doIsEqual: (x, y) => elementsCompare(x, y) === 0, + doAdd: elementsDisjointUnion, + doSubtract: elementsDisjointSubtract, }); diff --git a/packages/ERTP/src/typeGuards.js b/packages/ERTP/src/typeGuards.js index 8c56b60a6b9..78a9a2149ca 100644 --- a/packages/ERTP/src/typeGuards.js +++ b/packages/ERTP/src/typeGuards.js @@ -12,7 +12,7 @@ const NatValueShape = M.nat(); /** * When the AmountValue of an Amount fits the CopySetValueShape, i.e., when it * is a CopySet, then it represents the set of those - * keys, where each key represents some individual non fungible + * keys, where each key represents some individual non-fungible * item, like a concert ticket, from the non-fungible asset class * represented by that amount's brand. The amount itself represents * the set of these items, as opposed to any of the other items @@ -38,7 +38,34 @@ const CopySetValueShape = M.set(); */ const SetValueShape = M.arrayOf(M.key()); -const AmountValueShape = M.or(NatValueShape, CopySetValueShape, SetValueShape); +/** + * When the AmountValue of an Amount fits the CopyBagValueShape, i.e., when it + * is a CopyBag, then it represents the bag (multiset) of those + * keys, where each key represents some individual semi-fungible + * item, like a concert ticket, from the semi-fungible asset class + * represented by that amount's brand. The number of times that key + * appears in the bag is the number of fungible units of that key. + * The amount itself represents + * the bag of these items, as opposed to any of the other items + * from the same asset class. + * + * If a given value class represents concert tickets, it seems bizarre + * that we can form amounts of any key. The hard constraint is that + * the code that holds the mint for that asset class---the one associated + * with that brand, only mints the items representing the real units + * of that asset class as defined by it. Anyone else can put together + * an amount expressing, for example, that they "want" some items that + * will never be minted. That want will never be satisfied. + * "You can't always get..." + */ +const CopyBagValueShape = M.bagOf(); + +const AmountValueShape = M.or( + NatValueShape, + CopySetValueShape, + SetValueShape, + CopyBagValueShape, +); export const AmountShape = harden({ brand: M.remotable(), @@ -74,3 +101,12 @@ harden(isCopySetValue); */ export const isSetValue = value => matches(value, SetValueShape); harden(isSetValue); + +/** + * Returns true if value is a CopyBag + * + * @param {AmountValue} value + * @returns {value is CopyBagValue} + */ +export const isCopyBagValue = value => matches(value, CopyBagValueShape); +harden(isCopyBagValue); diff --git a/packages/ERTP/src/types.js b/packages/ERTP/src/types.js index 620a0e7f550..e39dff52bb2 100644 --- a/packages/ERTP/src/types.js +++ b/packages/ERTP/src/types.js @@ -21,7 +21,7 @@ */ /** - * @typedef {NatValue | SetValue | CopySetValue} AmountValue + * @typedef {NatValue | SetValue | CopySetValue | CopyBagValue} AmountValue * An `AmountValue` describes a set or quantity of assets that can be owned or * shared. * @@ -37,7 +37,7 @@ * to represent the array of its elements. `CopySetValue` is the proper * representation using a CopySet. * - * TODO Eventually add `CopyBagValue` for semi-fungible rights represented as a + * A semi-fungible `CopyBagValue` is represented as a * `CopyBag` of `Key` objects. "Bag" is synonymous with MultiSet, where an * element of a bag can be present once or more times, i.e., some positive * bigint number of times, representing that quantity of the asset represented @@ -51,7 +51,7 @@ */ /** - * @typedef {'nat' | 'set' | 'copySet' } AssetKind + * @typedef {'nat' | 'set' | 'copySet' | 'copyBag' } AssetKind * * See doc-comment for `AmountValue`. */ @@ -556,6 +556,14 @@ * @typedef {MathHelpers} CopySetMathHelpers */ +/** + * @typedef {CopyBag} CopyBagValue + */ + +/** + * @typedef {MathHelpers} CopyBagMathHelpers + */ + /** * @callback AssertAssetKind * @param {AssetKind} allegedAK diff --git a/packages/ERTP/test/unitTests/mathHelpers/test-copyBagMathHelpers.js b/packages/ERTP/test/unitTests/mathHelpers/test-copyBagMathHelpers.js new file mode 100644 index 00000000000..a823d0005d5 --- /dev/null +++ b/packages/ERTP/test/unitTests/mathHelpers/test-copyBagMathHelpers.js @@ -0,0 +1,213 @@ +// @ts-check +import { test } from '@agoric/swingset-vat/tools/prepare-test-env-ava.js'; +import { makeTagged } from '@agoric/marshal'; +import { + getCopyBagEntries, + makeCopyBagFromElements as makeBag, +} from '@agoric/store'; + +import { AmountMath as m, AssetKind } from '../../../src/index.js'; +import { mockBrand } from './mockBrand.js'; + +// The "unit tests" for MathHelpers actually make the calls through +// AmountMath so that we can test that any duplication is handled +// correctly. + +test('copyBag with strings make', t => { + t.notThrows( + () => m.make(mockBrand, makeBag(['1'])), + `['1'] is a valid string array`, + ); + t.notThrows( + () => m.make(mockBrand, makeBag([6])), + `[6] is a valid bag even though it isn't a string`, + ); + t.throws( + // @ts-ignore deliberate invalid arguments for testing + () => m.make(mockBrand, 'abc'), + { + message: + 'value "abc" must be a bigint, copySet, copyBag, or an array, not "string"', + }, + `'abc' is not a valid string array`, + ); + t.deepEqual(m.make(mockBrand, makeBag(['a', 'a'])), { + brand: mockBrand, + value: makeTagged('copyBag', [['a', 2n]]), + }); +}); + +test('copyBag with strings coerce', t => { + t.deepEqual( + m.coerce(mockBrand, harden({ brand: mockBrand, value: makeBag(['1']) })), + { brand: mockBrand, value: makeBag(['1']) }, + `coerce({ brand, value: ['1']}) is a valid amount`, + ); + t.notThrows( + () => + m.coerce(mockBrand, harden({ brand: mockBrand, value: makeBag([6]) })), + `[6] is a valid bag`, + ); + t.throws( + // @ts-ignore deliberate invalid arguments for testing + () => m.coerce(mockBrand, harden({ brand: mockBrand, value: '6' })), + { + message: + 'value "6" must be a bigint, copySet, copyBag, or an array, not "string"', + }, + `'6' is not a valid array`, + ); +}); + +test('copyBag with strings getValue', t => { + t.deepEqual( + m.getValue(mockBrand, harden({ brand: mockBrand, value: makeBag(['1']) })), + makeBag(['1']), + ); + t.deepEqual( + getCopyBagEntries( + /** @type {CopyBag} */ (m.getValue( + mockBrand, + harden({ brand: mockBrand, value: makeBag(['1']) }), + )), + ), + [['1', 1n]], + ); + t.deepEqual( + m.getValue(mockBrand, m.make(mockBrand, makeBag(['1']))), + makeBag(['1']), + ); +}); + +test('copyBag with strings makeEmpty', t => { + t.deepEqual( + m.makeEmpty(mockBrand, AssetKind.COPY_BAG), + harden({ brand: mockBrand, value: makeBag([]) }), + `empty is []`, + ); + + t.assert( + m.isEmpty(harden({ brand: mockBrand, value: makeBag([]) })), + `isEmpty([])) is true`, + ); + t.falsy( + m.isEmpty(harden({ brand: mockBrand, value: makeBag(['abc']) })), + `isEmpty(['abc']) is false`, + ); +}); + +test('copyBag with strings isGTE', t => { + t.assert( + m.isGTE( + harden({ brand: mockBrand, value: makeBag(['a']) }), + harden({ brand: mockBrand, value: makeBag(['a']) }), + ), + `overlap between left and right of isGTE should not throw`, + ); + t.assert( + m.isGTE( + harden({ brand: mockBrand, value: makeBag(['a', 'b']) }), + harden({ brand: mockBrand, value: makeBag(['a']) }), + ), + `['a', 'b'] is gte to ['a']`, + ); + t.falsy( + m.isGTE( + harden({ brand: mockBrand, value: makeBag(['a']) }), + harden({ brand: mockBrand, value: makeBag(['b']) }), + ), + `['a'] is not gte to ['b']`, + ); +}); + +test('copyBag with strings isEqual', t => { + t.assert( + m.isEqual( + harden({ brand: mockBrand, value: makeBag(['a']) }), + harden({ brand: mockBrand, value: makeBag(['a']) }), + ), + `overlap between left and right of isEqual is ok`, + ); + t.assert( + m.isEqual( + harden({ brand: mockBrand, value: makeBag(['a', 'b']) }), + harden({ brand: mockBrand, value: makeBag(['b', 'a']) }), + ), + `['a', 'b'] equals ['b', 'a']`, + ); + t.falsy( + m.isEqual( + harden({ brand: mockBrand, value: makeBag(['a']) }), + harden({ brand: mockBrand, value: makeBag(['b']) }), + ), + `['a'] does not equal ['b']`, + ); +}); + +test('copyBag with strings add', t => { + t.deepEqual( + m.add( + harden({ brand: mockBrand, value: makeBag(['a', 'a']) }), + harden({ brand: mockBrand, value: makeBag(['b']) }), + ), + { + brand: mockBrand, + value: makeTagged('copyBag', [ + ['b', 1n], + ['a', 2n], + ]), + }, + ); + t.deepEqual( + m.add( + harden({ brand: mockBrand, value: makeBag(['a']) }), + harden({ brand: mockBrand, value: makeBag(['b']) }), + ), + { brand: mockBrand, value: makeBag(['b', 'a']) }, + `['a'] + ['b'] = ['a', 'b']`, + ); +}); + +test('copyBag with strings subtract', t => { + t.throws( + () => + m.subtract( + harden({ brand: mockBrand, value: makeBag(['a', 'a']) }), + harden({ brand: mockBrand, value: makeBag(['b']) }), + ), + { message: /right element "b" was not in left/ }, + ); + t.throws( + () => + m.subtract( + harden({ brand: mockBrand, value: makeBag(['a']) }), + harden({ brand: mockBrand, value: makeBag(['b', 'b']) }), + ), + { message: /right element "b" was not in left/ }, + ); + t.deepEqual( + m.subtract( + harden({ brand: mockBrand, value: makeBag(['a']) }), + harden({ brand: mockBrand, value: makeBag(['a']) }), + ), + { brand: mockBrand, value: makeBag([]) }, + `overlap between left and right of subtract should not throw`, + ); + t.throws( + () => + m.subtract( + harden({ brand: mockBrand, value: makeBag(['a', 'b']) }), + harden({ brand: mockBrand, value: makeBag(['c']) }), + ), + { message: /right element "c" was not in left/ }, + `elements in right but not in left of subtract should throw`, + ); + t.deepEqual( + m.subtract( + harden({ brand: mockBrand, value: makeBag(['a', 'b']) }), + harden({ brand: mockBrand, value: makeBag(['a']) }), + ), + { brand: mockBrand, value: makeBag(['b']) }, + `['a', 'b'] - ['a'] = ['a']`, + ); +}); diff --git a/packages/ERTP/test/unitTests/mathHelpers/test-copySetMathHelpers.js b/packages/ERTP/test/unitTests/mathHelpers/test-copySetMathHelpers.js index 501a04366e6..50d84c147b9 100644 --- a/packages/ERTP/test/unitTests/mathHelpers/test-copySetMathHelpers.js +++ b/packages/ERTP/test/unitTests/mathHelpers/test-copySetMathHelpers.js @@ -23,7 +23,7 @@ test('copySet with strings make', t => { () => m.make(mockBrand, 'abc'), { message: - 'value "abc" must be a bigint, copySet, or an array, not "string"', + 'value "abc" must be a bigint, copySet, copyBag, or an array, not "string"', }, `'abc' is not a valid string array`, ); @@ -55,7 +55,8 @@ test('copySet with strings coerce', t => { // @ts-ignore deliberate invalid arguments for testing () => m.coerce(mockBrand, harden({ brand: mockBrand, value: '6' })), { - message: 'value "6" must be a bigint, copySet, or an array, not "string"', + message: + 'value "6" must be a bigint, copySet, copyBag, or an array, not "string"', }, `'6' is not a valid array`, ); diff --git a/packages/ERTP/test/unitTests/mathHelpers/test-natMathHelpers.js b/packages/ERTP/test/unitTests/mathHelpers/test-natMathHelpers.js index 897d915a44f..71842b7ec22 100644 --- a/packages/ERTP/test/unitTests/mathHelpers/test-natMathHelpers.js +++ b/packages/ERTP/test/unitTests/mathHelpers/test-natMathHelpers.js @@ -14,14 +14,15 @@ test('natMathHelpers make', t => { t.deepEqual(m.make(mockBrand, 4n), { brand: mockBrand, value: 4n }); // @ts-ignore deliberate invalid arguments for testing t.throws(() => m.make(mockBrand, 4), { - message: 'value 4 must be a bigint, copySet, or an array, not "number"', + message: + 'value 4 must be a bigint, copySet, copyBag, or an array, not "number"', }); t.throws( // @ts-ignore deliberate invalid arguments for testing () => m.make(mockBrand, 'abc'), { message: - 'value "abc" must be a bigint, copySet, or an array, not "string"', + 'value "abc" must be a bigint, copySet, copyBag, or an array, not "string"', }, `'abc' is not a nat`, ); @@ -29,7 +30,8 @@ test('natMathHelpers make', t => { // @ts-ignore deliberate invalid arguments for testing () => m.make(mockBrand, -1), { - message: 'value -1 must be a bigint, copySet, or an array, not "number"', + message: + 'value -1 must be a bigint, copySet, copyBag, or an array, not "number"', }, `- 1 is not a valid Nat`, ); @@ -98,7 +100,8 @@ test('natMathHelpers getValue', t => { t.is(m.getValue(mockBrand, m.make(mockBrand, 4n)), 4n); // @ts-ignore deliberate invalid arguments for testing t.throws(() => m.getValue(mockBrand, m.make(mockBrand, 4)), { - message: 'value 4 must be a bigint, copySet, or an array, not "number"', + message: + 'value 4 must be a bigint, copySet, copyBag, or an array, not "number"', }); }); @@ -154,7 +157,7 @@ test('natMathHelpers isEmpty', t => { () => m.isEmpty(harden({ brand: mockBrand, value: 'abc' })), { message: - 'value "abc" must be a bigint, copySet, or an array, not "string"', + 'value "abc" must be a bigint, copySet, copyBag, or an array, not "string"', }, `isEmpty('abc') throws because it cannot be coerced`, ); diff --git a/packages/ERTP/test/unitTests/mathHelpers/test-setMathHelpers.js b/packages/ERTP/test/unitTests/mathHelpers/test-setMathHelpers.js index 3e709e1c5e5..4570ccbcd12 100644 --- a/packages/ERTP/test/unitTests/mathHelpers/test-setMathHelpers.js +++ b/packages/ERTP/test/unitTests/mathHelpers/test-setMathHelpers.js @@ -22,9 +22,14 @@ const runSetMathHelpersTests = (t, [a, b, c], a2 = undefined) => { { brand: mockBrand, value: harden([a]) }, `[a] is a valid set`, ); - t.deepEqual( - m.make(mockBrand, harden([a, b])), - { brand: mockBrand, value: harden([b, a]) }, + t.assert( + m.isEqual( + m.make(mockBrand, harden([a, b])), + harden({ + brand: mockBrand, + value: harden([b, a]), + }), + ), `[b, a] is a valid set`, ); t.deepEqual( @@ -37,16 +42,19 @@ const runSetMathHelpersTests = (t, [a, b, c], a2 = undefined) => { { message: /value has duplicates/ }, `duplicates in make should throw`, ); - t.deepEqual( - m.make(mockBrand, harden(['a', 'b'])), - { brand: mockBrand, value: harden(['b', 'a']) }, + t.assert( + m.isEqual( + m.make(mockBrand, harden(['a', 'b'])), + harden({ brand: mockBrand, value: harden(['b', 'a']) }), + ), 'any key is a valid element', ); t.throws( // @ts-ignore deliberate invalid arguments for testing () => m.make(mockBrand, 'a'), { - message: 'value "a" must be a bigint, copySet, or an array, not "string"', + message: + 'value "a" must be a bigint, copySet, copyBag, or an array, not "string"', }, 'strings are not valid', ); @@ -64,9 +72,11 @@ const runSetMathHelpersTests = (t, [a, b, c], a2 = undefined) => { { brand: mockBrand, value: harden([a]) }, `[a] is a valid set`, ); - t.deepEqual( - m.coerce(mockBrand, harden({ brand: mockBrand, value: harden([a, b]) })), - { brand: mockBrand, value: harden([b, a]) }, + t.assert( + m.isEqual( + m.coerce(mockBrand, harden({ brand: mockBrand, value: harden([a, b]) })), + harden({ brand: mockBrand, value: harden([b, a]) }), + ), `[a, b] is a valid set`, ); t.deepEqual( @@ -79,16 +89,19 @@ const runSetMathHelpersTests = (t, [a, b, c], a2 = undefined) => { { message: /value has duplicates/ }, `duplicates in coerce should throw`, ); - t.deepEqual( - m.coerce(mockBrand, m.make(mockBrand, harden(['a', 'b']))), - { brand: mockBrand, value: harden(['b', 'a']) }, + t.assert( + m.isEqual( + m.coerce(mockBrand, m.make(mockBrand, harden(['a', 'b']))), + harden({ brand: mockBrand, value: harden(['b', 'a']) }), + ), 'any key is a valid element', ); t.throws( // @ts-ignore deliberate invalid arguments for testing () => m.coerce(mockBrand, harden({ brand: mockBrand, value: 'a' })), { - message: 'value "a" must be a bigint, copySet, or an array, not "string"', + message: + 'value "a" must be a bigint, copySet, copyBag, or an array, not "string"', }, 'strings are not valid', ); @@ -124,7 +137,7 @@ const runSetMathHelpersTests = (t, [a, b, c], a2 = undefined) => { () => m.isEmpty(harden({ brand: mockBrand, value: {} })), { message: - 'value {} must be a bigint, copySet, or an array, not "copyRecord"', + 'value {} must be a bigint, copySet, copyBag, or an array, not "copyRecord"', }, `m.isEmpty({}) throws`, ); @@ -284,20 +297,24 @@ const runSetMathHelpersTests = (t, [a, b, c], a2 = undefined) => { { message: /Sets must not have common elements: .*/ }, `overlap between left and right of add should throw`, ); - t.deepEqual( - m.add( - harden({ brand: mockBrand, value: [] }), - harden({ brand: mockBrand, value: [b, c] }), + t.assert( + m.isEqual( + m.add( + harden({ brand: mockBrand, value: [] }), + harden({ brand: mockBrand, value: [b, c] }), + ), + harden({ brand: mockBrand, value: [c, b] }), ), - { brand: mockBrand, value: [c, b] }, `anything + identity stays same`, ); - t.deepEqual( - m.add( - harden({ brand: mockBrand, value: [b, c] }), - harden({ brand: mockBrand, value: [] }), + t.assert( + m.isEqual( + m.add( + harden({ brand: mockBrand, value: [b, c] }), + harden({ brand: mockBrand, value: [] }), + ), + harden({ brand: mockBrand, value: [c, b] }), ), - { brand: mockBrand, value: [c, b] }, `anything + identity stays same`, ); if (a2 !== undefined) { @@ -348,12 +365,14 @@ const runSetMathHelpersTests = (t, [a, b, c], a2 = undefined) => { { message: /right element .* was not in left/ }, `elements in right but not in left of subtract should throw`, ); - t.deepEqual( - m.subtract( - harden({ brand: mockBrand, value: [b, c] }), - harden({ brand: mockBrand, value: [] }), + t.assert( + m.isEqual( + m.subtract( + harden({ brand: mockBrand, value: [b, c] }), + harden({ brand: mockBrand, value: [] }), + ), + harden({ brand: mockBrand, value: [c, b] }), ), - { brand: mockBrand, value: [c, b] }, `anything - identity stays same`, ); t.deepEqual( @@ -381,9 +400,9 @@ const runSetMathHelpersTests = (t, [a, b, c], a2 = undefined) => { }; test('setMathHelpers with handles', t => { - const a = Far('iface', {}); - const b = Far('iface', {}); - const c = Far('iface', {}); + const a = Far('iface a', {}); + const b = Far('iface b', {}); + const c = Far('iface c', {}); runSetMathHelpersTests(t, [a, b, c]); }); diff --git a/packages/ERTP/test/unitTests/mathHelpers/test-strSetMathHelpers.js b/packages/ERTP/test/unitTests/mathHelpers/test-strSetMathHelpers.js index 13726bf1654..b70e31eb3fb 100644 --- a/packages/ERTP/test/unitTests/mathHelpers/test-strSetMathHelpers.js +++ b/packages/ERTP/test/unitTests/mathHelpers/test-strSetMathHelpers.js @@ -23,7 +23,7 @@ test('set with strings make', t => { () => m.make(mockBrand, 'abc'), { message: - 'value "abc" must be a bigint, copySet, or an array, not "string"', + 'value "abc" must be a bigint, copySet, copyBag, or an array, not "string"', }, `'abc' is not a valid string array`, ); @@ -48,7 +48,8 @@ test('set with strings coerce', t => { // @ts-ignore deliberate invalid arguments for testing () => m.coerce(mockBrand, harden({ brand: mockBrand, value: '6' })), { - message: 'value "6" must be a bigint, copySet, or an array, not "string"', + message: + 'value "6" must be a bigint, copySet, copyBag, or an array, not "string"', }, `'6' is not a valid array`, ); diff --git a/packages/ERTP/test/unitTests/test-amountProperties.js b/packages/ERTP/test/unitTests/test-amountProperties.js new file mode 100644 index 00000000000..9428258b9c3 --- /dev/null +++ b/packages/ERTP/test/unitTests/test-amountProperties.js @@ -0,0 +1,86 @@ +import { test } from '@agoric/swingset-vat/tools/prepare-test-env-ava.js'; +import { makeCopyBag } from '@agoric/store'; +import fc from 'fast-check'; + +import { AmountMath as m, AssetKind } from '../../src/index.js'; +import { mockBrand } from './mathHelpers/mockBrand.js'; + +// Perhaps makeCopyBag should coalesce duplicate labels, but for now, it does +// not. +const distinctLabels = pairs => + new Set(pairs.map(([label, _qty]) => label)).size === pairs.length; +const positiveCounts = pairs => + pairs.filter(([_l, qty]) => qty > 0n).length === pairs.length; +const arbBagContents = fc + .nat(7) + .chain(size => + fc.array( + fc.tuple(fc.string(), fc.bigUint({ max: 1_000_000_000_000_000n })), + { minLength: size, maxLength: size }, + ), + ) + .filter(pairs => distinctLabels(pairs) && positiveCounts(pairs)); + +const arbAmount = arbBagContents.map(contents => + m.make(mockBrand, harden(makeCopyBag(contents))), +); + +// Note: we write P => Q as !P || Q since JS has no logical => operator +const implies = (p, q) => !p || q; + +test('isEqual is a (total) equivalence relation', t => { + fc.assert( + fc.property( + fc.record({ x: arbAmount, y: arbAmount, z: arbAmount }), + ({ x, y, z }) => { + t.true([true, false].includes(m.isEqual(x, y))); // Total + t.true(m.isEqual(x, x)); // Reflexive + t.true(implies(m.isEqual(x, y), m.isEqual(y, x))); // Symmetric + // Transitive + t.true(implies(m.isEqual(x, y) && m.isEqual(y, z), m.isEqual(x, z))); + }, + ), + ); +}); + +test('isGTE is a partial order with empty as minimum', t => { + const empty = m.makeEmpty(mockBrand, AssetKind.COPY_BAG); + fc.assert( + fc.property(fc.record({ x: arbAmount, y: arbAmount }), ({ x, y }) => { + t.true(m.isGTE(x, empty)); + t.true([true, false].includes(m.isGTE(x, y))); // Total + t.true(m.isGTE(x, x)); // Reflexive + // Antisymmetric + t.true(implies(m.isGTE(x, y) && m.isGTE(y, x), m.isEqual(x, y))); + }), + ); +}); + +test('add: closed, commutative, associative, monotonic, with empty identity', t => { + const empty = m.makeEmpty(mockBrand, AssetKind.COPY_BAG); + fc.assert( + fc.property( + fc.record({ x: arbAmount, y: arbAmount, z: arbAmount }), + ({ x, y, z }) => { + // note: + for SET is not total. + t.truthy(m.coerce(mockBrand, m.add(x, y))); + t.true(m.isEqual(m.add(x, empty), x)); // Identity (right) + t.true(m.isEqual(m.add(empty, x), x)); // Identity (left) + t.true(m.isEqual(m.add(x, y), m.add(y, x))); // Commutative + // Associative + t.true(m.isEqual(m.add(m.add(x, y), z), m.add(x, m.add(y, z)))); + t.true(m.isGTE(m.add(x, y), x)); // Monotonic (left) + t.true(m.isGTE(m.add(x, y), y)); // Monotonic (right) + }, + ), + ); +}); + +test('subtract: (x + y) - y = x; (y - x) + x = y if y >= x', t => { + fc.assert( + fc.property(fc.record({ x: arbAmount, y: arbAmount }), ({ x, y }) => { + t.true(m.isEqual(m.subtract(m.add(x, y), y), x)); + t.true(m.isGTE(y, x) ? m.isEqual(m.add(m.subtract(y, x), x), y) : true); + }), + ); +}); diff --git a/packages/ERTP/test/unitTests/test-inputValidation.js b/packages/ERTP/test/unitTests/test-inputValidation.js index ec4bcabd8bc..7d749b897be 100644 --- a/packages/ERTP/test/unitTests/test-inputValidation.js +++ b/packages/ERTP/test/unitTests/test-inputValidation.js @@ -14,7 +14,7 @@ test('makeIssuerKit bad allegedName', async t => { test('makeIssuerKit bad assetKind', async t => { // @ts-ignore Intentional wrong type for testing t.throws(() => makeIssuerKit('myTokens', 'somethingWrong'), { - message: /The assetKind "somethingWrong" must be one of \["copySet","nat","set"\]/, + message: /The assetKind "somethingWrong" must be one of \["copyBag","copySet","nat","set"\]/, }); }); diff --git a/packages/store/src/index.js b/packages/store/src/index.js index 78735e44421..e6b2f28d47d 100644 --- a/packages/store/src/index.js +++ b/packages/store/src/index.js @@ -1,6 +1,18 @@ // @ts-check -export { isKey, assertKey } from './keys/checkKey.js'; +export { + isKey, + assertKey, + makeCopySet, + getCopySetKeys, + makeCopyBag, + makeCopyBagFromElements, + getCopyBagEntries, + makeCopyMap, + getCopyMapEntries, +} from './keys/checkKey.js'; +export { coerceToElements } from './keys/copySet.js'; +export { coerceToBagEntries } from './keys/copyBag.js'; export { compareKeys, keyLT, @@ -9,7 +21,30 @@ export { keyGTE, keyGT, } from './keys/compareKeys.js'; -export { makeSetOps } from './keys/merge-set-operators.js'; +export { + elementsIsSuperset, + elementsIsDisjoint, + elementsCompare, + elementsUnion, + elementsDisjointUnion, + elementsIntersection, + elementsDisjointSubtract, + setIsSuperset, + setIsDisjoint, + setCompare, + setUnion, + setDisjointUnion, + setIntersection, + setDisjointSubtract, +} from './keys/merge-set-operators.js'; + +export { + bagIsSuperbag, + bagCompare, + bagUnion, + bagIntersection, + bagDisjointSubtract, +} from './keys/merge-bag-operators.js'; export { M, @@ -18,12 +53,7 @@ export { matches, fit, } from './patterns/patternMatchers.js'; -export { - compareRank, - isRankSorted, - sortByRank, - makeFullOrderComparatorKit, -} from './patterns/rankOrder.js'; +export { compareRank, isRankSorted, sortByRank } from './patterns/rankOrder.js'; export { makeScalarWeakSetStore } from './stores/scalarWeakSetStore.js'; export { makeScalarSetStore } from './stores/scalarSetStore.js'; @@ -44,5 +74,3 @@ export { // during the transition. export { makeLegacyMap, makeLegacyMap as default } from './legacy/legacyMap.js'; export { makeLegacyWeakMap } from './legacy/legacyWeakMap.js'; - -export { makeCopySet, getCopySetKeys } from './keys/copySet.js'; diff --git a/packages/store/src/keys/checkKey.js b/packages/store/src/keys/checkKey.js index b95c117b6ff..697f245c53a 100644 --- a/packages/store/src/keys/checkKey.js +++ b/packages/store/src/keys/checkKey.js @@ -5,14 +5,22 @@ import { assertChecker, assertPassable, + Far, getTag, isObject, + makeTagged, passStyleOf, } from '@agoric/marshal'; -import { checkCopySet, everyCopySetKey } from './copySet.js'; -import { checkCopyMap, everyCopyMapKey, everyCopyMapValue } from './copyMap.js'; +import { checkElements, makeSetOfElements } from './copySet.js'; +import { checkBagEntries, makeBagOfEntries } from './copyBag.js'; +import { + compareAntiRank, + makeFullOrderComparatorKit, + sortByRank, +} from '../patterns/rankOrder.js'; const { details: X, quote: q } = assert; +const { ownKeys } = Reflect; // ////////////////// Primitive and Scalar keys //////////////////////////////// @@ -79,15 +87,438 @@ export const assertScalarKey = val => { }; harden(assertScalarKey); -// ///////////////////////////// Keys ////////////////////////////////////////// +// ////////////////////////////// Keys ///////////////////////////////////////// + +/** @type {WeakSet} */ +const keyMemo = new WeakSet(); /** * @param {Passable} val * @param {Checker=} check * @returns {boolean} */ -const checkKeyInternal = (val, check = x => x) => { +export const checkKey = (val, check = x => x) => { + if (isPrimitiveKey(val)) { + return true; + } + if (!isObject(val)) { + // TODO There is not yet a checkPassable, but perhaps there should be. + // If that happens, we should call it here instead. + assertPassable(val); + return true; + } + if (keyMemo.has(val)) { + return true; + } // eslint-disable-next-line no-use-before-define + const result = checkKeyInternal(val, check); + if (result) { + // Don't cache the undefined cases, so that if it is tried again + // with `assertChecker` it'll throw a diagnostic again + keyMemo.add(val); + } + // Note that we do not memoize a negative judgement, so that if it is tried + // again with a checker, it will still produce a useful diagnostic. + return result; +}; +harden(checkKey); + +/** + * @param {Passable} val + * @returns {boolean} + */ +export const isKey = val => checkKey(val); +harden(isKey); + +/** + * @param {Key} val + */ +export const assertKey = val => { + checkKey(val, assertChecker); +}; +harden(assertKey); + +// //////////////////////////// CopySet //////////////////////////////////////// + +// Moved to here so they can check that the copySet contains only keys +// without creating an import cycle. + +/** @type WeakSet> */ +const copySetMemo = new WeakSet(); + +/** + * @param {Passable} s + * @param {Checker=} check + * @returns {boolean} + */ +export const checkCopySet = (s, check = x => x) => { + if (copySetMemo.has(s)) { + return true; + } + const result = + check( + passStyleOf(s) === 'tagged' && getTag(s) === 'copySet', + X`Not a copySet: ${s}`, + ) && + checkElements(s.payload, check) && + checkKey(s.payload); + if (result) { + copySetMemo.add(s); + } + return result; +}; +harden(checkCopySet); + +/** + * @callback IsCopySet + * @param {Passable} s + * @returns {s is CopySet} + */ + +/** @type {IsCopySet} */ +export const isCopySet = s => checkCopySet(s); +harden(isCopySet); + +/** + * @callback AssertCopySet + * @param {Passable} s + * @returns {asserts s is CopySet} + */ + +/** @type {AssertCopySet} */ +export const assertCopySet = s => { + checkCopySet(s, assertChecker); +}; +harden(assertCopySet); + +/** + * @template K + * @param {CopySet} s + * @returns {K[]} + */ +export const getCopySetKeys = s => { + assertCopySet(s); + return s.payload; +}; +harden(getCopySetKeys); + +/** + * @template K + * @param {CopySet} s + * @param {(key: K, index: number) => boolean} fn + * @returns {boolean} + */ +export const everyCopySetKey = (s, fn) => + getCopySetKeys(s).every((key, index) => fn(key, index)); +harden(everyCopySetKey); + +/** + * @template K + * @param {Iterable} elementIter + * @returns {CopySet} + */ +export const makeCopySet = elementIter => { + const result = makeSetOfElements(elementIter); + assertCopySet(result); + return result; +}; +harden(makeCopySet); + +// //////////////////////////// CopyBag //////////////////////////////////////// + +// Moved to here so they can check that the copyBag contains only keys +// without creating an import cycle. + +/** @type WeakSet> */ +const copyBagMemo = new WeakSet(); + +/** + * @param {Passable} b + * @param {Checker=} check + * @returns {boolean} + */ +export const checkCopyBag = (b, check = x => x) => { + if (copyBagMemo.has(b)) { + return true; + } + const result = + check( + passStyleOf(b) === 'tagged' && getTag(b) === 'copyBag', + X`Not a copyBag: ${b}`, + ) && + checkBagEntries(b.payload, check) && + checkKey(b.payload); + if (result) { + copyBagMemo.add(b); + } + return result; +}; +harden(checkCopyBag); + +/** + * @callback IsCopyBag + * @param {Passable} b + * @returns {b is CopyBag} + */ + +/** @type {IsCopyBag} */ +export const isCopyBag = b => checkCopyBag(b); +harden(isCopyBag); + +/** + * @callback AssertCopyBag + * @param {Passable} b + * @returns {asserts b is CopyBag} + */ + +/** @type {AssertCopyBag} */ +export const assertCopyBag = b => { + checkCopyBag(b, assertChecker); +}; +harden(assertCopyBag); + +/** + * @template K + * @param {CopyBag} b + * @returns {K[]} + */ +export const getCopyBagEntries = b => { + assertCopyBag(b); + return b.payload; +}; +harden(getCopyBagEntries); + +/** + * @template K + * @param {CopyBag} b + * @param {(entry: [K, bigint], index: number) => boolean} fn + * @returns {boolean} + */ +export const everyCopyBagEntry = (b, fn) => + getCopyBagEntries(b).every((entry, index) => fn(entry, index)); +harden(everyCopyBagEntry); + +/** + * @template K + * @param {Iterable<[K,bigint]>} bagEntryIter + * @returns {CopyBag} + */ +export const makeCopyBag = bagEntryIter => { + const result = makeBagOfEntries(bagEntryIter); + assertCopyBag(result); + return result; +}; +harden(makeCopyBag); + +/** + * @template K + * @param {Iterable} elementIter + * @returns {CopySet} + */ +export const makeCopyBagFromElements = elementIter => { + // This fullOrder contains history dependent state. It is specific + // to this one call and does not survive it. + const fullCompare = makeFullOrderComparatorKit().antiComparator; + const sorted = sortByRank(elementIter, fullCompare); + /** @type {[K,bigint][]} */ + const entries = []; + for (let i = 0; i < sorted.length; ) { + const k = sorted[i]; + let j = i + 1; + while (j < sorted.length && fullCompare(k, sorted[j]) === 0) { + j += 1; + } + entries.push([k, BigInt(j - i)]); + i = j; + } + return makeCopyBag(entries); +}; +harden(makeCopyBagFromElements); + +// //////////////////////////// CopyMap //////////////////////////////////////// + +// Moved to here so they can check that the copyMap's keys contains only keys +// without creating an import cycle. + +/** @type WeakSet> */ +const copyMapMemo = new WeakSet(); + +/** + * @param {Passable} m + * @param {Checker=} check + * @returns {boolean} + */ +export const checkCopyMap = (m, check = x => x) => { + if (copyMapMemo.has(m)) { + return true; + } + if (!(passStyleOf(m) === 'tagged' && getTag(m) === 'copyMap')) { + return check(false, X`Not a copyMap: ${m}`); + } + const { payload } = m; + if (passStyleOf(payload) !== 'copyRecord') { + return check(false, X`A copyMap's payload must be a record: ${m}`); + } + const { keys, values, ...rest } = payload; + const result = + check( + ownKeys(rest).length === 0, + X`A copyMap's payload must only have .keys and .values: ${m}`, + ) && + checkElements(keys, check) && + check( + passStyleOf(values) === 'copyArray', + X`A copyMap's .values must be a copyArray: ${m}`, + ) && + check( + keys.length === values.length, + X`A copyMap must have the same number of keys and values: ${m}`, + ); + if (result) { + copyMapMemo.add(m); + } + return result; +}; +harden(checkCopyMap); + +/** + * @callback IsCopyMap + * @param {Passable} m + * @returns {m is CopyMap} + */ + +/** @type {IsCopyMap} */ +export const isCopyMap = m => checkCopyMap(m); +harden(isCopyMap); + +/** + * @callback AssertCopyMap + * @param {Passable} m + * @returns {asserts m is CopyMap} + */ + +/** @type {AssertCopyMap} */ +export const assertCopyMap = m => { + checkCopyMap(m, assertChecker); +}; +harden(assertCopyMap); + +/** + * @template K,V + * @param {CopyMap} m + * @returns {K[]} + */ +export const getCopyMapKeys = m => { + assertCopyMap(m); + return m.payload.keys; +}; +harden(getCopyMapKeys); + +/** + * @template K,V + * @param {CopyMap} m + * @returns {V[]} + */ +export const getCopyMapValues = m => { + assertCopyMap(m); + return m.payload.values; +}; +harden(getCopyMapValues); + +/** + * @template K,V + * @param {CopyMap} m + * @returns {Iterable<[K,V]>} + */ +export const getCopyMapEntries = m => { + assertCopyMap(m); + const { + payload: { keys, values }, + } = m; + const { length } = keys; + return Far('CopyMap entries iterable', { + [Symbol.iterator]: () => { + let i = 0; + return Far('CopyMap entries iterator', { + next: () => { + /** @type {IteratorResult<[K,V],void>} */ + let result; + if (i < length) { + result = harden({ done: false, value: [keys[i], values[i]] }); + i += 1; + return result; + } else { + result = harden({ done: true, value: undefined }); + } + return result; + }, + }); + }, + }); +}; +harden(getCopyMapEntries); + +/** + * @template K,V + * @param {CopyMap} m + * @param {(key: K, index: number) => boolean} fn + * @returns {boolean} + */ +export const everyCopyMapKey = (m, fn) => + getCopyMapKeys(m).every((key, index) => fn(key, index)); +harden(everyCopyMapKey); + +/** + * @template K,V + * @param {CopyMap} m + * @param {(value: V, index: number) => boolean} fn + * @returns {boolean} + */ +export const everyCopyMapValue = (m, fn) => + getCopyMapValues(m).every((value, index) => fn(value, index)); +harden(everyCopyMapValue); + +/** + * @template K,V + * @param {CopyMap} m + * @returns {CopySet} + */ +export const copyMapKeySet = m => + // A copyMap's keys are already in the internal form used by copySets. + makeTagged('copySet', m.payload.keys); +harden(copyMapKeySet); + +/** + * @template K,V + * @param {Iterable<[K, V]>} entries + * @returns {CopyMap} + */ +export const makeCopyMap = entries => { + // This is weird, but reverse rank sorting the entries is a good first step + // for getting the rank sorted keys together with the values + // organized by those keys. Also, among values associated with + // keys in the same equivalence class, those are rank sorted. + // TODO This + // could solve the copyMap cover issue explained in patternMatchers.js. + // But only if we include this criteria in our validation of copyMaps, + // which we currently do not. + const sortedEntries = sortByRank(entries, compareAntiRank); + const keys = sortedEntries.map(([k, _v]) => k); + const values = sortedEntries.map(([_k, v]) => v); + const result = makeTagged('copyMap', { keys, values }); + assertCopyMap(result); + return result; +}; +harden(makeCopyMap); + +// //////////////////////// Keys Recur ///////////////////////////////////////// + +/** + * @param {Passable} val + * @param {Checker=} check + * @returns {boolean} + */ +const checkKeyInternal = (val, check = x => x) => { const checkIt = child => checkKey(child, check); const passStyle = passStyleOf(val); @@ -104,18 +535,17 @@ const checkKeyInternal = (val, check = x => x) => { const tag = getTag(val); switch (tag) { case 'copySet': { - return ( - checkCopySet(val, check) && - // For a copySet to be a key, all its elements must be keys - everyCopySetKey(val, checkIt) - ); + return checkCopySet(val, check); + } + case 'copyBag': { + return checkCopyBag(val, check); } case 'copyMap': { return ( checkCopyMap(val, check) && // For a copyMap to be a key, all its keys and values must - // be keys. - everyCopyMapKey(val, checkIt) && + // be keys. Keys already checked by `checkCopyMap` since + // that's a copyMap requirement in general. everyCopyMapValue(val, checkIt) ); } @@ -142,51 +572,3 @@ const checkKeyInternal = (val, check = x => x) => { } } }; - -/** @type {WeakSet} */ -const keyMemo = new WeakSet(); - -/** - * @param {Passable} val - * @param {Checker=} check - * @returns {boolean} - */ -export const checkKey = (val, check = x => x) => { - if (isPrimitiveKey(val)) { - return true; - } - if (!isObject(val)) { - // TODO There is not yet a checkPassable, but perhaps there should be. - // If that happens, we should call it here instead. - assertPassable(val); - return true; - } - if (keyMemo.has(val)) { - return true; - } - const result = checkKeyInternal(val, check); - if (result) { - // Don't cache the undefined cases, so that if it is tried again - // with `assertChecker` it'll throw a diagnostic again - keyMemo.add(val); - } - // Note that we do not memoize a negative judgement, so that if it is tried - // again with a checker, it will still produce a useful diagnostic. - return result; -}; -harden(checkKey); - -/** - * @param {Passable} val - * @returns {boolean} - */ -export const isKey = val => checkKey(val); -harden(isKey); - -/** - * @param {Key} val - */ -export const assertKey = val => { - checkKey(val, assertChecker); -}; -harden(assertKey); diff --git a/packages/store/src/keys/compareKeys.js b/packages/store/src/keys/compareKeys.js index f4e96f069f3..cd8a6f1bb92 100644 --- a/packages/store/src/keys/compareKeys.js +++ b/packages/store/src/keys/compareKeys.js @@ -5,6 +5,8 @@ import { passStyleOf, getTag } from '@agoric/marshal'; import { compareRank } from '../patterns/rankOrder.js'; import { assertKey } from './checkKey.js'; +import { bagCompare } from './merge-bag-operators.js'; +import { setCompare } from './merge-set-operators.js'; const { details: X, quote: q } = assert; const { ownKeys } = Reflect; @@ -118,72 +120,16 @@ export const compareKeys = (left, right) => { switch (leftTag) { case 'copySet': { // copySet X is smaller than copySet Y when every element of X - // is keyEQ to some element of Y and Y has at least one element - // that no element of X is keyEQ to. - // The following algorithm is good when there are few elements tied - // for the same rank. But it is O(N*M) in the size of these ties. - // Sets of remotables are a particularly bad case. For these, some - // kind of hash table (scalar set or map) should be used instead. - - // TODO to get something working, I am currently implementing - // only the special case where there are no rank-order ties. - - let result = 0; // start with the hypothesis they are keyEQ. - const xs = left.payload; - const ys = right.payload; - const xlen = xs.length; - const ylen = ys.length; - let xi = 0; - let yi = 0; - while (xi < xlen && yi < ylen) { - const x = xs[xi]; - const y = ys[yi]; - if (xi + 1 < xlen && compareRank(x, xs[xi + 1]) === 0) { - assert.fail(X`sets with rank ties not yet implemented: ${left}`); - } - if (yi + 1 < ylen && compareRank(y, ys[yi + 1]) === 0) { - assert.fail(X`sets with rank ties not yet implemented: ${right}`); - } - const comp = compareKeys(x, y); - if (Number.isNaN(comp)) { - // If they are incommensurate, then each element is not in the - // other set, so the sets are incommensurate. - return NaN; - } else if (comp === 0) { - // - xi += 1; - yi += 1; - } else { - if (result !== comp) { - if (result === 0) { - result = comp; - } else { - assert( - (result === -1 && comp === 1) || - (result === 1 && comp === -1), - ); - return NaN; - } - } - if (comp === 1) { - xi += 1; - } else { - assert(comp === -1); - yi += 1; - } - } - } - const comp = compareKeys(xlen, ylen); - if (comp === 0) { - return result; - } else if (result === 0 || result === comp) { - return comp; - } else { - assert( - (result === -1 && comp === 1) || (result === 1 && comp === -1), - ); - return NaN; - } + // is keyEQ to some element of Y and some element of Y is + // not keyEQ to any element of X. + return setCompare(left, right); + } + case 'copyBag': { + // copyBag X is smaller than copyBag Y when every element of X + // occurs no more than the keyEQ element of Y, and some element + // of Y occurs more than some element of X, where being absent + // from X counts as occuring zero times. + return bagCompare(left, right); } case 'copyMap': { // Two copyMaps that have different keys (according to keyEQ) are diff --git a/packages/store/src/keys/copyBag.js b/packages/store/src/keys/copyBag.js new file mode 100644 index 00000000000..b2e171207ce --- /dev/null +++ b/packages/store/src/keys/copyBag.js @@ -0,0 +1,123 @@ +// @ts-check + +import { assertChecker, makeTagged, passStyleOf } from '@agoric/marshal'; +import { + compareAntiRank, + isRankSorted, + makeFullOrderComparatorKit, + sortByRank, +} from '../patterns/rankOrder.js'; + +/// + +const { details: X } = assert; + +/** + * @template T + * @param {[T,bigint][]} bagEntries + * @param {FullCompare=} fullCompare If provided and `bagEntries` is already + * known to be sorted by this `fullCompare`, then we should get a memo hit + * rather than a resorting. However, currently, we still enumerate the entire + * array each time. + * + * TODO: If doing this reduntantly turns out to be expensive, we + * could memoize this no-duplicate-keys finding as well, independent + * of the `fullOrder` use to reach this finding. + * @param {Checker=} check + * @returns {boolean} + */ +const checkNoDuplicateKeys = ( + bagEntries, + fullCompare = undefined, + check = x => x, +) => { + // This fullOrder contains history dependent state. It is specific + // to this one call and does not survive it. + // TODO Once all our tooling is ready for `&&=`, the following + // line should be rewritten using it. + fullCompare = fullCompare || makeFullOrderComparatorKit().antiComparator; + + // Since the key is more significant than the value (the count), + // sorting by fullOrder is guaranteed to make duplicate keys + // adjacent independent of their counts. + bagEntries = sortByRank(bagEntries, fullCompare); + const { length } = bagEntries; + for (let i = 1; i < length; i += 1) { + const k0 = bagEntries[i - 1][0]; + const k1 = bagEntries[i][0]; + if (fullCompare(k0, k1) === 0) { + return check(false, X`value has duplicate keys: ${k0}`); + } + } + return true; +}; + +/** + * @template T + * @param {[T,bigint][]} bagEntries + * @param {FullCompare=} fullCompare + * @returns {void} + */ +export const assertNoDuplicateKeys = (bagEntries, fullCompare = undefined) => { + checkNoDuplicateKeys(bagEntries, fullCompare, assertChecker); +}; + +/** + * @param {[Passable,bigint][]} bagEntries + * @param {Checker=} check + * @returns {boolean} + */ +export const checkBagEntries = (bagEntries, check = x => x) => { + if (passStyleOf(bagEntries) !== 'copyArray') { + return check( + false, + X`The entries of a copyBag must be a copyArray: ${bagEntries}`, + ); + } + if (!isRankSorted(bagEntries, compareAntiRank)) { + return check( + false, + X`The entries of a copyBag must be sorted in reverse rank order: ${bagEntries}`, + ); + } + for (const entry of bagEntries) { + if ( + passStyleOf(entry) !== 'copyArray' || + entry.length !== 2 || + typeof entry[1] !== 'bigint' + ) { + return check( + false, + X`Each entry of a copyBag must be pair of a key and a bigint representing a count: ${entry}`, + ); + } + if (entry[1] < 1) { + return check( + false, + X`Each entry of a copyBag must have a positive count: ${entry}`, + ); + } + } + return checkNoDuplicateKeys(bagEntries, undefined, check); +}; +harden(checkBagEntries); + +export const assertBagEntries = bagEntries => + checkBagEntries(bagEntries, assertChecker); +harden(assertBagEntries); + +export const coerceToBagEntries = bagEntriesList => { + const bagEntries = sortByRank(bagEntriesList, compareAntiRank); + assertBagEntries(bagEntries); + return bagEntries; +}; +harden(coerceToBagEntries); + +/** + * @template K + * @param {Iterable} bagEntryIter + * @returns {CopyBag} + */ +export const makeBagOfEntries = bagEntryIter => + makeTagged('copyBag', coerceToBagEntries(bagEntryIter)); +harden(makeBagOfEntries); diff --git a/packages/store/src/keys/copyMap.js b/packages/store/src/keys/copyMap.js deleted file mode 100644 index 6837a9c41ae..00000000000 --- a/packages/store/src/keys/copyMap.js +++ /dev/null @@ -1,185 +0,0 @@ -// @ts-check - -import { - assertChecker, - Far, - getTag, - makeTagged, - passStyleOf, -} from '@agoric/marshal'; -import { compareAntiRank, sortByRank } from '../patterns/rankOrder.js'; -import { checkCopySetKeys } from './copySet.js'; - -/// - -const { details: X } = assert; -const { ownKeys } = Reflect; - -/** @type WeakSet> */ -const copyMapMemo = new WeakSet(); - -/** - * @param {Passable} m - * @param {Checker=} check - * @returns {boolean} - */ -export const checkCopyMap = (m, check = x => x) => { - if (copyMapMemo.has(m)) { - return true; - } - if (!(passStyleOf(m) === 'tagged' && getTag(m) === 'copyMap')) { - return check(false, X`Not a copyMap: ${m}`); - } - const { payload } = m; - if (passStyleOf(payload) !== 'copyRecord') { - return check(false, X`A copyMap's payload must be a record: ${m}`); - } - const { keys, values, ...rest } = payload; - const result = - check( - ownKeys(rest).length === 0, - X`A copyMap's payload must only have .keys and .values: ${m}`, - ) && - checkCopySetKeys(keys, check) && - check( - passStyleOf(values) === 'copyArray', - X`A copyMap's .values must be a copyArray: ${m}`, - ) && - check( - keys.length === values.length, - X`A copyMap must have the same number of keys and values: ${m}`, - ); - if (result) { - copyMapMemo.add(m); - } - return result; -}; -harden(checkCopyMap); - -/** - * @callback IsCopyMap - * @param {Passable} m - * @returns {m is CopyMap} - */ - -/** @type {IsCopyMap} */ -export const isCopyMap = m => checkCopyMap(m); -harden(isCopyMap); - -/** - * @callback AssertCopyMap - * @param {Passable} m - * @returns {asserts m is CopyMap} - */ - -/** @type {AssertCopyMap} */ -export const assertCopyMap = m => { - checkCopyMap(m, assertChecker); -}; -harden(assertCopyMap); - -/** - * @template K,V - * @param {CopyMap} m - * @returns {K[]} - */ -export const getCopyMapKeys = m => { - assertCopyMap(m); - return m.payload.keys; -}; -harden(getCopyMapKeys); - -/** - * @template K,V - * @param {CopyMap} m - * @returns {V[]} - */ -export const getCopyMapValues = m => { - assertCopyMap(m); - return m.payload.values; -}; -harden(getCopyMapValues); - -/** - * @template K,V - * @param {CopyMap} m - * @returns {Iterable<[K,V]>} - */ -export const getCopyMapEntries = m => { - assertCopyMap(m); - const { - payload: { keys, values }, - } = m; - const { length } = keys; - return Far('CopyMap entries iterable', { - [Symbol.iterator]: () => { - let i = 0; - return Far('CopyMap entries iterator', { - next: () => { - /** @type {IteratorResult<[K,V],void>} */ - let result; - if (i < length) { - result = harden({ done: false, value: [keys[i], values[i]] }); - i += 1; - return result; - } else { - result = harden({ done: true, value: undefined }); - } - return result; - }, - }); - }, - }); -}; -harden(getCopyMapEntries); - -/** - * @template K,V - * @param {CopyMap} m - * @param {(key: K, index: number) => boolean} fn - * @returns {boolean} - */ -export const everyCopyMapKey = (m, fn) => - getCopyMapKeys(m).every((key, index) => fn(key, index)); -harden(everyCopyMapKey); - -/** - * @template K,V - * @param {CopyMap} m - * @param {(value: V, index: number) => boolean} fn - * @returns {boolean} - */ -export const everyCopyMapValue = (m, fn) => - getCopyMapValues(m).every((value, index) => fn(value, index)); -harden(everyCopyMapValue); - -/** - * @template K,V - * @param {CopyMap} m - * @returns {CopySet} - */ -export const copyMapKeySet = m => - // A copyMap's keys are already in the internal form used by copySets. - makeTagged('copySet', m.payload.keys); -harden(copyMapKeySet); - -/** - * @template K,V - * @param {Iterable<[K, V]>} entries - * @returns {CopyMap} - */ -export const makeCopyMap = entries => { - // This is weird, but reverse rank sorting the entries is a good first step - // for getting the rank sorted keys together with the values - // organized by those keys. Also, among values associated with - // keys in the same equivalence class, those are rank sorted. - // TODO This - // could solve the copyMap cover issue explained in patternMatchers.js. - // But only if we include this criteria in our validation of copyMaps, - // which we currently do not. - const sortedEntries = sortByRank(entries, compareAntiRank); - const keys = sortedEntries.map(([k, _v]) => k); - const values = sortedEntries.map(([_k, v]) => v); - return makeTagged('copyMap', { keys, values }); -}; -harden(makeCopyMap); diff --git a/packages/store/src/keys/copySet.js b/packages/store/src/keys/copySet.js index a647f660d54..d50e2277f2f 100644 --- a/packages/store/src/keys/copySet.js +++ b/packages/store/src/keys/copySet.js @@ -1,11 +1,6 @@ // @ts-check -import { - assertChecker, - getTag, - makeTagged, - passStyleOf, -} from '@agoric/marshal'; +import { assertChecker, makeTagged, passStyleOf } from '@agoric/marshal'; import { compareAntiRank, isRankSorted, @@ -18,15 +13,35 @@ import { const { details: X } = assert; /** - * @param {FullCompare} fullCompare - * @returns {(keys: Key[], check?: Checker) => boolean} + * @template T + * @param {T[]} elements + * @param {FullCompare=} fullCompare If provided and `elements` is already known + * to be sorted by this `fullCompare`, then we should get a memo hit rather + * than a resorting. However, currently, we still enumerate the entire array + * each time. + * + * TODO: If doing this reduntantly turns out to be expensive, we + * could memoize this no-duplicate finding as well, independent + * of the `fullOrder` use to reach this finding. + * @param {Checker=} check + * @returns {boolean} */ -export const makeCheckNoDuplicates = fullCompare => (keys, check = x => x) => { - keys = sortByRank(keys, fullCompare); - const { length } = keys; +const checkNoDuplicates = ( + elements, + fullCompare = undefined, + check = x => x, +) => { + // This fullOrder contains history dependent state. It is specific + // to this one call and does not survive it. + // TODO Once all our tooling is ready for `&&=`, the following + // line should be rewritten using it. + fullCompare = fullCompare || makeFullOrderComparatorKit().antiComparator; + + elements = sortByRank(elements, fullCompare); + const { length } = elements; for (let i = 1; i < length; i += 1) { - const k0 = keys[i - 1]; - const k1 = keys[i]; + const k0 = elements[i - 1]; + const k1 = elements[i]; if (fullCompare(k0, k1) === 0) { return check(false, X`value has duplicates: ${k0}`); } @@ -35,114 +50,53 @@ export const makeCheckNoDuplicates = fullCompare => (keys, check = x => x) => { }; /** - * TODO SECURITY HAZARD: https://github.com/Agoric/agoric-sdk/issues/4261 - * This creates mutable static state that should be unobservable, since it - * is only used by checkNoDuplicates in an internal sort algorithm whose - * result is tested and then dropped. However, that has a bad performance - * cost. It is not yet clear how to fix this without opening a - * communications channel. + * @template T + * @param {T[]} elements + * @param {FullCompare=} fullCompare + * @returns {void} */ -const fullCompare = makeFullOrderComparatorKit(true).antiComparator; - -const checkNoDuplicates = makeCheckNoDuplicates(fullCompare); +export const assertNoDuplicates = (elements, fullCompare = undefined) => { + checkNoDuplicates(elements, fullCompare, assertChecker); +}; /** - * @param {Passable[]} keys + * @param {Passable[]} elements * @param {Checker=} check * @returns {boolean} */ -export const checkCopySetKeys = (keys, check = x => x) => { - if (passStyleOf(keys) !== 'copyArray') { +export const checkElements = (elements, check = x => x) => { + if (passStyleOf(elements) !== 'copyArray') { return check( false, - X`The keys of a copySet or copyMap must be a copyArray: ${keys}`, + X`The keys of a copySet or copyMap must be a copyArray: ${elements}`, ); } - if (!isRankSorted(keys, compareAntiRank)) { + if (!isRankSorted(elements, compareAntiRank)) { return check( false, - X`The keys of a copySet or copyMap must be sorted in reverse rank order: ${keys}`, + X`The keys of a copySet or copyMap must be sorted in reverse rank order: ${elements}`, ); } - return checkNoDuplicates(keys, check); + return checkNoDuplicates(elements, undefined, check); }; -harden(checkCopySetKeys); +harden(checkElements); -/** @type WeakSet> */ -const copySetMemo = new WeakSet(); +export const assertElements = elements => + checkElements(elements, assertChecker); +harden(assertElements); -/** - * @param {Passable} s - * @param {Checker=} check - * @returns {boolean} - */ -export const checkCopySet = (s, check = x => x) => { - if (copySetMemo.has(s)) { - return true; - } - const result = - check( - passStyleOf(s) === 'tagged' && getTag(s) === 'copySet', - X`Not a copySet: ${s}`, - ) && checkCopySetKeys(s.payload, check); - if (result) { - copySetMemo.add(s); - } - return result; +export const coerceToElements = elementsList => { + const elements = sortByRank(elementsList, compareAntiRank); + assertElements(elements); + return elements; }; -harden(checkCopySet); - -/** - * @callback IsCopySet - * @param {Passable} s - * @returns {s is CopySet} - */ - -/** @type {IsCopySet} */ -export const isCopySet = s => checkCopySet(s); -harden(isCopySet); - -/** - * @callback AssertCopySet - * @param {Passable} s - * @returns {asserts s is CopySet} - */ - -/** @type {AssertCopySet} */ -export const assertCopySet = s => { - checkCopySet(s, assertChecker); -}; -harden(assertCopySet); +harden(coerceToElements); /** * @template K - * @param {CopySet} s - * @returns {K[]} - */ -export const getCopySetKeys = s => { - assertCopySet(s); - return s.payload; -}; -harden(getCopySetKeys); - -/** - * @template K - * @param {CopySet} s - * @param {(key: K, index: number) => boolean} fn - * @returns {boolean} - */ -export const everyCopySetKey = (s, fn) => - getCopySetKeys(s).every((key, index) => fn(key, index)); -harden(everyCopySetKey); - -/** - * @template K - * @param {Iterable} elements + * @param {Iterable} elementIter * @returns {CopySet} */ -export const makeCopySet = elements => { - const result = makeTagged('copySet', sortByRank(elements, compareAntiRank)); - assertCopySet(result); - return result; -}; -harden(makeCopySet); +export const makeSetOfElements = elementIter => + makeTagged('copySet', coerceToElements(elementIter)); +harden(makeSetOfElements); diff --git a/packages/store/src/keys/merge-bag-operators.js b/packages/store/src/keys/merge-bag-operators.js new file mode 100644 index 00000000000..70fb99cefff --- /dev/null +++ b/packages/store/src/keys/merge-bag-operators.js @@ -0,0 +1,294 @@ +// @ts-check + +import { + assertRankSorted, + compareAntiRank, + makeFullOrderComparatorKit, + sortByRank, +} from '../patterns/rankOrder.js'; +import { assertNoDuplicateKeys, makeBagOfEntries } from './copyBag.js'; + +const { details: X, quote: q } = assert; + +// Based on merge-set-operators.js, but altered for the bag representation. +// TODO share more code with merge-set-operators.js, rather than +// duplicating with changes. + +/** + * Asserts that `bagEntries` is already rank sorted by `rankCompare`, where + * there + * may be contiguous regions of bagEntries whose keys are tied for the same + * rank. + * Returns an iterable that will enumerate all the bagEntries in order + * according to `fullOrder`, which should differ from `rankOrder` only + * by being more precise. + * + * This should be equivalent to resorting the entire `bagEntries` array + * according + * to `fullOrder`. However, it optimizes for the case where these contiguous + * runs that need to be resorted are either absent or small. + * + * @template T + * @param {[T,bigint][]} bagEntries + * @param {RankCompare} rankCompare + * @param {FullCompare} fullCompare + * @returns {Iterable<[T,bigint]>} + */ +const bagWindowResort = (bagEntries, rankCompare, fullCompare) => { + assertRankSorted(bagEntries, rankCompare); + const { length } = bagEntries; + let i = 0; + let optInnerIterator; + return harden({ + [Symbol.iterator]: () => + harden({ + next: () => { + if (optInnerIterator) { + const result = optInnerIterator.next(); + if (result.done) { + optInnerIterator = undefined; + // fall through + } else { + return result; + } + } + if (i < length) { + const entry = bagEntries[i]; + let j = i + 1; + while ( + j < length && + rankCompare(entry[0], bagEntries[j][0]) === 0 + ) { + j += 1; + } + if (j === i + 1) { + i = j; + return harden({ done: false, value: entry }); + } + const similarRun = bagEntries.slice(i, j); + i = j; + const resorted = sortByRank(similarRun, fullCompare); + // Providing the same `fullCompare` should cause a memo hit + // within `assertNoDuplicates` enabling it to avoid a + // redundant resorting. + assertNoDuplicateKeys(resorted, fullCompare); + // This is the raw JS array iterator whose `.next()` method + // does not harden the IteratorResult, in violation of our + // conventions. Fixing this is expensive and I'm confident the + // unfrozen value does not escape this file, so I'm leaving this + // as is. + optInnerIterator = resorted[Symbol.iterator](); + return optInnerIterator.next(); + } else { + return harden({ done: true, value: [null, 0n] }); + } + }, + }), + }); +}; + +/** + * Returns an iterable whose iteration results are [key, xCount, yCount] tuples + * representing the next key in the local full order, as well as how many + * times it ocurred in the x input iterator and the y input interator. + * + * For sets, these counts are always 0 or 1, but this representation + * generalizes nicely for bags. + * + * @template T + * @param {[T,bigint][]} xbagEntries + * @param {[T,bigint][]} ybagEntries + * @returns {Iterable<[T,bigint,bigint]>} + */ +const merge = (xbagEntries, ybagEntries) => { + // This fullOrder contains history dependent state. It is specific + // to this one `merge` call and does not survive it. + const fullCompare = makeFullOrderComparatorKit().antiComparator; + + const xs = bagWindowResort(xbagEntries, compareAntiRank, fullCompare); + const ys = bagWindowResort(ybagEntries, compareAntiRank, fullCompare); + return harden({ + [Symbol.iterator]: () => { + // These six `let` variables are buffering one ahead from the underlying + // iterators. Each iteration reports one or the other or both, and + // then refills the buffers of those it advanced. + /** @type {T} */ + let x; + let xc; + let xDone; + /** @type {T} */ + let y; + let yc; + let yDone; + + const xi = xs[Symbol.iterator](); + const nextX = () => { + assert(!xDone, X`Internal: nextX should not be called once done`); + ({ + done: xDone, + value: [x, xc], + } = xi.next()); + }; + nextX(); + + const yi = ys[Symbol.iterator](); + const nextY = () => { + assert(!yDone, X`Internal: nextY should not be called once done`); + ({ + done: yDone, + value: [y, yc], + } = yi.next()); + }; + nextY(); + + return harden({ + next: () => { + /** @type {boolean} */ + let done = false; + /** @type {[T,bigint,bigint]} */ + let value; + if (xDone && yDone) { + done = true; + // @ts-ignore Because the terminating value does not matter + value = [null, 0n, 0n]; + } else if (xDone) { + // only ys are left + value = [y, 0n, yc]; + nextY(); + } else if (yDone) { + // only xs are left + value = [x, xc, 0n]; + nextX(); + } else { + const comp = fullCompare(x, y); + if (comp === 0) { + // x and y are equivalent, so report both + value = [x, xc, yc]; + nextX(); + nextY(); + } else if (comp < 0) { + // x is earlier, so report it + value = [x, xc, 0n]; + nextX(); + } else { + // y is earlier, so report it + assert(comp > 0, X`Internal: Unexpected comp ${q(comp)}`); + value = [y, 0n, yc]; + nextY(); + } + } + return harden({ done, value }); + }, + }); + }, + }); +}; +harden(merge); + +// We should be able to use this for iterIsSuperset as well. +// The generalization is free. +const bagIterIsSuperbag = xyi => { + for (const [_m, xc, yc] of xyi) { + if (xc < yc) { + // something in y is not in x, so x is not a superbag of y + return false; + } + } + return true; +}; + +// We should be able to use this for iterIsDisjoint as well. +// The code is identical. +const bagIterIsDisjoint = xyi => { + for (const [_m, xc, yc] of xyi) { + if (xc >= 1n && yc >= 1n) { + // Something in both, so not disjoint + return false; + } + } + return true; +}; + +// We should be able to use this for iterCompare as well. +// The generalization is free. +const bagIterCompare = xyi => { + let loneY = false; + let loneX = false; + for (const [_m, xc, yc] of xyi) { + if (xc < yc) { + // something in y is not in x, so x is not a superbag of y + loneY = true; + } + if (xc > yc) { + // something in x is not in y, so y is not a superbag of x + loneX = true; + } + if (loneX && loneY) { + return NaN; + } + } + if (loneX) { + return 1; + } else if (loneY) { + return -1; + } else { + assert( + !loneX && !loneY, + X`Internal: Unexpected lone pair ${q([loneX, loneY])}`, + ); + return 0; + } +}; + +const bagIterUnion = xyi => { + const result = []; + for (const [m, xc, yc] of xyi) { + result.push([m, xc + yc]); + } + return result; +}; + +const bagIterIntersection = xyi => { + const result = []; + for (const [m, xc, yc] of xyi) { + const mc = xc <= yc ? xc : yc; + result.push([m, mc]); + } + return result; +}; + +const bagIterDisjointSubtract = xyi => { + const result = []; + for (const [m, xc, yc] of xyi) { + const mc = xc - yc; + assert(mc >= 0n, X`right element ${m} was not in left`); + if (mc >= 1n) { + // the x was not in y + result.push([m, mc]); + } + } + return result; +}; + +const mergeify = bagIterOp => (xbagEntries, ybagEntries) => + bagIterOp(merge(xbagEntries, ybagEntries)); + +const bagEntriesIsSuperbag = mergeify(bagIterIsSuperbag); +const bagEntriesIsDisjoint = mergeify(bagIterIsDisjoint); +const bagEntriesCompare = mergeify(bagIterCompare); +const bagEntriesUnion = mergeify(bagIterUnion); +const bagEntriesIntersection = mergeify(bagIterIntersection); +const bagEntriesDisjointSubtract = mergeify(bagIterDisjointSubtract); + +const rawBagify = bagEntriesOp => (xbag, ybag) => + bagEntriesOp(xbag.payload, ybag.payload); + +const bagify = bagEntriesOp => (xbag, ybag) => + makeBagOfEntries(bagEntriesOp(xbag.payload, ybag.payload)); + +export const bagIsSuperbag = rawBagify(bagEntriesIsSuperbag); +export const bagIsDisjoint = rawBagify(bagEntriesIsDisjoint); +export const bagCompare = rawBagify(bagEntriesCompare); +export const bagUnion = bagify(bagEntriesUnion); +export const bagIntersection = bagify(bagEntriesIntersection); +export const bagDisjointSubtract = bagify(bagEntriesDisjointSubtract); diff --git a/packages/store/src/keys/merge-set-operators.js b/packages/store/src/keys/merge-set-operators.js index b63fa315d07..6ba5ed63b08 100644 --- a/packages/store/src/keys/merge-set-operators.js +++ b/packages/store/src/keys/merge-set-operators.js @@ -1,53 +1,125 @@ // @ts-check -import { sortByRank } from '../patterns/rankOrder.js'; import { - getCopySetKeys, - makeCheckNoDuplicates, - makeCopySet, -} from './copySet.js'; + assertRankSorted, + compareAntiRank, + makeFullOrderComparatorKit, + sortByRank, +} from '../patterns/rankOrder.js'; +import { assertNoDuplicates, makeSetOfElements } from './copySet.js'; -const { details: X } = assert; +const { details: X, quote: q } = assert; /** - * Different than any valid value. Therefore, must not escape this module. + * Asserts that `elements` is already rank sorted by `rankCompare`, where there + * may be contiguous regions of elements tied for the same rank. + * Returns an iterable that will enumerate all the elements in order + * according to `fullOrder`, which should differ from `rankOrder` only + * by being more precise. + * + * This should be equivalent to resorting the entire `elements` array according + * to `fullOrder`. However, it optimizes for the case where these contiguous + * runs that need to be resorted are either absent or small. * - * @typedef {symbol} Pumpkin - */ -const PUMPKIN = Symbol('pumpkin'); - -/** * @template T - * @typedef {T | Pumpkin} Opt + * @param {T[]} elements + * @param {RankCompare} rankCompare + * @param {FullCompare} fullCompare + * @returns {Iterable} */ +const windowResort = (elements, rankCompare, fullCompare) => { + assertRankSorted(elements, rankCompare); + const { length } = elements; + let i = 0; + let optInnerIterator; + return harden({ + [Symbol.iterator]: () => + harden({ + next: () => { + if (optInnerIterator) { + const result = optInnerIterator.next(); + if (result.done) { + optInnerIterator = undefined; + // fall through + } else { + return result; + } + } + if (i < length) { + const value = elements[i]; + let j = i + 1; + while (j < length && rankCompare(value, elements[j]) === 0) { + j += 1; + } + if (j === i + 1) { + i = j; + return harden({ done: false, value }); + } + const similarRun = elements.slice(i, j); + i = j; + const resorted = sortByRank(similarRun, fullCompare); + // Providing the same `fullCompare` should cause a memo hit + // within `assertNoDuplicates` enabling it to avoid a + // redundant resorting. + assertNoDuplicates(resorted, fullCompare); + // This is the raw JS array iterator whose `.next()` method + // does not harden the IteratorResult, in violation of our + // conventions. Fixing this is expensive and I'm confident the + // unfrozen value does not escape this file, so I'm leaving this + // as is. + optInnerIterator = resorted[Symbol.iterator](); + return optInnerIterator.next(); + } else { + return harden({ done: true, value: null }); + } + }, + }), + }); +}; /** + * Returns an iterable whose iteration results are [key, xCount, yCount] tuples + * representing the next key in the local full order, as well as how many + * times it ocurred in the x input iterator and the y input interator. + * + * For sets, these counts are always 0 or 1, but this representation + * generalizes nicely for bags. + * * @template T - * @param {Iterable} xs - * @param {Iterable} ys - * @param {FullCompare} fullCompare - * @returns {Iterable<[Opt,Opt]>} + * @param {T[]} xelements + * @param {T[]} yelements + * @returns {Iterable<[T,bigint,bigint]>} */ -const merge = (xs, ys, fullCompare) => { +const merge = (xelements, yelements) => { + // This fullOrder contains history dependent state. It is specific + // to this one `merge` call and does not survive it. + const fullCompare = makeFullOrderComparatorKit().antiComparator; + + const xs = windowResort(xelements, compareAntiRank, fullCompare); + const ys = windowResort(yelements, compareAntiRank, fullCompare); return harden({ [Symbol.iterator]: () => { + // These four `let` variables are buffering one ahead from the underlying + // iterators. Each iteration reports one or the other or both, and + // then refills the buffers of those it advanced. + /** @type {T} */ + let x; + let xDone; + /** @type {T} */ + let y; + let yDone; + const xi = xs[Symbol.iterator](); - /** @type {Opt} */ - let x; // PUMPKIN when done const nextX = () => { - assert(x !== PUMPKIN); - const { done, value } = xi.next(); - x = done ? PUMPKIN : value; + assert(!xDone, X`Internal: nextX should not be called once done`); + ({ done: xDone, value: x } = xi.next()); }; nextX(); const yi = ys[Symbol.iterator](); - /** @type {Opt} */ - let y; // PUMPKIN when done const nextY = () => { - assert(y !== PUMPKIN); - const { done, value } = yi.next(); - y = done ? PUMPKIN : value; + assert(!yDone, X`Internal: nextY should not be called once done`); + ({ done: yDone, value: y } = yi.next()); }; nextY(); @@ -55,30 +127,35 @@ const merge = (xs, ys, fullCompare) => { next: () => { /** @type {boolean} */ let done = false; - /** @type {[Opt,Opt]} */ - let value = [x, y]; - if (x === PUMPKIN && y === PUMPKIN) { + /** @type {[T,bigint,bigint]} */ + let value; + if (xDone && yDone) { done = true; - } else if (x === PUMPKIN) { + // @ts-ignore Because the terminating value does not matter + value = [null, 0n, 0n]; + } else if (xDone) { // only ys are left + value = [y, 0n, 1n]; nextY(); - } else if (y === PUMPKIN) { + } else if (yDone) { // only xs are left + value = [x, 1n, 0n]; nextX(); } else { const comp = fullCompare(x, y); if (comp === 0) { // x and y are equivalent, so report both + value = [x, 1n, 1n]; nextX(); nextY(); } else if (comp < 0) { // x is earlier, so report it - value = [x, PUMPKIN]; + value = [x, 1n, 0n]; nextX(); } else { // y is earlier, so report it - assert(comp > 0); - value = [PUMPKIN, y]; + assert(comp > 0, X`Internal: Unexpected comp ${q(comp)}`); + value = [y, 0n, 1n]; nextY(); } } @@ -90,9 +167,9 @@ const merge = (xs, ys, fullCompare) => { }; harden(merge); -const isIterSuperset = xyi => { - for (const [x, _yr] of xyi) { - if (x === PUMPKIN) { +const iterIsSuperset = xyi => { + for (const [_m, xc, _yc] of xyi) { + if (xc === 0n) { // something in y is not in x, so x is not a superset of y return false; } @@ -100,9 +177,9 @@ const isIterSuperset = xyi => { return true; }; -const isIterDisjoint = xyi => { - for (const [x, y] of xyi) { - if (x !== PUMPKIN && y !== PUMPKIN) { +const iterIsDisjoint = xyi => { + for (const [_m, xc, yc] of xyi) { + if (xc >= 1n && yc >= 1n) { // Something in both, so not disjoint return false; } @@ -110,17 +187,45 @@ const isIterDisjoint = xyi => { return true; }; +const iterCompare = xyi => { + let loneY = false; + let loneX = false; + for (const [_m, xc, yc] of xyi) { + if (xc === 0n) { + // something in y is not in x, so x is not a superset of y + loneY = true; + } + if (yc === 0n) { + // something in x is not in y, so y is not a superset of x + loneX = true; + } + if (loneX && loneY) { + return NaN; + } + } + if (loneX) { + return 1; + } else if (loneY) { + return -1; + } else { + assert( + !loneX && !loneY, + X`Internal: Unexpected lone pair ${q([loneX, loneY])}`, + ); + return 0; + } +}; + const iterUnion = xyi => { const result = []; - for (const [x, y] of xyi) { - if (x !== PUMPKIN) { - result.push(x); + for (const [m, xc, yc] of xyi) { + if (xc >= 0n) { + result.push(m); } else { - assert(y !== PUMPKIN); + assert(yc >= 0n, X`Internal: Unexpected count ${q(yc)}`); // if x and y were both ready, then they were equivalent and - // above clause already took care of it. Only push y - // if x was absent. - result.push(y); + // above clause already took care of it. Otherwise push here. + result.push(m); } } return result; @@ -128,16 +233,13 @@ const iterUnion = xyi => { const iterDisjointUnion = xyi => { const result = []; - for (const [x, y] of xyi) { - assert( - x === PUMPKIN || y === PUMPKIN, - X`Sets must not have common elements: ${x}`, - ); - if (x !== PUMPKIN) { - result.push(x); + for (const [m, xc, yc] of xyi) { + assert(xc === 0n || yc === 0n, X`Sets must not have common elements: ${m}`); + if (xc >= 1n) { + result.push(m); } else { - assert(y !== PUMPKIN); - result.push(y); + assert(yc >= 1n, X`Internal: Unexpected count ${q(yc)}`); + result.push(m); } } return result; @@ -145,10 +247,10 @@ const iterDisjointUnion = xyi => { const iterIntersection = xyi => { const result = []; - for (const [x, y] of xyi) { - if (x !== PUMPKIN && y !== PUMPKIN) { + for (const [m, xc, yc] of xyi) { + if (xc >= 1n && yc >= 1n) { // If they are both present, then they were equivalent - result.push(x); + result.push(m); } } return result; @@ -156,82 +258,37 @@ const iterIntersection = xyi => { const iterDisjointSubtract = xyi => { const result = []; - for (const [x, y] of xyi) { - assert(x !== PUMPKIN, X`right element ${y} was not in left`); - if (y === PUMPKIN) { + for (const [m, xc, yc] of xyi) { + assert(xc >= 1n, X`right element ${m} was not in left`); + if (yc === 0n) { // the x was not in y - result.push(x); + result.push(m); } } return result; }; -/** - * @template T - * @typedef {Object} SetOps - * - * @property {(keys: Key[], check?: Checker) => boolean} checkNoDuplicates - * - * @property {(xlist: T[], ylist: T[]) => boolean} isListSuperset - * @property {(xlist: T[], ylist: T[]) => boolean} isListDisjoint - * @property {(xlist: T[], ylist: T[]) => T[]} listUnion - * @property {(xlist: T[], ylist: T[]) => T[]} listDisjointUnion - * @property {(xlist: T[], ylist: T[]) => T[]} listIntersection - * @property {(xlist: T[], ylist: T[]) => T[]} listDisjointSubtract - * - * @property {(x: CopySet, y: CopySet) => boolean} isSetSuperset - * @property {(x: CopySet, y: CopySet) => boolean} isSetDisjoint - * @property {(x: CopySet, y: CopySet) => CopySet} setUnion - * @property {(x: CopySet, y: CopySet) => CopySet} setDisjointUnion - * @property {(x: CopySet, y: CopySet) => CopySet} setIntersection - * @property {(x: CopySet, y: CopySet) => CopySet} setDisjointSubtract - */ - -/** - * @template T - * @param {FullCompare} fullCompare - * Must be a total order, not just a rank order. See makeFullOrderComparatorKit. - * @returns {SetOps} - */ -export const makeSetOps = fullCompare => { - const checkNoDuplicates = makeCheckNoDuplicates(fullCompare); - - const listify = iterOp => (xlist, ylist) => { - const xs = sortByRank(xlist, fullCompare); - const ys = sortByRank(ylist, fullCompare); - const xyi = merge(xs, ys, fullCompare); - return iterOp(xyi); - }; +const mergeify = iterOp => (xelements, yelements) => + iterOp(merge(xelements, yelements)); - const isListSuperset = listify(isIterSuperset); - const isListDisjoint = listify(isIterDisjoint); - const listUnion = listify(iterUnion); - const listDisjointUnion = listify(iterDisjointUnion); - const listIntersection = listify(iterIntersection); - const listDisjointSubtract = listify(iterDisjointSubtract); +export const elementsIsSuperset = mergeify(iterIsSuperset); +export const elementsIsDisjoint = mergeify(iterIsDisjoint); +export const elementsCompare = mergeify(iterCompare); +export const elementsUnion = mergeify(iterUnion); +export const elementsDisjointUnion = mergeify(iterDisjointUnion); +export const elementsIntersection = mergeify(iterIntersection); +export const elementsDisjointSubtract = mergeify(iterDisjointSubtract); - const rawSetify = listOp => (xset, yset) => - listOp(getCopySetKeys(xset), getCopySetKeys(yset)); +const rawSetify = elementsOp => (xset, yset) => + elementsOp(xset.payload, yset.payload); - const setify = listOp => (xset, yset) => - makeCopySet(listOp(getCopySetKeys(xset), getCopySetKeys(yset))); +const setify = elementsOp => (xset, yset) => + makeSetOfElements(elementsOp(xset.payload, yset.payload)); - return harden({ - checkNoDuplicates, - - isListSuperset, - isListDisjoint, - listUnion, - listDisjointUnion, - listIntersection, - listDisjointSubtract, - - isSetSuperset: rawSetify(isListSuperset), - isSetDisjoint: rawSetify(isListDisjoint), - setUnion: setify(listUnion), - setDisjointUnion: setify(listDisjointUnion), - setIntersection: setify(listIntersection), - setDisjointSubtract: setify(listDisjointSubtract), - }); -}; -harden(makeSetOps); +export const setIsSuperset = rawSetify(elementsIsSuperset); +export const setIsDisjoint = rawSetify(elementsIsDisjoint); +export const setCompare = rawSetify(elementsCompare); +export const setUnion = setify(elementsUnion); +export const setDisjointUnion = setify(elementsDisjointUnion); +export const setIntersection = setify(elementsIntersection); +export const setDisjointSubtract = setify(elementsDisjointSubtract); diff --git a/packages/store/src/patterns/patternMatchers.js b/packages/store/src/patterns/patternMatchers.js index 17f45c91203..d6b0f0763d4 100644 --- a/packages/store/src/patterns/patternMatchers.js +++ b/packages/store/src/patterns/patternMatchers.js @@ -22,9 +22,11 @@ import { isKey, checkScalarKey, isScalarKey, + checkCopySet, + checkCopyBag, + checkCopyMap, + copyMapKeySet, } from '../keys/checkKey.js'; -import { checkCopySet /* , makeCopySet XXX TEMP */ } from '../keys/copySet.js'; -import { checkCopyMap, copyMapKeySet } from '../keys/copyMap.js'; /// @@ -117,6 +119,20 @@ const makePatternKit = () => { ); return checkPattern(patt.payload[0], check); } + case 'copyBag': { + if (!checkCopyBag(patt, check)) { + return false; + } + // If it is a CopyBag, then it must also be a key and we + // should never get here. + if (isKey(patt)) { + assert.fail( + X`internal: The key case should have been dealt with earlier: ${patt}`, + ); + } else { + assert.fail(X`A CopyMap must be a Key but was not: ${patt}`); + } + } case 'copyMap': { return ( checkCopyMap(patt, check) && @@ -134,7 +150,7 @@ const makePatternKit = () => { default: { return check( false, - X`A passable tagged ${q(tag)} is not a key: ${patt}`, + X`A passable tagged ${q(tag)} is not a pattern: ${patt}`, ); } } @@ -802,6 +818,23 @@ const makePatternKit = () => { check(false, X`CopySets not yet supported as keys`), }); + /** @type {MatchHelper} */ + const matchBagOfHelper = Far('match:bagOf helper', { + checkMatches: (specimen, keyPatt, check = x => x) => + check( + passStyleOf(specimen) === 'tagged' && getTag(specimen) === 'copyBag', + X`${specimen} - Must be a a CopyBag`, + ) && + specimen.payload.every(([key, _count]) => checkMatches(key, keyPatt)), + + checkIsMatcherPayload: checkPattern, + + getRankCover: () => getPassStyleCover('tagged'), + + checkKeyPattern: (_, check = x => x) => + check(false, X`CopySets not yet supported as keys`), + }); + /** @type {MatchHelper} */ const matchMapOfHelper = Far('match:mapOf helper', { checkMatches: (specimen, [keyPatt, valuePatt], check = x => x) => @@ -973,6 +1006,7 @@ const makePatternKit = () => { 'match:arrayOf': matchArrayOfHelper, 'match:recordOf': matchRecordOfHelper, 'match:setOf': matchSetOfHelper, + 'match:bagOf': matchBagOfHelper, 'match:mapOf': matchMapOfHelper, 'match:split': matchSplitHelper, 'match:partial': matchPartialHelper, @@ -999,6 +1033,7 @@ const makePatternKit = () => { const RecordShape = makeKindMatcher('copyRecord'); const ArrayShape = makeKindMatcher('copyArray'); const SetShape = makeKindMatcher('copySet'); + const BagShape = makeKindMatcher('copyBag'); const MapShape = makeKindMatcher('copyMap'); const RemotableShape = makeKindMatcher('remotable'); const ErrorShape = makeKindMatcher('error'); @@ -1025,6 +1060,7 @@ const makePatternKit = () => { record: () => RecordShape, array: () => ArrayShape, set: () => SetShape, + bag: () => BagShape, map: () => MapShape, remotable: () => RemotableShape, error: () => ErrorShape, @@ -1046,6 +1082,7 @@ const makePatternKit = () => { recordOf: (keyPatt = M.any(), valuePatt = M.any()) => makeMatcher('match:recordOf', [keyPatt, valuePatt]), setOf: (keyPatt = M.any()) => makeMatcher('match:setOf', keyPatt), + bagOf: (keyPatt = M.any()) => makeMatcher('match:bagOf', keyPatt), mapOf: (keyPatt = M.any(), valuePatt = M.any()) => makeMatcher('match:mapOf', [keyPatt, valuePatt]), split: (base, rest = undefined) => diff --git a/packages/store/src/stores/scalarMapStore.js b/packages/store/src/stores/scalarMapStore.js index a7e1be4b0e2..c3c86f91f5c 100644 --- a/packages/store/src/stores/scalarMapStore.js +++ b/packages/store/src/stores/scalarMapStore.js @@ -7,8 +7,7 @@ import { mapIterable, } from '@agoric/marshal'; import { compareRank } from '../patterns/rankOrder.js'; -import { assertScalarKey } from '../keys/checkKey.js'; -import { makeCopyMap } from '../keys/copyMap.js'; +import { assertScalarKey, makeCopyMap } from '../keys/checkKey.js'; import { matches, fit, assertPattern } from '../patterns/patternMatchers.js'; import { makeWeakMapStoreMethods } from './scalarWeakMapStore.js'; import { makeCurrentKeysKit } from './store-utils.js'; diff --git a/packages/store/src/stores/scalarSetStore.js b/packages/store/src/stores/scalarSetStore.js index 199591762c1..309ccb741ae 100644 --- a/packages/store/src/stores/scalarSetStore.js +++ b/packages/store/src/stores/scalarSetStore.js @@ -2,8 +2,7 @@ import { Far, filterIterable } from '@agoric/marshal'; import { compareRank } from '../patterns/rankOrder.js'; -import { assertScalarKey } from '../keys/checkKey.js'; -import { makeCopySet } from '../keys/copySet.js'; +import { assertScalarKey, makeCopySet } from '../keys/checkKey.js'; import { matches, fit, assertPattern } from '../patterns/patternMatchers.js'; import { makeWeakSetStoreMethods } from './scalarWeakSetStore.js'; import { makeCurrentKeysKit } from './store-utils.js'; diff --git a/packages/store/src/types.js b/packages/store/src/types.js index fb227abcdde..75151919418 100644 --- a/packages/store/src/types.js +++ b/packages/store/src/types.js @@ -5,14 +5,17 @@ /** * @typedef {Passable} Key * Keys are pass-by-copy structures (CopyArray, CopyRecord, - * CopySet, CopyMap) that end in either passable primitive data or + * CopySet, CopyBag, CopyMap) that end in either passable primitive data or * Remotables (Far objects or their remote presences.) Keys are so named * because they can be used as keys in MapStores and CopyMaps, as well as - * the elements of CopySets. + * the elements of CopySets and CopyBags. * * Keys cannot contain promises or errors, as these do not have a useful * distributed equality semantics. Keys also cannot contain any CopyTagged - * except for those recognized as CopySets and CopyMaps. + * except for those recognized as CopySets, CopyBags, and CopyMaps. + * + * Be aware that we may recognize more CopyTaggeds over time, including + * CopyTaggeds recognized as keys. * * Distributed equality is location independent. * The same two keys, passed to another location, will be equal there iff @@ -22,7 +25,7 @@ /** * @typedef {Passable} Pattern * Patterns are pass-by-copy structures (CopyArray, CopyRecord, - * CopySet, CopyMap) that end in either Keys or Matchers. Each pattern + * CopySet, CopyBag, CopyMap) that end in either Keys or Matchers. Each pattern * acts as a declarative passable predicate over passables, where each passable * either passes a given pattern, or does not. Every key is also a pattern. * Used as a pattern, a key matches only "itself", i.e., keys that are equal @@ -32,7 +35,10 @@ * not have a useful distributed equality or matching semantics. Likewise, * no pattern can distinguish among promises, or distinguish among errors. * Patterns also cannot contain any CopyTaggeds except for those recognized as - * CopySets, CopyMaps, or Matchers. + * CopySets, CopyBags, CopyMaps, or Matchers. + * + * Be aware that we may recognize more CopyTaggeds over time, including + * CopyTaggeds recognized as patterns. * * Whether a passable matches a given pattern is location independent. * For a passable and a pattern, both passed to another location, the passable @@ -64,6 +70,11 @@ * @typedef {CopyTagged} CopySet */ +/** + * @template K + * @typedef {CopyTagged} CopyBag + */ + /** * @template K,V * @typedef {CopyTagged} CopyMap @@ -451,7 +462,7 @@ * The scalars are the primitive values and Remotables. * All scalars are keys. * @property {() => Matcher} key - * Can be in a copySet or the key in a CopyMap. + * Can be in a copySet or CopyBag, or the key in a CopyMap. * (Will eventually be able to a key is a MapStore.) * All keys are patterns that match only themselves. * @property {() => Matcher} pattern @@ -469,6 +480,7 @@ * @property {() => Matcher} record A CopyRecord * @property {() => Matcher} array A CopyArray * @property {() => Matcher} set A CopySet + * @property {() => Matcher} bag A CopyBag * @property {() => Matcher} map A CopyMap * @property {() => Matcher} remotable A far object or its remote presence * @property {() => Matcher} error @@ -502,6 +514,7 @@ * @property {(subPatt?: Pattern) => Matcher} arrayOf * @property {(keyPatt?: Pattern, valuePatt?: Pattern) => Matcher} recordOf * @property {(keyPatt?: Pattern) => Matcher} setOf + * @property {(keyPatt?: Pattern) => Matcher} bagOf * @property {(keyPatt?: Pattern, valuePatt?: Pattern) => Matcher} mapOf * @property {( * base: CopyRecord<*> | CopyArray<*>, diff --git a/packages/store/test/test-copyMap.js b/packages/store/test/test-copyMap.js index 3b3ad41736f..0b8ca86ebc9 100644 --- a/packages/store/test/test-copyMap.js +++ b/packages/store/test/test-copyMap.js @@ -2,7 +2,7 @@ import { test } from '@agoric/swingset-vat/tools/prepare-test-env-ava.js'; import { getTag, passStyleOf } from '@agoric/marshal'; -import { getCopyMapEntries, makeCopyMap } from '../src/keys/copyMap.js'; +import { getCopyMapEntries, makeCopyMap } from '../src/keys/checkKey.js'; import '../src/types.js'; test('copyMap - iters are passable', t => { diff --git a/packages/store/test/test-copySet.js b/packages/store/test/test-copySet.js new file mode 100644 index 00000000000..1cefffa110f --- /dev/null +++ b/packages/store/test/test-copySet.js @@ -0,0 +1,50 @@ +// @ts-check + +import { test } from '@agoric/swingset-vat/tools/prepare-test-env-ava.js'; +import { makeTagged } from '@agoric/marshal'; +import { makeCopySet } from '../src/keys/checkKey.js'; +import { + setIsSuperset, + setIsDisjoint, + setUnion, + setDisjointUnion, + setIntersection, + setDisjointSubtract, +} from '../src/keys/merge-set-operators.js'; +import { M, matches } from '../src/patterns/patternMatchers.js'; + +import '../src/types.js'; + +test('operations on copySets', t => { + const x = makeCopySet(['b', 'a', 'c']); + const y = makeCopySet(['a', 'b']); + const z = makeCopySet(['c', 'b']); + const yUz = setUnion(y, z); + t.throws(() => setDisjointUnion(y, z), { + message: /Sets must not have common elements: "b"/, + }); + const xMy = setDisjointSubtract(x, y); + t.throws(() => setDisjointUnion(y, z), { + message: /Sets must not have common elements: "b"/, + }); + const cy = setDisjointUnion(xMy, y); + const yIz = setIntersection(y, z); + + t.false(setIsDisjoint(y, z)); + t.assert(setIsDisjoint(xMy, y)); + + t.assert(setIsSuperset(x, y)); + t.assert(matches(x, yUz)); + t.assert(matches(x, M.gt(y))); + t.assert(matches(x, M.gt(z))); + t.false(matches(y, M.gte(z))); + t.false(matches(y, M.lte(z))); + + t.deepEqual(x, makeTagged('copySet', ['c', 'b', 'a'])); + t.deepEqual(x, yUz); + t.deepEqual(x, cy); + t.deepEqual(y, makeTagged('copySet', ['b', 'a'])); + t.deepEqual(z, makeTagged('copySet', ['c', 'b'])); + t.deepEqual(xMy, makeTagged('copySet', ['c'])); + t.deepEqual(yIz, makeTagged('copySet', ['b'])); +}); diff --git a/packages/store/test/test-patterns.js b/packages/store/test/test-patterns.js index 6cbfc525a26..5384c874912 100644 --- a/packages/store/test/test-patterns.js +++ b/packages/store/test/test-patterns.js @@ -2,7 +2,7 @@ // eslint-disable-next-line import/no-extraneous-dependencies import { test } from '@agoric/swingset-vat/tools/prepare-test-env-ava.js'; -import { makeCopySet } from '../src/keys/copySet.js'; +import { makeCopySet } from '../src/keys/checkKey.js'; import { fit, matches, M } from '../src/patterns/patternMatchers.js'; import '../src/types.js'; diff --git a/packages/zoe/test/unitTests/contractSupport/test-ratio.js b/packages/zoe/test/unitTests/contractSupport/test-ratio.js index 230007fa0cf..a36ac61b574 100644 --- a/packages/zoe/test/unitTests/contractSupport/test-ratio.js +++ b/packages/zoe/test/unitTests/contractSupport/test-ratio.js @@ -124,19 +124,23 @@ test('ratio - multiplyBy non Amount', t => { }); // @ts-ignore Incorrect values for testing t.throws(() => floorMultiplyBy(badAmount, makeRatio(25n, brand)), { - message: 'value 3.5 must be a bigint, copySet, or an array, not "number"', + message: + 'value 3.5 must be a bigint, copySet, copyBag, or an array, not "number"', }); // @ts-ignore Incorrect values for testing t.throws(() => ceilMultiplyBy(badAmount, makeRatio(25n, brand)), { - message: 'value 3.5 must be a bigint, copySet, or an array, not "number"', + message: + 'value 3.5 must be a bigint, copySet, copyBag, or an array, not "number"', }); // @ts-ignore Incorrect values for testing t.throws(() => floorDivideBy(badAmount, makeRatio(25n, brand)), { - message: 'value 3.5 must be a bigint, copySet, or an array, not "number"', + message: + 'value 3.5 must be a bigint, copySet, copyBag, or an array, not "number"', }); // @ts-ignore Incorrect values for testing t.throws(() => ceilDivideBy(badAmount, makeRatio(25n, brand)), { - message: 'value 3.5 must be a bigint, copySet, or an array, not "number"', + message: + 'value 3.5 must be a bigint, copySet, copyBag, or an array, not "number"', }); }); @@ -149,10 +153,12 @@ test('ratio - multiplyBy non Amount deprecated', t => { brand, }); t.throws(() => multiplyBy(badAmount, makeRatio(25n, brand)), { - message: 'value 3.5 must be a bigint, copySet, or an array, not "number"', + message: + 'value 3.5 must be a bigint, copySet, copyBag, or an array, not "number"', }); t.throws(() => divideBy(badAmount, makeRatio(25n, brand)), { - message: 'value 3.5 must be a bigint, copySet, or an array, not "number"', + message: + 'value 3.5 must be a bigint, copySet, copyBag, or an array, not "number"', }); }); @@ -338,7 +344,8 @@ test('ratio - Nats', t => { // @ts-ignore invalid arguments for testing t.throws(() => makeRatio(10.1, brand), { - message: 'value 10.1 must be a bigint, copySet, or an array, not "number"', + message: + 'value 10.1 must be a bigint, copySet, copyBag, or an array, not "number"', }); }); @@ -401,11 +408,13 @@ test('ratio bad inputs', t => { const moe = value => AmountMath.make(brand, value); // @ts-ignore invalid arguments for testing t.throws(() => makeRatio(-3, brand), { - message: 'value -3 must be a bigint, copySet, or an array, not "number"', + message: + 'value -3 must be a bigint, copySet, copyBag, or an array, not "number"', }); // @ts-ignore invalid arguments for testing t.throws(() => makeRatio(3n, brand, 100.5), { - message: 'value 100.5 must be a bigint, copySet, or an array, not "number"', + message: + 'value 100.5 must be a bigint, copySet, copyBag, or an array, not "number"', }); // @ts-ignore invalid arguments for testing t.throws(() => makeRatioFromAmounts(3n, moe(30n)), { diff --git a/packages/zoe/test/unitTests/test-testHelpers.js b/packages/zoe/test/unitTests/test-testHelpers.js index 20fce3e2bed..03290f44624 100644 --- a/packages/zoe/test/unitTests/test-testHelpers.js +++ b/packages/zoe/test/unitTests/test-testHelpers.js @@ -116,8 +116,5 @@ test('assertAmountsEqual - both mismatch', t => { const fakeT = makeFakeT(); assertAmountsEqual(fakeT, moola(0n), cryptoCats(harden(['Garfield']))); - t.is( - fakeT.getError(), - 'Neither brand nor value matched: {"brand":"[Alleged: moola brand]","value":"[0n]"}, {"brand":"[Alleged: CryptoCats brand]","value":["Garfield"]}', - ); + t.is(fakeT.getError(), 'Must be the same asset kind: 0 vs Garfield'); }); diff --git a/packages/zoe/test/unitTests/zcf/test-zcf.js b/packages/zoe/test/unitTests/zcf/test-zcf.js index 0efa0cf6689..a2af13ccf6e 100644 --- a/packages/zoe/test/unitTests/zcf/test-zcf.js +++ b/packages/zoe/test/unitTests/zcf/test-zcf.js @@ -392,7 +392,7 @@ test(`zcf.makeZCFMint - not a math kind`, async t => { const { zcf } = await setupZCFTest(); // @ts-ignore deliberate invalid arguments for testing await t.throwsAsync(() => zcf.makeZCFMint('A', 'whatever'), { - message: /The assetKind "whatever" must be one of \["copySet","nat","set"\]/, + message: /The assetKind "whatever" must be one of \["copyBag","copySet","nat","set"\]/, }); }); diff --git a/packages/zoe/test/zoeTestHelpers.js b/packages/zoe/test/zoeTestHelpers.js index a93502a84af..26950c38441 100644 --- a/packages/zoe/test/zoeTestHelpers.js +++ b/packages/zoe/test/zoeTestHelpers.js @@ -3,35 +3,36 @@ import { E } from '@agoric/eventual-send'; import '../exported.js'; -import { setMathHelpers } from '@agoric/ertp/src/mathHelpers/setMathHelpers.js'; -import { AmountMath, isNatValue, isSetValue } from '@agoric/ertp'; +import { AmountMath, assertValueGetHelpers } from '@agoric/ertp'; import { q } from '@agoric/assert'; export const assertAmountsEqual = (t, amount, expected, label = '') => { harden(amount); harden(expected); - const brandsEqual = amount.brand === expected.brand; const l = label ? `${label} ` : ''; - let valuesEqual; - if (isSetValue(expected.value)) { - valuesEqual = setMathHelpers.doIsEqual(amount.value, expected.value); - } else if (isNatValue(expected.value)) { - valuesEqual = amount.value === expected.value; - } else { - t.fail(`${l} illegal value; neither isNat() or isSet() was true`); - } + const brandsEqual = amount.brand === expected.brand; - if (brandsEqual && valuesEqual) { - t.truthy(AmountMath.isEqual(amount, expected), l); - } else if (brandsEqual && !valuesEqual) { + const helper = assertValueGetHelpers(expected.value); + if (helper !== assertValueGetHelpers(amount.value)) { t.fail( - `${l}value (${q(amount.value)}) expected to equal ${q(expected.value)}`, + `${l}Must be the same asset kind: ${amount.value} vs ${expected.value}`, ); - } else if (!brandsEqual && valuesEqual) { - t.fail(`${l}brand (${amount.brand}) expected to equal ${expected.brand}`); } else { - t.fail(`${l}Neither brand nor value matched: ${q(amount)}, ${q(expected)}`); + const valuesEqual = helper.doIsEqual(amount.value, expected.value); + if (brandsEqual && valuesEqual) { + t.truthy(AmountMath.isEqual(amount, expected), l); + } else if (brandsEqual && !valuesEqual) { + t.fail( + `${l}value (${q(amount.value)}) expected to equal ${q(expected.value)}`, + ); + } else if (!brandsEqual && valuesEqual) { + t.fail(`${l}brand (${amount.brand}) expected to equal ${expected.brand}`); + } else { + t.fail( + `${l}Neither brand nor value matched: ${q(amount)}, ${q(expected)}`, + ); + } } }; diff --git a/yarn.lock b/yarn.lock index e37692a6b18..a972dea7a3f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10033,6 +10033,13 @@ extsprintf@^1.2.0: resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8= +fast-check@^2.21.0: + version "2.21.0" + resolved "https://registry.yarnpkg.com/fast-check/-/fast-check-2.21.0.tgz#0d2e20bc65343ee67ec0f58373358140c08a1217" + integrity sha512-hkTRytqMceXfnSwPnryIqKkxKJjfcvtVqJrWRb8tgmfyUsGajIgQqDFxCJ+As+l9VLUCcmx6XIYoXeQe2Ih0UA== + dependencies: + pure-rand "^5.0.0" + fast-deep-equal@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49" @@ -16754,6 +16761,11 @@ puppeteer-core@^11.0.0: unbzip2-stream "1.4.3" ws "8.2.3" +pure-rand@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-5.0.0.tgz#87f5bdabeadbd8904e316913a5c0b8caac517b37" + integrity sha512-lD2/y78q+7HqBx2SaT6OT4UcwtvXNRfEpzYEzl0EQ+9gZq2Qi3fa0HDnYPeqQwhlHJFBUhT7AO3mLU3+8bynHA== + q@^1.1.2, q@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"