Rajveer Singh Pall commited on
Commit
8f41246
Β·
0 Parent(s):

Deploy IndiaFinBench research site

Browse files
.dockerignore ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ── SECURITY β€” never bake API keys into the image ──────────────────────────────
2
+ .env
3
+ .env.*
4
+
5
+ # ── Not needed at serve time (RAG index is pre-built) ──────────────────────────
6
+ data/parsed/
7
+ data/raw/
8
+ data/eval/
9
+
10
+ # ── Research artifacts β€” large, not needed in the app ──────────────────────────
11
+ annotation/
12
+ paper/
13
+ evaluation/
14
+ scripts/
15
+ notebooks/
16
+ _archive/
17
+ results/
18
+
19
+ # ── Dev tooling ────────────────────────────────────────────────────────────────
20
+ .claude/
21
+ demo/.claude/
22
+ docs/superpowers/
23
+
24
+ # ── Alternate RAG indices (only default rag/index/ is needed) ──────────────────
25
+ rag/index_800/
26
+ rag/index_2400/
27
+
28
+ # ── Build / runtime artifacts ──────────────────────────────────────────────────
29
+ *.pyc
30
+ *.pyo
31
+ *.pyd
32
+ __pycache__/
33
+ *.log
34
+ *.db.bak
35
+ .git/
36
+ .gitignore
37
+
38
+ # ── Misc ───────────────────────────────────────────────────────────────────────
39
+ *.tmp
40
+ .DS_Store
.gitattributes ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ *.pkl filter=lfs diff=lfs merge=lfs -text
2
+ *.index filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # ── System build dependencies ──────────────────────────────────────────────────
6
+ # gcc/g++ required for numpy and faiss-cpu compilation
7
+ RUN apt-get update && apt-get install -y --no-install-recommends \
8
+ gcc \
9
+ g++ \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
+ # ── Python dependencies ────────────────────────────────────────────────────────
13
+ # Install CPU-only torch FIRST (prevents pulling the 2 GB CUDA wheel when
14
+ # sentence-transformers later requests torch as a dependency)
15
+ RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu
16
+
17
+ # Copy requirements before source code so Docker can cache this layer
18
+ COPY demo/requirements.txt /tmp/demo-req.txt
19
+ COPY rag/requirements.txt /tmp/rag-req.txt
20
+ RUN pip install --no-cache-dir -r /tmp/demo-req.txt && \
21
+ pip install --no-cache-dir -r /tmp/rag-req.txt
22
+
23
+ # ── Pre-download BGE embedding model ──────────────────────────────────────────
24
+ # Bake the model into the image so startup is fast on HF Spaces (no network wait).
25
+ # Store in /app/.cache/huggingface so it survives the non-root user switch below.
26
+ ENV HF_HOME=/app/.cache/huggingface
27
+ RUN python -c "\
28
+ from sentence_transformers import SentenceTransformer; \
29
+ SentenceTransformer('BAAI/bge-base-en-v1.5')" \
30
+ && chmod -R 755 /app/.cache
31
+
32
+ # ── Application code ───────────────────────────────────────────────────────────
33
+ # .dockerignore excludes .env, data/parsed/, paper/, scripts/, etc.
34
+ COPY . .
35
+
36
+ # ── Non-root user (HF Spaces requirement) ─────────────────────────────────────
37
+ RUN useradd -m -u 1000 user && chown -R user:user /app
38
+ USER user
39
+
40
+ ENV HOME=/home/user \
41
+ PATH=/home/user/.local/bin:$PATH \
42
+ HF_HOME=/app/.cache/huggingface \
43
+ PORT=7860
44
+
45
+ EXPOSE 7860
46
+
47
+ # ── Start server ───────────────────────────────────────────────────────────────
48
+ # Run from /app (repo root) so both `demo` and `rag` are importable as packages.
49
+ # 1 worker keeps SQLite writes safe; 4 threads handle concurrent requests.
50
+ CMD exec gunicorn \
51
+ --bind "0.0.0.0:${PORT}" \
52
+ --workers 1 \
53
+ --threads 4 \
54
+ --timeout 120 \
55
+ demo.app:app
README.md ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: IndiaFinBench
3
+ emoji: πŸ“œ
4
+ colorFrom: red
5
+ colorTo: yellow
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: mit
10
+ short_description: LLM benchmark for Indian financial regulation
11
+ ---
12
+
13
+ # IndiaFinBench
14
+
15
+ **The first evaluation benchmark for large language model performance on Indian financial regulatory text.**
16
+
17
+ 406 expert-annotated questions over 192 SEBI & RBI regulatory documents (1992–2026) Β· 12 frontier models evaluated Β· hybrid FAISS + BM25 retrieval with Recall@5 = 0.785.
18
+
19
+ This Space hosts the live research site: the full leaderboard with statistical tier analysis, a dataset explorer, a live hybrid-RAG demo over the regulatory corpus, and model submission.
20
+
21
+ - **Dataset:** [Rajveer-code/IndiaFinBench](https://huggingface.co/datasets/Rajveer-code/IndiaFinBench) (CC BY 4.0)
22
+ - **Code & paper:** [github.com/Rajveer-code/IndiaFinBench](https://github.com/Rajveer-code/IndiaFinBench) (MIT)
23
+ - **Author:** Rajveer Singh Pall β€” rajveerpall04@gmail.com
demo/.dockerignore ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.pyd
5
+ *.db
6
+ .env
7
+ .git
8
+ .gitignore
9
+ *.md
10
+ leaderboard.db
11
+ rag/setup_datastore.py
12
+ .claude/
demo/.gitkeep ADDED
File without changes
demo/__init__.py ADDED
File without changes
demo/app.py ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ demo/app.py
3
+ -----------
4
+ Flask leaderboard + RAG demo for IndiaFinBench.
5
+
6
+ Routes:
7
+ GET / Main page
8
+ GET /api/leaderboard JSON leaderboard (12 models + human)
9
+ GET /api/example Random benchmark item
10
+ POST /api/rag Hybrid RAG query (rate-limited 20/min per IP)
11
+ POST /api/submit Returns pre-filled GitHub issue URL
12
+
13
+ Run locally:
14
+ python demo/app.py
15
+
16
+ On HuggingFace Spaces:
17
+ gunicorn --bind 0.0.0.0:7860 --workers 1 --threads 4 --timeout 120 demo.app:app
18
+ """
19
+
20
+ import json
21
+ import os
22
+ import random
23
+ import sys
24
+ import threading
25
+ import time
26
+ import urllib.parse
27
+ from collections import defaultdict, deque
28
+ from pathlib import Path
29
+
30
+ from flask import Flask, jsonify, render_template, request
31
+
32
+ # ── Python path ────────────────────────────────────────────────────────────────
33
+ # Root must come FIRST so `import rag` resolves to /app/rag/ (the real pipeline)
34
+ # not /app/demo/rag/ (old shim, now deleted).
35
+ # Demo comes second for database/ imports.
36
+ _DEMO_DIR = Path(__file__).parent
37
+ _ROOT_DIR = _DEMO_DIR.parent
38
+
39
+ if str(_ROOT_DIR) not in sys.path:
40
+ sys.path.insert(0, str(_ROOT_DIR))
41
+ if str(_DEMO_DIR) not in sys.path:
42
+ sys.path.insert(1, str(_DEMO_DIR))
43
+
44
+ from database.db import get_leaderboard, init_db # noqa: E402
45
+
46
+ # ── Data ───────────────────────────────────────────────────────────────────────
47
+
48
+ QUESTIONS_PATH = _DEMO_DIR / "data" / "questions.json"
49
+ with QUESTIONS_PATH.open(encoding="utf-8") as _f:
50
+ QUESTIONS: list[dict] = json.load(_f)
51
+
52
+ init_db()
53
+
54
+ # ── Flask app ──────────────────────────────────────────────────────────────────
55
+
56
+ app = Flask(
57
+ __name__,
58
+ template_folder=str(_DEMO_DIR / "templates"),
59
+ static_folder=str(_DEMO_DIR / "static"),
60
+ )
61
+ app.config["TEMPLATES_AUTO_RELOAD"] = True
62
+ app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 0
63
+
64
+ _JS_VER = str(int(time.time()))
65
+
66
+
67
+ @app.after_request
68
+ def _no_cache(response):
69
+ response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
70
+ response.headers["Pragma"] = "no-cache"
71
+ return response
72
+
73
+
74
+ # ── Constants ──────────────────────────────────────────────────────────────────
75
+
76
+ TASK_FULL = {
77
+ "regulatory_interpretation": "Regulatory Interpretation",
78
+ "numerical_reasoning": "Numerical Reasoning",
79
+ "contradiction_detection": "Contradiction Detection",
80
+ "temporal_reasoning": "Temporal Reasoning",
81
+ }
82
+
83
+ HUMAN_BASELINE = {
84
+ "rank": "β€”",
85
+ "label": "Human Expert",
86
+ "hf_id": "β€” (n=100 sampled items)",
87
+ "params": "β€”",
88
+ "type": "Human Baseline",
89
+ "overall": 69.0,
90
+ "reg": 55.6,
91
+ "num": 44.4,
92
+ "con": 83.3,
93
+ "tmp": 66.7,
94
+ "n_items": 100,
95
+ "submitted": "2026-03-15",
96
+ "is_human": True,
97
+ }
98
+
99
+ # ── RAG pipeline (lazy, thread-safe) ──────────────────────────────────────────
100
+
101
+ _INDEX_DIR = _ROOT_DIR / "rag" / "index"
102
+ _rag_pipeline = None
103
+ _rag_lock = threading.Lock()
104
+
105
+
106
+ def _get_rag_pipeline():
107
+ """Lazy-init the RAG pipeline with double-checked locking."""
108
+ global _rag_pipeline
109
+ if _rag_pipeline is not None:
110
+ return _rag_pipeline
111
+ with _rag_lock:
112
+ if _rag_pipeline is not None:
113
+ return _rag_pipeline
114
+ from rag.config import RAGConfig # noqa: PLC0415
115
+ from rag.pipeline import RAGPipeline # noqa: PLC0415
116
+ cfg = RAGConfig(index_dir=_INDEX_DIR)
117
+ p = RAGPipeline(config=cfg)
118
+ p.load_index()
119
+ _rag_pipeline = p
120
+ return _rag_pipeline
121
+
122
+
123
+ def _warmup_rag() -> None:
124
+ """Pre-load the pipeline at startup so the first user request is fast."""
125
+ try:
126
+ _get_rag_pipeline()
127
+ app.logger.info("RAG pipeline ready (FAISS + BM25 loaded)")
128
+ except Exception as exc: # noqa: BLE001
129
+ app.logger.warning("RAG warmup failed: %s", exc)
130
+
131
+
132
+ threading.Thread(target=_warmup_rag, daemon=True).start()
133
+
134
+ # ── Rate limiter ───────────────────────────────────────────────────────────────
135
+
136
+ _rag_rate: dict = defaultdict(deque)
137
+ _RL_N = 20 # requests
138
+ _RL_W = 60.0 # seconds window
139
+
140
+
141
+ def _rl_check(ip: str) -> bool:
142
+ """Return True if the IP is within the rate limit, False if exceeded."""
143
+ now = time.time()
144
+ q = _rag_rate[ip]
145
+ while q and now - q[0] > _RL_W:
146
+ q.popleft()
147
+ if len(q) >= _RL_N:
148
+ return False
149
+ q.append(now)
150
+ return True
151
+
152
+
153
+ # ── Helpers ────────────────────────────────────────────────────────────────────
154
+
155
+ def _normalize_models(df) -> list[dict]:
156
+ result = []
157
+ for _, row in df.iterrows():
158
+ result.append({
159
+ "rank": int(row["Rank"]),
160
+ "label": str(row["Model"]),
161
+ "hf_id": str(row["HF Model ID"]),
162
+ "params": str(row.get("Params", "β€”")),
163
+ "type": str(row.get("Type", "Open")),
164
+ "overall": round(float(row["Overall (%)"]), 1),
165
+ "reg": round(float(row["REG (%)"]), 1),
166
+ "num": round(float(row["NUM (%)"]), 1),
167
+ "con": round(float(row["CON (%)"]), 1),
168
+ "tmp": round(float(row["TMP (%)"]), 1),
169
+ "submitted": str(row.get("Submitted", "")),
170
+ "is_human": False,
171
+ })
172
+ return result
173
+
174
+
175
+ # ── Routes ─────────────────────────────────────────────────────────────────────
176
+
177
+ @app.route("/")
178
+ def index():
179
+ df = get_leaderboard()
180
+ models = _normalize_models(df) if not df.empty else []
181
+ return render_template(
182
+ "index.html",
183
+ models=models,
184
+ human=HUMAN_BASELINE,
185
+ model_count=12,
186
+ human_overall=f"{HUMAN_BASELINE['overall']:.1f}",
187
+ js_ver=_JS_VER,
188
+ )
189
+
190
+
191
+ @app.route("/api/leaderboard")
192
+ def api_leaderboard():
193
+ df = get_leaderboard()
194
+ models = _normalize_models(df) if not df.empty else []
195
+ models.append(HUMAN_BASELINE)
196
+ return jsonify(models)
197
+
198
+
199
+ @app.route("/api/submit", methods=["POST"])
200
+ def api_submit():
201
+ data = request.get_json() or {}
202
+ hf_id = (data.get("hf_id") or "").strip()
203
+ label = (data.get("label") or (hf_id.split("/")[-1] if hf_id else "")).strip()
204
+ params = (data.get("params") or "Unknown").strip() or "Unknown"
205
+ model_type = (data.get("model_type") or "Open").strip() or "Open"
206
+
207
+ if not hf_id:
208
+ return jsonify({"error": "hf_id is required"}), 400
209
+
210
+ safe = hf_id.replace("/", "_")
211
+ body = (
212
+ "**Model Submission for IndiaFinBench**\n\n"
213
+ "| Field | Value |\n|---|---|\n"
214
+ f"| Model Name | {label} |\n"
215
+ f"| HuggingFace ID | `{hf_id}` |\n"
216
+ f"| Parameters | {params} |\n"
217
+ f"| Type | {model_type} |\n\n"
218
+ "**Evaluation command:**\n"
219
+ "```bash\n"
220
+ "python evaluation/evaluate.py \\\n"
221
+ " --dataset data/benchmark/indiafinbench_v1.csv \\\n"
222
+ f" --model {hf_id} \\\n"
223
+ " --provider huggingface \\\n"
224
+ f" --output results/predictions/{safe}.csv\n"
225
+ "```\n"
226
+ )
227
+ issue_url = (
228
+ "https://github.com/Rajveer-code/IndiaFinBench/issues/new"
229
+ f"?title={urllib.parse.quote(f'[Submission] {label}')}"
230
+ f"&body={urllib.parse.quote(body)}"
231
+ "&labels=model-submission"
232
+ )
233
+ return jsonify({"issue_url": issue_url})
234
+
235
+
236
+ @app.route("/api/rag", methods=["POST"])
237
+ def api_rag():
238
+ data = request.get_json() or {}
239
+ query = (data.get("query") or "").strip()
240
+ if not query:
241
+ return jsonify({"error": "Missing query"}), 400
242
+
243
+ ip = request.remote_addr or "unknown"
244
+ if not _rl_check(ip):
245
+ return jsonify({"error": "Rate limit reached (20 req/min). Please wait."}), 429
246
+
247
+ try:
248
+ result = _get_rag_pipeline().ask(query)
249
+ except Exception as exc: # noqa: BLE001
250
+ result = {"error": f"RAG unavailable: {str(exc)[:300]}"}
251
+ return jsonify(result)
252
+
253
+
254
+ @app.route("/api/example")
255
+ def api_example():
256
+ task = request.args.get("task", "All")
257
+ diff = request.args.get("diff", "All")
258
+
259
+ pool = list(QUESTIONS)
260
+ if task != "All":
261
+ pool = [q for q in pool if TASK_FULL.get(q["task_type"], "") == task]
262
+ if diff != "All":
263
+ pool = [q for q in pool if q["difficulty"] == diff.lower()]
264
+
265
+ if not pool:
266
+ return jsonify({"error": "No examples match filters"})
267
+
268
+ q = random.choice(pool)
269
+ ctx = q.get("context") or (
270
+ "Passage A: " + q.get("context_a", "")
271
+ + "\n\nPassage B: " + q.get("context_b", "")
272
+ )
273
+ return jsonify({
274
+ "id": q["id"],
275
+ "task_type": TASK_FULL.get(q["task_type"], q["task_type"]),
276
+ "difficulty": q["difficulty"],
277
+ "context": ctx[:800] + ("…" if len(ctx) > 800 else ""),
278
+ "question": q["question"],
279
+ "answer": q["gold_answer"],
280
+ })
281
+
282
+
283
+ # ── Entry point ───────────────────────────────────────────────────────���────────
284
+
285
+ if __name__ == "__main__":
286
+ port = int(os.getenv("PORT", "7860"))
287
+ app.run(host="0.0.0.0", port=port, debug=False)
demo/data/baselines.json ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "model_id": "claude_haiku",
4
+ "label": "Claude 3 Haiku",
5
+ "hf_id": "anthropic/claude-3-haiku-20240307",
6
+ "params": "β€”",
7
+ "type": "Frontier API",
8
+ "scores": {
9
+ "REG": 0.9245,
10
+ "NUM": 0.9375,
11
+ "CON": 0.8667,
12
+ "TMP": 0.9143
13
+ },
14
+ "overall": 0.9133,
15
+ "n_items": 150,
16
+ "submitted": "2026-03-30",
17
+ "baseline": true,
18
+ "note": "Retired by Anthropic on April 19, 2026. Outputs cached for reproducibility."
19
+ },
20
+ {
21
+ "model_id": "gemini_flash",
22
+ "label": "Gemini 2.5 Flash",
23
+ "hf_id": "google/gemini-2.5-flash",
24
+ "params": "β€”",
25
+ "type": "Frontier API",
26
+ "scores": {
27
+ "REG": 0.9623,
28
+ "NUM": 0.8438,
29
+ "CON": 0.8333,
30
+ "TMP": 0.8
31
+ },
32
+ "overall": 0.8733,
33
+ "n_items": 150,
34
+ "submitted": "2026-03-31",
35
+ "baseline": true
36
+ },
37
+ {
38
+ "model_id": "llama4scout",
39
+ "label": "Llama 4 Scout 17B",
40
+ "hf_id": "meta-llama/llama-4-scout-17b-16e-instruct",
41
+ "params": "17B",
42
+ "type": "Open-weight API",
43
+ "scores": {
44
+ "REG": 0.7925,
45
+ "NUM": 0.75,
46
+ "CON": 1.0,
47
+ "TMP": 0.8
48
+ },
49
+ "overall": 0.8267,
50
+ "n_items": 150,
51
+ "submitted": "2026-04-08",
52
+ "baseline": true
53
+ },
54
+ {
55
+ "model_id": "qwen3_32b",
56
+ "label": "Qwen3-32B",
57
+ "hf_id": "Qwen/Qwen3-32B",
58
+ "params": "32B",
59
+ "type": "Open-weight API",
60
+ "scores": {
61
+ "REG": 0.7736,
62
+ "NUM": 0.75,
63
+ "CON": 0.8667,
64
+ "TMP": 0.9429
65
+ },
66
+ "overall": 0.8267,
67
+ "n_items": 150,
68
+ "submitted": "2026-04-08",
69
+ "baseline": true
70
+ },
71
+ {
72
+ "model_id": "llama33_70b",
73
+ "label": "LLaMA-3.3-70B",
74
+ "hf_id": "meta-llama/Llama-3.3-70B-Instruct",
75
+ "params": "70B",
76
+ "type": "Open-weight API",
77
+ "scores": {
78
+ "REG": 0.7736,
79
+ "NUM": 0.8438,
80
+ "CON": 0.9,
81
+ "TMP": 0.7714
82
+ },
83
+ "overall": 0.8133,
84
+ "n_items": 150,
85
+ "submitted": "2026-03-31",
86
+ "baseline": true
87
+ },
88
+ {
89
+ "model_id": "llama3_8b",
90
+ "label": "LLaMA-3-8B",
91
+ "hf_id": "meta-llama/Meta-Llama-3-8B-Instruct",
92
+ "params": "8B",
93
+ "type": "Local (Ollama)",
94
+ "scores": {
95
+ "REG": 0.7736,
96
+ "NUM": 0.625,
97
+ "CON": 0.8667,
98
+ "TMP": 0.7429
99
+ },
100
+ "overall": 0.7533,
101
+ "n_items": 150,
102
+ "submitted": "2026-03-31",
103
+ "baseline": true
104
+ },
105
+ {
106
+ "model_id": "gemma4_e4b",
107
+ "label": "Gemma 4 E4B",
108
+ "hf_id": "google/gemma-4-e4b",
109
+ "params": "4B",
110
+ "type": "Local (Ollama)",
111
+ "scores": {
112
+ "REG": 0.9057,
113
+ "NUM": 0.6563,
114
+ "CON": 0.7667,
115
+ "TMP": 0.5714
116
+ },
117
+ "overall": 0.7467,
118
+ "n_items": 150,
119
+ "submitted": "2026-04-10",
120
+ "baseline": true
121
+ },
122
+ {
123
+ "model_id": "mistral_7b",
124
+ "label": "Mistral-7B",
125
+ "hf_id": "mistralai/Mistral-7B-Instruct-v0.3",
126
+ "params": "7B",
127
+ "type": "Local (Ollama)",
128
+ "scores": {
129
+ "REG": 0.6981,
130
+ "NUM": 0.6875,
131
+ "CON": 0.8,
132
+ "TMP": 0.7429
133
+ },
134
+ "overall": 0.7267,
135
+ "n_items": 150,
136
+ "submitted": "2026-03-31",
137
+ "baseline": true
138
+ },
139
+ {
140
+ "model_id": "deepseek_r1_70b",
141
+ "label": "DeepSeek R1 70B",
142
+ "hf_id": "deepseek-ai/DeepSeek-R1-Distill-Llama-70B",
143
+ "params": "70B distilled",
144
+ "type": "Reasoning API",
145
+ "scores": {
146
+ "REG": 0.6038,
147
+ "NUM": 0.7813,
148
+ "CON": 0.9333,
149
+ "TMP": 0.6
150
+ },
151
+ "overall": 0.7067,
152
+ "n_items": 150,
153
+ "submitted": "2026-04-10",
154
+ "baseline": true,
155
+ "note": "Evaluated via OpenRouter (retired from Groq October 2, 2025). Identical model weights."
156
+ }
157
+ ]
demo/data/questions.json ADDED
The diff for this file is too large to render. See raw diff
 
demo/database/__init__.py ADDED
File without changes
demo/database/db.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ database/db.py
3
+ --------------
4
+ Purpose: SQLite-backed leaderboard database for IndiaFinBench Spaces.
5
+ Stores evaluation results, supports leaderboard retrieval.
6
+ Inputs: baselines.json (pre-populated on first init)
7
+ Outputs: Pandas DataFrame via get_leaderboard()
8
+ Usage:
9
+ from database.db import init_db, save_result, get_leaderboard
10
+ init_db()
11
+ df = get_leaderboard()
12
+ """
13
+
14
+ import json
15
+ import sqlite3
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import pandas as pd
21
+
22
+ # ── Configuration ──────────────────────────────────────────────────────────────
23
+
24
+ DB_PATH = Path(__file__).parent.parent / "leaderboard.db"
25
+ BASELINES_JSON = Path(__file__).parent.parent / "data/baselines.json"
26
+
27
+ TASK_SHORTS = ["REG", "NUM", "CON", "TMP"]
28
+
29
+ CREATE_TABLE_SQL = """
30
+ CREATE TABLE IF NOT EXISTS results (
31
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
32
+ model_id TEXT NOT NULL,
33
+ label TEXT NOT NULL,
34
+ hf_id TEXT NOT NULL,
35
+ params TEXT DEFAULT 'Unknown',
36
+ model_type TEXT DEFAULT 'Open',
37
+ overall REAL NOT NULL,
38
+ score_REG REAL DEFAULT 0.0,
39
+ score_NUM REAL DEFAULT 0.0,
40
+ score_CON REAL DEFAULT 0.0,
41
+ score_TMP REAL DEFAULT 0.0,
42
+ n_items INTEGER DEFAULT 150,
43
+ submitted_at TEXT NOT NULL,
44
+ is_baseline INTEGER DEFAULT 0,
45
+ notes TEXT DEFAULT ''
46
+ )
47
+ """
48
+
49
+
50
+ # ── Connection helper ──────────────────────────────────────────────────────────
51
+
52
+ def _connect() -> sqlite3.Connection:
53
+ """Open (or create) the leaderboard SQLite database.
54
+
55
+ Returns:
56
+ sqlite3.Connection with row_factory set to sqlite3.Row.
57
+ """
58
+ conn = sqlite3.connect(str(DB_PATH))
59
+ conn.row_factory = sqlite3.Row
60
+ return conn
61
+
62
+
63
+ # ── Initialisation ─────────────────────────────────────────────────────────────
64
+
65
+ def init_db() -> None:
66
+ """Create the results table and pre-populate with baseline models.
67
+
68
+ Safe to call multiple times β€” baselines are inserted only once (by hf_id).
69
+ """
70
+ conn = _connect()
71
+ with conn:
72
+ conn.execute(CREATE_TABLE_SQL)
73
+
74
+ # Load baselines from JSON
75
+ if not BASELINES_JSON.exists():
76
+ print(f" [WARN] baselines.json not found at {BASELINES_JSON}")
77
+ conn.close()
78
+ return
79
+
80
+ with BASELINES_JSON.open(encoding="utf-8") as f:
81
+ baselines = json.load(f)
82
+
83
+ with conn:
84
+ for b in baselines:
85
+ # Only insert if this hf_id is not already present
86
+ existing = conn.execute(
87
+ "SELECT id FROM results WHERE hf_id = ?", (b["hf_id"],)
88
+ ).fetchone()
89
+ if existing:
90
+ continue
91
+
92
+ scores = b.get("scores", {})
93
+ conn.execute(
94
+ """INSERT INTO results
95
+ (model_id, label, hf_id, params, model_type,
96
+ overall, score_REG, score_NUM, score_CON, score_TMP,
97
+ n_items, submitted_at, is_baseline)
98
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
99
+ (
100
+ b["model_id"], b["label"], b["hf_id"],
101
+ b.get("params", "N/A"), b.get("type", "API"),
102
+ b["overall"],
103
+ scores.get("REG", 0.0), scores.get("NUM", 0.0),
104
+ scores.get("CON", 0.0), scores.get("TMP", 0.0),
105
+ b.get("n_items", 150),
106
+ b.get("submitted", datetime.utcnow().strftime("%Y-%m-%d")),
107
+ 1,
108
+ ),
109
+ )
110
+
111
+ conn.close()
112
+ print(f" DB initialised: {DB_PATH}")
113
+
114
+
115
+ # ── Save result ────────────────────────────────────────────────────────────────
116
+
117
+ def save_result(
118
+ hf_id: str,
119
+ label: str,
120
+ overall: float,
121
+ per_task: dict[str, float],
122
+ params: str = "Unknown",
123
+ model_type: str = "Open",
124
+ n_items: int = 150,
125
+ notes: str = "",
126
+ ) -> int:
127
+ """Save a new evaluation result to the database.
128
+
129
+ Args:
130
+ hf_id: HuggingFace model ID.
131
+ label: Display name for the model.
132
+ overall: Overall accuracy (0–1).
133
+ per_task: Dict of task_short -> accuracy (0–1).
134
+ params: Parameter count string (e.g. "7B").
135
+ model_type: "Open" or "API".
136
+ n_items: Number of items evaluated.
137
+ notes: Optional notes.
138
+
139
+ Returns:
140
+ Row id of the inserted record.
141
+ """
142
+ conn = _connect()
143
+ with conn:
144
+ cursor = conn.execute(
145
+ """INSERT INTO results
146
+ (model_id, label, hf_id, params, model_type,
147
+ overall, score_REG, score_NUM, score_CON, score_TMP,
148
+ n_items, submitted_at, is_baseline, notes)
149
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,0,?)""",
150
+ (
151
+ hf_id.split("/")[-1], label, hf_id,
152
+ params, model_type, overall,
153
+ per_task.get("REG", 0.0), per_task.get("NUM", 0.0),
154
+ per_task.get("CON", 0.0), per_task.get("TMP", 0.0),
155
+ n_items,
156
+ datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
157
+ notes,
158
+ ),
159
+ )
160
+ row_id = cursor.lastrowid
161
+ conn.close()
162
+ return row_id
163
+
164
+
165
+ # ── Leaderboard retrieval ──────────────────────────────────────────────────────
166
+
167
+ def get_leaderboard(include_duplicates: bool = False) -> pd.DataFrame:
168
+ """Retrieve the leaderboard as a pandas DataFrame.
169
+
170
+ Args:
171
+ include_duplicates: If False (default), keep only the best submission
172
+ per hf_id.
173
+
174
+ Returns:
175
+ DataFrame sorted by overall accuracy descending, with columns:
176
+ Rank, Model, HF ID, Params, Type, Overall, REG, NUM, CON, TMP, Submitted.
177
+ """
178
+ conn = _connect()
179
+ query = "SELECT * FROM results ORDER BY overall DESC, submitted_at ASC"
180
+ df = pd.read_sql_query(query, conn)
181
+ conn.close()
182
+
183
+ if df.empty:
184
+ return df
185
+
186
+ if not include_duplicates:
187
+ df = df.sort_values("overall", ascending=False).drop_duplicates(
188
+ subset="hf_id", keep="first"
189
+ )
190
+
191
+ df = df.sort_values("overall", ascending=False).reset_index(drop=True)
192
+ df.insert(0, "Rank", range(1, len(df) + 1))
193
+
194
+ display_cols = {
195
+ "label": "Model",
196
+ "hf_id": "HF Model ID",
197
+ "params": "Params",
198
+ "model_type": "Type",
199
+ "overall": "Overall (%)",
200
+ "score_REG": "REG (%)",
201
+ "score_NUM": "NUM (%)",
202
+ "score_CON": "CON (%)",
203
+ "score_TMP": "TMP (%)",
204
+ "submitted_at": "Submitted",
205
+ }
206
+ df = df.rename(columns=display_cols)
207
+
208
+ # Convert 0–1 floats to percentages
209
+ pct_cols = ["Overall (%)", "REG (%)", "NUM (%)", "CON (%)", "TMP (%)"]
210
+ for col in pct_cols:
211
+ if col in df.columns:
212
+ df[col] = (df[col] * 100).round(1)
213
+
214
+ out_cols = ["Rank", "Model", "HF Model ID", "Params", "Type",
215
+ "Overall (%)", "REG (%)", "NUM (%)", "CON (%)", "TMP (%)", "Submitted"]
216
+ return df[[c for c in out_cols if c in df.columns]]
demo/requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ flask>=3.0
2
+ pandas
3
+ rapidfuzz
4
+ gunicorn>=22.0
5
+ numpy>=1.26
6
+ faiss-cpu>=1.8,<2.0
7
+ sentence-transformers>=3.0,<4.0
8
+ rank-bm25>=0.2,<0.3
9
+ groq>=0.11,<1.0
10
+ tqdm>=4.66
demo/static/css/main.css ADDED
@@ -0,0 +1,808 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ════════════════════════════════════════════════════════════════════
2
+ IndiaFinBench β€” "The Record"
3
+ Archival-editorial design system. Paper, ink, sealing wax, ledger green.
4
+ ════════════════════════════════════════════════════════════════════ */
5
+
6
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
7
+
8
+ :root {
9
+ --paper: #F5F1E8;
10
+ --paper-hi: #FBF8F1;
11
+ --paper-deep: #EDE7D9;
12
+ --ink: #1C1812;
13
+ --ink-2: #4A4338;
14
+ --ink-3: #8B8270;
15
+ --rule: rgba(28, 24, 18, 0.14);
16
+ --rule-soft: rgba(28, 24, 18, 0.08);
17
+ --red: #A33B20;
18
+ --red-soft: rgba(163, 59, 32, 0.10);
19
+ --green: #1F5C45;
20
+ --green-soft: rgba(31, 92, 69, 0.10);
21
+ --gold: #96752A;
22
+ --gold-soft: rgba(150, 117, 42, 0.12);
23
+ --saffron: #C96F12;
24
+
25
+ --serif: 'Fraunces', Georgia, serif;
26
+ --sans: 'Archivo', system-ui, sans-serif;
27
+ --mono: 'IBM Plex Mono', ui-monospace, monospace;
28
+
29
+ --maxw: 1080px;
30
+ --ease: cubic-bezier(0.22, 0.61, 0.36, 1);
31
+ }
32
+
33
+ html { scroll-behavior: smooth; }
34
+
35
+ body {
36
+ font-family: var(--sans);
37
+ background: var(--paper);
38
+ color: var(--ink);
39
+ line-height: 1.6;
40
+ font-size: 16px;
41
+ -webkit-font-smoothing: antialiased;
42
+ position: relative;
43
+ }
44
+
45
+ /* paper grain */
46
+ body::before {
47
+ content: '';
48
+ position: fixed; inset: 0;
49
+ pointer-events: none; z-index: 1000;
50
+ opacity: 0.35;
51
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.11 0 0 0 0 0.09 0 0 0 0 0.07 0 0 0 0.04 0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
52
+ }
53
+
54
+ ::selection { background: var(--red); color: var(--paper-hi); }
55
+
56
+ #archiveCanvas {
57
+ position: fixed; inset: 0; width: 100vw; height: 100vh;
58
+ z-index: 0; pointer-events: none;
59
+ }
60
+ main, .footer { position: relative; z-index: 2; }
61
+
62
+ :focus-visible { outline: 2px solid var(--red); outline-offset: 2px; }
63
+
64
+ a { color: inherit; }
65
+ .mono { font-family: var(--mono); }
66
+ .c-red { color: var(--red); }
67
+ .c-green { color: var(--green); }
68
+
69
+ .skip-link {
70
+ position: absolute; left: -9999px; top: 0;
71
+ background: var(--ink); color: var(--paper); padding: 10px 18px;
72
+ font-size: 13px; z-index: 2000; text-decoration: none;
73
+ }
74
+ .skip-link:focus { left: 12px; top: 12px; }
75
+
76
+ /* ════════ MASTHEAD ════════ */
77
+ .masthead {
78
+ position: fixed; top: 0; left: 0; right: 0; z-index: 600;
79
+ background: color-mix(in srgb, var(--paper) 88%, transparent);
80
+ backdrop-filter: blur(14px) saturate(140%);
81
+ border-bottom: 1px solid var(--rule);
82
+ }
83
+ .masthead::after {
84
+ content: ''; display: block; height: 1px;
85
+ background: var(--rule-soft); margin-top: 2px;
86
+ }
87
+ .masthead-inner {
88
+ max-width: 1280px; margin: 0 auto;
89
+ height: 60px; padding: 0 24px;
90
+ display: flex; align-items: center; justify-content: space-between; gap: 16px;
91
+ }
92
+ .brand { display: flex; align-items: baseline; gap: 10px; text-decoration: none; }
93
+ .brand-seal {
94
+ font-family: var(--serif); font-weight: 700; font-size: 22px;
95
+ color: var(--red); line-height: 1;
96
+ }
97
+ .brand-word {
98
+ font-family: var(--serif); font-size: 19px; font-weight: 600;
99
+ letter-spacing: -0.01em;
100
+ }
101
+ .brand-word em { font-style: italic; color: var(--red); }
102
+ .mast-nav { display: flex; gap: 4px; }
103
+ .mast-nav a {
104
+ font-size: 13px; font-weight: 500; color: var(--ink-2);
105
+ text-decoration: none; padding: 7px 11px; border-radius: 2px;
106
+ transition: color 0.15s, background 0.15s;
107
+ }
108
+ .mast-nav a:hover, .mast-nav a.active { color: var(--red); background: var(--red-soft); }
109
+ .mast-actions { display: flex; align-items: center; gap: 8px; }
110
+ .mast-btn {
111
+ font-size: 12.5px; font-weight: 600; text-decoration: none;
112
+ padding: 7px 14px; border: 1px solid var(--rule);
113
+ color: var(--ink-2); border-radius: 2px;
114
+ transition: border-color 0.15s, color 0.15s, background 0.15s;
115
+ }
116
+ .mast-btn:hover { border-color: var(--ink); color: var(--ink); }
117
+ .mast-btn-solid { background: var(--ink); color: var(--paper-hi); border-color: var(--ink); }
118
+ .mast-btn-solid:hover { background: var(--red); border-color: var(--red); color: var(--paper-hi); }
119
+ .mast-menu {
120
+ display: none; flex-direction: column; gap: 5px;
121
+ background: none; border: none; cursor: pointer; padding: 8px;
122
+ }
123
+ .mast-menu span { width: 20px; height: 2px; background: var(--ink); transition: transform 0.2s; }
124
+ .mast-menu[aria-expanded="true"] span:first-child { transform: translateY(3.5px) rotate(45deg); }
125
+ .mast-menu[aria-expanded="true"] span:last-child { transform: translateY(-3.5px) rotate(-45deg); }
126
+ .mast-drawer {
127
+ display: none; flex-direction: column;
128
+ border-top: 1px solid var(--rule); background: var(--paper-hi);
129
+ }
130
+ .mast-drawer.open { display: flex; }
131
+ .mast-drawer a {
132
+ padding: 14px 24px; text-decoration: none; font-size: 14px;
133
+ font-family: var(--mono); color: var(--ink-2);
134
+ border-bottom: 1px solid var(--rule-soft);
135
+ }
136
+ .mast-drawer a:hover { color: var(--red); background: var(--red-soft); }
137
+
138
+ /* ════════ PROGRESS RAIL ════════ */
139
+ .rail {
140
+ position: fixed; left: 26px; top: 50%; transform: translateY(-50%);
141
+ z-index: 500; display: flex; gap: 14px; align-items: stretch;
142
+ }
143
+ .rail-line { width: 1px; background: var(--rule); position: relative; }
144
+ .rail-fill {
145
+ position: absolute; top: 0; left: 0; width: 100%; height: 0%;
146
+ background: var(--red); transition: height 0.2s linear;
147
+ }
148
+ .rail-list { list-style: none; display: flex; flex-direction: column; gap: 18px; }
149
+ .rail-list a {
150
+ display: flex; align-items: baseline; gap: 8px;
151
+ text-decoration: none; opacity: 0.45; transition: opacity 0.2s;
152
+ }
153
+ .rail-list a b { font-family: var(--mono); font-size: 10px; font-weight: 600; color: var(--red); }
154
+ .rail-list a span {
155
+ font-size: 11px; font-weight: 500; letter-spacing: 0.06em;
156
+ text-transform: uppercase; color: var(--ink-2);
157
+ }
158
+ .rail-list li.on a, .rail-list a:hover { opacity: 1; }
159
+ @media (max-width: 1320px) { .rail { display: none; } }
160
+
161
+ /* ════════ BUTTONS / CHIPS / INPUTS ════════ */
162
+ .btn {
163
+ display: inline-flex; align-items: center; justify-content: center; gap: 8px;
164
+ font-family: var(--sans); font-size: 14px; font-weight: 600;
165
+ padding: 12px 24px; border-radius: 2px; text-decoration: none;
166
+ cursor: pointer; transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.1s;
167
+ border: 1px solid transparent;
168
+ }
169
+ .btn:active { transform: translateY(1px); }
170
+ .btn-ink { background: var(--ink); color: var(--paper-hi); border-color: var(--ink); }
171
+ .btn-ink:hover { background: var(--red); border-color: var(--red); }
172
+ .btn-ink:disabled { opacity: 0.45; cursor: not-allowed; }
173
+ .btn-line { background: transparent; color: var(--ink); border-color: var(--rule); }
174
+ .btn-line:hover { border-color: var(--red); color: var(--red); }
175
+ .btn-sm { padding: 8px 16px; font-size: 13px; }
176
+
177
+ .chip {
178
+ font-family: var(--sans); font-size: 12.5px; font-weight: 500;
179
+ padding: 7px 14px; background: var(--paper-hi);
180
+ border: 1px solid var(--rule); border-radius: 100px;
181
+ color: var(--ink-2); cursor: pointer;
182
+ transition: color 0.15s, border-color 0.15s;
183
+ }
184
+ .chip:hover { color: var(--red); border-color: var(--red); }
185
+
186
+ .input, .select {
187
+ font-family: var(--sans); font-size: 14px; color: var(--ink);
188
+ background: var(--paper-hi); border: 1px solid var(--rule);
189
+ border-radius: 2px; padding: 11px 14px; outline: none; width: 100%;
190
+ transition: border-color 0.15s, box-shadow 0.15s;
191
+ }
192
+ .input:focus, .select:focus { border-color: var(--red); box-shadow: 0 0 0 3px var(--red-soft); }
193
+ .input::placeholder { color: var(--ink-3); }
194
+ .select { cursor: pointer; }
195
+
196
+ /* ════════ HERO ════════ */
197
+ .hero {
198
+ position: relative; z-index: 2;
199
+ padding: 168px 24px 0;
200
+ border-bottom: 1px solid var(--rule);
201
+ }
202
+ /* radial paper glow keeps the headline column clear of the archive scene */
203
+ .hero::before {
204
+ content: ''; position: absolute; inset: 0; z-index: 1; pointer-events: none;
205
+ background: radial-gradient(ellipse 56% 54% at 50% 40%,
206
+ var(--paper) 30%,
207
+ color-mix(in srgb, var(--paper) 72%, transparent) 62%,
208
+ transparent 100%);
209
+ }
210
+ .hero-inner {
211
+ position: relative; z-index: 2;
212
+ max-width: var(--maxw); margin: 0 auto; text-align: center;
213
+ }
214
+ .hero-kicker {
215
+ font-family: var(--mono); font-size: 12px; font-weight: 500;
216
+ letter-spacing: 0.14em; text-transform: uppercase; color: var(--red);
217
+ margin-bottom: 28px;
218
+ }
219
+ .hero-kicker::before, .hero-kicker::after {
220
+ content: 'β€”β€”β€”'; letter-spacing: -0.18em; color: var(--rule);
221
+ margin: 0 14px; vertical-align: 2px;
222
+ }
223
+ .hero-title {
224
+ font-family: var(--serif); font-optical-sizing: auto;
225
+ font-size: clamp(44px, 7.4vw, 96px); font-weight: 560;
226
+ line-height: 1.02; letter-spacing: -0.022em;
227
+ max-width: 15ch; margin: 0 auto 28px;
228
+ }
229
+ .hero-title em { font-style: italic; color: var(--red); }
230
+ .hero-sub {
231
+ font-size: clamp(15px, 1.7vw, 18px); color: var(--ink-2);
232
+ max-width: 620px; margin: 0 auto 40px; line-height: 1.75;
233
+ }
234
+ .hero-sub strong { color: var(--ink); font-weight: 600; }
235
+ .hero-cta { display: flex; justify-content: center; gap: 12px; flex-wrap: wrap; margin-bottom: 64px; }
236
+
237
+ .hero-scrollcue {
238
+ font-family: var(--mono); font-size: 11px; font-weight: 500;
239
+ letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink-3);
240
+ margin-bottom: 56px;
241
+ }
242
+ .cue-arrow { display: inline-block; margin-left: 10px; color: var(--red); animation: cue-drop 2s var(--ease) infinite; }
243
+ @keyframes cue-drop { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(5px); } }
244
+
245
+ .hero-ledger {
246
+ position: relative; z-index: 2;
247
+ max-width: 1180px; margin: 0 auto;
248
+ display: grid; grid-template-columns: repeat(6, 1fr);
249
+ border: 1px solid var(--rule); border-bottom: none;
250
+ background: color-mix(in srgb, var(--paper-hi) 78%, transparent);
251
+ backdrop-filter: blur(6px);
252
+ }
253
+ .ledger-cell {
254
+ padding: 26px 18px 22px; text-align: center;
255
+ border-right: 1px solid var(--rule-soft);
256
+ }
257
+ .ledger-cell:last-child { border-right: none; }
258
+ .ledger-num {
259
+ display: block; font-family: var(--serif); font-weight: 600;
260
+ font-size: clamp(26px, 3vw, 40px); letter-spacing: -0.02em;
261
+ font-variant-numeric: tabular-nums; line-height: 1.1; margin-bottom: 6px;
262
+ }
263
+ .ledger-cell:nth-child(odd) .ledger-num { color: var(--ink); }
264
+ .ledger-cell:nth-child(even) .ledger-num { color: var(--red); }
265
+ .ledger-lbl {
266
+ font-size: 10.5px; font-weight: 600; letter-spacing: 0.1em;
267
+ text-transform: uppercase; color: var(--ink-3);
268
+ }
269
+ @media (max-width: 980px) { .hero-ledger { grid-template-columns: repeat(3, 1fr); } .ledger-cell:nth-child(3) { border-right: none; } .ledger-cell:nth-child(-n+3) { border-bottom: 1px solid var(--rule-soft); } }
270
+ @media (max-width: 560px) { .hero-ledger { grid-template-columns: repeat(2, 1fr); } .ledger-cell { border-right: 1px solid var(--rule-soft) !important; } .ledger-cell:nth-child(2n) { border-right: none !important; } .ledger-cell:nth-child(-n+4) { border-bottom: 1px solid var(--rule-soft); } }
271
+
272
+ /* ════════ CHAPTERS ════════ */
273
+ .chapter { padding: 110px 24px; position: relative; }
274
+ .chapter > * { max-width: var(--maxw); margin-left: auto; margin-right: auto; }
275
+ .ch-alt {
276
+ background: color-mix(in srgb, var(--paper-deep) 91%, transparent);
277
+ border-top: 1px solid var(--rule); border-bottom: 1px solid var(--rule);
278
+ }
279
+
280
+ .ch-head { margin-bottom: 56px; position: relative; }
281
+ .ch-no {
282
+ font-family: var(--mono); font-size: 13px; font-weight: 600;
283
+ letter-spacing: 0.18em; color: var(--red);
284
+ display: inline-block; margin-bottom: 14px;
285
+ }
286
+ .ch-no::after {
287
+ content: ''; display: inline-block; width: 64px; height: 1px;
288
+ background: var(--red); margin-left: 16px; vertical-align: 4px; opacity: 0.5;
289
+ }
290
+ .ch-title {
291
+ font-family: var(--serif); font-size: clamp(34px, 4.6vw, 56px);
292
+ font-weight: 560; letter-spacing: -0.02em; line-height: 1.05;
293
+ margin-bottom: 14px;
294
+ }
295
+ .ch-lede {
296
+ font-family: var(--serif); font-style: italic; font-size: clamp(17px, 2vw, 21px);
297
+ color: var(--ink-2); max-width: 60ch;
298
+ }
299
+
300
+ .prose { font-size: 16px; line-height: 1.8; color: var(--ink-2); }
301
+ .prose strong { color: var(--ink); }
302
+ .prose-narrow { max-width: 70ch; margin-bottom: 48px; }
303
+
304
+ /* vellum: frosted-paper panel that guarantees legibility over the scene */
305
+ .vellum {
306
+ background: color-mix(in srgb, var(--paper) 70%, transparent);
307
+ -webkit-backdrop-filter: blur(10px) saturate(130%);
308
+ backdrop-filter: blur(10px) saturate(130%);
309
+ border: 1px solid var(--rule-soft);
310
+ padding: 30px 34px;
311
+ }
312
+ @supports not (backdrop-filter: blur(1px)) {
313
+ .vellum { background: color-mix(in srgb, var(--paper) 94%, transparent); }
314
+ }
315
+
316
+ .prose-cols {
317
+ columns: 2; column-gap: 56px; column-rule: 1px solid var(--rule-soft);
318
+ margin-bottom: 64px; font-size: 16px; line-height: 1.8; color: var(--ink-2);
319
+ }
320
+ .prose-cols p { break-inside: avoid; margin-bottom: 1em; }
321
+ .prose-cols p:first-child::first-letter {
322
+ font-family: var(--serif); font-size: 3.4em; font-weight: 600;
323
+ float: left; line-height: 0.82; padding: 4px 10px 0 0; color: var(--red);
324
+ }
325
+ @media (max-width: 760px) { .prose-cols { columns: 1; } }
326
+
327
+ /* ════════ Β§01 PROBLEM ════════ */
328
+ .problem-grid {
329
+ display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px;
330
+ background: var(--rule); border: 1px solid var(--rule);
331
+ margin-bottom: 72px;
332
+ }
333
+ @media (max-width: 820px) { .problem-grid { grid-template-columns: 1fr; } }
334
+ .problem-card { background: var(--paper-hi); padding: 32px 28px; position: relative; }
335
+ .pc-no {
336
+ font-family: var(--serif); font-style: italic; font-size: 30px;
337
+ color: var(--red); display: block; margin-bottom: 16px; line-height: 1;
338
+ }
339
+ .problem-card h3 {
340
+ font-family: var(--serif); font-size: 20px; font-weight: 600;
341
+ letter-spacing: -0.01em; margin-bottom: 10px;
342
+ }
343
+ .problem-card p { font-size: 14px; line-height: 1.7; color: var(--ink-2); }
344
+
345
+ /* specimen exhibit */
346
+ .specimen {
347
+ border: 1px solid var(--ink); background: var(--paper-hi);
348
+ box-shadow: 4px 4px 0 var(--rule-soft);
349
+ padding: 0;
350
+ }
351
+ .spec-head {
352
+ display: flex; align-items: center; justify-content: space-between; gap: 12px;
353
+ padding: 14px 24px; border-bottom: 1px solid var(--rule);
354
+ }
355
+ .spec-tag {
356
+ font-family: var(--mono); font-size: 11px; font-weight: 600;
357
+ letter-spacing: 0.14em; text-transform: uppercase;
358
+ color: var(--paper-hi); background: var(--red); padding: 4px 10px;
359
+ }
360
+ .spec-src { font-family: var(--mono); font-size: 12px; color: var(--ink-3); }
361
+ .spec-body {
362
+ font-family: var(--serif); font-size: clamp(18px, 2.2vw, 23px);
363
+ line-height: 1.85; padding: 36px 40px; color: var(--ink);
364
+ }
365
+ .spec-body mark {
366
+ background: transparent; padding: 1px 3px; position: relative;
367
+ border-bottom: 2px solid; white-space: nowrap;
368
+ }
369
+ .m-num { color: var(--red); border-color: var(--red); background: var(--red-soft); }
370
+ .m-scope { color: var(--green); border-color: var(--green); background: var(--green-soft); }
371
+ .m-ref { color: var(--gold); border-color: var(--gold); background: var(--gold-soft); }
372
+ .spec-body mark::after {
373
+ content: attr(data-note);
374
+ position: absolute; left: 50%; bottom: calc(100% + 8px); transform: translateX(-50%) translateY(4px);
375
+ font-family: var(--mono); font-size: 10.5px; font-style: normal; white-space: nowrap;
376
+ background: var(--ink); color: var(--paper-hi); padding: 5px 10px;
377
+ opacity: 0; pointer-events: none; transition: opacity 0.18s, transform 0.18s;
378
+ }
379
+ .spec-body mark:hover::after { opacity: 1; transform: translateX(-50%) translateY(0); }
380
+ .spec-legend {
381
+ display: flex; flex-wrap: wrap; gap: 22px;
382
+ padding: 14px 24px; border-top: 1px solid var(--rule);
383
+ font-family: var(--mono); font-size: 11.5px; color: var(--ink-2);
384
+ }
385
+ .spec-legend span { display: inline-flex; align-items: center; gap: 8px; }
386
+ .lg { width: 14px; height: 3px; display: inline-block; }
387
+ .lg-num { background: var(--red); } .lg-scope { background: var(--green); } .lg-ref { background: var(--gold); }
388
+
389
+ /* ════════ Β§02 CORPUS ════════ */
390
+ .corpus-grid { display: grid; grid-template-columns: 5fr 6fr; gap: 56px; align-items: start; }
391
+ @media (max-width: 880px) { .corpus-grid { grid-template-columns: 1fr; } }
392
+ .corpus-copy .prose { margin-bottom: 32px; }
393
+ .corpus-stats { border-top: 1px solid var(--rule); }
394
+ .corpus-stats > div {
395
+ display: flex; justify-content: space-between; align-items: baseline;
396
+ padding: 11px 2px; border-bottom: 1px solid var(--rule-soft);
397
+ }
398
+ .corpus-stats dt { font-size: 13.5px; color: var(--ink-2); }
399
+ .corpus-stats dd { font-size: 14px; font-weight: 600; }
400
+
401
+ .doc-field { border: 1px solid var(--rule); background: var(--paper-hi); padding: 28px; }
402
+ .doc-dots {
403
+ display: grid; grid-template-columns: repeat(16, 1fr); gap: 7px;
404
+ margin-bottom: 20px;
405
+ }
406
+ .doc-dots i {
407
+ aspect-ratio: 3 / 4; border-radius: 1px; display: block;
408
+ opacity: 0; transform: scale(0.4) ;
409
+ transition: opacity 0.4s var(--ease), transform 0.4s var(--ease);
410
+ transition-delay: var(--d, 0s);
411
+ }
412
+ .doc-dots.shown i { opacity: 1; transform: scale(1); }
413
+ .doc-dots .sebi { background: var(--green); }
414
+ .doc-dots .rbi { background: var(--red); }
415
+ .doc-field figcaption {
416
+ display: flex; gap: 22px; align-items: center;
417
+ font-family: var(--mono); font-size: 12px; color: var(--ink-2);
418
+ }
419
+ .doc-field figcaption span { display: inline-flex; align-items: center; gap: 8px; }
420
+ .doc-total { margin-left: auto; color: var(--ink); font-weight: 600; }
421
+ .dot { width: 9px; height: 12px; border-radius: 1px; display: inline-block; }
422
+ .dot-sebi { background: var(--green); } .dot-rbi { background: var(--red); }
423
+
424
+ /* ════════ Β§03 BENCHMARK ════════ */
425
+ .pipeline {
426
+ list-style: none; counter-reset: step;
427
+ display: grid; grid-template-columns: repeat(5, 1fr);
428
+ border: 1px solid var(--rule); background: var(--paper-hi);
429
+ margin-bottom: 64px;
430
+ }
431
+ @media (max-width: 940px) { .pipeline { grid-template-columns: 1fr; } }
432
+ .pipeline li {
433
+ counter-increment: step;
434
+ padding: 26px 22px 24px; position: relative;
435
+ border-right: 1px solid var(--rule-soft);
436
+ }
437
+ .pipeline li:last-child { border-right: none; }
438
+ @media (max-width: 940px) { .pipeline li { border-right: none; border-bottom: 1px solid var(--rule-soft); } .pipeline li:last-child { border-bottom: none; } }
439
+ .pipeline li::before {
440
+ content: '0' counter(step);
441
+ font-family: var(--mono); font-size: 11px; font-weight: 600;
442
+ color: var(--red); letter-spacing: 0.12em; display: block; margin-bottom: 12px;
443
+ }
444
+ .pipeline li:not(:last-child)::after {
445
+ content: 'β†’'; position: absolute; right: -7px; top: 28px;
446
+ color: var(--red); font-size: 14px; z-index: 2;
447
+ }
448
+ @media (max-width: 940px) { .pipeline li::after { display: none; } }
449
+ .pipeline b {
450
+ display: block; font-family: var(--serif); font-size: 19px;
451
+ font-weight: 600; margin-bottom: 7px;
452
+ }
453
+ .pipeline span { font-size: 12.5px; line-height: 1.6; color: var(--ink-2); }
454
+
455
+ .task-grid {
456
+ display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px;
457
+ background: var(--rule); border: 1px solid var(--rule);
458
+ margin-bottom: 48px;
459
+ }
460
+ @media (max-width: 940px) { .task-grid { grid-template-columns: repeat(2, 1fr); } }
461
+ @media (max-width: 560px) { .task-grid { grid-template-columns: 1fr; } }
462
+ .task-card { background: var(--paper-hi); padding: 28px 24px; }
463
+ .task-code {
464
+ font-family: var(--mono); font-size: 13px; font-weight: 600;
465
+ letter-spacing: 0.1em; padding: 3px 9px; border: 1px solid currentColor;
466
+ display: inline-block; margin-bottom: 16px;
467
+ }
468
+ .task-card h3 { font-family: var(--serif); font-size: 18px; font-weight: 600; margin-bottom: 8px; letter-spacing: -0.01em; }
469
+ .task-card p { font-size: 13px; line-height: 1.65; color: var(--ink-2); margin-bottom: 16px; }
470
+ .task-n { font-family: var(--mono); font-size: 12px; color: var(--ink-3); }
471
+ .task-n b { color: var(--ink); font-weight: 600; }
472
+ .task-meter { height: 3px; background: var(--rule-soft); margin-top: 10px; overflow: hidden; }
473
+ .task-meter i { display: block; height: 100%; width: 0; transition: width 1.1s var(--ease) 0.2s; }
474
+ .shown .task-meter i { width: var(--w); }
475
+
476
+ .diff-strip { }
477
+ .diff-bar-outer {
478
+ display: flex; height: 56px; border: 1px solid var(--rule);
479
+ background: var(--paper-hi); overflow: hidden; margin-bottom: 14px;
480
+ }
481
+ .diff-seg {
482
+ width: 0; display: flex; align-items: center; justify-content: center;
483
+ transition: width 1.2s var(--ease); overflow: hidden; position: relative;
484
+ }
485
+ .shown .diff-seg { width: var(--w); }
486
+ .diff-seg span {
487
+ font-family: var(--mono); font-size: 12px; font-weight: 600; white-space: nowrap;
488
+ }
489
+ .ds-easy { background: var(--green-soft); color: var(--green); border-right: 1px solid var(--rule-soft); }
490
+ .ds-med { background: var(--gold-soft); color: var(--gold); border-right: 1px solid var(--rule-soft); }
491
+ .ds-hard { background: var(--red-soft); color: var(--red); }
492
+ .diff-note { font-size: 13px; color: var(--ink-3); }
493
+
494
+ /* ════════ PANELS (shared) ════════ */
495
+ .panel {
496
+ background: var(--paper-hi); border: 1px solid var(--rule);
497
+ padding: 32px; margin-bottom: 28px;
498
+ }
499
+ .panel-bar {
500
+ display: flex; align-items: flex-start; justify-content: space-between;
501
+ gap: 24px; flex-wrap: wrap; margin-bottom: 28px;
502
+ }
503
+ .panel-title { font-family: var(--serif); font-size: 24px; font-weight: 600; letter-spacing: -0.01em; margin-bottom: 6px; }
504
+ .panel-sub { font-size: 13.5px; color: var(--ink-3); max-width: 64ch; }
505
+
506
+ .tabset { display: inline-flex; border: 1px solid var(--rule); background: var(--paper); }
507
+ .tab {
508
+ font-family: var(--mono); font-size: 12.5px; font-weight: 500;
509
+ padding: 9px 18px; background: none; border: none; cursor: pointer;
510
+ color: var(--ink-3); border-right: 1px solid var(--rule-soft);
511
+ transition: color 0.15s, background 0.15s;
512
+ }
513
+ .tab:last-child { border-right: none; }
514
+ .tab:hover { color: var(--ink); }
515
+ .tab.active { background: var(--ink); color: var(--paper-hi); }
516
+
517
+ /* ════════ Β§04 CHART ════════ */
518
+ .chart { position: relative; padding: 8px 0 30px; }
519
+ .chart-baseline {
520
+ position: absolute; top: 0; bottom: 30px; width: 0;
521
+ border-left: 2px dashed var(--red); opacity: 0.55;
522
+ transition: left 0.9s var(--ease);
523
+ pointer-events: none; z-index: 3;
524
+ }
525
+ .chart-baseline em {
526
+ position: absolute; bottom: -26px; left: 50%; transform: translateX(-50%);
527
+ font-family: var(--mono); font-style: normal; font-size: 10.5px;
528
+ color: var(--red); white-space: nowrap;
529
+ }
530
+ .crow { display: flex; align-items: center; gap: 14px; margin-bottom: 9px; position: relative; }
531
+ .crow-label {
532
+ width: 168px; min-width: 168px; text-align: right;
533
+ display: flex; align-items: baseline; justify-content: flex-end; gap: 8px;
534
+ }
535
+ .crow-rank { font-family: var(--serif); font-style: italic; font-size: 13px; color: var(--ink-3); }
536
+ .crow-name {
537
+ font-size: 13.5px; font-weight: 600; white-space: nowrap;
538
+ overflow: hidden; text-overflow: ellipsis;
539
+ }
540
+ .crow-track { flex: 1; height: 34px; background: var(--rule-soft); position: relative; }
541
+ .crow-fill {
542
+ height: 100%; width: 0; position: relative;
543
+ transition: width 1.1s var(--ease);
544
+ display: flex; align-items: center;
545
+ }
546
+ .crow-fill::after { content: ''; position: absolute; right: 0; top: 0; bottom: 0; width: 3px; background: var(--ink); }
547
+ .t1 .crow-fill { background: var(--ink); }
548
+ .t2 .crow-fill { background: color-mix(in srgb, var(--ink) 64%, var(--paper)); }
549
+ .t3 .crow-fill { background: color-mix(in srgb, var(--ink) 40%, var(--paper)); }
550
+ .t1 .crow-fill::after { background: var(--red); }
551
+ .crow-val {
552
+ position: absolute; left: calc(100% + 10px); top: 50%; transform: translateY(-50%);
553
+ font-family: var(--mono); font-size: 12.5px; font-weight: 600;
554
+ color: var(--ink); white-space: nowrap; opacity: 0; transition: opacity 0.3s 0.7s;
555
+ }
556
+ .crow-fill.shown .crow-val { opacity: 1; }
557
+ .crow-tip {
558
+ position: absolute; bottom: calc(100% + 10px); left: 50%; transform: translateX(-50%);
559
+ background: var(--ink); color: var(--paper-hi); padding: 12px 16px;
560
+ font-size: 12px; white-space: nowrap; z-index: 50;
561
+ opacity: 0; pointer-events: none; transition: opacity 0.15s;
562
+ box-shadow: 4px 4px 0 var(--rule);
563
+ }
564
+ .crow-track:hover .crow-tip { opacity: 1; }
565
+ .crow-tip b { display: block; font-size: 13px; margin-bottom: 8px; padding-bottom: 7px; border-bottom: 1px solid rgba(251,248,241,0.25); }
566
+ .crow-tip .tt-grid { display: grid; grid-template-columns: auto auto; gap: 3px 20px; font-family: var(--mono); font-size: 11.5px; }
567
+ .crow-tip .tt-grid i { font-style: normal; color: rgba(251,248,241,0.55); }
568
+ .crow-tip .tt-ci { margin-top: 7px; font-size: 10.5px; color: rgba(251,248,241,0.5); text-align: center; }
569
+ .crow.human .crow-name { font-weight: 400; font-style: italic; color: var(--ink-2); }
570
+ .crow.human .crow-fill { background: repeating-linear-gradient(135deg, var(--rule), var(--rule) 4px, transparent 4px, transparent 8px); }
571
+ .crow.human .crow-fill::after { background: var(--ink-3); }
572
+ .chart-foot { font-size: 12.5px; color: var(--ink-3); margin-top: 24px; max-width: 70ch; }
573
+ .chart-foot strong { color: var(--ink-2); }
574
+ @media (max-width: 640px) {
575
+ .crow-label { width: 108px; min-width: 108px; }
576
+ .crow-name { font-size: 12px; }
577
+ .crow-val { display: none; }
578
+ }
579
+
580
+ /* ════════ TABLES ════════ */
581
+ .table-scroll { overflow-x: auto; }
582
+ .gz-table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
583
+ .gz-table thead tr { border-top: 2px solid var(--ink); border-bottom: 1px solid var(--ink); }
584
+ .gz-table th {
585
+ font-family: var(--mono); font-size: 11px; font-weight: 600;
586
+ text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-2);
587
+ text-align: left; padding: 12px 14px; white-space: nowrap; user-select: none;
588
+ }
589
+ .gz-table th.c, .gz-table td.c { text-align: center; }
590
+ .gz-table th.sortable { cursor: pointer; }
591
+ .gz-table th.sortable:hover { color: var(--red); }
592
+ .gz-table th.sorted { color: var(--red); }
593
+ .gz-table th.sorted::after { content: ' ↓'; }
594
+ .th-n { font-size: 9.5px; color: var(--ink-3); text-transform: none; letter-spacing: 0; }
595
+ .gz-table tbody tr { border-bottom: 1px solid var(--rule-soft); transition: background 0.12s; }
596
+ .gz-table tbody tr:hover { background: var(--red-soft); }
597
+ .gz-table td { padding: 13px 14px; white-space: nowrap; vertical-align: middle; }
598
+ .gz-table td.mono, .gz-table .mono { font-family: var(--mono); font-size: 12.5px; }
599
+ .row-hi { background: var(--green-soft); }
600
+ .row-hi:hover { background: var(--green-soft) !important; }
601
+ .cfg { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); margin-left: 6px; }
602
+ .cfg-pick {
603
+ font-family: var(--mono); font-size: 10px; font-weight: 600;
604
+ text-transform: uppercase; letter-spacing: 0.08em;
605
+ background: var(--green); color: var(--paper-hi); padding: 2px 8px; margin-left: 8px;
606
+ }
607
+
608
+ .rank-cell { font-family: var(--serif); font-style: italic; font-size: 15px; }
609
+ .rank-1 { color: var(--gold); font-weight: 700; }
610
+ .model-cell-name { font-weight: 600; font-size: 13.5px; }
611
+ .model-cell-id { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); margin-top: 2px; }
612
+ .score {
613
+ font-family: var(--mono); font-size: 12.5px; font-weight: 600;
614
+ padding: 3px 8px; display: inline-block; min-width: 56px;
615
+ }
616
+ .s-hi { background: var(--green-soft); color: var(--green); }
617
+ .s-md { background: var(--gold-soft); color: var(--gold); }
618
+ .s-lo { background: var(--red-soft); color: var(--red); }
619
+ .score-best { box-shadow: inset 0 0 0 1px currentColor; }
620
+ .access-tag {
621
+ font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.04em;
622
+ border: 1px solid var(--rule); padding: 3px 9px; color: var(--ink-2);
623
+ border-radius: 100px; white-space: nowrap;
624
+ }
625
+ .tr-human td { background: var(--paper); }
626
+ .tr-human .model-cell-name { font-style: italic; font-weight: 400; }
627
+ .tr-subset td { background: var(--gold-soft); }
628
+ .ci-cell { font-family: var(--mono); font-size: 11px; color: var(--ink-3); }
629
+ .delta-up { color: var(--green); font-weight: 600; }
630
+ .delta-down { color: var(--red); font-weight: 600; }
631
+
632
+ /* ════════ Β§05 FINDINGS ════════ */
633
+ .findings-grid {
634
+ display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px;
635
+ background: var(--rule); border: 1px solid var(--rule);
636
+ }
637
+ @media (max-width: 980px) { .findings-grid { grid-template-columns: repeat(2, 1fr); } }
638
+ @media (max-width: 640px) { .findings-grid { grid-template-columns: 1fr; } }
639
+ .finding { background: var(--paper-hi); padding: 32px 28px; position: relative; overflow: hidden; }
640
+ .finding::before {
641
+ content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px;
642
+ background: var(--red); transform: scaleX(0); transform-origin: left;
643
+ transition: transform 0.7s var(--ease) 0.15s;
644
+ }
645
+ .finding.shown::before { transform: scaleX(1); }
646
+ .f-no {
647
+ font-family: var(--mono); font-size: 10.5px; font-weight: 600;
648
+ letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink-3);
649
+ }
650
+ .f-stat {
651
+ font-family: var(--serif); font-size: clamp(30px, 3.4vw, 42px);
652
+ font-weight: 600; letter-spacing: -0.02em; color: var(--red);
653
+ margin: 10px 0 14px; line-height: 1; font-variant-numeric: tabular-nums;
654
+ }
655
+ .finding h3 { font-family: var(--serif); font-size: 18.5px; font-weight: 600; letter-spacing: -0.01em; line-height: 1.3; margin-bottom: 10px; }
656
+ .finding p:last-child { font-size: 13.5px; line-height: 1.7; color: var(--ink-2); }
657
+
658
+ /* ════════ Β§06 RETRIEVAL ════════ */
659
+ .rag-diagram { margin-bottom: 28px; border: 1px solid var(--rule); background: var(--paper-hi); padding: 24px 16px; }
660
+ .rag-diagram svg { width: 100%; height: auto; display: block; color: var(--ink-3); }
661
+ .rd-node rect { fill: var(--paper); stroke: var(--ink); stroke-width: 1.25; }
662
+ .rd-dense rect { stroke: var(--green); } .rd-dense .rd-t1 { fill: var(--green); }
663
+ .rd-sparse rect { stroke: var(--red); } .rd-sparse .rd-t1 { fill: var(--red); }
664
+ .rd-rrf rect { fill: var(--ink); stroke: var(--ink); }
665
+ .rd-rrf .rd-t1, .rd-rrf .rd-t2 { fill: var(--paper-hi); }
666
+ .rd-t1 { font-family: var(--sans); font-size: 15px; font-weight: 600; fill: var(--ink); }
667
+ .rd-t2 { font-family: var(--mono); font-size: 10px; fill: var(--ink-3); }
668
+ .rd-rrf .rd-t2 { fill: rgba(251,248,241,0.6); }
669
+ .rd-flow {
670
+ fill: none; stroke: var(--ink-3); stroke-width: 1.25;
671
+ stroke-dasharray: 5 5;
672
+ }
673
+ .shown .rd-flow { animation: flow 1.2s linear infinite; }
674
+ @keyframes flow { to { stroke-dashoffset: -20; } }
675
+
676
+ .panel-live { border-color: var(--ink); box-shadow: 5px 5px 0 var(--rule-soft); }
677
+ .live-dot {
678
+ display: inline-block; width: 9px; height: 9px; border-radius: 50%;
679
+ background: var(--green); margin-right: 10px; vertical-align: 2px;
680
+ animation: pulse 2.2s ease infinite;
681
+ }
682
+ @keyframes pulse { 0%,100% { box-shadow: 0 0 0 0 var(--green-soft); } 50% { box-shadow: 0 0 0 7px var(--green-soft); } }
683
+ .rag-row { display: flex; gap: 10px; margin-bottom: 14px; }
684
+ .rag-input { flex: 1; }
685
+ @media (max-width: 560px) { .rag-row { flex-direction: column; } }
686
+ .rag-examples { display: flex; flex-wrap: wrap; gap: 8px; }
687
+ .rag-out { margin-top: 26px; border-top: 1px solid var(--rule); padding-top: 22px; }
688
+ .rag-status { display: flex; align-items: center; gap: 10px; font-family: var(--mono); font-size: 12.5px; color: var(--ink-2); margin-bottom: 14px; }
689
+ .spinner {
690
+ width: 14px; height: 14px; border: 2px solid var(--rule);
691
+ border-top-color: var(--red); border-radius: 50%;
692
+ animation: spin 0.8s linear infinite;
693
+ }
694
+ @keyframes spin { to { transform: rotate(360deg); } }
695
+ .rag-answer { font-size: 15px; line-height: 1.85; color: var(--ink); white-space: pre-wrap; margin-bottom: 22px; }
696
+ .rag-answer:empty { margin: 0; }
697
+ .rag-sources { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; }
698
+ .rag-src {
699
+ border: 1px solid var(--rule); background: var(--paper); padding: 16px;
700
+ }
701
+ .rag-src-no {
702
+ font-family: var(--mono); font-size: 10px; font-weight: 600;
703
+ letter-spacing: 0.14em; text-transform: uppercase; color: var(--red);
704
+ margin-bottom: 6px;
705
+ }
706
+ .rag-src-title { font-size: 13px; font-weight: 600; line-height: 1.4; margin-bottom: 8px; }
707
+ .rag-src-text {
708
+ font-size: 12px; line-height: 1.65; color: var(--ink-2);
709
+ display: -webkit-box; -webkit-line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden;
710
+ margin-bottom: 12px;
711
+ }
712
+ .rag-src-scores {
713
+ display: flex; gap: 12px; font-family: var(--mono); font-size: 10px;
714
+ color: var(--ink-3); border-top: 1px solid var(--rule-soft); padding-top: 9px;
715
+ }
716
+ .rag-src-scores b { color: var(--ink-2); font-weight: 600; }
717
+
718
+ /* ════════ Β§07 ACCESS ════════ */
719
+ .explorer-controls { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
720
+ .explorer-controls .select { width: auto; }
721
+ .ex-card { border-top: 1px solid var(--rule); padding-top: 24px; }
722
+ .ex-meta { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 18px; }
723
+ .ex-id { font-size: 11px; color: var(--ink-3); }
724
+ .ex-badge {
725
+ font-family: var(--mono); font-size: 10.5px; font-weight: 600;
726
+ text-transform: uppercase; letter-spacing: 0.08em;
727
+ border: 1px solid var(--rule); padding: 3px 10px; color: var(--ink-2);
728
+ }
729
+ .ex-label {
730
+ font-family: var(--mono); font-size: 10.5px; font-weight: 600;
731
+ letter-spacing: 0.14em; text-transform: uppercase; color: var(--red);
732
+ margin-bottom: 7px;
733
+ }
734
+ .ex-context {
735
+ font-family: var(--serif); font-size: 16px; line-height: 1.8; color: var(--ink-2);
736
+ border-left: 2px solid var(--rule); padding: 4px 0 4px 20px; margin-bottom: 20px;
737
+ }
738
+ .ex-question { font-size: 16px; font-weight: 600; margin-bottom: 20px; }
739
+ .ex-answer { margin-top: 18px; background: var(--green-soft); border: 1px solid var(--green); padding: 16px 20px; }
740
+ .ex-answer .mono { font-size: 14px; color: var(--green); font-weight: 600; }
741
+
742
+ .access-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; align-items: start; }
743
+ @media (max-width: 880px) { .access-grid { grid-template-columns: 1fr; } }
744
+ .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
745
+ @media (max-width: 560px) { .form-grid { grid-template-columns: 1fr; } }
746
+ .field { display: flex; flex-direction: column; gap: 6px; }
747
+ .field > span { font-size: 12.5px; font-weight: 600; color: var(--ink-2); }
748
+ .field em { color: var(--red); font-style: normal; }
749
+ .form-foot { display: flex; flex-direction: column; gap: 14px; }
750
+ .status { font-size: 13px; line-height: 1.6; padding: 12px 16px; border: 1px solid var(--rule); white-space: pre-line; }
751
+ .status.ok { border-color: var(--green); background: var(--green-soft); color: var(--green); }
752
+ .status.err { border-color: var(--red); background: var(--red-soft); color: var(--red); }
753
+
754
+ .cite-box { position: relative; background: var(--paper); border: 1px solid var(--rule); padding: 18px 20px; margin-bottom: 18px; }
755
+ .cite-box pre { font-size: 11.5px; line-height: 1.8; overflow-x: auto; color: var(--ink-2); }
756
+ .copy-btn {
757
+ position: absolute; top: 10px; right: 10px;
758
+ font-family: var(--mono); font-size: 11px; font-weight: 600;
759
+ padding: 5px 12px; background: var(--paper-hi); border: 1px solid var(--rule);
760
+ cursor: pointer; color: var(--ink-2); transition: color 0.15s, border-color 0.15s;
761
+ }
762
+ .copy-btn:hover { color: var(--red); border-color: var(--red); }
763
+ .access-links { display: flex; gap: 10px; flex-wrap: wrap; }
764
+
765
+ /* ════════ FOOTER ════════ */
766
+ .footer { padding: 64px 24px 56px; text-align: center; border-top: 1px solid var(--rule); position: relative; }
767
+ .footer-rule {
768
+ width: 64px; height: 3px; background: var(--red); margin: 0 auto 28px;
769
+ }
770
+ .footer-brand { font-family: var(--serif); font-size: 22px; font-weight: 600; margin-bottom: 10px; }
771
+ .footer-brand em { font-style: italic; color: var(--red); }
772
+ .footer-brand .brand-seal { font-size: 18px; margin-right: 6px; }
773
+ .footer-line { font-size: 14px; color: var(--ink-2); margin-bottom: 6px; }
774
+ .footer-line a { color: var(--red); text-decoration: none; }
775
+ .footer-line a:hover { text-decoration: underline; }
776
+ .footer-sub { font-family: var(--mono); font-size: 11px; color: var(--ink-3); letter-spacing: 0.04em; }
777
+
778
+ /* ════════ REVEAL ════════ */
779
+ .reveal {
780
+ opacity: 0; transform: translateY(26px);
781
+ transition: opacity 0.8s var(--ease), transform 0.8s var(--ease);
782
+ }
783
+ .reveal.shown { opacity: 1; transform: translateY(0); }
784
+
785
+ /* ════════ RESPONSIVE / MOTION ════════ */
786
+ @media (max-width: 1060px) {
787
+ .mast-nav { display: none; }
788
+ }
789
+ @media (max-width: 680px) {
790
+ .mast-actions .mast-btn { display: none; }
791
+ .mast-menu { display: flex; }
792
+ .chapter { padding: 72px 18px; }
793
+ .hero { padding-top: 130px; }
794
+ .panel { padding: 22px 18px; }
795
+ .spec-body { padding: 26px 22px; }
796
+ .spec-body mark { white-space: normal; }
797
+ }
798
+
799
+ @media (prefers-reduced-motion: reduce) {
800
+ html { scroll-behavior: auto; }
801
+ *, *::before, *::after {
802
+ animation-duration: 0.01ms !important;
803
+ animation-iteration-count: 1 !important;
804
+ transition-duration: 0.01ms !important;
805
+ }
806
+ .reveal { opacity: 1; transform: none; }
807
+ .crow-fill, .diff-seg, .task-meter i { width: var(--w, 100%); }
808
+ }
demo/static/js/archive-scene.js ADDED
@@ -0,0 +1,409 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ════════════════════════════════════════════════════════════════════
2
+ IndiaFinBench β€” archive-scene.js
3
+ "The Archive Assembles": 192 regulatory documents rendered as ruled
4
+ paper cards in WebGL. Scroll morphs the field through three states β€”
5
+ drifting cloud (hero) β†’ tangled supersession chains (Β§01) β†’ ordered
6
+ archive wall, SEBI block then RBI block (Β§02) β€” then dissolves.
7
+ Raw WebGL + GLSL, no dependencies. Degrades silently without WebGL.
8
+ ════════════════════════════════════════════════════════════════════ */
9
+ (function () {
10
+ 'use strict';
11
+
12
+ var canvas = document.getElementById('archiveCanvas');
13
+ if (!canvas) return;
14
+ var gl = canvas.getContext('webgl', { alpha: true, antialias: true, premultipliedAlpha: false });
15
+ if (!gl) { canvas.remove(); return; }
16
+
17
+ var REDUCED = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
18
+ var N_DOCS = 192, N_SEBI = 92;
19
+ var N_MOTES = window.innerWidth < 700 ? 110 : 240;
20
+
21
+ /* ── Shaders ──────────────────────────────────────────────────────── */
22
+ var DOC_VS = [
23
+ 'attribute vec3 aCloud;',
24
+ 'attribute vec3 aTangle;',
25
+ 'attribute vec3 aGrid;',
26
+ 'attribute vec2 aCorner;',
27
+ 'attribute vec2 aMeta;', // x: kind (0 SEBI / 1 RBI), y: seed
28
+ 'uniform mat4 uProj;',
29
+ 'uniform mat4 uView;',
30
+ 'uniform float uT1;',
31
+ 'uniform float uT2;',
32
+ 'uniform float uTime;',
33
+ 'uniform float uSize;',
34
+ 'varying vec2 vUv;',
35
+ 'varying float vKind;',
36
+ 'varying float vFade;',
37
+ 'void main(){',
38
+ ' vec3 p = mix(aCloud, aTangle, uT1);',
39
+ ' p = mix(p, aGrid, uT2);',
40
+ ' float s = aMeta.y * 43.7;',
41
+ ' float calm = 1.0 - uT2 * 0.85;',
42
+ ' p += vec3(sin(uTime*0.40+s), cos(uTime*0.31+s*1.7), sin(uTime*0.23+s*2.3)) * 0.09 * calm;',
43
+ ' vec4 mv = uView * vec4(p, 1.0);',
44
+ ' mv.xy += aCorner * vec2(uSize, uSize*1.36);',
45
+ ' gl_Position = uProj * mv;',
46
+ ' vUv = aCorner * 0.5 + 0.5;',
47
+ ' vKind = aMeta.x;',
48
+ ' vFade = clamp(1.0 - (-mv.z - 4.0) / 13.0, 0.10, 0.85);',
49
+ '}'
50
+ ].join('\n');
51
+
52
+ var DOC_FS = [
53
+ 'precision mediump float;',
54
+ 'varying vec2 vUv;',
55
+ 'varying float vKind;',
56
+ 'varying float vFade;',
57
+ 'uniform float uAlpha;',
58
+ 'void main(){',
59
+ ' vec2 d = min(vUv, 1.0 - vUv);',
60
+ ' float border = 1.0 - step(0.07, min(d.x, d.y));',
61
+ // three ruled "text lines" inside the card
62
+ ' float lines = 0.0;',
63
+ ' if (vUv.x > 0.18 && vUv.x < 0.82 && vUv.y > 0.22 && vUv.y < 0.80) {',
64
+ ' lines = step(fract(vUv.y * 4.6), 0.14);',
65
+ ' }',
66
+ ' vec3 ink = vec3(0.110, 0.094, 0.071);',
67
+ ' vec3 sebi = vec3(0.122, 0.361, 0.271);',
68
+ ' vec3 rbi = vec3(0.639, 0.231, 0.125);',
69
+ ' vec3 paper = vec3(0.961, 0.945, 0.910);',
70
+ ' vec3 tint = mix(mix(sebi, rbi, vKind), paper, 0.30);', // muted toward paper
71
+ ' vec3 col = mix(ink, tint, border);',
72
+ ' float a = border * 0.42 + lines * 0.13 + 0.03;',
73
+ ' gl_FragColor = vec4(col, a * uAlpha * vFade);',
74
+ '}'
75
+ ].join('\n');
76
+
77
+ var LINE_VS = [
78
+ 'attribute vec3 aCloud;',
79
+ 'attribute vec3 aTangle;',
80
+ 'attribute vec3 aGrid;',
81
+ 'uniform mat4 uProj;',
82
+ 'uniform mat4 uView;',
83
+ 'uniform float uT1;',
84
+ 'uniform float uT2;',
85
+ 'void main(){',
86
+ ' vec3 p = mix(aCloud, aTangle, uT1);',
87
+ ' p = mix(p, aGrid, uT2);',
88
+ ' gl_Position = uProj * uView * vec4(p, 1.0);',
89
+ '}'
90
+ ].join('\n');
91
+
92
+ var LINE_FS = [
93
+ 'precision mediump float;',
94
+ 'uniform float uAlpha;',
95
+ 'void main(){ gl_FragColor = vec4(0.110, 0.094, 0.071, uAlpha); }'
96
+ ].join('\n');
97
+
98
+ var MOTE_VS = [
99
+ 'attribute vec3 aPos;',
100
+ 'attribute float aSeed;',
101
+ 'uniform mat4 uProj;',
102
+ 'uniform mat4 uView;',
103
+ 'uniform float uTime;',
104
+ 'void main(){',
105
+ ' vec3 p = aPos;',
106
+ ' float s = aSeed * 61.3;',
107
+ ' p += vec3(sin(uTime*0.18+s), cos(uTime*0.14+s*1.3), 0.0) * 0.45;',
108
+ ' vec4 mv = uView * vec4(p, 1.0);',
109
+ ' gl_Position = uProj * mv;',
110
+ ' gl_PointSize = clamp(36.0 / -mv.z, 1.0, 3.2);',
111
+ '}'
112
+ ].join('\n');
113
+
114
+ var MOTE_FS = [
115
+ 'precision mediump float;',
116
+ 'uniform float uAlpha;',
117
+ 'void main(){ gl_FragColor = vec4(0.110, 0.094, 0.071, 0.09 * uAlpha); }'
118
+ ].join('\n');
119
+
120
+ function compile(type, src) {
121
+ var sh = gl.createShader(type);
122
+ gl.shaderSource(sh, src);
123
+ gl.compileShader(sh);
124
+ if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
125
+ throw new Error(gl.getShaderInfoLog(sh) || 'shader compile failed');
126
+ }
127
+ return sh;
128
+ }
129
+ function program(vs, fs) {
130
+ var p = gl.createProgram();
131
+ gl.attachShader(p, compile(gl.VERTEX_SHADER, vs));
132
+ gl.attachShader(p, compile(gl.FRAGMENT_SHADER, fs));
133
+ gl.linkProgram(p);
134
+ if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
135
+ throw new Error(gl.getProgramInfoLog(p) || 'program link failed');
136
+ }
137
+ return p;
138
+ }
139
+
140
+ var docProg, lineProg, moteProg;
141
+ try {
142
+ docProg = program(DOC_VS, DOC_FS);
143
+ lineProg = program(LINE_VS, LINE_FS);
144
+ moteProg = program(MOTE_VS, MOTE_FS);
145
+ } catch (e) { canvas.remove(); return; }
146
+
147
+ /* ── Formations ───────────────────────────────────────────────────── */
148
+ var rand = (function () { // deterministic, so the scene is identical every visit
149
+ var s = 1337;
150
+ return function () { s = (s * 16807) % 2147483647; return (s - 1) / 2147483646; };
151
+ })();
152
+
153
+ var cloud = new Float32Array(N_DOCS * 3);
154
+ var tangle = new Float32Array(N_DOCS * 3);
155
+ var grid = new Float32Array(N_DOCS * 3);
156
+
157
+ // cloud: drifting halo around the text column β€” the centre stays empty
158
+ // so the hero headline and chapter prose are never occluded
159
+ for (var i = 0; i < N_DOCS; i++) {
160
+ var x, y;
161
+ do {
162
+ x = (rand() * 2 - 1) * 9.5;
163
+ y = (rand() * 2 - 1) * 4.6;
164
+ } while (Math.abs(x) < 3.6 && Math.abs(y) < 3.0); // keep-out rectangle
165
+ cloud[i * 3] = x;
166
+ cloud[i * 3 + 1] = y;
167
+ cloud[i * 3 + 2] = -9.0 - rand() * 4.5;
168
+ }
169
+
170
+ // tangle: 12 supersession chains β€” random walks knotting deep and to the
171
+ // right, clear of the reading column
172
+ var CHAINS = 12, perChain = Math.ceil(N_DOCS / CHAINS), idx = 0;
173
+ for (var c = 0; c < CHAINS; c++) {
174
+ var x = 4.4 + (rand() - 0.5) * 4.6, y = (rand() - 0.5) * 4.2, z = -11.5 + (rand() - 0.5) * 2.5;
175
+ for (var k = 0; k < perChain && idx < N_DOCS; k++, idx++) {
176
+ x += (rand() - 0.5) * 1.4 - (x - 4.4) * 0.08;
177
+ y += (rand() - 0.5) * 1.1 - y * 0.06;
178
+ z += (rand() - 0.5) * 0.8;
179
+ tangle[idx * 3] = x; tangle[idx * 3 + 1] = y; tangle[idx * 3 + 2] = z;
180
+ }
181
+ }
182
+
183
+ // grid: 16 Γ— 12 archive wall β€” SEBI block fills first, RBI block after.
184
+ // Sits deep so it reads as a watermark, not a competitor to the copy.
185
+ var COLS = 16, SX = 0.86, SY = 1.04;
186
+ for (i = 0; i < N_DOCS; i++) {
187
+ var col = i % COLS, row = Math.floor(i / COLS);
188
+ grid[i * 3] = (col - (COLS - 1) / 2) * SX + 2.2;
189
+ grid[i * 3 + 1] = ((11 - row) - 5.5) * SY * 0.62;
190
+ grid[i * 3 + 2] = -13.5 + (rand() - 0.5) * 0.3;
191
+ }
192
+
193
+ /* ── Doc quad buffers (6 verts per doc) ───────────────────────────── */
194
+ var V = N_DOCS * 6;
195
+ var bCloud = new Float32Array(V * 3), bTangle = new Float32Array(V * 3),
196
+ bGrid = new Float32Array(V * 3), bCorner = new Float32Array(V * 2),
197
+ bMeta = new Float32Array(V * 2);
198
+ var CORNERS = [-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1];
199
+ for (i = 0; i < N_DOCS; i++) {
200
+ var kind = i < N_SEBI ? 0 : 1, seed = rand();
201
+ for (var v = 0; v < 6; v++) {
202
+ var o = i * 6 + v;
203
+ bCloud.set([cloud[i * 3], cloud[i * 3 + 1], cloud[i * 3 + 2]], o * 3);
204
+ bTangle.set([tangle[i * 3], tangle[i * 3 + 1], tangle[i * 3 + 2]], o * 3);
205
+ bGrid.set([grid[i * 3], grid[i * 3 + 1], grid[i * 3 + 2]], o * 3);
206
+ bCorner.set([CORNERS[v * 2], CORNERS[v * 2 + 1]], o * 2);
207
+ bMeta.set([kind, seed], o * 2);
208
+ }
209
+ }
210
+
211
+ /* ── Chain line buffers (consecutive docs within each chain) ──────── */
212
+ var linePairs = [];
213
+ idx = 0;
214
+ for (c = 0; c < CHAINS; c++) {
215
+ for (k = 0; k < perChain - 1 && idx + 1 < N_DOCS; k++, idx++) linePairs.push(idx, idx + 1);
216
+ idx++;
217
+ }
218
+ var L = linePairs.length;
219
+ var lCloud = new Float32Array(L * 3), lTangle = new Float32Array(L * 3), lGrid = new Float32Array(L * 3);
220
+ for (i = 0; i < L; i++) {
221
+ var di = linePairs[i];
222
+ lCloud.set([cloud[di * 3], cloud[di * 3 + 1], cloud[di * 3 + 2]], i * 3);
223
+ lTangle.set([tangle[di * 3], tangle[di * 3 + 1], tangle[di * 3 + 2]], i * 3);
224
+ lGrid.set([grid[di * 3], grid[di * 3 + 1], grid[di * 3 + 2]], i * 3);
225
+ }
226
+
227
+ /* ── Motes ────────────────────────────────────────────────────────── */
228
+ var mPos = new Float32Array(N_MOTES * 3), mSeed = new Float32Array(N_MOTES);
229
+ for (i = 0; i < N_MOTES; i++) {
230
+ mPos[i * 3] = (rand() - 0.5) * 18;
231
+ mPos[i * 3 + 1] = (rand() - 0.5) * 9;
232
+ mPos[i * 3 + 2] = -6 - rand() * 9;
233
+ mSeed[i] = rand();
234
+ }
235
+
236
+ function buf(data) {
237
+ var b = gl.createBuffer();
238
+ gl.bindBuffer(gl.ARRAY_BUFFER, b);
239
+ gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
240
+ return b;
241
+ }
242
+ var docBufs = { cloud: buf(bCloud), tangle: buf(bTangle), grid: buf(bGrid), corner: buf(bCorner), meta: buf(bMeta) };
243
+ var lineBufs = { cloud: buf(lCloud), tangle: buf(lTangle), grid: buf(lGrid) };
244
+ var moteBufs = { pos: buf(mPos), seed: buf(mSeed) };
245
+
246
+ /* ── Matrices ─────────────────────────────────────────────────────── */
247
+ function perspective(fovy, aspect, near, far) {
248
+ var f = 1 / Math.tan(fovy / 2), nf = 1 / (near - far);
249
+ return new Float32Array([
250
+ f / aspect, 0, 0, 0,
251
+ 0, f, 0, 0,
252
+ 0, 0, (far + near) * nf, -1,
253
+ 0, 0, 2 * far * near * nf, 0
254
+ ]);
255
+ }
256
+ function viewMatrix(rx, ry) {
257
+ var cx = Math.cos(rx), sx = Math.sin(rx), cy = Math.cos(ry), sy = Math.sin(ry);
258
+ // rotateX(rx) * rotateY(ry), column-major
259
+ return new Float32Array([
260
+ cy, sx * sy, -cx * sy, 0,
261
+ 0, cx, sx, 0,
262
+ sy, -sx * cy, cx * cy, 0,
263
+ 0, 0, 0, 1
264
+ ]);
265
+ }
266
+
267
+ /* ── Uniform/attribute lookups ────────────────────────────────────── */
268
+ function locs(prog, attrs, unis) {
269
+ var out = { a: {}, u: {} };
270
+ attrs.forEach(function (n) { out.a[n] = gl.getAttribLocation(prog, n); });
271
+ unis.forEach(function (n) { out.u[n] = gl.getUniformLocation(prog, n); });
272
+ return out;
273
+ }
274
+ var docL = locs(docProg, ['aCloud', 'aTangle', 'aGrid', 'aCorner', 'aMeta'],
275
+ ['uProj', 'uView', 'uT1', 'uT2', 'uTime', 'uSize', 'uAlpha']);
276
+ var lineL = locs(lineProg, ['aCloud', 'aTangle', 'aGrid'], ['uProj', 'uView', 'uT1', 'uT2', 'uAlpha']);
277
+ var moteL = locs(moteProg, ['aPos', 'aSeed'], ['uProj', 'uView', 'uTime', 'uAlpha']);
278
+
279
+ function attrib(loc, b, size) {
280
+ gl.bindBuffer(gl.ARRAY_BUFFER, b);
281
+ gl.enableVertexAttribArray(loc);
282
+ gl.vertexAttribPointer(loc, size, gl.FLOAT, false, 0, 0);
283
+ }
284
+
285
+ /* ── State ────────────────────────────────────────────────────────── */
286
+ var proj, W = 0, H = 0;
287
+ var mouseX = 0, mouseY = 0, rotX = 0, rotY = 0;
288
+ var problemEl = document.getElementById('problem');
289
+ var corpusEl = document.getElementById('corpus');
290
+
291
+ function resize() {
292
+ var dpr = Math.min(window.devicePixelRatio || 1, 2);
293
+ W = window.innerWidth; H = window.innerHeight;
294
+ canvas.width = W * dpr; canvas.height = H * dpr;
295
+ gl.viewport(0, 0, canvas.width, canvas.height);
296
+ proj = perspective(50 * Math.PI / 180, W / H, 0.1, 60);
297
+ }
298
+ resize();
299
+ window.addEventListener('resize', resize);
300
+
301
+ if (!REDUCED) {
302
+ window.addEventListener('pointermove', function (e) {
303
+ mouseX = (e.clientX / W) * 2 - 1;
304
+ mouseY = (e.clientY / H) * 2 - 1;
305
+ }, { passive: true });
306
+ }
307
+
308
+ function smooth(t) { return t * t * (3 - 2 * t); }
309
+ function sectionT(el, span) {
310
+ if (!el) return 0;
311
+ var r = el.getBoundingClientRect();
312
+ return smooth(Math.max(0, Math.min(1, (H - r.top) / (H * span))));
313
+ }
314
+
315
+ function sceneAlpha() {
316
+ if (!corpusEl) return 1;
317
+ var r = corpusEl.getBoundingClientRect();
318
+ // fade out once the corpus section's bottom rises past 70% of the viewport
319
+ var f = Math.max(0, Math.min(1, (H * 0.7 - r.bottom) / (H * 0.45)));
320
+ return 1 - f;
321
+ }
322
+
323
+ /* ── Render ───────────────────────────────────────────────────────── */
324
+ gl.enable(gl.BLEND);
325
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
326
+ gl.clearColor(0, 0, 0, 0);
327
+
328
+ function draw(time, t1, t2, alpha) {
329
+ gl.clear(gl.COLOR_BUFFER_BIT);
330
+ if (alpha <= 0.005) return;
331
+ var view = viewMatrix(rotX, rotY);
332
+
333
+ // motes
334
+ gl.useProgram(moteProg);
335
+ attrib(moteL.a.aPos, moteBufs.pos, 3);
336
+ attrib(moteL.a.aSeed, moteBufs.seed, 1);
337
+ gl.uniformMatrix4fv(moteL.u.uProj, false, proj);
338
+ gl.uniformMatrix4fv(moteL.u.uView, false, view);
339
+ gl.uniform1f(moteL.u.uTime, time);
340
+ gl.uniform1f(moteL.u.uAlpha, alpha);
341
+ gl.drawArrays(gl.POINTS, 0, N_MOTES);
342
+
343
+ // chain lines: visible only inside the tangle phase
344
+ var lineAlpha = alpha * t1 * (1 - t2) * 0.11;
345
+ if (lineAlpha > 0.004) {
346
+ gl.useProgram(lineProg);
347
+ attrib(lineL.a.aCloud, lineBufs.cloud, 3);
348
+ attrib(lineL.a.aTangle, lineBufs.tangle, 3);
349
+ attrib(lineL.a.aGrid, lineBufs.grid, 3);
350
+ gl.uniformMatrix4fv(lineL.u.uProj, false, proj);
351
+ gl.uniformMatrix4fv(lineL.u.uView, false, view);
352
+ gl.uniform1f(lineL.u.uT1, t1);
353
+ gl.uniform1f(lineL.u.uT2, t2);
354
+ gl.uniform1f(lineL.u.uAlpha, lineAlpha);
355
+ gl.drawArrays(gl.LINES, 0, L);
356
+ }
357
+
358
+ // documents
359
+ gl.useProgram(docProg);
360
+ attrib(docL.a.aCloud, docBufs.cloud, 3);
361
+ attrib(docL.a.aTangle, docBufs.tangle, 3);
362
+ attrib(docL.a.aGrid, docBufs.grid, 3);
363
+ attrib(docL.a.aCorner, docBufs.corner, 2);
364
+ attrib(docL.a.aMeta, docBufs.meta, 2);
365
+ gl.uniformMatrix4fv(docL.u.uProj, false, proj);
366
+ gl.uniformMatrix4fv(docL.u.uView, false, view);
367
+ gl.uniform1f(docL.u.uT1, t1);
368
+ gl.uniform1f(docL.u.uT2, t2);
369
+ gl.uniform1f(docL.u.uTime, time);
370
+ var small = W < 700;
371
+ gl.uniform1f(docL.u.uSize, (small ? 0.115 : 0.16) + t2 * 0.08);
372
+ gl.uniform1f(docL.u.uAlpha, alpha * (small ? 0.55 : 0.85) * (1 - t2 * 0.3));
373
+ gl.drawArrays(gl.TRIANGLES, 0, V);
374
+ }
375
+
376
+ if (REDUCED) {
377
+ // single static frame: mid-cloud, no motion, no listeners beyond resize redraw
378
+ var staticDraw = function () { draw(0, 0, 0, 0.9); };
379
+ staticDraw();
380
+ window.addEventListener('resize', staticDraw);
381
+ return;
382
+ }
383
+
384
+ var running = false;
385
+ function frame(now) {
386
+ if (!running) return;
387
+ var t = now * 0.001;
388
+ rotY += ((mouseX * 0.10) - rotY) * 0.04;
389
+ rotX += ((mouseY * 0.06) - rotX) * 0.04;
390
+ var t1 = sectionT(problemEl, 0.95);
391
+ var t2 = sectionT(corpusEl, 0.85);
392
+ draw(t, t1, t2, sceneAlpha());
393
+ requestAnimationFrame(frame);
394
+ }
395
+ function startLoop() {
396
+ if (running) return;
397
+ running = true;
398
+ requestAnimationFrame(frame);
399
+ }
400
+
401
+ // one immediate frame so the scene exists even before the loop starts
402
+ draw(0, sectionT(problemEl, 0.95), sectionT(corpusEl, 0.85), sceneAlpha());
403
+
404
+ if (document.visibilityState === 'visible') startLoop();
405
+ document.addEventListener('visibilitychange', function () {
406
+ if (document.hidden) running = false;
407
+ else startLoop();
408
+ });
409
+ })();
demo/static/js/data.js ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ════════════════════════════════════════════════════════════════════
2
+ IndiaFinBench β€” data.js
3
+ Canonical frontend leaderboard data. Mirrors results/aggregate/
4
+ all_model_results.csv (Table 6) β€” do not edit numbers by hand.
5
+ ════════════════════════════════════════════════════════════════════ */
6
+
7
+ window.IFB_MODELS = [
8
+ { rank: 1, label: "Gemini 2.5 Flash", hf_id: "google/gemini-2.5-flash", params: "β€”", type: "Frontier API", overall: 89.7, reg: 93.1, num: 84.8, con: 88.7, tmp: 88.5, ci: "[86.3%, 92.3%]", n_items: 406, tier: 1 },
9
+ { rank: 2, label: "Qwen3-32B", hf_id: "Qwen/Qwen3-32B", params: "32B", type: "Open-weight API", overall: 85.5, reg: 85.1, num: 77.2, con: 90.3, tmp: 92.3, ci: "[81.7%, 88.6%]", n_items: 406, tier: 1 },
10
+ { rank: 3, label: "LLaMA-3.3-70B", hf_id: "meta-llama/Llama-3.3-70B-Versatile", params: "70B", type: "Open-weight API", overall: 83.7, reg: 86.2, num: 75.0, con: 95.2, tmp: 79.5, ci: "[79.8%, 87.0%]", n_items: 406, tier: 1 },
11
+ { rank: 4, label: "Llama 4 Scout 17B", hf_id: "meta-llama/Llama-4-Scout-17B", params: "17B", type: "Open-weight API", overall: 83.3, reg: 86.2, num: 66.3, con: 98.4, tmp: 84.6, ci: "[79.3%, 86.6%]", n_items: 406, tier: 1 },
12
+ { rank: 5, label: "Kimi K2", hf_id: "moonshotai/Kimi-K2", params: "1T (MoE, 32B active)", type: "Frontier API", overall: 81.5, reg: 89.1, num: 65.2, con: 91.9, tmp: 75.6, ci: "[77.5%, 85.0%]", n_items: 406, tier: 1 },
13
+ { rank: 6, label: "LLaMA-3-8B", hf_id: "meta-llama/Meta-Llama-3-8B-Instruct", params: "8B", type: "Local (Ollama)", overall: 78.1, reg: 79.9, num: 64.1, con: 93.5, tmp: 78.2, ci: "[73.8%, 81.8%]", n_items: 406, tier: 2 },
14
+ { rank: 7, label: "GPT-OSS 120B", hf_id: "openai/gpt-oss-120b", params: "120B", type: "Open-weight API", overall: 77.1, reg: 79.9, num: 59.8, con: 95.2, tmp: 76.9, ci: "[72.8%, 80.9%]", n_items: 406, tier: 2 },
15
+ { rank: 8, label: "GPT-OSS 20B", hf_id: "openai/gpt-oss-20b", params: "20B", type: "Open-weight API", overall: 76.8, reg: 79.9, num: 58.7, con: 95.2, tmp: 76.9, ci: "[72.5%, 80.7%]", n_items: 406, tier: 2 },
16
+ { rank: 9, label: "Gemini 2.5 Pro", hf_id: "google/gemini-2.5-pro", params: "β€”", type: "Frontier API", overall: 76.1, reg: 89.7, num: 48.9, con: 93.5, tmp: 64.1, ci: "[71.7%, 80.0%]", n_items: 406, tier: 2 },
17
+ { rank: 10, label: "Mistral-7B", hf_id: "mistralai/Mistral-7B-Instruct-v0.3", params: "7B", type: "Local (Ollama)", overall: 75.9, reg: 79.9, num: 66.3, con: 80.6, tmp: 74.4, ci: "[71.5%, 79.8%]", n_items: 406, tier: 2 },
18
+ { rank: 11, label: "DeepSeek R1 70B", hf_id: "deepseek-ai/DeepSeek-R1-Distill-Llama-70B", params: "70B", type: "Reasoning API", overall: 75.1, reg: 72.4, num: 69.6, con: 96.8, tmp: 70.5, ci: "[70.7%, 79.1%]", n_items: 406, tier: 2 },
19
+ { rank: 12, label: "Gemma 4 E4B", hf_id: "google/gemma-4-e4b", params: "4B", type: "Local (Ollama)", overall: 70.4, reg: 83.9, num: 50.0, con: 72.6, tmp: 62.8, ci: "[65.8%, 74.7%]", n_items: 406, tier: 3 }
20
+ ];
21
+
22
+ window.IFB_HUMAN = {
23
+ rank: "β€”", label: "Human Expert", hf_id: "β€” (n=100 sampled items)",
24
+ params: "β€”", type: "Human Baseline",
25
+ overall: 69.0, reg: 55.6, num: 44.4, con: 83.3, tmp: 66.7,
26
+ ci: "[59.4%, 77.2%]", n_items: 100, tier: 0, is_human: true
27
+ };
28
+
29
+ window.IFB_CLAUDE = {
30
+ rank: "†", label: "†Claude 3 Haiku", hf_id: "anthropic/claude-3-haiku (150-item subset)",
31
+ params: "β€”", type: "Frontier API†",
32
+ overall: 91.3, reg: 92.5, num: 93.8, con: 86.7, tmp: 91.4,
33
+ ci: "[85.7%, 94.9%]", n_items: 150, is_subset: true
34
+ };
35
+
36
+ window.IFB_DIFF = [
37
+ { label: "Gemini 2.5 Flash", easy: 92.5, med: 89.0, hard: 84.4 },
38
+ { label: "Qwen3-32B", easy: 81.9, med: 87.9, hard: 87.5 },
39
+ { label: "LLaMA-3.3-70B", easy: 79.4, med: 85.2, hard: 90.6 },
40
+ { label: "Llama 4 Scout 17B", easy: 82.5, med: 81.9, hard: 89.1 },
41
+ { label: "Kimi K2", easy: 81.9, med: 80.8, hard: 82.8 },
42
+ { label: "LLaMA-3-8B", easy: 76.2, med: 79.7, hard: 78.1 },
43
+ { label: "GPT-OSS 120B", easy: 79.4, med: 76.4, hard: 73.4 },
44
+ { label: "GPT-OSS 20B", easy: 75.0, med: 79.7, hard: 73.4 },
45
+ { label: "Gemini 2.5 Pro", easy: 83.1, med: 72.5, hard: 68.8 },
46
+ { label: "Mistral-7B", easy: 74.4, med: 76.9, hard: 76.6 },
47
+ { label: "DeepSeek R1 70B", easy: 72.5, med: 77.5, hard: 75.0 },
48
+ { label: "Gemma 4 E4B", easy: 82.5, med: 64.8, hard: 56.2 }
49
+ ];
50
+
51
+ window.IFB_TASKS = [
52
+ { code: "REG", key: "reg", label: "Regulatory Interpretation", n: 174, color: "#1F5C45",
53
+ desc: "Extract compliance rules, thresholds and deadlines from SEBI and RBI regulatory text." },
54
+ { code: "NUM", key: "num", label: "Numerical Reasoning", n: 92, color: "#A33B20",
55
+ desc: "Arithmetic over capital ratios, dividend limits, and margin requirements in regulatory prose." },
56
+ { code: "CON", key: "con", label: "Contradiction Detection", n: 62, color: "#96752A",
57
+ desc: "Identify whether two regulatory passages contradict each other on a stated issue." },
58
+ { code: "TMP", key: "tmp", label: "Temporal Reasoning", n: 78, color: "#3B3F8C",
59
+ desc: "Sequence regulatory amendments and identify which circular was operative at a given time." }
60
+ ];
demo/static/js/main.js ADDED
@@ -0,0 +1,512 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ════════════════════════════════════════════════════════════════════
2
+ IndiaFinBench β€” main.js
3
+ Chart, tables, canvas, scroll choreography, live RAG, explorer, submit.
4
+ Vanilla JS, no dependencies.
5
+ ════════════════════════════════════════════════════════════════════ */
6
+ (function () {
7
+ 'use strict';
8
+
9
+ var MODELS = window.IFB_MODELS || [];
10
+ var HUMAN = window.IFB_HUMAN || null;
11
+ var CLAUDE = window.IFB_CLAUDE || null;
12
+ var DIFF = window.IFB_DIFF || [];
13
+ var TASKS = window.IFB_TASKS || [];
14
+ var REDUCED = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
15
+
16
+ var $ = function (s, c) { return (c || document).querySelector(s); };
17
+ var $$ = function (s, c) { return Array.prototype.slice.call((c || document).querySelectorAll(s)); };
18
+
19
+ var esc = function (s) {
20
+ return String(s == null ? '' : s).replace(/[&<>"']/g, function (ch) {
21
+ return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch];
22
+ });
23
+ };
24
+
25
+ /* ── Deep links: jump instantly on load instead of animating from top ─ */
26
+ if (location.hash) {
27
+ var deepTarget = document.getElementById(location.hash.slice(1));
28
+ if (deepTarget) {
29
+ requestAnimationFrame(function () {
30
+ deepTarget.scrollIntoView({ behavior: 'instant', block: 'start' });
31
+ });
32
+ }
33
+ }
34
+
35
+ /* ── Masthead drawer ──────────────────────────────────────────────── */
36
+ var menuBtn = $('#menuBtn');
37
+ var drawer = $('#menuDrawer');
38
+ if (menuBtn && drawer) {
39
+ menuBtn.addEventListener('click', function () {
40
+ var open = drawer.classList.toggle('open');
41
+ menuBtn.setAttribute('aria-expanded', String(open));
42
+ });
43
+ $$('a', drawer).forEach(function (a) {
44
+ a.addEventListener('click', function () {
45
+ drawer.classList.remove('open');
46
+ menuBtn.setAttribute('aria-expanded', 'false');
47
+ });
48
+ });
49
+ }
50
+
51
+ /* ── Scroll reveal ────────────────────────────────────────────────── */
52
+ var revealIO = new IntersectionObserver(function (entries) {
53
+ entries.forEach(function (e) {
54
+ if (e.isIntersecting) {
55
+ e.target.classList.add('shown');
56
+ revealIO.unobserve(e.target);
57
+ }
58
+ });
59
+ }, { threshold: 0.12, rootMargin: '0px 0px -40px 0px' });
60
+
61
+ function watch(el) { if (el) revealIO.observe(el); }
62
+ $$('.reveal').forEach(watch);
63
+
64
+ /* ── Count-up numbers ─────────────────────────────────────────────── */
65
+ function countUp(el) {
66
+ var target = parseFloat(el.dataset.count);
67
+ var dec = parseInt(el.dataset.dec || '0', 10);
68
+ var suffix = el.dataset.suffix || '';
69
+ if (REDUCED) { el.textContent = target.toFixed(dec) + suffix; return; }
70
+ var t0 = null, DUR = 1400;
71
+ function frame(t) {
72
+ if (!t0) t0 = t;
73
+ var p = Math.min((t - t0) / DUR, 1);
74
+ var eased = 1 - Math.pow(1 - p, 3);
75
+ el.textContent = (target * eased).toFixed(dec) + suffix;
76
+ if (p < 1) requestAnimationFrame(frame);
77
+ }
78
+ requestAnimationFrame(frame);
79
+ }
80
+ var countIO = new IntersectionObserver(function (entries) {
81
+ entries.forEach(function (e) {
82
+ if (e.isIntersecting) { countUp(e.target); countIO.unobserve(e.target); }
83
+ });
84
+ }, { threshold: 0.4 });
85
+ $$('[data-count]').forEach(function (el) { countIO.observe(el); });
86
+
87
+ /* ── Progress rail ────────────────────────────────────────────────── */
88
+ var railFill = $('#railFill');
89
+ var railItems = $$('.rail-list li');
90
+ var chapterIds = railItems.map(function (li) { return li.dataset.ch; });
91
+
92
+ window.addEventListener('scroll', function () {
93
+ if (!railFill) return;
94
+ var h = document.documentElement.scrollHeight - window.innerHeight;
95
+ railFill.style.height = (h > 0 ? (window.scrollY / h) * 100 : 0) + '%';
96
+ }, { passive: true });
97
+
98
+ var chapterIO = new IntersectionObserver(function (entries) {
99
+ entries.forEach(function (e) {
100
+ if (e.isIntersecting) {
101
+ railItems.forEach(function (li) {
102
+ li.classList.toggle('on', li.dataset.ch === e.target.id);
103
+ });
104
+ $$('.mast-nav a').forEach(function (a) {
105
+ a.classList.toggle('active', a.getAttribute('href') === '#' + e.target.id);
106
+ });
107
+ }
108
+ });
109
+ }, { rootMargin: '-30% 0px -60% 0px' });
110
+ chapterIds.forEach(function (id) {
111
+ var sec = document.getElementById(id);
112
+ if (sec) chapterIO.observe(sec);
113
+ });
114
+
115
+ /* ── Β§02 Corpus: 192 document marks ───────────────────────────────── */
116
+ (function docDots() {
117
+ var holder = $('#docDots');
118
+ if (!holder) return;
119
+ var frag = document.createDocumentFragment();
120
+ for (var i = 0; i < 192; i++) {
121
+ var el = document.createElement('i');
122
+ el.className = i < 92 ? 'sebi' : 'rbi';
123
+ el.style.setProperty('--d', (i * 7) + 'ms');
124
+ frag.appendChild(el);
125
+ }
126
+ holder.appendChild(frag);
127
+ watchShown(holder);
128
+ })();
129
+
130
+ function watchShown(el) {
131
+ new IntersectionObserver(function (entries, io) {
132
+ entries.forEach(function (e) {
133
+ if (e.isIntersecting) { e.target.classList.add('shown'); io.unobserve(e.target); }
134
+ });
135
+ }, { threshold: 0.25 }).observe(el);
136
+ }
137
+
138
+ /* ── Β§03 Task cards ───────────────────────────────────────────────── */
139
+ (function taskCards() {
140
+ var grid = $('#taskGrid');
141
+ if (!grid || !TASKS.length) return;
142
+ grid.innerHTML = TASKS.map(function (t) {
143
+ var share = (t.n / 406) * 100;
144
+ return '<article class="task-card reveal">' +
145
+ '<span class="task-code" style="color:' + t.color + '">' + esc(t.code) + '</span>' +
146
+ '<h3>' + esc(t.label) + '</h3>' +
147
+ '<p>' + esc(t.desc) + '</p>' +
148
+ '<div class="task-n"><b>' + t.n + '</b> items Β· ' + share.toFixed(1) + '% of benchmark</div>' +
149
+ '<div class="task-meter"><i style="--w:' + share.toFixed(1) + '%;background:' + t.color + '"></i></div>' +
150
+ '</article>';
151
+ }).join('');
152
+ $$('.task-card', grid).forEach(function (card) { watch(card); watchShown(card); });
153
+ })();
154
+
155
+ /* ── Β§03 difficulty strip + diff segments need .shown ─────────────── */
156
+ $$('.diff-strip, .rag-diagram').forEach(watchShown);
157
+
158
+ /* ── Β§04 Chart ────────────────────────────────────────────────────── */
159
+ var METRIC_META = {
160
+ overall: { title: 'Overall accuracy', desc: 'All 406 items Β· zero-shot Β· 95% Wilson confidence intervals on hover' },
161
+ reg: { title: 'Regulatory Interpretation', desc: '174 items Β· precision reading of rules, thresholds and applicability' },
162
+ num: { title: 'Numerical Reasoning', desc: '92 items Β· arithmetic over figures embedded in regulatory prose' },
163
+ con: { title: 'Contradiction Detection', desc: '62 items Β· do two passages conflict on the stated issue?' },
164
+ tmp: { title: 'Temporal Reasoning', desc: '78 items Β· supersession chains and operative-date resolution' }
165
+ };
166
+
167
+ var chartEl = $('#barChart');
168
+ var chartShown = false;
169
+
170
+ function tipHTML(m) {
171
+ return '<b>' + esc(m.label) + '</b>' +
172
+ '<span class="tt-grid">' +
173
+ '<i>REG</i><span>' + m.reg.toFixed(1) + '%</span>' +
174
+ '<i>NUM</i><span>' + m.num.toFixed(1) + '%</span>' +
175
+ '<i>CON</i><span>' + m.con.toFixed(1) + '%</span>' +
176
+ '<i>TMP</i><span>' + m.tmp.toFixed(1) + '%</span>' +
177
+ '<i>Overall</i><span>' + m.overall.toFixed(1) + '%</span>' +
178
+ '</span>' +
179
+ (m.ci ? '<span class="tt-ci">95% CI ' + esc(m.ci) + ' Β· n = ' + m.n_items + '</span>' : '');
180
+ }
181
+
182
+ function buildChart(metric) {
183
+ if (!chartEl) return;
184
+ var rows = MODELS.slice();
185
+ if (HUMAN) rows.push(HUMAN);
186
+ rows.sort(function (a, b) { return b[metric] - a[metric]; });
187
+
188
+ var html = rows.map(function (m, idx) {
189
+ var v = m[metric];
190
+ var cls = m.is_human ? 'human' : ('t' + (m.tier || 2));
191
+ return '<div class="crow ' + cls + '">' +
192
+ '<div class="crow-label">' +
193
+ '<span class="crow-rank">' + (m.is_human ? 'β€”' : (idx + 1)) + '</span>' +
194
+ '<span class="crow-name' + (m.is_human ? ' human' : '') + '">' + esc(m.label) + '</span>' +
195
+ '</div>' +
196
+ '<div class="crow-track">' +
197
+ '<div class="crow-fill" data-w="' + v + '"><span class="crow-val">' + v.toFixed(1) + '%</span></div>' +
198
+ '<div class="crow-tip" role="tooltip">' + tipHTML(m) + '</div>' +
199
+ '</div>' +
200
+ '</div>';
201
+ }).join('');
202
+ html += '<div class="chart-baseline" id="chartBaseline"><em>human baseline Β· ' +
203
+ (HUMAN ? HUMAN[metric].toFixed(1) : 'β€”') + '%</em></div>';
204
+ chartEl.innerHTML = html;
205
+
206
+ function animate() {
207
+ $$('.crow-fill', chartEl).forEach(function (f) {
208
+ f.style.width = f.dataset.w + '%';
209
+ f.classList.add('shown');
210
+ });
211
+ positionBaseline(metric);
212
+ }
213
+ if (chartShown) {
214
+ requestAnimationFrame(function () { requestAnimationFrame(animate); });
215
+ } else {
216
+ new IntersectionObserver(function (entries, io) {
217
+ if (entries[0].isIntersecting) {
218
+ chartShown = true;
219
+ animate();
220
+ io.disconnect();
221
+ }
222
+ }, { threshold: 0.15 }).observe(chartEl);
223
+ }
224
+ }
225
+
226
+ function positionBaseline(metric) {
227
+ var line = $('#chartBaseline');
228
+ var track = $('.crow-track', chartEl);
229
+ if (!line || !track || !HUMAN) return;
230
+ var v = HUMAN[metric];
231
+ var left = track.offsetLeft + (track.offsetWidth * v / 100);
232
+ line.style.left = left + 'px';
233
+ }
234
+ window.addEventListener('resize', function () {
235
+ var active = $('.tab.active');
236
+ if (chartShown) positionBaseline(active ? active.dataset.t : 'overall');
237
+ });
238
+
239
+ $$('#taskTabs .tab').forEach(function (tab) {
240
+ tab.addEventListener('click', function () {
241
+ $$('#taskTabs .tab').forEach(function (t) {
242
+ t.classList.toggle('active', t === tab);
243
+ t.setAttribute('aria-selected', String(t === tab));
244
+ });
245
+ var k = tab.dataset.t;
246
+ $('#chartTitle').textContent = METRIC_META[k].title;
247
+ $('#chartDesc').textContent = METRIC_META[k].desc;
248
+ buildChart(k);
249
+ });
250
+ });
251
+ buildChart('overall');
252
+
253
+ /* ── Β§04 Results table ────────────────────────────────────────────── */
254
+ var sortKey = 'overall', sortAsc = false;
255
+
256
+ function scoreCell(v, isBest) {
257
+ var cls = v >= 85 ? 's-hi' : v >= 70 ? 's-md' : 's-lo';
258
+ return '<td class="c"><span class="score ' + cls + (isBest ? ' score-best' : '') + '">' + v.toFixed(1) + '</span></td>';
259
+ }
260
+
261
+ function buildTable() {
262
+ var body = $('#tBody');
263
+ if (!body) return;
264
+ var rows = MODELS.slice();
265
+ rows.sort(function (a, b) {
266
+ var x = a[sortKey], y = b[sortKey];
267
+ if (sortKey === 'params') { x = parseFloat(x) || 0; y = parseFloat(y) || 0; }
268
+ if (sortKey === 'rank') return sortAsc ? a.rank - b.rank : b.rank - a.rank;
269
+ return sortAsc ? x - y : y - x;
270
+ });
271
+
272
+ var best = {};
273
+ ['reg', 'num', 'con', 'tmp', 'overall'].forEach(function (k) {
274
+ best[k] = Math.max.apply(null, MODELS.map(function (m) { return m[k]; }));
275
+ });
276
+
277
+ var html = rows.map(function (m) {
278
+ return '<tr>' +
279
+ '<td class="c rank-cell' + (m.rank === 1 ? ' rank-1' : '') + '">' + m.rank + '</td>' +
280
+ '<td><div class="model-cell-name">' + esc(m.label) + '</div><div class="model-cell-id">' + esc(m.hf_id) + '</div></td>' +
281
+ '<td class="mono">' + esc(m.params) + '</td>' +
282
+ '<td><span class="access-tag">' + esc(m.type) + '</span></td>' +
283
+ scoreCell(m.reg, m.reg === best.reg) +
284
+ scoreCell(m.num, m.num === best.num) +
285
+ scoreCell(m.con, m.con === best.con) +
286
+ scoreCell(m.tmp, m.tmp === best.tmp) +
287
+ scoreCell(m.overall, m.overall === best.overall) +
288
+ '<td class="c ci-cell">' + esc(m.ci) + '</td>' +
289
+ '</tr>';
290
+ }).join('');
291
+
292
+ if (HUMAN) {
293
+ html += '<tr class="tr-human">' +
294
+ '<td class="c rank-cell">β€”</td>' +
295
+ '<td><div class="model-cell-name">' + esc(HUMAN.label) + '</div><div class="model-cell-id">' + esc(HUMAN.hf_id) + '</div></td>' +
296
+ '<td class="mono">β€”</td><td><span class="access-tag">Human baseline</span></td>' +
297
+ scoreCell(HUMAN.reg) + scoreCell(HUMAN.num) + scoreCell(HUMAN.con) + scoreCell(HUMAN.tmp) + scoreCell(HUMAN.overall) +
298
+ '<td class="c ci-cell">' + esc(HUMAN.ci) + '</td></tr>';
299
+ }
300
+ if (CLAUDE) {
301
+ html += '<tr class="tr-subset">' +
302
+ '<td class="c rank-cell">†</td>' +
303
+ '<td><div class="model-cell-name">' + esc(CLAUDE.label.replace('†', '')) + '</div><div class="model-cell-id">' + esc(CLAUDE.hf_id) + '</div></td>' +
304
+ '<td class="mono">β€”</td><td><span class="access-tag">150-item subset</span></td>' +
305
+ scoreCell(CLAUDE.reg) + scoreCell(CLAUDE.num) + scoreCell(CLAUDE.con) + scoreCell(CLAUDE.tmp) + scoreCell(CLAUDE.overall) +
306
+ '<td class="c ci-cell">' + esc(CLAUDE.ci) + '</td></tr>';
307
+ }
308
+ body.innerHTML = html;
309
+ }
310
+
311
+ $$('#resultsTable th.sortable').forEach(function (th) {
312
+ th.addEventListener('click', function () {
313
+ var k = th.dataset.k;
314
+ if (sortKey === k) sortAsc = !sortAsc;
315
+ else { sortKey = k; sortAsc = (k === 'rank'); }
316
+ $$('#resultsTable th').forEach(function (h) { h.classList.toggle('sorted', h === th); });
317
+ buildTable();
318
+ });
319
+ });
320
+ buildTable();
321
+
322
+ /* ── Β§04 Difficulty table ─────────────────────────────────────────── */
323
+ (function diffTable() {
324
+ var body = $('#diffBody');
325
+ if (!body || !DIFF.length) return;
326
+ body.innerHTML = DIFF.map(function (m) {
327
+ var d = m.hard - m.easy;
328
+ var sign = d >= 0 ? '+' : 'βˆ’';
329
+ var cls = d >= 0 ? 'delta-up' : 'delta-down';
330
+ return '<tr>' +
331
+ '<td><div class="model-cell-name">' + esc(m.label) + '</div></td>' +
332
+ '<td class="c mono">' + m.easy.toFixed(1) + '%</td>' +
333
+ '<td class="c mono">' + m.med.toFixed(1) + '%</td>' +
334
+ '<td class="c mono">' + m.hard.toFixed(1) + '%</td>' +
335
+ '<td class="c mono ' + cls + '">' + sign + Math.abs(d).toFixed(1) + '</td>' +
336
+ '</tr>';
337
+ }).join('');
338
+ })();
339
+
340
+ /* ── Β§06 Live RAG ──────��──────────────────────────────────────────── */
341
+ (function rag() {
342
+ var input = $('#ragQ'), btn = $('#ragBtn');
343
+ var out = $('#ragOut'), status = $('#ragStatus'), statusText = $('#ragStatusText');
344
+ var answerEl = $('#ragAnswer'), sourcesEl = $('#ragSources');
345
+ if (!input || !btn) return;
346
+
347
+ var PHASES = ['Encoding query…', 'Searching 4,347 chunks…', 'Fusing dense and sparse ranks…', 'Drafting cited answer…'];
348
+ var phaseTimer = null;
349
+
350
+ function startPhases() {
351
+ var i = 0;
352
+ statusText.textContent = PHASES[0];
353
+ phaseTimer = setInterval(function () {
354
+ i = Math.min(i + 1, PHASES.length - 1);
355
+ statusText.textContent = PHASES[i];
356
+ }, 1500);
357
+ }
358
+ function stopPhases() { clearInterval(phaseTimer); }
359
+
360
+ function run() {
361
+ var q = input.value.trim();
362
+ if (!q) return;
363
+ btn.disabled = true;
364
+ out.hidden = false;
365
+ status.hidden = false;
366
+ answerEl.textContent = '';
367
+ sourcesEl.innerHTML = '';
368
+ startPhases();
369
+
370
+ fetch('/api/rag', {
371
+ method: 'POST',
372
+ headers: { 'Content-Type': 'application/json' },
373
+ body: JSON.stringify({ query: q })
374
+ }).then(function (r) { return r.json(); }).then(function (data) {
375
+ stopPhases();
376
+ status.hidden = true;
377
+ if (data.error) {
378
+ answerEl.textContent = '⚠ ' + data.error;
379
+ } else {
380
+ answerEl.textContent = data.answer || '';
381
+ (data.sources || []).forEach(function (s, i) {
382
+ var card = document.createElement('div');
383
+ card.className = 'rag-src';
384
+ card.innerHTML =
385
+ '<div class="rag-src-no">Source ' + (i + 1) + ' Β· ' + esc(s.source || s.doc_id || '') + '</div>' +
386
+ '<div class="rag-src-title">' + esc(s.title || s.doc_id || 'Document') + '</div>' +
387
+ '<div class="rag-src-text">' + esc(s.text || '') + '</div>' +
388
+ '<div class="rag-src-scores">' +
389
+ '<span>RRF <b>' + (s.rrf_score != null ? Number(s.rrf_score).toFixed(4) : 'β€”') + '</b></span>' +
390
+ '<span>dense <b>' + (s.dense_score != null ? Number(s.dense_score).toFixed(3) : 'β€”') + '</b></span>' +
391
+ '<span>BM25 <b>' + (s.bm25_score != null ? Number(s.bm25_score).toFixed(2) : 'β€”') + '</b></span>' +
392
+ '</div>';
393
+ sourcesEl.appendChild(card);
394
+ });
395
+ }
396
+ btn.disabled = false;
397
+ }).catch(function (e) {
398
+ stopPhases();
399
+ status.hidden = true;
400
+ answerEl.textContent = '⚠ Network error: ' + e.message;
401
+ btn.disabled = false;
402
+ });
403
+ }
404
+
405
+ btn.addEventListener('click', run);
406
+ input.addEventListener('keydown', function (e) { if (e.key === 'Enter') run(); });
407
+ $$('.rag-examples .chip').forEach(function (chip) {
408
+ chip.addEventListener('click', function () {
409
+ input.value = chip.dataset.q || chip.textContent.trim();
410
+ run();
411
+ });
412
+ });
413
+ })();
414
+
415
+ /* ── Β§07 Specimen explorer ────────────────────────────────────────── */
416
+ (function explorer() {
417
+ var btn = $('#exBtn');
418
+ if (!btn) return;
419
+ btn.addEventListener('click', function () {
420
+ btn.disabled = true;
421
+ var task = encodeURIComponent($('#exTask').value);
422
+ var diff = encodeURIComponent($('#exDiff').value);
423
+ fetch('/api/example?task=' + task + '&diff=' + diff)
424
+ .then(function (r) { return r.json(); })
425
+ .then(function (q) {
426
+ btn.disabled = false;
427
+ var card = $('#exCard');
428
+ if (q.error) {
429
+ card.hidden = false;
430
+ $('#exId').textContent = '';
431
+ $('#exTaskBadge').textContent = '';
432
+ $('#exDiffBadge').textContent = '';
433
+ $('#exContext').textContent = q.error;
434
+ $('#exQuestion').textContent = '';
435
+ $('#exReveal').hidden = true;
436
+ $('#exAnswer').hidden = true;
437
+ return;
438
+ }
439
+ card.hidden = false;
440
+ $('#exId').textContent = q.id || '';
441
+ $('#exTaskBadge').textContent = q.task_type || '';
442
+ $('#exDiffBadge').textContent = q.difficulty || '';
443
+ $('#exContext').textContent = q.context || '';
444
+ $('#exQuestion').textContent = q.question || '';
445
+ $('#exReveal').hidden = false;
446
+ $('#exAnswer').hidden = true;
447
+ $('#exAnswerText').textContent = q.answer || '';
448
+ })
449
+ .catch(function () { btn.disabled = false; });
450
+ });
451
+ $('#exReveal').addEventListener('click', function () {
452
+ $('#exAnswer').hidden = false;
453
+ this.hidden = true;
454
+ });
455
+ })();
456
+
457
+ /* ── Β§07 Submit ───────────────────────────────────────────────────── */
458
+ (function submit() {
459
+ var btn = $('#subBtn');
460
+ if (!btn) return;
461
+ btn.addEventListener('click', function () {
462
+ var hfId = $('#hfId').value.trim();
463
+ var box = $('#statusBox');
464
+ box.hidden = false;
465
+ box.className = 'status';
466
+ if (!hfId || hfId.indexOf('/') < 1) {
467
+ box.classList.add('err');
468
+ box.textContent = 'Enter a valid HuggingFace model ID (org/model).';
469
+ return;
470
+ }
471
+ btn.disabled = true;
472
+ box.textContent = 'Preparing submission…';
473
+ fetch('/api/submit', {
474
+ method: 'POST',
475
+ headers: { 'Content-Type': 'application/json' },
476
+ body: JSON.stringify({
477
+ hf_id: hfId,
478
+ label: $('#dispName').value.trim(),
479
+ params: $('#modelParams').value.trim(),
480
+ model_type: $('#mtype').value
481
+ })
482
+ }).then(function (r) { return r.json(); }).then(function (data) {
483
+ btn.disabled = false;
484
+ if (data.issue_url) {
485
+ box.classList.add('ok');
486
+ box.textContent = 'Submission issue opened in a new tab β€” submit it on GitHub and the model joins the evaluation queue.';
487
+ window.open(data.issue_url, '_blank', 'noopener');
488
+ } else {
489
+ box.classList.add('err');
490
+ box.textContent = data.error || 'Submission failed.';
491
+ }
492
+ }).catch(function (e) {
493
+ btn.disabled = false;
494
+ box.classList.add('err');
495
+ box.textContent = 'Network error: ' + e.message;
496
+ });
497
+ });
498
+ })();
499
+
500
+ /* ── Β§07 Copy citation ────────────────────────────────────────────── */
501
+ (function cite() {
502
+ var btn = $('#copyBtn');
503
+ if (!btn) return;
504
+ btn.addEventListener('click', function () {
505
+ navigator.clipboard.writeText($('#citeText').textContent).then(function () {
506
+ btn.textContent = 'Copied';
507
+ setTimeout(function () { btn.textContent = 'Copy'; }, 1600);
508
+ });
509
+ });
510
+ })();
511
+
512
+ })();
demo/templates/index.html ADDED
@@ -0,0 +1,547 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ <title>IndiaFinBench β€” The First Benchmark for Indian Financial Regulation</title>
7
+ <meta name="description" content="IndiaFinBench: 406 expert-annotated questions over 192 SEBI &amp; RBI regulatory documents (1992–2026). Twelve frontier LLMs evaluated. Hybrid RAG with Recall@5 = 0.785. Open dataset, open leaderboard.">
8
+ <meta property="og:title" content="IndiaFinBench β€” Can language models read India's financial law?">
9
+ <meta property="og:description" content="The first evaluation benchmark for LLM performance on Indian financial regulatory text. 406 questions Β· 192 documents Β· 12 models Β· open dataset.">
10
+ <meta property="og:type" content="website">
11
+ <meta property="og:url" content="https://huggingface.co/spaces/Rajveer-code/IndiaFinBench">
12
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='8' fill='%23F5F1E8'/%3E%3Ctext x='32' y='46' text-anchor='middle' font-family='Georgia,serif' font-size='40' font-weight='700' fill='%23A33B20'%3E%C2%A7%3C/text%3E%3C/svg%3E">
13
+ <link rel="preconnect" href="https://fonts.googleapis.com">
14
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
15
+ <link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400..700;1,9..144,400..700&family=Archivo:wght@400;500;600;700&family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
16
+ <link rel="stylesheet" href="/static/css/main.css?v={{ js_ver }}">
17
+ <script type="application/ld+json">
18
+ {
19
+ "@context": "https://schema.org",
20
+ "@type": "Dataset",
21
+ "name": "IndiaFinBench",
22
+ "description": "An evaluation benchmark of 406 expert-annotated question-answer pairs over 192 SEBI and RBI regulatory documents (1992-2026), testing LLM performance on Indian financial regulatory text.",
23
+ "url": "https://github.com/Rajveer-code/IndiaFinBench",
24
+ "sameAs": "https://huggingface.co/datasets/Rajveer-code/IndiaFinBench",
25
+ "license": "https://creativecommons.org/licenses/by/4.0/",
26
+ "creator": { "@type": "Person", "name": "Rajveer Singh Pall" },
27
+ "keywords": ["LLM evaluation", "financial NLP", "Indian regulation", "SEBI", "RBI", "benchmark"]
28
+ }
29
+ </script>
30
+ </head>
31
+ <body>
32
+
33
+ <canvas id="archiveCanvas" aria-hidden="true"></canvas>
34
+
35
+ <a class="skip-link" href="#leaderboard">Skip to leaderboard</a>
36
+
37
+ <!-- ════════ MASTHEAD ════════ -->
38
+ <header class="masthead" id="masthead">
39
+ <div class="masthead-inner">
40
+ <a class="brand" href="#top" aria-label="IndiaFinBench β€” back to top">
41
+ <span class="brand-seal" aria-hidden="true">Β§</span>
42
+ <span class="brand-word">India<em>Fin</em>Bench</span>
43
+ </a>
44
+ <nav class="mast-nav" aria-label="Chapters">
45
+ <a href="#problem">Problem</a>
46
+ <a href="#corpus">Corpus</a>
47
+ <a href="#benchmark">Benchmark</a>
48
+ <a href="#leaderboard">Leaderboard</a>
49
+ <a href="#findings">Findings</a>
50
+ <a href="#retrieval">Retrieval</a>
51
+ <a href="#access">Access</a>
52
+ </nav>
53
+ <div class="mast-actions">
54
+ <a class="mast-btn" href="https://huggingface.co/datasets/Rajveer-code/IndiaFinBench" target="_blank" rel="noopener">Dataset</a>
55
+ <a class="mast-btn mast-btn-solid" href="https://github.com/Rajveer-code/IndiaFinBench" target="_blank" rel="noopener">GitHub</a>
56
+ <button class="mast-menu" id="menuBtn" aria-label="Open menu" aria-expanded="false">
57
+ <span></span><span></span>
58
+ </button>
59
+ </div>
60
+ </div>
61
+ <div class="mast-drawer" id="menuDrawer">
62
+ <a href="#problem">Β§ 01 β€” The Problem</a>
63
+ <a href="#corpus">Β§ 02 β€” The Corpus</a>
64
+ <a href="#benchmark">Β§ 03 β€” The Benchmark</a>
65
+ <a href="#leaderboard">Β§ 04 β€” The Evaluation</a>
66
+ <a href="#findings">Β§ 05 β€” The Findings</a>
67
+ <a href="#retrieval">Β§ 06 β€” The Retrieval</a>
68
+ <a href="#access">Β§ 07 β€” The Access</a>
69
+ </div>
70
+ </header>
71
+
72
+ <!-- ════════ PROGRESS RAIL ════════ -->
73
+ <aside class="rail" id="rail" aria-hidden="true">
74
+ <div class="rail-line"><div class="rail-fill" id="railFill"></div></div>
75
+ <ol class="rail-list">
76
+ <li data-ch="problem"><a href="#problem"><b>01</b><span>Problem</span></a></li>
77
+ <li data-ch="corpus"><a href="#corpus"><b>02</b><span>Corpus</span></a></li>
78
+ <li data-ch="benchmark"><a href="#benchmark"><b>03</b><span>Benchmark</span></a></li>
79
+ <li data-ch="leaderboard"><a href="#leaderboard"><b>04</b><span>Evaluation</span></a></li>
80
+ <li data-ch="findings"><a href="#findings"><b>05</b><span>Findings</span></a></li>
81
+ <li data-ch="retrieval"><a href="#retrieval"><b>06</b><span>Retrieval</span></a></li>
82
+ <li data-ch="access"><a href="#access"><b>07</b><span>Access</span></a></li>
83
+ </ol>
84
+ </aside>
85
+
86
+ <main id="top">
87
+
88
+ <!-- ════════ HERO ════════ -->
89
+ <section class="hero">
90
+ <div class="hero-inner">
91
+ <p class="hero-kicker reveal">A research benchmark &amp; open dataset Β· EMNLP 2026 submission</p>
92
+ <h1 class="hero-title reveal">Can language models read India&rsquo;s <em>financial&nbsp;law?</em></h1>
93
+ <p class="hero-sub reveal">
94
+ IndiaFinBench is the first evaluation benchmark for large language models on Indian
95
+ financial regulatory text β€” <strong>406 expert-annotated questions</strong> drawn from
96
+ <strong>192 SEBI and RBI documents</strong> spanning thirty-four years of the regulatory record.
97
+ </p>
98
+ <div class="hero-cta reveal">
99
+ <a class="btn btn-ink" href="#leaderboard">Read the leaderboard</a>
100
+ <a class="btn btn-line" href="#retrieval">Query the corpus live</a>
101
+ </div>
102
+ </div>
103
+ <p class="hero-scrollcue reveal" aria-hidden="true">Scroll β€” the archive assembles<span class="cue-arrow">↓</span></p>
104
+ <div class="hero-ledger reveal" role="list" aria-label="Benchmark statistics">
105
+ <div class="ledger-cell" role="listitem"><span class="ledger-num" data-count="406">0</span><span class="ledger-lbl">expert-annotated questions</span></div>
106
+ <div class="ledger-cell" role="listitem"><span class="ledger-num" data-count="192">0</span><span class="ledger-lbl">RBI &amp; SEBI documents</span></div>
107
+ <div class="ledger-cell" role="listitem"><span class="ledger-num" data-count="12">0</span><span class="ledger-lbl">frontier models evaluated</span></div>
108
+ <div class="ledger-cell" role="listitem"><span class="ledger-num" data-count="0.785" data-dec="3">0</span><span class="ledger-lbl">Recall@5, hybrid retrieval</span></div>
109
+ <div class="ledger-cell" role="listitem"><span class="ledger-num" data-count="89.7" data-dec="1" data-suffix="%">0</span><span class="ledger-lbl">best model accuracy</span></div>
110
+ <div class="ledger-cell" role="listitem"><span class="ledger-num" data-count="69.0" data-dec="1" data-suffix="%">0</span><span class="ledger-lbl">human expert baseline</span></div>
111
+ </div>
112
+ </section>
113
+
114
+ <!-- ════════ Β§01 THE PROBLEM ════════ -->
115
+ <section class="chapter" id="problem">
116
+ <header class="ch-head reveal">
117
+ <span class="ch-no">Β§ 01</span>
118
+ <h2 class="ch-title">The Problem</h2>
119
+ <p class="ch-lede">Benchmarks read English. Regulation reads differently.</p>
120
+ </header>
121
+
122
+ <div class="prose-cols vellum reveal">
123
+ <p>
124
+ Financial NLP benchmarks β€” FinQA, TAT-QA, FiQA β€” are built almost entirely on Western
125
+ corporate filings. None test whether a model can navigate the regulatory apparatus
126
+ governing the world&rsquo;s most populous market. Indian regulation poses three difficulties
127
+ that existing benchmarks never measure.
128
+ </p>
129
+ <p>
130
+ Numerical thresholds are buried in dense statutory prose, often written out in words.
131
+ Circulars supersede one another in long chains, so the <em>operative</em> rule at a given
132
+ date is a temporal-reasoning problem, not a lookup. And the vocabulary β€” LODR, PMLA,
133
+ AIF, FEMA, SFB β€” is jurisdiction-specific, thinly represented in Western training corpora.
134
+ </p>
135
+ </div>
136
+
137
+ <div class="problem-grid">
138
+ <article class="problem-card reveal">
139
+ <span class="pc-no">i</span>
140
+ <h3>Numbers hide in prose</h3>
141
+ <p>Capital ratios, margin requirements and filing deadlines appear as words inside clauses, not cells inside tables. Extraction requires precision reading, then arithmetic.</p>
142
+ </article>
143
+ <article class="problem-card reveal">
144
+ <span class="pc-no">ii</span>
145
+ <h3>Circulars supersede circulars</h3>
146
+ <p>A 2019 master circular may amend a 2014 directive that replaced a 1998 notification. Answering &ldquo;what rule applied in 2016?&rdquo; means untangling the chain.</p>
147
+ </article>
148
+ <article class="problem-card reveal">
149
+ <span class="pc-no">iii</span>
150
+ <h3>The vocabulary is sovereign</h3>
151
+ <p>LODR is not a typo and an AIF is not a hedge fund. Jurisdiction-specific terms of art carry exact legal meanings that general-purpose corpora rarely teach.</p>
152
+ </article>
153
+ </div>
154
+
155
+ <figure class="specimen reveal" aria-label="Annotated regulatory excerpt">
156
+ <figcaption class="spec-head">
157
+ <span class="spec-tag">Exhibit A</span>
158
+ <span class="spec-src">SEBI (LODR) Regulations Β· Regulation 33(3)(a)</span>
159
+ </figcaption>
160
+ <blockquote class="spec-body">
161
+ The listed entity shall submit quarterly and year-to-date standalone financial results to the
162
+ stock exchange within <mark class="m-num" data-note="numeral written as words">forty-five days</mark>
163
+ of end of each quarter, <mark class="m-scope" data-note="scope exception">other than the last quarter</mark>,
164
+ as per the requirements of <mark class="m-ref" data-note="cross-reference to resolve">Regulation&nbsp;33</mark>.
165
+ </blockquote>
166
+ <figcaption class="spec-legend">
167
+ <span><i class="lg lg-num"></i>numerical threshold in prose</span>
168
+ <span><i class="lg lg-scope"></i>scope exception</span>
169
+ <span><i class="lg lg-ref"></i>cross-reference</span>
170
+ </figcaption>
171
+ </figure>
172
+ </section>
173
+
174
+ <!-- ════════ Β§02 THE CORPUS ════════ -->
175
+ <section class="chapter ch-alt" id="corpus">
176
+ <header class="ch-head reveal">
177
+ <span class="ch-no">Β§ 02</span>
178
+ <h2 class="ch-title">The Corpus</h2>
179
+ <p class="ch-lede">Thirty-four years of primary sources, read in full.</p>
180
+ </header>
181
+
182
+ <div class="corpus-grid">
183
+ <div class="corpus-copy vellum reveal">
184
+ <p class="prose">
185
+ Every question in IndiaFinBench traces to a primary-source document published by the
186
+ Securities and Exchange Board of India or the Reserve Bank of India between
187
+ <strong>1992 and 2026</strong> β€” circulars, master directions, notifications and
188
+ regulations, collected with full source URLs and parsed into a queryable corpus.
189
+ </p>
190
+ <dl class="corpus-stats">
191
+ <div><dt>SEBI documents</dt><dd class="mono c-green">92</dd></div>
192
+ <div><dt>RBI documents</dt><dd class="mono c-red">100</dd></div>
193
+ <div><dt>Document span</dt><dd class="mono">1992–2026</dd></div>
194
+ <div><dt>Indexed chunks</dt><dd class="mono">4,347</dd></div>
195
+ <div><dt>Chunk size</dt><dd class="mono">1,600 chars</dd></div>
196
+ <div><dt>License</dt><dd class="mono">Public domain (GoI)</dd></div>
197
+ </dl>
198
+ </div>
199
+ <figure class="doc-field reveal" aria-label="192 source documents: 92 SEBI, 100 RBI">
200
+ <div class="doc-dots" id="docDots"></div>
201
+ <figcaption><span><i class="dot dot-sebi"></i>SEBI &middot; 92</span><span><i class="dot dot-rbi"></i>RBI &middot; 100</span><span class="doc-total">192 documents</span></figcaption>
202
+ </figure>
203
+ </div>
204
+ </section>
205
+
206
+ <!-- ════════ Β§03 THE BENCHMARK ════════ -->
207
+ <section class="chapter" id="benchmark">
208
+ <header class="ch-head reveal">
209
+ <span class="ch-no">Β§ 03</span>
210
+ <h2 class="ch-title">The Benchmark</h2>
211
+ <p class="ch-lede">From circular to question: a dual-validated construction.</p>
212
+ </header>
213
+
214
+ <ol class="pipeline reveal" aria-label="Benchmark construction pipeline">
215
+ <li><b>Collect</b><span>192 primary documents, 1992–2026, with source URLs</span></li>
216
+ <li><b>Author</b><span>QA pairs drafted against exact passages, four task types</span></li>
217
+ <li><b>Validate</b><span>model check on answerability β€” 90.7% agreement, ΞΊ = 0.918 (CON)</span></li>
218
+ <li><b>Adjudicate</b><span>human IAA on 180 items across 3 rounds β€” 77.2% agreement, ΞΊ = 0.645 (CON)</span></li>
219
+ <li><b>Release</b><span>406 items, CC BY 4.0, with per-item difficulty labels</span></li>
220
+ </ol>
221
+
222
+ <div class="task-grid" id="taskGrid"><!-- built by JS from IFB_TASKS --></div>
223
+
224
+ <div class="diff-strip reveal" aria-label="Difficulty distribution">
225
+ <div class="diff-bar-outer">
226
+ <div class="diff-seg ds-easy" style="--w:39.4%" title="Easy: 160 items"><span>Easy Β· 160</span></div>
227
+ <div class="diff-seg ds-med" style="--w:44.8%" title="Medium: 182 items"><span>Medium Β· 182</span></div>
228
+ <div class="diff-seg ds-hard" style="--w:15.8%" title="Hard: 64 items"><span>Hard Β· 64</span></div>
229
+ </div>
230
+ <p class="diff-note">Difficulty assigned at authoring time by reasoning depth β€” <em>hard</em> means multi-instrument tracking or compound arithmetic.</p>
231
+ </div>
232
+ </section>
233
+
234
+ <!-- ════════ Β§04 THE EVALUATION ════════ -->
235
+ <section class="chapter ch-alt" id="leaderboard">
236
+ <header class="ch-head reveal">
237
+ <span class="ch-no">Β§ 04</span>
238
+ <h2 class="ch-title">The Evaluation</h2>
239
+ <p class="ch-lede">Twelve models. Zero shots. One human baseline to beat.</p>
240
+ </header>
241
+
242
+ <div class="panel reveal">
243
+ <div class="panel-bar">
244
+ <div>
245
+ <h3 class="panel-title" id="chartTitle">Overall accuracy</h3>
246
+ <p class="panel-sub" id="chartDesc">All 406 items Β· zero-shot Β· 95% Wilson confidence intervals on hover</p>
247
+ </div>
248
+ <div class="tabset" id="taskTabs" role="tablist" aria-label="Metric">
249
+ <button class="tab active" role="tab" aria-selected="true" data-t="overall">Overall</button>
250
+ <button class="tab" role="tab" aria-selected="false" data-t="reg">REG</button>
251
+ <button class="tab" role="tab" aria-selected="false" data-t="num">NUM</button>
252
+ <button class="tab" role="tab" aria-selected="false" data-t="con">CON</button>
253
+ <button class="tab" role="tab" aria-selected="false" data-t="tmp">TMP</button>
254
+ </div>
255
+ </div>
256
+ <div class="chart" id="barChart"></div>
257
+ <p class="chart-foot">Dashed rule marks the human expert baseline for the selected metric (n = 100). Paired bootstrap over all 66 model pairs resolves <strong>three statistically distinct tiers</strong>.</p>
258
+ </div>
259
+
260
+ <div class="panel reveal">
261
+ <div class="panel-bar">
262
+ <div>
263
+ <h3 class="panel-title">Full results</h3>
264
+ <p class="panel-sub">Click a column to sort Β· † Claude 3 Haiku scored 91.3% on the initial 150-item subset; listed separately as not directly comparable</p>
265
+ </div>
266
+ </div>
267
+ <div class="table-scroll">
268
+ <table class="gz-table" id="resultsTable">
269
+ <thead><tr>
270
+ <th class="c sortable" data-k="rank">#</th>
271
+ <th>Model</th>
272
+ <th class="sortable" data-k="params">Params</th>
273
+ <th>Access</th>
274
+ <th class="c sortable" data-k="reg">REG</th>
275
+ <th class="c sortable" data-k="num">NUM</th>
276
+ <th class="c sortable" data-k="con">CON</th>
277
+ <th class="c sortable" data-k="tmp">TMP</th>
278
+ <th class="c sortable sorted" data-k="overall">Overall</th>
279
+ <th class="c">95% CI</th>
280
+ </tr></thead>
281
+ <tbody id="tBody"></tbody>
282
+ </table>
283
+ </div>
284
+ </div>
285
+
286
+ <div class="panel reveal">
287
+ <div class="panel-bar">
288
+ <div>
289
+ <h3 class="panel-title">Accuracy by difficulty</h3>
290
+ <p class="panel-sub">Ξ” = hard βˆ’ easy. LLaMA-3.3-70B <em>improves</em> on hard items; Gemma 4 E4B collapses by 26.3 points.</p>
291
+ </div>
292
+ </div>
293
+ <div class="table-scroll">
294
+ <table class="gz-table" id="diffTable">
295
+ <thead><tr><th>Model</th><th class="c">Easy <span class="th-n">n=160</span></th><th class="c">Medium <span class="th-n">n=182</span></th><th class="c">Hard <span class="th-n">n=64</span></th><th class="c">Ξ”</th></tr></thead>
296
+ <tbody id="diffBody"></tbody>
297
+ </table>
298
+ </div>
299
+ </div>
300
+ </section>
301
+
302
+ <!-- ════════ Β§05 THE FINDINGS ════════ -->
303
+ <section class="chapter" id="findings">
304
+ <header class="ch-head reveal">
305
+ <span class="ch-no">Β§ 05</span>
306
+ <h2 class="ch-title">The Findings</h2>
307
+ <p class="ch-lede">What 4,872 graded answers say about regulatory reasoning.</p>
308
+ </header>
309
+
310
+ <div class="findings-grid">
311
+ <article class="finding reveal">
312
+ <span class="f-no">Finding 1</span>
313
+ <p class="f-stat">3 tiers</p>
314
+ <h3>Performance is tiered, and the tiers are real</h3>
315
+ <p>Paired bootstrap (10,000 resamples, all 66 pairs) separates frontier API models (81–90%), mid-tier open-weight models (75–79%), and a small-model floor at 70%. Most cross-tier gaps are significant at p &lt; 0.05.</p>
316
+ </article>
317
+ <article class="finding reveal">
318
+ <span class="f-no">Finding 2</span>
319
+ <p class="f-stat">17B β‰ˆ 70B</p>
320
+ <h3>Scale alone does not buy regulatory reasoning</h3>
321
+ <p>Llama 4 Scout 17B statistically matches LLaMA-3.3-70B (p = 0.79) with a quarter of the parameters β€” and GPT-OSS 120B is indistinguishable from GPT-OSS 20B (p = 0.91, Ξ” = +0.3 pp).</p>
322
+ </article>
323
+ <article class="finding reveal">
324
+ <span class="f-no">Finding 3</span>
325
+ <p class="f-stat">35.9 pp</p>
326
+ <h3>Numerical reasoning is the discriminator</h3>
327
+ <p>NUM shows the widest spread of any task β€” from 84.8% (Gemini 2.5 Flash) down to 48.9% (Gemini 2.5 Pro). If you want to tell models apart, ask them to do arithmetic inside statute.</p>
328
+ </article>
329
+ <article class="finding reveal">
330
+ <span class="f-no">Finding 4</span>
331
+ <p class="f-stat">48.9% vs 89.7%</p>
332
+ <h3>Capability dissociates within a single model</h3>
333
+ <p>Gemini 2.5 Pro ranks first on regulatory interpretation yet last on numerical reasoning β€” task-type performance can split wide open inside the same weights.</p>
334
+ </article>
335
+ <article class="finding reveal">
336
+ <span class="f-no">Finding 5</span>
337
+ <p class="f-stat">11th / 12</p>
338
+ <h3>Reasoning-specialised β‰  timeline-capable</h3>
339
+ <p>DeepSeek R1 70B, built for chain-of-thought, ranks 11th overall and manages only 70.5% on temporal reasoning β€” general deliberation does not transfer to supersession chains.</p>
340
+ </article>
341
+ <article class="finding reveal">
342
+ <span class="f-no">Finding 6</span>
343
+ <p class="f-stat">12 / 12 &gt; human</p>
344
+ <h3>Every model beats the human baseline</h3>
345
+ <p>Human experts score 69.0% (n = 100, CI [59.4, 77.2]). All twelve models exceed it β€” yet the best still miss one answer in ten, in a domain where the answer is a legal obligation.</p>
346
+ </article>
347
+ </div>
348
+ </section>
349
+
350
+ <!-- ════════ Β§06 THE RETRIEVAL ════════ -->
351
+ <section class="chapter ch-alt" id="retrieval">
352
+ <header class="ch-head reveal">
353
+ <span class="ch-no">Β§ 06</span>
354
+ <h2 class="ch-title">The Retrieval</h2>
355
+ <p class="ch-lede">The benchmark closes the book. The retrieval system opens it.</p>
356
+ </header>
357
+
358
+ <p class="prose prose-narrow reveal">
359
+ IndiaFinBench evaluates closed-book reading. Its open-book counterpart is a hybrid
360
+ retrieval system over the same 192-document corpus: dense semantic search and sparse
361
+ lexical search run in parallel, fused by reciprocal rank β€” because regulatory text,
362
+ saturated with citation identifiers, rewards exact matching as much as meaning.
363
+ </p>
364
+
365
+ <figure class="rag-diagram reveal" aria-label="Hybrid retrieval pipeline">
366
+ <svg viewBox="0 0 940 240" xmlns="http://www.w3.org/2000/svg" role="img">
367
+ <defs>
368
+ <marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
369
+ <path d="M0 0L10 5L0 10z" fill="currentColor"/>
370
+ </marker>
371
+ </defs>
372
+ <g class="rd-node" transform="translate(10,90)">
373
+ <rect width="130" height="60" rx="3"/>
374
+ <text x="65" y="28" text-anchor="middle" class="rd-t1">Query</text>
375
+ <text x="65" y="45" text-anchor="middle" class="rd-t2">natural language</text>
376
+ </g>
377
+ <path class="rd-flow" d="M140 110 H 205 V 55 H 250" marker-end="url(#arr)"/>
378
+ <path class="rd-flow" d="M140 130 H 205 V 185 H 250" marker-end="url(#arr)"/>
379
+ <g class="rd-node rd-dense" transform="translate(250,25)">
380
+ <rect width="190" height="60" rx="3"/>
381
+ <text x="95" y="28" text-anchor="middle" class="rd-t1">Dense Β· FAISS</text>
382
+ <text x="95" y="45" text-anchor="middle" class="rd-t2">BGE Β· 768-d Β· 4,347 vectors</text>
383
+ </g>
384
+ <g class="rd-node rd-sparse" transform="translate(250,155)">
385
+ <rect width="190" height="60" rx="3"/>
386
+ <text x="95" y="28" text-anchor="middle" class="rd-t1">Sparse Β· BM25</text>
387
+ <text x="95" y="45" text-anchor="middle" class="rd-t2">lexical Β· 1,600-char chunks</text>
388
+ </g>
389
+ <path class="rd-flow" d="M440 55 H 505 V 105 H 530" marker-end="url(#arr)"/>
390
+ <path class="rd-flow" d="M440 185 H 505 V 135 H 530" marker-end="url(#arr)"/>
391
+ <g class="rd-node rd-rrf" transform="translate(530,90)">
392
+ <rect width="160" height="60" rx="3"/>
393
+ <text x="80" y="28" text-anchor="middle" class="rd-t1">RRF fusion</text>
394
+ <text x="80" y="45" text-anchor="middle" class="rd-t2">reciprocal rank Β· k = 60</text>
395
+ </g>
396
+ <path class="rd-flow" d="M690 120 H 780" marker-end="url(#arr)"/>
397
+ <g class="rd-node" transform="translate(780,90)">
398
+ <rect width="150" height="60" rx="3"/>
399
+ <text x="75" y="28" text-anchor="middle" class="rd-t1">Answer</text>
400
+ <text x="75" y="45" text-anchor="middle" class="rd-t2">LLaMA-3.3-70B Β· cited</text>
401
+ </g>
402
+ </svg>
403
+ </figure>
404
+
405
+ <div class="panel reveal">
406
+ <div class="panel-bar">
407
+ <div>
408
+ <h3 class="panel-title">Retrieval ablation</h3>
409
+ <p class="panel-sub">Six configurations. Hybrid fusion gains +9.7 points of Recall@5 over dense-only; BM25 alone wins MRR β€” lexical structure matters in law.</p>
410
+ </div>
411
+ </div>
412
+ <div class="table-scroll">
413
+ <table class="gz-table">
414
+ <thead><tr><th>Configuration</th><th class="c">Recall@5</th><th class="c">MRR</th><th class="c">p50 latency</th></tr></thead>
415
+ <tbody>
416
+ <tr><td>Dense only <span class="cfg">B0</span></td><td class="c mono">0.688</td><td class="c mono">0.542</td><td class="c mono">48 ms</td></tr>
417
+ <tr><td>BM25 only <span class="cfg">B1</span></td><td class="c mono">0.764</td><td class="c mono"><strong>0.674</strong></td><td class="c mono">30 ms</td></tr>
418
+ <tr class="row-hi"><td><strong>Hybrid RRF</strong> <span class="cfg">B2</span> <span class="cfg-pick">selected</span></td><td class="c mono"><strong>0.785</strong></td><td class="c mono">0.640</td><td class="c mono">77 ms</td></tr>
419
+ <tr><td>Small chunks, 800 chars <span class="cfg">B3</span></td><td class="c mono">0.583</td><td class="c mono">0.493</td><td class="c mono">138 ms</td></tr>
420
+ <tr><td>Large chunks, 2,400 chars <span class="cfg">B4</span></td><td class="c mono">0.542</td><td class="c mono">0.410</td><td class="c mono">71 ms</td></tr>
421
+ <tr><td>Hybrid, k = 10 <span class="cfg">B5</span></td><td class="c mono">0.785</td><td class="c mono">0.640</td><td class="c mono">78 ms</td></tr>
422
+ </tbody>
423
+ </table>
424
+ </div>
425
+ </div>
426
+
427
+ <div class="panel panel-live reveal" id="ragPanel">
428
+ <div class="panel-bar">
429
+ <div>
430
+ <h3 class="panel-title"><span class="live-dot" aria-hidden="true"></span>Ask the corpus</h3>
431
+ <p class="panel-sub">Live hybrid retrieval over all 192 documents β€” every claim sourced, every source scored.</p>
432
+ </div>
433
+ </div>
434
+ <div class="rag-row">
435
+ <input class="rag-input" id="ragQ" type="text" placeholder="What is the minimum capital adequacy ratio for banks?" aria-label="Question for the regulatory corpus">
436
+ <button class="btn btn-ink" id="ragBtn">Retrieve</button>
437
+ </div>
438
+ <div class="rag-examples" aria-label="Example queries">
439
+ <button class="chip" data-q="What is the circuit breaker limit for NSE stocks?">Circuit breaker limits</button>
440
+ <button class="chip" data-q="What is SEBI's definition of insider trading?">Insider trading definition</button>
441
+ <button class="chip" data-q="What is the minimum capital adequacy ratio for banks?">Capital adequacy ratio</button>
442
+ <button class="chip" data-q="What are the KYC requirements for opening a bank account?">KYC requirements</button>
443
+ </div>
444
+ <div class="rag-out" id="ragOut" hidden>
445
+ <div class="rag-status" id="ragStatus" hidden><span class="spinner" aria-hidden="true"></span><span id="ragStatusText">Retrieving from corpus…</span></div>
446
+ <div class="rag-answer" id="ragAnswer"></div>
447
+ <div class="rag-sources" id="ragSources"></div>
448
+ </div>
449
+ </div>
450
+ </section>
451
+
452
+ <!-- ════════ Β§07 THE ACCESS ════════ -->
453
+ <section class="chapter" id="access">
454
+ <header class="ch-head reveal">
455
+ <span class="ch-no">Β§ 07</span>
456
+ <h2 class="ch-title">The Access</h2>
457
+ <p class="ch-lede">An open dataset, an open leaderboard, an open invitation.</p>
458
+ </header>
459
+
460
+ <div class="panel reveal" id="explorerPanel">
461
+ <div class="panel-bar">
462
+ <div>
463
+ <h3 class="panel-title">Examine a specimen</h3>
464
+ <p class="panel-sub">Draw a random item from the 406 β€” filtered by task and difficulty, answer sealed until you ask.</p>
465
+ </div>
466
+ <div class="explorer-controls">
467
+ <select class="select" id="exTask" aria-label="Task type">
468
+ <option value="All">All tasks</option>
469
+ <option value="Regulatory Interpretation">Regulatory Interpretation</option>
470
+ <option value="Numerical Reasoning">Numerical Reasoning</option>
471
+ <option value="Contradiction Detection">Contradiction Detection</option>
472
+ <option value="Temporal Reasoning">Temporal Reasoning</option>
473
+ </select>
474
+ <select class="select" id="exDiff" aria-label="Difficulty">
475
+ <option value="All">All difficulties</option>
476
+ <option value="Easy">Easy</option>
477
+ <option value="Medium">Medium</option>
478
+ <option value="Hard">Hard</option>
479
+ </select>
480
+ <button class="btn btn-ink" id="exBtn">Draw item</button>
481
+ </div>
482
+ </div>
483
+ <div class="ex-card" id="exCard" hidden>
484
+ <div class="ex-meta"><span class="ex-id mono" id="exId"></span><span class="ex-badge" id="exTaskBadge"></span><span class="ex-badge" id="exDiffBadge"></span></div>
485
+ <p class="ex-label">Context</p>
486
+ <blockquote class="ex-context" id="exContext"></blockquote>
487
+ <p class="ex-label">Question</p>
488
+ <p class="ex-question" id="exQuestion"></p>
489
+ <button class="btn btn-line btn-sm" id="exReveal">Unseal answer</button>
490
+ <div class="ex-answer" id="exAnswer" hidden><p class="ex-label">Gold answer</p><p class="mono" id="exAnswerText"></p></div>
491
+ </div>
492
+ </div>
493
+
494
+ <div class="access-grid">
495
+ <div class="panel reveal">
496
+ <div class="panel-bar"><div><h3 class="panel-title">Submit a model</h3><p class="panel-sub">Any public HuggingFace model, evaluated zero-shot on all 406 items with four-stage scoring. Results join the leaderboard with Wilson CIs.</p></div></div>
497
+ <div class="form-grid">
498
+ <label class="field"><span>HuggingFace model ID <em>*</em></span><input class="input" id="hfId" type="text" placeholder="mistralai/Mistral-7B-Instruct-v0.3"></label>
499
+ <label class="field"><span>Display name</span><input class="input" id="dispName" type="text" placeholder="Mistral-7B"></label>
500
+ <label class="field"><span>Parameters</span><input class="input" id="modelParams" type="text" placeholder="7B"></label>
501
+ <label class="field"><span>Model type</span>
502
+ <select class="select" id="mtype"><option>Frontier API</option><option>Open-weight API</option><option>Local (Ollama)</option><option>Reasoning API</option></select>
503
+ </label>
504
+ </div>
505
+ <div class="form-foot">
506
+ <button class="btn btn-ink" id="subBtn">Open submission issue</button>
507
+ <p class="status" id="statusBox" role="status" hidden></p>
508
+ </div>
509
+ </div>
510
+
511
+ <div class="panel reveal">
512
+ <div class="panel-bar"><div><h3 class="panel-title">Cite the record</h3><p class="panel-sub">Dataset CC BY 4.0 Β· code MIT Β· source documents public domain (Government of India).</p></div></div>
513
+ <div class="cite-box">
514
+ <button class="copy-btn" id="copyBtn">Copy</button>
515
+ <pre class="mono" id="citeText">{% raw %}@article{pall2026indiafinbench,
516
+ title = {{IndiaFinBench}: An Evaluation Benchmark
517
+ for LLM Performance on Indian Financial
518
+ Regulatory Text},
519
+ author = {Pall, Rajveer Singh},
520
+ journal = {Proceedings of EMNLP},
521
+ year = {2026},
522
+ url = {https://github.com/Rajveer-code/IndiaFinBench}
523
+ }{% endraw %}</pre>
524
+ </div>
525
+ <div class="access-links">
526
+ <a class="btn btn-line" href="https://huggingface.co/datasets/Rajveer-code/IndiaFinBench" target="_blank" rel="noopener">Dataset on HuggingFace</a>
527
+ <a class="btn btn-line" href="https://github.com/Rajveer-code/IndiaFinBench" target="_blank" rel="noopener">Code on GitHub</a>
528
+ </div>
529
+ </div>
530
+ </div>
531
+ </section>
532
+
533
+ </main>
534
+
535
+ <!-- ════════ FOOTER ════════ -->
536
+ <footer class="footer">
537
+ <div class="footer-rule" aria-hidden="true"></div>
538
+ <p class="footer-brand"><span class="brand-seal">Β§</span> India<em>Fin</em>Bench</p>
539
+ <p class="footer-line">Rajveer Singh Pall Β· Gyan Ganga Institute of Technology and Sciences Β· <a href="mailto:rajveerpall04@gmail.com">rajveerpall04@gmail.com</a></p>
540
+ <p class="footer-sub">406 questions Β· 192 documents Β· 12 models Β· zero-shot Β· dataset CC BY 4.0 Β· code MIT</p>
541
+ </footer>
542
+
543
+ <script src="/static/js/data.js?v={{ js_ver }}"></script>
544
+ <script src="/static/js/main.js?v={{ js_ver }}" defer></script>
545
+ <script src="/static/js/archive-scene.js?v={{ js_ver }}" defer></script>
546
+ </body>
547
+ </html>
demo/tests/__init__.py ADDED
File without changes
demo/tests/test_app.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for demo/app.py API endpoints.
3
+ Run from repo root: pytest demo/tests/test_app.py -v
4
+ """
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ # Mirror the exact sys.path setup that demo/app.py uses at runtime:
10
+ # ROOT first (so `import rag` resolves to /repo/rag/), DEMO second.
11
+ _DEMO_DIR = Path(__file__).parent.parent # demo/
12
+ _ROOT_DIR = _DEMO_DIR.parent # repo root
13
+
14
+ if str(_ROOT_DIR) not in sys.path:
15
+ sys.path.insert(0, str(_ROOT_DIR))
16
+ if str(_DEMO_DIR) not in sys.path:
17
+ sys.path.insert(1, str(_DEMO_DIR))
18
+
19
+ import pytest
20
+
21
+ # Importing app triggers init_db() and the RAG warmup background thread.
22
+ from app import app as flask_app
23
+
24
+
25
+ @pytest.fixture
26
+ def client():
27
+ flask_app.config["TESTING"] = True
28
+ with flask_app.test_client() as c:
29
+ yield c
30
+
31
+
32
+ class TestIndex:
33
+ def test_returns_200(self, client):
34
+ r = client.get("/")
35
+ assert r.status_code == 200
36
+
37
+ def test_contains_brand(self, client):
38
+ r = client.get("/")
39
+ assert b"IndiaFinBench" in r.data
40
+
41
+
42
+ class TestLeaderboard:
43
+ def test_returns_json_list(self, client):
44
+ r = client.get("/api/leaderboard")
45
+ assert r.status_code == 200
46
+ data = json.loads(r.data)
47
+ assert isinstance(data, list)
48
+
49
+ def test_has_at_least_9_models(self, client):
50
+ r = client.get("/api/leaderboard")
51
+ data = json.loads(r.data)
52
+ models = [m for m in data if not m.get("is_human")]
53
+ assert len(models) >= 9
54
+
55
+ def test_each_model_has_required_keys(self, client):
56
+ r = client.get("/api/leaderboard")
57
+ data = json.loads(r.data)
58
+ for m in data:
59
+ for key in ("label", "overall", "reg", "num", "con", "tmp"):
60
+ assert key in m, f"Missing key '{key}' in model {m.get('label')}"
61
+
62
+
63
+ class TestExample:
64
+ def test_returns_question(self, client):
65
+ r = client.get("/api/example")
66
+ assert r.status_code == 200
67
+ data = json.loads(r.data)
68
+ assert "question" in data
69
+ assert "answer" in data
70
+ assert "task_type" in data
71
+
72
+ def test_task_filter(self, client):
73
+ r = client.get("/api/example?task=Regulatory+Interpretation")
74
+ assert r.status_code == 200
75
+ data = json.loads(r.data)
76
+ assert data.get("task_type") == "Regulatory Interpretation"
77
+
78
+ def test_unknown_filter_returns_error(self, client):
79
+ r = client.get("/api/example?task=NotATask&diff=NotADiff")
80
+ assert r.status_code == 200
81
+ data = json.loads(r.data)
82
+ assert "error" in data
83
+
84
+
85
+ class TestSubmit:
86
+ def test_returns_issue_url(self, client):
87
+ r = client.post(
88
+ "/api/submit",
89
+ data=json.dumps({"hf_id": "meta-llama/Llama-3.2-3B", "label": "Llama-3.2-3B"}),
90
+ content_type="application/json",
91
+ )
92
+ assert r.status_code == 200
93
+ data = json.loads(r.data)
94
+ assert "issue_url" in data
95
+ assert "github.com/Rajveer-code/IndiaFinBench/issues/new" in data["issue_url"]
96
+ assert "Llama-3.2-3B" in data["issue_url"]
97
+
98
+ def test_hf_id_in_issue_url(self, client):
99
+ r = client.post(
100
+ "/api/submit",
101
+ data=json.dumps({"hf_id": "mistralai/Mistral-7B-v0.3"}),
102
+ content_type="application/json",
103
+ )
104
+ data = json.loads(r.data)
105
+ assert "issue_url" in data
106
+ assert "mistralai" in data["issue_url"]
107
+
108
+ def test_missing_hf_id_returns_400(self, client):
109
+ r = client.post(
110
+ "/api/submit",
111
+ data=json.dumps({}),
112
+ content_type="application/json",
113
+ )
114
+ assert r.status_code == 400
115
+ data = json.loads(r.data)
116
+ assert "error" in data
117
+
118
+ def test_no_job_id_in_response(self, client):
119
+ r = client.post(
120
+ "/api/submit",
121
+ data=json.dumps({"hf_id": "some/model"}),
122
+ content_type="application/json",
123
+ )
124
+ data = json.loads(r.data)
125
+ assert "job_id" not in data
126
+
127
+
128
+ class TestRag:
129
+ def test_empty_query_returns_400(self, client):
130
+ r = client.post(
131
+ "/api/rag",
132
+ data=json.dumps({"query": ""}),
133
+ content_type="application/json",
134
+ )
135
+ assert r.status_code == 400
136
+
137
+ def test_missing_query_key_returns_400(self, client):
138
+ r = client.post(
139
+ "/api/rag",
140
+ data=json.dumps({}),
141
+ content_type="application/json",
142
+ )
143
+ assert r.status_code == 400
rag/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ rag/
3
+ ----
4
+ Local FAISS + BM25 hybrid RAG pipeline for the IndiaFinBench corpus.
5
+
6
+ Quick start:
7
+ from rag.pipeline import RAGPipeline
8
+ pipe = RAGPipeline()
9
+ pipe.load_index() # load pre-built index
10
+ result = pipe.ask("What are the KYC norms under SEBI?")
11
+ """
12
+
13
+ # Lazy import β€” do not eagerly pull in pipeline here; heavy deps may not be installed.
14
+ # Users should import directly: from rag.pipeline import RAGPipeline
rag/bm25_index.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ rag/bm25_index.py
3
+ -----------------
4
+ BM25Okapi index for lexical retrieval over chunk texts.
5
+
6
+ Tokenisation strategy:
7
+ Lowercase + strip punctuation EXCEPT hyphens and parentheses.
8
+ Rationale: Indian regulatory text contains tokens like "91-day", "4(2)(b)",
9
+ "Section-51A" whose internal punctuation carries semantic meaning.
10
+ Standard whitespace tokenisation after these targeted strips preserves them.
11
+
12
+ BM25 parameters:
13
+ k1=1.5 β€” term frequency saturation (standard Okapi default)
14
+ b=0.75 β€” document length normalisation (standard Okapi default)
15
+ These are not tuned; the corpus is too small to warrant grid search.
16
+ """
17
+
18
+ import pickle
19
+ import re
20
+ from pathlib import Path
21
+
22
+ from rank_bm25 import BM25Okapi
23
+
24
+ from rag.models import ChunkRecord
25
+
26
+
27
+ # Strip punctuation that is NOT part of regulatory term identifiers
28
+ _STRIP_RE = re.compile(r"[^\w\s\-()]")
29
+
30
+
31
+ class BM25Index:
32
+ def __init__(self) -> None:
33
+ self._bm25: BM25Okapi | None = None
34
+ self._chunks: list[ChunkRecord] = []
35
+
36
+ # ── Tokenisation ──────────────────────────────────────────────────────────
37
+
38
+ @staticmethod
39
+ def tokenise(text: str) -> list[str]:
40
+ text = text.lower()
41
+ text = _STRIP_RE.sub(" ", text)
42
+ return text.split()
43
+
44
+ # ── Build ─────────────────────────────────────────────────────────────────
45
+
46
+ def build(self, chunks: list[ChunkRecord]) -> None:
47
+ self._chunks = list(chunks)
48
+ tokenised = [self.tokenise(c.text) for c in chunks]
49
+ self._bm25 = BM25Okapi(tokenised, k1=1.5, b=0.75)
50
+
51
+ # ── Query ─────────────────────────────────────────────────────────────────
52
+
53
+ def search(self, query: str, k: int) -> list[tuple[ChunkRecord, float]]:
54
+ """Return up to k (chunk, bm25_score) pairs, descending by score."""
55
+ if self._bm25 is None:
56
+ raise RuntimeError("BM25Index not built. Call build() or load() first.")
57
+ tokens = self.tokenise(query)
58
+ scores = self._bm25.get_scores(tokens)
59
+ top_k = min(k, len(self._chunks))
60
+ ranked = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:top_k]
61
+ return [(self._chunks[i], float(scores[i])) for i in ranked]
62
+
63
+ # ── Persistence ───────────────────────────────────────────────────────────
64
+
65
+ def save(self, index_dir: Path) -> None:
66
+ index_dir = Path(index_dir)
67
+ index_dir.mkdir(parents=True, exist_ok=True)
68
+ with open(index_dir / "bm25.pkl", "wb") as fh:
69
+ pickle.dump((self._bm25, self._chunks), fh)
70
+
71
+ @classmethod
72
+ def load(cls, index_dir: Path) -> "BM25Index":
73
+ obj = cls()
74
+ with open(Path(index_dir) / "bm25.pkl", "rb") as fh:
75
+ obj._bm25, obj._chunks = pickle.load(fh)
76
+ return obj
77
+
78
+ @property
79
+ def size(self) -> int:
80
+ return len(self._chunks)
rag/chunking.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ rag/chunking.py
3
+ ---------------
4
+ Recursive character-level text splitter that respects paragraph, sentence,
5
+ and word boundaries β€” in that priority order.
6
+
7
+ Algorithm (same invariant as LangChain's RecursiveCharacterTextSplitter):
8
+ 1. Try each separator in SEPARATORS, highest-priority first.
9
+ 2. On the first separator found in the text, split and merge fragments
10
+ back into chunks ≀ target_chunk_chars, carrying overlap_chars of
11
+ context from the tail of each chunk into the next.
12
+ 3. Any merged chunk still over target_chunk_chars is recursively split
13
+ with the remaining lower-priority separators.
14
+ 4. Chunks below min_chunk_chars (degenerate headers/footers) are discarded.
15
+
16
+ Separators prioritised for Indian regulatory text:
17
+ \\n\\n > \\n > ". " > "; " > ", " > " " > "" (character-level fallback)
18
+ """
19
+
20
+ from rag.models import ChunkRecord, Document
21
+
22
+
23
+ _SEPARATORS = ["\n\n", "\n", ". ", "! ", "? ", "; ", ", ", " ", ""]
24
+
25
+
26
+ class RecursiveCharacterSplitter:
27
+ def __init__(
28
+ self,
29
+ target_chunk_chars: int = 1600,
30
+ overlap_chars: int = 200,
31
+ min_chunk_chars: int = 100,
32
+ separators: list[str] | None = None,
33
+ ) -> None:
34
+ self.target = target_chunk_chars
35
+ self.overlap = overlap_chars
36
+ self.min_size = min_chunk_chars
37
+ self.seps = separators if separators is not None else _SEPARATORS
38
+
39
+ # ── Public API ────────────────────────────────────────────────────────────
40
+
41
+ def split_document(self, doc: Document) -> list[ChunkRecord]:
42
+ fragments = self._split_recursive(doc.raw_text, self.seps)
43
+ records: list[ChunkRecord] = []
44
+ search_from = 0
45
+ for idx, text in enumerate(fragments):
46
+ # Best-effort character offset tracking.
47
+ # Use the first 60 chars as a stable anchor since overlap means
48
+ # the same text may appear twice near the split boundary.
49
+ anchor = text[:60]
50
+ pos = doc.raw_text.find(anchor, search_from)
51
+ char_start = pos if pos != -1 else search_from
52
+ char_end = char_start + len(text)
53
+ records.append(ChunkRecord(
54
+ chunk_id = f"{doc.doc_id}__{idx:04d}",
55
+ doc_id = doc.doc_id,
56
+ title = doc.title,
57
+ source = doc.source,
58
+ text = text,
59
+ chunk_idx = idx,
60
+ char_start = char_start,
61
+ char_end = char_end,
62
+ ))
63
+ # Advance past this chunk, minus the overlap window
64
+ search_from = max(0, char_end - self.overlap)
65
+ return records
66
+
67
+ # ── Core splitting logic ──────────────────────────────────────────────────
68
+
69
+ def _split_recursive(self, text: str, separators: list[str]) -> list[str]:
70
+ if len(text) <= self.target:
71
+ return [text] if len(text) >= self.min_size else []
72
+
73
+ if not separators:
74
+ # Character-level hard fallback: slice at target with overlap stride
75
+ result: list[str] = []
76
+ stride = max(1, self.target - self.overlap)
77
+ for i in range(0, len(text), stride):
78
+ chunk = text[i : i + self.target]
79
+ if len(chunk) >= self.min_size:
80
+ result.append(chunk)
81
+ return result
82
+
83
+ sep, *remaining = separators
84
+
85
+ if sep not in text:
86
+ return self._split_recursive(text, remaining)
87
+
88
+ # Split on this separator and merge into target-sized chunks
89
+ frags = text.split(sep)
90
+ merged = self._merge_with_overlap(frags, sep)
91
+
92
+ # Recursively split any chunk still above target
93
+ final: list[str] = []
94
+ for chunk in merged:
95
+ if len(chunk) > self.target and remaining:
96
+ final.extend(self._split_recursive(chunk, remaining))
97
+ else:
98
+ final.append(chunk)
99
+ return final
100
+
101
+ def _merge_with_overlap(self, frags: list[str], sep: str) -> list[str]:
102
+ """
103
+ Merge a list of text fragments into chunks ≀ target_chars.
104
+ After emitting a chunk, carry its last overlap_chars into the next
105
+ chunk to preserve cross-boundary context.
106
+ """
107
+ chunks: list[str] = []
108
+ current: list[str] = [] # fragments in the current chunk
109
+ current_len: int = 0
110
+
111
+ for frag in frags:
112
+ sep_cost = len(sep) if current else 0
113
+ addition = sep_cost + len(frag)
114
+
115
+ if current_len + addition > self.target and current:
116
+ # Emit current chunk
117
+ chunk_text = sep.join(current)
118
+ if len(chunk_text) >= self.min_size:
119
+ chunks.append(chunk_text)
120
+
121
+ # Carry overlap: walk backwards through current fragments
122
+ overlap_frags: list[str] = []
123
+ overlap_len: int = 0
124
+ for f in reversed(current):
125
+ cost = (len(sep) if overlap_frags else 0) + len(f)
126
+ if overlap_len + cost > self.overlap:
127
+ break
128
+ overlap_frags.insert(0, f)
129
+ overlap_len += cost
130
+
131
+ current = overlap_frags + [frag]
132
+ current_len = sum(
133
+ len(f) + (len(sep) if i > 0 else 0)
134
+ for i, f in enumerate(current)
135
+ )
136
+ else:
137
+ current.append(frag)
138
+ current_len += addition
139
+
140
+ # Flush the last chunk
141
+ if current:
142
+ chunk_text = sep.join(current)
143
+ if len(chunk_text) >= self.min_size:
144
+ chunks.append(chunk_text)
145
+
146
+ return chunks
rag/config.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ rag/config.py
3
+ -------------
4
+ Single source of truth for all RAG hyperparameters.
5
+ Override via environment variables or by constructing RAGConfig with explicit values.
6
+ """
7
+
8
+ import os
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+
12
+
13
+ @dataclass
14
+ class RAGConfig:
15
+ # ── Chunking ──────────────────────────────────────────────────────────────
16
+ target_chunk_chars: int = 1600 # ~400 tokens at 4 chars/token for English legal text
17
+ overlap_chars: int = 200 # ~50-token overlap to preserve cross-boundary clauses
18
+ min_chunk_chars: int = 100 # discard degenerate micro-chunks
19
+
20
+ # ── Embedding ─────────────────────────────────────────────────────────────
21
+ embedding_model: str = "BAAI/bge-base-en-v1.5" # 768-dim, 512-token max
22
+ embedding_batch_size: int = 64
23
+ embedding_device: str = "cpu"
24
+
25
+ # ── Retrieval ─────────────────────────────────────────────────────────────
26
+ top_k: int = 5 # final chunks injected into generation context
27
+ candidates: int = 20 # candidates fetched from each retriever before RRF
28
+ rrf_k: int = 60 # RRF constant (Cormack et al. 2009 default)
29
+ max_per_source: int = 3 # diversity cap: max chunks from a single "rbi"/"sebi" source
30
+
31
+ # ── Generation ────────────────────────────────────────────────────────────
32
+ llm_backend: str = field(default_factory=lambda: os.getenv("RAG_LLM_BACKEND", "groq"))
33
+ groq_model: str = "llama-3.3-70b-versatile"
34
+ ollama_model: str = "llama3.2:3b"
35
+ temperature: float = 0.0 # deterministic; critical for reproducible evaluation
36
+ max_tokens: int = 512
37
+
38
+ # ── Paths ─────────────────────────────────────────────────────────────────
39
+ data_dir: Path = field(default_factory=lambda: Path("data/parsed"))
40
+ index_dir: Path = field(default_factory=lambda: Path("rag/index"))
41
+
42
+ def __post_init__(self) -> None:
43
+ self.data_dir = Path(self.data_dir)
44
+ self.index_dir = Path(self.index_dir)
rag/data_loader.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ rag/data_loader.py
3
+ ------------------
4
+ Scan data/parsed/{rbi,sebi}/*.txt and return a flat list of Document objects.
5
+ Title extraction parses the filename convention used by the IndiaFinBench corpus:
6
+ {SOURCE}_{ShortLabel}_{full_slug}_{index}.txt
7
+ e.g. RBI_Master Dir_master_direction_-_..._084.txt β†’ title "RBI β€” Master Dir"
8
+ """
9
+
10
+ import re
11
+ from pathlib import Path
12
+
13
+ from rag.models import Document
14
+
15
+
16
+ class DataLoader:
17
+ SOURCES = ("rbi", "sebi")
18
+
19
+ def __init__(self, data_dir: Path | str) -> None:
20
+ self.data_dir = Path(data_dir)
21
+
22
+ def load(self) -> list[Document]:
23
+ docs: list[Document] = []
24
+ for source in self.SOURCES:
25
+ source_dir = self.data_dir / source
26
+ if not source_dir.exists():
27
+ continue
28
+ for fpath in sorted(source_dir.glob("*.txt")):
29
+ text = fpath.read_text(encoding="utf-8", errors="replace")
30
+ docs.append(Document(
31
+ doc_id=fpath.stem,
32
+ title=self._parse_title(fpath.stem, source),
33
+ source=source,
34
+ raw_text=text,
35
+ file_path=str(fpath.resolve()),
36
+ ))
37
+ return docs
38
+
39
+ @staticmethod
40
+ def _parse_title(stem: str, source: str) -> str:
41
+ """
42
+ Filename format: {SRC}_{ShortLabel}_{long_slug}_{index}
43
+ Extract the short label (second underscore-delimited field).
44
+ """
45
+ parts = stem.split("_", 2)
46
+ if len(parts) >= 2:
47
+ label = parts[1].strip()
48
+ # Collapse runs of spaces introduced by the naming convention
49
+ label = re.sub(r"\s+", " ", label)
50
+ return f"{source.upper()} β€” {label}"
51
+ return stem
rag/embeddings.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ rag/embeddings.py
3
+ -----------------
4
+ Thin wrapper around sentence-transformers for BGE-base-en-v1.5.
5
+
6
+ BGE asymmetric encoding (important):
7
+ - Corpus documents: encoded WITHOUT any prefix.
8
+ - Queries: encoded WITH the prefix "Represent this sentence for searching
9
+ relevant passages: " as specified by BAAI for bge-base-en-v1.5.
10
+ Skipping the query prefix causes a measurable recall drop (~3–5% on MTEB).
11
+
12
+ All output embeddings are L2-normalised so that inner product = cosine similarity.
13
+ This is enforced here β€” callers must not re-normalise.
14
+ """
15
+
16
+ import numpy as np
17
+ from sentence_transformers import SentenceTransformer
18
+
19
+
20
+ _QUERY_PREFIX = "Represent this sentence for searching relevant passages: "
21
+
22
+
23
+ class BGEEmbedder:
24
+ def __init__(
25
+ self,
26
+ model_name: str = "BAAI/bge-base-en-v1.5",
27
+ device: str = "cpu",
28
+ batch_size: int = 64,
29
+ ) -> None:
30
+ self._model = SentenceTransformer(model_name, device=device)
31
+ self.batch_size = batch_size
32
+ self.dim: int = self._model.get_sentence_embedding_dimension()
33
+
34
+ def encode_corpus(self, texts: list[str], show_progress: bool = True) -> np.ndarray:
35
+ """
36
+ Encode a list of corpus texts.
37
+ Returns float32 array of shape (len(texts), dim), L2-normalised.
38
+ """
39
+ embeddings = self._model.encode(
40
+ texts,
41
+ batch_size=self.batch_size,
42
+ show_progress_bar=show_progress,
43
+ normalize_embeddings=True,
44
+ convert_to_numpy=True,
45
+ )
46
+ return embeddings.astype(np.float32)
47
+
48
+ def encode_query(self, query: str) -> np.ndarray:
49
+ """
50
+ Encode a single query with the BGE query prefix.
51
+ Returns float32 array of shape (1, dim), L2-normalised.
52
+ """
53
+ embedding = self._model.encode(
54
+ _QUERY_PREFIX + query,
55
+ normalize_embeddings=True,
56
+ convert_to_numpy=True,
57
+ )
58
+ return embedding.astype(np.float32).reshape(1, -1)
rag/evaluation.py ADDED
@@ -0,0 +1,855 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ rag/evaluation.py
3
+ -----------------
4
+ Phase 3 evaluation framework.
5
+
6
+ Architecture principle: retrieval evaluation and generation evaluation are
7
+ STRICTLY SEPARATED. This makes failure attribution unambiguous.
8
+
9
+ Stage 1 β€” Retrieval-only (no LLM calls):
10
+ Recall@k, MRR, Precision@k for all B0–B5 ablation configs.
11
+
12
+ Stage 2 β€” Full pipeline (retrieval + generation + judge):
13
+ Faithfulness (Gemini 1.5 Flash as judge), Answer Relevance.
14
+ Run only on B2 (proposed) and B0 (dense baseline) to manage quota.
15
+
16
+ Eval dataset (50 items):
17
+ - 35 synthetic: generated by Gemini from corpus documents, with
18
+ verbatim_span used to locate ground-truth chunk(s).
19
+ - 15 adversarial: hardcoded to stress failure modes.
20
+ - Loaded from data/eval/eval_set.json if present; generated otherwise.
21
+
22
+ Config snapshot: frozen at eval start; written alongside results JSON so
23
+ every report is self-contained and reproducible.
24
+ """
25
+
26
+ import json
27
+ import logging
28
+ import os
29
+ import random
30
+ import time
31
+ from dataclasses import asdict, dataclass, field
32
+ from difflib import SequenceMatcher
33
+ from pathlib import Path
34
+ from typing import Any
35
+
36
+ import numpy as np
37
+
38
+ from rag.bm25_index import BM25Index
39
+ from rag.config import RAGConfig
40
+ from rag.embeddings import BGEEmbedder
41
+ from rag.index import FAISSIndex
42
+ from rag.models import ChunkRecord
43
+ from rag.retriever import HybridRetriever
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+ # ── Faithfulness judge: prompt version tracking ───────────────────────────────
48
+ # Increment when the prompt text changes so results remain comparable across runs.
49
+ # LLM-as-judge caveat: Gemini 1.5 Flash is itself a language model and may exhibit
50
+ # systematic biases (e.g., awarding higher faithfulness to longer, confident-sounding
51
+ # answers regardless of factual grounding). Scores should be interpreted as
52
+ # approximate signal, not ground truth. Use consistent judge model + prompt version
53
+ # across all ablation runs to ensure internal comparability.
54
+ FAITHFULNESS_JUDGE_PROMPT_VERSION = "v1.0"
55
+ FAITHFULNESS_JUDGE_MODEL = "gemini-1.5-flash"
56
+
57
+ # ─────────────────────────────────────────────────────────────────────────────
58
+ # Data types
59
+ # ─────────────────────────────────────────────────────────────────────────────
60
+
61
+ @dataclass
62
+ class EvalItem:
63
+ qid: str
64
+ question: str
65
+ reference_answer: str
66
+ relevant_chunk_ids: list[str] # empty β†’ unanswerable / annotation pending
67
+ tier: str # "synthetic" | "adversarial"
68
+ source_doc: str # primary doc_id (empty string if N/A)
69
+
70
+
71
+ @dataclass
72
+ class RetrievalMetrics:
73
+ recall_at_k: float
74
+ mrr: float
75
+ precision_at_k: float
76
+ k: int
77
+ n_queries: int # queries with non-empty relevant_chunk_ids
78
+
79
+
80
+ @dataclass
81
+ class GenerationMetrics:
82
+ faithfulness: float
83
+ hallucination_free_rate: float
84
+ answer_relevance: float
85
+ n_queries: int
86
+
87
+
88
+ @dataclass
89
+ class FailureRecord:
90
+ qid: str
91
+ question: str
92
+ expected_chunk_ids: list[str]
93
+ retrieved_chunk_ids: list[str]
94
+ model_answer: str
95
+ error_type: str # "retrieval_miss" | "hallucination" | "both" | "unanswerable_correct" | "unanswerable_hallucinated"
96
+ recall: float
97
+ faithfulness: float # -1.0 if not computed
98
+
99
+
100
+ @dataclass
101
+ class LatencyStats:
102
+ mean_ms: float
103
+ p50_ms: float
104
+ p95_ms: float
105
+ n_queries: int
106
+
107
+
108
+ @dataclass
109
+ class RunResult:
110
+ config_id: str
111
+ config_snapshot: dict[str, Any]
112
+ retrieval_metrics: RetrievalMetrics
113
+ generation_metrics: GenerationMetrics | None
114
+ latency: LatencyStats
115
+ failures: list[FailureRecord]
116
+ elapsed_seconds: float
117
+
118
+
119
+ # ─────────────────────────────────────────────────────────────────────────────
120
+ # Retrieval metrics (pure functions β€” no LLM calls)
121
+ # ─────────────────────────────────────────────────────────────────────────────
122
+
123
+ def compute_recall_at_k(retrieved_ids: list[str], relevant_ids: list[str]) -> float:
124
+ if not relevant_ids:
125
+ return 0.0
126
+ return len(set(retrieved_ids) & set(relevant_ids)) / len(relevant_ids)
127
+
128
+
129
+ def compute_mrr(retrieved_ids: list[str], relevant_ids: list[str]) -> float:
130
+ relevant_set = set(relevant_ids)
131
+ for rank, cid in enumerate(retrieved_ids, 1):
132
+ if cid in relevant_set:
133
+ return 1.0 / rank
134
+ return 0.0
135
+
136
+
137
+ def compute_precision_at_k(retrieved_ids: list[str], relevant_ids: list[str]) -> float:
138
+ if not retrieved_ids:
139
+ return 0.0
140
+ return len(set(retrieved_ids) & set(relevant_ids)) / len(retrieved_ids)
141
+
142
+
143
+ # ─────────────────────────────────────────────────────────────────────────────
144
+ # Generation metrics
145
+ # ─────────────────────────────────────────────────────────────────────────────
146
+
147
+ def compute_answer_relevance(
148
+ query: str, answer: str, embedder: BGEEmbedder
149
+ ) -> float:
150
+ """Cosine similarity between query embedding and answer embedding."""
151
+ q_emb = embedder.encode_query(query) # (1, 768) L2-normalised
152
+ a_emb = embedder.encode_corpus([answer], show_progress=False) # (1, 768)
153
+ return float(np.dot(q_emb, a_emb.T))
154
+
155
+
156
+ def judge_faithfulness(
157
+ answer: str,
158
+ source_texts: list[str],
159
+ gemini_model: Any,
160
+ ) -> tuple[float, list[dict]]:
161
+ """
162
+ Use Gemini as a strict claim-attribution judge.
163
+
164
+ Returns (faithfulness_score, claims_list).
165
+ faithfulness_score = supported_claims / total_claims.
166
+ On parse failure returns (0.5, []) β€” conservative middle ground.
167
+ """
168
+ source_block = "\n\n".join(
169
+ f"[Source {i}] {t}" for i, t in enumerate(source_texts, 1)
170
+ )
171
+ prompt = (
172
+ "You are a strict fact-checker. Your ONLY job is claim attribution.\n\n"
173
+ f"SOURCES:\n{source_block}\n\n"
174
+ f"ANSWER:\n{answer}\n\n"
175
+ "For each distinct factual claim in ANSWER, determine if it is:\n"
176
+ " SUPPORTED: directly stated or unambiguously implied by a source\n"
177
+ " UNSUPPORTED: relies on knowledge absent from the sources\n\n"
178
+ "Output ONLY valid JSON, no other text:\n"
179
+ '{"claims": [{"text": "...", "supported": true, "source_ref": "[Source N] or null"}], '
180
+ '"faithfulness_score": <float 0-1>}'
181
+ )
182
+ try:
183
+ resp = gemini_model.generate_content(prompt)
184
+ raw = resp.text.strip()
185
+ # Strip markdown code fences if present
186
+ if raw.startswith("```"):
187
+ raw = "\n".join(raw.split("\n")[1:])
188
+ if raw.endswith("```"):
189
+ raw = raw[:-3]
190
+ data = json.loads(raw)
191
+ return float(data["faithfulness_score"]), data.get("claims", [])
192
+ except Exception as exc:
193
+ logger.warning("Faithfulness judge parse error: %s", exc)
194
+ return 0.5, []
195
+
196
+
197
+ # ─────────────────────────────────────────────────────────────────────────────
198
+ # Eval dataset: load or generate
199
+ # ─────────────────────────────────────────────────────────────────────────────
200
+
201
+ def _find_relevant_chunks(
202
+ verbatim_span: str, chunks: list[ChunkRecord], threshold: float = 0.70
203
+ ) -> list[str]:
204
+ """Locate chunk IDs containing or closely matching verbatim_span."""
205
+ span_lower = verbatim_span.lower().strip()
206
+ matches: list[str] = []
207
+ for chunk in chunks:
208
+ text_lower = chunk.text.lower()
209
+ if span_lower in text_lower:
210
+ matches.append(chunk.chunk_id)
211
+ elif len(span_lower) >= 40:
212
+ # Fuzzy match only for substantial spans (avoids false positives on short strings)
213
+ window = text_lower[: len(span_lower) + 100]
214
+ ratio = SequenceMatcher(None, span_lower, window).ratio()
215
+ if ratio >= threshold:
216
+ matches.append(chunk.chunk_id)
217
+ return matches
218
+
219
+
220
+ def _hardcoded_adversarial_items() -> list[EvalItem]:
221
+ """
222
+ 15 manually crafted adversarial items covering the IndiaFinBench failure modes.
223
+ relevant_chunk_ids is empty for unanswerable queries (correct behaviour =
224
+ "insufficient context"); annotators should fill cross-doc and ref queries.
225
+ """
226
+ return [
227
+ # ── Cross-document synthesis (4) ──────────────────────────────────────
228
+ EvalItem(
229
+ qid="adv_001",
230
+ question="What KYC verification obligations apply to both SEBI-registered portfolio managers and RBI-regulated commercial banks?",
231
+ reference_answer="ANNOTATION REQUIRED",
232
+ relevant_chunk_ids=[],
233
+ tier="adversarial",
234
+ source_doc="",
235
+ ),
236
+ EvalItem(
237
+ qid="adv_002",
238
+ question="How do SEBI's anti-money laundering requirements for FPIs compare with RBI's AML obligations for NBFCs?",
239
+ reference_answer="ANNOTATION REQUIRED",
240
+ relevant_chunk_ids=[],
241
+ tier="adversarial",
242
+ source_doc="",
243
+ ),
244
+ EvalItem(
245
+ qid="adv_003",
246
+ question="Which SEBI and RBI circulars jointly govern the treatment of beneficial ownership disclosures?",
247
+ reference_answer="ANNOTATION REQUIRED",
248
+ relevant_chunk_ids=[],
249
+ tier="adversarial",
250
+ source_doc="",
251
+ ),
252
+ EvalItem(
253
+ qid="adv_004",
254
+ question="What reporting obligations exist under both SEBI and RBI frameworks for entities on UAPA designated lists?",
255
+ reference_answer="ANNOTATION REQUIRED",
256
+ relevant_chunk_ids=[],
257
+ tier="adversarial",
258
+ source_doc="",
259
+ ),
260
+ # ── Exact regulatory references (4) ───────────────────────────────────
261
+ EvalItem(
262
+ qid="adv_005",
263
+ question="What does Section 51A of UAPA 1967 specifically require financial institutions to do?",
264
+ reference_answer="ANNOTATION REQUIRED",
265
+ relevant_chunk_ids=[],
266
+ tier="adversarial",
267
+ source_doc="",
268
+ ),
269
+ EvalItem(
270
+ qid="adv_006",
271
+ question="What are the conditions specified under Regulation 4(2)(b) of the FPI Regulations 2019?",
272
+ reference_answer="ANNOTATION REQUIRED",
273
+ relevant_chunk_ids=[],
274
+ tier="adversarial",
275
+ source_doc="",
276
+ ),
277
+ EvalItem(
278
+ qid="adv_007",
279
+ question="Under which master direction does RBI mandate unique identifiers for financial market participants?",
280
+ reference_answer="ANNOTATION REQUIRED",
281
+ relevant_chunk_ids=[],
282
+ tier="adversarial",
283
+ source_doc="",
284
+ ),
285
+ EvalItem(
286
+ qid="adv_008",
287
+ question="What was the cut-off rate announced for the 91-day Treasury Bill auction in March 2026?",
288
+ reference_answer="ANNOTATION REQUIRED",
289
+ relevant_chunk_ids=[],
290
+ tier="adversarial",
291
+ source_doc="",
292
+ ),
293
+ # ── Unanswerable / out-of-corpus (4) ──────────────────────────────────
294
+ EvalItem(
295
+ qid="adv_009",
296
+ question="What is SEBI's regulatory framework for cryptocurrency derivative instruments?",
297
+ reference_answer="The provided context does not contain sufficient information to answer this question.",
298
+ relevant_chunk_ids=[], # correct answer = "insufficient context"
299
+ tier="adversarial",
300
+ source_doc="",
301
+ ),
302
+ EvalItem(
303
+ qid="adv_010",
304
+ question="What are RBI's guidelines on digital lending apps for fintech startups?",
305
+ reference_answer="The provided context does not contain sufficient information to answer this question.",
306
+ relevant_chunk_ids=[],
307
+ tier="adversarial",
308
+ source_doc="",
309
+ ),
310
+ EvalItem(
311
+ qid="adv_011",
312
+ question="What is the minimum net worth requirement for a crypto exchange seeking SEBI registration?",
313
+ reference_answer="The provided context does not contain sufficient information to answer this question.",
314
+ relevant_chunk_ids=[],
315
+ tier="adversarial",
316
+ source_doc="",
317
+ ),
318
+ EvalItem(
319
+ qid="adv_012",
320
+ question="What is RBI's position on issuing a retail Central Bank Digital Currency in India?",
321
+ reference_answer="The provided context does not contain sufficient information to answer this question.",
322
+ relevant_chunk_ids=[],
323
+ tier="adversarial",
324
+ source_doc="",
325
+ ),
326
+ # ── Temporal / version conflict (3) ───────────────────────────────────
327
+ EvalItem(
328
+ qid="adv_013",
329
+ question="When is the next Monetary Policy Committee meeting scheduled after April 2026?",
330
+ reference_answer="ANNOTATION REQUIRED",
331
+ relevant_chunk_ids=[],
332
+ tier="adversarial",
333
+ source_doc="",
334
+ ),
335
+ EvalItem(
336
+ qid="adv_014",
337
+ question="What was the outcome of the 622nd meeting of the RBI Central Board?",
338
+ reference_answer="ANNOTATION REQUIRED",
339
+ relevant_chunk_ids=[],
340
+ tier="adversarial",
341
+ source_doc="",
342
+ ),
343
+ EvalItem(
344
+ qid="adv_015",
345
+ question="What SEBI circular superseded or amended the most recent FPI KYC guidelines?",
346
+ reference_answer="ANNOTATION REQUIRED",
347
+ relevant_chunk_ids=[],
348
+ tier="adversarial",
349
+ source_doc="",
350
+ ),
351
+ ]
352
+
353
+
354
+ def _generate_synthetic_items(
355
+ docs: list,
356
+ chunks: list[ChunkRecord],
357
+ n: int = 35,
358
+ api_key: str | None = None,
359
+ seed: int = 42,
360
+ ) -> list[EvalItem]:
361
+ """
362
+ Generate n synthetic QA items via Gemini 1.5 Flash.
363
+ Each item's ground-truth chunk IDs are located via verbatim_span matching.
364
+ Requires GEMINI_API_KEY env var or explicit api_key parameter.
365
+ """
366
+ import google.generativeai as genai # type: ignore[import]
367
+
368
+ key = api_key or os.environ.get("GEMINI_API_KEY")
369
+ if not key:
370
+ raise EnvironmentError("GEMINI_API_KEY not set. Cannot generate synthetic eval set.")
371
+
372
+ genai.configure(api_key=key)
373
+ model = genai.GenerativeModel(
374
+ "gemini-1.5-flash",
375
+ generation_config={"temperature": 0.3, "max_output_tokens": 512},
376
+ )
377
+
378
+ rng = random.Random(seed)
379
+ sampled = rng.sample(docs, min(n, len(docs)))
380
+ items: list[EvalItem] = []
381
+ failed = 0
382
+
383
+ for i, doc in enumerate(sampled):
384
+ text_excerpt = doc.raw_text[:3000]
385
+ prompt = (
386
+ "Given the following regulatory text, write ONE specific factual question "
387
+ "whose exact answer can be found in one or two consecutive paragraphs.\n\n"
388
+ f"TEXT:\n{text_excerpt}\n\n"
389
+ "Requirements:\n"
390
+ "- The question must be answerable ONLY from this text.\n"
391
+ "- The answer must be precise, not vague.\n"
392
+ "- Include a verbatim_span: the first 60 characters of the exact answer text.\n\n"
393
+ "Output ONLY valid JSON, no other text:\n"
394
+ '{"question": "...", "answer": "...", "verbatim_span": "..."}'
395
+ )
396
+ try:
397
+ resp = model.generate_content(prompt)
398
+ raw = resp.text.strip()
399
+ if raw.startswith("```"):
400
+ raw = "\n".join(raw.split("\n")[1:]).rstrip("` \n")
401
+ data = json.loads(raw)
402
+
403
+ relevant_ids = _find_relevant_chunks(data["verbatim_span"], chunks)
404
+ items.append(EvalItem(
405
+ qid = f"syn_{i+1:03d}",
406
+ question = data["question"],
407
+ reference_answer = data["answer"],
408
+ relevant_chunk_ids = relevant_ids,
409
+ tier = "synthetic",
410
+ source_doc = doc.doc_id,
411
+ ))
412
+ # Respect Gemini free-tier rate limits (~2 RPM for flash)
413
+ time.sleep(0.5)
414
+
415
+ except Exception as exc:
416
+ logger.warning("Skipping doc %s: %s", doc.doc_id, exc)
417
+ failed += 1
418
+
419
+ logger.info(
420
+ "Generated %d synthetic items (%d failed) from %d docs.",
421
+ len(items), failed, len(sampled),
422
+ )
423
+ return items
424
+
425
+
426
+ def load_or_generate_eval_set(
427
+ path: Path,
428
+ docs: list | None = None,
429
+ chunks: list[ChunkRecord] | None = None,
430
+ n_synthetic: int = 35,
431
+ api_key: str | None = None,
432
+ seed: int = 42,
433
+ ) -> list[EvalItem]:
434
+ """
435
+ Load from path if it exists; otherwise generate and save.
436
+ docs and chunks required only for generation.
437
+ """
438
+ path = Path(path)
439
+ if path.exists():
440
+ raw = json.loads(path.read_text(encoding="utf-8"))
441
+ items = [EvalItem(**item) for item in raw]
442
+ logger.info("Loaded %d eval items from %s", len(items), path)
443
+ return items
444
+
445
+ if docs is None or chunks is None:
446
+ raise ValueError("docs and chunks required to generate eval set.")
447
+
448
+ synthetic = _generate_synthetic_items(docs, chunks, n_synthetic, api_key, seed)
449
+ adversarial = _hardcoded_adversarial_items()
450
+ items = synthetic + adversarial
451
+
452
+ path.parent.mkdir(parents=True, exist_ok=True)
453
+ path.write_text(
454
+ json.dumps([asdict(i) for i in items], indent=2, ensure_ascii=False),
455
+ encoding="utf-8",
456
+ )
457
+ logger.info("Saved %d eval items to %s", len(items), path)
458
+ return items
459
+
460
+
461
+ # ─────────────────────────────────────────────────────────────────────────────
462
+ # Stage 1: Retrieval evaluation (no LLM)
463
+ # ─────────────────────────────────────────────────────────────────────────────
464
+
465
+ def evaluate_retrieval(
466
+ retriever: HybridRetriever,
467
+ eval_items: list[EvalItem],
468
+ mode: str = "hybrid",
469
+ k: int = 5,
470
+ ) -> tuple[RetrievalMetrics, LatencyStats, list[FailureRecord]]:
471
+ """
472
+ Evaluate retriever on items that have ground-truth chunk IDs.
473
+ Items with empty relevant_chunk_ids are skipped for metric aggregation
474
+ (they still appear as failures if retrieval returns nothing useful).
475
+ Also measures per-query wall-clock latency (retrieval only, no LLM).
476
+ """
477
+ recalls, mrrs, precisions = [], [], []
478
+ latencies_ms: list[float] = []
479
+ failures: list[FailureRecord] = []
480
+
481
+ for item in eval_items:
482
+ t0 = time.perf_counter()
483
+ results = retriever.retrieve(item.question, mode=mode)
484
+ latencies_ms.append((time.perf_counter() - t0) * 1000)
485
+ retrieved_ids = [r.chunk.chunk_id for r in results]
486
+
487
+ if not item.relevant_chunk_ids:
488
+ # Adversarial / unanswerable β€” skip metric computation
489
+ continue
490
+
491
+ recall = compute_recall_at_k(retrieved_ids, item.relevant_chunk_ids)
492
+ mrr = compute_mrr(retrieved_ids, item.relevant_chunk_ids)
493
+ precision = compute_precision_at_k(retrieved_ids, item.relevant_chunk_ids)
494
+
495
+ recalls.append(recall)
496
+ mrrs.append(mrr)
497
+ precisions.append(precision)
498
+
499
+ if recall < 1.0:
500
+ failures.append(FailureRecord(
501
+ qid = item.qid,
502
+ question = item.question,
503
+ expected_chunk_ids = item.relevant_chunk_ids,
504
+ retrieved_chunk_ids = retrieved_ids,
505
+ model_answer = "", # not computed in retrieval-only pass
506
+ error_type = "retrieval_miss",
507
+ recall = recall,
508
+ faithfulness = -1.0,
509
+ ))
510
+
511
+ n = max(len(recalls), 1)
512
+ lat_sorted = sorted(latencies_ms)
513
+ p50 = lat_sorted[len(lat_sorted) // 2] if lat_sorted else 0.0
514
+ p95 = lat_sorted[int(len(lat_sorted) * 0.95)] if lat_sorted else 0.0
515
+ return (
516
+ RetrievalMetrics(
517
+ recall_at_k = sum(recalls) / n,
518
+ mrr = sum(mrrs) / n,
519
+ precision_at_k = sum(precisions) / n,
520
+ k = k,
521
+ n_queries = len(recalls),
522
+ ),
523
+ LatencyStats(
524
+ mean_ms = sum(latencies_ms) / max(len(latencies_ms), 1),
525
+ p50_ms = p50,
526
+ p95_ms = p95,
527
+ n_queries = len(latencies_ms),
528
+ ),
529
+ failures,
530
+ )
531
+
532
+
533
+ # ─────────────────────────────────────────────────────────────────────────────
534
+ # Stage 2: Generation evaluation (requires LLM + judge)
535
+ # ─────────────────────────────────────────────────────────────────────────────
536
+
537
+ def evaluate_generation(
538
+ pipeline: Any, # RAGPipeline
539
+ eval_items: list[EvalItem],
540
+ embedder: BGEEmbedder,
541
+ gemini_model: Any,
542
+ mode: str = "hybrid",
543
+ max_items: int | None = None,
544
+ ) -> tuple[GenerationMetrics, list[FailureRecord]]:
545
+ """
546
+ Run the full pipeline (retrieval + generation) and score each answer.
547
+ Retrieval and generation failures are recorded separately in FailureRecord.error_type.
548
+ """
549
+ faithfulness_scores: list[float] = []
550
+ hallucination_free: list[bool] = []
551
+ relevance_scores: list[float] = []
552
+ failures: list[FailureRecord] = []
553
+
554
+ items_to_eval = eval_items[:max_items] if max_items else eval_items
555
+
556
+ for item in items_to_eval:
557
+ result = pipeline.ask(item.question, mode=mode)
558
+
559
+ if "error" in result:
560
+ logger.warning("Pipeline error for %s: %s", item.qid, result["error"])
561
+ continue
562
+
563
+ answer = result["answer"]
564
+ source_texts = [s["text"] for s in result.get("sources", [])]
565
+ retrieved_ids = [s["chunk_id"] for s in result.get("sources", [])]
566
+
567
+ # Retrieval quality (if ground truth available)
568
+ recall = (
569
+ compute_recall_at_k(retrieved_ids, item.relevant_chunk_ids)
570
+ if item.relevant_chunk_ids else -1.0
571
+ )
572
+
573
+ # Faithfulness
574
+ f_score, claims = judge_faithfulness(answer, source_texts, gemini_model)
575
+ faithfulness_scores.append(f_score)
576
+ all_supported = all(c.get("supported", True) for c in claims)
577
+ hallucination_free.append(all_supported)
578
+
579
+ # Answer relevance
580
+ rel_score = compute_answer_relevance(item.question, answer, embedder)
581
+ relevance_scores.append(rel_score)
582
+
583
+ # Classify failure
584
+ is_retrieval_miss = bool(item.relevant_chunk_ids) and recall < 0.5
585
+ is_hallucination = f_score < 0.80
586
+ is_unanswerable = not item.relevant_chunk_ids
587
+
588
+ if is_unanswerable:
589
+ insufficient_phrase = "does not contain sufficient"
590
+ error_type = (
591
+ "unanswerable_correct"
592
+ if insufficient_phrase in answer.lower()
593
+ else "unanswerable_hallucinated"
594
+ )
595
+ elif is_retrieval_miss and is_hallucination:
596
+ error_type = "both"
597
+ elif is_retrieval_miss:
598
+ error_type = "retrieval_miss"
599
+ elif is_hallucination:
600
+ error_type = "hallucination"
601
+ else:
602
+ continue # no failure
603
+
604
+ failures.append(FailureRecord(
605
+ qid = item.qid,
606
+ question = item.question,
607
+ expected_chunk_ids = item.relevant_chunk_ids,
608
+ retrieved_chunk_ids = retrieved_ids,
609
+ model_answer = answer[:400],
610
+ error_type = error_type,
611
+ recall = recall,
612
+ faithfulness = f_score,
613
+ ))
614
+
615
+ n = max(len(faithfulness_scores), 1)
616
+ return (
617
+ GenerationMetrics(
618
+ faithfulness = sum(faithfulness_scores) / n,
619
+ hallucination_free_rate = sum(hallucination_free) / n,
620
+ answer_relevance = sum(relevance_scores) / n,
621
+ n_queries = len(faithfulness_scores),
622
+ ),
623
+ failures,
624
+ )
625
+
626
+
627
+ # ─────────────────────────────────────────────────────────────────────────────
628
+ # Ablation runner
629
+ # ─────────────────────────────────────────────────────────────────────────────
630
+
631
+ # Each config: id, retriever mode, top_k, optional alternative index dir
632
+ ABLATION_CONFIGS: list[dict] = [
633
+ {"id": "B0_dense_only", "mode": "dense", "top_k": 5, "index_dir": None, "chunk_chars": None},
634
+ {"id": "B1_bm25_only", "mode": "bm25", "top_k": 5, "index_dir": None, "chunk_chars": None},
635
+ {"id": "B2_hybrid", "mode": "hybrid", "top_k": 5, "index_dir": None, "chunk_chars": None},
636
+ {"id": "B3_small_chunks","mode": "hybrid", "top_k": 5, "index_dir": "rag/index_800", "chunk_chars": 800},
637
+ {"id": "B4_large_chunks","mode": "hybrid", "top_k": 5, "index_dir": "rag/index_2400", "chunk_chars": 2400},
638
+ {"id": "B5_higher_k", "mode": "hybrid", "top_k": 10, "index_dir": None, "chunk_chars": None},
639
+ ]
640
+
641
+
642
+ def _load_retriever_for_config(
643
+ cfg: dict,
644
+ base_faiss: FAISSIndex,
645
+ base_bm25: BM25Index,
646
+ embedder: BGEEmbedder,
647
+ base_cfg: RAGConfig,
648
+ ) -> HybridRetriever | None:
649
+ """Build a HybridRetriever for an ablation config. Returns None if index missing."""
650
+ if cfg["index_dir"] is not None:
651
+ alt_dir = Path(cfg["index_dir"])
652
+ if not (alt_dir / "faiss.index").exists():
653
+ logger.warning(
654
+ "Skipping %s: index not found at %s. "
655
+ "Run: python -m rag.scripts.build_index --index-dir %s --chunk-size %s",
656
+ cfg["id"], alt_dir, alt_dir, cfg["chunk_chars"],
657
+ )
658
+ return None
659
+ faiss_idx = FAISSIndex.load(alt_dir, embedder.dim)
660
+ bm25_idx = BM25Index.load(alt_dir)
661
+ else:
662
+ faiss_idx = base_faiss
663
+ bm25_idx = base_bm25
664
+
665
+ return HybridRetriever(
666
+ faiss_index = faiss_idx,
667
+ bm25_index = bm25_idx,
668
+ embedder = embedder,
669
+ top_k = cfg["top_k"],
670
+ candidates = base_cfg.candidates,
671
+ rrf_k = base_cfg.rrf_k,
672
+ max_per_source = base_cfg.max_per_source,
673
+ )
674
+
675
+
676
+ def run_ablation(
677
+ base_faiss: FAISSIndex,
678
+ base_bm25: BM25Index,
679
+ embedder: BGEEmbedder,
680
+ base_cfg: RAGConfig,
681
+ eval_items: list[EvalItem],
682
+ configs: list[dict] | None = None,
683
+ ) -> list[RunResult]:
684
+ configs = configs or ABLATION_CONFIGS
685
+ results: list[RunResult] = []
686
+
687
+ for cfg in configs:
688
+ retriever = _load_retriever_for_config(
689
+ cfg, base_faiss, base_bm25, embedder, base_cfg
690
+ )
691
+ if retriever is None:
692
+ continue
693
+
694
+ config_snapshot = {
695
+ "config_id": cfg["id"],
696
+ "mode": cfg["mode"],
697
+ "top_k": cfg["top_k"],
698
+ "chunk_chars": cfg["chunk_chars"] or base_cfg.target_chunk_chars,
699
+ "overlap_chars": base_cfg.overlap_chars,
700
+ "embedding_model": base_cfg.embedding_model,
701
+ "rrf_k": base_cfg.rrf_k,
702
+ "candidates": base_cfg.candidates,
703
+ }
704
+
705
+ logger.info("Running ablation: %s (mode=%s, k=%d)", cfg["id"], cfg["mode"], cfg["top_k"])
706
+ t0 = time.perf_counter()
707
+
708
+ metrics, latency, failures = evaluate_retrieval(
709
+ retriever, eval_items, mode=cfg["mode"], k=cfg["top_k"]
710
+ )
711
+
712
+ results.append(RunResult(
713
+ config_id = cfg["id"],
714
+ config_snapshot = config_snapshot,
715
+ retrieval_metrics = metrics,
716
+ generation_metrics = None, # filled in separately for B0 and B2
717
+ latency = latency,
718
+ failures = failures,
719
+ elapsed_seconds = time.perf_counter() - t0,
720
+ ))
721
+
722
+ return results
723
+
724
+
725
+ # ─────────────────────────────────────────────────────────────────────────────
726
+ # Terminal report
727
+ # ─────────────────────────────────────────────────────────────────────────────
728
+
729
+ def print_terminal_report(
730
+ ablation_results: list[RunResult],
731
+ gen_results_by_id: dict[str, tuple[GenerationMetrics, list[FailureRecord]]],
732
+ eval_items: list[EvalItem],
733
+ ) -> None:
734
+ n_syn = sum(1 for i in eval_items if i.tier == "synthetic")
735
+ n_adv = sum(1 for i in eval_items if i.tier == "adversarial")
736
+
737
+ w = 72
738
+ print()
739
+ print("β•”" + "═" * w + "β•—")
740
+ print("β•‘" + " IndiaFinBench RAG β€” Phase 3 Evaluation Report".center(w) + "β•‘")
741
+ print("β•š" + "═" * w + "╝")
742
+ print(f"\n Eval dataset : {len(eval_items)} queries ({n_syn} synthetic + {n_adv} adversarial)")
743
+ print(f" Embedding : BAAI/bge-base-en-v1.5 (768-dim, cosine, IndexFlatIP)")
744
+
745
+ # ── Retrieval table ───────────────────────────────────────────────────────
746
+ print()
747
+ print(" RETRIEVAL METRICS")
748
+ hdr = f" {'Config':<22} {'Recall@k':>9} {'MRR':>8} {'Prec@k':>8} {'k':>3} {'p50ms':>7} {'p95ms':>7} {'n':>4}"
749
+ print(hdr)
750
+ print(" " + "─" * (len(hdr) - 2))
751
+ for r in ablation_results:
752
+ m = r.retrieval_metrics
753
+ lat = r.latency
754
+ tag = " β—„ proposed" if r.config_id == "B2_hybrid" else ""
755
+ print(
756
+ f" {r.config_id:<22} {m.recall_at_k:>9.4f} {m.mrr:>8.4f} "
757
+ f"{m.precision_at_k:>8.4f} {m.k:>3} {lat.p50_ms:>7.1f} {lat.p95_ms:>7.1f} {m.n_queries:>4}{tag}"
758
+ )
759
+
760
+ # ── Thresholds ────────────────────────────────────────────────────────────
761
+ print()
762
+ print(" Targets: Recall@5 β‰₯ 0.80 | MRR β‰₯ 0.65 | Precision@5 β‰₯ 0.50")
763
+
764
+ # ── Generation table ──────────────────────────────────────────────────────
765
+ if gen_results_by_id:
766
+ print()
767
+ print(
768
+ f" GENERATION METRICS "
769
+ f"(judge: {FAITHFULNESS_JUDGE_MODEL}, prompt {FAITHFULNESS_JUDGE_PROMPT_VERSION})"
770
+ )
771
+ print(" NOTE: LLM-as-judge scores are approximate. Consistent judge model + prompt")
772
+ print(" version across all runs ensures internal comparability, not absolute accuracy.")
773
+ ghdr = f" {'Config':<22} {'Faithful':>9} {'Halluc-free':>12} {'Ans-Rel':>9} {'n':>4}"
774
+ print(ghdr)
775
+ print(" " + "─" * (len(ghdr) - 2))
776
+ for cfg_id, (gm, _) in gen_results_by_id.items():
777
+ print(
778
+ f" {cfg_id:<22} {gm.faithfulness:>9.4f} "
779
+ f"{gm.hallucination_free_rate:>12.4f} {gm.answer_relevance:>9.4f} {gm.n_queries:>4}"
780
+ )
781
+ print()
782
+ print(" Targets: Faithfulness β‰₯ 0.85 | Halluc-free β‰₯ 0.90 | Ans-Rel β‰₯ 0.75")
783
+
784
+ # ── Failure analysis ──────────────────────────────────────────────────────
785
+ print()
786
+ print(" FAILURE ANALYSIS (B2 Hybrid)")
787
+ b2 = next((r for r in ablation_results if r.config_id == "B2_hybrid"), None)
788
+ all_failures: list[FailureRecord] = list(b2.failures) if b2 else []
789
+ if "B2_hybrid" in gen_results_by_id:
790
+ all_failures.extend(gen_results_by_id["B2_hybrid"][1])
791
+
792
+ if not all_failures:
793
+ print(" No failures recorded.")
794
+ else:
795
+ from collections import Counter
796
+ error_counts = Counter(f.error_type for f in all_failures)
797
+ for etype, count in error_counts.most_common():
798
+ pct = count / len(eval_items) * 100
799
+ print(f" [{etype:<28}] {count:3d} queries ({pct:.1f}%)")
800
+
801
+ print()
802
+ print(" Top 2 failure examples:")
803
+ shown = 0
804
+ for f in all_failures[:10]:
805
+ if shown >= 2:
806
+ break
807
+ if f.error_type in ("retrieval_miss", "hallucination", "both"):
808
+ print(f" [{f.error_type}] {f.qid}: {f.question[:70]}")
809
+ if f.expected_chunk_ids:
810
+ print(f" expected: {f.expected_chunk_ids[:2]}")
811
+ print(f" got: {f.retrieved_chunk_ids[:2]}")
812
+ shown += 1
813
+
814
+ print()
815
+
816
+
817
+ # ─────────────────────────────────────────────────────────────────────────────
818
+ # Persistence
819
+ # ─────────────────────────────────────────────────────────────────────────────
820
+
821
+ def save_report(
822
+ ablation_results: list[RunResult],
823
+ gen_results: dict[str, tuple[GenerationMetrics, list[FailureRecord]]],
824
+ config_snapshot: dict,
825
+ path: Path,
826
+ ) -> None:
827
+ path = Path(path)
828
+ path.parent.mkdir(parents=True, exist_ok=True)
829
+
830
+ serialised_ablation = []
831
+ for r in ablation_results:
832
+ d = {
833
+ "config_id": r.config_id,
834
+ "config_snapshot": r.config_snapshot,
835
+ "elapsed_seconds": round(r.elapsed_seconds, 2),
836
+ "retrieval_metrics": asdict(r.retrieval_metrics),
837
+ "generation_metrics": None,
838
+ "failures": [asdict(f) for f in r.failures],
839
+ }
840
+ if r.config_id in gen_results:
841
+ gm, _ = gen_results[r.config_id]
842
+ d["generation_metrics"] = asdict(gm)
843
+ serialised_ablation.append(d)
844
+
845
+ report = {
846
+ "system_config": config_snapshot,
847
+ "judge_meta": {
848
+ "model": FAITHFULNESS_JUDGE_MODEL,
849
+ "prompt_version": FAITHFULNESS_JUDGE_PROMPT_VERSION,
850
+ "bias_note": "LLM judge scores approximate; compare only within same version.",
851
+ },
852
+ "ablation_results": serialised_ablation,
853
+ }
854
+ path.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
855
+ logger.info("Report saved to %s", path)
rag/generator.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ rag/generator.py
3
+ ----------------
4
+ LLM generation backend with Groq (primary) and Ollama (local fallback).
5
+
6
+ Backend selection: set env var RAG_LLM_BACKEND=groq|ollama, or pass
7
+ `backend` explicitly to LLMGenerator.__init__.
8
+
9
+ Prompt design decisions:
10
+ - Explicit prohibition on general knowledge: LLMs default to mixing
11
+ parametric memory with retrieved context, inflating faithfulness scores.
12
+ - temperature=0.0: deterministic output is required for reproducible eval.
13
+ - [Source N] citation format matches the source block numbering so the
14
+ judge in evaluation.py can cross-reference claims.
15
+ - "chunk {chunk_idx}" suffix in source header provides traceability to
16
+ exact chunk position, not just document title.
17
+ """
18
+
19
+ import os
20
+
21
+ from rag.models import RetrievalResult
22
+
23
+ _SYSTEM_PROMPT = (
24
+ "You are an expert in Indian financial regulation specialising in "
25
+ "Reserve Bank of India (RBI) and Securities and Exchange Board of India "
26
+ "(SEBI) regulatory documents.\n\n"
27
+ "Answer the question using ONLY the numbered source passages provided. "
28
+ "Cite every factual claim inline as [Source N]. "
29
+ "If the sources do not contain sufficient information to answer the "
30
+ "question, state this explicitly β€” do not infer, extrapolate, or draw "
31
+ "on general knowledge not present in the sources. "
32
+ "Be concise and precise. Maximum 200 words unless the question requires more."
33
+ )
34
+
35
+
36
+ def _build_context_block(results: list[RetrievalResult]) -> str:
37
+ parts = [
38
+ f"[Source {i}] {r.chunk.title} (chunk {r.chunk.chunk_idx})\n{r.chunk.text}"
39
+ for i, r in enumerate(results, 1)
40
+ ]
41
+ return "\n\n".join(parts)
42
+
43
+
44
+ class LLMGenerator:
45
+ def __init__(
46
+ self,
47
+ backend: str = "groq",
48
+ model: str | None = None,
49
+ max_tokens: int = 512,
50
+ temperature: float = 0.0,
51
+ ) -> None:
52
+ self.backend = backend
53
+ self.max_tokens = max_tokens
54
+ self.temperature = temperature
55
+
56
+ if backend == "groq":
57
+ from groq import Groq
58
+ self._client = Groq(api_key=os.environ["GROQ_API_KEY"])
59
+ self.model = model or "llama-3.3-70b-versatile"
60
+
61
+ elif backend == "ollama":
62
+ import ollama as _ollama # type: ignore[import]
63
+ self._client = _ollama
64
+ self.model = model or "llama3.2:3b"
65
+
66
+ else:
67
+ raise ValueError(
68
+ f"Unknown backend {backend!r}. Valid options: 'groq', 'ollama'."
69
+ )
70
+
71
+ def generate(self, query: str, results: list[RetrievalResult]) -> str:
72
+ if not results:
73
+ return (
74
+ "No relevant passages were found in the corpus for this query. "
75
+ "Please rephrase or ask a different question."
76
+ )
77
+
78
+ context_block = _build_context_block(results)
79
+ user_msg = (
80
+ f"SOURCES:\n{context_block}\n\n"
81
+ f"QUESTION:\n{query}\n\n"
82
+ f"ANSWER:"
83
+ )
84
+
85
+ if self.backend == "groq":
86
+ resp = self._client.chat.completions.create(
87
+ model=self.model,
88
+ messages=[
89
+ {"role": "system", "content": _SYSTEM_PROMPT},
90
+ {"role": "user", "content": user_msg},
91
+ ],
92
+ temperature=self.temperature,
93
+ max_tokens=self.max_tokens,
94
+ )
95
+ return resp.choices[0].message.content.strip()
96
+
97
+ elif self.backend == "ollama":
98
+ resp = self._client.chat(
99
+ model=self.model,
100
+ messages=[
101
+ {"role": "system", "content": _SYSTEM_PROMPT},
102
+ {"role": "user", "content": user_msg},
103
+ ],
104
+ options={
105
+ "temperature": self.temperature,
106
+ "num_predict": self.max_tokens,
107
+ },
108
+ )
109
+ return resp["message"]["content"].strip()
110
+
111
+ raise RuntimeError(f"Unhandled backend: {self.backend!r}")
rag/index.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ rag/index.py
3
+ ------------
4
+ FAISS IndexFlatIP wrapper for exact cosine-similarity retrieval.
5
+
6
+ Why IndexFlatIP?
7
+ At M β‰ˆ 7,000 chunks Γ— 768 dims the index is ~21.5 MB and a single query
8
+ costs ~1.35 ms on CPU (5.4M FP32 multiplications). Approximate indices
9
+ (IVF, HNSW) introduce recall loss without meaningful latency benefit at
10
+ this scale.
11
+
12
+ Embeddings MUST be L2-normalised before add() so that inner product = cosine.
13
+ The BGEEmbedder guarantees this; do not pass raw embeddings.
14
+ """
15
+
16
+ import pickle
17
+ from pathlib import Path
18
+
19
+ import faiss
20
+ import numpy as np
21
+
22
+ from rag.models import ChunkRecord
23
+
24
+
25
+ class FAISSIndex:
26
+ def __init__(self, dim: int) -> None:
27
+ self.dim = dim
28
+ self.index = faiss.IndexFlatIP(dim)
29
+ self._chunks: list[ChunkRecord] = []
30
+
31
+ # ── Build ─────────────────────────────────────────────────────────────────
32
+
33
+ def build(self, embeddings: np.ndarray, chunks: list[ChunkRecord]) -> None:
34
+ if embeddings.shape != (len(chunks), self.dim):
35
+ raise ValueError(
36
+ f"Embedding shape {embeddings.shape} does not match "
37
+ f"({len(chunks)}, {self.dim})"
38
+ )
39
+ self.index.add(embeddings)
40
+ self._chunks = list(chunks)
41
+
42
+ # ── Query ─────────────────────────────────────────────────────────────────
43
+
44
+ def search(
45
+ self, query_embedding: np.ndarray, k: int
46
+ ) -> list[tuple[ChunkRecord, float]]:
47
+ """
48
+ Return up to k (chunk, cosine_score) pairs, descending by score.
49
+ query_embedding must be shape (1, dim) and L2-normalised.
50
+ """
51
+ k = min(k, self.index.ntotal)
52
+ scores, indices = self.index.search(query_embedding, k)
53
+ results: list[tuple[ChunkRecord, float]] = []
54
+ for score, idx in zip(scores[0], indices[0]):
55
+ if idx == -1:
56
+ continue
57
+ results.append((self._chunks[idx], float(score)))
58
+ return results
59
+
60
+ # ── Persistence ───────────────────────────────────────────────────────────
61
+
62
+ def save(self, index_dir: Path) -> None:
63
+ index_dir = Path(index_dir)
64
+ index_dir.mkdir(parents=True, exist_ok=True)
65
+ faiss.write_index(self.index, str(index_dir / "faiss.index"))
66
+ with open(index_dir / "chunks.pkl", "wb") as fh:
67
+ pickle.dump(self._chunks, fh)
68
+
69
+ @classmethod
70
+ def load(cls, index_dir: Path, dim: int) -> "FAISSIndex":
71
+ index_dir = Path(index_dir)
72
+ obj = cls(dim)
73
+ obj.index = faiss.read_index(str(index_dir / "faiss.index"))
74
+ with open(index_dir / "chunks.pkl", "rb") as fh:
75
+ obj._chunks = pickle.load(fh)
76
+ return obj
77
+
78
+ @property
79
+ def size(self) -> int:
80
+ return self.index.ntotal
rag/index/bm25.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7428eaba34b62697268b295b50290e0e107309b24cad30011a50d22e946f1ba5
3
+ size 18054235
rag/index/chunks.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3fa2c5623c37543283e1a3465a2918849b74b05625bc291b3ed110de03c50387
3
+ size 10208799
rag/index/faiss.index ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6396cf0a9b54346b30f895354f5ea167b6c1c08445af191dafcefebb6c0498f4
3
+ size 17614893
rag/models.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ rag/models.py
3
+ -------------
4
+ Canonical data types shared across all RAG modules.
5
+ Import from here β€” never redefine these elsewhere.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+
10
+
11
+ @dataclass
12
+ class Document:
13
+ doc_id: str # filename stem, e.g. "RBI_Master_Dir_084"
14
+ title: str # short human-readable label parsed from filename
15
+ source: str # "rbi" | "sebi"
16
+ raw_text: str # text content; mutated in-place by TextPreprocessor
17
+ file_path: str # absolute path to source .txt file
18
+
19
+
20
+ @dataclass
21
+ class ChunkRecord:
22
+ chunk_id: str # f"{doc_id}__{chunk_idx:04d}" β€” globally unique
23
+ doc_id: str # parent Document.doc_id
24
+ title: str # inherited from parent Document
25
+ source: str # "rbi" | "sebi"
26
+ text: str # chunk text as indexed and embedded
27
+ chunk_idx: int # 0-indexed position within parent document
28
+ char_start: int # character offset in preprocessed document text
29
+ char_end: int # exclusive end offset
30
+
31
+
32
+ @dataclass
33
+ class RetrievalResult:
34
+ chunk: ChunkRecord
35
+ dense_score: float # cosine similarity from FAISS [-1, 1]
36
+ bm25_score: float # raw BM25Okapi score [0, ∞)
37
+ rrf_score: float # RRF fused score (0, 1/30]
38
+ dense_rank: int # 1-indexed rank in dense list (candidates+1 if absent)
39
+ bm25_rank: int # 1-indexed rank in BM25 list (candidates+1 if absent)
rag/pipeline.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ rag/pipeline.py
3
+ ---------------
4
+ RAGPipeline: the top-level orchestrator that wires ingestion, indexing,
5
+ retrieval, and generation into a single callable interface.
6
+
7
+ Public API (intentionally minimal, mirrors demo/rag/rag.py):
8
+ pipe = RAGPipeline()
9
+ pipe.build_index() # run once; serialises to rag/index/
10
+ pipe.load_index() # fast path on subsequent starts
11
+ result = pipe.ask("What are the KYC norms under SEBI?")
12
+ # result: {"answer": str, "sources": list[dict]} | {"error": str}
13
+
14
+ The demo integration in demo/rag/rag.py is a thin shim over this module.
15
+ """
16
+
17
+ import logging
18
+ from pathlib import Path
19
+
20
+ from rag.bm25_index import BM25Index
21
+ from rag.chunking import RecursiveCharacterSplitter
22
+ from rag.config import RAGConfig
23
+ from rag.data_loader import DataLoader
24
+ from rag.embeddings import BGEEmbedder
25
+ from rag.generator import LLMGenerator
26
+ from rag.index import FAISSIndex
27
+ from rag.models import RetrievalResult
28
+ from rag.preprocessing import TextPreprocessor
29
+ from rag.retriever import HybridRetriever
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ class RAGPipeline:
35
+ def __init__(self, config: RAGConfig | None = None) -> None:
36
+ self.cfg = config or RAGConfig()
37
+ self._embedder = BGEEmbedder(
38
+ model_name = self.cfg.embedding_model,
39
+ device = self.cfg.embedding_device,
40
+ batch_size = self.cfg.embedding_batch_size,
41
+ )
42
+ self._generator = None
43
+
44
+ if getattr(self.cfg, "enable_generation", True):
45
+ self._generator = LLMGenerator(
46
+ backend = self.cfg.llm_backend,
47
+ max_tokens = self.cfg.max_tokens,
48
+ temperature = self.cfg.temperature,
49
+ )
50
+ self._retriever: HybridRetriever | None = None
51
+
52
+ # ── Index build (offline) ─────────────────────────────────────────────────
53
+
54
+ def build_index(self) -> None:
55
+ """
56
+ Full ingestion pipeline: load β†’ preprocess β†’ chunk β†’ embed β†’ index.
57
+ Idempotent: safe to re-run; overwrites rag/index/ on disk.
58
+ Typical runtime on CPU: 2–4 minutes for the 192-document corpus.
59
+ """
60
+ loader = DataLoader(self.cfg.data_dir)
61
+ preprocessor = TextPreprocessor()
62
+ splitter = RecursiveCharacterSplitter(
63
+ target_chunk_chars = self.cfg.target_chunk_chars,
64
+ overlap_chars = self.cfg.overlap_chars,
65
+ min_chunk_chars = self.cfg.min_chunk_chars,
66
+ )
67
+
68
+ docs = loader.load()
69
+ logger.info("Loaded %d documents", len(docs))
70
+
71
+ for doc in docs:
72
+ doc.raw_text = preprocessor.process(doc.raw_text)
73
+
74
+ all_chunks = []
75
+ for doc in docs:
76
+ all_chunks.extend(splitter.split_document(doc))
77
+ logger.info("Created %d chunks", len(all_chunks))
78
+
79
+ texts = [c.text for c in all_chunks]
80
+ embeddings = self._embedder.encode_corpus(texts)
81
+
82
+ faiss_idx = FAISSIndex(self._embedder.dim)
83
+ faiss_idx.build(embeddings, all_chunks)
84
+ faiss_idx.save(self.cfg.index_dir)
85
+
86
+ bm25_idx = BM25Index()
87
+ bm25_idx.build(all_chunks)
88
+ bm25_idx.save(self.cfg.index_dir)
89
+
90
+ self._wire_retriever(faiss_idx, bm25_idx)
91
+ logger.info(
92
+ "Index built: %d vectors in FAISS, %d chunks in BM25. Saved to %s.",
93
+ faiss_idx.size, bm25_idx.size, self.cfg.index_dir,
94
+ )
95
+
96
+ # ── Index load (online startup) ───────────────────────────────────────────
97
+
98
+ def load_index(self) -> None:
99
+ faiss_idx = FAISSIndex.load(self.cfg.index_dir, self._embedder.dim)
100
+ bm25_idx = BM25Index.load(self.cfg.index_dir)
101
+ self._wire_retriever(faiss_idx, bm25_idx)
102
+ logger.info(
103
+ "Index loaded: %d vectors (%s).",
104
+ faiss_idx.size, self.cfg.index_dir,
105
+ )
106
+
107
+ def _wire_retriever(self, faiss_idx: FAISSIndex, bm25_idx: BM25Index) -> None:
108
+ self._retriever = HybridRetriever(
109
+ faiss_index = faiss_idx,
110
+ bm25_index = bm25_idx,
111
+ embedder = self._embedder,
112
+ top_k = self.cfg.top_k,
113
+ candidates = self.cfg.candidates,
114
+ rrf_k = self.cfg.rrf_k,
115
+ max_per_source = self.cfg.max_per_source,
116
+ )
117
+
118
+ # ── Query (online) ────────────────────────────────────────────────────────
119
+
120
+ def ask(self, query: str, mode: str = "hybrid") -> dict:
121
+ """
122
+ Full RAG pipeline: retrieve β†’ generate.
123
+
124
+ Args:
125
+ query: Natural language question.
126
+ mode: "hybrid" | "dense" | "bm25" β€” retriever mode for ablation.
127
+
128
+ Returns:
129
+ {"answer": str, "sources": list[dict]} on success
130
+ {"error": str} on failure
131
+ """
132
+ if not query or not query.strip():
133
+ return {"error": "Empty query."}
134
+ if self._retriever is None:
135
+ return {"error": "Index not loaded. Call build_index() or load_index() first."}
136
+
137
+ try:
138
+ results: list[RetrievalResult] = self._retriever.retrieve(
139
+ query.strip(), mode=mode
140
+ )
141
+ if self._generator is None:
142
+ return {"error": "Generation disabled in current configuration."}
143
+
144
+ answer = self._generator.generate(query.strip(), results)
145
+ sources = [
146
+ {
147
+ "chunk_id": r.chunk.chunk_id,
148
+ "doc_id": r.chunk.doc_id,
149
+ "title": r.chunk.title,
150
+ "source": r.chunk.source,
151
+ "text": r.chunk.text,
152
+ "rrf_score": round(r.rrf_score, 6),
153
+ "dense_score": round(r.dense_score, 6),
154
+ "bm25_score": round(r.bm25_score, 4),
155
+ }
156
+ for r in results
157
+ ]
158
+ return {"answer": answer, "sources": sources}
159
+
160
+ except Exception as exc: # noqa: BLE001
161
+ logger.exception("RAG pipeline error for query: %r", query)
162
+ return {"error": f"RAG pipeline error: {exc!s}"[:400]}
163
+
164
+ # ── Index status ──────────────────────────────────────────────────────────
165
+
166
+ @property
167
+ def index_ready(self) -> bool:
168
+ return self._retriever is not None
169
+
170
+ @property
171
+ def index_path(self) -> Path:
172
+ return self.cfg.index_dir
rag/preprocessing.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ rag/preprocessing.py
3
+ --------------------
4
+ Lightweight text normalisation for PDF-extracted Indian regulatory documents.
5
+ All operations are pure string transforms β€” no model inference.
6
+
7
+ Pipeline (applied in order):
8
+ 1. Unicode NFKC normalisation β€” resolve ligatures, non-breaking spaces
9
+ 2. Header/footer line removal β€” heuristic pattern match on short lines
10
+ 3. Trailing whitespace strip β€” per-line
11
+ 4. Blank-line collapse β€” 3+ consecutive blank lines β†’ 2
12
+ 5. Leading/trailing strip β€” final document trim
13
+ """
14
+
15
+ import re
16
+ import unicodedata
17
+
18
+
19
+ # Lines matching these patterns and under 80 chars are dropped.
20
+ # Ordered from most to least specific to minimise false positives.
21
+ _HEADER_FOOTER_PATTERNS = re.compile(
22
+ r"""
23
+ ^\s*(?:
24
+ (?:reserve\s+bank\s+of\s+india) |
25
+ (?:securities\s+and\s+exchange\s+board) |
26
+ (?:rbi\s*[/|–-]) |
27
+ (?:sebi\s*[/|–-]) |
28
+ (?:page\s+\d+\s*(?:of\s+\d+)?) |
29
+ (?:\d+\s*$) | # bare page numbers
30
+ (?:www\.\S+) |
31
+ (?:https?://\S+) |
32
+ (?:Β©\s*.+) |
33
+ (?:circular\s+no\.?\s*[a-z0-9/_\-\.]+$)
34
+ )\s*$
35
+ """,
36
+ re.IGNORECASE | re.VERBOSE,
37
+ )
38
+
39
+ _MULTI_BLANK = re.compile(r"\n{3,}")
40
+ _TRAILING_SPACE = re.compile(r"[ \t]+$", re.MULTILINE)
41
+
42
+
43
+ class TextPreprocessor:
44
+ def process(self, text: str) -> str:
45
+ text = unicodedata.normalize("NFKC", text)
46
+ text = self._strip_headers_footers(text)
47
+ text = _TRAILING_SPACE.sub("", text)
48
+ text = _MULTI_BLANK.sub("\n\n", text)
49
+ return text.strip()
50
+
51
+ @staticmethod
52
+ def _strip_headers_footers(text: str) -> str:
53
+ lines = text.splitlines()
54
+ cleaned: list[str] = []
55
+ for line in lines:
56
+ # Only apply heuristic to short lines to avoid false positives
57
+ if len(line) < 80 and _HEADER_FOOTER_PATTERNS.match(line):
58
+ continue
59
+ cleaned.append(line)
60
+ return "\n".join(cleaned)
rag/requirements.txt ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # RAG pipeline dependencies
2
+ # Pin minor versions; patch versions are free to float for security fixes.
3
+
4
+ # Core retrieval
5
+ faiss-cpu>=1.8,<2.0
6
+ sentence-transformers>=3.0,<4.0
7
+ rank-bm25>=0.2,<0.3
8
+
9
+ # Generation backends
10
+ groq>=0.11,<1.0 # Groq API client (primary)
11
+ ollama>=0.3,<1.0 # Ollama local client (fallback)
12
+
13
+ # Utilities
14
+ numpy>=1.26,<3.0
15
+ tqdm>=4.66,<5.0
16
+
17
+ # Evaluation (Phase 3)
18
+ google-generativeai>=0.8,<1.0 # Gemini 1.5 Flash as faithfulness judge
rag/retriever.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ rag/retriever.py
3
+ ----------------
4
+ Hybrid retriever combining dense (FAISS) and lexical (BM25) search via
5
+ Reciprocal Rank Fusion (Cormack et al., 2009).
6
+
7
+ RRF formula:
8
+ score(d) = Σ_{r ∈ {dense, bm25}} 1 / (k_RRF + rank_r(d))
9
+ where k_RRF=60 (empirically optimal constant from the original paper).
10
+ Chunks absent from a list receive rank = candidates + 1.
11
+
12
+ Two additional constraints:
13
+ - Source diversity: at most max_per_source chunks from the same regulatory
14
+ source ("rbi" or "sebi") in the final top-k, to support cross-document
15
+ synthesis queries.
16
+ - Score floor: dense-only and bm25-only modes supported for ablation
17
+ (pass mode="dense" | "bm25" | "hybrid").
18
+ """
19
+
20
+ from rag.bm25_index import BM25Index
21
+ from rag.embeddings import BGEEmbedder
22
+ from rag.index import FAISSIndex
23
+ from rag.models import ChunkRecord, RetrievalResult
24
+
25
+
26
+ class HybridRetriever:
27
+ def __init__(
28
+ self,
29
+ faiss_index: FAISSIndex,
30
+ bm25_index: BM25Index,
31
+ embedder: BGEEmbedder,
32
+ top_k: int = 5,
33
+ candidates: int = 20,
34
+ rrf_k: int = 60,
35
+ max_per_source: int = 3,
36
+ ) -> None:
37
+ self._faiss = faiss_index
38
+ self._bm25 = bm25_index
39
+ self._embedder = embedder
40
+ self.top_k = top_k
41
+ self.candidates = candidates
42
+ self.rrf_k = rrf_k
43
+ self.max_per_source = max_per_source
44
+
45
+ def retrieve(
46
+ self, query: str, mode: str = "hybrid"
47
+ ) -> list[RetrievalResult]:
48
+ """
49
+ mode: "hybrid" | "dense" | "bm25"
50
+ Used in Phase 3 ablation to isolate individual retriever contributions.
51
+ """
52
+ absent_rank = self.candidates + 1
53
+
54
+ # ── Dense retrieval ───────────────────────────────────────────────────
55
+ dense_hits: list[tuple[ChunkRecord, float]] = []
56
+ if mode in ("hybrid", "dense"):
57
+ qemb = self._embedder.encode_query(query)
58
+ dense_hits = self._faiss.search(qemb, self.candidates)
59
+
60
+ # ── BM25 retrieval ────────────────────────────────────────────────────
61
+ bm25_hits: list[tuple[ChunkRecord, float]] = []
62
+ if mode in ("hybrid", "bm25"):
63
+ bm25_hits = self._bm25.search(query, self.candidates)
64
+
65
+ # ── Build rank & score lookup maps ────────────────────────────────────
66
+ dense_rank = {c.chunk_id: r for r, (c, _) in enumerate(dense_hits, 1)}
67
+ bm25_rank = {c.chunk_id: r for r, (c, _) in enumerate(bm25_hits, 1)}
68
+ dense_score = {c.chunk_id: s for c, s in dense_hits}
69
+ bm25_score = {c.chunk_id: s for c, s in bm25_hits}
70
+ chunk_map = {c.chunk_id: c for c, _ in dense_hits + bm25_hits}
71
+
72
+ # ── RRF scoring ───────────────────────────────────────────────────────
73
+ rrf_scores: dict[str, float] = {}
74
+ for cid in chunk_map:
75
+ rd = dense_rank.get(cid, absent_rank)
76
+ rb = bm25_rank.get(cid, absent_rank)
77
+ if mode == "dense":
78
+ rrf_scores[cid] = 1.0 / (self.rrf_k + rd)
79
+ elif mode == "bm25":
80
+ rrf_scores[cid] = 1.0 / (self.rrf_k + rb)
81
+ else:
82
+ rrf_scores[cid] = 1.0 / (self.rrf_k + rd) + 1.0 / (self.rrf_k + rb)
83
+
84
+ ranked = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
85
+
86
+ # ── Source diversity cap + top-k selection ────────────────────────────
87
+ results: list[RetrievalResult] = []
88
+ source_count: dict[str, int] = {}
89
+
90
+ for cid, rrf in ranked:
91
+ if len(results) >= self.top_k:
92
+ break
93
+ chunk = chunk_map[cid]
94
+ n_from = source_count.get(chunk.source, 0)
95
+ if n_from >= self.max_per_source:
96
+ continue
97
+ source_count[chunk.source] = n_from + 1
98
+ results.append(RetrievalResult(
99
+ chunk = chunk,
100
+ dense_score = dense_score.get(cid, 0.0),
101
+ bm25_score = bm25_score.get(cid, 0.0),
102
+ rrf_score = rrf,
103
+ dense_rank = dense_rank.get(cid, absent_rank),
104
+ bm25_rank = bm25_rank.get(cid, absent_rank),
105
+ ))
106
+
107
+ return results
rag/scripts/__init__.py ADDED
File without changes
rag/scripts/build_index.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ rag/scripts/build_index.py
3
+ --------------------------
4
+ CLI entry point for the offline index build step.
5
+
6
+ Usage (from project root):
7
+ python -m rag.scripts.build_index
8
+ python -m rag.scripts.build_index --data-dir data/parsed --index-dir rag/index
9
+ python -m rag.scripts.build_index --chunk-size 800 # ablation: small chunks
10
+ python -m rag.scripts.build_index --chunk-size 2400 # ablation: large chunks
11
+
12
+ The script exits non-zero on any error so CI/CD pipelines can detect failures.
13
+ """
14
+
15
+ import argparse
16
+ import logging
17
+ import sys
18
+ import time
19
+ from pathlib import Path
20
+
21
+ from rag.pipeline import RAGPipeline
22
+
23
+ logging.basicConfig(
24
+ level=logging.INFO,
25
+ format="%(asctime)s %(levelname)-8s %(message)s",
26
+ datefmt="%H:%M:%S",
27
+ )
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ def parse_args() -> argparse.Namespace:
32
+ p = argparse.ArgumentParser(
33
+ description="Build FAISS + BM25 index for the IndiaFinBench RAG pipeline."
34
+ )
35
+ p.add_argument(
36
+ "--data-dir",
37
+ type=Path,
38
+ default=Path("data/parsed"),
39
+ help="Directory containing rbi/ and sebi/ subdirectories of .txt files.",
40
+ )
41
+ p.add_argument(
42
+ "--index-dir",
43
+ type=Path,
44
+ default=Path("rag/index"),
45
+ help="Output directory for faiss.index, chunks.pkl, bm25.pkl.",
46
+ )
47
+ p.add_argument("--chunk-size", type=int, default=1600)
48
+ p.add_argument("--overlap", type=int, default=200)
49
+ p.add_argument("--min-chunk", type=int, default=100)
50
+ p.add_argument("--batch-size", type=int, default=64, help="Embedding batch size.")
51
+ p.add_argument("--device", type=str, default="cpu")
52
+ return p.parse_args()
53
+
54
+
55
+ def main() -> None:
56
+ # Import here so the script works from any working directory
57
+ from rag.config import RAGConfig
58
+ from rag.pipeline import RAGPipeline
59
+
60
+ args = parse_args()
61
+ cfg = RAGConfig(
62
+ data_dir = args.data_dir,
63
+ index_dir = args.index_dir,
64
+ target_chunk_chars = args.chunk_size,
65
+ overlap_chars = args.overlap,
66
+ min_chunk_chars = args.min_chunk,
67
+ embedding_batch_size= args.batch_size,
68
+ embedding_device = args.device,
69
+ )
70
+
71
+ logger.info("Config: chunk=%d overlap=%d device=%s", cfg.target_chunk_chars, cfg.overlap_chars, cfg.embedding_device)
72
+ logger.info("Data dir : %s", cfg.data_dir.resolve())
73
+ logger.info("Index dir : %s", cfg.index_dir.resolve())
74
+
75
+ t0 = time.perf_counter()
76
+ try:
77
+ cfg.enable_generation = False
78
+ pipe = RAGPipeline(config=cfg)
79
+ pipe.build_index()
80
+ except Exception:
81
+ logger.exception("Index build failed.")
82
+ sys.exit(1)
83
+
84
+ elapsed = time.perf_counter() - t0
85
+ logger.info("Done. Total time: %.1fs", elapsed)
86
+
87
+
88
+ if __name__ == "__main__":
89
+ main()
rag/scripts/run_evaluation.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ rag/scripts/run_evaluation.py
3
+ ------------------------------
4
+ CLI entry point for Phase 3 evaluation.
5
+
6
+ Stages:
7
+ 1. Load frozen index (must exist β€” run build_index first).
8
+ 2. Load or generate the 50-item eval dataset.
9
+ 3. Run retrieval-only ablation (B0–B5) β€” no LLM calls, fast.
10
+ 4. Optionally run generation evaluation on B2 (hybrid) and B0 (dense)
11
+ using the configured LLM backend + Gemini faithfulness judge.
12
+ 5. Print structured terminal report.
13
+ 6. Save JSON report to data/eval/results_<timestamp>.json.
14
+
15
+ Usage:
16
+ python -m rag.scripts.run_evaluation
17
+ python -m rag.scripts.run_evaluation --no-generation
18
+ python -m rag.scripts.run_evaluation --eval-set data/eval/eval_set.json
19
+ python -m rag.scripts.run_evaluation --configs B0,B2,B5 # subset of ablation
20
+ """
21
+
22
+ import argparse
23
+ import dataclasses
24
+ import json
25
+ import logging
26
+ import os
27
+ import sys
28
+ import time
29
+ from datetime import datetime
30
+ from pathlib import Path
31
+
32
+ logging.basicConfig(
33
+ level=logging.INFO,
34
+ format="%(asctime)s %(levelname)-8s %(message)s",
35
+ datefmt="%H:%M:%S",
36
+ )
37
+ logger = logging.getLogger(__name__)
38
+
39
+
40
+ def parse_args() -> argparse.Namespace:
41
+ p = argparse.ArgumentParser(
42
+ description="Run Phase 3 evaluation for the IndiaFinBench RAG pipeline."
43
+ )
44
+ p.add_argument("--index-dir", type=Path, default=Path("rag/index"))
45
+ p.add_argument("--data-dir", type=Path, default=Path("data/parsed"))
46
+ p.add_argument("--eval-set", type=Path, default=Path("data/eval/eval_set.json"),
47
+ help="Path to eval_set.json. Generated if missing and --generate-eval set.")
48
+ p.add_argument("--output-dir", type=Path, default=Path("data/eval"))
49
+ p.add_argument("--no-generation", action="store_true",
50
+ help="Skip generation evaluation (retrieval metrics only).")
51
+ p.add_argument("--generate-eval", action="store_true",
52
+ help="Generate synthetic eval set via Gemini if eval_set.json missing.")
53
+ p.add_argument("--n-synthetic", type=int, default=35,
54
+ help="Number of synthetic QA pairs to generate.")
55
+ p.add_argument("--configs", type=str, default=None,
56
+ help="Comma-separated subset of ablation config IDs to run, "
57
+ "e.g. B0_dense_only,B2_hybrid,B5_higher_k")
58
+ p.add_argument("--gemini-key", type=str, default=None,
59
+ help="Gemini API key (overrides GEMINI_API_KEY env var).")
60
+ p.add_argument("--groq-key", type=str, default=None,
61
+ help="Groq API key (overrides GROQ_API_KEY env var).")
62
+ p.add_argument("--max-gen-items",type=int, default=None,
63
+ help="Limit generation eval to first N items (useful for quick tests).")
64
+ return p.parse_args()
65
+
66
+
67
+ def main() -> None:
68
+ # Force UTF-8 stdout so box-drawing chars in the terminal report work on Windows
69
+ sys.stdout.reconfigure(encoding="utf-8")
70
+
71
+ args = parse_args()
72
+
73
+ # Apply key overrides before any imports that might read them
74
+ if args.gemini_key:
75
+ os.environ["GEMINI_API_KEY"] = args.gemini_key
76
+ if args.groq_key:
77
+ os.environ["GROQ_API_KEY"] = args.groq_key
78
+
79
+ # ── Imports ───────────────────────────────────────────────────────────────
80
+ from rag.config import RAGConfig
81
+ from rag.data_loader import DataLoader
82
+ from rag.embeddings import BGEEmbedder
83
+ from rag.evaluation import (
84
+ ABLATION_CONFIGS,
85
+ FAITHFULNESS_JUDGE_MODEL,
86
+ FAITHFULNESS_JUDGE_PROMPT_VERSION,
87
+ evaluate_generation,
88
+ load_or_generate_eval_set,
89
+ print_terminal_report,
90
+ run_ablation,
91
+ save_report,
92
+ )
93
+ from rag.bm25_index import BM25Index
94
+ from rag.index import FAISSIndex
95
+ from rag.pipeline import RAGPipeline
96
+ from rag.preprocessing import TextPreprocessor
97
+ from rag.chunking import RecursiveCharacterSplitter
98
+
99
+ # ── Load index ────────────────────────────────────────────────────────────
100
+ if not (args.index_dir / "faiss.index").exists():
101
+ logger.error(
102
+ "Index not found at %s. Run: python -m rag.scripts.build_index first.",
103
+ args.index_dir,
104
+ )
105
+ sys.exit(1)
106
+
107
+ cfg = RAGConfig(data_dir=args.data_dir, index_dir=args.index_dir)
108
+
109
+ logger.info("Loading embedder: %s", cfg.embedding_model)
110
+ t_emb = time.perf_counter()
111
+ embedder = BGEEmbedder(
112
+ model_name = cfg.embedding_model,
113
+ device = cfg.embedding_device,
114
+ batch_size = cfg.embedding_batch_size,
115
+ )
116
+ logger.info(" Embedder loaded in %.1fs (dim=%d)", time.perf_counter() - t_emb, embedder.dim)
117
+
118
+ logger.info("Loading FAISS + BM25 index from %s", args.index_dir)
119
+ faiss_idx = FAISSIndex.load(args.index_dir, embedder.dim)
120
+ bm25_idx = BM25Index.load(args.index_dir)
121
+ logger.info(" Index: %d vectors, %d BM25 chunks", faiss_idx.size, bm25_idx.size)
122
+
123
+ # ── Load or generate eval set ─────────────────────────────────────────────
124
+ docs: list | None = None
125
+ chunks: list | None = None
126
+
127
+ if not args.eval_set.exists():
128
+ if not args.generate_eval:
129
+ logger.error(
130
+ "Eval set not found at %s. "
131
+ "Run with --generate-eval to create it, or provide --eval-set path.",
132
+ args.eval_set,
133
+ )
134
+ sys.exit(1)
135
+ logger.info("Generating synthetic eval set (%d items)…", args.n_synthetic)
136
+ loader = DataLoader(cfg.data_dir)
137
+ preprocessor = TextPreprocessor()
138
+ splitter = RecursiveCharacterSplitter(
139
+ target_chunk_chars=cfg.target_chunk_chars,
140
+ overlap_chars=cfg.overlap_chars,
141
+ min_chunk_chars=cfg.min_chunk_chars,
142
+ )
143
+ docs = loader.load()
144
+ for d in docs:
145
+ d.raw_text = preprocessor.process(d.raw_text)
146
+ chunks = [c for d in docs for c in splitter.split_document(d)]
147
+
148
+ eval_items = load_or_generate_eval_set(
149
+ path = args.eval_set,
150
+ docs = docs,
151
+ chunks = chunks,
152
+ n_synthetic = args.n_synthetic,
153
+ api_key = args.gemini_key,
154
+ )
155
+ n_with_gt = sum(1 for i in eval_items if i.relevant_chunk_ids)
156
+ logger.info(
157
+ "Eval set: %d items total (%d with ground-truth chunk IDs, %d adversarial).",
158
+ len(eval_items), n_with_gt, len(eval_items) - n_with_gt,
159
+ )
160
+
161
+ # ── Filter ablation configs ───────────────────────────────────────────────
162
+ selected_configs = ABLATION_CONFIGS
163
+ if args.configs:
164
+ ids = {c.strip() for c in args.configs.split(",")}
165
+ selected_configs = [c for c in ABLATION_CONFIGS if c["id"] in ids]
166
+ if not selected_configs:
167
+ logger.error("No matching configs found for: %s", args.configs)
168
+ sys.exit(1)
169
+
170
+ # ── Stage 1: Retrieval ablation ───────────────────────────────────────────
171
+ logger.info("Stage 1: Retrieval-only ablation (%d configs)…", len(selected_configs))
172
+ ablation_results = run_ablation(
173
+ base_faiss = faiss_idx,
174
+ base_bm25 = bm25_idx,
175
+ embedder = embedder,
176
+ base_cfg = cfg,
177
+ eval_items = eval_items,
178
+ configs = selected_configs,
179
+ )
180
+
181
+ # ── Stage 2: Generation evaluation (optional) ─────────────────────────────
182
+ gen_results: dict = {}
183
+
184
+ if not args.no_generation:
185
+ gemini_key = args.gemini_key or os.environ.get("GEMINI_API_KEY")
186
+ if not gemini_key:
187
+ logger.warning(
188
+ "GEMINI_API_KEY not set β€” skipping generation evaluation. "
189
+ "Set it or pass --gemini-key to enable faithfulness scoring."
190
+ )
191
+ else:
192
+ import google.generativeai as genai # type: ignore[import]
193
+ genai.configure(api_key=gemini_key)
194
+ judge_model = genai.GenerativeModel(
195
+ FAITHFULNESS_JUDGE_MODEL,
196
+ generation_config={"temperature": 0.0, "max_output_tokens": 1024},
197
+ )
198
+
199
+ # Run generation eval on B2 (proposed) and B0 (dense baseline)
200
+ for target_id in ("B2_hybrid", "B0_dense_only"):
201
+ target_cfg = next(
202
+ (c for c in selected_configs if c["id"] == target_id), None
203
+ )
204
+ if target_cfg is None:
205
+ continue
206
+ logger.info("Stage 2: Generation eval for %s…", target_id)
207
+
208
+ # Wire up a full pipeline with the appropriate mode
209
+ pipeline = RAGPipeline(config=cfg)
210
+ pipeline.load_index()
211
+
212
+ gm, gf = evaluate_generation(
213
+ pipeline = pipeline,
214
+ eval_items = eval_items,
215
+ embedder = embedder,
216
+ gemini_model= judge_model,
217
+ mode = target_cfg["mode"],
218
+ max_items = args.max_gen_items,
219
+ )
220
+ gen_results[target_id] = (gm, gf)
221
+
222
+ # ── Print terminal report ─────────────────────────────────────────────────
223
+ print_terminal_report(ablation_results, gen_results, eval_items)
224
+
225
+ # ── Save JSON report ──────────────────────────────────────────────────────
226
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
227
+ report_path = args.output_dir / f"results_{timestamp}.json"
228
+
229
+ config_snapshot = {
230
+ "embedding_model": cfg.embedding_model,
231
+ "target_chunk_chars": cfg.target_chunk_chars,
232
+ "overlap_chars": cfg.overlap_chars,
233
+ "top_k": cfg.top_k,
234
+ "candidates": cfg.candidates,
235
+ "rrf_k": cfg.rrf_k,
236
+ "max_per_source": cfg.max_per_source,
237
+ "llm_backend": cfg.llm_backend,
238
+ "temperature": cfg.temperature,
239
+ "index_dir": str(args.index_dir),
240
+ "eval_items": len(eval_items),
241
+ "items_with_gt": n_with_gt,
242
+ "judge_model": FAITHFULNESS_JUDGE_MODEL,
243
+ "judge_prompt_ver": FAITHFULNESS_JUDGE_PROMPT_VERSION,
244
+ "timestamp": timestamp,
245
+ }
246
+
247
+ save_report(ablation_results, gen_results, config_snapshot, report_path)
248
+ print(f"\n Report saved β†’ {report_path}")
249
+ print()
250
+
251
+
252
+ if __name__ == "__main__":
253
+ main()