From cab351877d0ad3d82088ef42150d21221756bd8a Mon Sep 17 00:00:00 2001 From: JayClaw Date: Sat, 28 Mar 2026 18:43:07 +0300 Subject: [PATCH 1/2] fix: add recommendedAction to oe_matchup_edge package B strategy signal-guided rate was ~6.7% because oe_matchup_edge did not include recommendedAction field. League runner reads this field and falls back to neutral/random when null. Adding oe_matchup_edge to the existing confidence/recommendedAction/signalDrivers block should raise signal-guided rate to ~90%+. --- src/seller/analytics/oeSignals.ts | 633 ++++++++++++++++++++++++++++++ 1 file changed, 633 insertions(+) create mode 100644 src/seller/analytics/oeSignals.ts diff --git a/src/seller/analytics/oeSignals.ts b/src/seller/analytics/oeSignals.ts new file mode 100644 index 0000000..0b90deb --- /dev/null +++ b/src/seller/analytics/oeSignals.ts @@ -0,0 +1,633 @@ +import { createPublicClient, http, parseAbi, fallback } from "viem"; +import { base } from "viem/chains"; +import dotenv from "dotenv"; + +dotenv.config(); + +type AgentStats = { + agent: string; + roundsAsCreator: number; + roundsAsJoiner: number; + creatorOddCount: number; + creatorEvenCount: number; + joinGuessOddCount: number; + joinGuessEvenCount: number; + creatorRevealSuccess: number; + creatorRevealTimeout: number; + byOpponent: Record< + string, + { creatorOdd: number; creatorEven: number; joinOdd: number; joinEven: number; total: number } + >; + recentCreatorParity: Array<"ODD" | "EVEN">; +}; + +type PackageId = + | "oe_bias_basic" + | "oe_bias_delta" + | "oe_regime_watch" + | "oe_matchup_edge" + | "oe_reveal_reliability" + | "oe_signal_pro" + | "oe_action_reco" + | "oe_meta_adapt" + | "oe_risk_guard" + | "oe_full_dossier" + | "oe_alpha_feed_24h"; + +const GAME_ABI = parseAbi([ + "function nextRoundId() view returns (uint256)", + "function rounds(uint256) view returns (address,bytes32,uint256,address,uint8,uint8,uint64,uint64)", + "event RoundSettled(uint256 indexed roundId, address indexed winner, uint256 payout, uint256 fee)", + "event RoundTimeoutSettled(uint256 indexed roundId, address indexed joiner, uint256 payout, uint256 fee)", +]); + +const ZERO = "0x0000000000000000000000000000000000000000"; + +const cache: { at: number; key: string; payload: any } = { at: 0, key: "", payload: null }; + +// Incremental log cache — avoids re-scanning old blocks on every warm cycle. +const _logCache: { + settledLogs: any[]; + timeoutLogs: any[]; + scannedToBlock: bigint; +} = { settledLogs: [], timeoutLogs: [], scannedToBlock: 0n }; + +// In-flight promise deduplication: if a scan is already running, subsequent +// callers wait for the same promise instead of starting a parallel scan. +const _inflight: Map> = new Map(); + +// -- Cache warming -------------------------------------------------------- +// Proactively refreshes the stats cache every ~45 s so that when a signal +// job arrives the data is already hot and delivery finishes well within the +// ~180 s ACP transaction window. +let _warmingInterval: ReturnType | null = null; + +export async function warmSignalCache(): Promise { + const contractAddress = (process.env.CONTRACT_ADDRESS ?? "") as `0x${string}`; + const rpcUrl = process.env.RPC_URL ?? process.env.BASE_RPC ?? "https://mainnet.base.org"; + if (!contractAddress) { + console.warn("[oeSignals] CONTRACT_ADDRESS not set — cannot warm cache"); + return; + } + try { + console.log("[oeSignals] Warming signal cache..."); + await scanStats({ contractAddress, rpcUrl, maxRounds: 700 }); + console.log("[oeSignals] Signal cache warmed."); + } catch (err) { + console.error("[oeSignals] Cache warm failed:", err); + } +} + +export function startSignalCacheWarmer(intervalMs = 45_000): void { + if (_warmingInterval) return; // already running + // Fire immediately (non-blocking), then on interval + warmSignalCache().catch(() => {}); + _warmingInterval = setInterval(() => { + warmSignalCache().catch(() => {}); + }, intervalMs); + _warmingInterval.unref?.(); // don't keep the process alive for this alone + console.log(`[oeSignals] Cache warmer started (interval=${intervalMs}ms).`); +} + +function norm(a?: string): string { + return String(a || "").toLowerCase(); +} + +function initStats(agent: string): AgentStats { + return { + agent, + roundsAsCreator: 0, + roundsAsJoiner: 0, + creatorOddCount: 0, + creatorEvenCount: 0, + joinGuessOddCount: 0, + joinGuessEvenCount: 0, + creatorRevealSuccess: 0, + creatorRevealTimeout: 0, + byOpponent: {}, + recentCreatorParity: [], + }; +} + +function ratio(n: number, d: number): number { + if (!d) return 0.5; + return n / d; +} + +function confidence(samples: number, skew: number): number { + // samples drive confidence up; extreme skew with low sample is penalized + const sampleScore = Math.min(0.92, 0.28 + Math.log2(Math.max(2, samples)) * 0.11); + const skewPenalty = samples < 15 ? Math.min(0.12, Math.abs(skew - 0.5) * 0.25) : 0; + const c = Math.max(0.15, Math.min(0.95, sampleScore - skewPenalty)); + return Number(c.toFixed(3)); +} + +function actionFromOddRate(oddRate: number, high = 0.52, low = 0.48): "ODD" | "EVEN" | "NO_EDGE" { + if (oddRate >= high) return "ODD"; + if (oddRate <= low) return "EVEN"; + return "NO_EDGE"; +} + +function actionFromDelta(delta: number, threshold = 0.04): "ODD" | "EVEN" | "NO_EDGE" { + if (delta >= threshold) return "ODD"; + if (delta <= -threshold) return "EVEN"; + return "NO_EDGE"; +} + +function getRecentStreak(parities: Array<"ODD" | "EVEN">, lookback = 5) { + const recent = parities.slice(-lookback); + const odd = recent.filter((x) => x === "ODD").length; + const even = recent.length - odd; + const tail = recent.slice(-3); + const tailAllSame = tail.length >= 3 && tail.every((x) => x === tail[0]); + + let action: "ODD" | "EVEN" | "NO_EDGE" = "NO_EDGE"; + if (recent.length >= 4) { + if (odd >= 3) + action = "ODD"; // 완화: 4→3 (5개 중 3개) + else if (even >= 3) action = "EVEN"; + } + if (action === "NO_EDGE" && tailAllSame) action = tail[0]; + + return { + lookback: recent.length, + odd, + even, + tail: recent, + action, + confidence: recent.length ? Number((Math.max(odd, even) / recent.length).toFixed(3)) : 0, + }; +} + +function majorityAction(candidates: Array<"ODD" | "EVEN" | "NO_EDGE">): "ODD" | "EVEN" | "NO_EDGE" { + const odd = candidates.filter((x) => x === "ODD").length; + const even = candidates.filter((x) => x === "EVEN").length; + if (odd >= 2 && odd > even) return "ODD"; + if (even >= 2 && even > odd) return "EVEN"; + return "NO_EDGE"; +} + +function inferCreatorParity(joinGuessIsOdd: boolean, joinerWon: boolean): "ODD" | "EVEN" { + if (joinerWon) return joinGuessIsOdd ? "ODD" : "EVEN"; + return joinGuessIsOdd ? "EVEN" : "ODD"; +} + +async function scanStats({ + contractAddress, + rpcUrl, + maxRounds = 600, +}: { + contractAddress: `0x${string}`; + rpcUrl: string; + maxRounds?: number; +}) { + const key = `${contractAddress}:${maxRounds}`; + const now = Date.now(); + if (cache.payload && cache.key === key && now - cache.at < 60_000) { + return cache.payload; + } + + // If a scan for this key is already in-flight, wait for it instead of starting a parallel one. + if (_inflight.has(key)) { + return _inflight.get(key)!; + } + + const scanPromise = _doScanStats({ contractAddress, rpcUrl, maxRounds, key }); + _inflight.set(key, scanPromise); + scanPromise.finally(() => _inflight.delete(key)); + return scanPromise; +} + +async function _doScanStats({ + contractAddress, + rpcUrl, + maxRounds = 600, + key, +}: { + contractAddress: `0x${string}`; + rpcUrl: string; + maxRounds?: number; + key: string; +}) { + const now = Date.now(); + + const fallbackList = ( + process.env.RPC_FALLBACKS ?? "https://base-rpc.publicnode.com,https://1rpc.io/base" + ) + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + const rpcCandidates = Array.from(new Set([rpcUrl, ...fallbackList])); + const transport = + rpcCandidates.length === 1 + ? http(rpcCandidates[0], { timeout: 10_000, retryCount: 2, retryDelay: 250 }) + : fallback( + rpcCandidates.map((url) => http(url, { timeout: 10_000, retryCount: 1, retryDelay: 250 })) + ); + + const client = createPublicClient({ chain: base, transport }); + const nextId = (await client.readContract({ + address: contractAddress, + abi: GAME_ABI, + functionName: "nextRoundId", + })) as bigint; + + const end = Number(nextId) - 1; + const start = Math.max(0, end - maxRounds + 1); + + const latestBlock = await client.getBlockNumber(); + const fromEnv = process.env.OE_SIGNAL_FROM_BLOCK + ? BigInt(process.env.OE_SIGNAL_FROM_BLOCK) + : latestBlock > 250000n + ? latestBlock - 250000n + : 0n; + const CHUNK = 9000n; + + async function getLogsWithRetry(eventAbi: any, from: bigint, to: bigint, attempts = 3) { + let lastErr: any; + for (let i = 1; i <= attempts; i++) { + try { + return await client.getLogs({ + address: contractAddress, + event: eventAbi, + fromBlock: from, + toBlock: to, + }); + } catch (err) { + lastErr = err; + if (i < attempts) { + const waitMs = 250 * i; + await new Promise((r) => setTimeout(r, waitMs)); + } + } + } + throw lastErr; + } + + async function getLogsChunked( + eventName: "RoundSettled" | "RoundTimeoutSettled", + fromBlock: bigint + ) { + const eventAbi = GAME_ABI.find((x: any) => x.type === "event" && x.name === eventName) as any; + const all: any[] = []; + for (let from = fromBlock; from <= latestBlock; from += CHUNK) { + const to = from + CHUNK - 1n > latestBlock ? latestBlock : from + CHUNK - 1n; + const part = await getLogsWithRetry(eventAbi, from, to, 3); + all.push(...(part as any[])); + } + return all; + } + + // Incremental log scan: only fetch blocks we haven't seen yet. + const incrementalFrom = _logCache.scannedToBlock > 0n ? _logCache.scannedToBlock + 1n : fromEnv; + const newSettled = await getLogsChunked("RoundSettled", incrementalFrom); + const newTimeout = await getLogsChunked("RoundTimeoutSettled", incrementalFrom); + _logCache.settledLogs.push(...newSettled); + _logCache.timeoutLogs.push(...newTimeout); + _logCache.scannedToBlock = latestBlock; + const settledLogs = _logCache.settledLogs; + const timeoutLogs = _logCache.timeoutLogs; + + const settledWinnerByRound = new Map(); + for (const l of settledLogs as any[]) { + const rid = Number(l.args?.roundId); + const winner = norm(l.args?.winner); + if (Number.isFinite(rid) && winner) settledWinnerByRound.set(rid, winner); + } + const timeoutByRound = new Set(); + for (const l of timeoutLogs as any[]) { + const rid = Number(l.args?.roundId); + if (Number.isFinite(rid)) timeoutByRound.add(rid); + } + + const byAgent = new Map(); + + // Fetch all round data in parallel via multicall batches (50 rounds per call) + const MCALL_BATCH = 50; + const allRoundData = new Map< + number, + readonly [string, string, bigint, string, number, number, bigint, bigint] + >(); + for (let batchStart = start; batchStart <= end; batchStart += MCALL_BATCH) { + const batchEnd = Math.min(batchStart + MCALL_BATCH - 1, end); + const calls = []; + for (let i = batchStart; i <= batchEnd; i++) { + calls.push({ + address: contractAddress, + abi: GAME_ABI, + functionName: "rounds" as const, + args: [BigInt(i)] as const, + }); + } + try { + const results = await client.multicall({ contracts: calls, allowFailure: true }); + for (let k = 0; k < results.length; k++) { + const res = results[k]; + if (res.status === "success") { + allRoundData.set(batchStart + k, res.result as any); + } + } + } catch { + // fall back to sequential on multicall failure + for (let i = batchStart; i <= batchEnd; i++) { + try { + const r = (await client.readContract({ + address: contractAddress, + abi: GAME_ABI, + functionName: "rounds", + args: [BigInt(i)], + })) as any; + allRoundData.set(i, r); + } catch { + /* skip */ + } + } + } + } + + for (let i = start; i <= end; i++) { + const r = allRoundData.get(i); + if (!r) continue; + + const creator = norm(r[0]); + const joiner = norm(r[3]); + const guess = Number(r[4]); // 0 ODD, 1 EVEN + const state = Number(r[5]); // 2 SETTLED expected + + if (!creator || creator === norm(ZERO)) continue; + + if (!byAgent.has(creator)) byAgent.set(creator, initStats(creator)); + const c = byAgent.get(creator)!; + c.roundsAsCreator += 1; + + if (joiner && joiner !== norm(ZERO)) { + if (!byAgent.has(joiner)) byAgent.set(joiner, initStats(joiner)); + const j = byAgent.get(joiner)!; + j.roundsAsJoiner += 1; + if (guess === 0) j.joinGuessOddCount += 1; + else j.joinGuessEvenCount += 1; + + const opp = j.byOpponent[creator] || { + creatorOdd: 0, + creatorEven: 0, + joinOdd: 0, + joinEven: 0, + total: 0, + }; + if (guess === 0) opp.joinOdd += 1; + else opp.joinEven += 1; + opp.total += 1; + j.byOpponent[creator] = opp; + } + + if (state === 2 && joiner && joiner !== norm(ZERO)) { + const timeout = timeoutByRound.has(i); + if (timeout) { + c.creatorRevealTimeout += 1; + } else { + c.creatorRevealSuccess += 1; + } + + const winner = settledWinnerByRound.get(i); + if (winner) { + const joinerWon = winner === joiner; + const creatorParity = inferCreatorParity(guess === 0, joinerWon); + if (creatorParity === "ODD") c.creatorOddCount += 1; + else c.creatorEvenCount += 1; + c.recentCreatorParity.push(creatorParity); + + const cOpp = c.byOpponent[joiner] || { + creatorOdd: 0, + creatorEven: 0, + joinOdd: 0, + joinEven: 0, + total: 0, + }; + if (creatorParity === "ODD") cOpp.creatorOdd += 1; + else cOpp.creatorEven += 1; + cOpp.total += 1; + c.byOpponent[joiner] = cOpp; + } + } + } + + const payload = { + byAgent, + scannedRounds: end >= start ? end - start + 1 : 0, + updatedAt: new Date().toISOString(), + }; + cache.at = now; + cache.key = key; + cache.payload = payload; + return payload; +} + +export async function buildSignalPackage({ + packageId, + targetAgent, + opponentAgent, + nRecent = 40, +}: { + packageId: PackageId; + targetAgent: string; + opponentAgent?: string; + nRecent?: number; +}) { + const contractAddress = (process.env.CONTRACT_ADDRESS ?? "") as `0x${string}`; + const rpcUrl = process.env.RPC_URL ?? process.env.BASE_RPC ?? "https://mainnet.base.org"; + if (!contractAddress) throw new Error("CONTRACT_ADDRESS not set"); + + const statsPayload = await scanStats({ contractAddress, rpcUrl, maxRounds: 700 }); + const s = statsPayload.byAgent.get(norm(targetAgent)) || initStats(norm(targetAgent)); + + const creatorSamples = s.creatorOddCount + s.creatorEvenCount; + const creatorOddRate = ratio(s.creatorOddCount, creatorSamples); + const creatorEvenRate = 1 - creatorOddRate; + + const recent = s.recentCreatorParity.slice(-Math.max(10, nRecent)); + const older = s.recentCreatorParity.slice(-Math.max(10, nRecent * 2), -Math.max(10, nRecent)); + const recentOdd = recent.filter((x: "ODD" | "EVEN") => x === "ODD").length; + const olderOdd = older.filter((x: "ODD" | "EVEN") => x === "ODD").length; + const recentOddRate = ratio(recentOdd, recent.length); + const olderOddRate = ratio(olderOdd, older.length); + const regimeDelta = Number((recentOddRate - olderOddRate).toFixed(3)); + + const revealTotal = s.creatorRevealSuccess + s.creatorRevealTimeout; + const revealReliability = ratio(s.creatorRevealSuccess, revealTotal); + + const oppStats = opponentAgent ? s.byOpponent[norm(opponentAgent)] : undefined; + const matchupOddRate = oppStats + ? ratio(oppStats.creatorOdd, oppStats.creatorOdd + oppStats.creatorEven) + : null; + const matchupShift = oppStats ? Number((matchupOddRate! - creatorOddRate).toFixed(3)) : null; + + const conf = confidence(creatorSamples, creatorOddRate); + const recentStreak = getRecentStreak(s.recentCreatorParity, 5); + // 2026-03-27: Lowered thresholds to reduce no_edge fallback rate + // Previous: 0.52/0.48 bias, 0.08 regime delta → ~49 no_edge per 319 rounds (26%) + // New: 0.505/0.495 bias, 0.04 regime delta → target ~10% no_edge + const biasAction = actionFromOddRate(creatorOddRate, 0.505, 0.495); + const regimeAction = actionFromDelta(regimeDelta, 0.04); + const matchupAction = + matchupOddRate === null ? "NO_EDGE" : actionFromOddRate(matchupOddRate, 0.505, 0.495); + const action = + majorityAction([biasAction, regimeAction, matchupAction, recentStreak.action]) !== "NO_EDGE" + ? majorityAction([biasAction, regimeAction, matchupAction, recentStreak.action]) + : biasAction !== "NO_EDGE" + ? biasAction + : recentStreak.action !== "NO_EDGE" + ? recentStreak.action + : regimeAction !== "NO_EDGE" + ? regimeAction + : matchupAction; + + const packageSignal = + packageId === "oe_bias_basic" + ? biasAction + : packageId === "oe_bias_delta" + ? regimeAction !== "NO_EDGE" + ? regimeAction + : recentStreak.action + : packageId === "oe_regime_watch" + ? recentStreak.action !== "NO_EDGE" + ? recentStreak.action + : regimeAction + : packageId === "oe_matchup_edge" + ? matchupAction !== "NO_EDGE" + ? matchupAction + : biasAction + : action; + + const base = { + packageId, + updatedAt: statsPayload.updatedAt, + targetAgent: norm(targetAgent), + sampleSize: creatorSamples, + biasScore: { + odd: Number(creatorOddRate.toFixed(3)), + even: Number(creatorEvenRate.toFixed(3)), + edge: Number(Math.abs(creatorOddRate - 0.5).toFixed(3)), + }, + } as any; + + if ( + [ + "oe_bias_delta", + "oe_regime_watch", + "oe_signal_pro", + "oe_action_reco", + "oe_meta_adapt", + "oe_risk_guard", + "oe_full_dossier", + "oe_alpha_feed_24h", + ].includes(packageId) + ) { + base.regime = { + recentWindow: recent.length, + recentOddRate: Number(recentOddRate.toFixed(3)), + priorOddRate: Number(olderOddRate.toFixed(3)), + delta: regimeDelta, + state: regimeDelta > 0.12 ? "SHIFT_TO_ODD" : regimeDelta < -0.12 ? "SHIFT_TO_EVEN" : "STABLE", + }; + base.recentStreak = recentStreak; + } + + if ( + [ + "oe_matchup_edge", + "oe_signal_pro", + "oe_action_reco", + "oe_meta_adapt", + "oe_risk_guard", + "oe_full_dossier", + "oe_alpha_feed_24h", + ].includes(packageId) + ) { + base.matchup = { + opponent: opponentAgent ? norm(opponentAgent) : null, + shift: matchupShift, + oddRateVsOpponent: matchupOddRate !== null ? Number(matchupOddRate!.toFixed(3)) : null, + note: opponentAgent + ? matchupShift === null + ? "insufficient matchup sample" + : "computed" + : "opponent not provided", + }; + } + + if ( + [ + "oe_reveal_reliability", + "oe_signal_pro", + "oe_action_reco", + "oe_meta_adapt", + "oe_risk_guard", + "oe_full_dossier", + "oe_alpha_feed_24h", + ].includes(packageId) + ) { + base.revealReliability = { + score: Number(revealReliability.toFixed(3)), + success: s.creatorRevealSuccess, + timeout: s.creatorRevealTimeout, + risk: revealReliability < 0.65 ? "HIGH" : revealReliability < 0.82 ? "MEDIUM" : "LOW", + }; + } + + base.signal = packageSignal; + + if ( + [ + "oe_matchup_edge", + "oe_signal_pro", + "oe_action_reco", + "oe_meta_adapt", + "oe_risk_guard", + "oe_full_dossier", + "oe_alpha_feed_24h", + ].includes(packageId) + ) { + base.confidence = conf; + base.recommendedAction = action; + base.signalDrivers = { + biasAction, + regimeAction, + matchupAction, + streakAction: recentStreak.action, + }; + } + + if (["oe_risk_guard", "oe_full_dossier", "oe_alpha_feed_24h"].includes(packageId)) { + base.riskGuard = { + overfitWarning: creatorSamples < 20, + lowLiquidityWarning: creatorSamples < 12, + noTradeZone: packageSignal === "NO_EDGE" || conf < 0.52, + }; + } + + if (["oe_full_dossier", "oe_alpha_feed_24h"].includes(packageId)) { + base.dossier = { + roundsAsCreator: s.roundsAsCreator, + roundsAsJoiner: s.roundsAsJoiner, + joinGuessBias: { + odd: Number( + ratio(s.joinGuessOddCount, s.joinGuessOddCount + s.joinGuessEvenCount).toFixed(3) + ), + even: Number( + (1 - ratio(s.joinGuessOddCount, s.joinGuessOddCount + s.joinGuessEvenCount)).toFixed(3) + ), + }, + scannedRounds: statsPayload.scannedRounds, + }; + } + + if (packageId === "oe_alpha_feed_24h") { + base.subscription = { + windowHours: 24, + mode: "snapshot", + note: "MVP mode: returns latest computed snapshot. Stream mode can be added later.", + }; + } + + return base; +} From 651e8b377dc63a22136db6608d46c806d69ff8b4 Mon Sep 17 00:00:00 2001 From: JayClaw Date: Sat, 28 Mar 2026 18:51:54 +0300 Subject: [PATCH 2/2] fix: unwrap signal envelope in fetchSignal + bootstrap adaptive mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fetchSignal was reading recommendedAction from top-level deliverable instead of deliverable.value (signal envelope). Now unwraps properly. - Adaptive joiner/creator modes were using pre-fix rounds (where signal was always null) to calculate accuracy → always 50% → mode=random. Now filters to rounds with actual signal data (rawAction non-null) and forces align/follow until 12+ real signal rounds accumulate. --- scripts/ab-league-continuous.mjs | 892 +++++++++++++++++++++++++++++++ 1 file changed, 892 insertions(+) create mode 100644 scripts/ab-league-continuous.mjs diff --git a/scripts/ab-league-continuous.mjs b/scripts/ab-league-continuous.mjs new file mode 100644 index 0000000..abae274 --- /dev/null +++ b/scripts/ab-league-continuous.mjs @@ -0,0 +1,892 @@ +/** + * ab-league-continuous.mjs + * Continuous A/B league runner for Odds or Evens. + * + * Correct field names (from seller offering.json schemas): + * create_round: { number: integer, tier: "S"|"M"|"L" } + * join_round: { roundId: string, guess: "ODD"|"EVEN" } + * reveal_round: { roundId: string } + * oe_action_reco: { targetAgent: string, opponentAgent: string } + * + * Group A (Baseline): random guess, no signal call. + * Group B (Signal): calls oe_action_reco first, uses recommendedAction. + * + * Queue-based: 1 creator + 1 joiner per round. No deadlock. + * Saves results to data/ab-league-results.json. + * Runs forever until SIGTERM/SIGINT. + */ + +import fs from "fs"; +import { randomInt } from "crypto"; +import { createWalletClient, createPublicClient, http, parseAbi, parseUnits, formatUnits } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { base } from "viem/chains"; +import dotenv from "dotenv"; +dotenv.config(); + +// Ensure /acp suffix is always present regardless of env var format +const _acp_raw = process.env.ACP_API_URL || "https://claw-api.virtuals.io"; +const ACP_BASE = _acp_raw.endsWith("/acp") ? _acp_raw : `${_acp_raw}/acp`; +const PROVIDER = process.env.ACP_PROVIDER_WALLET || "0xB213021c4fDaaB4307ab4B9D3817868e60B27FD2"; +const RESULTS_FILE = "./data/ab-league-results.json"; + +// Stake tier: XS = 0.01 USDC, S = 10 USDC, M = 50 USDC, L = 100 USDC +// Default to XS for low-cost V2C A/B execution. +const STAKE_TIER = process.env.STAKE_TIER || "XS"; + +// Account 3 buyer agents +const AGENTS = [ + { name: "MoltP20", wallet: "0xCfa74dA852459A84772ce1c158388b610C81cA85", apiKey: "acp-f7ee6ceef6f6760090d7" }, + { name: "Nova Pulse", wallet: "0xD6000DE1215965e6B241Ca22159dD8b18142C7aa", apiKey: "acp-cede5f74539d84488fdf" }, + { name: "KaiBot", wallet: "0x28578287cd74a1D5d7A64B4BbE20071Fd3bFC5a7", apiKey: "acp-5b9f640a33bf0703480b" }, +]; +// Zenith Loop: inject via env if available +if (process.env.ZENITH_API_KEY) { + AGENTS.push({ name: "Zenith Loop", wallet: "0xfF031E145ce0DbaE9F0B58A28D7dBaF8aF504f86", apiKey: process.env.ZENITH_API_KEY }); + console.log("[league] Zenith Loop added with injected API key."); +} + +const sleep = (ms) => new Promise(r => setTimeout(r, ms)); + +// ── On-chain fund management ────────────────────────────────────────────────── +const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +const TREASURY_ADDRESS = "0x39CE6c16C1Db05D904F504c7e7AeFD7eEC790F67"; +const REFILL_THRESHOLD = parseUnits("0.1", 6); // 0.1 USDC +const REFILL_AMOUNT = parseUnits((process.env.REFILL_AMOUNT_USDC || "1"), 6); // default 1 USDC per agent for XS league +const ERC20_ABI = parseAbi([ + "function transfer(address to, uint256 amount) returns (bool)", + "function balanceOf(address) view returns (uint256)" +]); + +// Lazy-init on-chain clients (require DEPLOYER_PRIVATE_KEY in env) +let _publicClient = null; +let _walletClient = null; + +function getOnchainClients() { + if (!process.env.DEPLOYER_PRIVATE_KEY) throw new Error("DEPLOYER_PRIVATE_KEY not set in .env"); + if (!_publicClient) { + // Use fallback RPCs to avoid rate limits on mainnet.base.org + const fallbacks = (process.env.RPC_FALLBACKS || "").split(",").map(s => s.trim()).filter(Boolean); + const rpcList = [process.env.BASE_RPC || "https://mainnet.base.org", ...fallbacks]; + // Pick a random one from the list to spread load + const rpc = rpcList[Math.floor(Math.random() * rpcList.length)]; + console.log(`[funds] Using RPC: ${rpc}`); + _publicClient = createPublicClient({ chain: base, transport: http(rpc) }); + const deployer = privateKeyToAccount(process.env.DEPLOYER_PRIVATE_KEY); + _walletClient = createWalletClient({ account: deployer, chain: base, transport: http(rpc) }); + console.log(`[funds] On-chain clients ready. Deployer=${deployer.address}`); + } + return { publicClient: _publicClient, walletClient: _walletClient }; +} + +async function getUsdcBalance(publicClient, address) { + return publicClient.readContract({ address: USDC_ADDRESS, abi: ERC20_ABI, functionName: "balanceOf", args: [address] }); +} + +/** + * checkAndRefillFunds — called before each round. + * + * If ANY agent is below REFILL_THRESHOLD (0.1 USDC default): + * 1. Each agent calls collect_to_treasury ACP job → USDC flows via ACP payment to treasury + * 2. Treasury redistributes REFILL_AMOUNT (1 USDC default, env-overridable) equally to all agents + * + * Note: collect_to_treasury moves funds via ACP payment flow (AA wallet → contract → treasury). + * The treasury→agent direction is done via direct on-chain transfer (deployer key). + */ +async function checkAndRefillFunds(agents) { + let clients; + try { clients = getOnchainClients(); } catch (e) { + console.log(`[funds] Skipping fund check: ${e.message}`); + return; + } + const { publicClient, walletClient } = clients; + + // Check all agent balances + const balances = await Promise.all(agents.map(a => getUsdcBalance(publicClient, a.wallet))); + const needsRefill = balances.some(b => b < REFILL_THRESHOLD); + + if (!needsRefill) { + const summary = agents.map((a, i) => `${a.name}=${formatUnits(balances[i], 6)}`).join(", "); + console.log(`[funds] Balances OK: ${summary}`); + return; + } + + console.log(`[funds] ⚠️ Low balance detected — initiating collect-then-redistribute`); + console.log(`[funds] Step 1: Collect all agent funds to treasury via collect_to_treasury ACP job`); + + // Step 1: Each agent with meaningful balance calls collect_to_treasury job + for (let i = 0; i < agents.length; i++) { + const agent = agents[i]; + const bal = balances[i]; + const balUsdc = Number(formatUnits(bal, 6)); + if (balUsdc < 1) { + console.log(`[funds] ${agent.name} balance too low to collect (${balUsdc} USDC), skipping`); + continue; + } + // Collect all but 0.5 USDC (leave for gas/fee buffer) + const collectAmount = Math.max(0, balUsdc - 0.5).toFixed(2); + if (Number(collectAmount) < 0.5) { + console.log(`[funds] ${agent.name} nothing significant to collect`); + continue; + } + console.log(`[funds] ${agent.name} collecting ${collectAmount} USDC to treasury via ACP job...`); + try { + await callJob(agent.apiKey, "collect_to_treasury", { amount_usdc: Number(collectAmount) }, 180000); + const newBal = await getUsdcBalance(publicClient, agent.wallet); + console.log(`[funds] ✅ ${agent.name} collected → balance now ${formatUnits(newBal, 6)} USDC`); + } catch (e) { + console.error(`[funds] ❌ collect_to_treasury failed for ${agent.name}: ${e.message}. Skipping.`); + } + } + + console.log(`[funds] Step 2: Redistributing ${formatUnits(REFILL_AMOUNT, 6)} USDC to each agent from treasury`); + + // Step 2: Treasury distributes equally to all agents + const treasuryBalance = await getUsdcBalance(publicClient, TREASURY_ADDRESS); + const totalNeeded = REFILL_AMOUNT * BigInt(agents.length); + console.log(`[funds] Treasury: ${formatUnits(treasuryBalance, 6)} USDC, need: ${formatUnits(totalNeeded, 6)} USDC for ${agents.length} agents`); + + if (treasuryBalance < totalNeeded) { + console.log(`[funds] ❌ Treasury insufficient for full redistribution. Distributing proportionally.`); + // Distribute what we can equally + const perAgent = treasuryBalance / BigInt(agents.length); + if (perAgent < parseUnits("1", 6)) { + console.log(`[funds] ❌ Treasury too low even for proportional distribution. Aborting.`); + return; + } + for (const agent of agents) { + try { + const hash = await walletClient.writeContract({ address: USDC_ADDRESS, abi: ERC20_ABI, functionName: "transfer", args: [agent.wallet, perAgent] }); + await publicClient.waitForTransactionReceipt({ hash }); + console.log(`[funds] ✅ ${agent.name} received ${formatUnits(perAgent, 6)} USDC (tx=${hash})`); + } catch (e) { + console.error(`[funds] ❌ Transfer failed for ${agent.name}: ${e.message}`); + } + } + return; + } + + for (const agent of agents) { + try { + const hash = await walletClient.writeContract({ address: USDC_ADDRESS, abi: ERC20_ABI, functionName: "transfer", args: [agent.wallet, REFILL_AMOUNT] }); + await publicClient.waitForTransactionReceipt({ hash }); + const newBal = await getUsdcBalance(publicClient, agent.wallet); + console.log(`[funds] ✅ ${agent.name} redistributed → ${formatUnits(newBal, 6)} USDC (tx=${hash})`); + } catch (e) { + console.error(`[funds] ❌ Redistribution failed for ${agent.name}: ${e.message}`); + } + } +} + +function normalizeResults(raw = {}) { + const rounds = Array.isArray(raw.rounds) ? raw.rounds : []; + const baseStats = raw.stats || raw.summary || {}; + return { + ...raw, + rounds, + stats: { + A: { wins: Number(baseStats?.A?.wins || 0), losses: Number(baseStats?.A?.losses || 0) }, + B: { wins: Number(baseStats?.B?.wins || 0), losses: Number(baseStats?.B?.losses || 0) }, + }, + startedAt: raw.startedAt || new Date().toISOString(), + }; +} + +function loadResults() { + try { return normalizeResults(JSON.parse(fs.readFileSync(RESULTS_FILE, "utf8"))); } + catch { + return normalizeResults({}); + } +} + +function saveResults(data) { + fs.mkdirSync("./data", { recursive: true }); + const normalized = normalizeResults(data); + normalized.summary = normalized.stats; // keep KPI readers that still expect summary working + fs.writeFileSync(RESULTS_FILE, JSON.stringify(normalized, null, 2)); +} + +async function jfetch(url, opts = {}) { + const r = await fetch(url, opts); + const txt = await r.text(); + let body; + try { body = JSON.parse(txt); } catch { body = { raw: txt }; } + return { ok: r.ok, status: r.status, body }; +} + +async function callJob(apiKey, offering, requirements, timeoutMs = 180000) { + const created = await jfetch(`${ACP_BASE}/jobs`, { + method: "POST", + headers: { "x-api-key": apiKey, "content-type": "application/json" }, + body: JSON.stringify({ + providerWalletAddress: PROVIDER, + jobOfferingName: offering, + serviceRequirements: requirements + }) + }); + if (!created.ok) throw new Error(`create_job[${offering}] failed (${created.status}): ${JSON.stringify(created.body).slice(0,300)}`); + // API may return data.id or data.jobId + const jobId = created.body?.data?.id ?? created.body?.data?.jobId; + if (!jobId) throw new Error(`no jobId from ${offering}: ${JSON.stringify(created.body).slice(0,300)}`); + + console.log(`[job] ${offering} → jobId=${jobId}`); + + // Poll for completion + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + await sleep(3000); + const s = await jfetch(`${ACP_BASE}/jobs/${jobId}`, { headers: { "x-api-key": apiKey } }); + const phase = String(s.body?.data?.phase ?? ""); + const errors = Array.isArray(s.body?.errors) ? s.body.errors : []; + if (errors.length > 0) { + const errText = errors.join(" | "); + if (/insufficient balance/i.test(errText)) { + throw new Error(`job ${jobId} payment blocker: ${errText}`); + } + console.log(`[job] ${offering}/${jobId} api-errors=${errText}`); + } + // Phase: REQUEST, NEGOTIATION, TRANSACTION, EVALUATION, COMPLETED, REJECTED, EXPIRED + const phaseLower = phase.toLowerCase(); + if (["completed","delivered","evaluation","4"].some(p => phaseLower.includes(p))) { + return { jobId, result: s.body?.data }; + } + if (["failed","rejected","cancelled","expired","5","6","7","8","9"].some(p => phaseLower.includes(p))) { + throw new Error(`job ${jobId} terminal state: phase=${phase}`); + } + console.log(`[job] ${offering}/${jobId} phase=${phase} (${Math.round((Date.now()-start)/1000)}s elapsed)`); + } + throw new Error(`job ${offering}/${jobId} timeout after ${timeoutMs}ms`); +} + +function parseDeliverable(result) { + // deliverable may be string JSON or object + const raw = result?.deliverable ?? result?.data?.deliverable ?? result?.serviceRequirements ?? ""; + if (!raw) return {}; + if (typeof raw === "object") return raw; + try { return JSON.parse(raw); } catch { return { raw }; } +} + +/** + * Group B — Role-aware ranked signal chain. + * + * We previously rotated a single package globally. That kept queue load low, + * but it also mixed weak packages into both roles and dropped straight to + * random on the first NO_EDGE/error. The current strategy keeps the runner + * sequential while retrying through a short ranked chain before fallback. + */ + +function extractAction(d) { + const v = d?.value ?? d; + const raw = v?.recommendedAction || v?.action || v?.recommendation || v?.signal || v?.direction || ""; + const s = String(raw).toUpperCase(); + if (s.includes("ODD")) return "ODD"; + if (s.includes("EVEN")) return "EVEN"; + return null; +} + +function extractSignalMeta(d) { + const v = d?.value ?? d ?? {}; + const confidence = Number( + v?.confidence ?? v?.score ?? v?.strength ?? v?.meta?.confidence ?? NaN + ); + // 2026-03-28 fix: prefer matchup.shift for oe_matchup_edge (was using global biasScore.edge) + const matchupShift = Number(v?.matchup?.shift ?? NaN); + const edge = Number( + Number.isFinite(matchupShift) ? Math.abs(matchupShift) + : (v?.edge ?? v?.biasScore?.edge ?? v?.meta?.edge ?? NaN) + ); + return { + confidence: Number.isFinite(confidence) ? confidence : null, + edge: Number.isFinite(edge) ? edge : null, + }; +} + +async function fetchSignal(callerApiKey, offering, params, timeout = 180000) { + try { + const { result } = await callJob(callerApiKey, offering, params, timeout); + const deliverable = parseDeliverable(result); + // unwrap { type, value } envelope used by signal offerings + const _v = deliverable?.value ?? deliverable; + const rawAction = _v?.recommendedAction || _v?.action || _v?.recommendation || _v?.signal || _v?.direction || null; + const meta = extractSignalMeta(deliverable); + return { + action: extractAction(deliverable), + rawAction: rawAction ? String(rawAction).toUpperCase() : null, + deliverable, + confidence: meta.confidence, + edge: meta.edge, + error: null, + }; + } catch (e) { + return { + action: null, + rawAction: null, + deliverable: null, + confidence: null, + edge: null, + error: String(e?.message || e), + }; + } +} + +const CREATOR_SIGNAL_CHAIN = [ + { name: "oe_matchup_edge", needsOpponent: true }, + { name: "oe_action_reco", needsOpponent: true }, + { name: "oe_bias_delta", needsOpponent: false }, // 3rd tier: allowed only if confidence >= 0.6 +]; + +// 2026-03-28 러시안블루 분석: +// oe_matchup_edge counter 56.0% (n=134) → 유일하게 유의미한 신호 +// oe_action_reco counter 52.6% (n=57) → 동전 던지기 수준, confidence 항상 >0.7로 gate 무효 +// oe_action_reco를 chain에서 제거하면 fallback으로 빠지는데, +// fallback-random(47.2%)보다 oe_action_reco(52.6%)가 아직 약간 나으므로 +// 2nd tier로 유지하되, 향후 추가 데이터로 재평가 +const JOINER_SIGNAL_CHAIN = [ + { name: "oe_matchup_edge", needsOpponent: true }, + { name: "oe_action_reco", needsOpponent: true }, // marginal: 52.6% counter, 55.6% actual B-win +]; + +async function getRankedSignal(callerApiKey, callerName, targetWallet, opponentWallet, chain, acceptSignal = null) { + const t = targetWallet.slice(0, 10); + let last = null; + + for (const sig of chain) { + console.log(`[signal] ${callerName} → ${sig.name} (target=${t}...)`); + const params = sig.needsOpponent + ? { targetAgent: targetWallet, opponentAgent: opponentWallet } + : { targetAgent: targetWallet }; + + const result = await fetchSignal(callerApiKey, sig.name, params); + console.log(`[signal] ${callerName} ${sig.name} → ${result.action || "NULL"}`); + last = { + offering: sig.name, + action: result.action, + rawAction: result.rawAction, + deliverable: result.deliverable, + confidence: result.confidence, + edge: result.edge, + error: result.error || null, + }; + + if (!result.action) continue; + if (typeof acceptSignal === 'function' && !acceptSignal(last)) { + console.log(`[signal] ${callerName} ${sig.name} rejected by quality gate (confidence=${last.confidence}, edge=${last.edge})`); + continue; + } + return last; + } + + return last || { + offering: null, + action: null, + rawAction: null, + deliverable: null, + confidence: null, + edge: null, + error: null, + }; +} + +function joinerSignalAllowed(signal) { + if (!signal?.action || !signal?.offering) return false; + if (signal.offering === 'oe_matchup_edge') { + // 2026-03-28 러시안블루 분석: threshold 0.04→0.02 완화 + // tiny shift(<0.03) counter 59.6%, small(0.03-0.06) 56.0% + // medium(0.06-0.1)은 오히려 50/50 → 높은 threshold가 나쁜 시그널만 남김 + // 거의 모든 matchup_edge 신호를 수용하되 action이 있으면 됨 + const edgeOk = signal.edge != null ? Math.abs(signal.edge) >= 0.02 : false; + const confidenceOk = signal.confidence != null ? signal.confidence >= 0.55 : false; + return edgeOk || confidenceOk; + } + if (signal.offering === 'oe_action_reco') { + // confidence가 항상 >0.7이라 gate 무의미. 그냥 action 있으면 수용. + return true; + } + return false; +} + +// 2026-03-28 v11: Adaptive direction based on recent signal accuracy. +// CRITICAL FIX: counter was locked while signal accuracy rose to 75% in recent 20R, +// meaning B was betting AGAINST a correct signal → 20.8% WR structural collapse. +// Now: measure recent signal accuracy and follow/counter accordingly. +let _cachedJoinerMode = null; +let _joinerModeRoundIdx = -1; + +function getJoinerMode(results) { + // Cache per round to avoid recomputing within the same round + const currentRound = (results?.rounds || []).length; + if (_joinerModeRoundIdx === currentRound && _cachedJoinerMode) return _cachedJoinerMode; + + const recent = (results?.rounds || []) + .filter(r => r?.joiner?.strategy === 'B') + .filter(r => r?.joiner?.decisionSource === 'signal-counter' || r?.joiner?.decisionSource === 'signal-align') + .filter(r => r?.joiner?.signalAction && r?.revealedResult) + .slice(-20); + + let mode; + // v15: only count rounds where signal was actually used (rawAction non-null) + // This excludes the pre-fix era where recommendedAction was always null + const recentWithSignal = recent.filter(r => r?.joiner?.signalRecommendedActionRaw); + if (recentWithSignal.length < 12) { + // Not enough post-fix signal data — force align to bootstrap accuracy tracking + mode = 'align'; + console.log(`[adaptive-joiner] bootstrap: only ${recentWithSignal.length} rounds with real signal → force align`); + } else { + const correct = recent.filter(r => r.joiner.signalAction === r.revealedResult).length; + const accuracy = correct / recent.length; + // v14: tightened thresholds — weak edge is still exploitable in i.i.d. games + if (accuracy >= 0.53) mode = 'align'; + else if (accuracy <= 0.47) mode = 'counter'; + else mode = 'random'; + console.log(`[adaptive-joiner] recent ${recent.length}R signal accuracy=${(accuracy*100).toFixed(1)}% → mode=${mode}`); + } + + _cachedJoinerMode = mode; + _joinerModeRoundIdx = currentRound; + return mode; +} + +async function getSignalForJoiner(joinerAgent, creatorWallet, results) { + const signal = await getRankedSignal( + joinerAgent.apiKey, + joinerAgent.name, + creatorWallet, + joinerAgent.wallet, + JOINER_SIGNAL_CHAIN, + joinerSignalAllowed, + ); + + if (signal.action) { + const mode = getJoinerMode(results); + let guess, label; + if (mode === 'align') { + guess = signal.action; + label = 'align'; + } else if (mode === 'counter') { + guess = signal.action === "ODD" ? "EVEN" : "ODD"; + label = 'counter'; + } else { + // random mode: ignore signal, pure random + guess = randomInt(0, 2) === 0 ? "ODD" : "EVEN"; + label = 'random-neutral'; + } + console.log(`[signal] ${joinerAgent.name} creator biased ${signal.action} → joiner picks ${guess} (${label})`); + return { + guess, + decisionSource: `signal-${label}`, + signalPackage: signal.offering, + signalAction: signal.action, + signalRecommendedActionRaw: signal.rawAction, + fallbackReason: null, + signalDeliverable: signal.deliverable, + signalError: signal.error, + }; + } + + // 2026-03-28: anti-bias fallback had 40.7% WR (worse than random 50%). + // Mean-reversion assumption doesn't hold in i.i.d. game. Pure random is safer. + const rand = randomInt(0, 2) === 0 ? "ODD" : "EVEN"; + console.log(`[signal] ${joinerAgent.name} chain exhausted → random fallback=${rand}`); + return { + guess: rand, + decisionSource: 'fallback-random', + signalPackage: signal.offering, + signalAction: signal.action, + signalRecommendedActionRaw: signal.rawAction, + fallbackReason: signal.error ? 'error' : 'no_edge', + signalDeliverable: signal.deliverable, + signalError: signal.error, + }; +} + +function creatorSignalAllowed(signal) { + if (!signal?.action || !signal?.offering) return false; + if (signal.offering === 'oe_matchup_edge' || signal.offering === 'oe_action_reco') return true; + // oe_bias_delta allowed as 3rd-tier if confidence >= 0.6 (strict gate to filter weak signals) + if (signal.offering === 'oe_bias_delta') { + return signal.confidence != null && signal.confidence >= 0.6; + } + return false; +} + +/** + * Group B — Creator strategy (2026-03-28 v5: per-agent anti-bias). + * Tracks each A-joiner's ODD/EVEN guess history and picks the opposite. + * A-joiner agents show persistent ODD bias (~55%), exploiting that + * should lift B-Creator from ~48% toward ~54%. + * Falls back to random if insufficient history (<5 rounds vs that joiner). + */ +const _opponentGuessHistory = {}; // { agentName: { ODD: number, EVEN: number } } +let _historyBootstrapped = false; + +function _bootstrapOpponentHistory(results) { + if (_historyBootstrapped) return; + for (const r of (results.rounds || [])) { + if (r.creator?.strategy === "B" && r.joiner?.name && r.joiner?.guess) { + if (!_opponentGuessHistory[r.joiner.name]) _opponentGuessHistory[r.joiner.name] = { ODD: 0, EVEN: 0 }; + _opponentGuessHistory[r.joiner.name][r.joiner.guess]++; + } + if (r.joiner?.strategy === "B" && r.creator?.name && r.creator?.impliedGuess) { + if (!_opponentGuessHistory[r.creator.name]) _opponentGuessHistory[r.creator.name] = { ODD: 0, EVEN: 0 }; + _opponentGuessHistory[r.creator.name][r.creator.impliedGuess]++; + } + } + _historyBootstrapped = true; + console.log(`[league] Opponent history bootstrapped:`, JSON.stringify(_opponentGuessHistory)); +} + +// 2026-03-28 v12: Creator adaptive direction. +// Creator signal predicts joiner's tendency. Direction (counter vs follow) is now adaptive +// based on recent accuracy of creator signal vs actual joiner guesses. +let _cachedCreatorMode = null; +let _creatorModeRoundIdx = -1; + +function getCreatorMode(results) { + const currentRound = (results?.rounds || []).length; + if (_creatorModeRoundIdx === currentRound && _cachedCreatorMode) return _cachedCreatorMode; + + const recent = (results?.rounds || []) + .filter(r => r?.creator?.strategy === 'B') + .filter(r => r?.creator?.decisionSource === 'signal-counter-creator' || r?.creator?.decisionSource === 'signal-follow-creator') + .filter(r => r?.creator?.signalAction && r?.joiner?.guess) + .slice(-20); + + let mode; + // v15: only count rounds where signal was actually used (rawAction non-null) + const recentWithSignal = recent.filter(r => r?.creator?.signalRecommendedActionRaw); + if (recentWithSignal.length < 12) { + // Not enough post-fix signal data — force follow to bootstrap + mode = 'follow'; + console.log(`[adaptive-creator] bootstrap: only ${recentWithSignal.length} rounds with real signal → force follow`); + } else { + // Signal accuracy = how often signal predicted joiner's actual guess correctly + const correct = recent.filter(r => r.creator.signalAction === r.joiner.guess).length; + const accuracy = correct / recent.length; + // v14: tightened thresholds — weak edge is still exploitable in i.i.d. games + if (accuracy >= 0.53) mode = 'counter'; // signal matches joiner guess → counter it + else if (accuracy <= 0.47) mode = 'follow'; // signal is inverse → follow means opposite of joiner + else mode = 'random'; + console.log(`[adaptive-creator] recent ${recent.length}R signal accuracy=${(accuracy*100).toFixed(1)}% → mode=${mode}`); + } + + _cachedCreatorMode = mode; + _creatorModeRoundIdx = currentRound; + return mode; +} + +async function getSignalForCreator(creatorAgent, joinerWallet, joinerName, results) { + const signal = await getRankedSignal( + creatorAgent.apiKey, + creatorAgent.name, + joinerWallet, // target = joiner (opponent) + creatorAgent.wallet, // self + CREATOR_SIGNAL_CHAIN, + creatorSignalAllowed, + ); + + if (signal.action) { + const mode = getCreatorMode(results); + let guess, label; + if (mode === 'counter') { + // Signal predicts joiner's tendency → pick opposite so joiner guesses wrong + guess = signal.action === "ODD" ? "EVEN" : "ODD"; + label = 'counter'; + } else if (mode === 'follow') { + // Signal is inverse-correlated with joiner → follow = same as signal + guess = signal.action; + label = 'follow'; + } else { + // Random: ignore signal + guess = randomInt(0, 2) === 0 ? "ODD" : "EVEN"; + label = 'random-neutral'; + } + console.log(`[signal] ${creatorAgent.name} creator-signal ${signal.offering} → ${signal.action} => ${label} ${guess}`); + return { + guess, + decisionSource: `signal-${label}-creator`, + signalPackage: signal.offering, + signalAction: signal.action, + signalRecommendedActionRaw: signal.rawAction, + fallbackReason: null, + signalDeliverable: signal.deliverable, + signalError: signal.error, + }; + } + + // Fallback: pure random + const rand = randomInt(0, 2) === 0 ? "ODD" : "EVEN"; + console.log(`[signal] ${creatorAgent.name} creator chain exhausted → random fallback=${rand}`); + return { + guess: rand, + decisionSource: 'fallback-random-creator', + signalPackage: signal.offering, + signalAction: signal.action, + signalRecommendedActionRaw: signal.rawAction, + fallbackReason: signal.error ? 'error' : 'no_edge', + signalDeliverable: signal.deliverable, + signalError: signal.error, + }; +} + +// Pair rotation: covers all unique pairs +function pickMatch(agents, roundIdx) { + const n = agents.length; + // Build all unique pairs + const pairs = []; + for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) pairs.push([i, j]); + const [ci, ji] = pairs[roundIdx % pairs.length]; + return { creator: agents[ci], joiner: agents[ji] }; +} + +// Pick two disjoint matches per slot with explicit direction rotation. +// For 4 agents, a full 6-slot / 12-round cycle makes every agent appear +// exactly 3x as creator and 3x as joiner. +function pickParallelMatches(agents, slotIdx) { + if (agents.length !== 4) { + const first = pickMatch(agents, slotIdx * 2); + const second = pickMatch(agents, slotIdx * 2 + 1); + return [first, second]; + } + + const slots = [ + [[0, 1], [2, 3]], + [[0, 2], [1, 3]], + [[0, 3], [1, 2]], + [[1, 0], [3, 2]], + [[2, 0], [3, 1]], + [[3, 0], [2, 1]], + ]; + + const slot = slots[slotIdx % slots.length]; + return slot.map(([creatorIdx, joinerIdx]) => ({ + creator: agents[creatorIdx], + joiner: agents[joinerIdx], + })); +} + +function pickStrategies(roundIdx) { + // Alternate each round: A/B swap + if (roundIdx % 2 === 0) return { creatorStrategy: "A", joinerStrategy: "B" }; + return { creatorStrategy: "B", joinerStrategy: "A" }; +} + +async function runRound(roundIdx, results, matchOverride, strategyOverride) { + const { creator, joiner } = matchOverride || pickMatch(AGENTS, roundIdx); + const { creatorStrategy, joinerStrategy } = strategyOverride || pickStrategies(roundIdx); + + console.log(`\n[league] ===== Round ${roundIdx + 1} =====`); + console.log(`[league] ${creator.name}(${creatorStrategy}) vs ${joiner.name}(${joinerStrategy})`); + + // B strategy: get signal first + let creatorGuess = randomInt(0, 2) === 0 ? "ODD" : "EVEN"; + let joinerGuess = randomInt(0, 2) === 0 ? "ODD" : "EVEN"; + let creatorDecisionMeta = { decisionSource: 'random-baseline', signalPackage: null, signalAction: null, signalRecommendedActionRaw: null, fallbackReason: null, signalDeliverable: null, signalError: null }; + let joinerDecisionMeta = { decisionSource: 'random-baseline', signalPackage: null, signalAction: null, signalRecommendedActionRaw: null, fallbackReason: null, signalDeliverable: null, signalError: null }; + + // 2026-03-28 v14: Re-enable signal calls for B strategy. + // v13 disabled signals based on pre-adaptive (v11/v12) data. + // Adaptive system (getJoinerMode/getCreatorMode) was never tested in production. + // Re-activating for 200-round experiment to measure adaptive performance. + // Thresholds tightened: align >= 0.53, counter <= 0.47 (was 0.58/0.42). + if (creatorStrategy === "B") { + _bootstrapOpponentHistory(results); + const creatorSignal = await getSignalForCreator(creator, joiner.wallet, joiner.name, results); + creatorGuess = creatorSignal.guess; + creatorDecisionMeta = creatorSignal; + console.log(`[league] ${creator.name} B-creator → ${creatorSignal.decisionSource} → ${creatorGuess}`); + } + if (joinerStrategy === "B") { + const joinerSignal = await getSignalForJoiner(joiner, creator.wallet, results); + joinerGuess = joinerSignal.guess; + joinerDecisionMeta = joinerSignal; + console.log(`[league] ${joiner.name} B-joiner → ${joinerSignal.decisionSource} → ${joinerGuess}`); + } + + // Step 1: Creator creates round + // create_round needs: { number: integer, tier: "S"|"M"|"L" } + // number is the creator's secret; guess is derived from it (odd number = ODD) + const secretNumber = creatorGuess === "ODD" + ? 2 * randomInt(1, 50) - 1 // odd: 1,3,5,...99 + : 2 * randomInt(1, 50); // even: 2,4,6,...100 + + console.log(`[league] ${creator.name} creating round (number=${secretNumber}, tier=${STAKE_TIER}, impliedGuess=${creatorGuess})...`); + let createResult; + try { + createResult = await callJob(creator.apiKey, "create_round", { + number: secretNumber, + tier: STAKE_TIER + }); + } catch (e) { + console.error(`[league] create_round failed: ${e.message}`); + return null; + } + + // Extract roundId from deliverable + const createDel = parseDeliverable(createResult.result); + const roundId = createDel?.roundId || createDel?.round_id || createDel?.id; + if (!roundId) { + console.error(`[league] no roundId from create_round: ${JSON.stringify(createDel).slice(0,300)}`); + return null; + } + console.log(`[league] Round created: roundId=${roundId}`); + + // Step 2: Joiner joins + // join_round needs: { roundId: string, guess: "ODD"|"EVEN" } + console.log(`[league] ${joiner.name} joining round ${roundId} (guess=${joinerGuess})...`); + try { + await callJob(joiner.apiKey, "join_round", { + roundId: roundId.toString(), + guess: joinerGuess + }); + } catch (e) { + console.error(`[league] join_round failed: ${e.message}`); + return null; + } + + // Step 3: Creator reveals + // reveal_round needs: { roundId: string } + console.log(`[league] ${creator.name} revealing round ${roundId}...`); + let revealResult; + try { + revealResult = await callJob(creator.apiKey, "reveal_round", { + roundId: roundId.toString() + }); + } catch (e) { + console.error(`[league] reveal_round failed: ${e.message}`); + return null; + } + + // Parse result: revealRound returns { roundId, revealedNumber, result: "ODD"|"EVEN", txHash, message } + // Winner determination: creator chose secretNumber (ODD/EVEN), + // if joiner guessed same as result → joiner wins; else creator wins. + const revealDel = parseDeliverable(revealResult.result); + const revealedResult = revealDel?.result; // "ODD" or "EVEN" + console.log(`[league] Revealed: result=${revealedResult}, creator implied ${creatorGuess}, joiner guessed ${joinerGuess}`); + + let creatorWon = false; + let joinerWon = false; + if (revealedResult) { + // Joiner wins if their guess matches the revealed result + joinerWon = joinerGuess === revealedResult; + creatorWon = !joinerWon; + } + + const roundRecord = { + roundIdx: roundIdx + 1, + roundId, + creator: { + name: creator.name, + wallet: creator.wallet, + strategy: creatorStrategy, + secretNumber, + impliedGuess: creatorGuess, + won: creatorWon, + decisionSource: creatorDecisionMeta.decisionSource, + signalPackage: creatorDecisionMeta.signalPackage, + signalAction: creatorDecisionMeta.signalAction, + signalRecommendedActionRaw: creatorDecisionMeta.signalRecommendedActionRaw, + fallbackReason: creatorDecisionMeta.fallbackReason, + signalDeliverable: creatorDecisionMeta.signalDeliverable, + signalError: creatorDecisionMeta.signalError, + }, + joiner: { + name: joiner.name, + wallet: joiner.wallet, + strategy: joinerStrategy, + guess: joinerGuess, + won: joinerWon, + decisionSource: joinerDecisionMeta.decisionSource, + signalPackage: joinerDecisionMeta.signalPackage, + signalAction: joinerDecisionMeta.signalAction, + signalRecommendedActionRaw: joinerDecisionMeta.signalRecommendedActionRaw, + fallbackReason: joinerDecisionMeta.fallbackReason, + signalDeliverable: joinerDecisionMeta.signalDeliverable, + signalError: joinerDecisionMeta.signalError, + }, + revealedResult, + winner: creatorWon ? creator.name : joinerWon ? joiner.name : "unknown", + timestamp: new Date().toISOString() + }; + + // Update stats + if (creatorWon) { results.stats[creatorStrategy].wins++; results.stats[joinerStrategy].losses++; } + else if (joinerWon) { results.stats[joinerStrategy].wins++; results.stats[creatorStrategy].losses++; } + + results.rounds.push(roundRecord); + + // Track opponent guess history for anti-bias strategy + if (creatorStrategy === "B" && joinerGuess) { + if (!_opponentGuessHistory[joiner.name]) _opponentGuessHistory[joiner.name] = { ODD: 0, EVEN: 0 }; + _opponentGuessHistory[joiner.name][joinerGuess]++; + } + if (joinerStrategy === "B" && creatorGuess) { + if (!_opponentGuessHistory[creator.name]) _opponentGuessHistory[creator.name] = { ODD: 0, EVEN: 0 }; + _opponentGuessHistory[creator.name][creatorGuess]++; + } + + saveResults(results); + + console.log(`[league] ✓ Round ${roundIdx+1} done: winner=${roundRecord.winner}`); + console.log(`[league] Stats | A: ${results.stats.A.wins}W/${results.stats.A.losses}L | B: ${results.stats.B.wins}W/${results.stats.B.losses}L`); + return roundRecord; +} + +async function main() { + console.log(`[league] A/B League starting with ${AGENTS.length} agents (stake tier=${STAKE_TIER})`); + console.log(`[league] Agents: ${AGENTS.map(a => a.name).join(", ")}`); + console.log("[league] Press Ctrl+C or send SIGTERM to stop.\n"); + + const results = loadResults(); + _bootstrapOpponentHistory(results); + let roundIdx = results.rounds.length; + if (roundIdx > 0) console.log(`[league] Resuming from round ${roundIdx + 1} (${results.rounds.length} prior rounds loaded)`); + + process.on("SIGTERM", () => { + console.log("[league] SIGTERM received. Saving and exiting."); + saveResults(results); + process.exit(0); + }); + process.on("SIGINT", () => { + console.log("[league] SIGINT received. Saving and exiting."); + saveResults(results); + process.exit(0); + }); + + let slotIdx = Math.floor(roundIdx / 2); // each slot runs 2 rounds + + while (true) { + try { + await checkAndRefillFunds(AGENTS); + + if (AGENTS.length >= 4) { + // Run 2 disjoint matches sequentially, with creator/joiner direction balanced across a 12-round cycle + const matches = pickParallelMatches(AGENTS, slotIdx); + const strategies = [pickStrategies(roundIdx), pickStrategies(roundIdx + 1)]; + + console.log(`\n[league] ===== Slot ${slotIdx + 1} (Rounds ${roundIdx + 1} & ${roundIdx + 2}) — SEQUENTIAL =====`); + console.log(`[league] Round 1: ${matches[0].creator.name}(${strategies[0].creatorStrategy}) vs ${matches[0].joiner.name}(${strategies[0].joinerStrategy})`); + console.log(`[league] Round 2: ${matches[1].creator.name}(${strategies[1].creatorStrategy}) vs ${matches[1].joiner.name}(${strategies[1].joinerStrategy})`); + + const r1 = await runRound(roundIdx, results, matches[0], strategies[0]).catch(e => { console.error(`[league] Round 1 error: ${e.message}`); return null; }); + const r2 = await runRound(roundIdx + 1, results, matches[1], strategies[1]).catch(e => { console.error(`[league] Round 2 error: ${e.message}`); return null; }); + + roundIdx += 2; + slotIdx++; + } else { + // Fallback: sequential for < 4 agents + await runRound(roundIdx, results); + roundIdx++; + } + + await sleep(1000); + } catch (e) { + const msg = String(e?.message || e); + console.error(`[league] Unhandled error near round ${roundIdx + 1}: ${msg}`); + const backoffMs = /insufficient balance|payment blocker/i.test(msg) ? 300000 : 5000; + console.log(`[league] Backing off for ${Math.round(backoffMs / 1000)}s before retry...`); + await sleep(backoffMs); + } + } +} + +main().catch(console.error);