Paul Babu Kadali commited on
Commit
2365e13
Β·
1 Parent(s): d388998

Fix: Wrap Gradio demo creation in function to prevent module-level initialization errors

Browse files
Files changed (1) hide show
  1. server.py +886 -0
server.py ADDED
@@ -0,0 +1,886 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ import gradio as gr
38
+ from typing import Any, Dict, List, Optional
39
+ from dotenv import load_dotenv
40
+
41
+ load_dotenv()
42
+
43
+ BASE_DIR = Path(__file__).parent.resolve()
44
+ if str(BASE_DIR) not in sys.path:
45
+ sys.path.insert(0, str(BASE_DIR))
46
+
47
+ # Also add the backend directory to sys.path so we can import 'app', 'causal', etc.
48
+ BACKEND_DIR = (BASE_DIR.parent / "noisy_boy_backend").resolve()
49
+ if BACKEND_DIR.exists() and str(BACKEND_DIR) not in sys.path:
50
+ sys.path.insert(0, str(BACKEND_DIR))
51
+
52
+ # ──────────────────────────────────────────────────────────────────[...]
53
+ # Logging
54
+ # ──────────────────────────────────────────────────────────────────[...]
55
+ logging.basicConfig(
56
+ level=logging.INFO,
57
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
58
+ handlers=[logging.StreamHandler()],
59
+ )
60
+ logger = logging.getLogger("dashboard-server")
61
+
62
+ # ──────────────────────────────────────────────────────────────────[...]
63
+ # Backend URL
64
+ # ──��───────────────────────────────────────────────────────────────[...]
65
+
66
+ _BACKEND_BASE_URL: str = os.environ.get("BACKEND_API_URL", "http://localhost:7860")
67
+
68
+
69
+ # ──────────────────────────────────────────────────────────────────[...]
70
+ # Public API
71
+ # ──────────────────────────────────────────────────────────────────[...]
72
+
73
+ def run_pipeline(
74
+ ticker: str = "RELIANCE",
75
+ threshold: float = 0.5,
76
+ treatment: Optional[str] = None,
77
+ outcome: Optional[str] = None,
78
+ include_pywhyllm: bool = False,
79
+ ) -> Dict[str, Any]:
80
+ """
81
+ Fetch the validated causal matrix for *ticker* from the backend API.
82
+
83
+ Parameters
84
+ ----------
85
+ ticker : NSE symbol (e.g. RELIANCE, HDFCBANK)
86
+ threshold : adjacency threshold for DAG construction
87
+ treatment : optional treatment node for pywhyllm assumptions
88
+ outcome : optional outcome node for pywhyllm assumptions
89
+ include_pywhyllm: request pywhyllm assumption report from backend
90
+
91
+ Returns
92
+ -------
93
+ dict with keys:
94
+ nodes, adj_matrix, dag_adj, equations, data_level,
95
+ topological_order, nodes_graph, links_graph
96
+ Raises RuntimeError if the backend cannot be reached or returns an error.
97
+ """
98
+ params: dict = {"threshold": threshold}
99
+ if treatment:
100
+ params["treatment"] = treatment
101
+ if outcome:
102
+ params["outcome"] = outcome
103
+ if include_pywhyllm:
104
+ params["include_pywhyllm"] = "true"
105
+
106
+ qs = urllib.parse.urlencode(params)
107
+ url = f"{_BACKEND_BASE_URL}/v2/api/singular-causal/causal-matrix/{ticker.upper()}?{qs}"
108
+ logger.info("run_pipeline: fetching %s", url)
109
+
110
+ try:
111
+ with urllib.request.urlopen(url, timeout=60) as resp:
112
+ raw = resp.read()
113
+ except urllib.error.URLError as exc:
114
+ raise RuntimeError(
115
+ f"Could not reach backend at {_BACKEND_BASE_URL}. "
116
+ f"Ensure noisy_boy_backend is running. Original error: {exc}"
117
+ ) from exc
118
+
119
+ payload = json.loads(raw)
120
+
121
+ status = payload.get("status")
122
+ if status == "not_found":
123
+ raise RuntimeError(
124
+ payload.get(
125
+ "detail",
126
+ f"No cached pipeline data for {ticker} on backend. "
127
+ "Run the singular-causal pipeline on the backend first.",
128
+ )
129
+ )
130
+ if status not in ("success", None, "ok"):
131
+ raise RuntimeError(
132
+ f"Backend returned unexpected status '{status}' for {ticker}. "
133
+ f"Payload: {payload}"
134
+ )
135
+
136
+ # Build frontend-friendly graph representation
137
+ nodes: List[str] = payload.get("nodes", [])
138
+ adj_matrix = payload.get("adj_matrix", [])
139
+ dag_adj = payload.get("dag_adj", [])
140
+
141
+ nodes_graph = [{"id": n, "label": n} for n in nodes]
142
+ links_graph = []
143
+ for i, src in enumerate(nodes):
144
+ for j, dst in enumerate(nodes):
145
+ if i != j:
146
+ try:
147
+ score = float(adj_matrix[i][j])
148
+ except (IndexError, TypeError, ValueError):
149
+ score = 0.0
150
+ if score >= threshold:
151
+ links_graph.append({"source": src, "target": dst, "score": round(score, 4)})
152
+
153
+ return {
154
+ **payload,
155
+ "nodes_graph": nodes_graph,
156
+ "links_graph": links_graph,
157
+ }
158
+
159
+ # ─────────────────��────────────────────────────────────────────────[...]
160
+ # Helpers
161
+ # ──────────────────────────────────────────────────────────────────[...]
162
+
163
+ # URL of the noisy_boy_backend β€” used to fetch the validated causal matrix.
164
+ # By default, point to ourselves since we now successfully mount the backend routers.
165
+ # Override via BACKEND_API_URL env var if running a separate backend on 8000.
166
+ _BACKEND_BASE_URL = os.environ.get("BACKEND_API_URL", "http://localhost:7860")
167
+
168
+
169
+ def _fetch_causal_matrix(
170
+ ticker: str,
171
+ treatment: Optional[str] = None,
172
+ outcome: Optional[str] = None,
173
+ include_pywhyllm: bool = False,
174
+ threshold: float = 0.5,
175
+ ) -> Optional[dict]:
176
+ """
177
+ Fetch the fully validated causal matrix from the backend API.
178
+
179
+ Calls GET {BACKEND_BASE_URL}/v2/api/singular-causal/causal-matrix/{ticker}
180
+ and returns the parsed JSON payload, or None on failure.
181
+
182
+ The payload contains:
183
+ nodes β€” ordered list of node names
184
+ adj_matrix β€” raw float adjacency matrix
185
+ dag_adj β€” thresholded 0/1 DAG
186
+ equations β€” per-node structural equations (coefficients, intercepts, residual_std)
187
+ data_level β€” (T, N) time-series observations used to fit the SCM
188
+ topological_order β€” nodes in topological traversal order
189
+ pywhyllm_report — (optional) assumption analysis for treatment→outcome
190
+ """
191
+ import urllib.request
192
+ import urllib.error
193
+ import urllib.parse
194
+
195
+ params: dict = {"threshold": threshold}
196
+ if treatment:
197
+ params["treatment"] = treatment
198
+ if outcome:
199
+ params["outcome"] = outcome
200
+ if include_pywhyllm:
201
+ params["include_pywhyllm"] = "true"
202
+
203
+ query_string = urllib.parse.urlencode(params)
204
+ url = f"{_BACKEND_BASE_URL}/v2/api/singular-causal/causal-matrix/{ticker.upper()}?{query_string}"
205
+
206
+ try:
207
+ with urllib.request.urlopen(url, timeout=30) as resp:
208
+ raw = resp.read()
209
+ data = json.loads(raw)
210
+ if data.get("status") not in ("success", None):
211
+ logger.warning(
212
+ "_fetch_causal_matrix: backend returned status=%s for URL %s. Payload: %s",
213
+ data.get("status"), url, data,
214
+ )
215
+ return None
216
+ return data
217
+ except Exception as exc:
218
+ logger.warning("_fetch_causal_matrix failed for %s: %s", ticker, exc)
219
+ return None
220
+
221
+
222
+ def _safe_json(obj: Any) -> Any:
223
+ """Recursively make numpy types JSON-serialisable."""
224
+ try:
225
+ import numpy as np
226
+ if isinstance(obj, np.ndarray):
227
+ return obj.tolist()
228
+ if isinstance(obj, np.integer):
229
+ return int(obj)
230
+ if isinstance(obj, np.floating):
231
+ return float(obj)
232
+ except ImportError:
233
+ pass
234
+ if isinstance(obj, dict):
235
+ return {k: _safe_json(v) for k, v in obj.items()}
236
+ if isinstance(obj, (list, tuple)):
237
+ return [_safe_json(v) for v in obj]
238
+ return obj
239
+
240
+
241
+ def _resolve_value(value: float, value_type: str, current: float) -> float:
242
+ """Convert a user-supplied value + value_type to the absolute node value."""
243
+ vt = value_type.strip().lower()
244
+ if vt == "absolute":
245
+ return value
246
+ if vt == "multiplier":
247
+ return current * value
248
+ if vt == "percent_change":
249
+ return current * (1.0 + value / 100.0)
250
+ # default: treat as absolute
251
+ return value
252
+
253
+
254
+ # ──────────────────────────────────────────────────────────────────[...]
255
+ # Pure-numpy inference helpers (no local causal training imports)
256
+ # These functions work entirely from the payload returned by the backend API.
257
+ # ──────────────────────────────────────────────────────────────────[...]
258
+
259
+ def _build_dag_from_payload(payload: dict):
260
+ """
261
+ Return a numpy bool DAG adjacency matrix and list of node names
262
+ from the backend causal-matrix payload.
263
+ """
264
+ import numpy as np
265
+ nodes = payload["nodes"]
266
+ dag_adj = np.array(payload["dag_adj"], dtype=bool)
267
+ adj_matrix = np.array(payload["adj_matrix"], dtype=float)
268
+ return nodes, dag_adj, adj_matrix
269
+
270
+
271
+ def _propagate_intervention(
272
+ nodes: list,
273
+ dag_adj,
274
+ equations: dict,
275
+ data_level,
276
+ topological_order: list,
277
+ treatment: str,
278
+ abs_value: float,
279
+ targets: list,
280
+ horizon: int = 5,
281
+ ):
282
+ """
283
+ Propagate a hard intervention (do(treatment=abs_value)) through the
284
+ structural equations for `horizon` steps, returning ATE per target node.
285
+ Uses only numpy β€” no local causal model imports.
286
+ """
287
+ import numpy as np
288
+
289
+ node_to_idx = {n: i for i, n in enumerate(nodes)}
290
+ n = len(nodes)
291
+ T = data_level.shape[0]
292
+
293
+ # Start from the last observed time step
294
+ state = data_level[-1].copy().astype(float)
295
+
296
+ # Fix the treatment node
297
+ t_idx = node_to_idx[treatment]
298
+ state[t_idx] = abs_value
299
+
300
+ ate_per_target: Dict[str, float] = {}
301
+ baseline = data_level[-1].copy().astype(float)
302
+
303
+ for _ in range(horizon):
304
+ new_state = state.copy()
305
+ for node_name in topological_order:
306
+ if node_name == treatment:
307
+ continue
308
+ eq = equations.get(node_name)
309
+ if eq is None:
310
+ continue
311
+ parents = eq.get("parents", [])
312
+ coefficients = eq.get("coefficients", {})
313
+ intercept = float(eq.get("intercept", 0.0))
314
+ if not parents:
315
+ continue
316
+ val = intercept
317
+ for p in parents:
318
+ p_idx = node_to_idx.get(p)
319
+ if p_idx is not None:
320
+ val += float(coefficients.get(p, 0.0)) * float(state[p_idx])
321
+ n_idx = node_to_idx[node_name]
322
+ new_state[n_idx] = val
323
+ state = new_state
324
+
325
+ for target in targets:
326
+ t_i = node_to_idx.get(target)
327
+ if t_i is not None:
328
+ ate_per_target[target] = float(state[t_i] - baseline[t_i])
329
+
330
+ return ate_per_target, state
331
+
332
+
333
+ def _abduct_and_predict(
334
+ nodes: list,
335
+ dag_adj,
336
+ equations: dict,
337
+ data_level,
338
+ topological_order: list,
339
+ treatment: str,
340
+ cf_value: float,
341
+ target: str,
342
+ observed_t: int,
343
+ ):
344
+ """
345
+ Simple SCM abduction for counterfactual:
346
+ 1. Abduct residuals from the observed time step.
347
+ 2. Re-run structural equations with treatment fixed to cf_value.
348
+ 3. Return factual_outcome, cf_outcome, ITE.
349
+ """
350
+ import numpy as np
351
+
352
+ node_to_idx = {n: i for i, n in enumerate(nodes)}
353
+ obs = data_level[observed_t].copy().astype(float)
354
+
355
+ # Abduct residuals
356
+ residuals: Dict[str, float] = {}
357
+ for node_name in topological_order:
358
+ eq = equations.get(node_name)
359
+ if eq is None or not eq.get("parents"):
360
+ residuals[node_name] = 0.0
361
+ continue
362
+ parents = eq.get("parents", [])
363
+ coefficients = eq.get("coefficients", {})
364
+ intercept = float(eq.get("intercept", 0.0))
365
+ predicted = intercept
366
+ for p in parents:
367
+ p_idx = node_to_idx.get(p)
368
+ if p_idx is not None:
369
+ predicted += float(coefficients.get(p, 0.0)) * float(obs[node_to_idx[p]])
370
+ residuals[node_name] = float(obs[node_to_idx[node_name]]) - predicted
371
+
372
+ # Counterfactual: fix treatment, replay equations with abducted noise
373
+ cf_state = obs.copy()
374
+ cf_state[node_to_idx[treatment]] = cf_value
375
+
376
+ for node_name in topological_order:
377
+ if node_name == treatment:
378
+ continue
379
+ eq = equations.get(node_name)
380
+ if eq is None or not eq.get("parents"):
381
+ continue
382
+ parents = eq.get("parents", [])
383
+ coefficients = eq.get("coefficients", {})
384
+ intercept = float(eq.get("intercept", 0.0))
385
+ predicted = intercept
386
+ for p in parents:
387
+ p_idx = node_to_idx.get(p)
388
+ if p_idx is not None:
389
+ predicted += float(coefficients.get(p, 0.0)) * float(cf_state[p_idx])
390
+ n_idx = node_to_idx[node_name]
391
+ cf_state[n_idx] = predicted + residuals.get(node_name, 0.0)
392
+
393
+ factual_outcome = float(obs[node_to_idx[target]])
394
+ cf_outcome = float(cf_state[node_to_idx[target]])
395
+ ite = cf_outcome - factual_outcome
396
+ return factual_outcome, cf_outcome, ite
397
+
398
+
399
+ # ── API: Causal inference (assert / intervene / counterfactual) ───────────
400
+ #
401
+ # Architecture:
402
+ # 1. Fetch the VALIDATED causal matrix from noisy_boy_backend via HTTP.
403
+ # The backend has already run CUTS+ learning + pywhyllm + DoWhy validation.
404
+ # 2. Use the payload data (equations, adj, data_level) for inference
405
+ # using pure numpy/pandas β€” no local causal training imports required.
406
+ # 3. Optionally consult pywhyllm guidance from the backend payload.
407
+
408
+
409
+ def run_inference(
410
+ ticker: str = "RELIANCE",
411
+ mode: str = "assert",
412
+ treatment: str = "Revenue",
413
+ outcome: Optional[str] = "NetIncome",
414
+ target: Optional[str] = None,
415
+ value: float = 1.1,
416
+ cf_value: Optional[float] = None,
417
+ value_type: str = "multiplier",
418
+ horizon: int = 5,
419
+ observed_t: int = -1,
420
+ threshold: float = 0.5,
421
+ use_pywhyllm: bool = False,
422
+ return_assumption_report: bool = False,
423
+ ) -> Dict[str, Any]:
424
+ """
425
+ Three-layer causal inference driven by the backend's validated causal matrix.
426
+
427
+ Parameters
428
+ ----------
429
+ ticker : NSE ticker (backend must have a cached pipeline run for it)
430
+ mode : "assert" | "intervene" | "counterfactual"
431
+ treatment : source node name
432
+ outcome : outcome node (assert / Layer-1 association)
433
+ target : target node (counterfactual / Layer-3); if None, falls back to outcome
434
+ value : intervention magnitude (Layer 2)
435
+ cf_value : explicit counterfactual value (Layer 3); if None, 'value' + 'value_type' used
436
+ value_type : "absolute" | "multiplier" | "percent_change"
437
+ horizon : propagation horizon for intervention (Layer 2, steps)
438
+ observed_t : time index for counterfactual abduction (Layer 3; -1 = last obs)
439
+ threshold : adjacency threshold used when loading the graph
440
+ use_pywhyllm : consult pywhyllm for structural assumptions before running DoWhy
441
+ return_assumption_report : include the pywhyllm report dict in the response
442
+
443
+ Returns
444
+ -------
445
+ JSON with ate, ci_lower, ci_upper, probability, ripple_effects,
446
+ and (for counterfactual) factual_outcome, counterfactual_outcome, ite,
447
+ shapley_contributions.
448
+ """
449
+ import numpy as np
450
+ import pandas as pd
451
+
452
+ try:
453
+ # ── 0. Determine target node ──────────────────────────────────────────
454
+ target_node = target if target else outcome
455
+ if not target_node:
456
+ return {"status": "error", "detail": "Either 'outcome' or 'target' must be provided."}
457
+
458
+ # ── 1. Fetch validated causal matrix from backend ─────────────────────
459
+ # This includes the adjacency matrix, fitted structural equations,
460
+ # level-domain data, and optionally a pywhyllm assumption report.
461
+ payload = _fetch_causal_matrix(
462
+ ticker=ticker,
463
+ treatment=treatment if use_pywhyllm else None,
464
+ outcome=target_node if use_pywhyllm else None,
465
+ include_pywhyllm=use_pywhyllm,
466
+ threshold=threshold,
467
+ )
468
+
469
+ if payload is None:
470
+ return {
471
+ "status": "error",
472
+ "detail": (
473
+ f"Could not fetch causal matrix for {ticker} from backend. "
474
+ "Ensure noisy_boy_backend is running and the pipeline has been run for this ticker."
475
+ ),
476
+ }
477
+
478
+ if payload.get("status") == "not_found":
479
+ return {
480
+ "status": "error",
481
+ "detail": payload.get("detail", f"No cached pipeline data for {ticker}."),
482
+ }
483
+
484
+ # ── 2. Unpack payload (no local causal training imports) ──────────────
485
+ nodes, dag_adj, adj_matrix = _build_dag_from_payload(payload)
486
+ node_to_idx = {n: i for i, n in enumerate(nodes)}
487
+ data_level = np.array(payload["data_level"], dtype=float)
488
+ equations_raw = payload.get("equations", {})
489
+ topo_order = payload.get("topological_order", nodes)
490
+
491
+ T = data_level.shape[0]
492
+ df = pd.DataFrame(data_level, columns=nodes)
493
+
494
+ if treatment not in node_to_idx:
495
+ return {"status": "error", "detail": f"Unknown treatment node: {treatment}"}
496
+ if target_node not in node_to_idx:
497
+ return {"status": "error", "detail": f"Unknown outcome/target node: {target_node}"}
498
+ if df.shape[0] < 5:
499
+ return {
500
+ "status": "error",
501
+ "detail": f"Insufficient observations ({df.shape[0]}) to run inference.",
502
+ }
503
+
504
+ # ── 3. pywhyllm structural guidance (from backend payload) ────────────
505
+ pywhyllm_report: Optional[dict] = payload.get("pywhyllm_report")
506
+ adjustment_sets: List[List[str]] = []
507
+
508
+ if use_pywhyllm and pywhyllm_report and pywhyllm_report.get("available"):
509
+ raw_backdoor = pywhyllm_report.get("suggested_backdoor_sets") or []
510
+ valid_nodes = set(nodes) - {treatment, target_node}
511
+ for suggested_set in raw_backdoor:
512
+ clean = [n for n in suggested_set if n in valid_nodes]
513
+ if clean and clean not in adjustment_sets:
514
+ adjustment_sets.append(clean)
515
+
516
+ confounders = [
517
+ n for n in (pywhyllm_report.get("suggested_confounders") or [])
518
+ if n in valid_nodes
519
+ ]
520
+ if confounders and confounders not in adjustment_sets:
521
+ adjustment_sets.append(confounders)
522
+
523
+ result: Dict[str, Any] = {}
524
+
525
+ # ═══════════════════════════════════════════════════════════════[...]
526
+ # LAYER 1 β€” Association: "What does Y look like given X?"
527
+ # Uses DoWhy with the backend-provided DAG, falling back to OLS.
528
+ # ═══════════════════════════════════════════════════════════════[...]
529
+ if mode == "assert":
530
+ try:
531
+ from dowhy import CausalModel
532
+
533
+ # Build DOT graph string from dag_adj
534
+ edges = []
535
+ for si, src in enumerate(nodes):
536
+ for di, dst in enumerate(nodes):
537
+ if dag_adj[si, di]:
538
+ edges.append(f"{src} -> {dst}")
539
+ graph_dot = "digraph{" + "; ".join(edges) + "}"
540
+
541
+ dowhy_model = CausalModel(
542
+ data=df,
543
+ treatment=treatment,
544
+ outcome=target_node,
545
+ graph=graph_dot,
546
+ )
547
+ identified_estimand = dowhy_model.identify_effect(
548
+ proceed_when_unidentifiable=True
549
+ )
550
+ estimate = dowhy_model.estimate_effect(
551
+ identified_estimand,
552
+ method_name="backdoor.linear_regression",
553
+ )
554
+ ate = float(estimate.value)
555
+
556
+ # Confidence interval from OLS residuals
557
+ se: float = 0.0
558
+ try:
559
+ import numpy.linalg as nla
560
+ X = df[[c for c in df.columns if c != target_node]].values
561
+ y = df[target_node].values
562
+ XtX_inv = nla.pinv(X.T @ X)
563
+ resid = y - X @ nla.lstsq(X, y, rcond=None)[0]
564
+ sigma2 = float(np.sum(resid ** 2) / max(1, len(y) - X.shape[1]))
565
+ t_idx_local = list(df.columns).index(treatment)
566
+ se = float(np.sqrt(max(0.0, sigma2 * XtX_inv[t_idx_local, t_idx_local])))
567
+ except Exception:
568
+ se = abs(ate) * 0.15 # graceful fallback
569
+
570
+ ci_lower = ate - 1.96 * se
571
+ ci_upper = ate + 1.96 * se
572
+ prob = min(1.0, abs(ate) / (abs(ate) + se + 1e-9))
573
+
574
+ # Ripple effects: direct downstream neighbours of treatment
575
+ ripple_effects = []
576
+ t_idx_g = node_to_idx[treatment]
577
+ for j, node in enumerate(nodes):
578
+ if node == treatment or node == target_node:
579
+ continue
580
+ if dag_adj[t_idx_g, j]:
581
+ edge_score = float(adj_matrix[t_idx_g, j])
582
+ ripple_effects.append({
583
+ "ticker": node,
584
+ "direction": 1 if ate > 0 else -1,
585
+ "magnitude": round(edge_score * abs(ate), 4),
586
+ })
587
+
588
+ result = {
589
+ "ate": ate,
590
+ "ci_lower": ci_lower,
591
+ "ci_upper": ci_upper,
592
+ "probability": prob,
593
+ "strategy": "backdoor.linear_regression",
594
+ "adjustment_set": adjustment_sets[0] if adjustment_sets else [],
595
+ "ripple_effects": ripple_effects,
596
+ }
597
+
598
+ except Exception as dowhy_exc:
599
+ # DoWhy not installed or identification failed β€” fall back to OLS
600
+ logger.warning("DoWhy association failed (%s), falling back to OLS", dowhy_exc)
601
+ t_idx_g = node_to_idx[treatment]
602
+ out_idx = node_to_idx[target_node]
603
+
604
+ # Simple OLS: regress target on treatment
605
+ X = df[[treatment]].values
606
+ y = df[target_node].values
607
+ import numpy.linalg as nla
608
+ coef = nla.lstsq(np.c_[np.ones(len(X)), X], y, rcond=None)[0]
609
+ ate = float(coef[1])
610
+ se = abs(ate) * 0.15
611
+ ci_lower = ate - 1.96 * se
612
+ ci_upper = ate + 1.96 * se
613
+
614
+ ripple_effects = []
615
+ for j, node in enumerate(nodes):
616
+ if node == treatment or node == target_node:
617
+ continue
618
+ if dag_adj[t_idx_g, j]:
619
+ ripple_effects.append({
620
+ "ticker": node,
621
+ "direction": 1 if ate > 0 else -1,
622
+ "magnitude": round(float(adj_matrix[t_idx_g, j]) * abs(ate), 4),
623
+ })
624
+
625
+ result = {
626
+ "ate": ate,
627
+ "ci_lower": ci_lower,
628
+ "ci_upper": ci_upper,
629
+ "probability": min(1.0, abs(ate) / (abs(ate) + se + 1e-9)),
630
+ "strategy": "ols_fallback",
631
+ "adjustment_set": adjustment_sets[0] if adjustment_sets else [],
632
+ "ripple_effects": ripple_effects,
633
+ }
634
+
635
+ # ═══════════════════════════════════════════════════════════════[...]
636
+ # LAYER 2 β€” Intervention: "What will happen to Y if we do X=value?"
637
+ # Propagates through structural equations from the backend payload.
638
+ # ═══════════════════════════════════════════════════════════════[...]
639
+ elif mode == "intervene":
640
+ current_val = float(data_level[-1, node_to_idx[treatment]])
641
+ abs_value = _resolve_value(value, value_type, current_val)
642
+
643
+ # Try DoWhy for ATE estimation first
644
+ ate = 0.0
645
+ method_used = "scm_propagation"
646
+ try:
647
+ from dowhy import CausalModel
648
+
649
+ edges = []
650
+ for si, src in enumerate(nodes):
651
+ for di, dst in enumerate(nodes):
652
+ if dag_adj[si, di]:
653
+ edges.append(f"{src} -> {dst}")
654
+ graph_dot = "digraph{" + "; ".join(edges) + "}"
655
+
656
+ dowhy_model = CausalModel(
657
+ data=df,
658
+ treatment=treatment,
659
+ outcome=target_node,
660
+ graph=graph_dot,
661
+ )
662
+ identified_estimand = dowhy_model.identify_effect(
663
+ proceed_when_unidentifiable=True
664
+ )
665
+ estimate = dowhy_model.estimate_effect(
666
+ identified_estimand,
667
+ method_name="backdoor.linear_regression",
668
+ )
669
+ ate_unit = float(estimate.value)
670
+ delta = abs_value - current_val
671
+ ate = ate_unit * delta
672
+ method_used = "backdoor.linear_regression"
673
+ except Exception as dowhy_exc:
674
+ logger.warning("DoWhy intervention failed (%s), using SCM propagation", dowhy_exc)
675
+
676
+ # SCM propagation for ripple effects (pure numpy, no training imports)
677
+ ate_per_target, final_state = _propagate_intervention(
678
+ nodes=nodes,
679
+ dag_adj=dag_adj,
680
+ equations=equations_raw,
681
+ data_level=data_level,
682
+ topological_order=topo_order,
683
+ treatment=treatment,
684
+ abs_value=abs_value,
685
+ targets=[target_node] + [n for n in nodes if n != treatment],
686
+ horizon=horizon,
687
+ )
688
+
689
+ if method_used == "scm_propagation" and target_node in ate_per_target:
690
+ ate = float(ate_per_target[target_node])
691
+
692
+ se = abs(ate) * 0.12
693
+ ci_lower = ate - 1.96 * se
694
+ ci_upper = ate + 1.96 * se
695
+
696
+ ripple_effects = []
697
+ for node, delta_val in ate_per_target.items():
698
+ if node == treatment:
699
+ continue
700
+ ripple_effects.append({
701
+ "ticker": node,
702
+ "direction": 1 if float(delta_val) > 0 else -1,
703
+ "magnitude": round(abs(float(delta_val)), 4),
704
+ })
705
+
706
+ result = {
707
+ "ate": ate,
708
+ "ci_lower": ci_lower,
709
+ "ci_upper": ci_upper,
710
+ "probability": min(1.0, abs(ate) / (abs(ate) + abs(ci_upper - ci_lower) / 2 + 1e-9)),
711
+ "strategy": method_used,
712
+ "intervention_value": abs_value,
713
+ "value_type": value_type,
714
+ "horizon": horizon,
715
+ "ripple_effects": ripple_effects,
716
+ "adjustment_set": adjustment_sets[0] if adjustment_sets else [],
717
+ }
718
+
719
+ # ═══════════════════════════════════════════════════════════════[...]
720
+ # LAYER 3 β€” Counterfactual: "What if X had been different in the past?"
721
+ # Uses SCM abduction via pure numpy structural equations.
722
+ # ═══════════════════════════════════════════════════════════════[...]
723
+ elif mode in ("counterfactual", "counter"):
724
+ # Resolve observed timestep
725
+ t = observed_t if observed_t >= 0 else (T + observed_t)
726
+ t = max(0, min(T - 1, t))
727
+
728
+ # Resolve counterfactual value
729
+ current_val = float(data_level[t, node_to_idx[treatment]])
730
+ if cf_value is not None:
731
+ abs_cf_value = float(cf_value)
732
+ else:
733
+ abs_cf_value = _resolve_value(value, value_type, current_val)
734
+
735
+ # Try DoWhy GCM first
736
+ gcm_used = False
737
+ factual_outcome = 0.0
738
+ cf_outcome_val = 0.0
739
+ ite = 0.0
740
+
741
+ try:
742
+ import dowhy.gcm as gcm_module
743
+ import networkx as nx
744
+
745
+ causal_graph = nx.DiGraph()
746
+ for si, src in enumerate(nodes):
747
+ for di, dst in enumerate(nodes):
748
+ if dag_adj[si, di]:
749
+ causal_graph.add_edge(src, dst)
750
+ for node in nodes:
751
+ if node not in causal_graph.nodes:
752
+ causal_graph.add_node(node)
753
+
754
+ gcm_model = gcm_module.InvertibleStructuralCausalModel(causal_graph)
755
+ gcm_module.auto.assign_mechanisms(gcm_model, df)
756
+ gcm_module.fit(gcm_model, df)
757
+
758
+ observed_data = df.iloc[[t]]
759
+ cf_val_fixed = abs_cf_value
760
+ cf_samples = gcm_module.counterfactual_samples(
761
+ gcm_model,
762
+ {treatment: lambda x, v=cf_val_fixed: np.full(x.shape, v)},
763
+ observed_data=observed_data,
764
+ num_samples_to_draw=1,
765
+ )
766
+
767
+ factual_outcome = float(observed_data[target_node].iloc[0])
768
+ cf_outcome_val = float(cf_samples[target_node].iloc[0])
769
+ ite = cf_outcome_val - factual_outcome
770
+ gcm_used = True
771
+
772
+ except Exception as gcm_exc:
773
+ logger.warning("DoWhy GCM counterfactual failed (%s), using SCM abduction", gcm_exc)
774
+
775
+ if not gcm_used:
776
+ factual_outcome, cf_outcome_val, ite = _abduct_and_predict(
777
+ nodes=nodes,
778
+ dag_adj=dag_adj,
779
+ equations=equations_raw,
780
+ data_level=data_level,
781
+ topological_order=topo_order,
782
+ treatment=treatment,
783
+ cf_value=abs_cf_value,
784
+ target=target_node,
785
+ observed_t=t,
786
+ )
787
+
788
+ # Shapley: single-treatment β€” just use the ITE directly
789
+ shapley = {treatment: ite}
790
+
791
+ # SE from residual_std of the target equation (from backend payload)
792
+ target_eq_data = equations_raw.get(target_node, {})
793
+ se = float(target_eq_data.get("residual_std", abs(ite) * 0.15))
794
+ ci_lower = ite - 1.96 * se
795
+ ci_upper = ite + 1.96 * se
796
+
797
+ result = {
798
+ "ate": ite,
799
+ "ite": ite,
800
+ "factual_outcome": factual_outcome,
801
+ "counterfactual_outcome": cf_outcome_val,
802
+ "ci_lower": ci_lower,
803
+ "ci_upper": ci_upper,
804
+ "probability": min(1.0, abs(ite) / (abs(ite) + se + 1e-9)),
805
+ "strategy": "dowhy_gcm" if gcm_used else "scm_abduction",
806
+ "counterfactual_value": abs_cf_value,
807
+ "value_type": value_type,
808
+ "observed_t": t,
809
+ "shapley_contributions": shapley,
810
+ "ripple_effects": [],
811
+ }
812
+
813
+ else:
814
+ return {
815
+ "status": "error",
816
+ "detail": f"Unknown mode '{mode}'. Must be one of: assert, intervene, counterfactual.",
817
+ }
818
+
819
+ # ── Attach pywhyllm assumption report if requested ────────────────────
820
+ if return_assumption_report and pywhyllm_report:
821
+ result["pywhyllm_report"] = pywhyllm_report
822
+
823
+ return _safe_json({"status": "ok", "ticker": ticker.upper(), "mode": mode, **result})
824
+
825
+ except Exception as exc:
826
+ logger.exception("run_inference failed")
827
+ return {"status": "error", "detail": str(exc)}
828
+
829
+
830
+ # ──────────────────────────────────────────────────────────────────[...]
831
+ # Gradio UI & Entry point
832
+ # ──────────────────────────────────────────────────────────────────[...]
833
+
834
+ def create_demo():
835
+ """Create and return the Gradio Blocks interface."""
836
+ with gr.Blocks(title="Iroha Causal Terminal") as demo:
837
+ gr.Markdown("# Iroha Causal Terminal")
838
+ gr.Markdown("Iroha Financial Intelligence β€” real-time causal probability matrix, HHKD decomposition, DoFlow inference and sector hierarchy over NIFTY50.")
839
+
840
+ with gr.Row():
841
+ ticker = gr.Textbox(label="Ticker", value="RELIANCE")
842
+ mode = gr.Dropdown(choices=["assert", "intervene", "counterfactual"], label="Mode", value="assert")
843
+ treatment = gr.Textbox(label="Treatment", value="Revenue")
844
+ outcome = gr.Textbox(label="Outcome", value="NetIncome")
845
+ target = gr.Textbox(label="Target", value="")
846
+
847
+ with gr.Row():
848
+ value = gr.Number(label="Value", value=1.1)
849
+ cf_value = gr.Number(label="CF Value")
850
+ value_type = gr.Dropdown(choices=["absolute", "multiplier", "percent_change"], label="Value Type", value="multiplier")
851
+ horizon = gr.Number(label="Horizon", value=5, precision=0)
852
+ observed_t = gr.Number(label="Observed T", value=-1, precision=0)
853
+ threshold = gr.Number(label="Threshold", value=0.5)
854
+
855
+ with gr.Row():
856
+ use_pywhyllm = gr.Checkbox(label="Use PyWhyLLM", value=False)
857
+ return_assumption_report = gr.Checkbox(label="Return Assumption Report", value=False)
858
+
859
+ btn = gr.Button("Run Inference")
860
+ out = gr.JSON(label="Result")
861
+
862
+ btn.click(
863
+ fn=run_inference,
864
+ inputs=[
865
+ ticker, mode, treatment, outcome, target, value, cf_value, value_type,
866
+ horizon, observed_t, threshold, use_pywhyllm, return_assumption_report
867
+ ],
868
+ outputs=out,
869
+ api_name="run_inference"
870
+ )
871
+
872
+ return demo
873
+
874
+
875
+ if __name__ == "__main__":
876
+ port = int(os.environ.get("GRADIO_SERVER_PORT", os.environ.get("PORT", "7860")))
877
+ host = os.environ.get("GRADIO_SERVER_NAME", "0.0.0.0")
878
+
879
+ logger.info(f"Starting Iroha Causal Terminal on {host}:{port}")
880
+
881
+ demo = create_demo()
882
+ demo.launch(
883
+ server_name=host,
884
+ server_port=port,
885
+ show_error=True,
886
+ )