Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ERTP): frugal split #8459

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/ERTP/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"@endo/far": "^1.1.6",
"@endo/marshal": "^1.5.4",
"@endo/nat": "^5.0.11",
"@endo/pass-style": "^1.4.4",
"@endo/patterns": "^1.4.4",
"@endo/promise-kit": "^1.1.6"
},
Expand Down
184 changes: 181 additions & 3 deletions packages/ERTP/src/amountMath.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { q, Fail } from '@endo/errors';
import { passStyleOf, assertRemotable, assertRecord } from '@endo/marshal';
import { passStyleOf, assertRemotable, assertRecord } from '@endo/pass-style';
import { M, isKey, kindOf, matches, mustMatch } from '@endo/patterns';

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';
import { AmountShape } from './typeGuards.js';

/**
* @import {CopyBag, CopySet} from '@endo/patterns';
* @import {Amount, AssetKind, AmountValue, AssetKindForValue, AssetValueForKind, Brand, CopyBagAmount, CopySetAmount, MathHelpers, NatAmount, NatValue, SetAmount, SetValue} from './types.js';
* @import {Amount, AssetKind, AmountValue, AssetValueForKind, Brand, CopyBagAmount, CopySetAmount, MathHelpers, NatAmount, NatValue, SetAmount, SetValue, AmountValueSplit} from './types.js';
*/

/**
Expand Down Expand Up @@ -184,6 +185,181 @@ const isGTE = (leftAmount, rightAmount, brand = undefined) => {
return h.doIsGTE(...coerceLR(h, leftAmount, rightAmount));
};

// ////////////////////// Frugal Split /////////////////////////////////////////

/**
* Best effort attempt to extract the smallest subset of `totalValue` needed to
* to match the `valuePattern`. We name this "frugal" rather than "minimal"
* because we only require this looser best-efforts requirement. Further, we
* require that successive versions of this code are monotonic non-worse
* efforts, i.e., that they do not become less precise than previous correct
* answers.
*
* @template {AmountValue} [V=AmountValue]
* @param {MathHelpers<V>} h
* @param {V} empty
* @param {V} totalValue
* @param {Pattern} valuePattern
* @returns {AmountValueSplit<V> | undefined}
*/
const frugalValueSplit = (h, empty, totalValue, valuePattern) => {
if (isKey(valuePattern)) {
const valueNeeded = /** @type {V} */ (valuePattern);
if (h.doIsGTE(totalValue, valueNeeded)) {
return harden({
matched: valueNeeded,
change: h.doSubtract(totalValue, valueNeeded),
});
}
return undefined;
}
if (matches(empty, valuePattern)) {
return harden({
matched: empty,
change: totalValue,
});
}
const valueSplit = h.doFrugalSplit(totalValue, valuePattern);
if (valueSplit !== undefined) {
return valueSplit;
}
if (matches(totalValue, valuePattern)) {
// Conservative safe overestimate. Over time, we may pick off
// more cases to become more precise (i.e., less conservative).
// This is consistent with the best-efforts sense of `frugalValueSplit`
// but means that clients should be prepared for these answers to
// become more precise over time while remaining safe.
return harden({
matched: totalValue,
change: empty,
});
}
// Some patterns might validly match only a non-empty strict subset of
// totalAmount, but in which the above case analysis fails to figure
// that out and we fall through here. As we expand this case analysis
// to pick off more cases, some of these cases may start succeeding.
// This is consistent with the best-efforts sense of
// `frugalValueSplit` but means that clients should be prepared for
// these false failures to become successes over time.
return undefined;
};

/**
* @template {AssetKind} [K=AssetKind]
* @typedef AmountSplit
* @property {Amount<K>} matched
* @property {Amount<K>} change
*/

