// Mad Max Research — iterative debate-mode research loop. // // Goal: gather 100 NEW SOURCES (evidence pieces) for or against the web's // ONE central hypothesis, then return a probabilistic verdict (e.g. "68% YES // based on 67 supporting / 33 contradicting"). Each iteration alternates // between OPTIMIST and CONTRARIAN, both grounded in AGENT.md + Vault Brief. // // Mad Max does NOT create new thesis nodes. There is exactly one thesis per // web — the hypothesis itself, rendered as the central `t_hypo` node. Each // iteration appends evidence (sources, market facts, science facts, tickers, // catalysts) attached to the central node with supports / contradicts edges. // // Sources without a URL must be either: // - market/economic facts citable from the vault (`category: 'market'`) // - scientific/factual claims grounded in a vault note (`category: 'science'`) // Loose unsourced claims are forbidden. const SOURCE_GOAL = 100; // gather this much evidence before verdict const MIN_NEW_SOURCES = 100; // Counted toward the "source" goal: source nodes (any category) — that's // what's evidence. Tickers + catalysts + edges are scaffolding, not evidence. const isEvidenceNode = (n) => n && n.type === 'source'; // Compact graph snapshot for the prompt function snapshotGraph(nodes, edges) { const ns = nodes.map(n => { if (n.type === 'thesis') return `[${n.id}] THESIS: ${n.label}${n.summary ? ' — '+n.summary : ''}`; if (n.type === 'ticker') return `[${n.id}] TICKER ${n.label} ${n.name||''}: $${n.price||'?'} (${(n.change||0)>0?'+':''}${n.change||0}%)`; if (n.type === 'catalyst') return `[${n.id}] CATALYST: ${n.label}${n.date?' — '+n.date:''}${n.impact?' ('+n.impact+')':''}${n.summary?' '+n.summary:''}`; if (n.type === 'source') return `[${n.id}] SOURCE ${n.publisher||''}${n.date?' ('+n.date+')':''}: "${(n.excerpt||n.label||'').slice(0,140)}"`; return `[${n.id}] ${n.type.toUpperCase()}: ${n.label}`; }).join('\n'); const es = edges.map(e => `${e.from} --${e.kind}--> ${e.to}`).join('\n'); return { ns, es }; } // Pull the FIRST top-level JSON object out of a model reply. // Tolerant of truncation, smart quotes, JS-style comments, trailing commas. function extractJSON(text) { if (!text) return null; // Remove ```json / ``` fences if present let cleaned = text.replace(/```json\s*/gi, '').replace(/```/g, ''); // Normalize smart quotes that some models emit instead of straight ones cleaned = cleaned .replace(/[‘’‚‛]/g, "'") // ' ' ‚ ‛ .replace(/[“”„‟]/g, '"'); // " " „ ‟ // Strip JS-style line comments (// ...) — only outside strings, conservatively cleaned = cleaned.replace(/^\s*\/\/[^\n]*$/gm, ''); // Strip /* */ block comments cleaned = cleaned.replace(/\/\*[\s\S]*?\*\//g, ''); const start = cleaned.indexOf('{'); if (start < 0) return null; cleaned = cleaned.slice(start); // Strict pass first const tryParse = (s) => { try { return JSON.parse(s); } catch(_) { return null; } }; const direct = tryParse(cleaned); if (direct) return direct; // Walk and track depth + string state. Find longest valid prefix and try to repair. let depth = 0, brackDepth = 0, inStr = false, esc = false; let lastCloseAtZero = -1; for (let i = 0; i < cleaned.length; i++) { const c = cleaned[i]; if (esc) { esc = false; continue; } if (c === '\\') { esc = true; continue; } if (c === '"') { inStr = !inStr; continue; } if (inStr) continue; if (c === '{') depth++; else if (c === '}') { depth--; if (depth === 0) lastCloseAtZero = i; } else if (c === '[') brackDepth++; else if (c === ']') brackDepth--; } // Case A: we saw a balanced top-level object — parse up to it if (lastCloseAtZero >= 0) { const sliced = cleaned.slice(0, lastCloseAtZero + 1); const p = tryParse(sliced); if (p) return p; } // Case B: truncated. Try to repair by trimming trailing partial token, closing strings, then closing brackets/braces. let repair = cleaned; // Drop a trailing partial unicode escape etc — back off until last sane char. // If we ended inside a string, terminate it. if (inStr) repair += '"'; // Drop trailing whitespace and trailing comma, partial keys/values (cut at last "," "}", "]" or quote boundary) // Find last position that's a clean cut point for (let attempts = 0; attempts < 12; attempts++) { let candidate = repair; candidate = candidate.replace(/,\s*$/, ''); // dangling comma candidate = candidate.replace(/:\s*$/, ': null'); // dangling key // Close brackets then braces based on depth tally for this candidate let d = 0, bd = 0, s = false, e = false; for (let i = 0; i < candidate.length; i++) { const c = candidate[i]; if (e) { e = false; continue; } if (c === '\\') { e = true; continue; } if (c === '"') { s = !s; continue; } if (s) continue; if (c === '{') d++; else if (c === '}') d--; else if (c === '[') bd++; else if (c === ']') bd--; } if (s) candidate += '"'; while (bd-- > 0) candidate += ']'; while (d-- > 0) candidate += '}'; // Strip trailing commas before close (`,]` or `,}`), which are common // model emission artifacts and break strict JSON.parse. const noTrailingCommas = candidate.replace(/,(\s*[\]}])/g, '$1'); const p = tryParse(noTrailingCommas) || tryParse(candidate); if (p) return p; // back off one token and retry repair = repair.replace(/[^,{}\[\]]*$/, '').replace(/[,{[]$/, m => m === ',' ? '' : m); if (!repair) break; // strip last property if we cut to a comma repair = repair.replace(/,\s*"[^"]*"\s*:\s*[^,}{\[\]]*$/, ''); } return null; } // One iteration: ask Claude for the next 3 evidence pieces. async function runIteration(nodes, edges, activeWeb, tweaks, iterNum, maxIters, startSourceCount) { const { ns, es } = snapshotGraph(nodes, edges); const totalSources = nodes.filter(isEvidenceNode).length; const newSources = totalSources - startSourceCount; const verdictUnlocked = newSources >= MIN_NEW_SOURCES; // Cycle through the full hedge-fund analyst roster (mirroring brain/0-Agents/) // instead of a binary optimist/contrarian flip. Each iteration brings a // different lens — fundamentals, growth, hedging, mental models, PhD, portfolio // modeling, value — alternating with a contrarian round so the thesis is // attacked from every angle. The post-Mad-Max Team auto-engagement still // runs the FULL roster on the synthesized verdict; this just makes Mad Max // itself richer than just yes-vs-no. const ANALYSTS = [ { tag: 'OPTIMIST', prompt: 'Argue the hypothesis is TRUE. Surface evidence that confirms the "yes" case. Look for the strongest version of the bullish argument.' }, { tag: 'CONTRARIAN', prompt: 'Argue the hypothesis is FALSE. Counter-data, conflicting filings, base-rate failures. Find the cracks. The hypothesis only matters if it can survive fair attack.' }, { tag: 'FUNDAMENTALS', prompt: 'Quality of business lens. Unit economics, moats, returns on capital, cash flow durability. Does the underlying business actually deliver if the thesis plays out?' }, { tag: 'GROWTH', prompt: 'Growth investor lens. TAM expansion, S-curves, network effects, distribution leverage. Where does compounding accelerate or stall?' }, { tag: 'HEDGING SPECIALIST', prompt: 'Risk lens. What hedges this exposure? Where does the position fail? Tail risks, correlation regimes, factor sensitivities, gamma traps.' }, { tag: 'MENTAL MODELS', prompt: 'Charlie Munger lens. Inversion, base rates, second-order effects, second-order incentives, what would surprise the consensus.' }, { tag: 'PhD RESEARCHER', prompt: 'Empirical lens. Cite the data — academic, regulatory, scientific. Where does peer-reviewed evidence (or its absence) support or break the thesis?' }, { tag: 'PORTFOLIO MODELING', prompt: 'Sizing lens. Position-construction view: Kelly fraction implied by edge, correlation with existing book, optimal expression (LEAPS strike + DTE).' }, { tag: 'VALUE', prompt: 'Value investor lens. Margin of safety, downside-first thinking, asset value vs market price, the "is this cheap?" question grounded in earnings power.' }, ]; const analystForIter = ANALYSTS[(iterNum - 1) % ANALYSTS.length]; const isOptimist = analystForIter.tag === 'OPTIMIST'; const stance = `${analystForIter.tag} — ${analystForIter.prompt} Never fabricate: if the vault has a deep dive, quote its evidence; if it doesn't, propose a real source on a real publisher's domain OR a verifiable economic/scientific fact citable from the vault.`; const hypothesis = (activeWeb.hypothesis && activeWeb.hypothesis.question) ? activeWeb.hypothesis : null; const hypoBlock = hypothesis && hypothesis.question ? `HYPOTHESIS (the ONE binary question this entire web exists to answer — already on the canvas as the central thesis node t_hypo): Q: ${hypothesis.question} RESOLVES BY: ${hypothesis.resolveBy || '(unset)'} IF YES: ${hypothesis.yesScenario || '(unset)'} IF NO: ${hypothesis.noScenario || '(unset)'} STATUS: ${hypothesis.status || 'open'}` : `HYPOTHESIS: (not set yet — the user should hit Invent before running Mad Max. Skip and stop.)`; const prompt = `MAD MAX DEBATE — you are an autonomous research agent gathering 100 evidence pieces for or against the web's ONE central hypothesis. Each iteration you alternate between OPTIMIST and CONTRARIAN. The web converges on a probabilistic verdict (e.g. "68% YES based on 67 supporting / 33 contradicting") once ${MIN_NEW_SOURCES} new evidence pieces are built. YOUR STANCE THIS ITERATION: ${stance} HORIZON: ${tweaks.horizon} · RISK: ${tweaks.riskTolerance}/100 WEB: "${activeWeb.name}" — ${activeWeb.tagline} ${hypoBlock} CURRENT NODES (${nodes.length}): ${ns} CURRENT EDGES (${edges.length}): ${es || '(none)'} PROGRESS: iter ${iterNum}/${maxIters} · ${newSources}/${MIN_NEW_SOURCES} evidence pieces built. ${verdictUnlocked ? 'VERDICT UNLOCKED — return a probabilistic verdict NOW. Set found_trade=true and supply the verdict block.' : `KEEP DIGGING — add 3-7 new nodes this iter from your ${analystForIter.tag} lens. AT LEAST 3 must be "source" nodes (the gate is sources). On top of those, add ANY MIX of additional sources (news/market/science/filing/data), tickers, and catalysts that strengthen the case. There are no caps on tickers, catalysts, or non-evidence sources — only the 3-source minimum and the one-thesis maximum. A rich iter might be: 3 news sources + 1 market fact + 1 science fact + 1 ticker + 1 catalyst.`} ONE THESIS PER WEB — the central hypothesis is the only thesis. DO NOT create new "thesis"-type nodes (any you propose will be rejected). Everything else can have many: multiple tickers, multiple catalysts, multiple sources of each category. Every source node you add must connect to the central node t_hypo with kind="supports" (argues for YES) or kind="contradicts" (argues for NO). Tickers connect to t_hypo with "mentions" (or to relevant sources). Catalysts use "triggers" toward whatever they would move. NODE TYPES YOU MAY USE — multiple instances of any of these are fine: - "source" — evidence. Use the "category" field: "news" news article (URL required, real publisher domain) "market" economic / market-structure fact — e.g. 'Large transformer lead times exceed 130 weeks'. URL optional if the excerpt cites a vault note (e.g. "per 2-Science & Tech Sectors/Energy for AI/...md"). "science" scientific/factual claim — e.g. 'Sandisk + SK Hynix collaborating on HBF'. URL optional with vault citation. "filing" 10-K/10-Q/8-K (URL on sec.gov) "data" vendor data point (URL on the data vendor's domain) - "ticker" — any stock/ETF relevant to the hypothesis. Live price hydrates from Massive automatically. Add new ones whenever a ticker surfaces in the iteration's evidence. - "catalyst" — any dated event that could resolve or move the hypothesis (earnings, FOMC, product launches, regulatory deadlines, contract awards). Multiple per iteration is encouraged when the hypothesis touches several names. QUALITY GATE FOR MARKET/SCIENCE FACTS WITHOUT URLs: the claim must be specific, quantified, and traceable to a vault note. Cite the vault path in the excerpt. Loose hand-wavy claims are forbidden — only crisp, defensible facts the user could verify in seconds. Reply with ONLY this minified JSON, NO commentary, NO code fences: {"think":"","stance":"${isOptimist ? 'optimist' : 'contrarian'}","nodes":[{"id":"","type":"source|ticker|catalyst","label":"","summary":"<≤12 words>","name":"","date":"","publisher":"","url":"","excerpt":"","category":"","impact":""}],"edges":[{"from":"","to":"t_hypo","kind":"supports|contradicts"},{"from":"","to":"","kind":"mentions|triggers"}],"found_trade":${verdictUnlocked ? 'true' : 'false'}${verdictUnlocked ? ',"trade":{"name":"","thesis":"<≤40 words explaining the probability split>","verdict":"yes|no|undecided","probability":<0-100, integer — your subjective P(YES) given the evidence>,"supportingCount":,"contradictingCount":,"legs":[{"side":"long","instrument":"","size":"<%>","rationale":""}],"expectedReturn":"<+X%>","maxLoss":"<-Y%>","horizon":"","conviction":<60-95>,"supports":[""]}' : ''}} ID conventions: source=s, ticker=k_, catalyst=c. EVERY source must have an edge to t_hypo (supports OR contradicts). News/filing/data sources MUST have a url on the publisher's real domain (bloomberg.com, ft.com, wsj.com, sec.gov, federalreserve.gov, ercot.com, IR sites, etc.) — never invent a generic /article URL. JSON ONLY.`; let raw; let webCitations = []; try { // Use /api/llm/research which enables Anthropic's server-side web_search // tool. The model can browse real articles before citing them — no more // hallucinated URLs. Citations are returned alongside text and we map // them onto the source nodes after JSON extraction. const r = await fetch('/api/llm/research', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: [{ role: 'user', content: prompt }], max_tokens: 3000, max_searches: 4, }), }); if (r.ok) { const j = await r.json(); raw = j.text; webCitations = j.citations || []; } else { throw new Error(`research endpoint ${r.status}`); } } catch (e) { // Fallback: plain /api/llm without web search (still works, but URLs // will be allowlist-stripped at staging — search fallback in display). try { raw = await window.claude.complete({ messages: [{ role: 'user', content: prompt }], max_tokens: 3000, }); } catch (e2) { return { error: 'LLM call failed: ' + (e2.message || e.message || 'unknown') }; } } const parsed = extractJSON(raw); if (!parsed) return { error: 'Could not parse model output', preview: (raw || '').slice(0, 80).replace(/\s+/g, ' ') + '…' }; parsed._stance = analystForIter.tag.toLowerCase().replace(/[^a-z]/g, '_'); parsed._stanceLabel = analystForIter.tag; parsed._citations = webCitations; return parsed; } // Place new nodes on the canvas — gentle ring-based layout from a focal node function positionNew(newNode, allNodes, anchorId) { const anchor = allNodes.find(n => n.id === anchorId) || allNodes[0]; if (!anchor) return { x: (Math.random()-0.5)*400, y: (Math.random()-0.5)*400 }; // Place around an expanding ring based on how many we've added const ring = 180 + Math.floor(allNodes.length / 8) * 80; const angle = (allNodes.length * 0.618 * Math.PI * 2) % (Math.PI * 2); // golden-angle spiral return { x: (anchor.x || 0) + Math.cos(angle) * ring, y: (anchor.y || 0) + Math.sin(angle) * ring, }; } async function runMadMax({ initialNodes, initialEdges, activeWeb, tweaks, setNodes, setEdges, onLog, onProgress, onResult, shouldStop, }) { // Up to 80 iterations · 3 evidence/iter · the gate is sources, not iters. // 80 iters * 3 sources/iter ≈ 240 chances to hit 100 — plenty of slack for // contrarian iters that propose 0 valid items. const MAX_ITERS = 80; // Track the current graph in local accumulators to avoid React stale-state races let liveNodes = [...initialNodes]; let liveEdges = [...initialEdges]; let foundTrade = null; const startSourceCount = initialNodes.filter(isEvidenceNode).length; const startCount = initialNodes.length; const sourceGoal = MIN_NEW_SOURCES; onLog({ tag: 'iter', text: `Starting debate sweep on "${activeWeb.name}". Gathering ${sourceGoal} evidence pieces for/against the hypothesis, then returning a probabilistic verdict.` }); let consecFails = 0; for (let i = 1; i <= MAX_ITERS; i++) { if (shouldStop()) { onLog({ tag: 'warn', text: 'Halted by user.' }); break; } const sourcesNow = liveNodes.filter(isEvidenceNode).length; const newSources = sourcesNow - startSourceCount; if (newSources >= sourceGoal && foundTrade) break; onLog({ tag: 'iter', text: `Iter ${i} · evidence ${newSources}/${sourceGoal} · nodes ${liveNodes.length} · edges ${liveEdges.length}` }); onProgress({ iter: i, max: MAX_ITERS, nodeCount: liveNodes.length, newNodes: newSources, sourceCount: sourcesNow }); // Run the iteration with one silent retry on parse failure. Most parse // fails are transient — the model's next attempt usually returns clean // JSON. We only count iterations as "consecutive failures" if BOTH the // first try and the retry fail. let result = await runIteration(liveNodes, liveEdges, activeWeb, tweaks, i, MAX_ITERS, startSourceCount); if (shouldStop()) break; if (result.error) { result = await runIteration(liveNodes, liveEdges, activeWeb, tweaks, i, MAX_ITERS, startSourceCount); if (shouldStop()) break; } if (result.error) { consecFails++; onLog({ tag: 'warn', text: result.error + (result.preview ? ' — got: ' + result.preview : '') }); if (consecFails >= 5) { onLog({ tag: 'warn', text: 'Five failed iterations in a row — aborting sweep. The model is not returning parseable JSON.' }); break; } continue; } consecFails = 0; const thinkText = result.think || result.thinking; if (thinkText) { const label = result._stanceLabel || result._stance || 'iter'; // Short tag for the log column — first 4 letters of the agent name keeps // the column tidy across all 9 personas. const tag = String(label).slice(0, 4).toLowerCase(); onLog({ tag, text: `[${label}] ${thinkText}` }); } // Accept either short or long field names const rawNodes = result.nodes || result.new_nodes || []; const rawEdges = result.edges || result.new_edges || []; // Web search citations from /api/llm/research — real publisher URLs // returned by Anthropic's web_search tool. We match these onto source // nodes by title overlap so each cited source gets a verified URL. const webCitations = result._citations || []; // Helper: pick the best citation for a given source node by token overlap // between citation.title and node.label. Returns null if no decent match. function _matchCitation(node) { if (!node || !node.label || webCitations.length === 0) return null; const norm = (s) => (s || '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').split(/\s+/).filter(w => w.length >= 4); const labelTokens = new Set(norm(node.label)); if (labelTokens.size === 0) return null; let best = null, bestScore = 0; for (const c of webCitations) { const cTokens = norm(c.title); let overlap = 0; for (const t of cTokens) if (labelTokens.has(t)) overlap += 1; const score = overlap / Math.max(1, Math.min(labelTokens.size, cTokens.length)); if (score > bestScore) { best = c; bestScore = score; } } // Require at least 35% token overlap to accept the match return bestScore >= 0.35 ? best : null; } // Add new nodes — but REJECT any new "thesis" type. There is exactly one // thesis per web (the central t_hypo node) and Mad Max must not create // siblings. If the model proposes one, downgrade it to a "source" with // the appropriate stance category. const usedIds = new Set(liveNodes.map(n => n.id)); const addedNodes = []; let rejectedTheses = 0; for (const nn of rawNodes) { if (!nn.id || !nn.type || !nn.label) continue; // Quality gate: drop "thesis" nodes (one hypothesis per web). if (nn.type === 'thesis') { rejectedTheses += 1; continue; } // URL provenance for source nodes — three-tier trust model: // 1. Web-search citation match (verified by Anthropic's web_search // tool — Claude actually browsed and read this article) // 2. TRUSTED_URL_DOMAINS (filings, regulators, gov data) // 3. Anything else → strip; display layer falls back to Google search if (nn.type === 'source') { // (1) try to match a real web-search citation const cite = _matchCitation(nn); if (cite && cite.url) { nn.url = cite.url; nn.url_verified_by_search = true; // bypasses the allowlist check if (cite.title && (!nn.publisher || nn.publisher === 'unknown')) { // Use the cited title's domain as a publisher hint if missing try { nn.publisher = nn.publisher || new URL(cite.url).hostname.replace('www.', ''); } catch {} } if (cite.cited_text && !(nn.excerpt || '').trim()) { nn.excerpt = cite.cited_text.slice(0, 280); } } else if (nn.url) { // (2) check trusted-domain allowlist const TRUSTED = [ 'sec.gov','federalreserve.gov','ercot.com','eia.gov','bls.gov', 'bea.gov','treasury.gov','whitehouse.gov','imf.org','congress.gov', 'cbo.gov','gao.gov','fdic.gov','occ.gov','fda.gov','ftc.gov', 'cftc.gov','finra.org', ]; try { const u = new URL(nn.url); if (!TRUSTED.some(d => u.hostname.endsWith(d))) { delete nn.url; } } catch { delete nn.url; } } } // STRICT no-Google-fallback policy: every source on the canvas must // have a verified URL (web_search cited / trusted-domain / user-pasted) // OR be a market/science fact that cites a vault note path. // Anything else gets DROPPED here so the canvas only shows real sources. if (nn.type === 'source' && !nn.url) { const cat = (nn.category || 'news').toLowerCase(); const hasVaultCite = /(\b\d-[A-Za-z][^\s/]*\/[^"]+\.md|agent\.md)/i.test(nn.excerpt || ''); const isVaultCitedFact = ['market', 'science'].includes(cat) && hasVaultCite; if (!isVaultCitedFact) { continue; // no verified web URL + no vault cite → reject } } // Disambiguate id collisions let id = nn.id; if (usedIds.has(id)) id = id + '_' + Math.random().toString(36).slice(2,5); usedIds.add(id); // Anchor new sources to t_hypo if available, otherwise to the last node const hypoId = liveNodes.find(n => n.isHypothesis)?.id || 't_hypo'; const anchorId = rawEdges.find(e => e.from === nn.id || e.to === nn.id) ? ((rawEdges.find(e => e.from === nn.id)?.to) || (rawEdges.find(e => e.to === nn.id)?.from)) : (liveNodes.find(n => n.id === hypoId) ? hypoId : liveNodes[liveNodes.length-1]?.id); const pos = positionNew(nn, liveNodes, anchorId); const cleaned = { ...nn, id, x: pos.x, y: pos.y }; // Default category to 'news' for sources missing it if (cleaned.type === 'source' && !cleaned.category) cleaned.category = 'news'; // Reasonable defaults if (cleaned.type === 'ticker' && typeof cleaned.sentiment !== 'number') cleaned.sentiment = 0; if (cleaned.type === 'ticker' && !cleaned.spark) { const base = cleaned.price || 100; cleaned.spark = Array.from({length: 16}, (_,k) => +(base * (1 + (Math.random()-0.5)*0.06 + k*0.002)).toFixed(2)); } addedNodes.push(cleaned); liveNodes.push(cleaned); } if (addedNodes.length) { const sourceAdded = addedNodes.filter(isEvidenceNode).length; onLog({ tag: 'add', text: `+${sourceAdded} evidence · ${addedNodes.map(n => `[${n.type[0]}${n.category ? ':'+n.category[0] : ''}] ${n.label}`).slice(0,3).join(', ')}${addedNodes.length>3 ? `, +${addedNodes.length-3} more`:''}` }); } if (rejectedTheses) { onLog({ tag: 'warn', text: `dropped ${rejectedTheses} new thesis node${rejectedTheses === 1 ? '' : 's'} — one hypothesis per web.` }); } // Add new edges (only those where both endpoints exist) const validIds = new Set(liveNodes.map(n => n.id)); const addedEdges = rawEdges.filter(e => e.from && e.to && validIds.has(e.from) && validIds.has(e.to) && e.from !== e.to); if (addedEdges.length) { liveEdges = [...liveEdges, ...addedEdges]; onLog({ tag: 'link', text: `+${addedEdges.length} edges` }); } // Push to React state so the canvas updates live setNodes(liveNodes); setEdges(liveEdges); const sourcesNowAfter = liveNodes.filter(isEvidenceNode).length; const newSourcesAfter = sourcesNowAfter - startSourceCount; onProgress({ iter: i, max: MAX_ITERS, nodeCount: liveNodes.length, newNodes: newSourcesAfter, sourceCount: sourcesNowAfter }); // Verdict is ONLY allowed once we've gathered ≥ goal evidence pieces. if (newSourcesAfter >= MIN_NEW_SOURCES && result.found_trade && result.trade) { foundTrade = result.trade; // Compute supporting/contradicting evidence from edges to t_hypo const hypoId = liveNodes.find(n => n.isHypothesis)?.id || 't_hypo'; const supports = liveEdges.filter(e => (e.to === hypoId || e.from === hypoId) && e.kind === 'supports').length; const contradicts = liveEdges.filter(e => (e.to === hypoId || e.from === hypoId) && e.kind === 'contradicts').length; const total = supports + contradicts; const probFromEdges = total > 0 ? Math.round((supports / total) * 100) : null; const probability = (typeof foundTrade.probability === 'number') ? Math.max(0, Math.min(100, foundTrade.probability)) : (probFromEdges != null ? probFromEdges : 50); foundTrade.probability = probability; foundTrade.supportingCount = foundTrade.supportingCount ?? supports; foundTrade.contradictingCount = foundTrade.contradictingCount ?? contradicts; onLog({ tag: 'find', text: `VERDICT: ${probability}% YES · ${foundTrade.supportingCount} supporting / ${foundTrade.contradictingCount} contradicting · "${foundTrade.name}"`, }); break; } else if (result.found_trade && newSourcesAfter < MIN_NEW_SOURCES) { onLog({ tag: 'warn', text: `Verdict ignored — only ${newSourcesAfter}/${MIN_NEW_SOURCES} evidence pieces. Keep digging.` }); } // Brief pause for visual rhythm await new Promise(r => setTimeout(r, 250)); } const finalSources = liveNodes.filter(isEvidenceNode).length - startSourceCount; if (!foundTrade && finalSources >= MIN_NEW_SOURCES) { onLog({ tag: 'done', text: `Built ${finalSources} evidence pieces — could not converge on a probabilistic verdict. Review the graph for the strongest cluster.` }); } else if (!foundTrade) { onLog({ tag: 'done', text: `Sweep ended early. ${finalSources}/${MIN_NEW_SOURCES} evidence pieces built — need full set for a verdict.` }); } else { onLog({ tag: 'done', text: `Sweep complete · ${foundTrade.probability}% ${foundTrade.verdict || 'YES'} · ${finalSources} evidence pieces.` }); } onResult({ trade: foundTrade, nodeCount: liveNodes.length, edgeCount: liveEdges.length, sourceCount: liveNodes.filter(isEvidenceNode).length, }); } // ─────── React overlay component ─────── const { useState: mmUseState, useEffect: mmUseEffect, useRef: mmUseRef } = React; // Roster used for the analyst-rotation chip strip — must match the ANALYSTS // array in buildPrompt() above. Order matters: cycle index = (iter - 1) % len. const MM_ROSTER_LABELS = [ 'Optimist', 'Contrarian', 'Fundamentals', 'Growth', 'Hedging', 'Mental Models', 'PhD', 'Portfolio Modeling', 'Value', ]; // ───────────────────────────────────────────────────────────────────── // BeliefCard — renders a canonical Mad Max Belief returned by // /api/madmax/run. Same fields as the visionOS/iOS/Android BeliefCard. // Pure presentational; no hooks, no fetch — caller passes the Belief in. // ───────────────────────────────────────────────────────────────────── function BeliefCard({ belief, title }) { if (!belief) return null; const pct = x => `${Math.round(x * 100)}%`; const percentish = x => x == null ? '—' : `${(x * 100).toFixed(1)}%`; const compositeColor = belief.P_composite >= 0.60 ? 'var(--accent, #00d4aa)' : belief.P_composite <= 0.40 ? 'var(--danger, #ff4466)' : 'var(--catalyst, #d4b800)'; const mono = { fontFamily: "'JetBrains Mono', monospace" }; return (
{title !== null && (
ƒ {title || 'CANONICAL MATH SIDECAR'} {belief.divergence_flag && ( △ DIV {Math.round(belief.divergence * 100)}% )}
)} {/* Tier 1 — composite / graph / pool */}
{/* Tier 2 — CI + prior */}
{/* Tier 3 — Kelly + position size */}
0 ? 'var(--accent, #00d4aa)' : 'var(--danger, #ff4466)'} /> {belief.position_size_pct != null && ( 0 ? 'var(--accent, #00d4aa)' : 'var(--danger, #ff4466)'} bold /> )}
{/* Tier 4 — audit */}
{belief.contributing_agents} agents · {belief.pool_method} {belief.graph_solvable && ` · graph: ${belief.graph_diagnostic}`} {belief.is_calibrated && ( isotonic calibrated · n={belief.calibration_sample_size} )}
); } function Stat({ label, value, color }) { return (
{label} {value}
); } function Mini({ label, value, color, bold }) { return (
{label} {value}
); } function MadMaxOverlay({ open, onClose, onHalt, running: runningProp, log, progress, result, onAcceptTrade, onDismiss, activeWeb }) { if (!open) return null; const scrollRef = mmUseRef(null); mmUseEffect(() => { if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; }, [log.length]); const newSources = progress.newNodes || 0; // legacy field name; really the new evidence count const pct = Math.min(100, (newSources / MIN_NEW_SOURCES) * 100); const finalPct = pct; // Phase 6 S — running state comes from parent (per-web map) not // from absence-of-result. Run can be complete (result set) but the // overlay can be hidden + reopened later. const running = runningProp != null ? runningProp : !result; const probability = result?.trade?.probability; const verdict = result?.trade?.verdict; const iter = progress.iter || 0; const currentAnalystIdx = iter > 0 ? (iter - 1) % MM_ROSTER_LABELS.length : -1; const currentAnalyst = currentAnalystIdx >= 0 ? MM_ROSTER_LABELS[currentAnalystIdx] : null; // Phase 2A.3 — canonical Belief state. Fetched on demand by clicking // "Run canonical math" in the result section. The Belief adds the // Brier-weighted log opinion pool + absorbing Markov chain + γ-blended // composite + 80% CI + Kelly-with-P_LB sizing to the local debate-loop // output. const [belief, setBelief] = mmUseState(null); const [beliefFetching, setBeliefFetching] = mmUseState(false); const [beliefError, setBeliefError] = mmUseState(null); async function runCanonicalMath() { if (!result?.trade) return; setBeliefFetching(true); setBeliefError(null); try { const b = await upgradeMadMaxToBelief(activeWeb, result.trade); setBelief(b); } catch (e) { setBeliefError((e?.message || String(e)).slice(0, 200)); } finally { setBeliefFetching(false); } } return (
{running ? 'Mad Max · digging' : 'Mad Max · complete'}
Deep research in motion
{/* Phase 6 S — three close paths: - Hide (background): keeps run alive, click topbar pill to reopen - Stop: hard-aborts the run - Close: tidy after a complete run */}
{running && ( )}
Round
{progress.iter || 0}/{progress.max || 80}
Sources
{newSources}/{MIN_NEW_SOURCES}
Confidence
= 60 ? '#00d4aa' : probability <= 40 ? '#ff4466' : '#d4b800') : 'var(--text-dim)' }}> {typeof probability === 'number' ? `${probability}% ${verdict ? verdict.toUpperCase() : 'YES'}` : 'building…'}
{running ? 'Now' : 'Status'}
{running ? (currentAnalyst || 'starting…') : 'DONE'}
{/* 9-analyst rotation strip — surfaces fix #9. Each chip is one of the brain/0-Agents/ personas. The active one glows; completed ones go green; pending ones stay dim. The user can see at a glance that Mad Max ISN'T just an optimist/contrarian flip but a full team rotation. */}
{MM_ROSTER_LABELS.map((label, i) => { const fullCycle = Math.floor(iter / MM_ROSTER_LABELS.length); const inThisCycle = currentAnalystIdx; const isActive = i === inThisCycle && running; const isDone = fullCycle > 0 || (i < inThisCycle && iter > 0); return ( {label} ); })}
{log.map((row, i) => (
{row.time}
{row.tag}
{row.text}
))}
{result?.trade && (
⌁ High-conviction trade · {result.trade.conviction || result.trade.convictionScore}/100
{result.trade.name}
{result.trade.thesis}
{/* Phase 2A.3 — canonical Belief upgrade. Fetches the backend's unified pipeline (log pool + Markov + γ-blend + 80% CI + Kelly P_LB) and renders the BeliefCard below. */}
{beliefError && (
✕ {beliefError}
)} {belief && }
)}
{newSources}/{MIN_NEW_SOURCES} sources · round {progress.iter || 0}/{progress.max || 80}
DEEP RESEARCH · OPTIMIST vs CONTRARIAN
); } // ───────────────────────────────────────────────────────────────────── // Mad Max canonical pipeline — Phase 2A merge endpoint client. // ───────────────────────────────────────────────────────────────────── // // `POST /api/madmax/run` is the unified backend pipeline that web, iOS, // Android, and visionOS all share. The math (Brier-weighted log opinion // pool + absorbing Markov chain + γ-blended composite + 80% CI + PAV // isotonic calibration + Kelly with P_LB) lives ONLY on the backend. // // The local 9-analyst React debate loop in `runMadMax` above is the // research sketch — it produces a `trade` with the model's subjective // probability. To upgrade that to the canonical Belief, call this // function with the analyst outputs and (optional) MD-style Markov // edges. // // Returns the canonical Belief object. See backend/tibeb/madmax/types/ // belief.py and brain/0-Agents/schemas/Belief.format.md for the field // schema. async function runMadMaxViaBackend({ hypothesis, analystProbabilities = null, perAnalystBrier = null, edges = null, startingState = null, fallbackProbability = null, gamma = 0.7, kellyAlpha = 0.25, kellyHardCap = 0.02, payoffRatio = 2.0, }) { const payload = { hypothesis: hypothesis, gamma, kelly_alpha: kellyAlpha, kelly_hard_cap: kellyHardCap, payoff_ratio: payoffRatio, }; if (Array.isArray(analystProbabilities)) { payload.analyst_probabilities = analystProbabilities; } if (perAnalystBrier && typeof perAnalystBrier === 'object') { payload.per_analyst_brier = Object.fromEntries( Object.entries(perAnalystBrier).map(([k, v]) => [k, [v[0], v[1]]]) ); } if (Array.isArray(edges) && edges.length) { payload.edges = edges.map(e => ({ from: e.from, to: e.to, weight: e.weight, ...(e.sources ? { sources: e.sources } : {}), })); } if (startingState) payload.starting_state = startingState; if (fallbackProbability != null) payload.fallback_probability = fallbackProbability; const r = await fetch('/api/madmax/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!r.ok) { const text = await r.text().catch(() => ''); throw new Error(`madmax/run ${r.status}: ${text.slice(0, 200)}`); } const j = await r.json(); return j.belief; } /** * Convert a local Mad Max verdict (the `mmResult.trade` block from * `runMadMax`) into a canonical Belief by POSTing the analyst-derived * probability + the web's edges to /api/madmax/run. */ async function upgradeMadMaxToBelief(activeWeb, trade) { const hypo = activeWeb?.hypothesis || {}; const hypoNode = (activeWeb?.nodes || []).find(n => n.id === 't_hypo' || n.isHypothesis); const startingState = hypoNode?.label || hypo.question || 'hypothesis'; const referenceClass = { name: 'generic-uninformed', n: 0, k: 0, n_min_threshold: 30, }; const hypothesisPayload = { text: hypo.question || activeWeb?.name || 'untitled hypothesis', resolution_fn: hypo.yesScenario || 'TBD — see web canvas', deadline: hypo.resolveBy || '2099-01-01T00:00:00Z', thesis_type: 'event-driven', horizon_bucket: '3-12mo', tickers: (activeWeb?.nodes || []) .filter(n => (n.type || '').toLowerCase() === 'ticker') .map(n => (n.label || '').replace(/^\$/, '').toUpperCase()) .filter(Boolean), reference_class: referenceClass, }; const edges = (activeWeb?.edges || []) .filter(e => typeof e.weight === 'number') .map(e => ({ from: e.from, to: e.to, weight: e.weight, sources: e.weight_sources })); const fallbackProbability = typeof trade?.probability === 'number' ? Math.max(0, Math.min(1, trade.probability / 100)) : 0.5; return runMadMaxViaBackend({ hypothesis: hypothesisPayload, analystProbabilities: [fallbackProbability], edges: edges.length ? edges : null, startingState, fallbackProbability, }); } Object.assign(window, { runMadMax, MadMaxOverlay, // Phase 2A — backend Belief client (THE MERGE) runMadMaxViaBackend, upgradeMadMaxToBelief, });