Abhijeet Mahapatra commited on
Commit
f86ffb5
Β·
2 Parent(s): 5eaf9ffc8375d0

Merged branches

Browse files
Files changed (6) hide show
  1. .gitignore +2 -2
  2. README.md +7 -0
  3. frontend/app.js +849 -0
  4. frontend/index.html +269 -0
  5. frontend/style.css +711 -0
  6. server.py +918 -0
.gitignore CHANGED
@@ -7,7 +7,7 @@
7
  *.csv
8
  *.png
9
  *.grd
10
- *.json
11
- *.yaml
12
  *.owl
13
  *.out
 
 
 
7
  *.csv
8
  *.png
9
  *.grd
 
 
10
  *.owl
11
  *.out
12
+ *.json
13
+ *.yaml
README.md CHANGED
@@ -7,3 +7,10 @@ sdk_version: 6.18.0
7
  app_file: server.py
8
  pinned: false
9
  ---
 
 
 
 
 
 
 
 
7
  app_file: server.py
8
  pinned: false
9
  ---
10
+
11
+ # Iroha - Financial Intelligence Pipeline API
12
+
13
+ **Version:** 1.0.0
14
+ **Description:** End-to-end financial intelligence pipeline for Net-of-Tax Alpha decisions using causal chain analysis, web scraping, and data aggregation.
15
+
16
+ ---
frontend/app.js ADDED
@@ -0,0 +1,849 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * CUTS+ Causal Terminal β€” Frontend Logic
3
+ * Communicates with the gr.Server backend via the Gradio JS Client
4
+ * and standard fetch() for REST helper endpoints.
5
+ */
6
+
7
+ // ── Gradio Client bootstrap ────────────────────────────────────────────────
8
+ // Loaded from CDN in index.html; window.GradioClient is set after import.
9
+ let GR_CLIENT = null;
10
+
11
+ async function initGradioClient() {
12
+ try {
13
+ const { Client } = await import('https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js');
14
+ GR_CLIENT = await Client.connect(window.location.origin);
15
+ setBannerState('gradio', 'ok', 'GRADIO OK');
16
+ } catch (err) {
17
+ console.warn('[Gradio Client] init failed (demo mode):', err);
18
+ setBannerState('gradio', 'err', 'GRADIO OFFLINE');
19
+ }
20
+ }
21
+
22
+ // ── Config ─────────────────────────────────────────────────────────────────
23
+ const BASE = window.location.origin; // same origin β€” gr.Server hosts both
24
+
25
+ // ── Sector / Ticker Data ───────────────────────────────────────────────────
26
+ const SECTORS = {
27
+ 'Energy': ['RELIANCE','ONGC','BPCL','IOC','GAIL'],
28
+ 'Technology': ['TCS','INFY','WIPRO','HCLTECH','TECHM'],
29
+ 'Financials': ['HDFCBANK','ICICIBANK','KOTAKBANK','AXISBANK','SBIN'],
30
+ 'Consumer': ['ITC','HINDUNILVR','NESTLEIND','BRITANNIA'],
31
+ 'Industrials': ['LT','ADANIPORTS','SIEMENS'],
32
+ 'Healthcare': ['SUNPHARMA','DRREDDY','CIPLA'],
33
+ 'Materials': ['TATASTEEL','JSWSTEEL','HINDALCO'],
34
+ 'Telecom': ['BHARTIARTL','INDUSINDBK'],
35
+ 'Realty': ['DLF','GODREJPROP'],
36
+ };
37
+ const ALL = Object.values(SECTORS).flat();
38
+ const N = ALL.length;
39
+ const TICKER_SEC = {};
40
+ for (const [s, ms] of Object.entries(SECTORS)) ms.forEach(t => TICKER_SEC[t] = s);
41
+ const SEC_NAMES = Object.keys(SECTORS);
42
+ const S = SEC_NAMES.length;
43
+
44
+ // ── Deterministic RNG ──────────────────────────────────────────────────────
45
+ function mkRng(seed) {
46
+ let s = seed;
47
+ return () => { s = (s * 16807) % 2147483647; return (s - 1) / 2147483646; };
48
+ }
49
+
50
+ // ── Ο† Potentials (HHKD output β€” seeded defaults, overridden by API) ────────
51
+ const rA = mkRng(42);
52
+ const PHI = {};
53
+ [
54
+ ['RELIANCE',2.41],['ONGC',2.18],['TCS',2.05],['BHARTIARTL',1.92],['LT',1.78],
55
+ ['INFY',1.65],['HDFCBANK',1.52],['ICICIBANK',1.39],['BPCL',1.28],['GAIL',1.14],
56
+ ['WIPRO',1.02],['HCLTECH',0.89],['IOC',0.76],['KOTAKBANK',0.65],['AXISBANK',0.54],
57
+ ['SBIN',0.41],['ITC',0.28],['HINDUNILVR',0.15],['NESTLEIND',0.03],['TATASTEEL',-0.09],
58
+ ['JSWSTEEL',-0.22],['HINDALCO',-0.35],['SUNPHARMA',-0.48],['DRREDDY',-0.61],
59
+ ['CIPLA',-0.74],['SIEMENS',-0.87],['ADANIPORTS',-1.13],
60
+ ['TECHM',-1.26],['BRITANNIA',-1.39],['INDUSINDBK',-1.52],['DLF',-1.65],
61
+ ['GODREJPROP',-1.78],
62
+ ].forEach(([t, v]) => PHI[t] = v);
63
+ ALL.forEach(t => { if (PHI[t] == null) PHI[t] = -1.2 + rA() * 0.4; });
64
+
65
+ const SEC_PHI = {};
66
+ for (const [s, ms] of Object.entries(SECTORS))
67
+ SEC_PHI[s] = ms.reduce((a, t) => a + (PHI[t] || 0), 0) / ms.length;
68
+
69
+ // ── Adjacency Matrix (seeded defaults, overridden by API) ──────────────────
70
+ const rB = mkRng(77);
71
+ const ADJ = [];
72
+ for (let i = 0; i < N; i++) {
73
+ ADJ.push([]);
74
+ for (let j = 0; j < N; j++) {
75
+ if (i === j) { ADJ[i].push(0); continue; }
76
+ const pd = (PHI[ALL[i]] || 0) - (PHI[ALL[j]] || 0);
77
+ const ss = TICKER_SEC[ALL[i]] === TICKER_SEC[ALL[j]];
78
+ let v = 0.04 + Math.max(0, pd) * 0.14 + (ss ? 0.09 : 0) + rB() * 0.08;
79
+ if (pd > 0.8) v += 0.22;
80
+ ADJ[i].push(Math.min(0.97, Math.max(0.01, v)));
81
+ }
82
+ }
83
+
84
+ const EDGES = [];
85
+ for (let i = 0; i < N; i++)
86
+ for (let j = 0; j < N; j++)
87
+ if (ADJ[i][j] > 0.5) EDGES.push({ si: i, ti: j, w: ADJ[i][j] });
88
+
89
+ // ── Sector Macro Adjacency ─────────────────────────────────────────────────
90
+ const MADJ = Array.from({ length: S }, () => Array(S).fill(0));
91
+ for (let a = 0; a < S; a++)
92
+ for (let b = 0; b < S; b++) {
93
+ if (a === b) continue;
94
+ MADJ[a][b] = Math.min(
95
+ 0.96,
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)
97
+ );
98
+ }
99
+
100
+ // ── DuPont Prior ───────────────────────────────────────────────────────────
101
+ const FNODES = ['Revenue','COGS','GrossProfit','EBITDA','EBIT','NetIncome','TotalAssets',
102
+ 'TotalDebt','Cash','OpCF','CapEx','FCF','Equity','Retained','Tax','Interest',
103
+ 'Depreciation','Inventory','AR','AP','PPE','Goodwill','EPS'];
104
+ const FN = FNODES.length;
105
+ const FPRIOR = Array.from({ length: FN }, () => Array(FN).fill(0));
106
+ [[0,1],[0,2],[2,3],[3,4],[4,5],[4,15],[1,16],[6,7],[6,12],[7,15],[9,11],[9,10],
107
+ [10,11],[5,13],[5,22],[4,14],[12,13],[0,9],[3,16],[6,18],[6,17],[6,19],[6,20]]
108
+ .forEach(([a, b]) => FPRIOR[a][b] = 1);
109
+
110
+ // ── News Feed Data ─────────────────────────────────────────────────────────
111
+ const NEWS = [
112
+ { sym:'RELIANCE', score:0.91, dir: 1, text:'RIL Jio 5G capex β‚Ή40kCr accelerates infrastructure spend', tags:['CapEx','FCF','Revenue'] },
113
+ { sym:'HDFCBANK', score:0.84, dir:-1, text:'RBI repo hike 25bps β€” NIM compression expected Q2FY25', tags:['NetIncome','Interest','TotalDebt'] },
114
+ { sym:'TCS', score:0.79, dir: 1, text:'TCS Q3 deal wins β‚Ή14kCr; US enterprise recovery signal', tags:['Revenue','NetIncome','EPS'] },
115
+ { sym:'TATASTEEL',score:0.55, dir:-1, text:'Coking coal import cost pressure; EBITDA margins at risk', tags:['COGS','GrossProfit','EBITDA'] },
116
+ { sym:'ONGC', score:0.72, dir: 1, text:'ONGC upstream production beats est; crude realisation up', tags:['Revenue','OpCF'] },
117
+ ];
118
+
119
+ // ── State ──────────────────────────────────────────────────────────────────
120
+ let currentTab = 'matrix';
121
+ let selTicker = 'RELIANCE';
122
+ let inferMode = 'assert';
123
+ let activeRipple = null;
124
+ let sbFilter = 'all';
125
+ let sbSearch = '';
126
+ let netPositions = {};
127
+ let popupTimer = null;
128
+
129
+ // ── Colour Helpers ─────────────────────────────────────────────────────────
130
+ function phiColor(v) {
131
+ if (v > 1.5) return '#f0a500';
132
+ if (v > 0.5) return '#d4b840';
133
+ if (v > -0.5) return '#00b8d4';
134
+ return '#5a5a54';
135
+ }
136
+ function adjColor(v) {
137
+ if (v > 0.7) return `rgba(224,52,52,${0.45 + v * 0.5})`;
138
+ if (v > 0.4) return `rgba(240,165,0,${0.25 + v * 0.65})`;
139
+ return `rgba(0,80,40,${v * 1.8})`;
140
+ }
141
+ function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
142
+ function fmtPhi(v) { return (v >= 0 ? '+' : '') + v.toFixed(2); }
143
+
144
+ // ── API Banner ─────────────────────────────────────────────────────────────
145
+ function setBannerState(id, state, label) {
146
+ const chip = document.getElementById(`api-${id}`);
147
+ if (!chip) return;
148
+ chip.className = `api-chip ${state}`;
149
+ const dot = chip.querySelector('.api-dot');
150
+ if (dot) dot.setAttribute('title', label);
151
+ const span = chip.querySelector('span:last-child');
152
+ if (span) span.textContent = label;
153
+ }
154
+
155
+ // ── Error Toast ────────────────────────────────────────────────────────────
156
+ function showToast(msg) {
157
+ const el = document.getElementById('error-toast');
158
+ if (!el) return;
159
+ el.textContent = msg;
160
+ el.classList.add('show');
161
+ setTimeout(() => el.classList.remove('show'), 3500);
162
+ }
163
+
164
+ // ── Clock ──────────────────────────────────────────────────────────────────
165
+ setInterval(() => {
166
+ const el = document.getElementById('clock');
167
+ if (el) el.textContent = new Date().toTimeString().slice(0, 8);
168
+ }, 1000);
169
+ setInterval(() => {
170
+ const el = document.getElementById('ss-loss');
171
+ if (el) el.textContent = (0.038 + Math.random() * 0.006).toFixed(4);
172
+ }, 3000);
173
+
174
+ // ── API Calls ──────────────────────────────────────────────────────────────
175
+ async function apiGet(path) {
176
+ try {
177
+ const r = await fetch(`${BASE}${path}`);
178
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
179
+ return await r.json();
180
+ } catch (e) {
181
+ console.warn(`[API] GET ${path} failed:`, e.message);
182
+ return null;
183
+ }
184
+ }
185
+
186
+ async function apiPost(path, body) {
187
+ try {
188
+ const r = await fetch(`${BASE}${path}`, {
189
+ method: 'POST',
190
+ headers: { 'Content-Type': 'application/json' },
191
+ body: JSON.stringify(body),
192
+ });
193
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
194
+ return await r.json();
195
+ } catch (e) {
196
+ console.warn(`[API] POST ${path} failed:`, e.message);
197
+ return null;
198
+ }
199
+ }
200
+
201
+ // Fetch causal graph for selected ticker and update ADJ / EDGES
202
+ async function fetchCausalGraph(ticker) {
203
+ setBannerState('pipeline', 'busy', 'LOADING…');
204
+ const data = await apiGet(`/v2/causal/singular-causal/graph/${ticker}`);
205
+ if (data && data.nodes && data.links) {
206
+ // Patch ADJ from API data
207
+ const apiIdxMap = {};
208
+ data.nodes.forEach((n, i) => { apiIdxMap[n.id || n.label] = i; });
209
+ // Mark in status
210
+ setBannerState('pipeline', 'ok', `GRAPH ${ticker} βœ“`);
211
+ return data;
212
+ }
213
+ setBannerState('pipeline', 'err', 'GRAPH OFFLINE');
214
+ return null;
215
+ }
216
+
217
+ // Fetch inference results
218
+ async function fetchInferenceResults(ticker) {
219
+ setBannerState('infer', 'busy', 'INFERRING…');
220
+ const data = await apiGet(`/v2/causal/singular-causal/results/${ticker}`);
221
+ if (data) {
222
+ setBannerState('infer', 'ok', `INFER ${ticker} βœ“`);
223
+ return data;
224
+ }
225
+ setBannerState('infer', 'err', 'INFER OFFLINE');
226
+ return null;
227
+ }
228
+
229
+ // ── Tab Switching ──────────────────────────────────────────────────────────
230
+ function setTab(t) {
231
+ currentTab = t;
232
+ document.querySelectorAll('.tab').forEach(b => {
233
+ const label = b.dataset.tab;
234
+ b.classList.toggle('active', label === t);
235
+ });
236
+ document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
237
+ const el = document.getElementById('view-' + t);
238
+ if (el) el.classList.add('active');
239
+ if (t === 'network') setTimeout(drawNetwork, 30);
240
+ if (t === 'hhkd') setTimeout(drawHHKD, 30);
241
+ if (t === 'sector') setTimeout(drawSector, 30);
242
+ if (t === 'single') setTimeout(drawSingle, 30);
243
+ if (activeRipple) applyRipple(activeRipple, 100);
244
+ }
245
+
246
+ // ── Sidebar ────────────────────────────────────────────────────────────────
247
+ function phiList() {
248
+ let list = [...ALL].sort((a, b) => (PHI[b] || 0) - (PHI[a] || 0));
249
+ if (sbFilter === 'up') list = list.filter(t => (PHI[t] || 0) > 0.5);
250
+ if (sbFilter === 'dn') list = list.filter(t => (PHI[t] || 0) < -0.5);
251
+ if (sbSearch) list = list.filter(t => t.toLowerCase().includes(sbSearch.toLowerCase()));
252
+ return list;
253
+ }
254
+
255
+ function buildSidebar() {
256
+ const list = phiList();
257
+ const countEl = document.getElementById('sb-count');
258
+ if (countEl) countEl.textContent = list.length;
259
+ const maxP = Math.max(...ALL.map(t => Math.abs(PHI[t] || 0)));
260
+ const container = document.getElementById('ticker-list');
261
+ if (!container) return;
262
+ container.innerHTML = list.map(t => {
263
+ const phi = PHI[t] || 0;
264
+ const c = phiColor(phi);
265
+ const w = Math.abs(phi) / maxP * 100;
266
+ return `<div class="ticker-row${t === selTicker ? ' sel' : ''}" id="tr-${t}"
267
+ onclick="selectTicker('${t}')"
268
+ onmouseenter="showPopup(event,'${t}')"
269
+ onmouseleave="hidePopup()">
270
+ <span class="t-sym">${t}</span>
271
+ <div class="t-bar"><div class="t-bar-fill" style="width:${w}%;background:${c};"></div></div>
272
+ <span class="t-phi" style="color:${c};">${phi >= 0 ? '+' : ''}${phi.toFixed(1)}</span>
273
+ </div>`;
274
+ }).join('');
275
+ }
276
+
277
+ function setSeg(btn, f) {
278
+ document.querySelectorAll('.seg-btn button').forEach(b => b.classList.remove('active'));
279
+ btn.classList.add('active');
280
+ sbFilter = f;
281
+ buildSidebar();
282
+ }
283
+
284
+ function filterTickers(v) { sbSearch = v; buildSidebar(); }
285
+
286
+ function selectTicker(t) {
287
+ selTicker = t;
288
+ const nameEl = document.getElementById('single-name');
289
+ if (nameEl) nameEl.textContent = t;
290
+ buildSidebar();
291
+ if (currentTab === 'single') drawSingle();
292
+ }
293
+
294
+ // ── Node Popup ─────────────────────────────────────────────────────────────
295
+ function showPopup(e, t) {
296
+ clearTimeout(popupTimer);
297
+ popupTimer = setTimeout(() => {
298
+ const phi = PHI[t] || 0;
299
+ const sorted = [...ALL].sort((a, b) => (PHI[b] || 0) - (PHI[a] || 0));
300
+ const rank = sorted.indexOf(t) + 1;
301
+ const idx = ALL.indexOf(t);
302
+ const outDeg = EDGES.filter(e => e.si === idx).length;
303
+ const inDeg = EDGES.filter(e => e.ti === idx).length;
304
+ const bestCause = EDGES.filter(e => e.si === idx).sort((a, b) => b.w - a.w)[0];
305
+ const pop = document.getElementById('node-popup');
306
+ if (!pop) return;
307
+ document.getElementById('np-name').textContent = t;
308
+ document.getElementById('np-sector').textContent = TICKER_SEC[t] || '';
309
+ document.getElementById('np-phi').textContent = fmtPhi(phi);
310
+ document.getElementById('np-rank').textContent = '#' + rank + (phi > 0.5 ? ' Upstream' : phi < -0.5 ? ' Sink' : ' Mid');
311
+ document.getElementById('np-out').textContent = outDeg;
312
+ document.getElementById('np-in').textContent = inDeg;
313
+ document.getElementById('np-cause').textContent = bestCause ? ALL[bestCause.ti] + ' ' + bestCause.w.toFixed(2) : 'β€”';
314
+ document.getElementById('np-news').textContent = (0.5 + Math.abs(phi) * 0.12).toFixed(2);
315
+ pop.style.display = 'block';
316
+ pop.style.left = (e.clientX + 16) + 'px';
317
+ pop.style.top = (e.clientY - 10) + 'px';
318
+ }, 200);
319
+ }
320
+
321
+ function hidePopup() {
322
+ clearTimeout(popupTimer);
323
+ const pop = document.getElementById('node-popup');
324
+ if (pop) pop.style.display = 'none';
325
+ }
326
+
327
+ // ── Heatmap ────────────────────────────────────────────────────────────────
328
+ function drawHeatmap() {
329
+ const svg = document.getElementById('heatmap-svg');
330
+ const body = document.getElementById('matrix-body');
331
+ if (!svg || !body) return;
332
+ const CELL = 12, PAD = 60;
333
+ const W = N * CELL + PAD, H = N * CELL + PAD;
334
+ svg.setAttribute('width', W);
335
+ svg.setAttribute('height', H);
336
+ svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
337
+ let h = '';
338
+ ALL.forEach((t, j) => {
339
+ const x = PAD + j * CELL + CELL / 2;
340
+ const isSel = t === selTicker;
341
+ h += `<text x="${x}" y="${PAD - 3}" fill="${isSel ? '#f0a500' : '#424240'}" font-size="7"
342
+ font-family="IBM Plex Mono" text-anchor="end"
343
+ transform="rotate(-60,${x},${PAD - 3})">${t}</text>`;
344
+ });
345
+ ALL.forEach((t, i) => {
346
+ const y = PAD + i * CELL + CELL / 2 + 3;
347
+ const isSel = t === selTicker;
348
+ h += `<text x="${PAD - 3}" y="${y}" fill="${isSel ? '#f0a500' : '#424240'}" font-size="7"
349
+ font-family="IBM Plex Mono" text-anchor="end">${t}</text>`;
350
+ });
351
+ ALL.forEach((src, i) => {
352
+ ALL.forEach((tgt, j) => {
353
+ if (i === j) {
354
+ h += `<rect x="${PAD + j * CELL}" y="${PAD + i * CELL}" width="${CELL - 1}" height="${CELL - 1}" fill="#111" rx="1"/>`;
355
+ return;
356
+ }
357
+ const v = ADJ[i][j];
358
+ const c = adjColor(v);
359
+ const isSel = src === selTicker || tgt === selTicker;
360
+ h += `<rect id="hm-${i}-${j}" class="hm-cell"
361
+ x="${PAD + j * CELL}" y="${PAD + i * CELL}"
362
+ width="${CELL - 1}" height="${CELL - 1}"
363
+ fill="${c}"
364
+ stroke="${isSel ? 'rgba(240,165,0,0.3)' : '#0f0f0f'}"
365
+ stroke-width="${isSel ? 1 : 0.3}" rx="1"
366
+ onmousemove="hmHover(event,'${src}','${tgt}',${v.toFixed(3)},${(PHI[src] || 0).toFixed(2)},${(PHI[tgt] || 0).toFixed(2)})"
367
+ onmouseleave="hidePopup()"
368
+ onclick="hmClick('${src}','${tgt}',${v.toFixed(3)})"/>`;
369
+ });
370
+ });
371
+ svg.innerHTML = h;
372
+ }
373
+
374
+ function hmHover(e, src, tgt, v, ps, pt) {
375
+ clearTimeout(popupTimer);
376
+ popupTimer = setTimeout(() => {
377
+ const pop = document.getElementById('node-popup');
378
+ if (!pop) return;
379
+ document.getElementById('np-name').textContent = src + ' β†’ ' + tgt;
380
+ document.getElementById('np-sector').textContent = (TICKER_SEC[src] || '') + 'β†’' + (TICKER_SEC[tgt] || '');
381
+ document.getElementById('np-phi').textContent = v.toFixed(3);
382
+ document.getElementById('np-rank').textContent = (ps - pt) > 0.1 ? 'GRADIENT' : 'CYCLIC';
383
+ document.getElementById('np-out').textContent = (ps >= 0 ? '+' : '') + ps.toFixed(2);
384
+ document.getElementById('np-in').textContent = (pt >= 0 ? '+' : '') + pt.toFixed(2);
385
+ document.getElementById('np-cause').textContent = v > 0.5 ? 'CAUSAL EDGE' : 'WEAK';
386
+ document.getElementById('np-news').textContent = 'β€”';
387
+ pop.style.display = 'block';
388
+ pop.style.left = (e.clientX + 12) + 'px';
389
+ pop.style.top = (e.clientY - 10) + 'px';
390
+ }, 100);
391
+ }
392
+
393
+ function hmClick(src, tgt) {
394
+ hidePopup();
395
+ const srcEl = document.getElementById('infer-src');
396
+ const tgtEl = document.getElementById('infer-tgt');
397
+ if (srcEl) srcEl.value = src;
398
+ if (tgtEl) tgtEl.value = tgt;
399
+ }
400
+
401
+ // ── Network ────────────────────────────────────────────────────────────────
402
+ function drawNetwork() {
403
+ const svg = document.getElementById('net-svg');
404
+ if (!svg) return;
405
+ const W = svg.clientWidth || 700, H = svg.clientHeight || 480;
406
+ svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
407
+ const sorted = [...ALL].sort((a, b) => (PHI[b] || 0) - (PHI[a] || 0));
408
+ netPositions = {};
409
+ const COLS = 6;
410
+ sorted.forEach((t, i) => {
411
+ const col = i % COLS;
412
+ const row = Math.floor(i / COLS);
413
+ const rows = Math.ceil(N / COLS);
414
+ netPositions[t] = {
415
+ x: 40 + col * ((W - 80) / COLS),
416
+ y: 40 + row * ((H - 80) / rows),
417
+ };
418
+ });
419
+ let h = '';
420
+ // Edges
421
+ EDGES.filter(e => e.w > 0.65).forEach(e => {
422
+ const s = ALL[e.si], t = ALL[e.ti];
423
+ const sp = netPositions[s], tp = netPositions[t];
424
+ if (!sp || !tp) return;
425
+ const strong = e.w > 0.8;
426
+ const col = strong ? '#e03434' : '#38382e';
427
+ const sw = strong ? 1.5 : 0.7;
428
+ const dash = strong ? '' : `stroke-dasharray="3 3"`;
429
+ h += `<line class="net-edge" x1="${sp.x}" y1="${sp.y}" x2="${tp.x}" y2="${tp.y}"
430
+ stroke="${col}" stroke-width="${sw}" stroke-opacity="0.65" ${dash}/>`;
431
+ });
432
+ // Nodes
433
+ sorted.forEach(t => {
434
+ const p = netPositions[t];
435
+ const phi = PHI[t] || 0;
436
+ const r = 5 + Math.abs(phi) * 2.5;
437
+ const c = phiColor(phi);
438
+ const sel = t === selTicker;
439
+ h += `<g class="net-node" onclick="selectTicker('${t}')"
440
+ onmouseenter="showPopup(event,'${t}')"
441
+ onmouseleave="hidePopup()">
442
+ <circle cx="${p.x}" cy="${p.y}" r="${r}"
443
+ fill="${c}22" stroke="${sel ? '#f0a500' : c}"
444
+ stroke-width="${sel ? 2 : 1}"/>
445
+ <text x="${p.x}" y="${p.y + r + 8}" fill="${sel ? '#f0a500' : '#5a5a54'}"
446
+ font-size="7" font-family="IBM Plex Mono" text-anchor="middle">${t}</text>
447
+ </g>`;
448
+ });
449
+ svg.innerHTML = h;
450
+ }
451
+
452
+ // ── HHKD ───────────────────────────────────────────────────────────────────
453
+ function drawHHKD() {
454
+ const phiChart = document.getElementById('phi-chart');
455
+ const diag = document.getElementById('hhkd-diag');
456
+ if (!phiChart || !diag) return;
457
+ const sorted = [...ALL].sort((a, b) => (PHI[b] || 0) - (PHI[a] || 0)).slice(0, 16);
458
+ const maxAbs = Math.max(...ALL.map(t => Math.abs(PHI[t] || 0)));
459
+ phiChart.innerHTML = sorted.map(t => {
460
+ const phi = PHI[t] || 0;
461
+ const c = phiColor(phi);
462
+ const w = Math.abs(phi) / maxAbs * 100;
463
+ return `<div class="phi-row" onclick="selectTicker('${t}')">
464
+ <span class="phi-sym">${t}</span>
465
+ <div class="phi-bar-wrap"><div class="phi-bar-fill" style="width:${w}%;background:${c};"></div></div>
466
+ <span class="phi-val" style="color:${c};">${fmtPhi(phi)}</span>
467
+ </div>`;
468
+ }).join('');
469
+
470
+ const gradRatio = (0.90 + Math.random() * 0.05);
471
+ diag.innerHTML = `
472
+ <div class="acc-row"><span class="acc-k">β€–J_gradβ€–</span><span class="acc-v am">${(gradRatio * 2.1).toFixed(3)}</span></div>
473
+ <div class="acc-row"><span class="acc-k">β€–J_cycβ€–</span><span class="acc-v">${((1 - gradRatio) * 2.1).toFixed(3)}</span></div>
474
+ <div class="acc-row"><span class="acc-k">β€–J_resβ€–</span><span class="acc-v up">3.2e-7</span></div>
475
+ <div class="acc-row"><span class="acc-k">Gradient %</span><span class="acc-v up">${(gradRatio * 100).toFixed(1)}%</span></div>
476
+ `;
477
+
478
+ // J_grad SVG heat strip
479
+ const jg = document.getElementById('jgrad-svg');
480
+ if (!jg) return;
481
+ jg.setAttribute('width', '100%');
482
+ jg.setAttribute('height', '60');
483
+ let hg = '';
484
+ SEC_NAMES.forEach((sec, si) => {
485
+ SEC_NAMES.forEach((sec2, sj) => {
486
+ if (si === sj) return;
487
+ const v = MADJ[si][sj];
488
+ const c = adjColor(v);
489
+ const W = 32, H = 28;
490
+ hg += `<rect x="${sj * (W + 2)}" y="${si * (H + 2)}" width="${W}" height="${H}"
491
+ fill="${c}" rx="2" opacity="0.8"
492
+ onmousemove="hmHover(event,'${sec}','${sec2}',${v.toFixed(3)},${SEC_PHI[sec].toFixed(2)},${SEC_PHI[sec2].toFixed(2)})"
493
+ onmouseleave="hidePopup()"/>`;
494
+ });
495
+ });
496
+ jg.innerHTML = hg;
497
+ }
498
+
499
+ // ── Sector ─────────────────────────────────────────────────────────────────
500
+ function drawSector() {
501
+ const grid = document.getElementById('sector-grid');
502
+ if (!grid) return;
503
+ const sorted = [...SEC_NAMES].sort((a, b) => (SEC_PHI[b] || 0) - (SEC_PHI[a] || 0));
504
+ grid.innerHTML = sorted.map(sec => {
505
+ const phi = SEC_PHI[sec] || 0;
506
+ const c = phiColor(phi);
507
+ const members = SECTORS[sec] || [];
508
+ return `<div class="sec-card">
509
+ <div class="sec-card-head" onclick="this.nextElementSibling.classList.toggle('open')">
510
+ <span class="sec-name">${sec.toUpperCase()}</span>
511
+ <span class="sec-phi" style="color:${c};">${fmtPhi(phi)}</span>
512
+ </div>
513
+ <div class="sec-members open">
514
+ ${members.map(t => {
515
+ const tp = PHI[t] || 0;
516
+ return `<div class="sec-chip" style="color:${phiColor(tp)};"
517
+ onclick="selectTicker('${t}')" title="Ο†=${fmtPhi(tp)}">${t}</div>`;
518
+ }).join('')}
519
+ </div>
520
+ </div>`;
521
+ }).join('');
522
+
523
+ // Sector macro SVG
524
+ const svg = document.getElementById('macro-svg');
525
+ if (!svg) return;
526
+ const W = svg.parentElement ? (svg.parentElement.clientWidth || 400) : 400;
527
+ const H = 120;
528
+ svg.setAttribute('width', W);
529
+ svg.setAttribute('height', H);
530
+ const cx = W / 2, cy = H / 2, r = Math.min(cx, cy) - 18;
531
+ const pts = SEC_NAMES.map((s, i) => {
532
+ const angle = (i / S) * 2 * Math.PI - Math.PI / 2;
533
+ return { x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle), s };
534
+ });
535
+ let h = '';
536
+ pts.forEach((p, a) => pts.forEach((q, b) => {
537
+ if (a >= b) return;
538
+ const v = MADJ[a][b];
539
+ const col = v > 0.6 ? 'rgba(240,165,0,0.35)' : 'rgba(56,56,46,0.4)';
540
+ h += `<line x1="${p.x}" y1="${p.y}" x2="${q.x}" y2="${q.y}"
541
+ stroke="${col}" stroke-width="${v > 0.6 ? 1.2 : 0.5}"/>`;
542
+ }));
543
+ pts.forEach((p, i) => {
544
+ const phi = SEC_PHI[SEC_NAMES[i]] || 0;
545
+ const c = phiColor(phi);
546
+ h += `<circle cx="${p.x}" cy="${p.y}" r="5" fill="${c}33" stroke="${c}" stroke-width="1"/>`;
547
+ h += `<text x="${p.x}" y="${p.y - 8}" fill="${c}" font-size="6"
548
+ font-family="IBM Plex Mono" text-anchor="middle">${SEC_NAMES[i].slice(0, 4).toUpperCase()}</text>`;
549
+ });
550
+ svg.innerHTML = h;
551
+ }
552
+
553
+ // ── Single Ticker View ─────────────────────────────────────────────────────
554
+ function drawSingle() {
555
+ // DuPont prior SVG
556
+ const svg = document.getElementById('dupont-svg');
557
+ if (!svg) return;
558
+ const CELL = 9;
559
+ const W = FN * CELL + 10, H = FN * CELL + 10;
560
+ svg.setAttribute('width', W);
561
+ svg.setAttribute('height', H);
562
+ let h = '';
563
+ for (let i = 0; i < FN; i++) {
564
+ for (let j = 0; j < FN; j++) {
565
+ const v = FPRIOR[i][j];
566
+ h += `<rect x="${5 + j * CELL}" y="${5 + i * CELL}" width="${CELL - 1}" height="${CELL - 1}"
567
+ fill="${v ? '#f0a500' : '#141414'}" rx="1" opacity="${v ? 0.85 : 0.4}"
568
+ title="${FNODES[i]}β†’${FNODES[j]}"/>`;
569
+ }
570
+ }
571
+ svg.innerHTML = h;
572
+
573
+ // Discovered edges
574
+ const idx = ALL.indexOf(selTicker);
575
+ const outEdges = EDGES.filter(e => e.si === idx).sort((a, b) => b.w - a.w).slice(0, 8);
576
+ const discEl = document.getElementById('disc-edges');
577
+ if (discEl) {
578
+ discEl.innerHTML = outEdges.map(e => {
579
+ const t = ALL[e.ti];
580
+ const c = e.w > 0.7 ? 'var(--red)' : e.w > 0.5 ? 'var(--amber)' : 'var(--muted)';
581
+ return `<div class="acc-row">
582
+ <span class="acc-k">${selTicker} β†’ ${t}</span>
583
+ <span class="acc-v" style="color:${c};">${e.w.toFixed(3)}</span>
584
+ </div>`;
585
+ }).join('') || '<div class="acc-row"><span class="acc-k" style="color:var(--muted);">No causal edges above threshold</span></div>';
586
+ }
587
+
588
+ // CAMEF forecast sparkline
589
+ const camef = document.getElementById('camef-svg');
590
+ if (camef) {
591
+ const phi = PHI[selTicker] || 0;
592
+ const pts2 = Array.from({ length: 20 }, (_, i) => ({
593
+ x: 10 + i * 18,
594
+ y: 55 - phi * 8 + (Math.sin(i * 0.7 + phi) * 6 + (Math.random() - 0.5) * 4),
595
+ }));
596
+ const col = phi > 0 ? 'var(--green)' : 'var(--red)';
597
+ const pathD = pts2.map((p, i) => (i === 0 ? `M${p.x},${p.y}` : `L${p.x},${p.y}`)).join(' ');
598
+ camef.setAttribute('width', '100%');
599
+ camef.setAttribute('height', '70');
600
+ camef.setAttribute('viewBox', `0 0 380 70`);
601
+ camef.innerHTML = `<path d="${pathD}" stroke="${col}" stroke-width="1.5" fill="none" opacity="0.85"/>`;
602
+ }
603
+
604
+ // FCM lag bars
605
+ const fcm = document.getElementById('fcm-bars');
606
+ if (fcm) {
607
+ const phi = PHI[selTicker] || 0;
608
+ const lags = ['G₁','Gβ‚‚','G₃','Gβ‚„'];
609
+ fcm.innerHTML = lags.map((g, i) => {
610
+ const v = Math.max(0.05, Math.min(0.95, 0.5 + phi * 0.12 - i * 0.08 + Math.random() * 0.06));
611
+ const col = v > 0.6 ? 'var(--amber)' : v > 0.4 ? 'var(--cyan)' : 'var(--muted)';
612
+ return `<div class="phi-row">
613
+ <span class="phi-sym">${g}</span>
614
+ <div class="phi-bar-wrap"><div class="phi-bar-fill" style="width:${v * 100}%;background:${col};"></div></div>
615
+ <span class="phi-val" style="color:${col};">${v.toFixed(2)}</span>
616
+ </div>`;
617
+ }).join('');
618
+ }
619
+ }
620
+
621
+ // ── Inference Engine ───────────────────────────────────────────────────────
622
+ function setInferMode(m) {
623
+ inferMode = m;
624
+ document.querySelectorAll('.infer-mode button').forEach(b => b.classList.remove('active'));
625
+ const btn = document.querySelector(`.m-${m}`);
626
+ if (btn) btn.classList.add('active');
627
+ buildInferForm();
628
+ }
629
+
630
+ function buildInferForm() {
631
+ const form = document.getElementById('infer-form');
632
+ if (!form) return;
633
+ const tickers = ALL.map(t => `<option value="${t}">${t}</option>`).join('');
634
+ const color = { assert: 'cyan', intervene: 'amber', counter: 'purple' }[inferMode] || 'cyan';
635
+
636
+ form.innerHTML = `
637
+ <div class="infer-label">SOURCE NODE</div>
638
+ <select class="infer-select" id="infer-src"><option value="">β€” select β€”</option>${tickers}</select>
639
+ ${inferMode !== 'assert' ? `
640
+ <div class="infer-label">TARGET NODE</div>
641
+ <select class="infer-select" id="infer-tgt"><option value="">β€” select β€”</option>${tickers}</select>
642
+ ` : ''}
643
+ <div class="slider-wrap">
644
+ <div class="slider-row">
645
+ <span class="infer-label">VALUE DELTA</span>
646
+ <span class="slider-val" id="slider-val">0.50</span>
647
+ </div>
648
+ <input type="range" min="0.1" max="2.0" step="0.05" value="0.5"
649
+ oninput="document.getElementById('slider-val').textContent=parseFloat(this.value).toFixed(2)">
650
+ </div>
651
+ <button class="run-btn ${inferMode}" onclick="runInference()" id="run-infer-btn">
652
+ β–Ά RUN ${inferMode.toUpperCase()}
653
+ </button>
654
+ `;
655
+ }
656
+
657
+ async function runInference() {
658
+ const src = document.getElementById('infer-src')?.value;
659
+ const tgt = document.getElementById('infer-tgt')?.value;
660
+ const delta = parseFloat(document.querySelector('.infer-form input[type=range]')?.value || 0.5);
661
+ const btn = document.getElementById('run-infer-btn');
662
+
663
+ if (!src) { showToast('Select a source node first'); return; }
664
+
665
+ setBannerState('infer', 'busy', 'RUNNING…');
666
+ if (btn) { btn.disabled = true; btn.textContent = 'RUNNINGοΏ½οΏ½οΏ½'; }
667
+
668
+ // Try Gradio API first
669
+ let result = null;
670
+ if (GR_CLIENT) {
671
+ try {
672
+ const gr_result = await GR_CLIENT.predict('/run_inference', {
673
+ ticker: src, mode: inferMode,
674
+ treatment: src, outcome: tgt || src,
675
+ value: delta,
676
+ });
677
+ result = gr_result?.data;
678
+ } catch (e) {
679
+ console.warn('[Gradio predict] failed:', e);
680
+ }
681
+ }
682
+
683
+ // Fallback: REST API
684
+ if (!result) {
685
+ const apiRes = await apiPost('/v2/causal/doflow-inference', {
686
+ ticker: src,
687
+ mode: inferMode,
688
+ treatment: src,
689
+ outcome: tgt || src,
690
+ value: delta,
691
+ });
692
+ result = apiRes;
693
+ }
694
+
695
+ setBannerState('infer', result ? 'ok' : 'err', result ? 'INFER OK' : 'INFER ERR');
696
+ if (btn) { btn.disabled = false; btn.textContent = `β–Ά RUN ${inferMode.toUpperCase()}`; }
697
+
698
+ renderInferenceResult(src, tgt, delta, result);
699
+ if (result) { activeRipple = { src, dir: delta > 0 ? 1 : -1 }; applyRipple(activeRipple, 0); }
700
+ }
701
+
702
+ function renderInferenceResult(src, tgt, delta, data) {
703
+ const area = document.getElementById('results-area');
704
+ if (!area) return;
705
+
706
+ const ate = data?.ate ?? (delta * (PHI[src] || 0.5) * 0.3);
707
+ const prob = data?.probability ?? (0.5 + Math.abs(PHI[src] || 0) * 0.07);
708
+ const confLow = data?.ci_lower ?? (ate - 0.12);
709
+ const confHigh = data?.ci_upper ?? (ate + 0.12);
710
+ const counterfact = data?.counterfactual_outcome ?? (ate * 0.85);
711
+ const ripples = data?.ripple_effects ?? EDGES
712
+ .filter(e => e.si === ALL.indexOf(src))
713
+ .sort((a, b) => b.w - a.w)
714
+ .slice(0, 5)
715
+ .map(e => ({ ticker: ALL[e.ti], direction: ate > 0 ? 1 : -1, magnitude: e.w * Math.abs(ate) }));
716
+
717
+ const ateAbs = Math.min(1, Math.abs(ate) / 1.5);
718
+ const ateCol = ate >= 0 ? 'var(--green)' : 'var(--red)';
719
+
720
+ const rippleChips = ripples.map(r =>
721
+ `<span class="ripple-chip ${r.direction > 0 ? 'up' : 'dn'}">
722
+ ${r.ticker} ${r.direction > 0 ? '↑' : '↓'} ${Math.abs(r.magnitude).toFixed(2)}
723
+ </span>`
724
+ ).join('');
725
+
726
+ area.innerHTML = `
727
+ <div class="result-card ${inferMode}">
728
+ <div class="rc-head ${inferMode}">${inferMode.toUpperCase()} β€” ${src}${tgt ? ' β†’ ' + tgt : ''}</div>
729
+ <div class="rc-row"><span class="rc-k">ATE</span><span class="rc-v ${ate >= 0 ? 'up' : 'dn'}">${ate >= 0 ? '+' : ''}${ate.toFixed(3)}</span></div>
730
+ <div class="rc-row"><span class="rc-k">P(effect)</span><span class="rc-v am">${prob.toFixed(3)}</span></div>
731
+ <div class="rc-row"><span class="rc-k">95% CI</span><span class="rc-v">[${confLow.toFixed(2)}, ${confHigh.toFixed(2)}]</span></div>
732
+ ${inferMode === 'counter' ? `<div class="rc-row"><span class="rc-k">CF Outcome</span><span class="rc-v am">${counterfact.toFixed(3)}</span></div>` : ''}
733
+ <div class="ate-track"><div class="ate-fill" style="width:${ateAbs * 100}%;background:${ateCol};"></div></div>
734
+ <div class="ripple-effects">
735
+ <div class="ripple-title">RIPPLE EFFECTS β†’</div>
736
+ ${rippleChips || '<span style="color:var(--muted);font-size:9px;">No downstream ripples detected</span>'}
737
+ </div>
738
+ </div>
739
+ `;
740
+ }
741
+
742
+ // ── Ripple Propagation ─────────────────────────────────────────────────────
743
+ function applyRipple(ripple, delay) {
744
+ setTimeout(() => {
745
+ const srcIdx = ALL.indexOf(ripple.src);
746
+ if (srcIdx < 0) return;
747
+ const downstream = EDGES
748
+ .filter(e => e.si === srcIdx)
749
+ .sort((a, b) => b.w - a.w)
750
+ .slice(0, 8);
751
+
752
+ // Heatmap ripple
753
+ if (currentTab === 'matrix') {
754
+ downstream.forEach(e => {
755
+ const cell = document.getElementById(`hm-${srcIdx}-${e.ti}`);
756
+ if (!cell) return;
757
+ cell.classList.remove('ripple-out', 'ripple-in', 'ripple-pulse');
758
+ void cell.offsetWidth;
759
+ cell.classList.add(ripple.dir > 0 ? 'ripple-in' : 'ripple-out');
760
+ setTimeout(() => cell.classList.remove('ripple-out', 'ripple-in'), 1200);
761
+ });
762
+ }
763
+
764
+ // Sidebar ripple
765
+ downstream.forEach(e => {
766
+ const t = ALL[e.ti];
767
+ const row = document.getElementById(`tr-${t}`);
768
+ if (!row) return;
769
+ row.classList.remove('rippling', 'rippling-up');
770
+ void row.offsetWidth;
771
+ row.classList.add(ripple.dir > 0 ? 'rippling-up' : 'rippling');
772
+ setTimeout(() => row.classList.remove('rippling', 'rippling-up'), 700);
773
+ });
774
+
775
+ // Sector chips
776
+ if (currentTab === 'sector') {
777
+ downstream.forEach(e => {
778
+ const t = ALL[e.ti];
779
+ document.querySelectorAll('.sec-chip').forEach(ch => {
780
+ if (ch.textContent.trim() === t) {
781
+ ch.classList.remove('rippling', 'rippling-up');
782
+ void ch.offsetWidth;
783
+ ch.classList.add(ripple.dir > 0 ? 'rippling-up' : 'rippling');
784
+ setTimeout(() => ch.classList.remove('rippling', 'rippling-up'), 800);
785
+ }
786
+ });
787
+ });
788
+ }
789
+ }, delay);
790
+ }
791
+
792
+ // ── News Feed ─────────────────���────────────────────────────────────────────
793
+ function buildNewsFeed() {
794
+ const el = document.getElementById('news-feed');
795
+ if (!el) return;
796
+ el.innerHTML = NEWS.map(n => {
797
+ const cls = n.score > 0.75 ? 'hi' : n.score > 0.5 ? 'md' : 'lo';
798
+ const dirCls = n.dir > 0 ? 'up' : 'dn';
799
+ return `<div class="news-item" onclick="selectTicker('${n.sym}')">
800
+ <div class="news-top">
801
+ <span class="news-score ${cls}">${n.score.toFixed(2)}</span>
802
+ <span class="news-sym">${n.sym}</span>
803
+ <span style="color:${n.dir > 0 ? 'var(--green)' : 'var(--red)'}; font-size:9px;">${n.dir > 0 ? 'β–²' : 'β–Ό'}</span>
804
+ </div>
805
+ <div class="news-text">${n.text}</div>
806
+ <div class="news-tags">${n.tags.map(t => `<span class="news-tag">${t}</span>`).join('')}</div>
807
+ </div>`;
808
+ }).join('');
809
+ }
810
+
811
+ // ── API Sidebar Fetch ──────────────────────────────────────────────────────
812
+ async function loadApiStatus() {
813
+ const health = await apiGet('/v2/health').catch(() => null);
814
+ setBannerState('rest', health !== null ? 'ok' : 'err', health !== null ? 'REST OK' : 'REST ERR');
815
+ }
816
+
817
+ // ── Initialise ─────────────────────────────────────────────────────────────
818
+ async function init() {
819
+ buildSidebar();
820
+ buildNewsFeed();
821
+ buildInferForm();
822
+ setInferMode('assert');
823
+ drawHeatmap();
824
+
825
+ // Fade out loading overlay
826
+ setTimeout(() => {
827
+ const overlay = document.getElementById('loading-overlay');
828
+ if (overlay) overlay.classList.add('hidden');
829
+ setTimeout(() => { if (overlay) overlay.remove(); }, 500);
830
+ }, 1200);
831
+
832
+ // Async API checks
833
+ await initGradioClient();
834
+ await loadApiStatus();
835
+ }
836
+
837
+ document.addEventListener('DOMContentLoaded', init);
838
+
839
+ // Expose globals needed by inline onclick handlers
840
+ window.setTab = setTab;
841
+ window.setSeg = setSeg;
842
+ window.filterTickers = filterTickers;
843
+ window.selectTicker = selectTicker;
844
+ window.showPopup = showPopup;
845
+ window.hidePopup = hidePopup;
846
+ window.setInferMode = setInferMode;
847
+ window.runInference = runInference;
848
+ window.hmHover = hmHover;
849
+ window.hmClick = hmClick;
frontend/index.html ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <meta name="description" content="CUTS+ Causal Terminal β€” Iroha Financial Intelligence. Real-time causal probability matrix, HHKD decomposition, and DoFlow inference over NIFTY50.">
7
+ <title>CUTS+ Causal Terminal Β· Iroha</title>
8
+
9
+ <!-- Stylesheet (separate file served via StaticFiles) -->
10
+ <link rel="stylesheet" href="/static/style.css">
11
+ </head>
12
+ <body>
13
+
14
+ <!-- ── Loading Overlay ─────────────────────────────────────────────────── -->
15
+ <div id="loading-overlay">
16
+ <div class="loading-brand">CUTS+ CAUSAL</div>
17
+ <div class="loading-bar-wrap">
18
+ <div class="loading-bar-fill"></div>
19
+ </div>
20
+ <div class="loading-status" id="loading-status">INITIALISING ENGINE…</div>
21
+ </div>
22
+
23
+ <!-- ── Top Bar ─────────────────────────────────────────────────────────── -->
24
+ <div class="topbar">
25
+ <div style="display:flex;align-items:center;gap:16px;">
26
+ <div class="brand">CUTS+ CAUSAL</div>
27
+ <div class="tabs">
28
+ <button class="tab active" data-tab="matrix" onclick="setTab('matrix')">MATRIX</button>
29
+ <button class="tab" data-tab="network" onclick="setTab('network')">NETWORK</button>
30
+ <button class="tab" data-tab="hhkd" onclick="setTab('hhkd')">HHKD</button>
31
+ <button class="tab" data-tab="sector" onclick="setTab('sector')">SECTOR</button>
32
+ <button class="tab" data-tab="single" onclick="setTab('single')">SINGLE TICKER</button>
33
+ </div>
34
+ </div>
35
+ <div class="topbar-right">
36
+ <span><span class="live-dot"></span> LIVE</span>
37
+ <span id="clock" style="color:var(--amber);">--:--:--</span>
38
+ <span>NIFTY50 Β· EPOCH 30/30</span>
39
+ </div>
40
+ </div>
41
+
42
+ <!-- ── API Status Banner ───────────────────────────────────────────────── -->
43
+ <div id="api-banner">
44
+ <div class="api-chip" id="api-rest">
45
+ <div class="api-dot"></div><span>REST API</span>
46
+ </div>
47
+ <div class="api-chip" id="api-gradio">
48
+ <div class="api-dot"></div><span>GRADIO CLIENT</span>
49
+ </div>
50
+ <div class="api-chip" id="api-pipeline">
51
+ <div class="api-dot"></div><span>PIPELINE</span>
52
+ </div>
53
+ <div class="api-chip" id="api-infer">
54
+ <div class="api-dot"></div><span>INFERENCE</span>
55
+ </div>
56
+ </div>
57
+
58
+ <!-- ── Body ────────────────────────────────────────────────────────────── -->
59
+ <div class="body">
60
+
61
+ <!-- LEFT SIDEBAR -->
62
+ <div class="sidebar">
63
+ <div class="sb-header">
64
+ <span>TICKERS β€” Ο† RANK</span>
65
+ <span id="sb-count" style="color:var(--muted);">36</span>
66
+ </div>
67
+ <div class="sb-seg">
68
+ <div class="seg-btn">
69
+ <button class="active" onclick="setSeg(this,'all')">ALL</button>
70
+ <button onclick="setSeg(this,'up')">UPSTREAM</button>
71
+ <button onclick="setSeg(this,'dn')">SINK</button>
72
+ </div>
73
+ </div>
74
+ <div class="sb-search">
75
+ <input type="text" placeholder="Search ticker…" id="sb-search-input"
76
+ oninput="filterTickers(this.value)" autocomplete="off">
77
+ </div>
78
+ <div class="ticker-list" id="ticker-list"></div>
79
+ </div>
80
+
81
+ <!-- CENTER PANEL -->
82
+ <div class="center">
83
+
84
+ <!-- MATRIX VIEW -->
85
+ <div class="view active" id="view-matrix">
86
+ <div class="view-header">
87
+ <span class="vh-title">CAUSAL PROBABILITY MATRIX</span>
88
+ <span class="vh-meta">GΒ·Οƒ(G_T) Β· GUMBEL-SOFTMAX Β· HOVER β†’ DETAILS Β· CLICK β†’ PIN</span>
89
+ </div>
90
+ <div class="legend">
91
+ <div class="leg-item"><div class="leg-dot" style="background:#e03434;"></div>Strong cause &gt;0.7</div>
92
+ <div class="leg-item"><div class="leg-dot" style="background:#f0a500;"></div>Moderate 0.4–0.7</div>
93
+ <div class="leg-item"><div class="leg-dot" style="background:#003a20;"></div>Weak &lt;0.4</div>
94
+ <div class="leg-item" style="margin-left:auto;color:var(--muted);">Inference ripples across this map in real-time</div>
95
+ </div>
96
+ <div class="view-body" style="padding:0;" id="matrix-body">
97
+ <svg id="heatmap-svg"></svg>
98
+ </div>
99
+ </div>
100
+
101
+ <!-- NETWORK VIEW -->
102
+ <div class="view" id="view-network">
103
+ <div class="view-header">
104
+ <span class="vh-title">CAUSAL NETWORK GRAPH</span>
105
+ <span class="vh-meta">THRESHOLDED DAG · θ=0.5 · DRAG TO EXPLORE · NODE SIZE ∝ OUT-DEGREE</span>
106
+ </div>
107
+ <div class="legend">
108
+ <div class="leg-item"><div class="leg-dot" style="background:var(--amber);"></div>High Ο† (upstream)</div>
109
+ <div class="leg-item"><div class="leg-dot" style="background:var(--cyan);"></div>Mid Ο†</div>
110
+ <div class="leg-item"><div class="leg-dot" style="background:var(--muted);"></div>Low Ο† (sink)</div>
111
+ <div class="leg-item"><div class="leg-line" style="background:var(--red);"></div>Strong edge</div>
112
+ </div>
113
+ <div class="view-body" style="padding:0;position:relative;">
114
+ <svg id="net-svg"></svg>
115
+ </div>
116
+ </div>
117
+
118
+ <!-- HHKD VIEW -->
119
+ <div class="view" id="view-hhkd">
120
+ <div class="view-header">
121
+ <span class="vh-title">HELMHOLTZ-HODGE-KODAIRA DECOMPOSITION</span>
122
+ <span class="vh-meta">J_b β†’ J_grad + J_res Β· β€–RESIDUALβ€– &lt; 10⁻⁢</span>
123
+ </div>
124
+ <div class="view-body" style="padding:10px;">
125
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px;">
126
+ <div>
127
+ <div style="color:var(--amber);font-size:9px;letter-spacing:2px;margin-bottom:8px;text-transform:uppercase;">
128
+ Scalar Potential Ο† β€” Upstream Ranking
129
+ </div>
130
+ <div id="phi-chart"></div>
131
+ </div>
132
+ <div>
133
+ <div style="color:var(--cyan);font-size:9px;letter-spacing:2px;margin-bottom:8px;text-transform:uppercase;">
134
+ Decomposition Diagnostics
135
+ </div>
136
+ <div id="hhkd-diag"></div>
137
+ <div style="margin-top:10px;">
138
+ <div style="color:var(--muted);font-size:9px;letter-spacing:1px;margin-bottom:6px;">GRADIENT vs CYCLIC SPLIT</div>
139
+ <div style="height:18px;background:var(--bg4);border-radius:3px;overflow:hidden;display:flex;">
140
+ <div style="height:18px;width:93.8%;background:var(--amber);display:flex;align-items:center;justify-content:center;font-size:8px;color:#000;font-weight:600;">GRADIENT 93.8%</div>
141
+ <div style="flex:1;height:18px;background:var(--cyan-lo);display:flex;align-items:center;justify-content:center;font-size:8px;color:var(--cyan);">6.2%</div>
142
+ </div>
143
+ </div>
144
+ </div>
145
+ </div>
146
+ <div style="color:var(--amber);font-size:9px;letter-spacing:2px;margin-bottom:8px;text-transform:uppercase;">
147
+ Gradient Flow J_grad β€” Sector Heatmap
148
+ </div>
149
+ <svg id="jgrad-svg"></svg>
150
+ </div>
151
+ </div>
152
+
153
+ <!-- SECTOR VIEW -->
154
+ <div class="view" id="view-sector">
155
+ <div class="view-header">
156
+ <span class="vh-title">SECTOR MACRO GRAPH β€” REASON TRIPLET ⟨G, A, E⟩</span>
157
+ <span class="vh-meta">BIDIRECTIONAL MESSAGE PASSING Β· CrossLevelMPNN</span>
158
+ </div>
159
+ <div class="view-body" style="padding:0;overflow-y:auto;">
160
+ <div style="padding:10px;">
161
+ <div style="color:var(--amber);font-size:9px;letter-spacing:2px;margin-bottom:8px;text-transform:uppercase;">Macro Sector Adjacency</div>
162
+ <svg id="macro-svg"></svg>
163
+ </div>
164
+ <div class="sector-grid" id="sector-grid"></div>
165
+ </div>
166
+ </div>
167
+
168
+ <!-- SINGLE TICKER VIEW -->
169
+ <div class="view" id="view-single">
170
+ <div class="view-header">
171
+ <div>
172
+ <span class="vh-title">SINGLE TICKER β€” FUNDAMENTAL CAUSAL</span>
173
+ <span id="single-name" style="color:var(--amber);font-size:13px;font-weight:600;margin-left:12px;">RELIANCE</span>
174
+ </div>
175
+ <span class="vh-meta">DuPont PRIOR Β· 23 NODES Β· SCM RIDGE Β· CAMEF GPT4MTS</span>
176
+ </div>
177
+ <div class="view-body" style="padding:10px;overflow-y:auto;">
178
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
179
+ <div>
180
+ <div style="color:var(--amber);font-size:9px;letter-spacing:2px;margin-bottom:6px;text-transform:uppercase;">DuPont Prior Adjacency (23Γ—23)</div>
181
+ <svg id="dupont-svg"></svg>
182
+ <div style="color:var(--cyan);font-size:9px;letter-spacing:2px;margin:10px 0 6px;text-transform:uppercase;">Discovered Causal Edges</div>
183
+ <div id="disc-edges"></div>
184
+ </div>
185
+ <div>
186
+ <div style="color:var(--amber);font-size:9px;letter-spacing:2px;margin-bottom:6px;text-transform:uppercase;">CAMEF Stress Forecast</div>
187
+ <svg id="camef-svg"></svg>
188
+ <div style="color:var(--cyan);font-size:9px;letter-spacing:2px;margin:10px 0 6px;text-transform:uppercase;">FCM Lag-Graph G₁–Gβ‚„</div>
189
+ <div id="fcm-bars"></div>
190
+ </div>
191
+ </div>
192
+ </div>
193
+ </div>
194
+
195
+ </div><!-- /center -->
196
+
197
+ <!-- RIGHT PANEL -->
198
+ <div class="rpanel">
199
+
200
+ <!-- INFERENCE ENGINE -->
201
+ <div style="flex-shrink:0;border-bottom:1px solid var(--border);">
202
+ <div class="rp-head">CAUSAL INFERENCE ENGINE</div>
203
+ <div class="infer-panel">
204
+ <div class="infer-mode">
205
+ <button class="m-assert active" onclick="setInferMode('assert')">ASSERT</button>
206
+ <button class="m-intervene" onclick="setInferMode('intervene')">INTERVENE</button>
207
+ <button class="m-counter" onclick="setInferMode('counter')">COUNTERΒ·F</button>
208
+ </div>
209
+ <div class="infer-form" id="infer-form">
210
+ <!-- populated by app.js -->
211
+ </div>
212
+ </div>
213
+ </div>
214
+
215
+ <!-- RESULTS -->
216
+ <div style="flex:1;overflow-y:auto;">
217
+ <div class="rp-head">
218
+ RESULTS &amp; RIPPLE TRACE
219
+ <span id="result-count" style="color:var(--muted);font-size:9px;font-weight:400;"></span>
220
+ </div>
221
+ <div style="padding:8px;" id="results-area">
222
+ <div style="color:var(--muted);font-size:10px;text-align:center;padding:20px 0;">
223
+ Run an inference query to see results and ripple effects across all views.
224
+ </div>
225
+ </div>
226
+
227
+ <!-- NEWS FEED -->
228
+ <div class="rp-head" style="margin-top:0;">LLM DENOISED NEWS</div>
229
+ <div id="news-feed"></div>
230
+ </div>
231
+
232
+ </div><!-- /rpanel -->
233
+
234
+ </div><!-- /body -->
235
+
236
+ <!-- STATUS STRIP -->
237
+ <div class="status-strip">
238
+ <div class="ss-chip"><span class="ss-k">TICKERS</span><span class="ss-v am">36</span></div>
239
+ <div class="ss-chip"><span class="ss-k">EDGES</span><span class="ss-v am">127</span></div>
240
+ <div class="ss-chip"><span class="ss-k">DENSITY</span><span class="ss-v">5.2%</span></div>
241
+ <div class="ss-chip"><span class="ss-k">Ξ»_s</span><span class="ss-v">0.10</span></div>
242
+ <div class="ss-chip"><span class="ss-k">Ξ»_d</span><span class="ss-v">1.00</span></div>
243
+ <div class="ss-chip"><span class="ss-k">LOSS</span><span class="ss-v up" id="ss-loss">0.0412</span></div>
244
+ <div class="ss-chip"><span class="ss-k">PRIOR CONFORM</span><span class="ss-v up">91.3%</span></div>
245
+ <div class="ss-chip"><span class="ss-k">β€–J_resβ€–</span><span class="ss-v am">3.2e-7</span></div>
246
+ <div class="ss-chip"><span class="ss-k">EPOCH</span><span class="ss-v">30/30 βœ“</span></div>
247
+ </div>
248
+
249
+ <!-- NODE POPUP -->
250
+ <div class="node-popup" id="node-popup">
251
+ <div class="np-head">
252
+ <span id="np-name">RELIANCE</span>
253
+ <span id="np-sector" style="font-size:9px;color:var(--muted);font-weight:400;">Energy</span>
254
+ </div>
255
+ <div class="np-row"><span class="np-k">Ο† Potential</span><span class="np-v am" id="np-phi">+2.41</span></div>
256
+ <div class="np-row"><span class="np-k">Rank</span><span class="np-v up" id="np-rank">#1 Upstream</span></div>
257
+ <div class="np-row"><span class="np-k">Out-degree</span><span class="np-v" id="np-out">8</span></div>
258
+ <div class="np-row"><span class="np-k">In-degree</span><span class="np-v" id="np-in">2</span></div>
259
+ <div class="np-row"><span class="np-k">Strongest cause</span><span class="np-v am" id="np-cause">ONGC 0.847</span></div>
260
+ <div class="np-row"><span class="np-k">News score</span><span class="np-v up" id="np-news">0.87</span></div>
261
+ </div>
262
+
263
+ <!-- ERROR TOAST -->
264
+ <div id="error-toast"></div>
265
+
266
+ <!-- App JS (separate file served via StaticFiles) -->
267
+ <script type="module" src="/static/app.js"></script>
268
+ </body>
269
+ </html>
frontend/style.css ADDED
@@ -0,0 +1,711 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── Google Fonts ──────────────────────────────────────────────────────────── */
2
+ @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500;600&display=swap');
3
+
4
+ /* ── Design Tokens ─────────────────────────────────────────────────────────── */
5
+ :root {
6
+ --bg0: #090909;
7
+ --bg1: #0e0e0e;
8
+ --bg2: #141414;
9
+ --bg3: #1c1c1c;
10
+ --bg4: #242424;
11
+ --border: #252525;
12
+ --border-hi: #383838;
13
+
14
+ --amber: #f0a500;
15
+ --amber-lo: rgba(240,165,0,0.12);
16
+ --amber-dim: #7a5200;
17
+ --red: #e03434;
18
+ --red-lo: rgba(224,52,52,0.12);
19
+ --green: #00c87a;
20
+ --green-lo: rgba(0,200,122,0.12);
21
+ --cyan: #00b8d4;
22
+ --cyan-lo: rgba(0,184,212,0.12);
23
+ --purple: #a78bfa;
24
+ --purple-lo: rgba(167,139,250,0.12);
25
+ --white: #e8e4d9;
26
+ --muted: #5a5a54;
27
+ --muted2: #38382e;
28
+ --font: 'IBM Plex Mono','Courier New',monospace;
29
+ --r: 3px;
30
+ }
31
+
32
+ /* ── Reset ─────────────────────────────────────────────────────────────────── */
33
+ *, *::before, *::after {
34
+ margin: 0;
35
+ padding: 0;
36
+ box-sizing: border-box;
37
+ }
38
+
39
+ html, body {
40
+ height: 100%;
41
+ overflow: hidden;
42
+ }
43
+
44
+ body {
45
+ background: var(--bg0);
46
+ color: var(--white);
47
+ font-family: var(--font);
48
+ font-size: 11px;
49
+ line-height: 1.5;
50
+ display: flex;
51
+ flex-direction: column;
52
+ }
53
+
54
+ /* ── Scrollbar ─────────────────────────────────────────────────────────────── */
55
+ ::-webkit-scrollbar { width: 3px; height: 3px; }
56
+ ::-webkit-scrollbar-track { background: var(--bg0); }
57
+ ::-webkit-scrollbar-thumb { background: var(--muted2); }
58
+
59
+ /* ── Loading Overlay ───────────────────────────────────────────────────────── */
60
+ #loading-overlay {
61
+ position: fixed;
62
+ inset: 0;
63
+ background: var(--bg0);
64
+ z-index: 9999;
65
+ display: flex;
66
+ flex-direction: column;
67
+ align-items: center;
68
+ justify-content: center;
69
+ gap: 16px;
70
+ transition: opacity 0.4s ease;
71
+ }
72
+ #loading-overlay.hidden { opacity: 0; pointer-events: none; }
73
+
74
+ .loading-brand {
75
+ color: var(--amber);
76
+ font-size: 16px;
77
+ font-weight: 600;
78
+ letter-spacing: 4px;
79
+ }
80
+ .loading-bar-wrap {
81
+ width: 220px;
82
+ height: 2px;
83
+ background: var(--bg3);
84
+ border-radius: 2px;
85
+ overflow: hidden;
86
+ }
87
+ .loading-bar-fill {
88
+ height: 2px;
89
+ background: var(--amber);
90
+ border-radius: 2px;
91
+ animation: loadbar 1.6s ease-in-out forwards;
92
+ }
93
+ @keyframes loadbar {
94
+ 0% { width: 0%; }
95
+ 60% { width: 80%; }
96
+ 100% { width: 100%; }
97
+ }
98
+ .loading-status {
99
+ font-size: 9px;
100
+ color: var(--muted);
101
+ letter-spacing: 1.5px;
102
+ }
103
+
104
+ /* ── Top Bar ───────────────────────────────────────────────────────────────── */
105
+ .topbar {
106
+ height: 38px;
107
+ background: var(--bg1);
108
+ border-bottom: 1px solid var(--amber);
109
+ display: flex;
110
+ align-items: center;
111
+ justify-content: space-between;
112
+ padding: 0 14px;
113
+ flex-shrink: 0;
114
+ z-index: 200;
115
+ }
116
+
117
+ .brand {
118
+ color: var(--amber);
119
+ font-weight: 600;
120
+ font-size: 12px;
121
+ letter-spacing: 3px;
122
+ }
123
+
124
+ .tabs {
125
+ display: flex;
126
+ gap: 1px;
127
+ }
128
+
129
+ .tab {
130
+ background: none;
131
+ border: none;
132
+ color: var(--muted);
133
+ font-family: var(--font);
134
+ font-size: 10px;
135
+ padding: 0 14px;
136
+ height: 38px;
137
+ cursor: pointer;
138
+ letter-spacing: 1.5px;
139
+ text-transform: uppercase;
140
+ border-bottom: 2px solid transparent;
141
+ transition: color 0.15s, border-color 0.15s;
142
+ }
143
+ .tab:hover { color: var(--white); }
144
+ .tab.active { color: var(--amber); border-bottom-color: var(--amber); }
145
+
146
+ .topbar-right {
147
+ display: flex;
148
+ align-items: center;
149
+ gap: 12px;
150
+ font-size: 9px;
151
+ color: var(--muted);
152
+ }
153
+
154
+ .live-dot {
155
+ width: 5px;
156
+ height: 5px;
157
+ background: var(--green);
158
+ border-radius: 50%;
159
+ display: inline-block;
160
+ animation: blink 2s infinite;
161
+ }
162
+ @keyframes blink { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
163
+
164
+ /* ── API Status Banner ─────────────────────────────────────────────────────── */
165
+ #api-banner {
166
+ height: 22px;
167
+ background: var(--bg2);
168
+ border-bottom: 1px solid var(--border);
169
+ display: flex;
170
+ align-items: center;
171
+ padding: 0 14px;
172
+ gap: 14px;
173
+ flex-shrink: 0;
174
+ font-size: 9px;
175
+ letter-spacing: 1px;
176
+ }
177
+ .api-chip {
178
+ display: flex;
179
+ align-items: center;
180
+ gap: 4px;
181
+ color: var(--muted);
182
+ }
183
+ .api-chip.ok .api-dot { background: var(--green); }
184
+ .api-chip.err .api-dot { background: var(--red); }
185
+ .api-chip.busy .api-dot { background: var(--amber); animation: blink 1s infinite; }
186
+ .api-dot {
187
+ width: 5px;
188
+ height: 5px;
189
+ border-radius: 50%;
190
+ background: var(--muted2);
191
+ }
192
+
193
+ /* ── Body Layout ───────────────────────────────────────────────────────────── */
194
+ .body {
195
+ flex: 1;
196
+ display: grid;
197
+ grid-template-columns: 200px 1fr 260px;
198
+ overflow: hidden;
199
+ min-height: 0;
200
+ }
201
+
202
+ /* ── Left Sidebar ──────────────────────────────────────────────────────────── */
203
+ .sidebar {
204
+ background: var(--bg1);
205
+ border-right: 1px solid var(--border);
206
+ display: flex;
207
+ flex-direction: column;
208
+ overflow: hidden;
209
+ }
210
+
211
+ .sb-header {
212
+ padding: 7px 10px;
213
+ background: var(--bg2);
214
+ border-bottom: 1px solid var(--border);
215
+ color: var(--amber);
216
+ font-size: 9px;
217
+ letter-spacing: 2px;
218
+ text-transform: uppercase;
219
+ display: flex;
220
+ justify-content: space-between;
221
+ align-items: center;
222
+ flex-shrink: 0;
223
+ }
224
+
225
+ .sb-seg {
226
+ padding: 6px 8px;
227
+ border-bottom: 1px solid var(--border);
228
+ flex-shrink: 0;
229
+ }
230
+ .seg-btn { display: flex; gap: 3px; }
231
+ .seg-btn button {
232
+ flex: 1;
233
+ background: var(--bg3);
234
+ border: 1px solid var(--border);
235
+ color: var(--muted);
236
+ font-family: var(--font);
237
+ font-size: 9px;
238
+ padding: 4px;
239
+ cursor: pointer;
240
+ border-radius: var(--r);
241
+ letter-spacing: 1px;
242
+ transition: all 0.15s;
243
+ }
244
+ .seg-btn button.active {
245
+ background: var(--amber);
246
+ color: #000;
247
+ border-color: var(--amber);
248
+ font-weight: 600;
249
+ }
250
+
251
+ .sb-search {
252
+ padding: 6px 8px;
253
+ border-bottom: 1px solid var(--border);
254
+ flex-shrink: 0;
255
+ }
256
+ .sb-search input {
257
+ width: 100%;
258
+ background: var(--bg3);
259
+ border: 1px solid var(--border);
260
+ color: var(--white);
261
+ font-family: var(--font);
262
+ font-size: 10px;
263
+ padding: 4px 8px;
264
+ outline: none;
265
+ border-radius: var(--r);
266
+ transition: border-color 0.15s;
267
+ }
268
+ .sb-search input:focus { border-color: var(--amber-dim); }
269
+
270
+ .ticker-list {
271
+ overflow-y: auto;
272
+ flex: 1;
273
+ }
274
+
275
+ .ticker-row {
276
+ display: flex;
277
+ align-items: center;
278
+ padding: 5px 10px;
279
+ cursor: pointer;
280
+ border-bottom: 1px solid var(--border);
281
+ transition: background 0.1s;
282
+ gap: 6px;
283
+ }
284
+ .ticker-row:hover { background: var(--bg3); }
285
+ .ticker-row.sel {
286
+ background: var(--amber-lo);
287
+ border-left: 2px solid var(--amber);
288
+ }
289
+ .ticker-row.rippling { animation: rowripple 0.6s ease-out; }
290
+ .ticker-row.rippling-up { animation: rowripple-up 0.6s ease-out; }
291
+ @keyframes rowripple { 0% { background: rgba(224,52,52,.35); } 100% { background: transparent; } }
292
+ @keyframes rowripple-up { 0% { background: rgba(0,200,122,.35); } 100% { background: transparent; } }
293
+
294
+ .t-sym { color: var(--amber); font-size: 10px; font-weight: 600; width: 62px; flex-shrink: 0; }
295
+ .t-phi { font-size: 9px; text-align: right; flex-shrink: 0; width: 32px; }
296
+ .t-bar { flex: 1; height: 3px; background: var(--bg4); border-radius: 2px; overflow: hidden; }
297
+ .t-bar-fill { height: 3px; border-radius: 2px; transition: width 0.3s; }
298
+
299
+ /* ── Center Panel ──────────────────────────────────────────────────────────── */
300
+ .center {
301
+ display: flex;
302
+ flex-direction: column;
303
+ overflow: hidden;
304
+ background: var(--bg0);
305
+ position: relative;
306
+ }
307
+
308
+ .view {
309
+ display: none;
310
+ flex: 1;
311
+ flex-direction: column;
312
+ overflow: hidden;
313
+ }
314
+ .view.active { display: flex; }
315
+
316
+ .view-header {
317
+ padding: 8px 14px;
318
+ background: var(--bg2);
319
+ border-bottom: 1px solid var(--border);
320
+ display: flex;
321
+ align-items: center;
322
+ justify-content: space-between;
323
+ flex-shrink: 0;
324
+ }
325
+ .vh-title { color: var(--amber); font-size: 10px; letter-spacing: 2px; font-weight: 600; }
326
+ .vh-meta { color: var(--muted); font-size: 9px; }
327
+
328
+ .view-body {
329
+ flex: 1;
330
+ overflow: auto;
331
+ padding: 12px;
332
+ position: relative;
333
+ }
334
+
335
+ /* ── Legend ────────────────────────────────────────────────────────────────── */
336
+ .legend {
337
+ display: flex;
338
+ gap: 14px;
339
+ align-items: center;
340
+ padding: 6px 12px;
341
+ border-bottom: 1px solid var(--border);
342
+ flex-shrink: 0;
343
+ flex-wrap: wrap;
344
+ }
345
+ .leg-item { display: flex; align-items: center; gap: 5px; font-size: 9px; color: var(--muted); }
346
+ .leg-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
347
+ .leg-line { width: 18px; height: 2px; flex-shrink: 0; }
348
+
349
+ /* ── Heatmap ───────────────────────────────────────────────────────���───────── */
350
+ .hm-wrap { overflow: auto; padding: 0; }
351
+ #heatmap-svg { display: block; }
352
+ .hm-cell { cursor: pointer; transition: opacity 0.15s; }
353
+ .hm-cell:hover { opacity: 0.75; stroke: #fff !important; stroke-width: 1.5 !important; }
354
+ .hm-cell.ripple-out { animation: hmripple 1s ease-out forwards; }
355
+ .hm-cell.ripple-in { animation: hmripple-in 0.8s ease-out forwards; }
356
+ .hm-cell.ripple-pulse { animation: hmpulse 1.2s ease-in-out 3; }
357
+ @keyframes hmripple { 0% { opacity:1; fill: rgba(224,52,52,0.9); } 100% { opacity: 1; } }
358
+ @keyframes hmripple-in { 0% { opacity:1; fill: rgba(0,200,122,0.9); } 100% { opacity: 1; } }
359
+ @keyframes hmpulse { 0%,100% { opacity:1; } 50% { opacity: 0.3; } }
360
+
361
+ /* ── Network ───────────────────────────────────────────────────────────────── */
362
+ #net-svg { display: block; width: 100%; height: 100%; }
363
+ .net-node { cursor: pointer; }
364
+ .net-node:hover circle { stroke-width: 2; }
365
+ .net-edge { transition: stroke-width 0.2s, stroke-opacity 0.2s; }
366
+
367
+ /* ── Right Panel ───────────────────────────────────────────────────────────── */
368
+ .rpanel {
369
+ background: var(--bg1);
370
+ border-left: 1px solid var(--border);
371
+ display: flex;
372
+ flex-direction: column;
373
+ overflow: hidden;
374
+ }
375
+
376
+ .rp-sec { border-bottom: 1px solid var(--border); flex-shrink: 0; }
377
+ .rp-head {
378
+ padding: 6px 10px;
379
+ background: var(--bg2);
380
+ border-bottom: 1px solid var(--border);
381
+ font-size: 9px;
382
+ letter-spacing: 2px;
383
+ color: var(--cyan);
384
+ font-weight: 600;
385
+ text-transform: uppercase;
386
+ display: flex;
387
+ justify-content: space-between;
388
+ align-items: center;
389
+ }
390
+ .rp-row {
391
+ display: flex;
392
+ justify-content: space-between;
393
+ padding: 4px 10px;
394
+ border-bottom: 1px solid var(--border);
395
+ font-size: 10px;
396
+ }
397
+ .rp-k { color: var(--muted); }
398
+ .rp-v { color: var(--white); }
399
+ .rp-v.up { color: var(--green); }
400
+ .rp-v.dn { color: var(--red); }
401
+ .rp-v.am { color: var(--amber); }
402
+
403
+ /* ── Inference Panel ───────────────────────────────────────────────────────── */
404
+ .infer-panel { padding: 10px; }
405
+ .infer-mode { display: flex; gap: 4px; margin-bottom: 10px; }
406
+ .infer-mode button {
407
+ flex: 1;
408
+ background: var(--bg3);
409
+ border: 1px solid var(--border);
410
+ color: var(--muted);
411
+ font-family: var(--font);
412
+ font-size: 9px;
413
+ padding: 5px;
414
+ cursor: pointer;
415
+ border-radius: var(--r);
416
+ letter-spacing: 1px;
417
+ transition: all 0.15s;
418
+ }
419
+ .infer-mode button.active { font-weight: 600; }
420
+ .infer-mode button.m-assert.active { background: var(--cyan-lo); color: var(--cyan); border-color: var(--cyan); }
421
+ .infer-mode button.m-intervene.active { background: var(--amber-lo); color: var(--amber); border-color: var(--amber); }
422
+ .infer-mode button.m-counter.active { background: var(--purple-lo); color: var(--purple); border-color: var(--purple); }
423
+
424
+ .infer-form {
425
+ background: var(--bg2);
426
+ border: 1px solid var(--border);
427
+ border-radius: var(--r);
428
+ padding: 10px;
429
+ margin-bottom: 8px;
430
+ }
431
+ .infer-label {
432
+ font-size: 9px;
433
+ color: var(--muted);
434
+ letter-spacing: 1.5px;
435
+ text-transform: uppercase;
436
+ margin-bottom: 5px;
437
+ }
438
+ .infer-select {
439
+ width: 100%;
440
+ background: var(--bg3);
441
+ border: 1px solid var(--border);
442
+ color: var(--white);
443
+ font-family: var(--font);
444
+ font-size: 10px;
445
+ padding: 5px 8px;
446
+ outline: none;
447
+ border-radius: var(--r);
448
+ margin-bottom: 8px;
449
+ cursor: pointer;
450
+ }
451
+ .infer-select:focus { border-color: var(--amber-dim); }
452
+
453
+ .slider-wrap { margin-bottom: 10px; }
454
+ .slider-row { display: flex; justify-content: space-between; margin-bottom: 4px; }
455
+ .slider-val { color: var(--amber); font-weight: 600; font-size: 10px; }
456
+ input[type=range] { width: 100%; accent-color: var(--amber); cursor: pointer; height: 3px; }
457
+
458
+ .run-btn {
459
+ width: 100%;
460
+ padding: 8px;
461
+ border: none;
462
+ font-family: var(--font);
463
+ font-size: 10px;
464
+ font-weight: 600;
465
+ letter-spacing: 2px;
466
+ cursor: pointer;
467
+ border-radius: var(--r);
468
+ transition: opacity 0.2s, transform 0.2s;
469
+ }
470
+ .run-btn.assert { background: var(--cyan); color: #000; }
471
+ .run-btn.intervene { background: var(--amber); color: #000; }
472
+ .run-btn.counter { background: var(--purple); color: #000; }
473
+ .run-btn:hover { opacity: 0.85; transform: translateY(-1px); }
474
+ .run-btn:active { transform: translateY(0); }
475
+ .run-btn:disabled { opacity: 0.4; cursor: not-allowed; }
476
+
477
+ /* ── Result Cards ──────────────────────────────────────────────────────────── */
478
+ .result-card {
479
+ background: var(--bg2);
480
+ border: 1px solid var(--border);
481
+ border-radius: var(--r);
482
+ padding: 10px;
483
+ margin-bottom: 8px;
484
+ position: relative;
485
+ overflow: hidden;
486
+ }
487
+ .result-card::before {
488
+ content: '';
489
+ position: absolute;
490
+ top: 0; left: 0;
491
+ width: 3px; height: 100%;
492
+ }
493
+ .result-card.assert::before { background: var(--cyan); }
494
+ .result-card.intervene::before { background: var(--amber); }
495
+ .result-card.counter::before { background: var(--purple); }
496
+ .rc-head { font-size: 9px; letter-spacing: 1.5px; margin-bottom: 6px; font-weight: 600; }
497
+ .rc-head.assert { color: var(--cyan); }
498
+ .rc-head.intervene { color: var(--amber); }
499
+ .rc-head.counter { color: var(--purple); }
500
+ .rc-row { display: flex; justify-content: space-between; font-size: 10px; padding: 2px 0; }
501
+ .rc-k { color: var(--muted); }
502
+ .rc-v { color: var(--white); }
503
+ .rc-v.up { color: var(--green); }
504
+ .rc-v.dn { color: var(--red); }
505
+ .rc-v.am { color: var(--amber); }
506
+
507
+ .ate-track {
508
+ height: 4px;
509
+ background: var(--bg4);
510
+ border-radius: 2px;
511
+ margin-top: 6px;
512
+ overflow: hidden;
513
+ }
514
+ .ate-fill {
515
+ height: 4px;
516
+ border-radius: 2px;
517
+ transition: width 0.8s ease;
518
+ }
519
+
520
+ .ripple-effects {
521
+ margin-top: 8px;
522
+ padding-top: 8px;
523
+ border-top: 1px solid var(--border);
524
+ }
525
+ .ripple-title { font-size: 9px; color: var(--muted); letter-spacing: 1px; margin-bottom: 5px; }
526
+ .ripple-chip {
527
+ display: inline-flex;
528
+ align-items: center;
529
+ gap: 4px;
530
+ padding: 2px 7px;
531
+ border-radius: 2px;
532
+ font-size: 9px;
533
+ margin: 2px;
534
+ border: 1px solid;
535
+ }
536
+ .ripple-chip.up { background: var(--green-lo); border-color: var(--green); color: var(--green); }
537
+ .ripple-chip.dn { background: var(--red-lo); border-color: var(--red); color: var(--red); }
538
+
539
+ /* ── News Feed ─────────────────────────────────────────────────────────────── */
540
+ .news-item {
541
+ padding: 7px 10px;
542
+ border-bottom: 1px solid var(--border);
543
+ cursor: pointer;
544
+ transition: background 0.1s;
545
+ }
546
+ .news-item:hover { background: var(--bg3); }
547
+ .news-top { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; }
548
+ .news-score { font-size: 8px; padding: 1px 5px; border-radius: 2px; font-weight: 600; }
549
+ .news-score.hi { background: var(--green-lo); color: var(--green); }
550
+ .news-score.md { background: var(--amber-lo); color: var(--amber); }
551
+ .news-score.lo { background: var(--red-lo); color: var(--red); }
552
+ .news-sym { color: var(--amber); font-size: 9px; font-weight: 600; }
553
+ .news-text { font-size: 9px; color: var(--muted); line-height: 1.5; margin-bottom: 4px; }
554
+ .news-tags { display: flex; gap: 3px; flex-wrap: wrap; }
555
+ .news-tag { font-size: 8px; padding: 1px 5px; border: 1px solid var(--border); color: var(--muted); border-radius: 2px; }
556
+
557
+ /* ── HHKD View ─────────────────────────────────────────────────────────────── */
558
+ .phi-row {
559
+ display: flex;
560
+ align-items: center;
561
+ gap: 8px;
562
+ padding: 4px 10px;
563
+ border-bottom: 1px solid var(--border);
564
+ cursor: pointer;
565
+ transition: background 0.1s;
566
+ }
567
+ .phi-row:hover { background: var(--bg3); }
568
+ .phi-sym { width: 66px; font-size: 10px; color: var(--amber); font-weight: 600; flex-shrink: 0; }
569
+ .phi-bar-wrap { flex: 1; height: 8px; background: var(--bg4); border-radius: 4px; overflow: hidden; }
570
+ .phi-bar-fill { height: 8px; border-radius: 4px; transition: width 0.4s; }
571
+ .phi-val { width: 36px; text-align: right; font-size: 10px; flex-shrink: 0; }
572
+
573
+ /* ── Sector View ───────────────────────────────────────────────────────────── */
574
+ .sector-grid { padding: 10px; display: flex; flex-direction: column; gap: 8px; }
575
+ .sec-card { background: var(--bg2); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; }
576
+ .sec-card-head {
577
+ padding: 7px 12px;
578
+ display: flex;
579
+ align-items: center;
580
+ justify-content: space-between;
581
+ cursor: pointer;
582
+ transition: background 0.1s;
583
+ }
584
+ .sec-card-head:hover { background: var(--bg3); }
585
+ .sec-name { font-size: 11px; font-weight: 600; letter-spacing: 1px; color: var(--white); }
586
+ .sec-phi { font-size: 10px; }
587
+ .sec-members { display: flex; flex-wrap: wrap; gap: 4px; padding: 8px; }
588
+ .sec-chip {
589
+ padding: 3px 9px;
590
+ border: 1px solid var(--border);
591
+ font-size: 9px;
592
+ border-radius: 2px;
593
+ cursor: pointer;
594
+ transition: all 0.15s;
595
+ }
596
+ .sec-chip:hover { border-color: var(--amber); color: var(--amber); }
597
+ .sec-chip.rippling { animation: chipripple 0.7s ease-out; }
598
+ .sec-chip.rippling-up { animation: chipripple-up 0.7s ease-out; }
599
+ @keyframes chipripple { 0% { background: rgba(224,52,52,.4); border-color: var(--red); } 100% { background: transparent; } }
600
+ @keyframes chipripple-up { 0% { background: rgba(0,200,122,.4); border-color: var(--green); } 100% { background: transparent; } }
601
+
602
+ /* ── Node Popup ────────────────────────────────────────────────────────────── */
603
+ .node-popup {
604
+ position: fixed;
605
+ z-index: 500;
606
+ background: var(--bg2);
607
+ border: 1px solid var(--amber);
608
+ border-radius: var(--r);
609
+ padding: 12px;
610
+ min-width: 200px;
611
+ max-width: 260px;
612
+ pointer-events: none;
613
+ display: none;
614
+ box-shadow: 0 8px 32px rgba(0,0,0,.6);
615
+ }
616
+ .np-head {
617
+ color: var(--amber);
618
+ font-size: 12px;
619
+ font-weight: 600;
620
+ margin-bottom: 8px;
621
+ display: flex;
622
+ justify-content: space-between;
623
+ align-items: center;
624
+ }
625
+ .np-row {
626
+ display: flex;
627
+ justify-content: space-between;
628
+ padding: 3px 0;
629
+ border-bottom: 1px solid var(--border);
630
+ font-size: 10px;
631
+ }
632
+ .np-row:last-child { border: none; }
633
+ .np-k { color: var(--muted); }
634
+ .np-v { color: var(--white); }
635
+ .np-v.up { color: var(--green); }
636
+ .np-v.dn { color: var(--red); }
637
+ .np-v.am { color: var(--amber); }
638
+
639
+ /* ── Status Strip ──────────────────────────────────────────────────────────── */
640
+ .status-strip {
641
+ height: 22px;
642
+ background: var(--bg2);
643
+ border-top: 1px solid var(--border);
644
+ display: flex;
645
+ align-items: center;
646
+ padding: 0 10px;
647
+ gap: 16px;
648
+ flex-shrink: 0;
649
+ overflow: hidden;
650
+ }
651
+ .ss-chip { font-size: 9px; display: flex; gap: 5px; white-space: nowrap; }
652
+ .ss-k { color: var(--muted); }
653
+ .ss-v { color: var(--white); }
654
+ .ss-v.am { color: var(--amber); }
655
+ .ss-v.up { color: var(--green); }
656
+
657
+ /* ── Accordion ─────────────────────────────────────────────────────────────── */
658
+ .accordion { border: 1px solid var(--border); border-radius: var(--r); margin-bottom: 6px; overflow: hidden; }
659
+ .acc-head {
660
+ display: flex;
661
+ align-items: center;
662
+ justify-content: space-between;
663
+ padding: 7px 10px;
664
+ cursor: pointer;
665
+ background: var(--bg2);
666
+ user-select: none;
667
+ }
668
+ .acc-head:hover { background: var(--bg3); }
669
+ .acc-title { font-size: 10px; color: var(--white); font-weight: 500; letter-spacing: 0.5px; }
670
+ .acc-badge { font-size: 8px; padding: 1px 6px; border-radius: 2px; font-weight: 600; letter-spacing: 1px; }
671
+ .acc-badge.up { background: var(--green-lo); color: var(--green); }
672
+ .acc-badge.dn { background: var(--red-lo); color: var(--red); }
673
+ .acc-badge.am { background: var(--amber-lo); color: var(--amber); }
674
+ .acc-badge.cy { background: var(--cyan-lo); color: var(--cyan); }
675
+ .acc-chevron { color: var(--muted); font-size: 10px; transition: transform 0.2s; }
676
+ .acc-chevron.open { transform: rotate(180deg); }
677
+ .acc-body { display: none; border-top: 1px solid var(--border); }
678
+ .acc-body.open { display: block; }
679
+ .acc-row { display: flex; justify-content: space-between; padding: 4px 10px; border-bottom: 1px solid var(--border); font-size: 10px; }
680
+ .acc-k { color: var(--muted); }
681
+ .acc-v { color: var(--white); }
682
+ .acc-v.up { color: var(--green); }
683
+ .acc-v.dn { color: var(--red); }
684
+ .acc-v.am { color: var(--amber); }
685
+
686
+ /* ── Ripple Ring ───────────────────────────────────────────────────────────── */
687
+ @keyframes pulse-ring { 0% { transform: scale(.8); opacity: 1; } 100% { transform: scale(2.5); opacity: 0; } }
688
+ .ripple-ring {
689
+ position: absolute;
690
+ border-radius: 50%;
691
+ pointer-events: none;
692
+ animation: pulse-ring 0.8s ease-out forwards;
693
+ }
694
+
695
+ /* ── Error toast ───────────────────────────────────────────────────────────── */
696
+ #error-toast {
697
+ position: fixed;
698
+ bottom: 28px;
699
+ left: 50%;
700
+ transform: translateX(-50%) translateY(60px);
701
+ background: var(--red-lo);
702
+ border: 1px solid var(--red);
703
+ color: var(--red);
704
+ font-size: 10px;
705
+ padding: 8px 16px;
706
+ border-radius: var(--r);
707
+ z-index: 9000;
708
+ transition: transform 0.3s ease;
709
+ letter-spacing: 0.5px;
710
+ }
711
+ #error-toast.show { transform: translateX(-50%) translateY(0); }
server.py ADDED
@@ -0,0 +1,918 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ dashboard_server.py
3
+ ====================
4
+ Iroha Financial Intelligence β€” gr.Server entry point.
5
+
6
+ Architecture
7
+ ------------
8
+ gr.Server (extends FastAPI)
9
+ β”œβ”€β”€ GET / β†’ serves frontend/index.html
10
+ β”œβ”€β”€ GET /static/* β†’ serves frontend/{style.css, app.js} (StaticFiles)
11
+ β”‚
12
+ β”œβ”€β”€ @server.api run_inference β†’ DoFlow / SCM causal query (via BACKEND_API)
13
+ β”‚
14
+ β”œβ”€β”€ GET /v2/health β†’ health-check
15
+ β”‚
16
+ └── All existing /v2/* routers from main.py are included here too
17
+ (so this server is a superset of main.py).
18
+
19
+ Usage
20
+ -----
21
+ python dashboard_server.py
22
+
23
+ Or with uvicorn:
24
+ uvicorn dashboard_server:server --host 0.0.0.0 --port 7860 --reload
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ import sys
31
+ import json
32
+ import logging
33
+ import urllib.error
34
+ import urllib.parse
35
+ import urllib.request
36
+ from pathlib import Path
37
+ from gradio import Server
38
+ from typing import Any, Dict, List, Optional
39
+ from fastapi.responses import HTMLResponse
40
+ from fastapi.staticfiles import StaticFiles
41
+ from fastapi.middleware.cors import CORSMiddleware
42
+ from dotenv import load_dotenv
43
+
44
+ load_dotenv()
45
+
46
+ BASE_DIR = Path(__file__).parent.resolve()
47
+ if str(BASE_DIR) not in sys.path:
48
+ sys.path.insert(0, str(BASE_DIR))
49
+
50
+ # Also add the backend directory to sys.path so we can import 'app', 'causal', etc.
51
+ BACKEND_DIR = (BASE_DIR.parent / "noisy_boy_backend").resolve()
52
+ if BACKEND_DIR.exists() and str(BACKEND_DIR) not in sys.path:
53
+ sys.path.insert(0, str(BACKEND_DIR))
54
+
55
+ # ─────────────────────────────────────────────────────────────────────────────
56
+ # Logging
57
+ # ─────────────────────────────────────────────────────────────────────────────
58
+ logging.basicConfig(
59
+ level=logging.INFO,
60
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
61
+ handlers=[logging.StreamHandler()],
62
+ )
63
+ logger = logging.getLogger("dashboard-server")
64
+
65
+ # ─────────────────────────────────────────────────────────────────────────────
66
+ # gr.Server
67
+ # ─────────────────────────────────────────────────────────────────────────────
68
+
69
+ FRONTEND_DIR = BASE_DIR / "frontend"
70
+ INDEX_HTML = FRONTEND_DIR / "index.html"
71
+
72
+ # ─────────────────────────────────────────────────────────────────────────────
73
+ # Backend URL
74
+ # ─────────────────────────────────────────────────────────────────────────────
75
+
76
+ _BACKEND_BASE_URL: str = os.environ.get("BACKEND_API_URL", "http://localhost:7860")
77
+
78
+
79
+ # ─────────────────────────────────────────────────────────────────────────────
80
+ # Public API
81
+ # ─────────────────────────────────────────────────────────────────────────────
82
+
83
+ def run_pipeline(
84
+ ticker: str = "RELIANCE",
85
+ threshold: float = 0.5,
86
+ treatment: Optional[str] = None,
87
+ outcome: Optional[str] = None,
88
+ include_pywhyllm: bool = False,
89
+ ) -> Dict[str, Any]:
90
+ """
91
+ Fetch the validated causal matrix for *ticker* from the backend API.
92
+
93
+ Parameters
94
+ ----------
95
+ ticker : NSE symbol (e.g. RELIANCE, HDFCBANK)
96
+ threshold : adjacency threshold for DAG construction
97
+ treatment : optional treatment node for pywhyllm assumptions
98
+ outcome : optional outcome node for pywhyllm assumptions
99
+ include_pywhyllm: request pywhyllm assumption report from backend
100
+
101
+ Returns
102
+ -------
103
+ dict with keys:
104
+ nodes, adj_matrix, dag_adj, equations, data_level,
105
+ topological_order, nodes_graph, links_graph
106
+ Raises RuntimeError if the backend cannot be reached or returns an error.
107
+ """
108
+ params: dict = {"threshold": threshold}
109
+ if treatment:
110
+ params["treatment"] = treatment
111
+ if outcome:
112
+ params["outcome"] = outcome
113
+ if include_pywhyllm:
114
+ params["include_pywhyllm"] = "true"
115
+
116
+ qs = urllib.parse.urlencode(params)
117
+ url = f"{_BACKEND_BASE_URL}/v2/api/singular-causal/causal-matrix/{ticker.upper()}?{qs}"
118
+ logger.info("run_pipeline: fetching %s", url)
119
+
120
+ try:
121
+ with urllib.request.urlopen(url, timeout=60) as resp:
122
+ raw = resp.read()
123
+ except urllib.error.URLError as exc:
124
+ raise RuntimeError(
125
+ f"Could not reach backend at {_BACKEND_BASE_URL}. "
126
+ f"Ensure noisy_boy_backend is running. Original error: {exc}"
127
+ ) from exc
128
+
129
+ payload = json.loads(raw)
130
+
131
+ status = payload.get("status")
132
+ if status == "not_found":
133
+ raise RuntimeError(
134
+ payload.get(
135
+ "detail",
136
+ f"No cached pipeline data for {ticker} on backend. "
137
+ "Run the singular-causal pipeline on the backend first.",
138
+ )
139
+ )
140
+ if status not in ("success", None, "ok"):
141
+ raise RuntimeError(
142
+ f"Backend returned unexpected status '{status}' for {ticker}. "
143
+ f"Payload: {payload}"
144
+ )
145
+
146
+ # Build frontend-friendly graph representation
147
+ nodes: List[str] = payload.get("nodes", [])
148
+ adj_matrix = payload.get("adj_matrix", [])
149
+ dag_adj = payload.get("dag_adj", [])
150
+
151
+ nodes_graph = [{"id": n, "label": n} for n in nodes]
152
+ links_graph = []
153
+ for i, src in enumerate(nodes):
154
+ for j, dst in enumerate(nodes):
155
+ if i != j:
156
+ try:
157
+ score = float(adj_matrix[i][j])
158
+ except (IndexError, TypeError, ValueError):
159
+ score = 0.0
160
+ if score >= threshold:
161
+ links_graph.append({"source": src, "target": dst, "score": round(score, 4)})
162
+
163
+ return {
164
+ **payload,
165
+ "nodes_graph": nodes_graph,
166
+ "links_graph": links_graph,
167
+ }
168
+
169
+ server = Server(
170
+ title="Iroha Causal Terminal",
171
+ description=(
172
+ "Iroha Financial Intelligence β€” real-time causal probability matrix, "
173
+ "HHKD decomposition, DoFlow inference and sector hierarchy over NIFTY50."
174
+ ),
175
+ version="2.0.0",
176
+ )
177
+
178
+ # ── CORS (same as main.py) ────────────────────────────────────────────────
179
+ server.add_middleware(
180
+ CORSMiddleware,
181
+ allow_origins=["*"],
182
+ allow_credentials=True,
183
+ allow_methods=["*"],
184
+ allow_headers=["*"],
185
+ )
186
+
187
+ # ── Static files β€” mount frontend/ at /static ─────────────────────────────
188
+ server.mount(
189
+ "/static",
190
+ StaticFiles(directory=str(FRONTEND_DIR)),
191
+ name="static",
192
+ )
193
+
194
+ # ─────────────────────────────────────────────────────────────────────────────
195
+ # HTML route β€” serves the custom frontend
196
+ # ─────────────────────────────────────────────────────────────────────────────
197
+
198
+ @server.get("/", response_class=HTMLResponse, include_in_schema=False)
199
+ async def serve_index():
200
+ """Serve the Iroha Causal Terminal SPA."""
201
+ if not INDEX_HTML.exists():
202
+ return HTMLResponse("<h1>Frontend not found. Run from backend/</h1>", status_code=500)
203
+ return HTMLResponse(INDEX_HTML.read_text(encoding="utf-8"))
204
+
205
+
206
+ # ─────────────────────────────────────────────────────────────────────────────
207
+ # Health check
208
+ # ─────────────────────────────────────────────────────────────────────────────
209
+
210
+ @server.get("/v2/health", tags=["utility"])
211
+ async def health():
212
+ """Lightweight health-check used by the frontend API banner."""
213
+ return {"status": "ok", "version": "2.0.0"}
214
+
215
+
216
+ # ─────────────────────────────────────────────────────────────────────────────
217
+ # gr.Server API endpoints (Gradio-backed β€” queue + SSE streaming)
218
+ # These are reachable via the Gradio JS Client as well as plain fetch().
219
+ # ─────────────────────────────────────────────────────────────────────────────
220
+
221
+ # ── Helpers ───────────────────────────────────────────────────────────────
222
+
223
+ # URL of the noisy_boy_backend β€” used to fetch the validated causal matrix.
224
+ # By default, point to ourselves since we now successfully mount the backend routers.
225
+ # Override via BACKEND_API_URL env var if running a separate backend on 8000.
226
+ _BACKEND_BASE_URL = os.environ.get("BACKEND_API_URL", "http://localhost:7860")
227
+
228
+
229
+ def _fetch_causal_matrix(
230
+ ticker: str,
231
+ treatment: Optional[str] = None,
232
+ outcome: Optional[str] = None,
233
+ include_pywhyllm: bool = False,
234
+ threshold: float = 0.5,
235
+ ) -> Optional[dict]:
236
+ """
237
+ Fetch the fully validated causal matrix from the backend API.
238
+
239
+ Calls GET {BACKEND_BASE_URL}/v2/api/singular-causal/causal-matrix/{ticker}
240
+ and returns the parsed JSON payload, or None on failure.
241
+
242
+ The payload contains:
243
+ nodes β€” ordered list of node names
244
+ adj_matrix β€” raw float adjacency matrix
245
+ dag_adj β€” thresholded 0/1 DAG
246
+ equations β€” per-node structural equations (coefficients, intercepts, residual_std)
247
+ data_level β€” (T, N) time-series observations used to fit the SCM
248
+ topological_order β€” nodes in topological traversal order
249
+ pywhyllm_report — (optional) assumption analysis for treatment→outcome
250
+ """
251
+ import urllib.request
252
+ import urllib.error
253
+ import urllib.parse
254
+
255
+ params: dict = {"threshold": threshold}
256
+ if treatment:
257
+ params["treatment"] = treatment
258
+ if outcome:
259
+ params["outcome"] = outcome
260
+ if include_pywhyllm:
261
+ params["include_pywhyllm"] = "true"
262
+
263
+ query_string = urllib.parse.urlencode(params)
264
+ url = f"{_BACKEND_BASE_URL}/v2/api/singular-causal/causal-matrix/{ticker.upper()}?{query_string}"
265
+
266
+ try:
267
+ with urllib.request.urlopen(url, timeout=30) as resp:
268
+ raw = resp.read()
269
+ data = json.loads(raw)
270
+ if data.get("status") not in ("success", None):
271
+ logger.warning(
272
+ "_fetch_causal_matrix: backend returned status=%s for URL %s. Payload: %s",
273
+ data.get("status"), url, data,
274
+ )
275
+ return None
276
+ return data
277
+ except Exception as exc:
278
+ logger.warning("_fetch_causal_matrix failed for %s: %s", ticker, exc)
279
+ return None
280
+
281
+
282
+ def _safe_json(obj: Any) -> Any:
283
+ """Recursively make numpy types JSON-serialisable."""
284
+ try:
285
+ import numpy as np
286
+ if isinstance(obj, np.ndarray):
287
+ return obj.tolist()
288
+ if isinstance(obj, np.integer):
289
+ return int(obj)
290
+ if isinstance(obj, np.floating):
291
+ return float(obj)
292
+ except ImportError:
293
+ pass
294
+ if isinstance(obj, dict):
295
+ return {k: _safe_json(v) for k, v in obj.items()}
296
+ if isinstance(obj, (list, tuple)):
297
+ return [_safe_json(v) for v in obj]
298
+ return obj
299
+
300
+
301
+ def _resolve_value(value: float, value_type: str, current: float) -> float:
302
+ """Convert a user-supplied value + value_type to the absolute node value."""
303
+ vt = value_type.strip().lower()
304
+ if vt == "absolute":
305
+ return value
306
+ if vt == "multiplier":
307
+ return current * value
308
+ if vt == "percent_change":
309
+ return current * (1.0 + value / 100.0)
310
+ # default: treat as absolute
311
+ return value
312
+
313
+
314
+ # ─────────────────────────────────────────────────────────────────────────────
315
+ # Pure-numpy inference helpers (no local causal training imports)
316
+ # These functions work entirely from the payload returned by the backend API.
317
+ # ─────────────────────────────────────────────────────────────────────────────
318
+
319
+ def _build_dag_from_payload(payload: dict):
320
+ """
321
+ Return a numpy bool DAG adjacency matrix and list of node names
322
+ from the backend causal-matrix payload.
323
+ """
324
+ import numpy as np
325
+ nodes = payload["nodes"]
326
+ dag_adj = np.array(payload["dag_adj"], dtype=bool)
327
+ adj_matrix = np.array(payload["adj_matrix"], dtype=float)
328
+ return nodes, dag_adj, adj_matrix
329
+
330
+
331
+ def _propagate_intervention(
332
+ nodes: list,
333
+ dag_adj,
334
+ equations: dict,
335
+ data_level,
336
+ topological_order: list,
337
+ treatment: str,
338
+ abs_value: float,
339
+ targets: list,
340
+ horizon: int = 5,
341
+ ):
342
+ """
343
+ Propagate a hard intervention (do(treatment=abs_value)) through the
344
+ structural equations for `horizon` steps, returning ATE per target node.
345
+ Uses only numpy β€” no local causal model imports.
346
+ """
347
+ import numpy as np
348
+
349
+ node_to_idx = {n: i for i, n in enumerate(nodes)}
350
+ n = len(nodes)
351
+ T = data_level.shape[0]
352
+
353
+ # Start from the last observed time step
354
+ state = data_level[-1].copy().astype(float)
355
+
356
+ # Fix the treatment node
357
+ t_idx = node_to_idx[treatment]
358
+ state[t_idx] = abs_value
359
+
360
+ ate_per_target: Dict[str, float] = {}
361
+ baseline = data_level[-1].copy().astype(float)
362
+
363
+ for _ in range(horizon):
364
+ new_state = state.copy()
365
+ for node_name in topological_order:
366
+ if node_name == treatment:
367
+ continue
368
+ eq = equations.get(node_name)
369
+ if eq is None:
370
+ continue
371
+ parents = eq.get("parents", [])
372
+ coefficients = eq.get("coefficients", {})
373
+ intercept = float(eq.get("intercept", 0.0))
374
+ if not parents:
375
+ continue
376
+ val = intercept
377
+ for p in parents:
378
+ p_idx = node_to_idx.get(p)
379
+ if p_idx is not None:
380
+ val += float(coefficients.get(p, 0.0)) * float(state[p_idx])
381
+ n_idx = node_to_idx[node_name]
382
+ new_state[n_idx] = val
383
+ state = new_state
384
+
385
+ for target in targets:
386
+ t_i = node_to_idx.get(target)
387
+ if t_i is not None:
388
+ ate_per_target[target] = float(state[t_i] - baseline[t_i])
389
+
390
+ return ate_per_target, state
391
+
392
+
393
+ def _abduct_and_predict(
394
+ nodes: list,
395
+ dag_adj,
396
+ equations: dict,
397
+ data_level,
398
+ topological_order: list,
399
+ treatment: str,
400
+ cf_value: float,
401
+ target: str,
402
+ observed_t: int,
403
+ ):
404
+ """
405
+ Simple SCM abduction for counterfactual:
406
+ 1. Abduct residuals from the observed time step.
407
+ 2. Re-run structural equations with treatment fixed to cf_value.
408
+ 3. Return factual_outcome, cf_outcome, ITE.
409
+ """
410
+ import numpy as np
411
+
412
+ node_to_idx = {n: i for i, n in enumerate(nodes)}
413
+ obs = data_level[observed_t].copy().astype(float)
414
+
415
+ # Abduct residuals
416
+ residuals: Dict[str, float] = {}
417
+ for node_name in topological_order:
418
+ eq = equations.get(node_name)
419
+ if eq is None or not eq.get("parents"):
420
+ residuals[node_name] = 0.0
421
+ continue
422
+ parents = eq.get("parents", [])
423
+ coefficients = eq.get("coefficients", {})
424
+ intercept = float(eq.get("intercept", 0.0))
425
+ predicted = intercept
426
+ for p in parents:
427
+ p_idx = node_to_idx.get(p)
428
+ if p_idx is not None:
429
+ predicted += float(coefficients.get(p, 0.0)) * float(obs[node_to_idx[p]])
430
+ residuals[node_name] = float(obs[node_to_idx[node_name]]) - predicted
431
+
432
+ # Counterfactual: fix treatment, replay equations with abducted noise
433
+ cf_state = obs.copy()
434
+ cf_state[node_to_idx[treatment]] = cf_value
435
+
436
+ for node_name in topological_order:
437
+ if node_name == treatment:
438
+ continue
439
+ eq = equations.get(node_name)
440
+ if eq is None or not eq.get("parents"):
441
+ continue
442
+ parents = eq.get("parents", [])
443
+ coefficients = eq.get("coefficients", {})
444
+ intercept = float(eq.get("intercept", 0.0))
445
+ predicted = intercept
446
+ for p in parents:
447
+ p_idx = node_to_idx.get(p)
448
+ if p_idx is not None:
449
+ predicted += float(coefficients.get(p, 0.0)) * float(cf_state[p_idx])
450
+ n_idx = node_to_idx[node_name]
451
+ cf_state[n_idx] = predicted + residuals.get(node_name, 0.0)
452
+
453
+ factual_outcome = float(obs[node_to_idx[target]])
454
+ cf_outcome = float(cf_state[node_to_idx[target]])
455
+ ite = cf_outcome - factual_outcome
456
+ return factual_outcome, cf_outcome, ite
457
+
458
+
459
+ # ── API: Causal inference (assert / intervene / counterfactual) ───────────
460
+ #
461
+ # Architecture:
462
+ # 1. Fetch the VALIDATED causal matrix from noisy_boy_backend via HTTP.
463
+ # The backend has already run CUTS+ learning + pywhyllm + DoWhy validation.
464
+ # 2. Use the payload data (equations, adj, data_level) for inference
465
+ # using pure numpy/pandas β€” no local causal training imports required.
466
+ # 3. Optionally consult pywhyllm guidance from the backend payload.
467
+
468
+
469
+ @server.api(
470
+ name="run_inference",
471
+ description=(
472
+ "Run causal inference (association / intervention / counterfactual) "
473
+ "using a validated causal matrix fetched from the backend API. "
474
+ "Layers: 1=Association(DoWhy backdoor), 2=Intervention(SCM propagation), "
475
+ "3=Counterfactual(SCM abduction)."
476
+ ),
477
+ concurrency_limit=4,
478
+ )
479
+ def run_inference(
480
+ ticker: str = "RELIANCE",
481
+ mode: str = "assert",
482
+ treatment: str = "Revenue",
483
+ outcome: Optional[str] = "NetIncome",
484
+ target: Optional[str] = None,
485
+ value: float = 1.1,
486
+ cf_value: Optional[float] = None,
487
+ value_type: str = "multiplier",
488
+ horizon: int = 5,
489
+ observed_t: int = -1,
490
+ threshold: float = 0.5,
491
+ use_pywhyllm: bool = False,
492
+ return_assumption_report: bool = False,
493
+ ) -> Dict[str, Any]:
494
+ """
495
+ Three-layer causal inference driven by the backend's validated causal matrix.
496
+
497
+ Parameters
498
+ ----------
499
+ ticker : NSE ticker (backend must have a cached pipeline run for it)
500
+ mode : "assert" | "intervene" | "counterfactual"
501
+ treatment : source node name
502
+ outcome : outcome node (assert / Layer-1 association)
503
+ target : target node (counterfactual / Layer-3); if None, falls back to outcome
504
+ value : intervention magnitude (Layer 2)
505
+ cf_value : explicit counterfactual value (Layer 3); if None, 'value' + 'value_type' used
506
+ value_type : "absolute" | "multiplier" | "percent_change"
507
+ horizon : propagation horizon for intervention (Layer 2, steps)
508
+ observed_t : time index for counterfactual abduction (Layer 3; -1 = last obs)
509
+ threshold : adjacency threshold used when loading the graph
510
+ use_pywhyllm : consult pywhyllm for structural assumptions before running DoWhy
511
+ return_assumption_report : include the pywhyllm report dict in the response
512
+
513
+ Returns
514
+ -------
515
+ JSON with ate, ci_lower, ci_upper, probability, ripple_effects,
516
+ and (for counterfactual) factual_outcome, counterfactual_outcome, ite,
517
+ shapley_contributions.
518
+ """
519
+ import numpy as np
520
+ import pandas as pd
521
+
522
+ try:
523
+ # ── 0. Determine target node ──────────────────────────────────────────
524
+ target_node = target if target else outcome
525
+ if not target_node:
526
+ return {"status": "error", "detail": "Either 'outcome' or 'target' must be provided."}
527
+
528
+ # ── 1. Fetch validated causal matrix from backend ─────────────────────
529
+ # This includes the adjacency matrix, fitted structural equations,
530
+ # level-domain data, and optionally a pywhyllm assumption report.
531
+ payload = _fetch_causal_matrix(
532
+ ticker=ticker,
533
+ treatment=treatment if use_pywhyllm else None,
534
+ outcome=target_node if use_pywhyllm else None,
535
+ include_pywhyllm=use_pywhyllm,
536
+ threshold=threshold,
537
+ )
538
+
539
+ if payload is None:
540
+ return {
541
+ "status": "error",
542
+ "detail": (
543
+ f"Could not fetch causal matrix for {ticker} from backend. "
544
+ "Ensure noisy_boy_backend is running and the pipeline has been run for this ticker."
545
+ ),
546
+ }
547
+
548
+ if payload.get("status") == "not_found":
549
+ return {
550
+ "status": "error",
551
+ "detail": payload.get("detail", f"No cached pipeline data for {ticker}."),
552
+ }
553
+
554
+ # ── 2. Unpack payload (no local causal training imports) ──────────────
555
+ nodes, dag_adj, adj_matrix = _build_dag_from_payload(payload)
556
+ node_to_idx = {n: i for i, n in enumerate(nodes)}
557
+ data_level = np.array(payload["data_level"], dtype=float)
558
+ equations_raw = payload.get("equations", {})
559
+ topo_order = payload.get("topological_order", nodes)
560
+
561
+ T = data_level.shape[0]
562
+ df = pd.DataFrame(data_level, columns=nodes)
563
+
564
+ if treatment not in node_to_idx:
565
+ return {"status": "error", "detail": f"Unknown treatment node: {treatment}"}
566
+ if target_node not in node_to_idx:
567
+ return {"status": "error", "detail": f"Unknown outcome/target node: {target_node}"}
568
+ if df.shape[0] < 5:
569
+ return {
570
+ "status": "error",
571
+ "detail": f"Insufficient observations ({df.shape[0]}) to run inference.",
572
+ }
573
+
574
+ # ── 3. pywhyllm structural guidance (from backend payload) ────────────
575
+ pywhyllm_report: Optional[dict] = payload.get("pywhyllm_report")
576
+ adjustment_sets: List[List[str]] = []
577
+
578
+ if use_pywhyllm and pywhyllm_report and pywhyllm_report.get("available"):
579
+ raw_backdoor = pywhyllm_report.get("suggested_backdoor_sets") or []
580
+ valid_nodes = set(nodes) - {treatment, target_node}
581
+ for suggested_set in raw_backdoor:
582
+ clean = [n for n in suggested_set if n in valid_nodes]
583
+ if clean and clean not in adjustment_sets:
584
+ adjustment_sets.append(clean)
585
+
586
+ confounders = [
587
+ n for n in (pywhyllm_report.get("suggested_confounders") or [])
588
+ if n in valid_nodes
589
+ ]
590
+ if confounders and confounders not in adjustment_sets:
591
+ adjustment_sets.append(confounders)
592
+
593
+ result: Dict[str, Any] = {}
594
+
595
+ # ═══════════════════════════════════════════════════════════════════════
596
+ # LAYER 1 β€” Association: "What does Y look like given X?"
597
+ # Uses DoWhy with the backend-provided DAG, falling back to OLS.
598
+ # ═══════════════════════════════════════════════════════════════════════
599
+ if mode == "assert":
600
+ try:
601
+ from dowhy import CausalModel
602
+
603
+ # Build DOT graph string from dag_adj
604
+ edges = []
605
+ for si, src in enumerate(nodes):
606
+ for di, dst in enumerate(nodes):
607
+ if dag_adj[si, di]:
608
+ edges.append(f"{src} -> {dst}")
609
+ graph_dot = "digraph{" + "; ".join(edges) + "}"
610
+
611
+ dowhy_model = CausalModel(
612
+ data=df,
613
+ treatment=treatment,
614
+ outcome=target_node,
615
+ graph=graph_dot,
616
+ )
617
+ identified_estimand = dowhy_model.identify_effect(
618
+ proceed_when_unidentifiable=True
619
+ )
620
+ estimate = dowhy_model.estimate_effect(
621
+ identified_estimand,
622
+ method_name="backdoor.linear_regression",
623
+ )
624
+ ate = float(estimate.value)
625
+
626
+ # Confidence interval from OLS residuals
627
+ se: float = 0.0
628
+ try:
629
+ import numpy.linalg as nla
630
+ X = df[[c for c in df.columns if c != target_node]].values
631
+ y = df[target_node].values
632
+ XtX_inv = nla.pinv(X.T @ X)
633
+ resid = y - X @ nla.lstsq(X, y, rcond=None)[0]
634
+ sigma2 = float(np.sum(resid ** 2) / max(1, len(y) - X.shape[1]))
635
+ t_idx_local = list(df.columns).index(treatment)
636
+ se = float(np.sqrt(max(0.0, sigma2 * XtX_inv[t_idx_local, t_idx_local])))
637
+ except Exception:
638
+ se = abs(ate) * 0.15 # graceful fallback
639
+
640
+ ci_lower = ate - 1.96 * se
641
+ ci_upper = ate + 1.96 * se
642
+ prob = min(1.0, abs(ate) / (abs(ate) + se + 1e-9))
643
+
644
+ # Ripple effects: direct downstream neighbours of treatment
645
+ ripple_effects = []
646
+ t_idx_g = node_to_idx[treatment]
647
+ for j, node in enumerate(nodes):
648
+ if node == treatment or node == target_node:
649
+ continue
650
+ if dag_adj[t_idx_g, j]:
651
+ edge_score = float(adj_matrix[t_idx_g, j])
652
+ ripple_effects.append({
653
+ "ticker": node,
654
+ "direction": 1 if ate > 0 else -1,
655
+ "magnitude": round(edge_score * abs(ate), 4),
656
+ })
657
+
658
+ result = {
659
+ "ate": ate,
660
+ "ci_lower": ci_lower,
661
+ "ci_upper": ci_upper,
662
+ "probability": prob,
663
+ "strategy": "backdoor.linear_regression",
664
+ "adjustment_set": adjustment_sets[0] if adjustment_sets else [],
665
+ "ripple_effects": ripple_effects,
666
+ }
667
+
668
+ except Exception as dowhy_exc:
669
+ # DoWhy not installed or identification failed β€” fall back to OLS
670
+ logger.warning("DoWhy association failed (%s), falling back to OLS", dowhy_exc)
671
+ t_idx_g = node_to_idx[treatment]
672
+ out_idx = node_to_idx[target_node]
673
+
674
+ # Simple OLS: regress target on treatment
675
+ X = df[[treatment]].values
676
+ y = df[target_node].values
677
+ import numpy.linalg as nla
678
+ coef = nla.lstsq(np.c_[np.ones(len(X)), X], y, rcond=None)[0]
679
+ ate = float(coef[1])
680
+ se = abs(ate) * 0.15
681
+ ci_lower = ate - 1.96 * se
682
+ ci_upper = ate + 1.96 * se
683
+
684
+ ripple_effects = []
685
+ for j, node in enumerate(nodes):
686
+ if node == treatment or node == target_node:
687
+ continue
688
+ if dag_adj[t_idx_g, j]:
689
+ ripple_effects.append({
690
+ "ticker": node,
691
+ "direction": 1 if ate > 0 else -1,
692
+ "magnitude": round(float(adj_matrix[t_idx_g, j]) * abs(ate), 4),
693
+ })
694
+
695
+ result = {
696
+ "ate": ate,
697
+ "ci_lower": ci_lower,
698
+ "ci_upper": ci_upper,
699
+ "probability": min(1.0, abs(ate) / (abs(ate) + se + 1e-9)),
700
+ "strategy": "ols_fallback",
701
+ "adjustment_set": adjustment_sets[0] if adjustment_sets else [],
702
+ "ripple_effects": ripple_effects,
703
+ }
704
+
705
+ # ═══════════════════════════════════════════════════════════════════════
706
+ # LAYER 2 β€” Intervention: "What will happen to Y if we do X=value?"
707
+ # Propagates through structural equations from the backend payload.
708
+ # ════════════════════════════��══════════════════════════════════════════
709
+ elif mode == "intervene":
710
+ current_val = float(data_level[-1, node_to_idx[treatment]])
711
+ abs_value = _resolve_value(value, value_type, current_val)
712
+
713
+ # Try DoWhy for ATE estimation first
714
+ ate = 0.0
715
+ method_used = "scm_propagation"
716
+ try:
717
+ from dowhy import CausalModel
718
+
719
+ edges = []
720
+ for si, src in enumerate(nodes):
721
+ for di, dst in enumerate(nodes):
722
+ if dag_adj[si, di]:
723
+ edges.append(f"{src} -> {dst}")
724
+ graph_dot = "digraph{" + "; ".join(edges) + "}"
725
+
726
+ dowhy_model = CausalModel(
727
+ data=df,
728
+ treatment=treatment,
729
+ outcome=target_node,
730
+ graph=graph_dot,
731
+ )
732
+ identified_estimand = dowhy_model.identify_effect(
733
+ proceed_when_unidentifiable=True
734
+ )
735
+ estimate = dowhy_model.estimate_effect(
736
+ identified_estimand,
737
+ method_name="backdoor.linear_regression",
738
+ )
739
+ ate_unit = float(estimate.value)
740
+ delta = abs_value - current_val
741
+ ate = ate_unit * delta
742
+ method_used = "backdoor.linear_regression"
743
+ except Exception as dowhy_exc:
744
+ logger.warning("DoWhy intervention failed (%s), using SCM propagation", dowhy_exc)
745
+
746
+ # SCM propagation for ripple effects (pure numpy, no training imports)
747
+ ate_per_target, final_state = _propagate_intervention(
748
+ nodes=nodes,
749
+ dag_adj=dag_adj,
750
+ equations=equations_raw,
751
+ data_level=data_level,
752
+ topological_order=topo_order,
753
+ treatment=treatment,
754
+ abs_value=abs_value,
755
+ targets=[target_node] + [n for n in nodes if n != treatment],
756
+ horizon=horizon,
757
+ )
758
+
759
+ if method_used == "scm_propagation" and target_node in ate_per_target:
760
+ ate = float(ate_per_target[target_node])
761
+
762
+ se = abs(ate) * 0.12
763
+ ci_lower = ate - 1.96 * se
764
+ ci_upper = ate + 1.96 * se
765
+
766
+ ripple_effects = []
767
+ for node, delta_val in ate_per_target.items():
768
+ if node == treatment:
769
+ continue
770
+ ripple_effects.append({
771
+ "ticker": node,
772
+ "direction": 1 if float(delta_val) > 0 else -1,
773
+ "magnitude": round(abs(float(delta_val)), 4),
774
+ })
775
+
776
+ result = {
777
+ "ate": ate,
778
+ "ci_lower": ci_lower,
779
+ "ci_upper": ci_upper,
780
+ "probability": min(1.0, abs(ate) / (abs(ate) + abs(ci_upper - ci_lower) / 2 + 1e-9)),
781
+ "strategy": method_used,
782
+ "intervention_value": abs_value,
783
+ "value_type": value_type,
784
+ "horizon": horizon,
785
+ "ripple_effects": ripple_effects,
786
+ "adjustment_set": adjustment_sets[0] if adjustment_sets else [],
787
+ }
788
+
789
+ # ═══════════════════════════════════════════════════════════════════════
790
+ # LAYER 3 β€” Counterfactual: "What if X had been different in the past?"
791
+ # Uses SCM abduction via pure numpy structural equations.
792
+ # ═══════════════════════════════════════════════════════════════════════
793
+ elif mode in ("counterfactual", "counter"):
794
+ # Resolve observed timestep
795
+ t = observed_t if observed_t >= 0 else (T + observed_t)
796
+ t = max(0, min(T - 1, t))
797
+
798
+ # Resolve counterfactual value
799
+ current_val = float(data_level[t, node_to_idx[treatment]])
800
+ if cf_value is not None:
801
+ abs_cf_value = float(cf_value)
802
+ else:
803
+ abs_cf_value = _resolve_value(value, value_type, current_val)
804
+
805
+ # Try DoWhy GCM first
806
+ gcm_used = False
807
+ factual_outcome = 0.0
808
+ cf_outcome_val = 0.0
809
+ ite = 0.0
810
+
811
+ try:
812
+ import dowhy.gcm as gcm_module
813
+ import networkx as nx
814
+
815
+ causal_graph = nx.DiGraph()
816
+ for si, src in enumerate(nodes):
817
+ for di, dst in enumerate(nodes):
818
+ if dag_adj[si, di]:
819
+ causal_graph.add_edge(src, dst)
820
+ for node in nodes:
821
+ if node not in causal_graph.nodes:
822
+ causal_graph.add_node(node)
823
+
824
+ gcm_model = gcm_module.InvertibleStructuralCausalModel(causal_graph)
825
+ gcm_module.auto.assign_mechanisms(gcm_model, df)
826
+ gcm_module.fit(gcm_model, df)
827
+
828
+ observed_data = df.iloc[[t]]
829
+ cf_val_fixed = abs_cf_value
830
+ cf_samples = gcm_module.counterfactual_samples(
831
+ gcm_model,
832
+ {treatment: lambda x, v=cf_val_fixed: np.full(x.shape, v)},
833
+ observed_data=observed_data,
834
+ num_samples_to_draw=1,
835
+ )
836
+
837
+ factual_outcome = float(observed_data[target_node].iloc[0])
838
+ cf_outcome_val = float(cf_samples[target_node].iloc[0])
839
+ ite = cf_outcome_val - factual_outcome
840
+ gcm_used = True
841
+
842
+ except Exception as gcm_exc:
843
+ logger.warning("DoWhy GCM counterfactual failed (%s), using SCM abduction", gcm_exc)
844
+
845
+ if not gcm_used:
846
+ factual_outcome, cf_outcome_val, ite = _abduct_and_predict(
847
+ nodes=nodes,
848
+ dag_adj=dag_adj,
849
+ equations=equations_raw,
850
+ data_level=data_level,
851
+ topological_order=topo_order,
852
+ treatment=treatment,
853
+ cf_value=abs_cf_value,
854
+ target=target_node,
855
+ observed_t=t,
856
+ )
857
+
858
+ # Shapley: single-treatment β€” just use the ITE directly
859
+ shapley = {treatment: ite}
860
+
861
+ # SE from residual_std of the target equation (from backend payload)
862
+ target_eq_data = equations_raw.get(target_node, {})
863
+ se = float(target_eq_data.get("residual_std", abs(ite) * 0.15))
864
+ ci_lower = ite - 1.96 * se
865
+ ci_upper = ite + 1.96 * se
866
+
867
+ result = {
868
+ "ate": ite,
869
+ "ite": ite,
870
+ "factual_outcome": factual_outcome,
871
+ "counterfactual_outcome": cf_outcome_val,
872
+ "ci_lower": ci_lower,
873
+ "ci_upper": ci_upper,
874
+ "probability": min(1.0, abs(ite) / (abs(ite) + se + 1e-9)),
875
+ "strategy": "dowhy_gcm" if gcm_used else "scm_abduction",
876
+ "counterfactual_value": abs_cf_value,
877
+ "value_type": value_type,
878
+ "observed_t": t,
879
+ "shapley_contributions": shapley,
880
+ "ripple_effects": [],
881
+ }
882
+
883
+ else:
884
+ return {
885
+ "status": "error",
886
+ "detail": f"Unknown mode '{mode}'. Must be one of: assert, intervene, counterfactual.",
887
+ }
888
+
889
+ # ── Attach pywhyllm assumption report if requested ────────────────────
890
+ if return_assumption_report and pywhyllm_report:
891
+ result["pywhyllm_report"] = pywhyllm_report
892
+
893
+ return _safe_json({"status": "ok", "ticker": ticker.upper(), "mode": mode, **result})
894
+
895
+ except Exception as exc:
896
+ logger.exception("run_inference failed")
897
+ return {"status": "error", "detail": str(exc)}
898
+
899
+
900
+ # ─────────────────────────────────────────────────────────────────────────────
901
+ # Entry point
902
+ # ─────────────────────────────────────────────────────────────────────────────
903
+
904
+ if __name__ == "__main__":
905
+ port = int(os.environ.get("GRADIO_SERVER_PORT", os.environ.get("PORT", "7860")))
906
+ host = os.environ.get("GRADIO_SERVER_NAME", "0.0.0.0")
907
+
908
+ logger.info(f"Starting Iroha Causal Terminal on {host}:{port}")
909
+ logger.info(f" β†’ Frontend : http://localhost:{port}/")
910
+ logger.info(f" β†’ API docs : http://localhost:{port}/docs")
911
+
912
+ server.launch(
913
+ server_name=host,
914
+ server_port=port,
915
+ allowed_paths=[str(FRONTEND_DIR)],
916
+ show_error=True,
917
+ quiet=False,
918
+ )