/**
* Best effort attempt to extract the smallest subset of `totalAmount` needed to
* to match the `pattern`. We name this "frugal" rather than "minimal" because
* we only require this looser best-efforts requirement. Further, we require
* that successive versions of this code are monotonic non-worse efforts, i.e.,
* that they do not become less precise than previous correct answers.
*
* @param {Amount} totalAmount
* @param {Pattern} pattern
* @returns {AmountSplit | undefined}
*/
const frugalSplit = (totalAmount, pattern) => {
mustMatch(
harden([totalAmount, pattern]),
harden([AmountShape, M.pattern()]),
'frugalSplit',
);
if (isKey(pattern)) {
const needed = /** @type {Amount} */ (pattern);
if (isGTE(totalAmount, needed)) {
return harden({
matched: needed,
// eslint-disable-next-line no-use-before-define
change: AmountMath.subtract(totalAmount, needed),
});
}
return undefined;
}
const { brand, value: totalValue } = totalAmount;
const h = assertValueGetHelpers(totalValue);
const empty = /** @type {Amount} */ (
harden({
brand,
value: h.doMakeEmpty(),
})
);
if (matches(empty, pattern)) {
return harden({
matched: empty,
change: totalAmount,
});
}
const patternKind = kindOf(pattern);

switch (patternKind) {
case 'copyRecord': {
mustMatch(
pattern,
harden({
brand: M.pattern(),
value: M.pattern(),
}),
'amountPattern record',
);
const recordPattern = /** @type {{ brand: Pattern; value: Pattern }} */ (
pattern
);
const { brand: brandPattern, value: valuePattern } = recordPattern;
if (!matches(brand, brandPattern)) {
return undefined;
}
const valueSplit = frugalValueSplit(
h,
empty.value,
totalValue,
valuePattern,
);
if (valueSplit === undefined) {
return undefined;
}
return /** @type {AmountSplit} */ (
harden({
matched: {
brand,
value: valueSplit.matched,
},
change: {
brand,
value: valueSplit.change,
},
})
);
}
default: {
if (matches(totalAmount, pattern)) {
// Conservative safe overestimate. Over time, we may pick off
// more cases to become more precise (i.e., less conservative).
// This is consistent with the best-efforts sense of `frugalSplit`
// but means that clients should be prepared for these answers to
// become more precise over time while remaining safe.
return harden({
matched: totalAmount,
change: empty,
});
}
// Some patterns might validly match only a non-empty strict subset of
// totalAmount, but in which the above case analysis fails to figure
// that out and we fall through here. As we expand this case analysis
// to pick off more cases, some of these cases may start succeeding.
// This is consistent with the best-efforts sense of
// `frugalSplit` but means that clients should be prepared for
// these false failures to become successes over time.
return undefined;
}
}
};

// ////////////////////// AmountMath ///////////////////////////////////////////

/**
* Logic for manipulating amounts.
*
Expand Down Expand Up @@ -383,6 +559,8 @@ const AmountMath = {
: isGTE(y, x)
? y
: Fail`${x} and ${y} are incomparable`,

frugalSplit,
};
harden(AmountMath);

Expand Down
1 change: 1 addition & 0 deletions packages/ERTP/src/mathHelpers/copyBagMathHelpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,5 @@ export const copyBagMathHelpers = harden({
doIsEqual: keyEQ,
doAdd: bagUnion,
doSubtract: bagDisjointSubtract,
doFrugalSplit: (_totalValue, _valuePattern) => undefined,
});
1 change: 1 addition & 0 deletions packages/ERTP/src/mathHelpers/copySetMathHelpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,5 @@ export const copySetMathHelpers = harden({
doIsEqual: keyEQ,
doAdd: setDisjointUnion,
doSubtract: setDisjointSubtract,
doFrugalSplit: (_totalValue, _valuePattern) => undefined,
});
1 change: 1 addition & 0 deletions packages/ERTP/src/mathHelpers/natMathHelpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,5 @@ export const natMathHelpers = harden({
// BigInts don't observably overflow
doAdd: (left, right) => left + right,
doSubtract: (left, right) => Nat(left - right),
doFrugalSplit: (_totalValue, _valuePattern) => undefined,
});
1 change: 1 addition & 0 deletions packages/ERTP/src/mathHelpers/setMathHelpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,5 @@ export const setMathHelpers = harden({
doIsEqual: (x, y) => elementsCompare(x, y) === 0,
doAdd: elementsDisjointUnion,
doSubtract: elementsDisjointSubtract,
doFrugalSplit: (_totalValue, _valuePattern) => undefined,
});
25 changes: 25 additions & 0 deletions packages/ERTP/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,14 @@ export type PaymentMethods<K extends AssetKind = AssetKind> = {
*/
getAllegedBrand: () => Brand<K>;
};

