Spaces:
Runtime error
Runtime error
Rajveer Singh Pall commited on
Commit Β·
8f41246
0
Parent(s):
Deploy IndiaFinBench research site
Browse files- .dockerignore +40 -0
- .gitattributes +2 -0
- Dockerfile +55 -0
- README.md +23 -0
- demo/.dockerignore +12 -0
- demo/.gitkeep +0 -0
- demo/__init__.py +0 -0
- demo/app.py +287 -0
- demo/data/baselines.json +157 -0
- demo/data/questions.json +0 -0
- demo/database/__init__.py +0 -0
- demo/database/db.py +216 -0
- demo/requirements.txt +10 -0
- demo/static/css/main.css +808 -0
- demo/static/js/archive-scene.js +409 -0
- demo/static/js/data.js +60 -0
- demo/static/js/main.js +512 -0
- demo/templates/index.html +547 -0
- demo/tests/__init__.py +0 -0
- demo/tests/test_app.py +143 -0
- rag/__init__.py +14 -0
- rag/bm25_index.py +80 -0
- rag/chunking.py +146 -0
- rag/config.py +44 -0
- rag/data_loader.py +51 -0
- rag/embeddings.py +58 -0
- rag/evaluation.py +855 -0
- rag/generator.py +111 -0
- rag/index.py +80 -0
- rag/index/bm25.pkl +3 -0
- rag/index/chunks.pkl +3 -0
- rag/index/faiss.index +3 -0
- rag/models.py +39 -0
- rag/pipeline.py +172 -0
- rag/preprocessing.py +60 -0
- rag/requirements.txt +18 -0
- rag/retriever.py +107 -0
- rag/scripts/__init__.py +0 -0
- rag/scripts/build_index.py +89 -0
- rag/scripts/run_evaluation.py +253 -0
.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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 & 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 & open dataset Β· EMNLP 2026 submission</p>
|
| 92 |
+
<h1 class="hero-title reveal">Can language models read India’s <em>financial 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 & 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’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 “what rule applied in 2016?” 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 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 · 92</span><span><i class="dot dot-rbi"></i>RBI · 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 < 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 > 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()
|