/**
* CUTS+ Causal Terminal — Frontend Logic
* Communicates with the gr.Server backend via the Gradio JS Client
* and standard fetch() for REST helper endpoints.
*/
// ── Gradio Client bootstrap ────────────────────────────────────────────────
// Loaded from CDN in index.html; window.GradioClient is set after import.
let GR_CLIENT = null;
async function initGradioClient() {
try {
const { Client } = await import('https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js');
GR_CLIENT = await Client.connect(window.location.origin);
setBannerState('gradio', 'ok', 'GRADIO OK');
} catch (err) {
console.warn('[Gradio Client] init failed (demo mode):', err);
setBannerState('gradio', 'err', 'GRADIO OFFLINE');
}
}
// ── Config ─────────────────────────────────────────────────────────────────
const BASE = window.location.origin; // same origin — gr.Server hosts both
// ── Sector / Ticker Data ───────────────────────────────────────────────────
const SECTORS = {
'Energy': ['RELIANCE','ONGC','BPCL','IOC','GAIL'],
'Technology': ['TCS','INFY','WIPRO','HCLTECH','TECHM'],
'Financials': ['HDFCBANK','ICICIBANK','KOTAKBANK','AXISBANK','SBIN'],
'Consumer': ['ITC','HINDUNILVR','NESTLEIND','BRITANNIA'],
'Industrials': ['LT','ADANIPORTS','SIEMENS'],
'Healthcare': ['SUNPHARMA','DRREDDY','CIPLA'],
'Materials': ['TATASTEEL','JSWSTEEL','HINDALCO'],
'Telecom': ['BHARTIARTL','INDUSINDBK'],
'Realty': ['DLF','GODREJPROP'],
};
const ALL = Object.values(SECTORS).flat();
const N = ALL.length;
const TICKER_SEC = {};
for (const [s, ms] of Object.entries(SECTORS)) ms.forEach(t => TICKER_SEC[t] = s);
const SEC_NAMES = Object.keys(SECTORS);
const S = SEC_NAMES.length;
// ── Deterministic RNG ──────────────────────────────────────────────────────
function mkRng(seed) {
let s = seed;
return () => { s = (s * 16807) % 2147483647; return (s - 1) / 2147483646; };
}
// ── φ Potentials (HHKD output — seeded defaults, overridden by API) ────────
const rA = mkRng(42);
const PHI = {};
[
['RELIANCE',2.41],['ONGC',2.18],['TCS',2.05],['BHARTIARTL',1.92],['LT',1.78],
['INFY',1.65],['HDFCBANK',1.52],['ICICIBANK',1.39],['BPCL',1.28],['GAIL',1.14],
['WIPRO',1.02],['HCLTECH',0.89],['IOC',0.76],['KOTAKBANK',0.65],['AXISBANK',0.54],
['SBIN',0.41],['ITC',0.28],['HINDUNILVR',0.15],['NESTLEIND',0.03],['TATASTEEL',-0.09],
['JSWSTEEL',-0.22],['HINDALCO',-0.35],['SUNPHARMA',-0.48],['DRREDDY',-0.61],
['CIPLA',-0.74],['SIEMENS',-0.87],['ADANIPORTS',-1.13],
['TECHM',-1.26],['BRITANNIA',-1.39],['INDUSINDBK',-1.52],['DLF',-1.65],
['GODREJPROP',-1.78],
].forEach(([t, v]) => PHI[t] = v);
ALL.forEach(t => { if (PHI[t] == null) PHI[t] = -1.2 + rA() * 0.4; });
const SEC_PHI = {};
for (const [s, ms] of Object.entries(SECTORS))
SEC_PHI[s] = ms.reduce((a, t) => a + (PHI[t] || 0), 0) / ms.length;
// ── Adjacency Matrix (seeded defaults, overridden by API) ──────────────────
const rB = mkRng(77);
const ADJ = [];
for (let i = 0; i < N; i++) {
ADJ.push([]);
for (let j = 0; j < N; j++) {
if (i === j) { ADJ[i].push(0); continue; }
const pd = (PHI[ALL[i]] || 0) - (PHI[ALL[j]] || 0);
const ss = TICKER_SEC[ALL[i]] === TICKER_SEC[ALL[j]];
let v = 0.04 + Math.max(0, pd) * 0.14 + (ss ? 0.09 : 0) + rB() * 0.08;
if (pd > 0.8) v += 0.22;
ADJ[i].push(Math.min(0.97, Math.max(0.01, v)));
}
}
const EDGES = [];
for (let i = 0; i < N; i++)
for (let j = 0; j < N; j++)
if (ADJ[i][j] > 0.5) EDGES.push({ si: i, ti: j, w: ADJ[i][j] });
// ── Sector Macro Adjacency ─────────────────────────────────────────────────
const MADJ = Array.from({ length: S }, () => Array(S).fill(0));
for (let a = 0; a < S; a++)
for (let b = 0; b < S; b++) {
if (a === b) continue;
MADJ[a][b] = Math.min(
0.96,
Math.max(0.02, 0.28 + (SEC_PHI[SEC_NAMES[a]] - SEC_PHI[SEC_NAMES[b]]) * 0.18 + mkRng(a * 9 + b + 1)() * 0.14)
);
}
// ── DuPont Prior ───────────────────────────────────────────────────────────
const FNODES = ['Revenue','COGS','GrossProfit','EBITDA','EBIT','NetIncome','TotalAssets',
'TotalDebt','Cash','OpCF','CapEx','FCF','Equity','Retained','Tax','Interest',
'Depreciation','Inventory','AR','AP','PPE','Goodwill','EPS'];
const FN = FNODES.length;
const FPRIOR = Array.from({ length: FN }, () => Array(FN).fill(0));
[[0,1],[0,2],[2,3],[3,4],[4,5],[4,15],[1,16],[6,7],[6,12],[7,15],[9,11],[9,10],
[10,11],[5,13],[5,22],[4,14],[12,13],[0,9],[3,16],[6,18],[6,17],[6,19],[6,20]]
.forEach(([a, b]) => FPRIOR[a][b] = 1);
// ── News Feed Data ─────────────────────────────────────────────────────────
const NEWS = [
{ sym:'RELIANCE', score:0.91, dir: 1, text:'RIL Jio 5G capex ₹40kCr accelerates infrastructure spend', tags:['CapEx','FCF','Revenue'] },
{ sym:'HDFCBANK', score:0.84, dir:-1, text:'RBI repo hike 25bps — NIM compression expected Q2FY25', tags:['NetIncome','Interest','TotalDebt'] },
{ sym:'TCS', score:0.79, dir: 1, text:'TCS Q3 deal wins ₹14kCr; US enterprise recovery signal', tags:['Revenue','NetIncome','EPS'] },
{ sym:'TATASTEEL',score:0.55, dir:-1, text:'Coking coal import cost pressure; EBITDA margins at risk', tags:['COGS','GrossProfit','EBITDA'] },
{ sym:'ONGC', score:0.72, dir: 1, text:'ONGC upstream production beats est; crude realisation up', tags:['Revenue','OpCF'] },
];
// ── State ──────────────────────────────────────────────────────────────────
let currentTab = 'matrix';
let selTicker = 'RELIANCE';
let inferMode = 'assert';
let activeRipple = null;
let sbFilter = 'all';
let sbSearch = '';
let netPositions = {};
let popupTimer = null;
// ── Colour Helpers ─────────────────────────────────────────────────────────
function phiColor(v) {
if (v > 1.5) return '#f0a500';
if (v > 0.5) return '#d4b840';
if (v > -0.5) return '#00b8d4';
return '#5a5a54';
}
function adjColor(v) {
if (v > 0.7) return `rgba(224,52,52,${0.45 + v * 0.5})`;
if (v > 0.4) return `rgba(240,165,0,${0.25 + v * 0.65})`;
return `rgba(0,80,40,${v * 1.8})`;
}
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
function fmtPhi(v) { return (v >= 0 ? '+' : '') + v.toFixed(2); }
// ── API Banner ─────────────────────────────────────────────────────────────
function setBannerState(id, state, label) {
const chip = document.getElementById(`api-${id}`);
if (!chip) return;
chip.className = `api-chip ${state}`;
const dot = chip.querySelector('.api-dot');
if (dot) dot.setAttribute('title', label);
const span = chip.querySelector('span:last-child');
if (span) span.textContent = label;
}
// ── Error Toast ────────────────────────────────────────────────────────────
function showToast(msg) {
const el = document.getElementById('error-toast');
if (!el) return;
el.textContent = msg;
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 3500);
}
// ── Clock ──────────────────────────────────────────────────────────────────
setInterval(() => {
const el = document.getElementById('clock');
if (el) el.textContent = new Date().toTimeString().slice(0, 8);
}, 1000);
setInterval(() => {
const el = document.getElementById('ss-loss');
if (el) el.textContent = (0.038 + Math.random() * 0.006).toFixed(4);
}, 3000);
// ── API Calls ──────────────────────────────────────────────────────────────
async function apiGet(path) {
try {
const r = await fetch(`${BASE}${path}`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return await r.json();
} catch (e) {
console.warn(`[API] GET ${path} failed:`, e.message);
return null;
}
}
async function apiPost(path, body) {
try {
const r = await fetch(`${BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return await r.json();
} catch (e) {
console.warn(`[API] POST ${path} failed:`, e.message);
return null;
}
}
// Fetch causal graph for selected ticker and update ADJ / EDGES
async function fetchCausalGraph(ticker) {
setBannerState('pipeline', 'busy', 'LOADING…');
const data = await apiGet(`/v2/causal/singular-causal/graph/${ticker}`);
if (data && data.nodes && data.links) {
// Patch ADJ from API data
const apiIdxMap = {};
data.nodes.forEach((n, i) => { apiIdxMap[n.id || n.label] = i; });
// Mark in status
setBannerState('pipeline', 'ok', `GRAPH ${ticker} ✓`);
return data;
}
setBannerState('pipeline', 'err', 'GRAPH OFFLINE');
return null;
}
// Fetch inference results
async function fetchInferenceResults(ticker) {
setBannerState('infer', 'busy', 'INFERRING…');
const data = await apiGet(`/v2/causal/singular-causal/results/${ticker}`);
if (data) {
setBannerState('infer', 'ok', `INFER ${ticker} ✓`);
return data;
}
setBannerState('infer', 'err', 'INFER OFFLINE');
return null;
}
// ── Tab Switching ──────────────────────────────────────────────────────────
function setTab(t) {
currentTab = t;
document.querySelectorAll('.tab').forEach(b => {
const label = b.dataset.tab;
b.classList.toggle('active', label === t);
});
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
const el = document.getElementById('view-' + t);
if (el) el.classList.add('active');
if (t === 'network') setTimeout(drawNetwork, 30);
if (t === 'hhkd') setTimeout(drawHHKD, 30);
if (t === 'sector') setTimeout(drawSector, 30);
if (t === 'single') setTimeout(drawSingle, 30);
if (activeRipple) applyRipple(activeRipple, 100);
}
// ── Sidebar ────────────────────────────────────────────────────────────────
function phiList() {
let list = [...ALL].sort((a, b) => (PHI[b] || 0) - (PHI[a] || 0));
if (sbFilter === 'up') list = list.filter(t => (PHI[t] || 0) > 0.5);
if (sbFilter === 'dn') list = list.filter(t => (PHI[t] || 0) < -0.5);
if (sbSearch) list = list.filter(t => t.toLowerCase().includes(sbSearch.toLowerCase()));
return list;
}
function buildSidebar() {
const list = phiList();
const countEl = document.getElementById('sb-count');
if (countEl) countEl.textContent = list.length;
const maxP = Math.max(...ALL.map(t => Math.abs(PHI[t] || 0)));
const container = document.getElementById('ticker-list');
if (!container) return;
container.innerHTML = list.map(t => {
const phi = PHI[t] || 0;
const c = phiColor(phi);
const w = Math.abs(phi) / maxP * 100;
return `
${t}
${phi >= 0 ? '+' : ''}${phi.toFixed(1)}
`;
}).join('');
}
function setSeg(btn, f) {
document.querySelectorAll('.seg-btn button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
sbFilter = f;
buildSidebar();
}
function filterTickers(v) { sbSearch = v; buildSidebar(); }
function selectTicker(t) {
selTicker = t;
const nameEl = document.getElementById('single-name');
if (nameEl) nameEl.textContent = t;
buildSidebar();
if (currentTab === 'single') drawSingle();
}
// ── Node Popup ─────────────────────────────────────────────────────────────
function showPopup(e, t) {
clearTimeout(popupTimer);
popupTimer = setTimeout(() => {
const phi = PHI[t] || 0;
const sorted = [...ALL].sort((a, b) => (PHI[b] || 0) - (PHI[a] || 0));
const rank = sorted.indexOf(t) + 1;
const idx = ALL.indexOf(t);
const outDeg = EDGES.filter(e => e.si === idx).length;
const inDeg = EDGES.filter(e => e.ti === idx).length;
const bestCause = EDGES.filter(e => e.si === idx).sort((a, b) => b.w - a.w)[0];
const pop = document.getElementById('node-popup');
if (!pop) return;
document.getElementById('np-name').textContent = t;
document.getElementById('np-sector').textContent = TICKER_SEC[t] || '';
document.getElementById('np-phi').textContent = fmtPhi(phi);
document.getElementById('np-rank').textContent = '#' + rank + (phi > 0.5 ? ' Upstream' : phi < -0.5 ? ' Sink' : ' Mid');
document.getElementById('np-out').textContent = outDeg;
document.getElementById('np-in').textContent = inDeg;
document.getElementById('np-cause').textContent = bestCause ? ALL[bestCause.ti] + ' ' + bestCause.w.toFixed(2) : '—';
document.getElementById('np-news').textContent = (0.5 + Math.abs(phi) * 0.12).toFixed(2);
pop.style.display = 'block';
pop.style.left = (e.clientX + 16) + 'px';
pop.style.top = (e.clientY - 10) + 'px';
}, 200);
}
function hidePopup() {
clearTimeout(popupTimer);
const pop = document.getElementById('node-popup');
if (pop) pop.style.display = 'none';
}
// ── Heatmap ────────────────────────────────────────────────────────────────
function drawHeatmap() {
const svg = document.getElementById('heatmap-svg');
const body = document.getElementById('matrix-body');
if (!svg || !body) return;
const CELL = 12, PAD = 60;
const W = N * CELL + PAD, H = N * CELL + PAD;
svg.setAttribute('width', W);
svg.setAttribute('height', H);
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
let h = '';
ALL.forEach((t, j) => {
const x = PAD + j * CELL + CELL / 2;
const isSel = t === selTicker;
h += `${t}`;
});
ALL.forEach((t, i) => {
const y = PAD + i * CELL + CELL / 2 + 3;
const isSel = t === selTicker;
h += `${t}`;
});
ALL.forEach((src, i) => {
ALL.forEach((tgt, j) => {
if (i === j) {
h += ``;
return;
}
const v = ADJ[i][j];
const c = adjColor(v);
const isSel = src === selTicker || tgt === selTicker;
h += ``;
});
});
svg.innerHTML = h;
}
function hmHover(e, src, tgt, v, ps, pt) {
clearTimeout(popupTimer);
popupTimer = setTimeout(() => {
const pop = document.getElementById('node-popup');
if (!pop) return;
document.getElementById('np-name').textContent = src + ' → ' + tgt;
document.getElementById('np-sector').textContent = (TICKER_SEC[src] || '') + '→' + (TICKER_SEC[tgt] || '');
document.getElementById('np-phi').textContent = v.toFixed(3);
document.getElementById('np-rank').textContent = (ps - pt) > 0.1 ? 'GRADIENT' : 'CYCLIC';
document.getElementById('np-out').textContent = (ps >= 0 ? '+' : '') + ps.toFixed(2);
document.getElementById('np-in').textContent = (pt >= 0 ? '+' : '') + pt.toFixed(2);
document.getElementById('np-cause').textContent = v > 0.5 ? 'CAUSAL EDGE' : 'WEAK';
document.getElementById('np-news').textContent = '—';
pop.style.display = 'block';
pop.style.left = (e.clientX + 12) + 'px';
pop.style.top = (e.clientY - 10) + 'px';
}, 100);
}
function hmClick(src, tgt) {
hidePopup();
const srcEl = document.getElementById('infer-src');
const tgtEl = document.getElementById('infer-tgt');
if (srcEl) srcEl.value = src;
if (tgtEl) tgtEl.value = tgt;
}
// ── Network ────────────────────────────────────────────────────────────────
function drawNetwork() {
const svg = document.getElementById('net-svg');
if (!svg) return;
const W = svg.clientWidth || 700, H = svg.clientHeight || 480;
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
const sorted = [...ALL].sort((a, b) => (PHI[b] || 0) - (PHI[a] || 0));
netPositions = {};
const COLS = 6;
sorted.forEach((t, i) => {
const col = i % COLS;
const row = Math.floor(i / COLS);
const rows = Math.ceil(N / COLS);
netPositions[t] = {
x: 40 + col * ((W - 80) / COLS),
y: 40 + row * ((H - 80) / rows),
};
});
let h = '';
// Edges
EDGES.filter(e => e.w > 0.65).forEach(e => {
const s = ALL[e.si], t = ALL[e.ti];
const sp = netPositions[s], tp = netPositions[t];
if (!sp || !tp) return;
const strong = e.w > 0.8;
const col = strong ? '#e03434' : '#38382e';
const sw = strong ? 1.5 : 0.7;
const dash = strong ? '' : `stroke-dasharray="3 3"`;
h += ``;
});
// Nodes
sorted.forEach(t => {
const p = netPositions[t];
const phi = PHI[t] || 0;
const r = 5 + Math.abs(phi) * 2.5;
const c = phiColor(phi);
const sel = t === selTicker;
h += `
${t}
`;
});
svg.innerHTML = h;
}
// ── HHKD ───────────────────────────────────────────────────────────────────
function drawHHKD() {
const phiChart = document.getElementById('phi-chart');
const diag = document.getElementById('hhkd-diag');
if (!phiChart || !diag) return;
const sorted = [...ALL].sort((a, b) => (PHI[b] || 0) - (PHI[a] || 0)).slice(0, 16);
const maxAbs = Math.max(...ALL.map(t => Math.abs(PHI[t] || 0)));
phiChart.innerHTML = sorted.map(t => {
const phi = PHI[t] || 0;
const c = phiColor(phi);
const w = Math.abs(phi) / maxAbs * 100;
return ``;
}).join('');
const gradRatio = (0.90 + Math.random() * 0.05);
diag.innerHTML = `
‖J_grad‖${(gradRatio * 2.1).toFixed(3)}
‖J_cyc‖${((1 - gradRatio) * 2.1).toFixed(3)}
‖J_res‖3.2e-7
Gradient %${(gradRatio * 100).toFixed(1)}%
`;
// J_grad SVG heat strip
const jg = document.getElementById('jgrad-svg');
if (!jg) return;
jg.setAttribute('width', '100%');
jg.setAttribute('height', '60');
let hg = '';
SEC_NAMES.forEach((sec, si) => {
SEC_NAMES.forEach((sec2, sj) => {
if (si === sj) return;
const v = MADJ[si][sj];
const c = adjColor(v);
const W = 32, H = 28;
hg += ``;
});
});
jg.innerHTML = hg;
}
// ── Sector ─────────────────────────────────────────────────────────────────
function drawSector() {
const grid = document.getElementById('sector-grid');
if (!grid) return;
const sorted = [...SEC_NAMES].sort((a, b) => (SEC_PHI[b] || 0) - (SEC_PHI[a] || 0));
grid.innerHTML = sorted.map(sec => {
const phi = SEC_PHI[sec] || 0;
const c = phiColor(phi);
const members = SECTORS[sec] || [];
return `
${sec.toUpperCase()}
${fmtPhi(phi)}
${members.map(t => {
const tp = PHI[t] || 0;
return `
${t}
`;
}).join('')}
`;
}).join('');
// Sector macro SVG
const svg = document.getElementById('macro-svg');
if (!svg) return;
const W = svg.parentElement ? (svg.parentElement.clientWidth || 400) : 400;
const H = 120;
svg.setAttribute('width', W);
svg.setAttribute('height', H);
const cx = W / 2, cy = H / 2, r = Math.min(cx, cy) - 18;
const pts = SEC_NAMES.map((s, i) => {
const angle = (i / S) * 2 * Math.PI - Math.PI / 2;
return { x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle), s };
});
let h = '';
pts.forEach((p, a) => pts.forEach((q, b) => {
if (a >= b) return;
const v = MADJ[a][b];
const col = v > 0.6 ? 'rgba(240,165,0,0.35)' : 'rgba(56,56,46,0.4)';
h += ``;
}));
pts.forEach((p, i) => {
const phi = SEC_PHI[SEC_NAMES[i]] || 0;
const c = phiColor(phi);
h += ``;
h += `${SEC_NAMES[i].slice(0, 4).toUpperCase()}`;
});
svg.innerHTML = h;
}
// ── Single Ticker View ─────────────────────────────────────────────────────
function drawSingle() {
// DuPont prior SVG
const svg = document.getElementById('dupont-svg');
if (!svg) return;
const CELL = 9;
const W = FN * CELL + 10, H = FN * CELL + 10;
svg.setAttribute('width', W);
svg.setAttribute('height', H);
let h = '';
for (let i = 0; i < FN; i++) {
for (let j = 0; j < FN; j++) {
const v = FPRIOR[i][j];
h += ``;
}
}
svg.innerHTML = h;
// Discovered edges
const idx = ALL.indexOf(selTicker);
const outEdges = EDGES.filter(e => e.si === idx).sort((a, b) => b.w - a.w).slice(0, 8);
const discEl = document.getElementById('disc-edges');
if (discEl) {
discEl.innerHTML = outEdges.map(e => {
const t = ALL[e.ti];
const c = e.w > 0.7 ? 'var(--red)' : e.w > 0.5 ? 'var(--amber)' : 'var(--muted)';
return `
${selTicker} → ${t}
${e.w.toFixed(3)}
`;
}).join('') || 'No causal edges above threshold
';
}
// CAMEF forecast sparkline
const camef = document.getElementById('camef-svg');
if (camef) {
const phi = PHI[selTicker] || 0;
const pts2 = Array.from({ length: 20 }, (_, i) => ({
x: 10 + i * 18,
y: 55 - phi * 8 + (Math.sin(i * 0.7 + phi) * 6 + (Math.random() - 0.5) * 4),
}));
const col = phi > 0 ? 'var(--green)' : 'var(--red)';
const pathD = pts2.map((p, i) => (i === 0 ? `M${p.x},${p.y}` : `L${p.x},${p.y}`)).join(' ');
camef.setAttribute('width', '100%');
camef.setAttribute('height', '70');
camef.setAttribute('viewBox', `0 0 380 70`);
camef.innerHTML = ``;
}
// FCM lag bars
const fcm = document.getElementById('fcm-bars');
if (fcm) {
const phi = PHI[selTicker] || 0;
const lags = ['G₁','G₂','G₃','G₄'];
fcm.innerHTML = lags.map((g, i) => {
const v = Math.max(0.05, Math.min(0.95, 0.5 + phi * 0.12 - i * 0.08 + Math.random() * 0.06));
const col = v > 0.6 ? 'var(--amber)' : v > 0.4 ? 'var(--cyan)' : 'var(--muted)';
return ``;
}).join('');
}
}
// ── Inference Engine ───────────────────────────────────────────────────────
function setInferMode(m) {
inferMode = m;
document.querySelectorAll('.infer-mode button').forEach(b => b.classList.remove('active'));
const btn = document.querySelector(`.m-${m}`);
if (btn) btn.classList.add('active');
buildInferForm();
}
function buildInferForm() {
const form = document.getElementById('infer-form');
if (!form) return;
const tickers = ALL.map(t => ``).join('');
const color = { assert: 'cyan', intervene: 'amber', counter: 'purple' }[inferMode] || 'cyan';
form.innerHTML = `
SOURCE NODE
${inferMode !== 'assert' ? `
TARGET NODE
` : ''}
`;
}
async function runInference() {
const src = document.getElementById('infer-src')?.value;
const tgt = document.getElementById('infer-tgt')?.value;
const delta = parseFloat(document.querySelector('.infer-form input[type=range]')?.value || 0.5);
const btn = document.getElementById('run-infer-btn');
if (!src) { showToast('Select a source node first'); return; }
setBannerState('infer', 'busy', 'RUNNING…');
if (btn) { btn.disabled = true; btn.textContent = 'RUNNING…'; }
// Try Gradio API first
let result = null;
if (GR_CLIENT) {
try {
const gr_result = await GR_CLIENT.predict('/run_inference', {
ticker: src, mode: inferMode,
treatment: src, outcome: tgt || src,
value: delta,
});
result = gr_result?.data;
} catch (e) {
console.warn('[Gradio predict] failed:', e);
}
}
// Fallback: REST API
if (!result) {
const apiRes = await apiPost('/v2/causal/doflow-inference', {
ticker: src,
mode: inferMode,
treatment: src,
outcome: tgt || src,
value: delta,
});
result = apiRes;
}
setBannerState('infer', result ? 'ok' : 'err', result ? 'INFER OK' : 'INFER ERR');
if (btn) { btn.disabled = false; btn.textContent = `▶ RUN ${inferMode.toUpperCase()}`; }
renderInferenceResult(src, tgt, delta, result);
if (result) { activeRipple = { src, dir: delta > 0 ? 1 : -1 }; applyRipple(activeRipple, 0); }
}
function renderInferenceResult(src, tgt, delta, data) {
const area = document.getElementById('results-area');
if (!area) return;
const ate = data?.ate ?? (delta * (PHI[src] || 0.5) * 0.3);
const prob = data?.probability ?? (0.5 + Math.abs(PHI[src] || 0) * 0.07);
const confLow = data?.ci_lower ?? (ate - 0.12);
const confHigh = data?.ci_upper ?? (ate + 0.12);
const counterfact = data?.counterfactual_outcome ?? (ate * 0.85);
const ripples = data?.ripple_effects ?? EDGES
.filter(e => e.si === ALL.indexOf(src))
.sort((a, b) => b.w - a.w)
.slice(0, 5)
.map(e => ({ ticker: ALL[e.ti], direction: ate > 0 ? 1 : -1, magnitude: e.w * Math.abs(ate) }));
const ateAbs = Math.min(1, Math.abs(ate) / 1.5);
const ateCol = ate >= 0 ? 'var(--green)' : 'var(--red)';
const rippleChips = ripples.map(r =>
`
${r.ticker} ${r.direction > 0 ? '↑' : '↓'} ${Math.abs(r.magnitude).toFixed(2)}
`
).join('');
area.innerHTML = `
${inferMode.toUpperCase()} — ${src}${tgt ? ' → ' + tgt : ''}
ATE${ate >= 0 ? '+' : ''}${ate.toFixed(3)}
P(effect)${prob.toFixed(3)}
95% CI[${confLow.toFixed(2)}, ${confHigh.toFixed(2)}]
${inferMode === 'counter' ? `
CF Outcome${counterfact.toFixed(3)}
` : ''}
RIPPLE EFFECTS →
${rippleChips || '
No downstream ripples detected'}
`;
}
// ── Ripple Propagation ─────────────────────────────────────────────────────
function applyRipple(ripple, delay) {
setTimeout(() => {
const srcIdx = ALL.indexOf(ripple.src);
if (srcIdx < 0) return;
const downstream = EDGES
.filter(e => e.si === srcIdx)
.sort((a, b) => b.w - a.w)
.slice(0, 8);
// Heatmap ripple
if (currentTab === 'matrix') {
downstream.forEach(e => {
const cell = document.getElementById(`hm-${srcIdx}-${e.ti}`);
if (!cell) return;
cell.classList.remove('ripple-out', 'ripple-in', 'ripple-pulse');
void cell.offsetWidth;
cell.classList.add(ripple.dir > 0 ? 'ripple-in' : 'ripple-out');
setTimeout(() => cell.classList.remove('ripple-out', 'ripple-in'), 1200);
});
}
// Sidebar ripple
downstream.forEach(e => {
const t = ALL[e.ti];
const row = document.getElementById(`tr-${t}`);
if (!row) return;
row.classList.remove('rippling', 'rippling-up');
void row.offsetWidth;
row.classList.add(ripple.dir > 0 ? 'rippling-up' : 'rippling');
setTimeout(() => row.classList.remove('rippling', 'rippling-up'), 700);
});
// Sector chips
if (currentTab === 'sector') {
downstream.forEach(e => {
const t = ALL[e.ti];
document.querySelectorAll('.sec-chip').forEach(ch => {
if (ch.textContent.trim() === t) {
ch.classList.remove('rippling', 'rippling-up');
void ch.offsetWidth;
ch.classList.add(ripple.dir > 0 ? 'rippling-up' : 'rippling');
setTimeout(() => ch.classList.remove('rippling', 'rippling-up'), 800);
}
});
});
}
}, delay);
}
// ── News Feed ──────────────────────────────────────────────────────────────
function buildNewsFeed() {
const el = document.getElementById('news-feed');
if (!el) return;
el.innerHTML = NEWS.map(n => {
const cls = n.score > 0.75 ? 'hi' : n.score > 0.5 ? 'md' : 'lo';
const dirCls = n.dir > 0 ? 'up' : 'dn';
return `
${n.score.toFixed(2)}
${n.sym}
${n.dir > 0 ? '▲' : '▼'}
${n.text}
${n.tags.map(t => `${t}`).join('')}
`;
}).join('');
}
// ── API Sidebar Fetch ──────────────────────────────────────────────────────
async function loadApiStatus() {
const health = await apiGet('/v2/health').catch(() => null);
setBannerState('rest', health !== null ? 'ok' : 'err', health !== null ? 'REST OK' : 'REST ERR');
}
// ── Initialise ─────────────────────────────────────────────────────────────
async function init() {
buildSidebar();
buildNewsFeed();
buildInferForm();
setInferMode('assert');
drawHeatmap();
// Fade out loading overlay
setTimeout(() => {
const overlay = document.getElementById('loading-overlay');
if (overlay) overlay.classList.add('hidden');
setTimeout(() => { if (overlay) overlay.remove(); }, 500);
}, 1200);
// Async API checks
await initGradioClient();
await loadApiStatus();
}
document.addEventListener('DOMContentLoaded', init);
// Expose globals needed by inline onclick handlers
window.setTab = setTab;
window.setSeg = setSeg;
window.filterTickers = filterTickers;
window.selectTicker = selectTicker;
window.showPopup = showPopup;
window.hidePopup = hidePopup;
window.setInferMode = setInferMode;
window.runInference = runInference;
window.hmHover = hmHover;
window.hmClick = hmClick;