diff --git a/packages/arbitration-sdk/package.json b/packages/arbitration-sdk/package.json index ed41562..b618d8e 100644 --- a/packages/arbitration-sdk/package.json +++ b/packages/arbitration-sdk/package.json @@ -16,7 +16,7 @@ "subgraph": "tsx src/subgraph-cli.ts", "fund": "tsx src/fund-cli.ts", "typecheck": "tsc --noEmit", - "test": "tsx --test src/proof.test.ts src/recommendation.test.ts src/fallback.test.ts src/swapvm.test.ts src/fixtures.test.ts src/grammar.test.ts src/validate.test.ts src/subgraph.test.ts src/context.test.ts src/compose.test.ts src/serve.test.ts src/compile.test.ts", + "test": "tsx --test src/proof.test.ts src/recommendation.test.ts src/fallback.test.ts src/swapvm.test.ts src/fixtures.test.ts src/grammar.test.ts src/validate.test.ts src/subgraph.test.ts src/context.test.ts src/compose.test.ts src/appetite.test.ts src/pairing.test.ts src/tiers.test.ts src/serve.test.ts src/compile.test.ts", "fixtures": "tsx src/fixtures-cli.ts" }, "dependencies": { diff --git a/packages/arbitration-sdk/src/appetite.test.ts b/packages/arbitration-sdk/src/appetite.test.ts new file mode 100644 index 0000000..22fa930 --- /dev/null +++ b/packages/arbitration-sdk/src/appetite.test.ts @@ -0,0 +1,61 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { appetitePromptBlock, classifyRiskAppetite } from "./appetite.ts"; + +test("conservative wording classifies as conservative", () => { + for (const p of [ + "keep it safe, I don't want to lose my stack", + "a conservative position on my USDC please", + "low risk market making", + "be careful with this", + "preserve my capital while earning a little", + ]) { + assert.equal(classifyRiskAppetite(p), "conservative", p); + } +}); + +test("aggressive wording classifies as aggressive", () => { + for (const p of [ + "max yield, I can stomach a drawdown", + "go aggressive on WETH/USDC", + "full degen mode", + "I want the risky option", + "maximise fees, I don't care about the range", + ]) { + assert.equal(classifyRiskAppetite(p), "aggressive", p); + } +}); + +test("neutral when the prompt says nothing about risk", () => { + for (const p of [ + "market-make WETH/USDC for a week", + "put my USDC to work", + "", + " ", + ]) { + assert.equal(classifyRiskAppetite(p), "neutral", JSON.stringify(p)); + } +}); + +test("a tie reads as neutral rather than picking a side", () => { + assert.equal( + classifyRiskAppetite("mostly safe but a little aggressive"), + "neutral", + ); +}); + +test("whole words only — 'unsafe' is not 'safe'", () => { + // Substring matching would read this as conservative, which is backwards. + assert.notEqual(classifyRiskAppetite("nothing unsafe about it"), "conservative"); +}); + +test("classification is case-insensitive", () => { + assert.equal(classifyRiskAppetite("KEEP IT SAFE"), "conservative"); + assert.equal(classifyRiskAppetite("Max Yield"), "aggressive"); +}); + +test("the prompt line carries the level and never asks the model to re-derive it", () => { + const block = appetitePromptBlock("aggressive"); + assert.ok(block.includes("AGGRESSIVE"), block); + assert.ok(/do NOT re-infer/i.test(block), block); +}); diff --git a/packages/arbitration-sdk/src/appetite.ts b/packages/arbitration-sdk/src/appetite.ts new file mode 100644 index 0000000..370e793 --- /dev/null +++ b/packages/arbitration-sdk/src/appetite.ts @@ -0,0 +1,90 @@ +// Risk appetite, read from the user's own words (T0.1 / issue #24). +// +// Users say how much risk they want in the sentence they type ("keep it safe", +// "max yield, I can take the hit"), and the prompt used to carry none of that +// signal. Classifying it is deliberately NOT the model's job: mixing a +// classification task into a JSON-emission task raises the malformed-output +// rate, and the composer gets one retry before the deterministic fallback takes +// over. So we classify here, and the prompt hands the model a fact to condition +// on rather than a judgement to make. +// +// What this DOESN'T do: rate the risk of a recommendation. That is Gate 2's +// job (F2 §4, deferred) and the app says "risk rating unavailable" until it +// ships. This only reads what the user asked for. + +export type RiskAppetite = "conservative" | "neutral" | "aggressive"; + +// Word-boundary matched, lowercased. Multi-word phrases are matched as written. +// Deliberately small: every entry is a phrase users actually type, and a +// lexicon nobody can hold in their head is one nobody can debug on stage. +const CONSERVATIVE = [ + "safe", + "safely", + "safest", + "stable", + "conservative", + "cautious", + "careful", + "preserve", + "protect", + "low risk", + "minimal risk", + "don't want to lose", + "dont want to lose", +]; + +const AGGRESSIVE = [ + "aggressive", + "aggressively", + "risky", + "high risk", + "degen", + "max yield", + "maximum yield", + "maximise", + "maximize", + "chase", + "yolo", + "can stomach", + "gamble", +]; + +// Whole-word (or whole-phrase) match, so "unsafe" does not read as "safe" and +// "maximise" does not fire on "maximised". Escaping is unnecessary — every +// entry above is letters, spaces and apostrophes. +function countMatches(haystack: string, needles: string[]): number { + let n = 0; + for (const needle of needles) { + if (new RegExp(`(^|\\W)${needle}(\\W|$)`, "i").test(haystack)) n += 1; + } + return n; +} + +/** + * Classify the risk appetite a prompt expresses. + * + * The rule is deliberately blunt — more conservative hits than aggressive ones + * means conservative, and vice versa; a tie or no hits means neutral. It must + * stay explainable in one sentence. + * + * KNOWN LIMITATION: no negation handling, so "not too risky" reads as + * aggressive. Acceptable, because the output only shifts which band widths are + * SUGGESTED — it never touches the budget, never overrides a validator + * invariant, and the user reviews the recommendation before signing anything. + */ +export function classifyRiskAppetite(prompt: string): RiskAppetite { + const conservative = countMatches(prompt, CONSERVATIVE); + const aggressive = countMatches(prompt, AGGRESSIVE); + if (conservative > aggressive) return "conservative"; + if (aggressive > conservative) return "aggressive"; + return "neutral"; +} + +/// The one line the prompt carries. The legend is fixed; only the level moves. +export function appetitePromptBlock(appetite: RiskAppetite): string { + return [ + `RISK APPETITE (derived deterministically from the user's words — do NOT re-infer it): ${appetite.toUpperCase()}`, + " conservative = prefer wider bands and lower fees; neutral = balanced;", + " aggressive = tighter bands are acceptable when the extra fill volume justifies them.", + ].join("\n"); +} diff --git a/packages/arbitration-sdk/src/compose.test.ts b/packages/arbitration-sdk/src/compose.test.ts index ba537eb..dd921b5 100644 --- a/packages/arbitration-sdk/src/compose.test.ts +++ b/packages/arbitration-sdk/src/compose.test.ts @@ -17,6 +17,7 @@ import { type InferFn, } from "./compose.ts"; import { stubContext, TOKENS } from "./context.ts"; +import { bandTiers } from "./tiers.ts"; import { FALLBACK_SOURCE } from "./fallback.ts"; import type { Config } from "./config.ts"; import type { ZGBroker } from "./inference.ts"; @@ -176,3 +177,83 @@ test("the composer never mutates the request or context", async () => { assert.deepEqual(REQ, reqCopy); assert.deepEqual(CTX, ctxCopy); }); + +// ---- The Tier 0 "echo, don't compute" blocks (#24, #25, #26) -------------- +// +// Each block exists because the alternative is a 7B model deriving the value +// itself: classifying the user's risk wording, dividing a budget by a mid +// price across two decimal scales, or picking band widths from a volatility. +// These assert the derived values actually reach the prompt. + +const PAIR_REQ: RecommendationRequest = { + prompt: "market-make WETH/USDC, keep it safe", + budget: [ + { symbol: "WETH", address: TOKENS.WETH.address, amount: "2" }, + { symbol: "USDC", address: TOKENS.USDC.address, amount: "3000" }, + ], + maxStrategies: 3, + maxDeadlineSec: 604_800, +}; + +test("the prompt states the risk appetite read from the user's words", () => { + const [, user] = buildComposeMessages(PAIR_REQ, CTX); + assert.match(user.content, /RISK APPETITE .*: CONSERVATIVE/); + + const [, degen] = buildComposeMessages( + { ...PAIR_REQ, prompt: "max yield, I can stomach it" }, + CTX, + ); + assert.match(degen.content, /RISK APPETITE .*: AGGRESSIVE/); +}); + +test("the prompt carries pairing arithmetic the model would otherwise do itself", () => { + const [, user] = buildComposeMessages(PAIR_REQ, CTX); + // 3000 USDC binds against 2 WETH at mid 3450 → 0.869565 WETH, 1000 each. + assert.match(user.content, /REFERENCE PAIRING/); + assert.ok(user.content.includes("0.869565"), "value-matched total missing"); + assert.ok(user.content.includes("0.289855"), "per-strategy share missing"); +}); + +test("the prompt carries band tiers, with the appetite applied", () => { + const [, user] = buildComposeMessages(PAIR_REQ, CTX); + assert.match(user.content, /SUGGESTED BAND TIERS/); + for (const t of bandTiers( + CTX.pair.realizedVol7dPct, + PAIR_REQ.maxDeadlineSec, + "conservative", + )) { + assert.ok(user.content.includes(String(t.bandBps)), `${t.bandBps} missing`); + } +}); + +test("blocks are omitted, never faked, when their inputs are absent", () => { + // A single-token budget has no second side to pair against: no pairing block + // rather than an invented counterpart. (REQ is USDC-only.) + const [, single] = buildComposeMessages(REQ, CTX); + assert.ok(!single.content.includes("REFERENCE PAIRING")); + + // A request that cannot carry three strategies gets no tier block rather + // than a truncated one. + const [, oneShot] = buildComposeMessages({ ...PAIR_REQ, maxStrategies: 1 }, CTX); + assert.ok(!oneShot.content.includes("SUGGESTED BAND TIERS")); + // ...but the pairing block stays, now split for a single strategy. + assert.ok(oneShot.content.includes("REFERENCE PAIRING")); +}); + +test("the tier block never outlives its stub label", () => { + // Pair data is a stub end to end (F3 job 2). The tiers are derived from it, + // so they must inherit the label — the same rule contextPromptBlock follows. + const [, user] = buildComposeMessages(PAIR_REQ, CTX); + const tierBlock = user.content.slice(user.content.indexOf("SUGGESTED BAND TIERS")); + assert.match(tierBlock, /STUB/); +}); + +test("rejection feedback still lands after the new blocks", () => { + const [, user] = buildComposeMessages(PAIR_REQ, CTX, "I7: deadline is stale"); + assert.match(user.content, /PREVIOUS ATTEMPT WAS REJECTED/); + assert.ok( + user.content.indexOf("PREVIOUS ATTEMPT") > + user.content.indexOf("SUGGESTED BAND TIERS"), + "feedback must come last, closest to the model's turn", + ); +}); diff --git a/packages/arbitration-sdk/src/compose.ts b/packages/arbitration-sdk/src/compose.ts index a3b3342..562e5ac 100644 --- a/packages/arbitration-sdk/src/compose.ts +++ b/packages/arbitration-sdk/src/compose.ts @@ -18,6 +18,9 @@ import { inferChat, type ChatMessage, type ZGBroker } from "./inference.ts"; import type { InferResult } from "./proof.ts"; import { grammarPromptBlock } from "./grammar.ts"; import { contextPromptBlock, type MarketContext } from "./context.ts"; +import { appetitePromptBlock, classifyRiskAppetite } from "./appetite.ts"; +import { pairingPlan, pairingPromptBlock } from "./pairing.ts"; +import { bandTiers, tiersPromptBlock } from "./tiers.ts"; import { parseRecommendation, type ParseResult, @@ -58,9 +61,17 @@ const OUTPUT_SCHEMA = `Return ONLY a JSON object (no markdown fences, no prose), // differently at hour 30 than at hour 14, "we edited the prompt" is the most // likely answer — unanswerable unless the version is recorded. Version 1 was // the app-side six-section contract deleted in PR #30; this builder succeeds -// it. Bump on ANY change to the framing below, grammarPromptBlock() or -// contextPromptBlock(). -export const PROMPT_VERSION = "sluice.compose/2"; +// it. Bump on ANY change to the framing below, grammarPromptBlock(), +// contextPromptBlock(), or the appetite/pairing/tier blocks. +// +// /3 adds the Tier 0 "echo, don't compute" blocks: risk appetite (appetite.ts), +// reference pairing (pairing.ts) and band tiers (tiers.ts). +export const PROMPT_VERSION = "sluice.compose/3"; + +// How many band tiers a tiered recommendation carries — see tiers.ts. A request +// that allows fewer strategies than this gets no tier block at all rather than +// a truncated one. +const TIER_COUNT = 3; export function buildComposeMessages( req: RecommendationRequest, @@ -88,9 +99,33 @@ export function buildComposeMessages( const now = ctx.observedAt; const deadlineMax = now + req.maxDeadlineSec; + // Everything the model would otherwise have to DERIVE, derived here instead: + // what the user's words say about risk, the pairing arithmetic, and the band + // widths. Each block is omitted rather than faked when its inputs are absent + // — a budget that does not hold both sides of the pair gets no pairing block, + // and a request that cannot carry three strategies gets no tier block. + const appetite = classifyRiskAppetite(req.prompt); + const tiered = req.maxStrategies >= TIER_COUNT; + const pairing = pairingPlan(ctx, req, tiered ? TIER_COUNT : 1); + const tiers = tiered + ? tiersPromptBlock( + bandTiers(ctx.pair.realizedVol7dPct, req.maxDeadlineSec, appetite), + { + // Pair data (F3 job 2) is a stub for every context we can build + // today — contextPromptBlock states the same thing the same way. + // Both flip together when F3 Open Q2 settles the price source. + stubVol: true, + maxStrategies: req.maxStrategies, + hasPairing: pairing !== null, + }, + ) + : ""; + const user = [ `USER PROMPT: ${req.prompt}`, "", + appetitePromptBlock(appetite), + "", "BUDGET (a ceiling the user set — never exceed, per token):", budgetLines, "", @@ -104,6 +139,8 @@ export function buildComposeMessages( ` in (now, now + maxDeadlineSec] = (${now}, ${deadlineMax}]; use ${deadlineMax} unless a shorter one is intended.`, "", contextPromptBlock(ctx), + ...(pairing ? ["", pairingPromptBlock(pairing)] : []), + ...(tiers ? ["", tiers] : []), extra ? `\nPREVIOUS ATTEMPT WAS REJECTED — fix these and return valid JSON only:\n${extra}` : "", diff --git a/packages/arbitration-sdk/src/pairing.test.ts b/packages/arbitration-sdk/src/pairing.test.ts new file mode 100644 index 0000000..372c1ed --- /dev/null +++ b/packages/arbitration-sdk/src/pairing.test.ts @@ -0,0 +1,112 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { pairingPlan, pairingPromptBlock } from "./pairing.ts"; +import { stubContext, TOKENS, type MarketContext } from "./context.ts"; +import type { RecommendationRequest } from "./recommendation.ts"; + +const CTX = stubContext(); // WETH/USDC, mid 3450 +const WETH = TOKENS.WETH.address; +const USDC = TOKENS.USDC.address; + +function req( + budget: RecommendationRequest["budget"], + over: Partial = {}, +): RecommendationRequest { + return { + prompt: "market-make WETH/USDC", + budget, + maxStrategies: 3, + maxDeadlineSec: 604_800, + ...over, + }; +} + +const both = (weth: string, usdc: string) => [ + { symbol: "WETH", address: WETH, amount: weth }, + { symbol: "USDC", address: USDC, amount: usdc }, +]; + +test("the binding ceiling decides the size — USDC binds against a large WETH budget", () => { + // 2 WETH is 6900 USDC of value at mid 3450, but only 3000 USDC is offered. + const p = pairingPlan(CTX, req(both("2", "3000")), 3); + assert.ok(p); + assert.equal(p.binding, "USDC"); + // Legs come back in canonical ascending address order: WETH (0x42…) first. + assert.equal(p.legs[0].symbol, "WETH"); + assert.equal(p.legs[1].symbol, "USDC"); + // 3000 / 3450 = 0.869565…, truncated to 6 dp. + assert.equal(p.legs[0].total, "0.869565"); + assert.equal(p.legs[1].total, "3000"); +}); + +test("the other side binds when it is the scarce one", () => { + // 0.5 WETH is 1725 USDC of value; 10000 USDC is far more than needed. + const p = pairingPlan(CTX, req(both("0.5", "10000")), 3); + assert.ok(p); + assert.equal(p.binding, "WETH"); + assert.equal(p.legs[0].total, "0.5"); + assert.equal(p.legs[1].total, "1725"); +}); + +test("the shipped ratio is the mid, not the ratio of the two ceilings", () => { + const p = pairingPlan(CTX, req(both("2", "3000")), 1); + assert.ok(p); + // total USDC / total WETH must reproduce the mid (3000 / 0.869565 ≈ 3450). + const implied = Number(p.legs[1].total) / Number(p.legs[0].total); + assert.ok(Math.abs(implied - 3450) < 0.01, `implied ${implied}`); +}); + +test("per-strategy shares are truncated down, so N of them never exceed the budget (I2)", () => { + const p = pairingPlan(CTX, req(both("2", "3000")), 3); + assert.ok(p); + assert.equal(p.legs[1].perStrategy, "1000"); // 3000 / 3 + // 0.869565 / 3 = 0.289855 exactly at 6 dp; the sum must not exceed the total. + assert.equal(p.legs[0].perStrategy, "0.289855"); + const summed = 3 * Number(p.legs[0].perStrategy); + assert.ok(summed <= Number(p.legs[0].total), `${summed} > ${p.legs[0].total}`); +}); + +test("a share that does not divide evenly rounds DOWN, never up", () => { + // 1000 USDC across 3 strategies = 333.333333… — truncated, so 3 shares are + // strictly less than the ceiling. Rounding up would breach it. + const p = pairingPlan(CTX, req(both("1", "1000")), 3); + assert.ok(p); + assert.equal(p.legs[1].perStrategy, "333.333333"); + assert.ok(3 * Number(p.legs[1].perStrategy) < 1000); +}); + +test("no plan when the budget does not hold both sides of the pair", () => { + assert.equal( + pairingPlan(CTX, req([{ symbol: "USDC", address: USDC, amount: "1000" }]), 3), + null, + ); +}); + +test("no plan when the mid is unusable — never a fabricated price", () => { + const zeroMid: MarketContext = { ...CTX, pair: { ...CTX.pair, midPrice: 0 } }; + assert.equal(pairingPlan(zeroMid, req(both("2", "3000")), 3), null); + + const expMid: MarketContext = { ...CTX, pair: { ...CTX.pair, midPrice: 1e-7 } }; + assert.equal(pairingPlan(expMid, req(both("2", "3000")), 3), null); +}); + +test("arithmetic is exact, not floating point", () => { + // 0.1 + 0.1 + 0.1 is 0.30000000000000004 in IEEE-754. Three shares of a 0.3 + // ceiling must not exceed it. + const p = pairingPlan(CTX, req(both("0.3", "10000")), 3); + assert.ok(p); + assert.equal(p.legs[0].total, "0.3"); + assert.equal(p.legs[0].perStrategy, "0.1"); +}); + +test("the block tells the model to echo, and names the binding side", () => { + const block = pairingPromptBlock(pairingPlan(CTX, req(both("2", "3000")), 3)); + assert.ok(/ECHO these numbers/i.test(block), block); + assert.ok(block.includes("0.869565"), block); + assert.ok(block.includes("USDC is the binding ceiling"), block); + assert.ok(block.includes("per strategy"), block); +}); + +test("no plan renders as nothing at all, not as an empty heading", () => { + assert.equal(pairingPromptBlock(null), ""); +}); diff --git a/packages/arbitration-sdk/src/pairing.ts b/packages/arbitration-sdk/src/pairing.ts new file mode 100644 index 0000000..51b4f7c --- /dev/null +++ b/packages/arbitration-sdk/src/pairing.ts @@ -0,0 +1,154 @@ +// Value-matched pairing arithmetic, computed FOR the model (T0.2 / issue #25). +// +// The shipped virtualAmounts set both the price (their ratio) and the depth +// (their size) — grammar.ts says exactly that, and then the prompt leaves the +// model to divide a budget by a mid price across two different decimal scales. +// That is the arithmetic a 7B model quietly gets wrong, and a wrong ratio is +// not cosmetic: it ships a strategy priced off-mid, which is free money for the +// first taker. Nothing in the validator catches it today (a price-vs-mid +// invariant is Tier 2 work), so the cheapest fix is to not ask. +// +// Everything here is exact fixed-point on decimal strings. A float would shift +// the last digits of a number that ends up inside a signed artifact. + +import type { MarketContext } from "./context.ts"; +import type { RecommendationRequest, TokenBudget } from "./recommendation.ts"; + +// Internal working precision, and the precision we print at. Six fractional +// digits is exact for USDC (6 decimals) and a fine granularity for a WETH +// CEILING — and it keeps the model from emitting more digits than a token has, +// which the app otherwise has to truncate on the way back in. +const WORK_ONE = 10n ** 18n; +const DISPLAY_FRAC = 6; +const DISPLAY_ONE = 10n ** BigInt(DISPLAY_FRAC); + +const DECIMAL = /^\d+(\.\d+)?$/; + +/// Decimal string -> integer scaled by 1e18. Null if it is not a plain decimal. +function parseDec(s: string): bigint | null { + if (!DECIMAL.test(s)) return null; + const [whole, frac = ""] = s.split("."); + return BigInt(whole + (frac + "0".repeat(18)).slice(0, 18)); +} + +/// A JS number as a decimal string. Rejects the exponential forms (1e-7, 1e21) +/// rather than parsing them — midPrice is data we display and divide by, and a +/// silently mangled price is worse than no pairing block at all. +function numberToDec(n: number): string | null { + if (!Number.isFinite(n) || n <= 0) return null; + const s = String(n); + return DECIMAL.test(s) ? s : null; +} + +/// Scaled integer -> decimal string, TRUNCATED to DISPLAY_FRAC digits and with +/// trailing zeros trimmed. Truncation (never rounding) keeps a ceiling a +/// ceiling: dropping digits can only understate what the user offered. +function formatDec(v: bigint): string { + const scaled = v / (WORK_ONE / DISPLAY_ONE); + const whole = scaled / DISPLAY_ONE; + const frac = (scaled % DISPLAY_ONE).toString().padStart(DISPLAY_FRAC, "0").replace(/0+$/, ""); + return frac === "" ? whole.toString() : `${whole}.${frac}`; +} + +const mul = (a: bigint, b: bigint) => (a * b) / WORK_ONE; +const div = (a: bigint, b: bigint) => (a * WORK_ONE) / b; + +export type PairingLeg = { + symbol: string; + address: string; + /** The ceiling the user set, verbatim. */ + budget: string; + /** How much of it a mid-consistent pair can actually use, in total. */ + total: string; + /** That total divided by the number of strategies asked for. */ + perStrategy: string; +}; + +export type Pairing = { + /** token1 per token0, as a decimal string. */ + mid: string; + /** Both legs, in canonical ascending address order (matches I10). */ + legs: [PairingLeg, PairingLeg]; + /** The symbol whose ceiling binds the pair — the other is left partly unused. */ + binding: string; + strategies: number; +}; + +/** + * The largest mid-consistent pair that fits BOTH ceilings, and its per-strategy + * share. + * + * Whichever ceiling binds decides the size: 2 WETH against 3000 USDC at a mid + * of 3450 cannot use the whole 2 WETH, because 2 WETH is 6900 USDC of value + * and the user only offered 3000. Committing both ceilings in full would ship + * a price that is not the mid — which is exactly the mistake this exists to + * prevent. + * + * Returns null when there is nothing to compute: a budget that does not hold + * both sides of the context's pair, or an unusable mid. The prompt then simply + * omits the block rather than showing invented numbers. + */ +export function pairingPlan( + ctx: MarketContext, + req: RecommendationRequest, + strategies: number, +): Pairing | null { + const [sym0, sym1] = ctx.pair.pair.split("/").map((s) => s.trim()); + if (!sym0 || !sym1) return null; + + const find = (symbol: string): TokenBudget | undefined => + req.budget.find((b) => b.symbol.toUpperCase() === symbol.toUpperCase()); + const b0 = find(sym0); + const b1 = find(sym1); + if (!b0 || !b1) return null; + + const midStr = numberToDec(ctx.pair.midPrice); + if (!midStr) return null; + const mid = parseDec(midStr); + const cap0 = parseDec(b0.amount); + const cap1 = parseDec(b1.amount); + if (!mid || !cap0 || !cap1 || mid === 0n || cap0 === 0n || cap1 === 0n) return null; + if (strategies < 1) return null; + + // Value-match, then let the binding side decide the size. + const needed1 = mul(cap0, mid); // token1 required to pair ALL of token0 + const token0Binds = needed1 <= cap1; + const total0 = token0Binds ? cap0 : div(cap1, mid); + const total1 = token0Binds ? needed1 : cap1; + + // Truncating each share DOWN means N shares sum to at most the total, so the + // per-token sum across strategies stays inside the budget (I2) by + // construction rather than by the model's arithmetic. + const n = BigInt(strategies); + const leg = (b: TokenBudget, total: bigint): PairingLeg => ({ + symbol: b.symbol, + address: b.address, + budget: b.amount, + total: formatDec(total), + perStrategy: formatDec(total / n), + }); + + const legs: [PairingLeg, PairingLeg] = [leg(b0, total0), leg(b1, total1)]; + legs.sort((a, b) => (a.address.toLowerCase() < b.address.toLowerCase() ? -1 : 1)); + + return { + mid: midStr, + legs, + binding: token0Binds ? b0.symbol : b1.symbol, + strategies, + }; +} + +export function pairingPromptBlock(p: Pairing | null): string { + if (!p) return ""; + const [a, b] = p.legs; + const per = p.strategies > 1 ? ` | per strategy: ${a.perStrategy} + ${b.perStrategy}` : ""; + return [ + "REFERENCE PAIRING — computed for you. ECHO these numbers; do NOT do your own arithmetic:", + ` at mid ${p.mid}, the largest pair fitting both ceilings is ${a.total} ${a.symbol} + ${b.total} ${b.symbol}${per}`, + ` (${p.binding} is the binding ceiling; the other side is deliberately not fully used)`, + " The RATIO of a strategy's virtualAmounts IS its shipped price. Keep that ratio,", + " unless the user's own words ask for a price away from the mid — then say so by", + " changing the ratio deliberately, never by accident.", + ].join("\n"); +} diff --git a/packages/arbitration-sdk/src/tiers.test.ts b/packages/arbitration-sdk/src/tiers.test.ts new file mode 100644 index 0000000..e7aeb90 --- /dev/null +++ b/packages/arbitration-sdk/src/tiers.test.ts @@ -0,0 +1,144 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { bandTiers, tiersPromptBlock } from "./tiers.ts"; +import { FEE_BPS_ONE } from "./opcodes.ts"; + +const WEEK = 7 * 24 * 60 * 60; +const YEAR = 365 * 24 * 60 * 60; +const VOL = 58; // the stub pair's realised volatility, ANNUALISED percent + +test("three tiers, strictly decreasing wide -> tight", () => { + const t = bandTiers(VOL, WEEK, "neutral"); + assert.deepEqual( + t.map((x) => x.tier), + ["wide", "mid", "tight"], + ); + assert.ok(t[0].bandBps > t[1].bandBps, JSON.stringify(t)); + assert.ok(t[1].bandBps > t[2].bandBps, JSON.stringify(t)); +}); + +test("every bandBps is an encodable integer in (0, FEE_BPS_ONE)", () => { + for (const appetite of ["conservative", "neutral", "aggressive"] as const) { + for (const vol of [0, 0.5, 58, 500, 100_000]) { + for (const horizon of [60, 3600, WEEK, 30 * WEEK]) { + for (const t of bandTiers(vol, horizon, appetite)) { + assert.ok(Number.isInteger(t.bandBps), `${t.bandBps}`); + assert.ok(t.bandBps > 0 && t.bandBps < FEE_BPS_ONE, `${t.bandBps}`); + } + } + } + } +}); + +test("appetite shifts the set tighter without changing the count", () => { + // Below the clamp ceiling, every tier tightens strictly with appetite — the + // "suggest 5/7/9 instead of 3/5/7" behaviour, expressed in band width. + const conservative = bandTiers(5, WEEK, "conservative"); + const neutral = bandTiers(5, WEEK, "neutral"); + const aggressive = bandTiers(5, WEEK, "aggressive"); + + assert.equal(aggressive.length, 3); + for (let i = 0; i < 3; i++) { + assert.ok(conservative[i].bandBps > neutral[i].bandBps, `tier ${i}`); + assert.ok(neutral[i].bandBps > aggressive[i].bandBps, `tier ${i}`); + } +}); + +test("appetite never LOOSENS a tier, even where the clamp flattens it", () => { + // Under a volatility high enough to pin the widest bands to the encodable + // ceiling they come out equal rather than ordered. That is honest — nothing + // is wider than "wider than any move" — but appetite must never hand a + // conservative user a TIGHTER band than a degen one. + const c = bandTiers(400, WEEK, "conservative"); + const n = bandTiers(400, WEEK, "neutral"); + const a = bandTiers(400, WEEK, "aggressive"); + for (let i = 0; i < 3; i++) { + assert.ok(c[i].bandBps >= n[i].bandBps, `tier ${i}`); + assert.ok(n[i].bandBps >= a[i].bandBps, `tier ${i}`); + } + assert.equal(c[0].bandBps, n[0].bandBps); // both clamped — pinned deliberately +}); + +test("a shorter horizon gives tighter bands — vol is scaled by sqrt(time)", () => { + const week = bandTiers(VOL, WEEK, "neutral"); + const day = bandTiers(VOL, 86_400, "neutral"); + for (let i = 0; i < 3; i++) assert.ok(day[i].bandBps < week[i].bandBps); +}); + +test("the neutral mid tier is the horizon-scaled volatility itself", () => { + // 58% annualised over one week is 58 * sqrt(7/365) ≈ 8.03%, and the neutral + // mid multiplier is 1 — so the mid tier IS the move the pair is expected to + // make over the strategy's life. Sane market-making bands; the un-scaled + // reading would put this at ±58%. + const [, mid] = bandTiers(VOL, WEEK, "neutral"); + const expectedPct = VOL * Math.sqrt(WEEK / YEAR); + assert.equal(mid.bandBps, Math.round((expectedPct / 100) * FEE_BPS_ONE)); + assert.ok(Number(mid.movePct) > 7 && Number(mid.movePct) < 9, mid.movePct); +}); + +test("the stub pair produces bands a market maker would actually ship", () => { + // Guards the calibration decision in tiers.ts: if this starts asserting + // ±87%/±99% again, the annualised reading has been lost and the tiers are + // full-range in all but name. + for (const t of bandTiers(VOL, WEEK, "neutral")) { + assert.ok(Number(t.movePct) < 25, `${t.tier} at ±${t.movePct}% is not a band`); + } +}); + +test("degenerate volatility still yields three usable, distinct tiers", () => { + for (const vol of [0, -5, Number.NaN]) { + const t = bandTiers(vol, WEEK, "neutral"); + assert.ok(t[0].bandBps > t[1].bandBps && t[1].bandBps > t[2].bandBps, `${vol}`); + assert.ok(t[2].bandBps > 0, `${vol}`); + } +}); + +test("extreme volatility clamps below the encodable ceiling and stays ordered", () => { + const t = bandTiers(100_000, 30 * WEEK, "conservative"); + assert.ok(t[0].bandBps < FEE_BPS_ONE); + assert.ok(t[0].bandBps > t[1].bandBps && t[1].bandBps > t[2].bandBps); +}); + +test("movePct is derived from bandBps, so label and number cannot disagree", () => { + for (const t of bandTiers(VOL, WEEK, "aggressive")) { + assert.equal(t.movePct, ((t.bandBps / FEE_BPS_ONE) * 100).toFixed(2)); + } +}); + +test("the block carries the integers, the stub label, and no yield claim", () => { + const block = tiersPromptBlock(bandTiers(VOL, WEEK, "neutral"), { + stubVol: true, + maxStrategies: 3, + hasPairing: true, + }); + for (const t of bandTiers(VOL, WEEK, "neutral")) { + assert.ok(block.includes(String(t.bandBps)), `${t.bandBps} missing`); + } + assert.ok(block.includes("STUB"), block); + assert.ok(/do not invent one/i.test(block), block); + assert.ok(/three strategies/i.test(block), block); + // Mechanics, never yield: no projected-return language may appear here. + // ("return" alone is the JSON instruction verb, not a yield claim.) + assert.ok(!/\bAPR\b|\bexpected (return|yield)\b/i.test(block), block); +}); + +test("the stub label disappears once the volatility is real", () => { + const block = tiersPromptBlock(bandTiers(VOL, WEEK, "neutral"), { + stubVol: false, + maxStrategies: 3, + hasPairing: true, + }); + assert.ok(!block.includes("STUB"), block); +}); + +test("with no pairing block, the task says DIVIDE instead of pointing at a block that is not there", () => { + const block = tiersPromptBlock(bandTiers(VOL, WEEK, "neutral"), { + stubVol: true, + maxStrategies: 3, + hasPairing: false, + }); + // A dangling "take the amounts from REFERENCE PAIRING" is an invitation to + // invent one when that block was omitted. + assert.ok(!block.includes("REFERENCE PAIRING"), block); + assert.ok(/DIVIDE the budget/.test(block), block); +}); diff --git a/packages/arbitration-sdk/src/tiers.ts b/packages/arbitration-sdk/src/tiers.ts new file mode 100644 index 0000000..60e69d8 --- /dev/null +++ b/packages/arbitration-sdk/src/tiers.ts @@ -0,0 +1,127 @@ +// Band tiers: one pair, three widths (T0.3 / issue #26). +// +// The product wants risk-tiered recommendations. The full version ranks +// different PAIRS by historical yield, which needs market data we do not have +// yet (F3 Open Q2). But on a single pair the risk axis already exists: band +// width. A tighter XYC_CONCENTRATE_GROW_LIQUIDITY_2D band quotes deeper — more +// fill volume for the same commitment — and is exhausted by a smaller price +// move; a wider band is safer and earns less. grammar.ts states exactly that +// trade-off, and `banded` is the one shape with sustained fills on real Base. +// +// So three tiers ship with zero new data: same pair, same budget, three widths, +// computed from realised volatility and handed to the model as integers to +// echo. +// +// Labels describe MECHANICS ("exhausted by a ±X% move"), never yield: we have +// no fee-APR source yet, and a projected return we cannot back is exactly the +// kind of claim this project refuses to make. Rating the risk of the resulting +// recommendation is Gate 2's job (F2 §4, deferred) — this only sizes bands. + +import type { RiskAppetite } from "./appetite.ts"; +import { FEE_BPS_ONE } from "./opcodes.ts"; + +export type BandTier = { + tier: "wide" | "mid" | "tight"; + /** Out of FEE_BPS_ONE (1e9), integer — the value the model echoes. */ + bandBps: number; + /** Derived FROM bandBps, so the label can never disagree with the number. */ + movePct: string; +}; + +const YEAR_SEC = 365 * 24 * 60 * 60; + +// CALIBRATION — a real decision, stated because the field name alone does not +// settle it. `realizedVol7dPct` is read as an ANNUALISED percentage measured +// over a 7-day sample (the standard convention for quoted volatility), not as +// "the price moved 58% during that week". Two reasons: 58 is the canonical +// annualised figure for ETH, and the other reading produces ±87%–±99% bands — +// full-range in all but name, which is not a market-making recommendation. +// They differ by ~3.5x, so this must be confirmed when F3 Open Q2 lands a real +// volatility source (the field may want renaming then); the multipliers below +// are the knob if it turns out otherwise. + +// Multiples of the horizon-scaled volatility, per tier. Appetite shifts the SET, +// never the count: a risk-inclined user gets three tighter choices, not more +// choices. (This is the "suggest 5/7/9 instead of 3/5/7" behaviour, expressed +// in band width — the only risk axis this venue actually gives us.) +const MULTIPLIERS: Record = { + conservative: [3, 1.5, 0.75], + neutral: [2, 1, 0.5], + aggressive: [1, 0.5, 0.25], +}; + +// A vol floor so a zero/absent volatility still produces three distinct, usable +// bands rather than three zeros, and a ceiling below 100% because a band at or +// past FEE_BPS_ONE is not encodable. +const MIN_VOL_PCT = 0.1; +const MAX_BAND_PCT = 99; + +const pctToBps = (pct: number) => Math.round((pct / 100) * FEE_BPS_ONE); +const bpsToPct = (bps: number) => ((bps / FEE_BPS_ONE) * 100).toFixed(2); + +/** + * Three band widths for one pair, wide → tight. + * + * Volatility is quoted annualised and a strategy lives for `horizonSec`, so it + * is scaled by sqrt(time) before use: a band that makes sense for a year is far + * too wide for a week. The result is clamped into the encodable range and forced + * strictly decreasing — under an extreme volatility all three would otherwise + * pin to the ceiling, and three identical "tiers" would be a lie the UI would + * happily render. + */ +export function bandTiers( + realizedVol7dPct: number, + horizonSec: number, + appetite: RiskAppetite, +): BandTier[] { + const vol = Number.isFinite(realizedVol7dPct) ? Math.max(realizedVol7dPct, 0) : 0; + const horizon = Math.max(horizonSec, 1); + const scaled = Math.max(vol * Math.sqrt(horizon / YEAR_SEC), MIN_VOL_PCT); + + const names: BandTier["tier"][] = ["wide", "mid", "tight"]; + const bps = MULTIPLIERS[appetite].map((m) => + Math.min(Math.max(pctToBps(Math.min(scaled * m, MAX_BAND_PCT)), 1), FEE_BPS_ONE - 1), + ); + // Strictly decreasing, wide → tight. Only bites at the clamp ceiling. + for (let i = 1; i < bps.length; i++) { + bps[i] = Math.min(bps[i], bps[i - 1] - 1); + } + + return names.map((tier, i) => ({ + tier, + bandBps: bps[i], + movePct: bpsToPct(bps[i]), + })); +} + +export function tiersPromptBlock( + tiers: BandTier[], + opts: { stubVol: boolean; maxStrategies: number; hasPairing: boolean }, +): string { + const rows = tiers.map( + (t) => + ` ${t.tier.padEnd(5)} bandBps ${String(t.bandBps).padStart(10)} — inventory is exhausted by a ±${t.movePct}% move`, + ); + const source = opts.stubVol + ? "the pair's realised volatility (STUB pair data — F3 job 2 / Open Q2, not live)" + : "the pair's realised volatility"; + + return [ + `SUGGESTED BAND TIERS — derived from ${source}, scaled to this request's`, + "deadline and shifted for the stated risk appetite. Integers; echo them:", + ...rows, + "A tighter band quotes DEEPER for the same commitment (more fill volume) and is", + "exhausted by a smaller price move. That is the whole trade-off — there is no", + "yield estimate here, so do not invent one.", + "", + `TASK — because maxStrategies is ${opts.maxStrategies}: unless the user asked for one`, + "specific shape, return exactly three strategies on this pair, one per tier above,", + "using the `banded` or `banded-fee` template with that tier's bandBps.", + // Never point at a block that was not rendered: with no pairing plan there + // is no per-strategy line to copy, and a dangling reference is an invitation + // to invent one. + opts.hasPairing + ? "Take each strategy's amounts from REFERENCE PAIRING's per-strategy line — do not divide anything yourself." + : "DIVIDE the budget between the three — the per-token total across all of them must stay within the ceiling, so do not give each strategy the full amount.", + ].join("\n"); +}