Skip to content

Commit

Permalink
feat(ertp): frugal split
Browse files Browse the repository at this point in the history
  • Loading branch information
erights committed Sep 7, 2024
1 parent 67275c2 commit b71b04d
Show file tree
Hide file tree
Showing 9 changed files with 306 additions and 12 deletions.
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.5",
"@endo/marshal": "^1.5.3",
"@endo/nat": "^5.0.10",
"@endo/pass-style": "^1.4.3",
"@endo/patterns": "^1.4.3",
"@endo/promise-kit": "^1.1.5"
},
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,
});
22 changes: 22 additions & 0 deletions packages/ERTP/src/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,13 @@ export {};

// /////////////////////////// MathHelpers /////////////////////////////////////

/**
* @template {AmountValue} [V=AmountValue]
* @typedef AmountValueSplit
* @property {V} matched
* @property {V} change
*/

/**
* @template {AmountValue} V
* @typedef {object} MathHelpers All of the difference in how digital asset
Expand All @@ -410,6 +417,21 @@ export {};
* @property {(left: V, right: V) => V} doSubtract Return what remains after
* removing the right from the left. If something in the right was not in the
* left, we throw an error.
* @property {(
* totalValue: V,
* valuePattern: Pattern,
* ) => AmountValueSplit<V> | undefined} doFrugalSplit
* 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.
*/

/** @typedef {bigint} NatValue */
Expand Down
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

0 comments on commit b71b04d

Please sign in to comment.