/** * Decomposition output types — the structured result the StrategyDecomposer emits after reasoning * over a free-text strategy idea. * * Critical design choices: * * 1. EVERY emitted leaf carries a `status: "verified" | "missing" | "needs_research"`. The * decomposer is forbidden to emit a leaf without calling `verify_policy_id` first. After * parsing the agent's response, a post-check downgrades the overall `simulationReady` flag * to false if ANY leaf isn't `verified`. This is the "100% accuracy" rule the user requested. * * 2. Topology is explicit: `homeChain` (where shares are minted, must be a Lucidly factory chain) * is distinct from `positionChains` (where capital lives, can be ANY EVM chain with bridge * support + leaves). `bridgeEdges` captures the cross-chain capital flows so the UI + simulator * can show "step 2: bridge USDC mainnet → HyperEVM via CCIP". * * 3. The shape is designed to flow directly into the SimulatorClient.simulatePlan() input: each * verified leaf has the {target, calldata, value} fields the simulator needs (calldata being * optional at this stage — encoded after the user provides params). * * 4. Confidence is a 0..1 score with a documented breakdown so the UI can render a confidence * bar and tell the user *why* it's not 1.0 (e.g., "two leaves marked needs_research"). */ import { z } from "zod"; // ─── Atomic types ──────────────────────────────────────────────────────────── export const LeafStatusSchema = z.enum(["verified", "missing", "needs_research"]); export type LeafStatus = z.infer; /** Single Merkle-gated leaf the decomposer has emitted, with its verification status. */ export const DecomposedLeafSchema = z.object({ /** Bytes32 hex policy id from the live registry. ONLY set when status === "verified". */ policyId: z.string().regex(/^0x[a-fA-F0-9]{64}$/, "policyId must be a 32-byte hex").nullable(), /** Protocol name — "Morpho Blue", "Aave V3", "Pendle", "CCIP", "HL CoreWriter", etc. */ protocol: z.string().min(1), /** Function signature, e.g. "supply((address,address,address,address,uint256),uint256,uint256,address,bytes)". */ selector: z.string().min(1), /** Target contract address. ONLY set when status === "verified". */ target: z.string().regex(/^0x[a-fA-F0-9]{40}$/).nullable(), /** Decoder/sanitizer address from the policy (the boring-vault manager invokes this). */ decoderAndSanitizer: z.string().regex(/^0x[a-fA-F0-9]{40}$/).nullable().optional(), /** Status of THIS leaf: * - verified → confirmed deployed on the vault's policy registry via verify_policy_id * - missing → the protocol exists in the catalog but THIS vault doesn't have it whitelisted * - needs_research → the agent couldn't confirm at all (catalog miss + onchain inconclusive) */ status: LeafStatusSchema, /** Human-readable rationale for why this leaf is in the plan. */ rationale: z.string().min(1), /** Optional pre-encoded calldata (hex) — set when params are bound and the leaf is sim-ready. */ calldata: z.string().regex(/^0x[a-fA-F0-9]*$/).nullable().optional(), /** Optional native value attached to the call (hex). Default "0x0". */ value: z.string().regex(/^0x[a-fA-F0-9]+$/).nullable().optional(), }); export type DecomposedLeaf = z.infer; /** Bridge edge — a cross-chain capital flow the decomposer identified. */ export const DecomposedBridgeEdgeSchema = z.object({ fromChain: z.number().int().positive(), toChain: z.number().int().positive(), /** Bridge rail — CCTP, CCIP, LZ (LayerZero), HL_CORE_BRIDGE, NATIVE, etc. */ rail: z.string().min(1), /** Asset symbol or address being bridged. */ asset: z.string().min(1), /** The actual leaves that initiate + receive the bridge. Receiver may not need a leaf * (CCIP onMessageReceived is unsolicited), in which case this lists only the sender leaf. */ senderLeaves: z.array(DecomposedLeafSchema), /** Estimated end-to-end finality window for the bridge ("instant", "~12min", "~10-15min"). */ finality: z.string(), rationale: z.string().min(1), }); export type DecomposedBridgeEdge = z.infer; /** A leg = a contiguous sequence of leaves on a single chain that share a purpose. */ export const DecomposedLegSchema = z.object({ /** EVM chain id where this leg executes. */ chain: z.number().int().positive(), /** Display name — "ethereum", "base", "hyperevm", etc. */ chainName: z.string().min(1), /** "onchain" = sent through the boring vault manager; "api" = venue API call (HL CoreWriter, etc., which is technically onchain on its own L1 but conceptually a venue action). */ kind: z.enum(["onchain", "api"]), /** open_position | rebalance | close_position | emergency_unwind | fund_margin | etc. */ purpose: z.string().min(1), /** Ordered leaves that execute on this leg. */ leaves: z.array(DecomposedLeafSchema).min(1), rationale: z.string().min(1), }); export type DecomposedLeg = z.infer; // ─── Top-level result ──────────────────────────────────────────────────────── export const DecompositionConfidenceBreakdownSchema = z.object({ /** 0..1 — how well the user's idea matches a known archetype. */ archetypeMatch: z.number().min(0).max(1), /** 0..1 — what fraction of emitted leaves are verified vs missing/needs_research. */ leafCoverage: z.number().min(0).max(1), /** 0..1 — for cross-chain strategies, whether ALL bridge rails are confirmed available. 1.0 for single-chain. */ bridgeCoverage: z.number().min(0).max(1), }); export type DecompositionConfidenceBreakdown = z.infer; export const DecompositionResultSchema = z.object({ /** Schema version. Bump on breaking shape changes. */ apiVersion: z.literal("1"), /** Caller-supplied request id for audit-log correlation (does NOT affect decomposition). */ requestId: z.string().nullable(), /** Echo of the original prompt — the agent might rewrite slightly for clarity, but the source text stays. */ originalPrompt: z.string(), /** Stable kebab-case id the decomposer invented for this plan. Lets the UI cache + cite it. */ planId: z.string().min(1), /** Pretty title the decomposer suggested. */ displayName: z.string().min(1), /** Family bucket — leveraged_carry | lp_hedge | yield_basket | delta_neutral | other. * "other" is allowed because the user's vision is "any possible strategy" — not just the 4. */ archetype: z.enum(["leveraged_carry", "lp_hedge", "yield_basket", "delta_neutral", "other"]), /** Closest corpus strategy id the decomposer drew from, or null if "other" with no near match. */ nearestCorpusRef: z.string().nullable(), /** Plain-English summary of what the strategy does + why it works. */ summary: z.string().min(20), /** Where shares are minted — must be a Lucidly factory chain [1, 8453, 42161, 10]. */ homeChain: z.number().int().refine((c) => [1, 8453, 42161, 10].includes(c), { message: "homeChain must be a Lucidly factory chain (1, 8453, 42161, or 10)", }), /** Every chain where capital lives. Can include non-factory chains. */ positionChains: z.array(z.number().int().positive()).min(1), /** Cross-chain capital flows. Empty for single-chain strategies. */ bridgeEdges: z.array(DecomposedBridgeEdgeSchema), /** Per-chain leg sequences. */ legs: z.array(DecomposedLegSchema).min(1), /** Confidence in this decomposition — overall and broken down. */ confidence: z.number().min(0).max(1), confidenceBreakdown: DecompositionConfidenceBreakdownSchema, /** Warnings the user should see — leaves marked missing, bridge rails not yet whitelisted, etc. */ warnings: z.array(z.string()), /** TRUE iff every emitted leaf is "verified" AND every bridge has a confirmed sender leaf. * Post-check enforced at parse time — the agent can't lie. */ simulationReady: z.boolean(), /** When simulationReady === false, list of concrete actions the user (or vault owner) can take. */ ifMissing: z.array(z.object({ kind: z.enum(["request_policy", "enable_existing_policy", "research_protocol", "alternative_route"]), detail: z.string().min(1), suggestedLink: z.string().nullable().optional(), })), /** Risk register the agent surfaced. Same shape as the corpus. */ riskFactors: z.record(z.string(), z.string()), /** Citations — which corpus entries + research tools the decomposer used. */ citations: z.array(z.object({ source: z.enum(["corpus", "policy_registry", "blockchain_read", "web_search", "llm_knowledge"]), detail: z.string(), })), /** When the decomposition was produced. */ generatedAt: z.string(), }); export type DecompositionResult = z.infer; // ─── Helpers ──────────────────────────────────────────────────────────────── /** Compute the post-parse simulationReady + confidenceBreakdown from a candidate result. * Used both inside the decomposer (to overwrite the agent's claimed values with verified ones) * and by callers who want to re-check a result after editing it. */ export function recomputeReadiness(result: DecompositionResult): { simulationReady: boolean; confidence: number; confidenceBreakdown: DecompositionConfidenceBreakdown; } { const allLeaves = result.legs.flatMap((l) => l.leaves); const verifiedCount = allLeaves.filter((l) => l.status === "verified").length; const leafCoverage = allLeaves.length === 0 ? 0 : verifiedCount / allLeaves.length; // Bridge coverage: every edge has at least one sender leaf, and ALL sender leaves are verified. let bridgeCoverage = 1.0; if (result.bridgeEdges.length > 0) { let edgesGood = 0; for (const edge of result.bridgeEdges) { if (edge.senderLeaves.length === 0) continue; const allSendersVerified = edge.senderLeaves.every((l) => l.status === "verified"); if (allSendersVerified) edgesGood++; } bridgeCoverage = edgesGood / result.bridgeEdges.length; } // Archetype match — the agent's own confidence on this is kept (it's the only signal we have). const archetypeMatch = result.confidenceBreakdown?.archetypeMatch ?? 0.5; const breakdown: DecompositionConfidenceBreakdown = { archetypeMatch, leafCoverage, bridgeCoverage, }; // Overall confidence: weighted blend. Leaf coverage carries the most weight because it's the // hard constraint — without verified leaves the plan literally cannot execute. const confidence = 0.25 * archetypeMatch + 0.55 * leafCoverage + 0.20 * bridgeCoverage; const simulationReady = leafCoverage === 1.0 && bridgeCoverage === 1.0; return { simulationReady, confidence, confidenceBreakdown: breakdown }; } /** Lint a DecompositionResult: returns a list of warning strings that the agent missed. * Doesn't throw — just surfaces what the post-parse check found. */ export function lintDecomposition(result: DecompositionResult): string[] { const warnings: string[] = []; // Every cross-chain strategy must declare bridgeEdges const homeChain = result.homeChain; const otherChains = result.positionChains.filter((c) => c !== homeChain); if (otherChains.length > 0 && result.bridgeEdges.length === 0) { warnings.push( `position chains include ${otherChains.join(", ")} beyond homeChain ${homeChain}, but no bridgeEdges declared — capital cannot reach those chains without a bridge`, ); } // Every positionChain must appear in legs OR as a bridge endpoint for (const pc of result.positionChains) { const hasLeg = result.legs.some((l) => l.chain === pc); const hasBridge = result.bridgeEdges.some((e) => e.fromChain === pc || e.toChain === pc); if (!hasLeg && !hasBridge) { warnings.push(`position chain ${pc} declared but no legs or bridge edges target it`); } } // Every non-verified leaf must surface in `ifMissing` so the user knows what to do const allLeaves = result.legs.flatMap((l) => l.leaves); const nonVerified = allLeaves.filter((l) => l.status !== "verified"); if (nonVerified.length > 0 && result.ifMissing.length === 0) { warnings.push(`${nonVerified.length} leaf(s) not verified but ifMissing is empty — agent didn't suggest a remediation path`); } return warnings; }