Skip to content

Commit 2ac186c

Browse files
authored
feat(ymax-planner): Improve the "No feasible solution" error message for a graph with debug: true (#12641)
## Description * teach `preflightValidateNetworkPlan` about new AssetPlaceRef syntax (DepositFromChainRef `+${ChainName}` and WithdrawToChainRef `-${ChainName}`) to avoid errors like _`Unsupported position key: "+Ethereum"`_ * unquote the message for better readability * translate `e$nn` edge ids into `$src->$dest` labels * consolidate pick_e$nn and via_e$nn variable assignments for the same edge * sort edges by ascending id * underscore-separate number literal digits into groups of 3 such that e.g. 10k uusdc looks like "10_000_000_000". ### Security Considerations None known ### Scaling Considerations The increased object and text manipulation is a negligible burden, and even so, only applies when the solver rejects a flow graph. ### Documentation Considerations n/a ### Testing Considerations Since these error messages are intended to be human-friendly, added test coverage for them. ### Upgrade Considerations Safe for immediate deployment.
2 parents fbebe4f + b966e86 commit 2ac186c

3 files changed

Lines changed: 153 additions & 12 deletions

File tree

packages/portfolio-contract/test/rebalance.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -626,6 +626,67 @@ testWithAllModes(
626626
},
627627
);
628628

