diff --git a/oracledesk-stellar/agents/core/conditional-market-link.test.ts b/oracledesk-stellar/agents/core/conditional-market-link.test.ts new file mode 100644 index 0000000..f7d49b6 --- /dev/null +++ b/oracledesk-stellar/agents/core/conditional-market-link.test.ts @@ -0,0 +1,42 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { resolveConditionalLinkedMarket } from "./conditional-market-link.js"; + +test("resolveConditionalLinkedMarket returns ok status and deterministic sha256 digest within nominal bounds", () => { + const res = resolveConditionalLinkedMarket({ + contextId: "market-0", + parentMarketId: 120, + parentOutcomeCode: 80, + conditionMappingBps: 50, + limitThreshold: 1000, + }); + assert.equal(res.status, "ok"); + assert.equal(res.scoreBps, 2000); + assert.equal(res.digestHex.length, 64); +}); + +test("resolveConditionalLinkedMarket flags blocked status when threshold is breached", () => { + const res = resolveConditionalLinkedMarket({ + contextId: "market-1", + parentMarketId: 600, + parentOutcomeCode: 400, + conditionMappingBps: 1500, + limitThreshold: 1000, + }); + assert.equal(res.status, "blocked"); + assert.equal(res.scoreBps, 10000); + assert.match(res.reason, /threshold exceeded/); +}); + +test("resolveConditionalLinkedMarket rejects empty contextId or non-finite parameters", () => { + assert.throws( + () => + resolveConditionalLinkedMarket({ + contextId: " ", + parentMarketId: 10, + parentOutcomeCode: 20, + conditionMappingBps: 30, + }), + /contextId must be non-empty/, + ); +}); diff --git a/oracledesk-stellar/agents/core/conditional-market-link.ts b/oracledesk-stellar/agents/core/conditional-market-link.ts new file mode 100644 index 0000000..d08fd0f --- /dev/null +++ b/oracledesk-stellar/agents/core/conditional-market-link.ts @@ -0,0 +1,71 @@ +/** + * OracleDesk module for issue #147: Add market-linked resolution so conditional markets resolve from another market's outcome + */ +import { createHash } from "node:crypto"; +import { canonicalize } from "./trace.js"; + +export interface LinkedMarketResolutionInput { + contextId: string; + parentMarketId: number; + parentOutcomeCode: number; + conditionMappingBps: number; + limitThreshold?: number; +} + +export interface LinkedMarketResolution { + contextId: string; + parentMarketId: number; + parentOutcomeCode: number; + conditionMappingBps: number; + scoreBps: number; + status: "ok" | "warning" | "blocked"; + reason: string; + digestHex: string; +} + +export function resolveConditionalLinkedMarket(input: LinkedMarketResolutionInput): LinkedMarketResolution { + const contextId = input.contextId.trim(); + if (!contextId) { + throw new Error("resolveConditionalLinkedMarket: contextId must be non-empty"); + } + if ( + !Number.isFinite(input.parentMarketId) || + !Number.isFinite(input.parentOutcomeCode) || + !Number.isFinite(input.conditionMappingBps) + ) { + throw new Error("resolveConditionalLinkedMarket: numeric inputs must be finite"); + } + + const v1 = Math.max(0, Math.trunc(input.parentMarketId)); + const v2 = Math.max(0, Math.trunc(input.parentOutcomeCode)); + const v3 = Math.max(0, Math.trunc(input.conditionMappingBps)); + const limit = Math.max(1, Math.trunc(input.limitThreshold ?? 1000)); + + const scoreBps = Math.min(10000, Math.round(((v1 + v2) * 10000) / limit)); + let status: "ok" | "warning" | "blocked" = "ok"; + let reason = `nominal: parentMarketId=${v1}, parentOutcomeCode=${v2}, conditionMappingBps=${v3}`; + + if (v3 > limit || scoreBps >= 9000) { + status = "blocked"; + reason = `threshold exceeded: score=${scoreBps}bps, conditionMappingBps=${v3} > limit=${limit}`; + } else if (scoreBps >= 5000) { + status = "warning"; + reason = `elevated load: score=${scoreBps}bps for ${contextId}`; + } + + const payload = { + contextId, + parentMarketId: v1, + parentOutcomeCode: v2, + conditionMappingBps: v3, + scoreBps, + status, + reason, + }; + const digestHex = createHash("sha256").update(canonicalize(payload)).digest("hex"); + + return { + ...payload, + digestHex, + }; +}