Skip to content
Merged
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
2 changes: 1 addition & 1 deletion packages/arbitration-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
61 changes: 61 additions & 0 deletions packages/arbitration-sdk/src/appetite.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
90 changes: 90 additions & 0 deletions packages/arbitration-sdk/src/appetite.ts
Original file line number Diff line number Diff line change
@@ -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");
}
81 changes: 81 additions & 0 deletions packages/arbitration-sdk/src/compose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
);
});
43 changes: 40 additions & 3 deletions packages/arbitration-sdk/src/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
"",
Expand All @@ -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}`
: "",
Expand Down
Loading