629+
test('infeasibility error with debug=true', async t => {
630+
// @ts-expect-error fake chain names
631+
const network: NetworkSpec = harden({
632+
debug: true,
633+
environment: 'test',
634+
chains: [
635+
{ name: 'FakeSupply', control: 'axelar' },
636+
{ name: 'FakeSink', control: 'axelar' },
637+
],
638+
pools: [
639+
{ pool: 'Aave_FakeSupply', chain: 'FakeSupply', protocol: 'Aave' },
640+
{ pool: 'Aave_FakeSink', chain: 'FakeSink', protocol: 'Aave' },
641+
],
642+
links: [
643+
{
644+
src: '@FakeSupply',
645+
dest: '@FakeSink',
646+
transfer: 'cctpV2',
647+
variableFeeBps: 0,
648+
timeSec: 30,
649+
min: 100_000n,
650+
},
651+
{
652+
src: '@FakeSink',
653+
dest: '@FakeSupply',
654+
transfer: 'cctpV2',
655+
variableFeeBps: 0,
656+
timeSec: 30,
657+
min: 100_000n,
658+
},
659+
],
660+
});
661+
const current = { '+FakeSupply': token(10_000_000_000n) }; // $10k
662+
const target = {
663+
Aave_FakeSupply: token(9_999_990_000n),
664+
// delta is too small for link
665+
Aave_FakeSink: token(10000n),
666+
};
667+
let thrown: Error | undefined;
668+
await null;
669+
try {
670+
await planRebalanceFlow({
671+
network,
672+
// @ts-expect-error fake chain names
673+
current,
674+
// @ts-expect-error fake chain names
675+
target,
676+
brand: TOK_BRAND,
677+
feeBrand: FEE_BRAND,
678+
gasEstimator,
679+
});
680+
} catch (err) {
681+
thrown = err as Error;
682+
}
683+
t.assert(thrown);
684+
t.is(
685+
thrown?.message,
686+
'No feasible solution: nodes=5 edges=11 | supply: pos 0 must equal neg 10000000000000000 (diff -10000000000000000) | WARN: total supply does not balance to 0 | sources=0 sinks=2 | hubs: @FakeSink, @FakeSupply | inter-hub edges: @FakeSupply->@FakeSink, @FakeSink->@FakeSupply | { feasible: false, result: 0, bounded: true, e01: { arc: "@FakeSupply->Aave_FakeSupply", pick: 0.009_999_990, via: 9_999_990_000.000_002 }, e02: { arc: "Aave_FakeSink->@FakeSink", via: 9_999_990_000.000_002 }, e10: { arc: "@FakeSink->@FakeSupply", pick: 0.009_999_990, via: 9_999_990_000.000_002 } }',
687+
);
688+
});
689+
629690
test('solver differentiates cheapest vs. fastest', async t => {
630691
const network: NetworkSpec = {
631692
debug: true,

packages/portfolio-contract/tools/graph-diagnose.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ import { Fail, q } from '@endo/errors';
66
import type { NatAmount } from '@agoric/ertp/src/types.js';
77
import { provideLazyMap, typedEntries } from '@agoric/internal/src/js-utils.js';
88
import { tryNow } from '@agoric/internal/src/ses-utils.js';
9-
import { isInterChainAccountRef } from '@agoric/portfolio-api/src/type-guards.js';
9+
import {
10+
isDepositFromChainRef,
11+
isInstrumentId,
12+
isInterChainAccountRef,
13+
isWithdrawToChainRef,
14+
} from '@agoric/portfolio-api/src/type-guards.js';
1015
import type {
1116
AssetPlaceRef,
1217
InterChainAccountRef,
@@ -42,7 +47,7 @@ const bfs = <T>(start: T, adj: Map<T, T[]>): Set<T> => {
4247
* Heuristics only: checks supply balance and reachability of sinks from sources.
4348
*
4449
* Example output (when graph.debug is true):
45-
* No feasible solution: nodes=7 edges=12 | supply: sum=0 pos=1500 neg=1500 (pos should equal neg; sum should be 0) | sources=2 sinks=2 | sources with no path to any sink (1): Aave_Arbitrum(800) | hubs present: @agoric, @noble, @Arbitrum | inter-hub edges: @agoric->@noble, @noble->@Arbitrum
50+
* `No feasible solution: nodes=7 edges=12 | supply: sum=0 pos=1500 neg=1500 (pos should equal neg; sum should be 0) | sources=2 sinks=2 | sources with no path to any sink (1): Aave_Arbitrum(800) | hubs: @agoric, @noble, @Arbitrum | inter-hub edges: @agoric->@noble, @noble->@Arbitrum`
4651
*
4752
* How to enable:
4853
* - Set `debug: true` on the NetworkSpec used to build the graph.
@@ -109,7 +114,7 @@ export const diagnoseInfeasible = (
109114
`sources with no path to any sink (${stranded.length}): ${sample}`,
110115
);
111116
}
112-
lines.push(`hubs present: ${[...hubSet].sort().join(', ')}`);
117+
lines.push(`hubs: ${[...hubSet].sort().join(', ')}`);
113118
lines.push(
114119
`inter-hub edges: ${hubEdges.length ? hubEdges.join(', ') : '(none)'}`,
115120
);
@@ -155,8 +160,16 @@ export const preflightValidateNetworkPlan = (
155160
const tgt = target[k]?.value ?? 0n;
156161
if (cur === tgt) continue;
157162

158-
const placeInfo = PoolPlaces[k as PoolKey];
159-
placeInfo || declared.has(k) || Fail`Unsupported position key: ${q(k)}`;
163+
if (isInstrumentId(k)) {
164+
const placeInfo = PoolPlaces[k as PoolKey];
165+
placeInfo || declared.has(k) || Fail`Unsupported position key: ${q(k)}`;
166+
} else {
167+
[
168+
isInterChainAccountRef,
169+
isDepositFromChainRef,
170+
isWithdrawToChainRef,
171+
].some(p => p(k)) || Fail`Unsupported key: ${q(k)}`;
172+
}
160173
const chain = tryNow(
161174
() => chainOf(k),
162175
() => undefined,

packages/portfolio-contract/tools/plan-solve.ts

Lines changed: 74 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,14 @@ const replaceOrInit = <K, V>(
4545
map.set(key, callback(old, key, exists));
4646
};
4747

48-
// XXX These probably belong in @agoric/internal.
48+
// #region XXX These probably belong in @agoric/internal.
49+
const compareStrings = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
50+
51+
const alphabetizeRecord = <T extends Record<string, unknown>>(obj: T) =>
52+
Object.fromEntries(
53+
Object.entries(obj).sort(([k1], [k2]) => compareStrings(k1, k2)),
54+
);
55+
4956
/**
5057
* Return the minimum and maximum bigint value from non-empty arguments, similar
5158
* to `Math.min` and `Math.max` (which don't work with bigints).
@@ -59,12 +66,14 @@ const bigIntExtremes = (first: bigint, ...rest: bigint[]) => {
5966
}
6067
return { min, max };
6168
};
69+
6270
/**
6371
* Return the maximum bigint value from non-empty arguments, similar to
6472
* `Math.max` (which doesn't work with bigints).
6573
*/
6674
const bigIntMax = (first: bigint, ...rest: bigint[]): bigint =>
6775
bigIntExtremes(first, ...rest).max;
76+
// #endregion
6877

6978
/** The count of minor units per major unit (e.g., uusdc per USDC) */
7079
const UNIT_SCALE = 1e6;
@@ -350,7 +359,7 @@ const refineModel = (
350359

351360
/**
352361
* Represent a JSON-serializable object as a spacey single-line literal with
353-
* identifier-compatible property names unquoted.
362+
* identifier-compatible property names unquoted and number digits grouped.
354363
*/
355364
const prettyJsonable = (obj: unknown): string => {
356365
const jsonText = JSON.stringify(obj, null, 1);
@@ -360,8 +369,24 @@ const prettyJsonable = (obj: unknown): string => {
360369
strings.push(s);
361370
return '#';
362371
});
363-
// Condense the [now guaranteed-insignificant] whitespace.
364-
const singleLine = safe.replace(/\s+/g, ' ');
372+
// Condense the [now guaranteed-insignificant] whitespace and insert
373+
// underscores to separate digits into groups of 3.
374+
const singleLine = safe
375+
.replace(/\s+/g, ' ')
376+
.replace(/([0-9]+)([.][0-9]+)?/g, (_x, w, f = '') => {
377+
const wCount = w.length;
378+
const wGroups = w.slice(wCount % 3).match(/[0-9]{3}/g) || [];
379+
if (wCount % 3) wGroups.unshift(w.slice(0, wCount % 3));
380+
381+
const fGroups = f.match(/[0-9]{1,3}/g) || [];
382+
const lastFGroup = fGroups.pop();
383+
if (lastFGroup) {
384+
fGroups.push(lastFGroup.padEnd(3, '0'));
385+
fGroups[0] = `.${fGroups[0]}`;
386+
}
387+
388+
return `${wGroups.join('_')}${fGroups.join('_')}`;
389+
});
365390
// Restore the strings, stripping quotes from property names as possible.
366391
const pretty = singleLine.replaceAll('#', () => {
367392
const s = strings.shift() as string;
@@ -371,6 +396,48 @@ const prettyJsonable = (obj: unknown): string => {
371396
return pretty;
372397
};
373398

399+
/**
400+
* Translate opaque variable names associated with edges (e.g., "pick_e00" and
401+
* "via_e99") into ordered human-readable records like
402+
* `"e00": { arc: "@agoric->@Ethereum", pick: 0.01, via: 1_000_000 }`.
403+
*/
404+
const summarizeSolution = (
405+
graph: FlowGraph,
406+
solution?: LpSolution,
407+
): null | Record<string, unknown> => {
408+
if (!solution) return null;
409+
410+
const { base, edges } = typedEntries(solution).reduce<{
411+
base: Record<string, unknown>;
412+
edges: Record<string, unknown>;
413+
}>(
414+
(groups, [key, value]) => {
415+
const keyParts = key.match(/^(.*?)_(e[0-9]+)$/);
416+
if (!keyParts) {
417+
groups.base[key] = value;
418+
} else {
419+
const [, kind, edgeId] = keyParts;
420+
const edgeData = groups.edges[edgeId] as any;
421+
if (!edgeData) {
422+
const edge = graph.edges.find(e => e.id === edgeId);
423+
const label = edge ? `${edge.src}->${edge.dest}` : '<unknown>';
424+
groups.edges[edgeId] = { arc: label, [kind]: value };
425+
} else {
426+
const prevLastKey = Object.keys(edgeData).pop() as string;
427+
edgeData[kind] = value;
428+
if (kind < prevLastKey) {
429+
const { arc, ...rest } = edgeData;
430+
groups.edges[edgeId] = { arc, ...alphabetizeRecord(rest) };
431+
}
432+
}
433+
}
434+
return groups;
435+
},
436+
{ base: { __proto__: null }, edges: { __proto__: null } },
437+
);
438+
return { ...base, ...alphabetizeRecord(edges) };
439+
};
440+
374441
const solveLPModel = (
375442
model: LpModel,
376443
graph: FlowGraph,
@@ -382,11 +449,11 @@ const solveLPModel = (
382449
// instead. If result is undefined or there are no variable values, it's truly infeasible.
383450
if (!(solution?.feasible || solution?.result)) {
384451
if (graph.debug) {
385-
// Emit richer context only on demand to avoid noisy passing runs
452+
// Emit richer context.
386453
let msg = formatInfeasibleDiagnostics(graph, model);
387-
msg += ` | ${prettyJsonable(solution)}`;
454+
msg += ` | ${prettyJsonable(summarizeSolution(graph, solution))}`;
388455
console.error('[solver] No feasible solution. Diagnostics:', msg);
389-
failUnsolvable(X`No feasible solution: ${msg}`);
456+
failUnsolvable(`No feasible solution: ${msg}`);
390457
}
391458
failUnsolvable(X`No feasible solution: ${solution}`);
392459
}

0 commit comments

Comments
 (0)