From 78b832f926b79141f7f5ee10e92a2d7b1e79fade Mon Sep 17 00:00:00 2001 From: Theodore Song Date: Wed, 19 Aug 2026 08:26:13 -0400 Subject: [PATCH] Diversify adaptive evidence and repair state failover --- README.md | 23 ++++++-- api/_db.js | 104 +++++++++++++++++++++++----------- api/state.js | 39 +++++++++++-- index.html | 44 +++++++++++--- package.json | 4 +- scripts/test-server-state.mjs | 44 ++++++++++++++ sw.js | 2 +- 7 files changed, 206 insertions(+), 54 deletions(-) create mode 100644 scripts/test-server-state.mjs diff --git a/README.md b/README.md index 77f1285..262c2bf 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,12 @@ https://polymarket-site-eta.vercel.app/personal.html The site fetches live Polymarket markets, generates agent suggestions, lets you run frequent paper cycles, and syncs the shared arena state through Neon or -Vercel Blob. Build 57 also installs an offline app shell and caches timestamped +Vercel Blob. Build 58 also installs an offline app shell and caches timestamped market snapshots. During an outage, cycles continue locally; cached entries are allowed for 90 minutes, older snapshots become mark-only, and all cached data expires after 24 hours. -Build 57 ranks the competition by each agent's return since Strategy 50 began. +Build 58 ranks the competition by each agent's return since Strategy 50 began. Historical replay equity remains visible for context, but it no longer makes an agent look like the current leader when the live adaptive strategy is losing. @@ -114,7 +114,7 @@ feature views before repeatable positive evidence can increase size or repeatabl negative evidence can block a new entry. Mixed evidence stays close to neutral instead of being mistaken for an edge. -Build 57 enforces the documented offline boundary end to end. Cached snapshots +Build 58 enforces the documented offline boundary end to end. Cached snapshots under 90 minutes old may continue paper execution. Older snapshots remain usable for valuation and chart snapshots for up to 24 hours, but cannot trigger entries, stop-losses, gain-stops, risk rebalances, settlements, or policy exits. Network @@ -127,11 +127,18 @@ adaptive baselines, pending signal grades, and trade evidence remain in one stra lineage until the actual entry, sizing, or exit logic changes. Legacy build 40 and 41 records are migrated into the same strategy lineage without losing evidence. -Build 57 independently refreshes markets for matured pending signals that have +Build 58 independently refreshes markets for matured pending signals that have left the current top-500 activity scan. Unavailable markets remain queued for a bounded retry window. This prevents activity-rank survivorship from deciding which wins and losses reach the adaptive calibration ledger. +Build 58 also allocates the 300 pending observation slots by evidence coverage. +Under-sampled signal/side/category cohorts are observed first, followed by +under-sampled independent events and market sides, with conviction used only as +a later tie-breaker. This prevents the same popular contracts from monopolizing +the ledger and gives the learner a realistic path to promote or reject more +diverse cohorts. + Strategy 50 coordinates high-risk exploration globally. Near-term, extreme-price, and other gap-prone positions may be held materially by only one agent, while ordinary independently confirmed markets retain the two-agent cap. The robustly @@ -225,7 +232,13 @@ Pick one — all give you a public URL: Use `.env.example` as the setup template. - `DATABASE_URL` or `NEON_DATABASE_URL` enables Neon-backed shared state; - `BLOB_READ_WRITE_TOKEN` is the fallback provider. + `BLOB_READ_WRITE_TOKEN` is the fallback provider. The backend accepts raw + Postgres URLs, quoted URLs, `DATABASE_URL=...`, and Neon dashboard + `psql 'postgresql://...'` copy formats, and prefers a syntactically valid + alias if the primary value is malformed. Shared-state reads and writes also + fail over across distinct configured database URLs when one has stale + credentials or points at an empty project. `/api/state` reports only + sanitized provider error codes when all stores are unavailable. - `ACCOUNT_SESSION_SECRET` signs cloud paper-account sessions. If omitted, the app falls back to the existing server secret/token, but production should use a dedicated value. diff --git a/api/_db.js b/api/_db.js index a8ec6f4..62ea69e 100644 --- a/api/_db.js +++ b/api/_db.js @@ -1,11 +1,31 @@ import { neon } from "@neondatabase/serverless"; -let sqlClient; +const sqlClients = new Map(); let schemaReady; -let sharedStateSchemaReady; +const sharedStateSchemasReady = new Set(); + +export function normalizeDatabaseUrl(raw) { + let value = String(raw || "").trim(); + const assignment = value.match(/^(?:DATABASE_URL|NEON_DATABASE_URL)\s*=\s*([\s\S]+)$/i); + if (assignment) value = assignment[1].trim(); + const psql = value.match(/^psql\s+(['"])([\s\S]+)\1\s*$/i); + if (psql) value = psql[2].trim(); + else if ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith('"') && value.endsWith('"'))) { + value = value.slice(1, -1).trim(); + } + return value; +} + +export function databaseUrls() { + const candidates = [process.env.DATABASE_URL, process.env.NEON_DATABASE_URL] + .map(normalizeDatabaseUrl).filter(Boolean); + const ordered = [...candidates.filter(value => /^postgres(?:ql)?:\/\//i.test(value)), + ...candidates.filter(value => !/^postgres(?:ql)?:\/\//i.test(value))]; + return [...new Set(ordered)]; +} export function databaseUrl() { - return process.env.DATABASE_URL || process.env.NEON_DATABASE_URL || ""; + return databaseUrls()[0] || ""; } export function hasDatabase() { @@ -13,17 +33,18 @@ export function hasDatabase() { } export function sql() { - if (!sqlClient) { - const url = databaseUrl(); - if (!url) throw new Error("Database is not configured"); - sqlClient = neon(url); - } - return sqlClient; + const url = databaseUrl(); + if (!url) throw new Error("Database is not configured"); + return sqlForUrl(url); } -async function ensureSharedStateSchema() { - if (sharedStateSchemaReady) return; - const db = sql(); +function sqlForUrl(url) { + if (!sqlClients.has(url)) sqlClients.set(url, neon(url)); + return sqlClients.get(url); +} + +async function ensureSharedStateSchema(db, url) { + if (sharedStateSchemasReady.has(url)) return; await db` create table if not exists shared_app_state ( state_key text primary key, @@ -31,33 +52,50 @@ async function ensureSharedStateSchema() { updated_at timestamptz not null default now() ) `; - sharedStateSchemaReady = true; + sharedStateSchemasReady.add(url); +} + +async function withSharedStateDatabase(operation, acceptResult = () => true) { + let lastError, lastResult, hadSuccess = false; + for (const url of databaseUrls()) { + try { + const db = sqlForUrl(url); + await ensureSharedStateSchema(db, url); + lastResult = await operation(db); + hadSuccess = true; + if (acceptResult(lastResult)) return lastResult; + } catch (error) { + lastError = error; + } + } + if (hadSuccess) return lastResult; + throw lastError || new Error("Database is not configured"); } export async function readSharedAppState(stateKey) { - await ensureSharedStateSchema(); - const db = sql(); - const rows = await db` - select payload, updated_at - from shared_app_state - where state_key = ${stateKey} - limit 1 - `; - return rows[0] || null; + return withSharedStateDatabase(async db => { + const rows = await db` + select payload, updated_at + from shared_app_state + where state_key = ${stateKey} + limit 1 + `; + return rows[0] || null; + }, result => result !== null); } export async function writeSharedAppState(stateKey, payload) { - await ensureSharedStateSchema(); - const db = sql(); - const rows = await db` - insert into shared_app_state (state_key, payload, updated_at) - values (${stateKey}, ${JSON.stringify(payload)}::jsonb, now()) - on conflict (state_key) do update set - payload = excluded.payload, - updated_at = now() - returning updated_at - `; - return rows[0] || null; + return withSharedStateDatabase(async db => { + const rows = await db` + insert into shared_app_state (state_key, payload, updated_at) + values (${stateKey}, ${JSON.stringify(payload)}::jsonb, now()) + on conflict (state_key) do update set + payload = excluded.payload, + updated_at = now() + returning updated_at + `; + return rows[0] || null; + }); } export async function ensureSchema() { diff --git a/api/state.js b/api/state.js index 38a1751..75e58db 100644 --- a/api/state.js +++ b/api/state.js @@ -17,14 +17,27 @@ function withBlobAuth(options = {}) { return token ? { ...options, token } : options; } +export function providerErrorCode(error) { + if (!error) return "not_configured_or_not_attempted"; + const message = String(error.message || error).toLowerCase(); + if (message.includes("invalid") && (message.includes("url") || message.includes("connection string"))) return "invalid_connection_string"; + if (message.includes("password authentication") || message.includes("unauthorized") || /\b(?:401|403)\b/.test(message)) return "authorization_failed"; + if (message.includes("enotfound") || message.includes("getaddrinfo") || message.includes("dns")) return "dns_failed"; + if (message.includes("timeout") || message.includes("timed out")) return "timeout"; + if (message.includes("fetch failed") || message.includes("connection") || message.includes("connect")) return "connection_failed"; + return "unavailable"; +} + async function readJsonBlob() { let databaseAvailable = false; + let databaseError = null; if (hasDatabase()) { try { const row = await readSharedAppState(DATABASE_STATE_KEY); databaseAvailable = true; if (row && row.payload) return row.payload; - } catch { + } catch (err) { + databaseError = err; databaseAvailable = false; } } @@ -44,7 +57,14 @@ async function readJsonBlob() { if (!primaryError) primaryError = err; } - if (primaryError && !databaseAvailable) throw primaryError; + if (primaryError && !databaseAvailable) { + const error = new Error("Shared state providers are unavailable"); + error.providers = { + database: hasDatabase() ? providerErrorCode(databaseError) : "not_configured", + blob: providerErrorCode(primaryError), + }; + throw error; + } return null; } @@ -155,7 +175,7 @@ function compactPortfolio(p) { return out; } -function compactAgentState(st) { +export function compactAgentState(st) { if (!st || typeof st !== "object") return st; const out = { ...st, agents: {} }; for (const id of AGENT_IDS) { @@ -165,6 +185,7 @@ function compactAgentState(st) { out.signal_ledger = { pending: Array.isArray(st.signal_ledger.pending) ? st.signal_ledger.pending.slice(-SIGNAL_LEDGER_LIMITS.pending) : [], outcomes: Array.isArray(st.signal_ledger.outcomes) ? st.signal_ledger.outcomes.slice(-SIGNAL_LEDGER_LIMITS.outcomes) : [], + expired_ungraded: Number(st.signal_ledger.expired_ungraded || 0), }; } delete out.whales; @@ -172,7 +193,7 @@ function compactAgentState(st) { return out; } -function compactSuggestion(s) { +export function compactSuggestion(s) { if (!s || typeof s !== "object") return s; return { market_id: s.market_id, question: s.question, event: s.event, url: s.url, category: s.category, @@ -182,7 +203,13 @@ function compactSuggestion(s) { evidence_score: s.evidence_score, evidence_source_count: s.evidence_source_count, quality: s.quality, conviction: s.conviction, volume: s.volume, volume_24hr: s.volume_24hr, liquidity: s.liquidity, spread: s.spread, price_change_1h: s.price_change_1h, price_change_1d: s.price_change_1d, price_change_1w: s.price_change_1w, - momentum_strength: s.momentum_strength, trade_ready: s.trade_ready, watch_only: s.watch_only, jump_risk: s.jump_risk, + momentum_strength: s.momentum_strength, signal_strength: s.signal_strength, signal_confidence: s.signal_confidence, + signal_type: s.signal_type, trade_ready: s.trade_ready, entry_candidate: s.entry_candidate, + audited_observation_only: s.audited_observation_only, adaptive_promotion: s.adaptive_promotion, + watch_only: s.watch_only, jump_risk: s.jump_risk, requires_live: s.requires_live, + bundle_id: s.bundle_id, bundle_side: s.bundle_side, bundle_cost_per_unit: s.bundle_cost_per_unit, + bundle_payout_per_unit: s.bundle_payout_per_unit, bundle_net_profit_per_unit: s.bundle_net_profit_per_unit, + bundle_legs: s.bundle_legs, days_to_resolution: s.days_to_resolution, drivers: s.drivers, rationale: s.rationale, }; } @@ -243,6 +270,7 @@ export default async function handler(req, res) { state: null, degraded: true, error: err && err.message ? err.message : "Cloud state provider unavailable", + providers: err && err.providers ? err.providers : undefined, }); } } @@ -261,6 +289,7 @@ export default async function handler(req, res) { degraded: true, retryable: true, error: err && err.message ? err.message : "Cloud state provider unavailable", + providers: err && err.providers ? err.providers : undefined, }); } const incomingItems = { ...body.items }; diff --git a/index.html b/index.html index 935f2b5..143d12c 100644 --- a/index.html +++ b/index.html @@ -341,7 +341,7 @@ footer{margin-top:34px;padding-top:22px;border-top:1px solid var(--border);color