export type AmountValueSplit <
V extends AmountValue = AmountValue,
> = {
matched: V,
change: V,
};

/**
* All of the difference in how digital asset
* amount are manipulated can be reduced to the behavior of the math on
Expand Down Expand Up @@ -459,6 +467,23 @@ export type MathHelpers<V extends AmountValue> = {
* left, we throw an error.
*/
doSubtract: (left: V, right: V) => V;
/**
* Only needs to deal with the helper-specific cases left over after the
* `frugalValueSplit` in amountMath.js has taken case of the cases it can
* handle optimally. When `valuePattern` is
*
* - a concrete value (i.e., a `Key`), producing an exact subtract.
* - Anything that matches `empty`, since that gives an optimally frugal success.
*
* `doFrugalSplit` should return `undefined` anytime it has nothing further
* contribute. That will not be interpreted as saying that failure to split
* should be reported. Rather, the caller may then fall back to generic
* conservative checks.
*/
doFrugalSplit: (
totalValue: V,
valuePattern: Pattern,
) => AmountValueSplit<V> | undefined;
};
export type NatValue = bigint;
export type SetValue<K extends Key = Key> = K[];
88 changes: 88 additions & 0 deletions packages/ERTP/test/unitTests/frugal-split.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { test } from '@agoric/swingset-vat/tools/prepare-test-env-ava.js';

import { M } from '@endo/patterns';
import { AmountMath as am } from '../../src/index.js';
import { mockNatBrand } from './mathHelpers/mockBrand.js';

// Each of the test results marked "conservative" below are allowed to get
// more accurate over time. They may not become less accurate.
// Once a test result is marked "accurate", it must not change.

test('frugalSplit', t => {
const $ = value =>
harden({
brand: mockNatBrand,
value,
});

// ground patterns, i.e., which patterns which are concrete amounts
t.deepEqual(am.frugalSplit($(3n), $(2n)), {
// accurate
matched: $(2n),
change: $(1n),
});

// outer amount patterns
t.deepEqual(am.frugalSplit($(3n), M.any()), {
// accurate
matched: $(0n),
change: $(3n),
});
t.deepEqual(am.frugalSplit($(3n), M.gte($(2n))), {
// conservative
matched: $(3n),
change: $(0n),
});
t.deepEqual(am.frugalSplit($(3n), M.lte($(2n))), {
// accurate
matched: $(0n),
change: $(3n),
});
t.deepEqual(
am.frugalSplit($(3n), M.and($(2n))),
// conservative
undefined,
);
t.deepEqual(am.frugalSplit($(3n), M.and($(0n))), {
// accurate
matched: $(0n),
change: $(3n),
});
t.deepEqual(am.frugalSplit($(3n), M.and($(3n))), {
// accurate
matched: $(3n),
change: $(0n),
});

// inner amount patterns, i.e., amount-value patterns
t.deepEqual(am.frugalSplit($(3n), $(M.any())), {
// accurate
matched: $(0n),
change: $(3n),
});
t.deepEqual(am.frugalSplit($(3n), $(M.gte(2n))), {
// conservative
matched: $(3n),
change: $(0n),
});
t.deepEqual(am.frugalSplit($(3n), $(M.lte(2n))), {
// accurate
matched: $(0n),
change: $(3n),
});
t.deepEqual(
am.frugalSplit($(3n), $(M.and(2n))),
// conservative
undefined,
);
t.deepEqual(am.frugalSplit($(3n), $(M.and(0n))), {
// accurate
matched: $(0n),
change: $(3n),
});
t.deepEqual(am.frugalSplit($(3n), $(M.and(3n))), {
// accurate
matched: $(3n),
change: $(0n),
});
});
Loading
Loading