Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- .env.example +5 -0
- .gitattributes +1 -0
- .gitignore +42 -0
- .pytest_cache/.gitignore +2 -0
- .pytest_cache/CACHEDIR.TAG +4 -0
- .pytest_cache/README.md +8 -0
- .pytest_cache/v/cache/lastfailed +1 -0
- .pytest_cache/v/cache/nodeids +9 -0
- .uv-cache/.gitignore +1 -0
- .uv-cache/CACHEDIR.TAG +1 -0
- .uv-cache/sdists-v9/.gitignore +0 -0
- Dockerfile +15 -0
- PROJECT_LOG.md +347 -0
- README.md +143 -5
- app.py +286 -0
- data/chunks.jsonl +0 -0
- data/gold_dataset.json +212 -0
- data/index.chunks.json +0 -0
- data/index.faiss +3 -0
- pyproject.toml +38 -0
- requirements.txt +10 -0
- researchpath/__init__.py +3 -0
- researchpath/corpus.py +74 -0
- researchpath/embeddings.py +46 -0
- researchpath/eval.py +220 -0
- researchpath/index.py +70 -0
- researchpath/llm.py +82 -0
- researchpath/parsing.py +88 -0
- researchpath/planning.py +174 -0
- researchpath/prerequisites.py +174 -0
- researchpath/rag.py +74 -0
- researchpath/retrieval.py +170 -0
- scripts/ask.py +90 -0
- scripts/build_index.py +52 -0
- scripts/fetch_corpus.py +160 -0
- scripts/parse_corpus.py +67 -0
- scripts/plan.py +85 -0
- scripts/run_eval.py +224 -0
- scripts/smoke_test.py +54 -0
- tests/test_planning.py +67 -0
- uv.lock +0 -0
.env.example
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copy this file to `.env` and fill in your real keys.
|
| 2 |
+
# `.env` is gitignored — your real keys will NEVER be pushed to GitHub.
|
| 3 |
+
|
| 4 |
+
GEMINI_API_KEY=your-gemini-key-here
|
| 5 |
+
GROQ_API_KEY=your-groq-key-here
|
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
data/index.faiss filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Secrets
|
| 2 |
+
.env
|
| 3 |
+
.env.local
|
| 4 |
+
|
| 5 |
+
# Python
|
| 6 |
+
__pycache__/
|
| 7 |
+
*.pyc
|
| 8 |
+
*.pyo
|
| 9 |
+
*.egg-info/
|
| 10 |
+
.pytest_cache/
|
| 11 |
+
.ruff_cache/
|
| 12 |
+
.mypy_cache/
|
| 13 |
+
|
| 14 |
+
# Virtual env (uv creates .venv)
|
| 15 |
+
.venv/
|
| 16 |
+
|
| 17 |
+
# Data — don't commit downloaded papers or raw eval results
|
| 18 |
+
data/papers/
|
| 19 |
+
data/eval_results*.json
|
| 20 |
+
*.pkl
|
| 21 |
+
|
| 22 |
+
# Pre-built index and corpus data are committed (needed for HF Spaces)
|
| 23 |
+
# data/index.faiss, data/index.chunks.json, data/chunks.jsonl, data/gold_dataset.json
|
| 24 |
+
|
| 25 |
+
# Notebooks
|
| 26 |
+
.ipynb_checkpoints/
|
| 27 |
+
|
| 28 |
+
# IDE
|
| 29 |
+
.vscode/
|
| 30 |
+
.idea/
|
| 31 |
+
*.swp
|
| 32 |
+
|
| 33 |
+
# Claude Code personal settings (keys, permissions for this user only)
|
| 34 |
+
.claude/settings.local.json
|
| 35 |
+
|
| 36 |
+
# OS
|
| 37 |
+
.DS_Store
|
| 38 |
+
Thumbs.db
|
| 39 |
+
|
| 40 |
+
# Build
|
| 41 |
+
dist/
|
| 42 |
+
build/
|
.pytest_cache/.gitignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Created by pytest automatically.
|
| 2 |
+
*
|
.pytest_cache/CACHEDIR.TAG
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Signature: 8a477f597d28d172789f06886806bc55
|
| 2 |
+
# This file is a cache directory tag created by pytest.
|
| 3 |
+
# For information about cache directory tags, see:
|
| 4 |
+
# https://bford.info/cachedir/spec.html
|
.pytest_cache/README.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# pytest cache directory #
|
| 2 |
+
|
| 3 |
+
This directory contains data from the pytest's cache plugin,
|
| 4 |
+
which provides the `--lf` and `--ff` options, as well as the `cache` fixture.
|
| 5 |
+
|
| 6 |
+
**Do not** commit this to version control.
|
| 7 |
+
|
| 8 |
+
See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information.
|
.pytest_cache/v/cache/lastfailed
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{}
|
.pytest_cache/v/cache/nodeids
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
"tests/test_planning.py::test_known_set_truncates_chain",
|
| 3 |
+
"tests/test_planning.py::test_known_target_yields_empty_plan",
|
| 4 |
+
"tests/test_planning.py::test_rainbow_full_chain",
|
| 5 |
+
"tests/test_planning.py::test_render_text_contains_target_title",
|
| 6 |
+
"tests/test_planning.py::test_target_only_when_no_prereqs",
|
| 7 |
+
"tests/test_planning.py::test_topological_order_respects_edges",
|
| 8 |
+
"tests/test_planning.py::test_unknown_target_raises"
|
| 9 |
+
]
|
.uv-cache/.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
*
|
.uv-cache/CACHEDIR.TAG
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Signature: 8a477f597d28d172789f06886806bc55
|
.uv-cache/sdists-v9/.gitignore
ADDED
|
File without changes
|
Dockerfile
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
COPY requirements.txt .
|
| 6 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
+
|
| 8 |
+
COPY . .
|
| 9 |
+
|
| 10 |
+
EXPOSE 7860
|
| 11 |
+
|
| 12 |
+
CMD ["python", "-m", "streamlit", "run", "app.py", \
|
| 13 |
+
"--server.port=7860", \
|
| 14 |
+
"--server.address=0.0.0.0", \
|
| 15 |
+
"--server.headless=true"]
|
PROJECT_LOG.md
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ResearchPath — Project Log
|
| 2 |
+
|
| 3 |
+
A chronological record of decisions, work sessions, issues, and outcomes for the ResearchPath portfolio project.
|
| 4 |
+
|
| 5 |
+
**Schema for each entry:**
|
| 6 |
+
|
| 7 |
+
```
|
| 8 |
+
## YYYY-MM-DD HH:MM — [type] Title
|
| 9 |
+
**Type:** setup | decision | implementation | issue | resolution | discussion
|
| 10 |
+
**Status:** in-progress | complete | blocked | reverted
|
| 11 |
+
**Duration:** approx time
|
| 12 |
+
|
| 13 |
+
### Context
|
| 14 |
+
### Decisions
|
| 15 |
+
### Actions taken
|
| 16 |
+
### Issues encountered
|
| 17 |
+
### Files touched
|
| 18 |
+
### Outcome
|
| 19 |
+
### Next
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
---
|
| 23 |
+
|
| 24 |
+
## 2026-05-06 15:00 — [discussion] Project framing & scope
|
| 25 |
+
|
| 26 |
+
**Type:** decision
|
| 27 |
+
**Status:** complete
|
| 28 |
+
**Duration:** ~30 min
|
| 29 |
+
|
| 30 |
+
### Context
|
| 31 |
+
Chetan (~2.5 YOE Data Scientist, Kyndryl + ex-Swiss Re, targeting top product companies) needed a portfolio project. Earlier "Agentic RAG Document Search" on his resume was copied from another GitHub — needed a genuine, defensible project.
|
| 32 |
+
|
| 33 |
+
### Decisions
|
| 34 |
+
- **Rejected** Underwriter Copilot (insurance) and Equity Research Analyst (SEC filings) — too generic / didn't match his preference for research-flavored work.
|
| 35 |
+
- **Rejected** generic-RAG-over-papers framing — already exists (Perplexity, Elicit, Consensus); not enough differentiation.
|
| 36 |
+
- **Committed**: **ResearchPath** — agentic research-onboarding companion. Killer feature: prerequisite-chain reading-path planning. Genuinely agentic (graph traversal + recursive retrieval + reasoning over user state), not RAG-with-extra-steps.
|
| 37 |
+
- Demo domain: **Reinforcement Learning**. Architecture domain-agnostic.
|
| 38 |
+
- Stack: Gemini 2.5 Flash Lite + Groq Llama 3.3 70B (free tiers), BAAI/bge-small-en embeddings, FAISS, LangGraph, Streamlit, RAGAS.
|
| 39 |
+
- Eval-first methodology: gold dataset + ablation table is the differentiator from generic portfolios.
|
| 40 |
+
|
| 41 |
+
### Outcome
|
| 42 |
+
Project scope locked. 5-week plan in README.
|
| 43 |
+
|
| 44 |
+
### Next
|
| 45 |
+
Day 1 setup: env, deps, API keys, smoke test.
|
| 46 |
+
|
| 47 |
+
---
|
| 48 |
+
|
| 49 |
+
## 2026-05-06 15:20 — [setup] Day 1: Environment bootstrap
|
| 50 |
+
|
| 51 |
+
**Type:** setup
|
| 52 |
+
**Status:** complete
|
| 53 |
+
**Duration:** ~45 min
|
| 54 |
+
|
| 55 |
+
### Context
|
| 56 |
+
Stand up local dev env so the agent work in Week 2+ has a working LLM substrate.
|
| 57 |
+
|
| 58 |
+
### Decisions
|
| 59 |
+
- Use `uv` for Python + dependency management (already installed on user's machine).
|
| 60 |
+
- Default Gemini model: `gemini-2.5-flash-lite` (chosen after `gemini-2.5-flash` returned transient 503s during smoke test).
|
| 61 |
+
|
| 62 |
+
### Actions taken
|
| 63 |
+
- Scaffolded repo files: `.gitignore`, `.env.example`, `README.md`, `pyproject.toml`, `researchpath/__init__.py`, `researchpath/llm.py`, `scripts/smoke_test.py`.
|
| 64 |
+
- `uv python install 3.12` → installed CPython 3.12.9.
|
| 65 |
+
- `uv sync` → resolved 38 packages including `google-genai`, `groq`, `python-dotenv`, `rich`, `pytest`, `ruff`.
|
| 66 |
+
- User created `.env` from template and pasted Gemini + Groq API keys.
|
| 67 |
+
- `uv run python scripts/smoke_test.py` → both providers returned valid completions.
|
| 68 |
+
|
| 69 |
+
### Issues encountered
|
| 70 |
+
- **Unicode arrow `→` broke on Windows cp1252 codepage** when `rich` tried to write to stdout. Fix: removed the arrow from `scripts/smoke_test.py`. Also set `PYTHONIOENCODING=utf-8` for the run.
|
| 71 |
+
- **Gemini 2.5 Flash returned 503 UNAVAILABLE (transient overload)** despite a valid key. Fix: switched default model in `researchpath/llm.py` from `gemini-2.5-flash` to `gemini-2.5-flash-lite`.
|
| 72 |
+
|
| 73 |
+
### Files touched
|
| 74 |
+
- Created: `.gitignore`, `.env.example`, `README.md`, `pyproject.toml`, `researchpath/__init__.py`, `researchpath/llm.py`, `scripts/smoke_test.py`, `.claude/settings.local.json`, `PROJECT_LOG.md`.
|
| 75 |
+
- User-created (gitignored): `.env`.
|
| 76 |
+
|
| 77 |
+
### Outcome
|
| 78 |
+
Both Gemini and Groq APIs verified end-to-end. `.claude/settings.local.json` grants Bash + PowerShell auto-approval for this project (gitignored — personal preference, not committed).
|
| 79 |
+
|
| 80 |
+
### Next
|
| 81 |
+
Day 2: git init → arxiv corpus fetch script → download ~17 canonical RL papers → first commit.
|
| 82 |
+
|
| 83 |
+
---
|
| 84 |
+
|
| 85 |
+
## 2026-05-06 16:00 — [implementation] Day 2: Corpus pipeline + first commit
|
| 86 |
+
|
| 87 |
+
**Type:** implementation
|
| 88 |
+
**Status:** complete (with one outstanding follow-up — see "Outcome")
|
| 89 |
+
**Duration:** ~75 min
|
| 90 |
+
|
| 91 |
+
### Context
|
| 92 |
+
Build the end-to-end corpus path: fetch canonical RL papers from arXiv → parse PDFs → chunk text into a single JSONL ready for embedding. Land everything as the first git commit.
|
| 93 |
+
|
| 94 |
+
### Decisions
|
| 95 |
+
- **17-paper canon** chosen across 5 eras (value-based, policy-gradient, actor-critic, model-based, RLHF). See `researchpath/corpus.py`.
|
| 96 |
+
- **Streaming downloads via `requests`** instead of `arxiv` library's built-in downloader — the latter was truncating large PDFs at exact 1MB / 2MB boundaries on Windows.
|
| 97 |
+
- **PDF validation step** (open with pymupdf, read page 1) gates every download; truncated files auto-deleted and retried.
|
| 98 |
+
- **Chunking**: 800-char windows, 100-char overlap, sentence-boundary aware, hyphenation-aware.
|
| 99 |
+
- **Accept partial corpus (10/17)** rather than grind on rate-limited retries — script is idempotent so missing papers can be picked up later.
|
| 100 |
+
|
| 101 |
+
### Actions taken
|
| 102 |
+
- Added deps: `arxiv`, `pymupdf`. `uv sync` resolved 5 new packages.
|
| 103 |
+
- `researchpath/corpus.py` — 17-paper canon with `era` tag.
|
| 104 |
+
- `scripts/fetch_corpus.py` — streaming downloader with per-paper validate-and-retry, 12s inter-request spacing, 30s backoff.
|
| 105 |
+
- `researchpath/parsing.py` — PDF text extraction + sentence-boundary chunking.
|
| 106 |
+
- `scripts/parse_corpus.py` — drives parsing across all valid PDFs into `data/chunks.jsonl`.
|
| 107 |
+
- Ran fetcher: 10 papers landed cleanly. Deleted 3 truncated stragglers (`1801.01290`, `1911.08265`, `2301.04104`).
|
| 108 |
+
- Ran parser: **1,093 chunks, 778,995 chars** across 10 papers.
|
| 109 |
+
- `git init -b main` → first commit `270993c` ("Bootstrap ResearchPath: scaffold + corpus pipeline"). 13 files, 1,611 insertions.
|
| 110 |
+
|
| 111 |
+
### Issues encountered
|
| 112 |
+
- **arXiv HTTP 429 rate-limiting** on PDF endpoint. Tried 3s→8s→12s inter-request delays; eventually too much accumulated state on this IP. Pivoted to "accept partial corpus, document the gap" per user's `feedback_dont_grind.md`.
|
| 113 |
+
- **`arxiv` library truncates large PDFs at 1MB/2MB** even on retry — buffer issue specific to its downloader. Switched to direct `requests` streaming. (Confirmed by post-hoc validation: 3 stragglers stuck at exactly 2,097,152 / 1,048,576 bytes.)
|
| 114 |
+
- **Unicode `→` in console output** broke on cp1252 (already known from Day 1; reused `PYTHONIOENCODING=utf-8`).
|
| 115 |
+
|
| 116 |
+
### Files touched
|
| 117 |
+
- Added: `researchpath/corpus.py`, `researchpath/parsing.py`, `scripts/fetch_corpus.py`, `scripts/parse_corpus.py`.
|
| 118 |
+
- Modified: `pyproject.toml` (+arxiv, +pymupdf).
|
| 119 |
+
- Generated (gitignored): `data/papers/*.pdf` (10 files), `data/chunks.jsonl`.
|
| 120 |
+
|
| 121 |
+
### Outcome
|
| 122 |
+
End-to-end corpus path works: arxiv ID → validated PDF → chunked JSONL. Repo under git, first commit landed.
|
| 123 |
+
|
| 124 |
+
**Outstanding:** 4 papers still missing — `1511.05952` (PER), `1707.06347` (PPO), `1802.01561` (IMPALA), `2106.01345` (Decision Transformer). PPO is the painful gap (canonical paper). Re-run `uv run python scripts/fetch_corpus.py` later (script is idempotent + only retries missing/invalid).
|
| 125 |
+
|
| 126 |
+
### Next
|
| 127 |
+
Day 3: embeddings + FAISS index over `data/chunks.jsonl`. Use `BAAI/bge-small-en-v1.5` (CPU-friendly, ~133MB, strong on academic text). Then a tiny CLI: ask question → top-k chunks → grounded Gemini answer. That's the baseline RAG to measure against.
|
| 128 |
+
|
| 129 |
+
---
|
| 130 |
+
|
| 131 |
+
## 2026-05-06 17:00 — [implementation] Day 3: Embeddings, FAISS index, baseline RAG
|
| 132 |
+
|
| 133 |
+
**Type:** implementation
|
| 134 |
+
**Status:** complete
|
| 135 |
+
**Duration:** ~50 min
|
| 136 |
+
|
| 137 |
+
### Context
|
| 138 |
+
Stand up the first measurable RAG system: chunks → embeddings → vector search → grounded LLM answer with citations. This becomes the baseline that all future improvements (hybrid retrieval, reranker, agent planning) get measured against.
|
| 139 |
+
|
| 140 |
+
### Decisions
|
| 141 |
+
- **Embedding model: `BAAI/bge-small-en-v1.5`** (384 dim, ~133MB, CPU-fast, strong on academic text). Documents embedded raw; queries prefixed with the BGE-recommended retrieval instruction for measurably better results.
|
| 142 |
+
- **FAISS `IndexFlatIP`** with L2-normalized embeddings — exact cosine search, fast enough for ~1k chunks. Will swap to HNSW if corpus grows past ~50k.
|
| 143 |
+
- **Citation format `[arxiv_id, p<page>]`** — parseable by future eval code, human-readable in output.
|
| 144 |
+
- **System prompt is strict**: only-from-sources, no speculation. Will tighten further once we have eval numbers.
|
| 145 |
+
|
| 146 |
+
### Actions taken
|
| 147 |
+
- Added deps: `sentence-transformers`, `faiss-cpu`, `numpy`. `uv sync` resolved 29 new packages (~330MB inc. torch CPU).
|
| 148 |
+
- `researchpath/embeddings.py` — `Embedder` wrapper with separate `embed_documents` / `embed_query` (BGE wants different prefixes).
|
| 149 |
+
- `researchpath/index.py` — `build_index`, `load_index`, `search`. `Hit` dataclass carries score + citation metadata.
|
| 150 |
+
- `researchpath/rag.py` — `build_prompt`, `answer`. Strict system prompt enforcing citations + grounding.
|
| 151 |
+
- `scripts/build_index.py` — drives end-to-end embed + persist.
|
| 152 |
+
- `scripts/ask.py` — CLI for question → answer with citations. `--show-sources` flag for debugging retrieval.
|
| 153 |
+
- Built index: 1,093 chunks embedded in **148s** on CPU. Persisted `data/index.faiss` + `data/index.chunks.json`.
|
| 154 |
+
- **Smoke test query**: *"How does Rainbow combine multiple deep Q-learning improvements like Double DQN, Dueling networks, and prioritized replay?"*
|
| 155 |
+
- Answer correctly cited [1710.02298, p1] for Rainbow components and [1511.06581, p4] for Dueling architecture details.
|
| 156 |
+
- Listed all 6 Rainbow components accurately. No hallucination observed.
|
| 157 |
+
- 1,106 input / 196 output tokens (≈ free under Gemini quota).
|
| 158 |
+
|
| 159 |
+
### Issues encountered
|
| 160 |
+
- **HF Hub symlink warning on Windows** — cache-system can't use symlinks without admin/Developer Mode. Cosmetic only; cache works in degraded mode (more disk, no functional impact).
|
| 161 |
+
- **`VIRTUAL_ENV` mismatch warning from uv** — leftover env var from a prior shell session pointing at a different Python. Harmless; `.venv` is used regardless.
|
| 162 |
+
|
| 163 |
+
### Files touched
|
| 164 |
+
- Added: `researchpath/embeddings.py`, `researchpath/index.py`, `researchpath/rag.py`, `scripts/build_index.py`, `scripts/ask.py`.
|
| 165 |
+
- Modified: `pyproject.toml` (+sentence-transformers, +faiss-cpu, +numpy), `uv.lock`.
|
| 166 |
+
- Generated (gitignored): `data/index.faiss`, `data/index.chunks.json`.
|
| 167 |
+
|
| 168 |
+
### Outcome
|
| 169 |
+
Baseline RAG is **end-to-end working** and producing high-quality, grounded, cited answers on a real RL question. This is the system we'll measure in Day 4.
|
| 170 |
+
|
| 171 |
+
### Next
|
| 172 |
+
Day 4: **eval harness + gold dataset.** Build ~30 (question, expected answer key, expected source citations) tuples sourced from OpenAI Spinning Up problem sets, Sutton & Barto exercises, and 3-5 hand-curated questions per major paper. Then implement metrics (citation faithfulness, retrieval recall@k, answer quality blind-rated 1-5). First numbers go into the README eval table.
|
| 173 |
+
|
| 174 |
+
---
|
| 175 |
+
|
| 176 |
+
## 2026-05-06 18:00 — [implementation] Day 4: Eval harness + gold dataset + baseline numbers
|
| 177 |
+
|
| 178 |
+
**Type:** implementation
|
| 179 |
+
**Status:** complete
|
| 180 |
+
**Duration:** ~90 min
|
| 181 |
+
|
| 182 |
+
### Context
|
| 183 |
+
Build a rigorous eval harness before optimizing anything. Eval-first is the portfolio differentiator — ablation table beats vibes.
|
| 184 |
+
|
| 185 |
+
### Decisions
|
| 186 |
+
- **30-question gold dataset** across 10 indexed papers (3 per single-paper + 4 cross-paper comparison). Difficulty-stratified: 7 easy / 15 medium / 8 hard. Each example: `{id, question, expected_arxiv_ids, expected_key_claim, difficulty}`.
|
| 187 |
+
- **Three metrics** (no citation faithfulness per-claim — too expensive on free tier):
|
| 188 |
+
- `retrieval_recall@k`: fraction of expected arxiv IDs found in top-k hits
|
| 189 |
+
- `citation_presence`: answer cites at least one expected paper (string check)
|
| 190 |
+
- `answer_correctness`: LLM-as-judge (Groq llama-3.3-70b), YES/NO prompt
|
| 191 |
+
- **Judge model: Groq** not Gemini — Gemini free tier has 20 req/day limit (hit during initial test run). Groq handles batch eval at no cost.
|
| 192 |
+
- **RAG model: Groq** for batch eval. Gemini stays as default for `scripts/ask.py` (the demo CLI). Added `--groq` flag to `run_eval.py` to toggle.
|
| 193 |
+
- **Retry logic** added to `gemini_generate()` — parses retry delay from 429 error body, sleeps and retries up to 5 times.
|
| 194 |
+
|
| 195 |
+
### Actions taken
|
| 196 |
+
- `data/gold_dataset.json` — 30 QA triples (hand-authored from Spinning Up + paper reading).
|
| 197 |
+
- `researchpath/eval.py` — `GoldExample`, `EvalResult`, `EvalSummary` dataclasses + `evaluate_example`, `summarize`, `results_to_json`.
|
| 198 |
+
- `scripts/run_eval.py` — full pipeline: load index → batch retrieve + generate → judge → print table + save JSON.
|
| 199 |
+
- Updated `researchpath/rag.py` — added `generate_fn` injection param + `answer_groq()` convenience wrapper.
|
| 200 |
+
- Updated `researchpath/llm.py` — retry-with-backoff on Gemini 429 (parses `retryDelay` from error body).
|
| 201 |
+
- Ran full 30-question eval (`--groq`). Duration: ~3.5 min. Results saved to `data/eval_results.json`.
|
| 202 |
+
|
| 203 |
+
### Issues encountered
|
| 204 |
+
- **Gemini 20 req/day free-tier limit** — hit on second run (smoke test + partial full run = 20 calls). Fix: route eval to Groq entirely.
|
| 205 |
+
- **Retry delay detection**: Gemini's 429 body contains `retryDelay: '10s'` in the Details array. Regex extracts it; if missing, defaults to 15s sleep.
|
| 206 |
+
|
| 207 |
+
### Files touched
|
| 208 |
+
- Added: `data/gold_dataset.json`, `researchpath/eval.py`, `scripts/run_eval.py`.
|
| 209 |
+
- Modified: `researchpath/rag.py` (generate_fn injection, answer_groq), `researchpath/llm.py` (retry logic), `README.md` (eval table row 1).
|
| 210 |
+
- Generated (gitignored): `data/eval_results.json`.
|
| 211 |
+
|
| 212 |
+
### Outcome
|
| 213 |
+
|
| 214 |
+
**Baseline RAG numbers (Groq llama-3.3-70b, k=5, n=30):**
|
| 215 |
+
|
| 216 |
+
| Metric | Value |
|
| 217 |
+
|---|---|
|
| 218 |
+
| Retrieval Recall@5 | **90.0%** |
|
| 219 |
+
| Citation Presence | **86.7%** |
|
| 220 |
+
| Answer Correctness | **36.7%** |
|
| 221 |
+
| Avg Latency | **5.07s** |
|
| 222 |
+
| RAG Tokens | 33,498 in / 7,000 out |
|
| 223 |
+
|
| 224 |
+
**Gap analysis:** Retrieval finds the right paper in 27/30 cases — the FAISS index is working well. But answer correctness is only 37%, revealing two failure modes:
|
| 225 |
+
1. **Wrong-chunk problem**: Right paper retrieved, but the specific mechanistic claim is in a chunk outside top-5 (e.g., Rainbow's 6 components, distributional RL definition). Fix: hybrid retrieval (BM25 + vector) to catch exact-term hits + smaller chunks.
|
| 226 |
+
2. **Precision failure**: Right chunk retrieved, but answer paraphrases around the key claim without naming the specific mechanism (e.g., "stability" instead of "identifiability" for Dueling DQN mean subtraction). Fix: reranker + more directive prompt.
|
| 227 |
+
|
| 228 |
+
Difficulty breakdown: easy=28.6%, medium=53.3%, hard=12.5% answer correctness — harder questions show compounding retrieval + precision failures.
|
| 229 |
+
|
| 230 |
+
### Next
|
| 231 |
+
Day 5: Optimization round 1 — implement hybrid BM25+vector retrieval and measure improvement on same 30 gold examples. Target: push Answer Correctness from 37% → 55%+.
|
| 232 |
+
|
| 233 |
+
---
|
| 234 |
+
|
| 235 |
+
## 2026-05-06 19:30 — [implementation] Day 5: Hybrid BM25+FAISS retrieval (RRF fusion)
|
| 236 |
+
|
| 237 |
+
**Type:** implementation
|
| 238 |
+
**Status:** complete
|
| 239 |
+
**Duration:** ~90 min
|
| 240 |
+
|
| 241 |
+
### Context
|
| 242 |
+
Day 4 baseline showed Retrieval Recall@5=90% but Answer Correctness=37%. Root-cause: right paper retrieved but wrong chunks — dense embeddings de-prioritize specific technical terms ("conjugate gradient", "Polyak averaging", "identifiability") relative to surrounding context. BM25 keyword search catches exact-term hits that dense misses.
|
| 243 |
+
|
| 244 |
+
### Decisions
|
| 245 |
+
- **BM25Okapi** (rank-bm25 library) for sparse retrieval over the same 1,093 chunks.
|
| 246 |
+
- **Reciprocal Rank Fusion** (RRF, k=60) to combine BM25 and FAISS rankings without normalizing incompatible score spaces.
|
| 247 |
+
- **Stop-word filter** in tokenizer: query "What does GAE stand for?" without stop words = `['gae', 'stand', 'fundamental', 'tradeoff']` — much better signal than including "what", "does", "for", "it".
|
| 248 |
+
- **fetch_k=20** from each retriever before fusion, then top-k=5 from combined candidate set.
|
| 249 |
+
|
| 250 |
+
### Actions taken
|
| 251 |
+
- `researchpath/retrieval.py` — `BM25Retriever`, `HybridRetriever` with RRF fusion. Stop-word list added to tokenizer.
|
| 252 |
+
- `researchpath/index.py` — added `global_idx` field to `Hit` dataclass (FAISS vector position, globally unique across papers). Fixed **critical bug**: original code used `chunk_idx` (within-paper index, 0..N) as RRF dict key, causing collisions across papers with same within-paper index.
|
| 253 |
+
- `scripts/ask.py` — added `--hybrid` flag.
|
| 254 |
+
- `scripts/run_eval.py` — added `--hybrid` flag, fixed `Path.relative_to()` crash on relative paths.
|
| 255 |
+
- Ran full hybrid eval (`--groq --hybrid`): 29/30 questions (cross_04 errored on Groq 100K daily TPD limit).
|
| 256 |
+
|
| 257 |
+
### Issues encountered
|
| 258 |
+
- **global_idx bug**: `chunk_idx` is per-paper, not globally unique. Two chunks from different papers with the same within-paper index collided in the RRF dict, silently corrupting results. Fixed by adding `global_idx` (FAISS position) to Hit and using it in HybridRetriever. Pre-fix: hybrid was WORSE than dense in all spot checks. Post-fix: BETTER or TIED.
|
| 259 |
+
- **BM25 stop-word pollution**: Without filtering, "What does GAE manage?" matched InstructGPT appendix QA sections (had literal "What is X? A: ...") scoring 19.9. After adding stop-word filter, query tokens = `['gae', 'stand', 'fundamental', 'tradeoff', 'manage']`, GAE paper correctly ranked first.
|
| 260 |
+
- **Groq 100K daily TPD**: Exhausted daily token limit on the 30th question (cross_04). n=29 eval still valid; cross_04 retry deferred.
|
| 261 |
+
|
| 262 |
+
### Files touched
|
| 263 |
+
- Added: `researchpath/retrieval.py`.
|
| 264 |
+
- Modified: `researchpath/index.py` (+global_idx field), `scripts/ask.py` (+--hybrid), `scripts/run_eval.py` (+--hybrid, path fix), `README.md` (eval table col 2), `PROJECT_LOG.md`.
|
| 265 |
+
- Generated (gitignored): results in memory only (save failed on path bug, now fixed).
|
| 266 |
+
|
| 267 |
+
### Outcome
|
| 268 |
+
|
| 269 |
+
**Hybrid BM25+FAISS results (Groq llama-3.3-70b, k=5, n=29):**
|
| 270 |
+
|
| 271 |
+
| Metric | Baseline | Hybrid | Delta |
|
| 272 |
+
|---|---|---|---|
|
| 273 |
+
| Retrieval Recall@5 | 90.0% | 89.7% | -0.3 pp |
|
| 274 |
+
| Citation Presence | 86.7% | 86.2% | -0.5 pp |
|
| 275 |
+
| **Answer Correctness** | **36.7%** | **72.4%** | **+35.7 pp** |
|
| 276 |
+
| Avg Latency | 5.07s | 4.58s | -0.5s |
|
| 277 |
+
|
| 278 |
+
By difficulty: easy 100%, medium 73.3%, hard 42.9%. Hard questions remain the weak spot.
|
| 279 |
+
|
| 280 |
+
**Why hybrid helped so much:** The global_idx bug fix + stop-word filter together fixed hybrid retrieval so it correctly fuses dense + sparse signals. Many questions where the right paper was retrieved but answer was wrong (dense recall=100%, correct=N in baseline) flipped to correct under hybrid because BM25 surfaced chunks with the specific technical term rather than contextual chunks about the same topic.
|
| 281 |
+
|
| 282 |
+
### Next
|
| 283 |
+
Retry cross_04 when Groq TPD resets (saves the 30/30 result). Then: cross-encoder reranker as column 3 in the ablation table. Target: 75%+ correctness on hard questions.
|
| 284 |
+
|
| 285 |
+
---
|
| 286 |
+
|
| 287 |
+
## 2026-05-07 — [implementation] Day 5 cont.: Agentic planning loop (offline prerequisite-chain planner)
|
| 288 |
+
|
| 289 |
+
**Type:** implementation
|
| 290 |
+
**Status:** complete
|
| 291 |
+
**Duration:** ~60 min
|
| 292 |
+
|
| 293 |
+
### Context
|
| 294 |
+
The killer differentiator of ResearchPath over generic RAG is the prerequisite-chain reading-path planner: given a target RL paper and what the user already knows, compute the topologically-sorted chain of papers they need to read first. This module runs fully offline — no LLM calls, no API quota burned. It's the deterministic core that an LLM annotation layer will augment later.
|
| 295 |
+
|
| 296 |
+
### Decisions
|
| 297 |
+
- **Static prerequisite graph** as the seed: hand-curate ~10 edges covering canonical chains (DQN→Double DQN→Dueling DQN→Rainbow, TRPO→GAE, DQN→DDPG, DQN→A3C, InstructGPT→DPO). Enough to demo the killer feature; LLM-extracted edges added in a future iteration.
|
| 298 |
+
- **BFS backwards** from target, stopping at `known_ids` — avoids pulling in papers the user already understands.
|
| 299 |
+
- **Kahn's algorithm** for topological sort with `(year, arxiv_id)` tie-breaking — deterministic + chronologically sensible for RL literature.
|
| 300 |
+
- **ReadingStep annotation**: each step carries `bridges_to` (which later papers depend on it), `concepts` (the bridging ideas), and `why` (user-facing rationale) aggregated from the static edge metadata.
|
| 301 |
+
|
| 302 |
+
### Actions taken
|
| 303 |
+
- `researchpath/prerequisites.py` — `Prerequisite` dataclass + `STATIC_PREREQUISITES` tuple (10 hand-curated edges) + `edges_into` / `edges_from` accessors.
|
| 304 |
+
- `researchpath/planning.py` — `ReadingStep`, `ReadingPlan` dataclasses + `_bfs_backwards`, `_topo_sort` (Kahn's), `_build_step`, `plan_reading_path`. `ReadingPlan.render_text()` produces human-readable ordered list.
|
| 305 |
+
- `scripts/plan.py` — CLI: `--target` (arxiv ID or tag, case-insensitive), `--known` (comma-separated), `--list`. Rich Panel output.
|
| 306 |
+
- `tests/test_planning.py` — 7 pytest tests: no-prereq paper (1 step), Rainbow full chain (4 steps in order), known set truncates, known target → empty plan, topo order respects all edges, unknown target raises ValueError, render_text contains target title.
|
| 307 |
+
|
| 308 |
+
### Smoke tests (pre-pytest, verified manually):
|
| 309 |
+
- Rainbow (`1710.02298`, no prior knowledge): `1312.5602 → 1509.06461 → 1511.06581 → 1710.02298` ✓
|
| 310 |
+
- Rainbow knowing DQN: `1509.06461 → 1511.06581 → 1710.02298` (3 steps) ✓
|
| 311 |
+
- DPO (`2305.18290`) knowing InstructGPT: 1 step (DPO itself) ✓
|
| 312 |
+
- DDPG (`1509.02971`): `1312.5602 → 1509.02971` (2 steps) ✓
|
| 313 |
+
|
| 314 |
+
### Issues encountered
|
| 315 |
+
None. BFS + Kahn's on the static graph is deterministic and small enough that correctness is straightforward to verify.
|
| 316 |
+
|
| 317 |
+
### Files touched
|
| 318 |
+
- Added: `researchpath/prerequisites.py`, `researchpath/planning.py`, `scripts/plan.py`, `tests/test_planning.py`.
|
| 319 |
+
|
| 320 |
+
### Outcome
|
| 321 |
+
7/7 pytest tests pass in 0.09s. Planning loop runs offline without any API calls. Demonstrates genuine agentic behavior (graph traversal, state-aware planning) that differentiates ResearchPath from simple RAG demos.
|
| 322 |
+
|
| 323 |
+
**Sample CLI output** (`uv run python scripts/plan.py --target Rainbow`):
|
| 324 |
+
```
|
| 325 |
+
1. [1312.5602] DQN (2013) — Playing Atari with Deep Reinforcement Learning
|
| 326 |
+
bridges: -> 1509.06461, 1511.06581, 1710.02298
|
| 327 |
+
concepts: Q-learning, experience replay, target network
|
| 328 |
+
|
| 329 |
+
2. [1509.06461] DoubleDQN (2015) — Deep Reinforcement Learning with Double Q-learning
|
| 330 |
+
bridges: -> 1511.06581, 1710.02298
|
| 331 |
+
concepts: overestimation bias, decoupled action selection/evaluation, Double Q-learning
|
| 332 |
+
|
| 333 |
+
3. [1511.06581] DuelingDQN (2015) — Dueling Network Architectures for Deep Reinforcement Learning
|
| 334 |
+
bridges: -> 1710.02298
|
| 335 |
+
concepts: dueling networks
|
| 336 |
+
|
| 337 |
+
4. [1710.02298] Rainbow (2017) — Rainbow: Combining Improvements in Deep Reinforcement Learning
|
| 338 |
+
bridges: <- target
|
| 339 |
+
```
|
| 340 |
+
|
| 341 |
+
### Next
|
| 342 |
+
- Retry cross_04 when Groq TPD resets → update README hybrid result from n=29 to n=30
|
| 343 |
+
- Run reranker full eval (column 3 in ablation table): `--hybrid --rerank`
|
| 344 |
+
- LLM annotation layer: map free-text user background → `known_ids` set; per-step "what to focus on" rationale
|
| 345 |
+
- Fetch 4 missing papers: PER (1511.05952), PPO (1707.06347), IMPALA (1802.01561), Decision Transformer (2106.01345)
|
| 346 |
+
|
| 347 |
+
---
|
README.md
CHANGED
|
@@ -1,10 +1,148 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: ResearchPath
|
| 3 |
+
emoji: 🗺️
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
short_description: Agentic RL reading-path planner with grounded Q&A
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# ResearchPath
|
| 13 |
+
|
| 14 |
+
> An agentic research-onboarding companion. Give it a target paper and your background; it builds a personalized, dependency-ordered reading plan with grounded explanations.
|
| 15 |
+
>
|
| 16 |
+
> **Demo domain:** Reinforcement Learning. **Architecture:** domain-agnostic.
|
| 17 |
+
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
## The problem
|
| 21 |
+
|
| 22 |
+
Getting into a new research field is brutal. You open the SOTA paper, it assumes 8 prior concepts. You read those papers, they assume 5 more. Existing tools (Perplexity, Elicit, Consensus) retrieve papers but don't *plan* — they don't tell you what order to read things in *based on your specific background*.
|
| 23 |
+
|
| 24 |
+
## What ResearchPath does
|
| 25 |
+
|
| 26 |
+
Input:
|
| 27 |
+
- A target paper or topic (e.g., *"PPO"*)
|
| 28 |
+
- Your current background (e.g., *"basic supervised ML, calculus, no RL"*)
|
| 29 |
+
|
| 30 |
+
Output:
|
| 31 |
+
1. **Prerequisite reading plan** — a topologically-sorted reading list of foundational papers, with a "why this is next" justification grounded in the dependency chain
|
| 32 |
+
2. **Concept genealogy** — how each key concept evolved across the chain
|
| 33 |
+
3. **Notation glossary** — reconciles symbols across papers (X in paper A = θ in paper B)
|
| 34 |
+
4. **Grounded Q&A** — ask follow-ups, every claim cites a specific paper section
|
| 35 |
+
5. *(Stretch)* **Open problems surfacer** — clusters "Future Work" sections from recent papers in the subfield
|
| 36 |
+
|
| 37 |
+
## Why this is genuinely agentic (not RAG-with-extra-steps)
|
| 38 |
+
|
| 39 |
+
The reading-path builder is a real planning problem:
|
| 40 |
+
|
| 41 |
+
```
|
| 42 |
+
read(target_paper) → extract assumed prerequisites
|
| 43 |
+
for each prerequisite:
|
| 44 |
+
if user_knows(prereq): skip
|
| 45 |
+
else: retrieve canonical paper for prereq → recurse
|
| 46 |
+
build dependency DAG → topologically sort → generate bridge explanations
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
Graph traversal + recursive retrieval + reasoning over user state. Not "embed query, return top-5 chunks."
|
| 50 |
+
|
| 51 |
+
---
|
| 52 |
+
|
| 53 |
+
## Evaluation
|
| 54 |
+
|
| 55 |
+
Eval is the differentiator. Every change ships with numbers.
|
| 56 |
+
|
| 57 |
+
**Gold dataset:** 30 hand-authored (question, expected source, key claim) triples across 10 canonical RL papers. Difficulty-stratified: 7 easy / 15 medium / 8 hard. Questions adapted from OpenAI Spinning Up, Sutton & Barto concepts, and paper-specific mechanism questions.
|
| 58 |
+
|
| 59 |
+
### v1 — 10-paper corpus (1,093 chunks)
|
| 60 |
+
|
| 61 |
+
| Metric | Baseline RAG | + Hybrid Retrieval |
|
| 62 |
+
|---|---|---|
|
| 63 |
+
| Retrieval Recall@5 | **90.0%** | **89.7%** |
|
| 64 |
+
| Citation Presence | **86.7%** | **86.2%** |
|
| 65 |
+
| Answer Correctness | **36.7%** | **72.4%** |
|
| 66 |
+
| Avg Latency (s) | **5.07** | **4.58** |
|
| 67 |
+
| RAG Tokens (in/out) | **33,498 / 7,000** | **31,439 / 6,905** |
|
| 68 |
+
|
| 69 |
+
*Hybrid BM25+FAISS via RRF fusion (n=29). Answer Correctness nearly doubled (+35.7 pp) with no latency regression — driven by hybrid correctly surfacing in-paper chunks that dense embeddings de-prioritized.*
|
| 70 |
+
|
| 71 |
+
### v2 — Full 17-paper corpus (1,789 chunks)
|
| 72 |
+
|
| 73 |
+
Corpus expanded to all 17 canonical RL papers (PER, PPO, SAC, IMPALA, MuZero, DreamerV3, Decision Transformer added). Prerequisite graph updated with 11 new edges.
|
| 74 |
+
|
| 75 |
+
| Metric | Baseline RAG | + Hybrid Retrieval | + Reranker |
|
| 76 |
+
|---|---|---|---|
|
| 77 |
+
| Retrieval Recall@5 | **84.0%** | pending | **83.3%** |
|
| 78 |
+
| Citation Presence | **80.0%** | pending | **83.3%** |
|
| 79 |
+
| Answer Correctness | **48.0%** | pending | **56.7%** |
|
| 80 |
+
| Avg Latency (s) | **4.63** | pending | **5.13** |
|
| 81 |
+
| RAG Tokens (in/out) | **27,027 / 6,336** | pending | **31,579 / 11,215** |
|
| 82 |
+
|
| 83 |
+
*v2 baseline n=25 (5 skipped, Groq 100k TPD hit). Reranker: BM25+FAISS+CrossEncoder, n=30. Hybrid v2 pending token reset. Reranker adds +8.7 pp over v2 baseline; larger corpus raises baseline from 37% → 48% even without retrieval improvements.*
|
| 84 |
+
|
| 85 |
+
---
|
| 86 |
+
|
| 87 |
+
## Stack
|
| 88 |
+
|
| 89 |
+
| Layer | Choice | Why |
|
| 90 |
+
|---|---|---|
|
| 91 |
+
| Planning LLM | Gemini 2.5 Flash Lite (free tier) | Strong reasoning, generous free quota |
|
| 92 |
+
| Fast LLM | Groq Llama 3.3 70B (free) | Fast inference for inner-loop retrieval |
|
| 93 |
+
| Embeddings | BAAI/bge-small-en-v1.5 | CPU-friendly, strong on academic text, free |
|
| 94 |
+
| Vector store | FAISS IndexFlatIP | Local, free, exact cosine, fast at ~2k chunks |
|
| 95 |
+
| Retrieval | BM25 + FAISS via RRF + CrossEncoder rerank | Three tiers, each measurably better |
|
| 96 |
+
| Agent framework | Static DAG + BFS + Kahn's topo sort | Deterministic planning, no LLM cost |
|
| 97 |
+
| UI | Streamlit | Demo-grade, ships fast |
|
| 98 |
+
| Eval | Custom harness + LLM-as-judge | Citation recall, answer correctness, latency |
|
| 99 |
+
| Deploy | Hugging Face Spaces | Free public URL |
|
| 100 |
+
|
| 101 |
+
---
|
| 102 |
+
|
| 103 |
+
## Status
|
| 104 |
+
|
| 105 |
+
- [x] Week 1 — Repo scaffold + smoke test
|
| 106 |
+
- [x] Week 1 — arXiv corpus ingestion (10/17 RL papers, 1,093 chunks)
|
| 107 |
+
- [x] Week 1 — Baseline RAG (FAISS + bge-small + Gemini/Groq), smoke tested
|
| 108 |
+
- [x] Week 2 — Eval harness + 30-question gold dataset
|
| 109 |
+
- [x] Week 2 — Baseline RAG numbers (Recall@5 90%, Answer Correctness 37%)
|
| 110 |
+
- [x] Week 2 — Hybrid BM25+FAISS retrieval via RRF (Answer Correctness 72%, +35 pp)
|
| 111 |
+
- [x] Week 3 — Reranker (cross-encoder): +8.7 pp over v2 baseline
|
| 112 |
+
- [x] Week 3 — Agentic planning loop: offline prerequisite-chain planner, 7 tests passing
|
| 113 |
+
- [x] Week 3 — Full 17-paper corpus (1,789 chunks) + expanded prerequisite DAG (21 edges)
|
| 114 |
+
- [ ] Week 4 — Hybrid v2 eval (pending Groq token reset), ablation table complete, demo video
|
| 115 |
+
- [ ] Week 5 — Streamlit UI, HF Spaces deploy
|
| 116 |
+
|
| 117 |
+
---
|
| 118 |
+
|
| 119 |
+
## Local development
|
| 120 |
+
|
| 121 |
+
```powershell
|
| 122 |
+
# 1. Install uv (one-time): https://astral.sh/uv
|
| 123 |
+
# 2. Sync dependencies
|
| 124 |
+
uv sync
|
| 125 |
+
|
| 126 |
+
# 3. Set up env
|
| 127 |
+
copy .env.example .env
|
| 128 |
+
# Fill in GEMINI_API_KEY and GROQ_API_KEY in .env
|
| 129 |
+
|
| 130 |
+
# 4. Run smoke test
|
| 131 |
+
uv run python scripts/smoke_test.py
|
| 132 |
+
|
| 133 |
+
# 5. Build corpus (downloads ~17 PDFs from arXiv, parses, embeds — one-time ~15 min)
|
| 134 |
+
uv run python scripts/fetch_corpus.py
|
| 135 |
+
uv run python scripts/parse_corpus.py
|
| 136 |
+
uv run python scripts/build_index.py
|
| 137 |
+
|
| 138 |
+
# 6. Ask a question
|
| 139 |
+
uv run python scripts/ask.py "What is the key idea behind PPO?"
|
| 140 |
+
uv run python scripts/ask.py --hybrid --rerank "How does Rainbow combine Double DQN and PER?"
|
| 141 |
+
|
| 142 |
+
# 7. Get a reading plan
|
| 143 |
+
uv run python scripts/plan.py --target PPO
|
| 144 |
+
uv run python scripts/plan.py --target Rainbow --known DQN
|
| 145 |
+
|
| 146 |
+
# 8. Run the full eval
|
| 147 |
+
uv run python scripts/run_eval.py --groq --hybrid
|
| 148 |
+
```
|
app.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ResearchPath Streamlit app.
|
| 2 |
+
|
| 3 |
+
Two tabs:
|
| 4 |
+
1. Reading Path — prerequisite-chain planner (the unique agentic feature)
|
| 5 |
+
2. Ask — grounded Q&A over the indexed RL corpus
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import sys
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
| 13 |
+
|
| 14 |
+
import streamlit as st
|
| 15 |
+
|
| 16 |
+
from researchpath.corpus import CANONICAL_RL_PAPERS
|
| 17 |
+
from researchpath.planning import plan_reading_path
|
| 18 |
+
|
| 19 |
+
ROOT = Path(__file__).resolve().parent
|
| 20 |
+
INDEX_PATH = ROOT / "data" / "index.faiss"
|
| 21 |
+
|
| 22 |
+
_TAG_TO_ID = {p.tag.lower(): p.arxiv_id for p in CANONICAL_RL_PAPERS}
|
| 23 |
+
_ID_TO_PAPER = {p.arxiv_id: p for p in CANONICAL_RL_PAPERS}
|
| 24 |
+
_ERA_ORDER = ["value-based", "policy-gradient", "actor-critic", "model-based", "rlhf"]
|
| 25 |
+
|
| 26 |
+
st.set_page_config(
|
| 27 |
+
page_title="ResearchPath",
|
| 28 |
+
page_icon="🗺️",
|
| 29 |
+
layout="wide",
|
| 30 |
+
initial_sidebar_state="collapsed",
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
# ── minimal style ──────────────────────────────────────────────────────────
|
| 34 |
+
st.markdown(
|
| 35 |
+
"""
|
| 36 |
+
<style>
|
| 37 |
+
.step-card {
|
| 38 |
+
background: #1e2330;
|
| 39 |
+
border-left: 4px solid #4f8ef7;
|
| 40 |
+
border-radius: 6px;
|
| 41 |
+
padding: 14px 18px;
|
| 42 |
+
margin-bottom: 12px;
|
| 43 |
+
}
|
| 44 |
+
.step-card h4 { margin: 0 0 4px 0; color: #e0e6f0; }
|
| 45 |
+
.step-card .concepts { color: #a0aec0; font-size: 0.85rem; }
|
| 46 |
+
.step-card .why { color: #cbd5e0; font-size: 0.9rem; margin-top: 6px; }
|
| 47 |
+
.tag-chip {
|
| 48 |
+
display: inline-block;
|
| 49 |
+
background: #2d3748;
|
| 50 |
+
color: #90cdf4;
|
| 51 |
+
border-radius: 12px;
|
| 52 |
+
padding: 2px 10px;
|
| 53 |
+
font-size: 0.78rem;
|
| 54 |
+
margin-right: 4px;
|
| 55 |
+
}
|
| 56 |
+
</style>
|
| 57 |
+
""",
|
| 58 |
+
unsafe_allow_html=True,
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
# ── header ─────────────────────────────────────────────────────────────────
|
| 62 |
+
st.title("🗺️ ResearchPath")
|
| 63 |
+
st.caption(
|
| 64 |
+
"An agentic research-onboarding companion for Reinforcement Learning. "
|
| 65 |
+
"Give it a target paper and your background; it builds a personalized, "
|
| 66 |
+
"dependency-ordered reading plan."
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
tab_plan, tab_ask = st.tabs(["📚 Reading Path", "💬 Ask a Question"])
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# ── helpers ────────────────────────────────────────────────────────────────
|
| 73 |
+
|
| 74 |
+
def _resolve(token: str) -> str | None:
|
| 75 |
+
token = token.strip()
|
| 76 |
+
if token in _ID_TO_PAPER:
|
| 77 |
+
return token
|
| 78 |
+
if token.lower() in _TAG_TO_ID:
|
| 79 |
+
return _TAG_TO_ID[token.lower()]
|
| 80 |
+
return None
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _paper_label(arxiv_id: str) -> str:
|
| 84 |
+
p = _ID_TO_PAPER[arxiv_id]
|
| 85 |
+
return f"{p.tag} ({p.year})"
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@st.cache_resource(show_spinner="Loading index and embedder…")
|
| 89 |
+
def _load_retrieval():
|
| 90 |
+
"""Load FAISS index + embedder once per session."""
|
| 91 |
+
from researchpath.embeddings import Embedder
|
| 92 |
+
from researchpath.index import load_index
|
| 93 |
+
from researchpath.retrieval import HybridRetriever
|
| 94 |
+
|
| 95 |
+
if not INDEX_PATH.exists():
|
| 96 |
+
return None, None, None
|
| 97 |
+
index, chunks = load_index(INDEX_PATH)
|
| 98 |
+
embedder = Embedder()
|
| 99 |
+
retriever = HybridRetriever(index, chunks, embedder)
|
| 100 |
+
return index, chunks, retriever
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 104 |
+
# TAB 1 — Reading Path
|
| 105 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 106 |
+
with tab_plan:
|
| 107 |
+
st.subheader("Build your personalized reading path")
|
| 108 |
+
st.markdown(
|
| 109 |
+
"Select a **target paper** you want to understand, then tell us what you **already know**. "
|
| 110 |
+
"ResearchPath traces the prerequisite chain and gives you a topologically-sorted reading list "
|
| 111 |
+
"with *why each paper is next*."
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
# Group papers by era for a nicer selectbox
|
| 115 |
+
era_groups: dict[str, list] = {era: [] for era in _ERA_ORDER}
|
| 116 |
+
for p in CANONICAL_RL_PAPERS:
|
| 117 |
+
era_groups[p.era].append(p)
|
| 118 |
+
|
| 119 |
+
paper_options = []
|
| 120 |
+
for era in _ERA_ORDER:
|
| 121 |
+
for p in era_groups[era]:
|
| 122 |
+
paper_options.append(p.arxiv_id)
|
| 123 |
+
|
| 124 |
+
def _format_option(arxiv_id: str) -> str:
|
| 125 |
+
p = _ID_TO_PAPER[arxiv_id]
|
| 126 |
+
return f"{p.tag} — {p.title[:60]}{'…' if len(p.title) > 60 else ''} ({p.year})"
|
| 127 |
+
|
| 128 |
+
col_target, col_known = st.columns([1, 1])
|
| 129 |
+
|
| 130 |
+
with col_target:
|
| 131 |
+
target_id = st.selectbox(
|
| 132 |
+
"Target paper",
|
| 133 |
+
options=paper_options,
|
| 134 |
+
format_func=_format_option,
|
| 135 |
+
index=paper_options.index("1710.02298"), # default: Rainbow
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
with col_known:
|
| 139 |
+
known_options = [aid for aid in paper_options if aid != target_id]
|
| 140 |
+
known_ids_raw = st.multiselect(
|
| 141 |
+
"Papers you already know (optional)",
|
| 142 |
+
options=known_options,
|
| 143 |
+
format_func=_format_option,
|
| 144 |
+
default=[],
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
if st.button("Generate reading path", type="primary", use_container_width=True):
|
| 148 |
+
plan = plan_reading_path(target_id, known_ids=set(known_ids_raw))
|
| 149 |
+
target_paper = _ID_TO_PAPER[target_id]
|
| 150 |
+
|
| 151 |
+
if not plan.steps:
|
| 152 |
+
st.success(f"You're already ready to read **{target_paper.tag}** directly!")
|
| 153 |
+
else:
|
| 154 |
+
st.markdown(
|
| 155 |
+
f"**{len(plan.steps)} paper(s)** to read before (and including) "
|
| 156 |
+
f"**{target_paper.tag}**, in order:"
|
| 157 |
+
)
|
| 158 |
+
for i, step in enumerate(plan.steps, 1):
|
| 159 |
+
p = step.paper
|
| 160 |
+
is_target = p.arxiv_id == target_id
|
| 161 |
+
border_color = "#f6c90e" if is_target else "#4f8ef7"
|
| 162 |
+
label = "🎯 TARGET" if is_target else f"Step {i}"
|
| 163 |
+
concepts_html = "".join(
|
| 164 |
+
f'<span class="tag-chip">{c}</span>' for c in step.concepts
|
| 165 |
+
)
|
| 166 |
+
bridges_text = (
|
| 167 |
+
"→ " + ", ".join(_paper_label(b) for b in step.bridges_to)
|
| 168 |
+
if step.bridges_to
|
| 169 |
+
else ""
|
| 170 |
+
)
|
| 171 |
+
st.markdown(
|
| 172 |
+
f"""
|
| 173 |
+
<div class="step-card" style="border-left-color:{border_color}">
|
| 174 |
+
<h4>{label} [{p.arxiv_id}] <b>{p.tag}</b> <span style="color:#718096;font-weight:normal">({p.year})</span></h4>
|
| 175 |
+
<div style="color:#a0aec0;font-size:0.9rem">{p.title}</div>
|
| 176 |
+
{f'<div class="concepts" style="margin-top:8px">{concepts_html}</div>' if concepts_html else ""}
|
| 177 |
+
{f'<div class="why">{step.why}</div>' if step.why else ""}
|
| 178 |
+
{f'<div style="color:#718096;font-size:0.82rem;margin-top:6px">{bridges_text}</div>' if bridges_text else ""}
|
| 179 |
+
</div>
|
| 180 |
+
""",
|
| 181 |
+
unsafe_allow_html=True,
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
with st.expander("Browse all canonical RL papers"):
|
| 185 |
+
for era in _ERA_ORDER:
|
| 186 |
+
st.markdown(f"**{era.replace('-', ' ').title()}**")
|
| 187 |
+
for p in era_groups[era]:
|
| 188 |
+
st.markdown(
|
| 189 |
+
f" `{p.arxiv_id}` **{p.tag}** ({p.year}) — {p.title}"
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 194 |
+
# TAB 2 — Ask a Question
|
| 195 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 196 |
+
with tab_ask:
|
| 197 |
+
st.subheader("Ask anything about the RL corpus")
|
| 198 |
+
st.markdown(
|
| 199 |
+
"Every answer is grounded in the indexed papers with **`[arxiv_id, p<page>]` citations**. "
|
| 200 |
+
"No hallucination — if the corpus doesn't contain an answer, the model says so."
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
index_ready = INDEX_PATH.exists()
|
| 204 |
+
if not index_ready:
|
| 205 |
+
st.warning(
|
| 206 |
+
"No FAISS index found at `data/index.faiss`. "
|
| 207 |
+
"Run `uv run python scripts/build_index.py` first."
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
with st.form("ask_form"):
|
| 211 |
+
question = st.text_area(
|
| 212 |
+
"Your question",
|
| 213 |
+
placeholder="What is the main idea behind Proximal Policy Optimization?",
|
| 214 |
+
height=80,
|
| 215 |
+
)
|
| 216 |
+
col_k, col_mode, _ = st.columns([1, 2, 3])
|
| 217 |
+
with col_k:
|
| 218 |
+
k = st.number_input("Top-k chunks", min_value=1, max_value=20, value=5)
|
| 219 |
+
with col_mode:
|
| 220 |
+
retrieval_mode = st.selectbox(
|
| 221 |
+
"Retrieval mode",
|
| 222 |
+
["Hybrid (BM25 + FAISS)", "Dense (FAISS only)"],
|
| 223 |
+
)
|
| 224 |
+
submitted = st.form_submit_button("Ask", type="primary", use_container_width=True)
|
| 225 |
+
|
| 226 |
+
import os
|
| 227 |
+
_gemini_key = os.environ.get("GEMINI_API_KEY", "")
|
| 228 |
+
if not _gemini_key:
|
| 229 |
+
st.info(
|
| 230 |
+
"**GEMINI_API_KEY not set** — the Ask tab needs a Gemini API key to generate answers. "
|
| 231 |
+
"Set it in your `.env` file (local) or as a Space secret (HF Spaces). "
|
| 232 |
+
"The Reading Path tab above works entirely offline with no API key."
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
if submitted and question.strip() and index_ready:
|
| 236 |
+
if not _gemini_key:
|
| 237 |
+
st.error("Cannot generate an answer: GEMINI_API_KEY is not set.")
|
| 238 |
+
else:
|
| 239 |
+
_, _, retriever = _load_retrieval()
|
| 240 |
+
if retriever is None:
|
| 241 |
+
st.error("Failed to load index.")
|
| 242 |
+
else:
|
| 243 |
+
with st.spinner("Retrieving and generating…"):
|
| 244 |
+
from researchpath.rag import answer as rag_answer
|
| 245 |
+
from researchpath.index import load_index, search
|
| 246 |
+
from researchpath.embeddings import Embedder
|
| 247 |
+
|
| 248 |
+
if retrieval_mode.startswith("Hybrid"):
|
| 249 |
+
hits = retriever.search(question.strip(), k=int(k))
|
| 250 |
+
else:
|
| 251 |
+
index, chunks = load_index(INDEX_PATH)
|
| 252 |
+
embedder = Embedder()
|
| 253 |
+
hits = search(index, chunks, embedder, question.strip(), k=int(k))
|
| 254 |
+
|
| 255 |
+
result = rag_answer(question.strip(), hits)
|
| 256 |
+
|
| 257 |
+
st.markdown("### Answer")
|
| 258 |
+
st.markdown(result.answer)
|
| 259 |
+
|
| 260 |
+
st.markdown("---")
|
| 261 |
+
col_meta1, col_meta2, col_meta3 = st.columns(3)
|
| 262 |
+
col_meta1.metric("Chunks retrieved", len(hits))
|
| 263 |
+
col_meta2.metric("Tokens in", result.llm.input_tokens or "—")
|
| 264 |
+
col_meta3.metric("Tokens out", result.llm.output_tokens or "—")
|
| 265 |
+
|
| 266 |
+
with st.expander(f"📄 Retrieved chunks ({len(hits)})"):
|
| 267 |
+
for i, h in enumerate(hits, 1):
|
| 268 |
+
st.markdown(
|
| 269 |
+
f"**#{i}** `[{h.arxiv_id}, p{h.page}]` "
|
| 270 |
+
f"score={h.score:.3f}"
|
| 271 |
+
)
|
| 272 |
+
st.text(h.text[:400] + ("…" if len(h.text) > 400 else ""))
|
| 273 |
+
st.divider()
|
| 274 |
+
|
| 275 |
+
# Example questions
|
| 276 |
+
with st.expander("Example questions to try"):
|
| 277 |
+
examples = [
|
| 278 |
+
"What are the six components Rainbow combines?",
|
| 279 |
+
"How does PPO avoid the large policy update problem in TRPO?",
|
| 280 |
+
"What is the key insight behind Generalized Advantage Estimation?",
|
| 281 |
+
"How does DDPG handle continuous action spaces?",
|
| 282 |
+
"What is the DPO loss function and why does it not need a reward model?",
|
| 283 |
+
"How does A3C's asynchronous training replace experience replay?",
|
| 284 |
+
]
|
| 285 |
+
for ex in examples:
|
| 286 |
+
st.markdown(f"- *{ex}*")
|
data/chunks.jsonl
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
data/gold_dataset.json
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"id": "dqn_01",
|
| 4 |
+
"question": "What neural network architecture did DQN use to play Atari games, and what was the input representation?",
|
| 5 |
+
"expected_arxiv_ids": ["1312.5602"],
|
| 6 |
+
"expected_key_claim": "convolutional neural network with raw pixel input, specifically 84x84 grayscale frames with the last 4 frames stacked as input",
|
| 7 |
+
"difficulty": "easy"
|
| 8 |
+
},
|
| 9 |
+
{
|
| 10 |
+
"id": "dqn_02",
|
| 11 |
+
"question": "What is experience replay in DQN and why does it improve training stability?",
|
| 12 |
+
"expected_arxiv_ids": ["1312.5602"],
|
| 13 |
+
"expected_key_claim": "stores agent transitions in a replay buffer and samples random minibatches to break temporal correlations between consecutive updates",
|
| 14 |
+
"difficulty": "easy"
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
"id": "dqn_03",
|
| 18 |
+
"question": "What is the target network in DQN and what problem does it address?",
|
| 19 |
+
"expected_arxiv_ids": ["1312.5602"],
|
| 20 |
+
"expected_key_claim": "a separate network with periodically-copied weights used to compute stable TD targets, reducing oscillation caused by moving targets in standard Q-learning",
|
| 21 |
+
"difficulty": "medium"
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
"id": "ddqn_01",
|
| 25 |
+
"question": "What overestimation problem in standard DQN does Double DQN address?",
|
| 26 |
+
"expected_arxiv_ids": ["1509.06461"],
|
| 27 |
+
"expected_key_claim": "DQN uses the same network for both action selection and value evaluation in the max operator, causing systematic overestimation of Q-values",
|
| 28 |
+
"difficulty": "medium"
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
"id": "ddqn_02",
|
| 32 |
+
"question": "How does Double DQN decouple action selection from action evaluation?",
|
| 33 |
+
"expected_arxiv_ids": ["1509.06461"],
|
| 34 |
+
"expected_key_claim": "the online network selects the greedy action while the target network evaluates its value, preventing the same network from both choosing and scoring actions",
|
| 35 |
+
"difficulty": "medium"
|
| 36 |
+
},
|
| 37 |
+
{
|
| 38 |
+
"id": "dueling_01",
|
| 39 |
+
"question": "What are the Value and Advantage streams in the Dueling Network architecture and how are they combined?",
|
| 40 |
+
"expected_arxiv_ids": ["1511.06581"],
|
| 41 |
+
"expected_key_claim": "shared convolutional encoder splits into V(s) stream and A(s,a) stream; combined as Q(s,a) = V(s) + A(s,a) minus mean advantage for identifiability",
|
| 42 |
+
"difficulty": "medium"
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
"id": "dueling_02",
|
| 46 |
+
"question": "Why must the advantage stream be mean-subtracted in the Dueling DQN aggregation module?",
|
| 47 |
+
"expected_arxiv_ids": ["1511.06581"],
|
| 48 |
+
"expected_key_claim": "without centering, V and A are not uniquely identifiable from Q alone; subtracting the mean advantage forces V to be the state value and A to be relative advantages",
|
| 49 |
+
"difficulty": "hard"
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
"id": "rainbow_01",
|
| 53 |
+
"question": "What six algorithmic improvements does Rainbow combine?",
|
| 54 |
+
"expected_arxiv_ids": ["1710.02298"],
|
| 55 |
+
"expected_key_claim": "double Q-learning, prioritized experience replay, dueling networks, multi-step returns, distributional RL (C51), and noisy nets for exploration",
|
| 56 |
+
"difficulty": "easy"
|
| 57 |
+
},
|
| 58 |
+
{
|
| 59 |
+
"id": "rainbow_02",
|
| 60 |
+
"question": "What is distributional RL and how does Rainbow use it?",
|
| 61 |
+
"expected_arxiv_ids": ["1710.02298"],
|
| 62 |
+
"expected_key_claim": "instead of estimating expected return, distributional RL models the full probability distribution over returns; Rainbow uses C51 which represents this as a categorical distribution over fixed discrete atoms",
|
| 63 |
+
"difficulty": "hard"
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
"id": "rainbow_03",
|
| 67 |
+
"question": "What is noisy networks (NoisyNets) and how does Rainbow use it for exploration?",
|
| 68 |
+
"expected_arxiv_ids": ["1710.02298"],
|
| 69 |
+
"expected_key_claim": "replaces epsilon-greedy exploration with learned stochastic weights in the network's linear layers; noise parameters are learned by gradient descent, enabling state-dependent exploration",
|
| 70 |
+
"difficulty": "hard"
|
| 71 |
+
},
|
| 72 |
+
{
|
| 73 |
+
"id": "trpo_01",
|
| 74 |
+
"question": "What is the trust region constraint in TRPO and why is it necessary?",
|
| 75 |
+
"expected_arxiv_ids": ["1502.05477"],
|
| 76 |
+
"expected_key_claim": "constrains KL divergence between old and new policy to be below a threshold delta at each update, preventing destructively large policy changes that collapse performance",
|
| 77 |
+
"difficulty": "medium"
|
| 78 |
+
},
|
| 79 |
+
{
|
| 80 |
+
"id": "trpo_02",
|
| 81 |
+
"question": "What optimization algorithm does TRPO use to solve the constrained policy update problem?",
|
| 82 |
+
"expected_arxiv_ids": ["1502.05477"],
|
| 83 |
+
"expected_key_claim": "conjugate gradient to compute the natural policy gradient direction, then a backtracking line search to find the largest step satisfying the KL constraint",
|
| 84 |
+
"difficulty": "hard"
|
| 85 |
+
},
|
| 86 |
+
{
|
| 87 |
+
"id": "trpo_03",
|
| 88 |
+
"question": "What surrogate objective does TRPO optimize and how does it relate to the true policy objective?",
|
| 89 |
+
"expected_arxiv_ids": ["1502.05477"],
|
| 90 |
+
"expected_key_claim": "optimizes a surrogate objective using importance sampling ratios of new to old policy probabilities, which is a local approximation to the true objective guaranteed to be a lower bound within the trust region",
|
| 91 |
+
"difficulty": "hard"
|
| 92 |
+
},
|
| 93 |
+
{
|
| 94 |
+
"id": "gae_01",
|
| 95 |
+
"question": "What does GAE stand for and what fundamental tradeoff does it manage?",
|
| 96 |
+
"expected_arxiv_ids": ["1506.02438"],
|
| 97 |
+
"expected_key_claim": "Generalized Advantage Estimation; trades off bias versus variance in advantage estimates for policy gradient methods using an exponential weighting parameter lambda",
|
| 98 |
+
"difficulty": "easy"
|
| 99 |
+
},
|
| 100 |
+
{
|
| 101 |
+
"id": "gae_02",
|
| 102 |
+
"question": "How does the GAE lambda parameter interpolate between Monte Carlo and TD advantage estimates?",
|
| 103 |
+
"expected_arxiv_ids": ["1506.02438"],
|
| 104 |
+
"expected_key_claim": "lambda=0 reduces to single-step TD advantage (low variance, high bias); lambda=1 reduces to Monte Carlo return minus baseline (low bias, high variance); intermediate values exponentially weight n-step returns",
|
| 105 |
+
"difficulty": "medium"
|
| 106 |
+
},
|
| 107 |
+
{
|
| 108 |
+
"id": "a3c_01",
|
| 109 |
+
"question": "How does A3C use asynchronous parallel workers to stabilize training?",
|
| 110 |
+
"expected_arxiv_ids": ["1602.01783"],
|
| 111 |
+
"expected_key_claim": "multiple actor-learner threads each interact with independent environment copies and asynchronously send gradient updates to a shared global network, decorrelating experience without requiring a replay buffer",
|
| 112 |
+
"difficulty": "medium"
|
| 113 |
+
},
|
| 114 |
+
{
|
| 115 |
+
"id": "a3c_02",
|
| 116 |
+
"question": "What advantage function does A3C use in its policy gradient update?",
|
| 117 |
+
"expected_arxiv_ids": ["1602.01783"],
|
| 118 |
+
"expected_key_claim": "uses n-step returns as the target minus the value function V(s) as a baseline; the value network is trained simultaneously as a critic to estimate state values",
|
| 119 |
+
"difficulty": "medium"
|
| 120 |
+
},
|
| 121 |
+
{
|
| 122 |
+
"id": "a3c_03",
|
| 123 |
+
"question": "How does A3C handle both discrete and continuous action spaces?",
|
| 124 |
+
"expected_arxiv_ids": ["1602.01783"],
|
| 125 |
+
"expected_key_claim": "for discrete actions uses softmax policy; for continuous actions outputs mean and variance of a Gaussian, sampling actions from it; the same asynchronous framework applies to both",
|
| 126 |
+
"difficulty": "hard"
|
| 127 |
+
},
|
| 128 |
+
{
|
| 129 |
+
"id": "ddpg_01",
|
| 130 |
+
"question": "How does DDPG extend DQN to continuous action spaces?",
|
| 131 |
+
"expected_arxiv_ids": ["1509.02971"],
|
| 132 |
+
"expected_key_claim": "uses a deterministic actor network that outputs the exact continuous action and a critic network that evaluates Q(s,a); applies the deterministic policy gradient theorem rather than maximizing over a discrete action set",
|
| 133 |
+
"difficulty": "medium"
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
"id": "ddpg_02",
|
| 137 |
+
"question": "What exploration strategy does DDPG use for continuous action spaces?",
|
| 138 |
+
"expected_arxiv_ids": ["1509.02971"],
|
| 139 |
+
"expected_key_claim": "adds temporally-correlated Ornstein-Uhlenbeck noise to the deterministic policy output during training, encouraging exploration with smooth, momentum-based noise rather than independent Gaussian noise",
|
| 140 |
+
"difficulty": "medium"
|
| 141 |
+
},
|
| 142 |
+
{
|
| 143 |
+
"id": "ddpg_03",
|
| 144 |
+
"question": "How does DDPG update its target networks and why does this differ from DQN?",
|
| 145 |
+
"expected_arxiv_ids": ["1509.02971"],
|
| 146 |
+
"expected_key_claim": "uses soft Polyak averaging (target = tau*online + (1-tau)*target) at every step instead of hard periodic copying, providing slower and more stable target updates for continuous control",
|
| 147 |
+
"difficulty": "medium"
|
| 148 |
+
},
|
| 149 |
+
{
|
| 150 |
+
"id": "instructgpt_01",
|
| 151 |
+
"question": "What are the three training stages in InstructGPT's RLHF pipeline?",
|
| 152 |
+
"expected_arxiv_ids": ["2203.02155"],
|
| 153 |
+
"expected_key_claim": "first supervised fine-tuning on human-written demonstrations, then reward model training from pairwise human preference comparisons, then PPO policy optimization against the reward model with KL penalty",
|
| 154 |
+
"difficulty": "easy"
|
| 155 |
+
},
|
| 156 |
+
{
|
| 157 |
+
"id": "instructgpt_02",
|
| 158 |
+
"question": "What is the KL penalty term in InstructGPT's RL objective and why is it included?",
|
| 159 |
+
"expected_arxiv_ids": ["2203.02155"],
|
| 160 |
+
"expected_key_claim": "penalizes KL divergence between the RLHF policy and the original SFT policy to prevent reward hacking, maintain language quality, and avoid the policy collapsing to adversarial outputs that game the reward model",
|
| 161 |
+
"difficulty": "medium"
|
| 162 |
+
},
|
| 163 |
+
{
|
| 164 |
+
"id": "instructgpt_03",
|
| 165 |
+
"question": "How does InstructGPT collect human preference data for training the reward model?",
|
| 166 |
+
"expected_arxiv_ids": ["2203.02155"],
|
| 167 |
+
"expected_key_claim": "human labelers rank multiple model completions for the same prompt; these pairwise orderings are converted into preference pairs used to train the reward model with a cross-entropy loss",
|
| 168 |
+
"difficulty": "easy"
|
| 169 |
+
},
|
| 170 |
+
{
|
| 171 |
+
"id": "dpo_01",
|
| 172 |
+
"question": "What is the core insight of DPO that eliminates the need for explicit RL?",
|
| 173 |
+
"expected_arxiv_ids": ["2305.18290"],
|
| 174 |
+
"expected_key_claim": "shows that the optimal RLHF policy implies a closed-form reward expressible as log probability ratios between the policy and reference model; this allows directly optimizing the policy from preference data without training a separate reward model",
|
| 175 |
+
"difficulty": "hard"
|
| 176 |
+
},
|
| 177 |
+
{
|
| 178 |
+
"id": "dpo_02",
|
| 179 |
+
"question": "What is the DPO training objective and what data does it require?",
|
| 180 |
+
"expected_arxiv_ids": ["2305.18290"],
|
| 181 |
+
"expected_key_claim": "binary cross-entropy loss on preference pairs where loss increases the likelihood of preferred completions relative to rejected ones, weighted by how much the policy deviates from the reference model; requires only (prompt, chosen, rejected) triples",
|
| 182 |
+
"difficulty": "medium"
|
| 183 |
+
},
|
| 184 |
+
{
|
| 185 |
+
"id": "cross_01",
|
| 186 |
+
"question": "How do DQN and Double DQN differ in their Bellman target computation?",
|
| 187 |
+
"expected_arxiv_ids": ["1312.5602", "1509.06461"],
|
| 188 |
+
"expected_key_claim": "DQN uses the target network for both selecting and evaluating the greedy action; Double DQN uses the online network to select the action and the target network only to evaluate it, eliminating the maximization bias",
|
| 189 |
+
"difficulty": "medium"
|
| 190 |
+
},
|
| 191 |
+
{
|
| 192 |
+
"id": "cross_02",
|
| 193 |
+
"question": "What architectural innovation separates Dueling DQN from standard DQN?",
|
| 194 |
+
"expected_arxiv_ids": ["1511.06581", "1312.5602"],
|
| 195 |
+
"expected_key_claim": "Dueling DQN splits the final layers into two streams estimating state value V(s) and state-action advantages A(s,a) separately, which standard DQN does not do",
|
| 196 |
+
"difficulty": "easy"
|
| 197 |
+
},
|
| 198 |
+
{
|
| 199 |
+
"id": "cross_03",
|
| 200 |
+
"question": "How do InstructGPT and DPO differ in their approach to aligning language models with human preferences?",
|
| 201 |
+
"expected_arxiv_ids": ["2203.02155", "2305.18290"],
|
| 202 |
+
"expected_key_claim": "InstructGPT trains an explicit reward model then runs PPO against it; DPO directly optimizes the policy from preference data using a classification loss, eliminating the reward model and RL training loop entirely",
|
| 203 |
+
"difficulty": "medium"
|
| 204 |
+
},
|
| 205 |
+
{
|
| 206 |
+
"id": "cross_04",
|
| 207 |
+
"question": "How do DDPG and A3C differ in their approach to continuous control problems?",
|
| 208 |
+
"expected_arxiv_ids": ["1509.02971", "1602.01783"],
|
| 209 |
+
"expected_key_claim": "DDPG is off-policy and uses a deterministic actor-critic with experience replay; A3C is on-policy and uses asynchronous parallel workers without a replay buffer; both can handle continuous actions",
|
| 210 |
+
"difficulty": "hard"
|
| 211 |
+
}
|
| 212 |
+
]
|
data/index.chunks.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
data/index.faiss
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5ab13f9b884b3a0e294322dbb2b6568578d9bbbc370ee7e20e701d639aa55947
|
| 3 |
+
size 2747949
|
pyproject.toml
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "researchpath"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Agentic research onboarding companion — builds personalized reading paths into RL research."
|
| 5 |
+
requires-python = ">=3.11"
|
| 6 |
+
dependencies = [
|
| 7 |
+
"google-genai>=1.0.0",
|
| 8 |
+
"groq>=0.11.0",
|
| 9 |
+
"python-dotenv>=1.0.0",
|
| 10 |
+
"rich>=13.0.0",
|
| 11 |
+
"arxiv>=2.1.0",
|
| 12 |
+
"pymupdf>=1.24.0",
|
| 13 |
+
"sentence-transformers>=3.0.0",
|
| 14 |
+
"faiss-cpu>=1.8.0",
|
| 15 |
+
"numpy>=1.26.0",
|
| 16 |
+
"rank-bm25>=0.2.2",
|
| 17 |
+
"streamlit>=1.35.0",
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
[dependency-groups]
|
| 21 |
+
dev = [
|
| 22 |
+
"pytest>=8.0.0",
|
| 23 |
+
"ruff>=0.6.0",
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
[build-system]
|
| 27 |
+
requires = ["hatchling"]
|
| 28 |
+
build-backend = "hatchling.build"
|
| 29 |
+
|
| 30 |
+
[tool.hatch.build.targets.wheel]
|
| 31 |
+
packages = ["researchpath"]
|
| 32 |
+
|
| 33 |
+
[tool.ruff]
|
| 34 |
+
line-length = 100
|
| 35 |
+
target-version = "py311"
|
| 36 |
+
|
| 37 |
+
[tool.ruff.lint]
|
| 38 |
+
select = ["E", "F", "I", "B", "UP"]
|
requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
google-genai>=1.0.0
|
| 2 |
+
groq>=0.11.0
|
| 3 |
+
python-dotenv>=1.0.0
|
| 4 |
+
rich>=13.0.0
|
| 5 |
+
pymupdf>=1.24.0
|
| 6 |
+
sentence-transformers>=3.0.0
|
| 7 |
+
faiss-cpu>=1.8.0
|
| 8 |
+
numpy>=1.26.0
|
| 9 |
+
rank-bm25>=0.2.2
|
| 10 |
+
streamlit>=1.35.0
|
researchpath/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ResearchPath — agentic research onboarding companion."""
|
| 2 |
+
|
| 3 |
+
__version__ = "0.1.0"
|
researchpath/corpus.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Canonical Reinforcement Learning paper corpus.
|
| 2 |
+
|
| 3 |
+
These ~17 papers form a defensible reading-path graph from foundational
|
| 4 |
+
deep RL (DQN, 2013) to modern RLHF (DPO, 2023). The agent will reason over
|
| 5 |
+
their content + cross-references to build personalized reading plans.
|
| 6 |
+
|
| 7 |
+
`era` tags are used as a coarse prior for prerequisite ordering — the agent
|
| 8 |
+
can override based on extracted concept dependencies.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from dataclasses import dataclass
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass(frozen=True)
|
| 16 |
+
class Paper:
|
| 17 |
+
arxiv_id: str
|
| 18 |
+
title: str
|
| 19 |
+
year: int
|
| 20 |
+
authors: str
|
| 21 |
+
tag: str
|
| 22 |
+
era: str # value-based | policy-gradient | actor-critic | model-based | rlhf
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
CANONICAL_RL_PAPERS: list[Paper] = [
|
| 26 |
+
# --- Value-based deep RL ---
|
| 27 |
+
Paper("1312.5602", "Playing Atari with Deep Reinforcement Learning", 2013,
|
| 28 |
+
"Mnih et al.", "DQN", "value-based"),
|
| 29 |
+
Paper("1509.06461", "Deep Reinforcement Learning with Double Q-learning", 2015,
|
| 30 |
+
"van Hasselt et al.", "Double DQN", "value-based"),
|
| 31 |
+
Paper("1511.06581", "Dueling Network Architectures for Deep Reinforcement Learning", 2016,
|
| 32 |
+
"Wang et al.", "Dueling DQN", "value-based"),
|
| 33 |
+
Paper("1511.05952", "Prioritized Experience Replay", 2015,
|
| 34 |
+
"Schaul et al.", "PER", "value-based"),
|
| 35 |
+
Paper("1710.02298", "Rainbow: Combining Improvements in Deep Reinforcement Learning", 2017,
|
| 36 |
+
"Hessel et al.", "Rainbow", "value-based"),
|
| 37 |
+
|
| 38 |
+
# --- Policy gradient / trust region ---
|
| 39 |
+
Paper("1502.05477", "Trust Region Policy Optimization", 2015,
|
| 40 |
+
"Schulman et al.", "TRPO", "policy-gradient"),
|
| 41 |
+
Paper("1506.02438",
|
| 42 |
+
"High-Dimensional Continuous Control Using Generalized Advantage Estimation", 2015,
|
| 43 |
+
"Schulman et al.", "GAE", "policy-gradient"),
|
| 44 |
+
Paper("1707.06347", "Proximal Policy Optimization Algorithms", 2017,
|
| 45 |
+
"Schulman et al.", "PPO", "policy-gradient"),
|
| 46 |
+
|
| 47 |
+
# --- Actor-critic / continuous control ---
|
| 48 |
+
Paper("1602.01783", "Asynchronous Methods for Deep Reinforcement Learning", 2016,
|
| 49 |
+
"Mnih et al.", "A3C", "actor-critic"),
|
| 50 |
+
Paper("1509.02971", "Continuous Control with Deep Reinforcement Learning", 2015,
|
| 51 |
+
"Lillicrap et al.", "DDPG", "actor-critic"),
|
| 52 |
+
Paper("1801.01290",
|
| 53 |
+
"Soft Actor-Critic: Off-Policy Maximum Entropy Deep Reinforcement Learning", 2018,
|
| 54 |
+
"Haarnoja et al.", "SAC", "actor-critic"),
|
| 55 |
+
Paper("1802.01561", "IMPALA: Scalable Distributed Deep-RL", 2018,
|
| 56 |
+
"Espeholt et al.", "IMPALA", "actor-critic"),
|
| 57 |
+
|
| 58 |
+
# --- Model-based / planning ---
|
| 59 |
+
Paper("1911.08265",
|
| 60 |
+
"Mastering Atari, Go, Chess and Shogi by Planning with a Learned Model", 2019,
|
| 61 |
+
"Schrittwieser et al.", "MuZero", "model-based"),
|
| 62 |
+
Paper("2301.04104", "Mastering Diverse Domains through World Models", 2023,
|
| 63 |
+
"Hafner et al.", "DreamerV3", "model-based"),
|
| 64 |
+
|
| 65 |
+
# --- Sequence modeling for RL ---
|
| 66 |
+
Paper("2106.01345", "Decision Transformer: Reinforcement Learning via Sequence Modeling", 2021,
|
| 67 |
+
"Chen et al.", "Decision Transformer", "model-based"),
|
| 68 |
+
|
| 69 |
+
# --- RLHF (the modern application that put RL in every product hiring conversation) ---
|
| 70 |
+
Paper("2203.02155", "Training language models to follow instructions with human feedback", 2022,
|
| 71 |
+
"Ouyang et al.", "InstructGPT", "rlhf"),
|
| 72 |
+
Paper("2305.18290", "Direct Preference Optimization: Your Language Model is Secretly a Reward Model",
|
| 73 |
+
2023, "Rafailov et al.", "DPO", "rlhf"),
|
| 74 |
+
]
|
researchpath/embeddings.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sentence-transformers wrapper for chunk + query embedding.
|
| 2 |
+
|
| 3 |
+
Uses BAAI/bge-small-en-v1.5: 384-dim, ~133MB, CPU-fast, strong on academic text.
|
| 4 |
+
Embeddings are L2-normalized so cosine similarity == inner product (lets us
|
| 5 |
+
use FAISS IndexFlatIP).
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
from sentence_transformers import SentenceTransformer
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
DEFAULT_MODEL = "BAAI/bge-small-en-v1.5"
|
| 14 |
+
EMBED_DIM = 384
|
| 15 |
+
|
| 16 |
+
# BGE models recommend prefixing search queries (not documents) with this
|
| 17 |
+
# instruction. Improves retrieval quality measurably on academic text.
|
| 18 |
+
QUERY_INSTRUCTION = "Represent this question for retrieving relevant scientific passages: "
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class Embedder:
|
| 22 |
+
def __init__(self, model_name: str = DEFAULT_MODEL):
|
| 23 |
+
self.model_name = model_name
|
| 24 |
+
self._model: SentenceTransformer | None = None
|
| 25 |
+
|
| 26 |
+
@property
|
| 27 |
+
def model(self) -> SentenceTransformer:
|
| 28 |
+
if self._model is None:
|
| 29 |
+
self._model = SentenceTransformer(self.model_name)
|
| 30 |
+
return self._model
|
| 31 |
+
|
| 32 |
+
def embed_documents(self, texts: list[str], batch_size: int = 32) -> np.ndarray:
|
| 33 |
+
return self.model.encode(
|
| 34 |
+
texts,
|
| 35 |
+
batch_size=batch_size,
|
| 36 |
+
normalize_embeddings=True,
|
| 37 |
+
show_progress_bar=True,
|
| 38 |
+
convert_to_numpy=True,
|
| 39 |
+
).astype(np.float32)
|
| 40 |
+
|
| 41 |
+
def embed_query(self, text: str) -> np.ndarray:
|
| 42 |
+
prompt = QUERY_INSTRUCTION + text
|
| 43 |
+
vec = self.model.encode(
|
| 44 |
+
[prompt], normalize_embeddings=True, convert_to_numpy=True
|
| 45 |
+
)[0].astype(np.float32)
|
| 46 |
+
return vec
|
researchpath/eval.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluation harness for ResearchPath baseline RAG.
|
| 2 |
+
|
| 3 |
+
Metrics:
|
| 4 |
+
retrieval_recall_at_k — fraction of expected arxiv IDs found in top-k hits
|
| 5 |
+
citation_presence — does the answer cite at least one expected paper?
|
| 6 |
+
answer_correctness — LLM-as-judge: does answer capture the key claim?
|
| 7 |
+
latency_s — wall-clock seconds for retrieve + generate
|
| 8 |
+
tokens_in/out — for cost tracking
|
| 9 |
+
|
| 10 |
+
Gold dataset schema (data/gold_dataset.json):
|
| 11 |
+
id, question, expected_arxiv_ids, expected_key_claim, difficulty
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
import time
|
| 17 |
+
from dataclasses import dataclass, field
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
from researchpath.index import Hit
|
| 21 |
+
from researchpath.llm import LLMResponse, groq_generate
|
| 22 |
+
from researchpath.rag import RAGAnswer
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@dataclass(frozen=True)
|
| 26 |
+
class GoldExample:
|
| 27 |
+
id: str
|
| 28 |
+
question: str
|
| 29 |
+
expected_arxiv_ids: list[str]
|
| 30 |
+
expected_key_claim: str
|
| 31 |
+
difficulty: str = "medium"
|
| 32 |
+
|
| 33 |
+
@classmethod
|
| 34 |
+
def from_dict(cls, d: dict) -> "GoldExample":
|
| 35 |
+
return cls(
|
| 36 |
+
id=d["id"],
|
| 37 |
+
question=d["question"],
|
| 38 |
+
expected_arxiv_ids=d["expected_arxiv_ids"],
|
| 39 |
+
expected_key_claim=d["expected_key_claim"],
|
| 40 |
+
difficulty=d.get("difficulty", "medium"),
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@dataclass
|
| 45 |
+
class EvalResult:
|
| 46 |
+
example: GoldExample
|
| 47 |
+
hits: list[Hit]
|
| 48 |
+
rag_answer: RAGAnswer
|
| 49 |
+
retrieval_recall: float # fraction of expected IDs in top-k
|
| 50 |
+
citation_present: bool # answer cites >= 1 expected paper
|
| 51 |
+
answer_correct: bool # LLM judge says key claim is captured
|
| 52 |
+
answer_correct_raw: str # raw judge response
|
| 53 |
+
latency_s: float
|
| 54 |
+
judge_tokens_in: int = 0
|
| 55 |
+
judge_tokens_out: int = 0
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@dataclass
|
| 59 |
+
class EvalSummary:
|
| 60 |
+
n: int
|
| 61 |
+
retrieval_recall_at_k: float
|
| 62 |
+
citation_presence: float
|
| 63 |
+
answer_correctness: float
|
| 64 |
+
avg_latency_s: float
|
| 65 |
+
total_rag_tokens_in: int
|
| 66 |
+
total_rag_tokens_out: int
|
| 67 |
+
total_judge_tokens_in: int
|
| 68 |
+
total_judge_tokens_out: int
|
| 69 |
+
by_difficulty: dict[str, dict] = field(default_factory=dict)
|
| 70 |
+
|
| 71 |
+
def print_table(self) -> None:
|
| 72 |
+
print("\n" + "=" * 62)
|
| 73 |
+
print(f" Baseline RAG Eval | n={self.n}")
|
| 74 |
+
print("=" * 62)
|
| 75 |
+
print(f" Retrieval Recall@5 : {self.retrieval_recall_at_k:.1%}")
|
| 76 |
+
print(f" Citation Presence : {self.citation_presence:.1%}")
|
| 77 |
+
print(f" Answer Correctness : {self.answer_correctness:.1%}")
|
| 78 |
+
print(f" Avg Latency : {self.avg_latency_s:.2f}s")
|
| 79 |
+
print(f" RAG Tokens : {self.total_rag_tokens_in:,} in / {self.total_rag_tokens_out:,} out")
|
| 80 |
+
print(f" Judge Tokens : {self.total_judge_tokens_in:,} in / {self.total_judge_tokens_out:,} out")
|
| 81 |
+
if self.by_difficulty:
|
| 82 |
+
print("\n By difficulty:")
|
| 83 |
+
for diff, stats in sorted(self.by_difficulty.items()):
|
| 84 |
+
print(f" {diff:8s}: recall={stats['recall']:.1%} correct={stats['correct']:.1%} n={stats['n']}")
|
| 85 |
+
print("=" * 62 + "\n")
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def load_gold_dataset(path: Path) -> list[GoldExample]:
|
| 89 |
+
with open(path, encoding="utf-8") as f:
|
| 90 |
+
data = json.load(f)
|
| 91 |
+
return [GoldExample.from_dict(d) for d in data]
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _retrieval_recall(hits: list[Hit], expected_ids: list[str]) -> float:
|
| 95 |
+
hit_ids = {h.arxiv_id for h in hits}
|
| 96 |
+
found = sum(1 for eid in expected_ids if eid in hit_ids)
|
| 97 |
+
return found / len(expected_ids)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _citation_present(answer_text: str, expected_ids: list[str]) -> bool:
|
| 101 |
+
return any(eid in answer_text for eid in expected_ids)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
_JUDGE_PROMPT = """You are an evaluator. Determine whether the given Answer adequately captures the Key Claim.
|
| 105 |
+
|
| 106 |
+
Key Claim: {key_claim}
|
| 107 |
+
|
| 108 |
+
Answer: {answer}
|
| 109 |
+
|
| 110 |
+
Does the answer capture the essence of the key claim? Reply with exactly one word: YES or NO."""
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _judge_answer(answer_text: str, key_claim: str, model: str = "llama-3.3-70b-versatile") -> tuple[bool, str, LLMResponse]:
|
| 114 |
+
prompt = _JUDGE_PROMPT.format(key_claim=key_claim, answer=answer_text)
|
| 115 |
+
resp = groq_generate(prompt, model=model)
|
| 116 |
+
raw = resp.text.strip().upper()
|
| 117 |
+
correct = raw.startswith("YES")
|
| 118 |
+
return correct, raw, resp
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def evaluate_example(
|
| 122 |
+
example: GoldExample,
|
| 123 |
+
hits: list[Hit],
|
| 124 |
+
rag_answer: RAGAnswer,
|
| 125 |
+
latency_s: float,
|
| 126 |
+
judge_model: str = "llama-3.3-70b-versatile",
|
| 127 |
+
) -> EvalResult:
|
| 128 |
+
recall = _retrieval_recall(hits, example.expected_arxiv_ids)
|
| 129 |
+
cite_ok = _citation_present(rag_answer.answer, example.expected_arxiv_ids)
|
| 130 |
+
correct, raw, judge_resp = _judge_answer(rag_answer.answer, example.expected_key_claim, model=judge_model)
|
| 131 |
+
|
| 132 |
+
return EvalResult(
|
| 133 |
+
example=example,
|
| 134 |
+
hits=hits,
|
| 135 |
+
rag_answer=rag_answer,
|
| 136 |
+
retrieval_recall=recall,
|
| 137 |
+
citation_present=cite_ok,
|
| 138 |
+
answer_correct=correct,
|
| 139 |
+
answer_correct_raw=raw,
|
| 140 |
+
latency_s=latency_s,
|
| 141 |
+
judge_tokens_in=judge_resp.input_tokens or 0,
|
| 142 |
+
judge_tokens_out=judge_resp.output_tokens or 0,
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def summarize(results: list[EvalResult]) -> EvalSummary:
|
| 147 |
+
n = len(results)
|
| 148 |
+
recall = sum(r.retrieval_recall for r in results) / n
|
| 149 |
+
cite = sum(r.citation_present for r in results) / n
|
| 150 |
+
correct = sum(r.answer_correct for r in results) / n
|
| 151 |
+
avg_lat = sum(r.latency_s for r in results) / n
|
| 152 |
+
|
| 153 |
+
rag_in = sum(r.rag_answer.llm.input_tokens or 0 for r in results)
|
| 154 |
+
rag_out = sum(r.rag_answer.llm.output_tokens or 0 for r in results)
|
| 155 |
+
judge_in = sum(r.judge_tokens_in for r in results)
|
| 156 |
+
judge_out = sum(r.judge_tokens_out for r in results)
|
| 157 |
+
|
| 158 |
+
by_diff: dict[str, dict] = {}
|
| 159 |
+
for r in results:
|
| 160 |
+
d = r.example.difficulty
|
| 161 |
+
if d not in by_diff:
|
| 162 |
+
by_diff[d] = {"n": 0, "recall_sum": 0.0, "correct_sum": 0}
|
| 163 |
+
by_diff[d]["n"] += 1
|
| 164 |
+
by_diff[d]["recall_sum"] += r.retrieval_recall
|
| 165 |
+
by_diff[d]["correct_sum"] += int(r.answer_correct)
|
| 166 |
+
|
| 167 |
+
by_diff_summary = {
|
| 168 |
+
d: {
|
| 169 |
+
"n": v["n"],
|
| 170 |
+
"recall": v["recall_sum"] / v["n"],
|
| 171 |
+
"correct": v["correct_sum"] / v["n"],
|
| 172 |
+
}
|
| 173 |
+
for d, v in by_diff.items()
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
return EvalSummary(
|
| 177 |
+
n=n,
|
| 178 |
+
retrieval_recall_at_k=recall,
|
| 179 |
+
citation_presence=cite,
|
| 180 |
+
answer_correctness=correct,
|
| 181 |
+
avg_latency_s=avg_lat,
|
| 182 |
+
total_rag_tokens_in=rag_in,
|
| 183 |
+
total_rag_tokens_out=rag_out,
|
| 184 |
+
total_judge_tokens_in=judge_in,
|
| 185 |
+
total_judge_tokens_out=judge_out,
|
| 186 |
+
by_difficulty=by_diff_summary,
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def results_to_json(results: list[EvalResult], summary: EvalSummary) -> dict:
|
| 191 |
+
return {
|
| 192 |
+
"summary": {
|
| 193 |
+
"n": summary.n,
|
| 194 |
+
"retrieval_recall_at_k": summary.retrieval_recall_at_k,
|
| 195 |
+
"citation_presence": summary.citation_presence,
|
| 196 |
+
"answer_correctness": summary.answer_correctness,
|
| 197 |
+
"avg_latency_s": summary.avg_latency_s,
|
| 198 |
+
"total_rag_tokens_in": summary.total_rag_tokens_in,
|
| 199 |
+
"total_rag_tokens_out": summary.total_rag_tokens_out,
|
| 200 |
+
"total_judge_tokens_in": summary.total_judge_tokens_in,
|
| 201 |
+
"total_judge_tokens_out": summary.total_judge_tokens_out,
|
| 202 |
+
"by_difficulty": summary.by_difficulty,
|
| 203 |
+
},
|
| 204 |
+
"results": [
|
| 205 |
+
{
|
| 206 |
+
"id": r.example.id,
|
| 207 |
+
"difficulty": r.example.difficulty,
|
| 208 |
+
"question": r.example.question,
|
| 209 |
+
"expected_arxiv_ids": r.example.expected_arxiv_ids,
|
| 210 |
+
"retrieved_arxiv_ids": [h.arxiv_id for h in r.hits],
|
| 211 |
+
"retrieval_recall": r.retrieval_recall,
|
| 212 |
+
"citation_present": r.citation_present,
|
| 213 |
+
"answer_correct": r.answer_correct,
|
| 214 |
+
"answer_correct_raw": r.answer_correct_raw,
|
| 215 |
+
"latency_s": r.latency_s,
|
| 216 |
+
"answer": r.rag_answer.answer,
|
| 217 |
+
}
|
| 218 |
+
for r in results
|
| 219 |
+
],
|
| 220 |
+
}
|
researchpath/index.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FAISS index over chunks. Flat inner-product index — exact, fast enough for ~1k chunks."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import faiss
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
from researchpath.embeddings import EMBED_DIM, Embedder
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass(frozen=True)
|
| 15 |
+
class Hit:
|
| 16 |
+
score: float
|
| 17 |
+
arxiv_id: str
|
| 18 |
+
page: int
|
| 19 |
+
chunk_idx: int # within-paper index (from chunk dict)
|
| 20 |
+
global_idx: int # position in the chunks list == FAISS vector index
|
| 21 |
+
text: str
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _chunks_path(index_path: Path) -> Path:
|
| 25 |
+
return index_path.with_suffix(".chunks.json")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def build_index(chunks: list[dict], embedder: Embedder, out_path: Path) -> None:
|
| 29 |
+
"""Embed all chunks and persist a FAISS Flat-IP index + a parallel chunks.json."""
|
| 30 |
+
texts = [c["text"] for c in chunks]
|
| 31 |
+
embs = embedder.embed_documents(texts)
|
| 32 |
+
assert embs.shape == (len(chunks), EMBED_DIM), embs.shape
|
| 33 |
+
|
| 34 |
+
index = faiss.IndexFlatIP(EMBED_DIM)
|
| 35 |
+
index.add(embs)
|
| 36 |
+
|
| 37 |
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
| 38 |
+
faiss.write_index(index, str(out_path))
|
| 39 |
+
with open(_chunks_path(out_path), "w", encoding="utf-8") as f:
|
| 40 |
+
json.dump(chunks, f, ensure_ascii=False)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def load_index(path: Path) -> tuple[faiss.Index, list[dict]]:
|
| 44 |
+
index = faiss.read_index(str(path))
|
| 45 |
+
with open(_chunks_path(path), encoding="utf-8") as f:
|
| 46 |
+
chunks = json.load(f)
|
| 47 |
+
return index, chunks
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def search(
|
| 51 |
+
index: faiss.Index, chunks: list[dict], embedder: Embedder, query: str, k: int = 5
|
| 52 |
+
) -> list[Hit]:
|
| 53 |
+
q = embedder.embed_query(query).reshape(1, -1)
|
| 54 |
+
scores, ids = index.search(q, k)
|
| 55 |
+
out: list[Hit] = []
|
| 56 |
+
for score, idx in zip(scores[0], ids[0]):
|
| 57 |
+
if idx == -1:
|
| 58 |
+
continue
|
| 59 |
+
c = chunks[idx]
|
| 60 |
+
out.append(
|
| 61 |
+
Hit(
|
| 62 |
+
score=float(score),
|
| 63 |
+
arxiv_id=c["arxiv_id"],
|
| 64 |
+
page=c["page"],
|
| 65 |
+
chunk_idx=c["chunk_idx"],
|
| 66 |
+
global_idx=int(idx),
|
| 67 |
+
text=c["text"],
|
| 68 |
+
)
|
| 69 |
+
)
|
| 70 |
+
return out
|
researchpath/llm.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Thin LLM client wrappers for Gemini and Groq.
|
| 2 |
+
|
| 3 |
+
Both functions return a uniform `LLMResponse` so the rest of the project
|
| 4 |
+
doesn't care which provider produced the text.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import re
|
| 10 |
+
import time
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
|
| 13 |
+
from dotenv import load_dotenv
|
| 14 |
+
|
| 15 |
+
load_dotenv()
|
| 16 |
+
|
| 17 |
+
_GEMINI_MAX_RETRIES = 5
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass
|
| 21 |
+
class LLMResponse:
|
| 22 |
+
text: str
|
| 23 |
+
model: str
|
| 24 |
+
input_tokens: int | None = None
|
| 25 |
+
output_tokens: int | None = None
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _parse_retry_delay(err_str: str, default: float = 15.0) -> float:
|
| 29 |
+
m = re.search(r"retry[^0-9]*([0-9]+(?:\.[0-9]+)?)\s*s", err_str, re.IGNORECASE)
|
| 30 |
+
if m:
|
| 31 |
+
return float(m.group(1)) + 1.0
|
| 32 |
+
return default
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def gemini_generate(prompt: str, model: str = "gemini-2.5-flash-lite") -> LLMResponse:
|
| 36 |
+
from google import genai
|
| 37 |
+
|
| 38 |
+
api_key = os.environ.get("GEMINI_API_KEY")
|
| 39 |
+
if not api_key:
|
| 40 |
+
raise RuntimeError("GEMINI_API_KEY not set. Copy .env.example to .env and fill it in.")
|
| 41 |
+
|
| 42 |
+
client = genai.Client(api_key=api_key)
|
| 43 |
+
|
| 44 |
+
for attempt in range(_GEMINI_MAX_RETRIES):
|
| 45 |
+
try:
|
| 46 |
+
response = client.models.generate_content(model=model, contents=prompt)
|
| 47 |
+
usage = getattr(response, "usage_metadata", None)
|
| 48 |
+
return LLMResponse(
|
| 49 |
+
text=response.text or "",
|
| 50 |
+
model=model,
|
| 51 |
+
input_tokens=getattr(usage, "prompt_token_count", None) if usage else None,
|
| 52 |
+
output_tokens=getattr(usage, "candidates_token_count", None) if usage else None,
|
| 53 |
+
)
|
| 54 |
+
except Exception as exc:
|
| 55 |
+
err_str = str(exc)
|
| 56 |
+
if "429" in err_str or "RESOURCE_EXHAUSTED" in err_str:
|
| 57 |
+
wait = _parse_retry_delay(err_str)
|
| 58 |
+
if attempt < _GEMINI_MAX_RETRIES - 1:
|
| 59 |
+
print(f" [gemini 429] waiting {wait:.0f}s (attempt {attempt + 1}/{_GEMINI_MAX_RETRIES})")
|
| 60 |
+
time.sleep(wait)
|
| 61 |
+
continue
|
| 62 |
+
raise
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def groq_generate(prompt: str, model: str = "llama-3.3-70b-versatile") -> LLMResponse:
|
| 66 |
+
from groq import Groq
|
| 67 |
+
|
| 68 |
+
api_key = os.environ.get("GROQ_API_KEY")
|
| 69 |
+
if not api_key:
|
| 70 |
+
raise RuntimeError("GROQ_API_KEY not set. Copy .env.example to .env and fill it in.")
|
| 71 |
+
|
| 72 |
+
client = Groq(api_key=api_key)
|
| 73 |
+
completion = client.chat.completions.create(
|
| 74 |
+
model=model,
|
| 75 |
+
messages=[{"role": "user", "content": prompt}],
|
| 76 |
+
)
|
| 77 |
+
return LLMResponse(
|
| 78 |
+
text=completion.choices[0].message.content or "",
|
| 79 |
+
model=model,
|
| 80 |
+
input_tokens=completion.usage.prompt_tokens,
|
| 81 |
+
output_tokens=completion.usage.completion_tokens,
|
| 82 |
+
)
|
researchpath/parsing.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PDF parsing + chunking for the corpus.
|
| 2 |
+
|
| 3 |
+
Each chunk records the source paper, page number, and chunk index so the
|
| 4 |
+
agent can later cite back to the exact location in the PDF.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import re
|
| 9 |
+
from dataclasses import asdict, dataclass
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Iterator
|
| 12 |
+
|
| 13 |
+
import pymupdf
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass(frozen=True)
|
| 17 |
+
class Chunk:
|
| 18 |
+
arxiv_id: str
|
| 19 |
+
page: int # 1-indexed for citations
|
| 20 |
+
chunk_idx: int # within the paper, 0-indexed
|
| 21 |
+
text: str
|
| 22 |
+
n_chars: int
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# Sensible defaults for academic paper RAG. ~800 chars ≈ 200 tokens, with
|
| 26 |
+
# 100-char overlap so concepts spanning chunk boundaries aren't sliced.
|
| 27 |
+
DEFAULT_CHUNK_CHARS = 800
|
| 28 |
+
DEFAULT_OVERLAP_CHARS = 100
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _normalize_whitespace(text: str) -> str:
|
| 32 |
+
# Collapse hyphenated line breaks ("rein-\nforcement" -> "reinforcement"),
|
| 33 |
+
# then collapse runs of whitespace.
|
| 34 |
+
text = re.sub(r"-\n", "", text)
|
| 35 |
+
text = re.sub(r"\s+", " ", text)
|
| 36 |
+
return text.strip()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _split_into_chunks(
|
| 40 |
+
text: str,
|
| 41 |
+
chunk_chars: int = DEFAULT_CHUNK_CHARS,
|
| 42 |
+
overlap_chars: int = DEFAULT_OVERLAP_CHARS,
|
| 43 |
+
) -> list[str]:
|
| 44 |
+
if not text:
|
| 45 |
+
return []
|
| 46 |
+
if len(text) <= chunk_chars:
|
| 47 |
+
return [text]
|
| 48 |
+
|
| 49 |
+
chunks: list[str] = []
|
| 50 |
+
start = 0
|
| 51 |
+
while start < len(text):
|
| 52 |
+
end = min(start + chunk_chars, len(text))
|
| 53 |
+
# try to break at a sentence/paragraph boundary near the end
|
| 54 |
+
if end < len(text):
|
| 55 |
+
window = text[end - 100 : end]
|
| 56 |
+
for sep in (". ", "? ", "! ", "\n"):
|
| 57 |
+
idx = window.rfind(sep)
|
| 58 |
+
if idx != -1:
|
| 59 |
+
end = end - 100 + idx + len(sep)
|
| 60 |
+
break
|
| 61 |
+
chunks.append(text[start:end].strip())
|
| 62 |
+
if end == len(text):
|
| 63 |
+
break
|
| 64 |
+
start = max(end - overlap_chars, start + 1)
|
| 65 |
+
return [c for c in chunks if c]
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def parse_pdf(pdf_path: Path) -> Iterator[Chunk]:
|
| 69 |
+
"""Yield chunks for every page of a single paper."""
|
| 70 |
+
arxiv_id = pdf_path.stem
|
| 71 |
+
chunk_idx = 0
|
| 72 |
+
with pymupdf.open(pdf_path) as doc:
|
| 73 |
+
for page_num in range(doc.page_count):
|
| 74 |
+
raw = doc[page_num].get_text()
|
| 75 |
+
text = _normalize_whitespace(raw)
|
| 76 |
+
for piece in _split_into_chunks(text):
|
| 77 |
+
yield Chunk(
|
| 78 |
+
arxiv_id=arxiv_id,
|
| 79 |
+
page=page_num + 1,
|
| 80 |
+
chunk_idx=chunk_idx,
|
| 81 |
+
text=piece,
|
| 82 |
+
n_chars=len(piece),
|
| 83 |
+
)
|
| 84 |
+
chunk_idx += 1
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def chunk_to_dict(chunk: Chunk) -> dict:
|
| 88 |
+
return asdict(chunk)
|
researchpath/planning.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reading-path planner: given a target paper + what the user already knows,
|
| 2 |
+
compute a topologically-sorted prerequisite chain.
|
| 3 |
+
|
| 4 |
+
Algorithm (offline, no LLM):
|
| 5 |
+
1. BFS backwards from the target through the prerequisite graph.
|
| 6 |
+
2. Stop expanding a node when it (or one of its ancestors) is in `known`.
|
| 7 |
+
3. Topologically sort the resulting subgraph (Kahn's algorithm).
|
| 8 |
+
4. Annotate each step with the bridging concepts/why from the edges that
|
| 9 |
+
connect it to nodes the reader hasn't seen yet.
|
| 10 |
+
|
| 11 |
+
This is the *deterministic* core of the agentic feature. The LLM layer
|
| 12 |
+
(researchpath.planner_llm — to be added when token budget allows) augments
|
| 13 |
+
this skeleton by:
|
| 14 |
+
- inferring additional concept-level dependencies the static graph misses,
|
| 15 |
+
- mapping the user's free-text background ("I know basic supervised
|
| 16 |
+
learning, no RL") to a `known` set,
|
| 17 |
+
- generating a per-step "what to focus on when reading this" rationale.
|
| 18 |
+
|
| 19 |
+
Even with the LLM unavailable, this module produces a defensible reading
|
| 20 |
+
plan for the canonical chains.
|
| 21 |
+
"""
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
from collections import defaultdict, deque
|
| 25 |
+
from dataclasses import dataclass
|
| 26 |
+
|
| 27 |
+
from researchpath.corpus import CANONICAL_RL_PAPERS, Paper
|
| 28 |
+
from researchpath.prerequisites import Prerequisite, edges_into
|
| 29 |
+
|
| 30 |
+
_PAPER_BY_ID: dict[str, Paper] = {p.arxiv_id: p for p in CANONICAL_RL_PAPERS}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass(frozen=True)
|
| 34 |
+
class ReadingStep:
|
| 35 |
+
paper: Paper
|
| 36 |
+
bridges_to: tuple[str, ...] # arxiv_ids this step is a prerequisite for
|
| 37 |
+
concepts: tuple[str, ...] # concepts this step introduces (union over outgoing edges)
|
| 38 |
+
why: str # combined rationale across outgoing edges
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@dataclass(frozen=True)
|
| 42 |
+
class ReadingPlan:
|
| 43 |
+
target_id: str
|
| 44 |
+
known_ids: frozenset[str]
|
| 45 |
+
steps: tuple[ReadingStep, ...] # in reading order; target is the last entry
|
| 46 |
+
|
| 47 |
+
def render_text(self) -> str:
|
| 48 |
+
if not self.steps:
|
| 49 |
+
return f"You're already ready to read {self.target_id} directly."
|
| 50 |
+
lines: list[str] = []
|
| 51 |
+
for i, step in enumerate(self.steps, 1):
|
| 52 |
+
p = step.paper
|
| 53 |
+
tag = "<- target" if p.arxiv_id == self.target_id else f"-> {', '.join(step.bridges_to)}"
|
| 54 |
+
lines.append(f"{i}. [{p.arxiv_id}] {p.tag} ({p.year}) — {p.title}")
|
| 55 |
+
lines.append(f" bridges: {tag}")
|
| 56 |
+
if step.concepts:
|
| 57 |
+
lines.append(f" concepts: {', '.join(step.concepts)}")
|
| 58 |
+
if step.why:
|
| 59 |
+
lines.append(f" why: {step.why}")
|
| 60 |
+
lines.append("")
|
| 61 |
+
return "\n".join(lines).rstrip()
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _bfs_backwards(target: str, known: frozenset[str]) -> set[str]:
|
| 65 |
+
"""All paper IDs the user must read to reach `target`, excluding those in `known`."""
|
| 66 |
+
if target in known:
|
| 67 |
+
return set()
|
| 68 |
+
|
| 69 |
+
needed: set[str] = {target}
|
| 70 |
+
queue: deque[str] = deque([target])
|
| 71 |
+
|
| 72 |
+
while queue:
|
| 73 |
+
node = queue.popleft()
|
| 74 |
+
for edge in edges_into(node):
|
| 75 |
+
if edge.from_id in known:
|
| 76 |
+
continue
|
| 77 |
+
if edge.from_id not in needed:
|
| 78 |
+
needed.add(edge.from_id)
|
| 79 |
+
queue.append(edge.from_id)
|
| 80 |
+
return needed
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _topo_sort(node_set: set[str]) -> list[str]:
|
| 84 |
+
"""Kahn's algorithm restricted to the prerequisite subgraph induced by node_set.
|
| 85 |
+
|
| 86 |
+
Tie-breaks on (year, arxiv_id) so the ordering is deterministic and roughly
|
| 87 |
+
chronological — which lines up with reading order in RL.
|
| 88 |
+
"""
|
| 89 |
+
in_degree: dict[str, int] = defaultdict(int)
|
| 90 |
+
out_edges: dict[str, list[str]] = defaultdict(list)
|
| 91 |
+
|
| 92 |
+
for node in node_set:
|
| 93 |
+
for edge in edges_into(node):
|
| 94 |
+
if edge.from_id in node_set:
|
| 95 |
+
out_edges[edge.from_id].append(node)
|
| 96 |
+
in_degree[node] += 1
|
| 97 |
+
|
| 98 |
+
ready: list[str] = sorted(
|
| 99 |
+
[n for n in node_set if in_degree[n] == 0],
|
| 100 |
+
key=lambda nid: (_PAPER_BY_ID[nid].year, nid),
|
| 101 |
+
)
|
| 102 |
+
out: list[str] = []
|
| 103 |
+
|
| 104 |
+
while ready:
|
| 105 |
+
node = ready.pop(0)
|
| 106 |
+
out.append(node)
|
| 107 |
+
for child in out_edges.get(node, []):
|
| 108 |
+
in_degree[child] -= 1
|
| 109 |
+
if in_degree[child] == 0:
|
| 110 |
+
ready.append(child)
|
| 111 |
+
ready.sort(key=lambda nid: (_PAPER_BY_ID[nid].year, nid))
|
| 112 |
+
|
| 113 |
+
if len(out) != len(node_set):
|
| 114 |
+
raise ValueError(
|
| 115 |
+
f"Cycle in prerequisite graph; ordered {len(out)}/{len(node_set)} nodes."
|
| 116 |
+
)
|
| 117 |
+
return out
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _build_step(paper_id: str, ordered_ids: list[str]) -> ReadingStep:
|
| 121 |
+
"""Aggregate outgoing edges (paper_id -> later) into a single annotated step."""
|
| 122 |
+
paper = _PAPER_BY_ID[paper_id]
|
| 123 |
+
later = set(ordered_ids[ordered_ids.index(paper_id) + 1 :])
|
| 124 |
+
|
| 125 |
+
concepts: list[str] = []
|
| 126 |
+
bridges: list[str] = []
|
| 127 |
+
whys: list[str] = []
|
| 128 |
+
seen_concepts: set[str] = set()
|
| 129 |
+
|
| 130 |
+
for edge in _outgoing(paper_id):
|
| 131 |
+
if edge.to_id not in later:
|
| 132 |
+
continue
|
| 133 |
+
bridges.append(edge.to_id)
|
| 134 |
+
for c in edge.concepts:
|
| 135 |
+
if c not in seen_concepts:
|
| 136 |
+
concepts.append(c)
|
| 137 |
+
seen_concepts.add(c)
|
| 138 |
+
whys.append(edge.why)
|
| 139 |
+
|
| 140 |
+
why = " ".join(whys)
|
| 141 |
+
return ReadingStep(
|
| 142 |
+
paper=paper,
|
| 143 |
+
bridges_to=tuple(bridges),
|
| 144 |
+
concepts=tuple(concepts),
|
| 145 |
+
why=why,
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _outgoing(arxiv_id: str) -> list[Prerequisite]:
|
| 150 |
+
from researchpath.prerequisites import STATIC_PREREQUISITES
|
| 151 |
+
return [e for e in STATIC_PREREQUISITES if e.from_id == arxiv_id]
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def plan_reading_path(target_id: str, known_ids: set[str] | None = None) -> ReadingPlan:
|
| 155 |
+
"""Compute the prerequisite reading path for `target_id`.
|
| 156 |
+
|
| 157 |
+
Args:
|
| 158 |
+
target_id: arxiv ID of the paper the user wants to read.
|
| 159 |
+
known_ids: arxiv IDs the user already understands (BFS stops here).
|
| 160 |
+
|
| 161 |
+
Returns:
|
| 162 |
+
A ReadingPlan whose `steps` are in reading order, ending with the target.
|
| 163 |
+
"""
|
| 164 |
+
if target_id not in _PAPER_BY_ID:
|
| 165 |
+
raise ValueError(f"Unknown target paper: {target_id}")
|
| 166 |
+
known = frozenset(known_ids or ())
|
| 167 |
+
|
| 168 |
+
needed = _bfs_backwards(target_id, known)
|
| 169 |
+
if not needed:
|
| 170 |
+
return ReadingPlan(target_id=target_id, known_ids=known, steps=())
|
| 171 |
+
|
| 172 |
+
ordered_ids = _topo_sort(needed)
|
| 173 |
+
steps = tuple(_build_step(pid, ordered_ids) for pid in ordered_ids)
|
| 174 |
+
return ReadingPlan(target_id=target_id, known_ids=known, steps=steps)
|
researchpath/prerequisites.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hand-curated prerequisite graph for the canonical RL corpus.
|
| 2 |
+
|
| 3 |
+
Each edge `(from_id, to_id)` means: to read `to_id` productively, the reader
|
| 4 |
+
should already understand the ideas in `from_id`. The `concepts` field names
|
| 5 |
+
the bridging idea(s); `why` is a short rationale shown to the user when the
|
| 6 |
+
planner walks this edge.
|
| 7 |
+
|
| 8 |
+
This is the static seed for the agentic planning loop. At runtime the agent
|
| 9 |
+
augments these edges with LLM-extracted concept dependencies, but the static
|
| 10 |
+
graph guarantees the planner never produces nonsense for the canonical chain
|
| 11 |
+
(DQN -> Double DQN -> Dueling DQN -> Rainbow, etc.) even when the LLM is
|
| 12 |
+
unavailable or rate-limited.
|
| 13 |
+
|
| 14 |
+
Edge sources: the dependency chains are well-known in the RL literature and
|
| 15 |
+
match how the papers cite each other (verifiable by reading the related-work
|
| 16 |
+
sections of the dependent papers).
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
from dataclasses import dataclass
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass(frozen=True)
|
| 24 |
+
class Prerequisite:
|
| 25 |
+
from_id: str # arxiv_id of the prerequisite paper
|
| 26 |
+
to_id: str # arxiv_id of the dependent paper
|
| 27 |
+
concepts: tuple[str, ...] # bridging ideas
|
| 28 |
+
why: str # short user-facing rationale
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
STATIC_PREREQUISITES: tuple[Prerequisite, ...] = (
|
| 32 |
+
# --- value-based chain ---
|
| 33 |
+
Prerequisite(
|
| 34 |
+
from_id="1312.5602", to_id="1509.06461",
|
| 35 |
+
concepts=("Q-learning", "experience replay", "target network"),
|
| 36 |
+
why="Double DQN modifies the DQN max-operator. You need DQN's "
|
| 37 |
+
"Q-learning + replay + target-network setup to see what's being changed.",
|
| 38 |
+
),
|
| 39 |
+
Prerequisite(
|
| 40 |
+
from_id="1509.06461", to_id="1511.06581",
|
| 41 |
+
concepts=("overestimation bias", "decoupled action selection/evaluation"),
|
| 42 |
+
why="Dueling builds on Double DQN's evaluation update; both share the same "
|
| 43 |
+
"training loop and Atari benchmark.",
|
| 44 |
+
),
|
| 45 |
+
Prerequisite(
|
| 46 |
+
from_id="1312.5602", to_id="1511.06581",
|
| 47 |
+
concepts=("Q-network architecture", "Atari pipeline"),
|
| 48 |
+
why="Dueling redesigns DQN's network architecture — needed to see the original.",
|
| 49 |
+
),
|
| 50 |
+
Prerequisite(
|
| 51 |
+
from_id="1312.5602", to_id="1511.05952",
|
| 52 |
+
concepts=("experience replay", "Q-learning"),
|
| 53 |
+
why="PER replaces DQN's uniform replay sampling with priority-weighted sampling. "
|
| 54 |
+
"The mechanism only makes sense with DQN's replay buffer as the starting point.",
|
| 55 |
+
),
|
| 56 |
+
Prerequisite(
|
| 57 |
+
from_id="1509.06461", to_id="1710.02298",
|
| 58 |
+
concepts=("Double Q-learning",),
|
| 59 |
+
why="Rainbow includes Double Q-learning as one of its six components.",
|
| 60 |
+
),
|
| 61 |
+
Prerequisite(
|
| 62 |
+
from_id="1511.06581", to_id="1710.02298",
|
| 63 |
+
concepts=("dueling networks",),
|
| 64 |
+
why="Rainbow includes dueling networks as one of its six components.",
|
| 65 |
+
),
|
| 66 |
+
Prerequisite(
|
| 67 |
+
from_id="1511.05952", to_id="1710.02298",
|
| 68 |
+
concepts=("prioritized experience replay",),
|
| 69 |
+
why="Rainbow includes PER as one of its six components.",
|
| 70 |
+
),
|
| 71 |
+
Prerequisite(
|
| 72 |
+
from_id="1312.5602", to_id="1710.02298",
|
| 73 |
+
concepts=("DQN baseline",),
|
| 74 |
+
why="Rainbow's ablations are all measured against the DQN baseline.",
|
| 75 |
+
),
|
| 76 |
+
|
| 77 |
+
# --- policy-gradient chain ---
|
| 78 |
+
Prerequisite(
|
| 79 |
+
from_id="1502.05477", to_id="1506.02438",
|
| 80 |
+
concepts=("trust region", "policy gradient",
|
| 81 |
+
"natural gradient via conjugate gradient"),
|
| 82 |
+
why="GAE was developed alongside TRPO and is typically paired with it. "
|
| 83 |
+
"GAE's experiments use TRPO as the policy update.",
|
| 84 |
+
),
|
| 85 |
+
Prerequisite(
|
| 86 |
+
from_id="1502.05477", to_id="1707.06347",
|
| 87 |
+
concepts=("trust region", "clipped surrogate objective"),
|
| 88 |
+
why="PPO is explicitly 'a simpler, more stable alternative to TRPO'. "
|
| 89 |
+
"The clipped objective is only legible against the TRPO constraint it replaces.",
|
| 90 |
+
),
|
| 91 |
+
Prerequisite(
|
| 92 |
+
from_id="1506.02438", to_id="1707.06347",
|
| 93 |
+
concepts=("generalized advantage estimation",),
|
| 94 |
+
why="PPO uses GAE for advantage estimation in all its experiments.",
|
| 95 |
+
),
|
| 96 |
+
|
| 97 |
+
# --- actor-critic chain ---
|
| 98 |
+
Prerequisite(
|
| 99 |
+
from_id="1312.5602", to_id="1602.01783",
|
| 100 |
+
concepts=("Q-learning", "replay vs on-policy"),
|
| 101 |
+
why="A3C is positioned as a parallel-actor alternative to DQN's replay-based "
|
| 102 |
+
"training. Need to understand DQN to see why asynchrony substitutes for replay.",
|
| 103 |
+
),
|
| 104 |
+
Prerequisite(
|
| 105 |
+
from_id="1312.5602", to_id="1509.02971",
|
| 106 |
+
concepts=("DQN target network", "off-policy Q-learning"),
|
| 107 |
+
why="DDPG is 'DQN for continuous actions' — same target-network and replay-buffer "
|
| 108 |
+
"machinery, but with a deterministic actor on top of the critic.",
|
| 109 |
+
),
|
| 110 |
+
Prerequisite(
|
| 111 |
+
from_id="1509.02971", to_id="1801.01290",
|
| 112 |
+
concepts=("deterministic policy gradient", "actor-critic", "replay buffer"),
|
| 113 |
+
why="SAC is the maximum-entropy successor to DDPG for continuous control. "
|
| 114 |
+
"Understanding DDPG's actor-critic + replay setup is needed to see what SAC changes.",
|
| 115 |
+
),
|
| 116 |
+
Prerequisite(
|
| 117 |
+
from_id="1602.01783", to_id="1802.01561",
|
| 118 |
+
concepts=("asynchronous actors", "on-policy gradient"),
|
| 119 |
+
why="IMPALA builds directly on A3C's parallel-actor idea but decouples "
|
| 120 |
+
"acting from learning via a learner-actor architecture and V-trace correction.",
|
| 121 |
+
),
|
| 122 |
+
|
| 123 |
+
# --- model-based / planning chain ---
|
| 124 |
+
Prerequisite(
|
| 125 |
+
from_id="1312.5602", to_id="1911.08265",
|
| 126 |
+
concepts=("Q-values", "Atari benchmark"),
|
| 127 |
+
why="MuZero is measured against DQN-family baselines on Atari. "
|
| 128 |
+
"Its learned value function replaces the DQN Q-network.",
|
| 129 |
+
),
|
| 130 |
+
Prerequisite(
|
| 131 |
+
from_id="1602.01783", to_id="1911.08265",
|
| 132 |
+
concepts=("policy gradient", "MCTS + neural network"),
|
| 133 |
+
why="MuZero combines MCTS search with a learned model; A3C's policy-gradient "
|
| 134 |
+
"framework is the RL substrate underneath the search.",
|
| 135 |
+
),
|
| 136 |
+
Prerequisite(
|
| 137 |
+
from_id="1911.08265", to_id="2301.04104",
|
| 138 |
+
concepts=("world model", "latent-space planning"),
|
| 139 |
+
why="DreamerV3 is world-model RL generalized beyond board games; "
|
| 140 |
+
"MuZero's learned model + latent-space planning is the direct antecedent.",
|
| 141 |
+
),
|
| 142 |
+
|
| 143 |
+
# --- sequence modeling for RL ---
|
| 144 |
+
Prerequisite(
|
| 145 |
+
from_id="1707.06347", to_id="2106.01345",
|
| 146 |
+
concepts=("return-conditioned policy", "offline RL"),
|
| 147 |
+
why="Decision Transformer frames offline RL as sequence modeling; "
|
| 148 |
+
"PPO is the canonical online policy-gradient baseline it's contrasted against.",
|
| 149 |
+
),
|
| 150 |
+
|
| 151 |
+
# --- RLHF chain ---
|
| 152 |
+
Prerequisite(
|
| 153 |
+
from_id="1707.06347", to_id="2203.02155",
|
| 154 |
+
concepts=("PPO", "KL-constrained policy optimization"),
|
| 155 |
+
why="InstructGPT uses PPO as the RL optimizer in its RLHF pipeline. "
|
| 156 |
+
"The KL penalty and clipped objective are central to why it works.",
|
| 157 |
+
),
|
| 158 |
+
Prerequisite(
|
| 159 |
+
from_id="2203.02155", to_id="2305.18290",
|
| 160 |
+
concepts=("RLHF pipeline", "reward model", "KL-constrained PPO"),
|
| 161 |
+
why="DPO is explicitly framed as 'RLHF without RL'. The InstructGPT paper "
|
| 162 |
+
"is the standard reference for the RLHF pipeline that DPO replaces.",
|
| 163 |
+
),
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def edges_into(arxiv_id: str) -> list[Prerequisite]:
|
| 168 |
+
"""Direct prerequisites of `arxiv_id` (i.e. papers that should be read before it)."""
|
| 169 |
+
return [p for p in STATIC_PREREQUISITES if p.to_id == arxiv_id]
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def edges_from(arxiv_id: str) -> list[Prerequisite]:
|
| 173 |
+
"""Direct dependents of `arxiv_id` (i.e. papers that build on it)."""
|
| 174 |
+
return [p for p in STATIC_PREREQUISITES if p.from_id == arxiv_id]
|
researchpath/rag.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Baseline RAG: retrieve top-k chunks, hand them to Gemini with strict citation rules.
|
| 2 |
+
|
| 3 |
+
This is the first measurable system. Day 4 adds the eval harness so we can
|
| 4 |
+
quantify how well it works before optimizing.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
|
| 10 |
+
from collections.abc import Callable
|
| 11 |
+
|
| 12 |
+
from researchpath.corpus import CANONICAL_RL_PAPERS
|
| 13 |
+
from researchpath.index import Hit
|
| 14 |
+
from researchpath.llm import LLMResponse, gemini_generate, groq_generate
|
| 15 |
+
|
| 16 |
+
_TAG_BY_ID = {p.arxiv_id: p.tag for p in CANONICAL_RL_PAPERS}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
SYSTEM_PROMPT = """You are an expert RL research assistant. Answer the user's question using ONLY the provided source chunks. Follow these rules strictly:
|
| 20 |
+
|
| 21 |
+
1. Cite every factual claim using the format [arxiv_id, p<page>], e.g. [1707.06347, p3].
|
| 22 |
+
2. If the sources don't contain enough information to answer, say so explicitly. Do NOT speculate beyond the sources.
|
| 23 |
+
3. Be concise. Prefer 2-4 short paragraphs over long prose.
|
| 24 |
+
4. If the question is conceptual (not factual), still ground your explanation in the cited sources."""
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclass(frozen=True)
|
| 28 |
+
class RAGAnswer:
|
| 29 |
+
question: str
|
| 30 |
+
answer: str
|
| 31 |
+
hits: list[Hit]
|
| 32 |
+
llm: LLMResponse
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _format_sources(hits: list[Hit]) -> str:
|
| 36 |
+
blocks: list[str] = []
|
| 37 |
+
for h in hits:
|
| 38 |
+
tag = _TAG_BY_ID.get(h.arxiv_id, "?")
|
| 39 |
+
header = f"[{h.arxiv_id}, p{h.page}] ({tag})"
|
| 40 |
+
blocks.append(f"{header}\n{h.text}")
|
| 41 |
+
return "\n\n---\n\n".join(blocks)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def build_prompt(question: str, hits: list[Hit]) -> str:
|
| 45 |
+
sources = _format_sources(hits)
|
| 46 |
+
return f"""{SYSTEM_PROMPT}
|
| 47 |
+
|
| 48 |
+
# Sources
|
| 49 |
+
|
| 50 |
+
{sources}
|
| 51 |
+
|
| 52 |
+
# Question
|
| 53 |
+
|
| 54 |
+
{question}
|
| 55 |
+
|
| 56 |
+
# Answer
|
| 57 |
+
"""
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def answer(
|
| 61 |
+
question: str,
|
| 62 |
+
hits: list[Hit],
|
| 63 |
+
model: str = "gemini-2.5-flash-lite",
|
| 64 |
+
generate_fn: Callable[..., LLMResponse] | None = None,
|
| 65 |
+
) -> RAGAnswer:
|
| 66 |
+
prompt = build_prompt(question, hits)
|
| 67 |
+
fn = generate_fn or gemini_generate
|
| 68 |
+
llm = fn(prompt, model=model)
|
| 69 |
+
return RAGAnswer(question=question, answer=llm.text, hits=hits, llm=llm)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def answer_groq(question: str, hits: list[Hit], model: str = "llama-3.3-70b-versatile") -> RAGAnswer:
|
| 73 |
+
"""Groq-backed answer — use during eval to avoid Gemini rate limits."""
|
| 74 |
+
return answer(question, hits, model=model, generate_fn=groq_generate)
|
researchpath/retrieval.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Retrieval strategies: BM25, FAISS vector, hybrid RRF fusion, cross-encoder rerank.
|
| 2 |
+
|
| 3 |
+
Hybrid retrieval is the first optimization pass over the baseline FAISS-only system.
|
| 4 |
+
BM25 catches exact-term hits (e.g. "conjugate gradient", "Polyak averaging",
|
| 5 |
+
"six components") that dense embeddings can miss when the query term is rare in
|
| 6 |
+
the training distribution of the embedding model.
|
| 7 |
+
|
| 8 |
+
Reciprocal Rank Fusion (RRF) combines rankings without needing to normalize
|
| 9 |
+
incompatible score spaces (inner-product vs BM25 counts):
|
| 10 |
+
rrf(d) = sum_i 1 / (k + rank_i(d))
|
| 11 |
+
where k=60 is the standard constant that dampens the impact of high ranks.
|
| 12 |
+
|
| 13 |
+
The cross-encoder reranker is an over-the-top stage that scores each
|
| 14 |
+
(query, chunk) pair jointly via a small transformer, replacing the bi-encoder
|
| 15 |
+
similarity used for first-stage retrieval. It's slower (no caching of chunk
|
| 16 |
+
embeddings) but more accurate, so we only run it on the top-N candidates from
|
| 17 |
+
hybrid retrieval — cheap reranker over expensive joint scoring.
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import re
|
| 22 |
+
from dataclasses import dataclass
|
| 23 |
+
|
| 24 |
+
import faiss
|
| 25 |
+
import numpy as np
|
| 26 |
+
from rank_bm25 import BM25Okapi
|
| 27 |
+
|
| 28 |
+
from researchpath.embeddings import Embedder
|
| 29 |
+
from researchpath.index import Hit
|
| 30 |
+
from researchpath.index import search as faiss_search
|
| 31 |
+
|
| 32 |
+
_RRF_K = 60
|
| 33 |
+
DEFAULT_RERANKER_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
| 34 |
+
|
| 35 |
+
_STOP_WORDS = frozenset(
|
| 36 |
+
"a an the and or but if of in on at to for with by from is are was were be been"
|
| 37 |
+
" being have has had do does did will would could should may might shall can"
|
| 38 |
+
" it its this that these those he she they we you i me my your our their"
|
| 39 |
+
" what which who how when where why what's does it's what's that's there's"
|
| 40 |
+
" not no nor so very just also both each few more most other some such than"
|
| 41 |
+
" too very as into up out down about above after before".split()
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _tokenize(text: str) -> list[str]:
|
| 46 |
+
"""Lowercase, keep hyphenated tokens, strip stop words.
|
| 47 |
+
|
| 48 |
+
Stop-word filtering is critical for BM25 over academic text: queries like
|
| 49 |
+
'What does GAE stand for' would otherwise match InstructGPT appendix QA
|
| 50 |
+
sections via 'what', 'does', 'it' rather than the actual term 'gae'.
|
| 51 |
+
"""
|
| 52 |
+
tokens = re.findall(r"[a-z0-9]+(?:-[a-z0-9]+)*", text.lower())
|
| 53 |
+
return [t for t in tokens if t not in _STOP_WORDS]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class BM25Retriever:
|
| 57 |
+
"""BM25Okapi over the chunk corpus. Built in-memory from chunks list."""
|
| 58 |
+
|
| 59 |
+
def __init__(self, chunks: list[dict]) -> None:
|
| 60 |
+
self.chunks = chunks
|
| 61 |
+
tokenized = [_tokenize(c["text"]) for c in chunks]
|
| 62 |
+
self._bm25 = BM25Okapi(tokenized)
|
| 63 |
+
|
| 64 |
+
def search(self, query: str, k: int = 20) -> list[tuple[int, float]]:
|
| 65 |
+
"""Return (original_chunk_list_index, bm25_score) for top-k chunks."""
|
| 66 |
+
tokens = _tokenize(query)
|
| 67 |
+
scores = self._bm25.get_scores(tokens)
|
| 68 |
+
top_idx = np.argsort(scores)[::-1][:k]
|
| 69 |
+
return [(int(i), float(scores[i])) for i in top_idx if scores[i] > 0]
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class HybridRetriever:
|
| 73 |
+
"""Fuses FAISS dense search and BM25 sparse search via Reciprocal Rank Fusion."""
|
| 74 |
+
|
| 75 |
+
def __init__(
|
| 76 |
+
self,
|
| 77 |
+
index: faiss.Index,
|
| 78 |
+
chunks: list[dict],
|
| 79 |
+
embedder: Embedder,
|
| 80 |
+
fetch_k: int = 20,
|
| 81 |
+
) -> None:
|
| 82 |
+
self.index = index
|
| 83 |
+
self.chunks = chunks
|
| 84 |
+
self.embedder = embedder
|
| 85 |
+
self.fetch_k = fetch_k
|
| 86 |
+
self._bm25 = BM25Retriever(chunks)
|
| 87 |
+
|
| 88 |
+
def search(self, query: str, k: int = 5) -> list[Hit]:
|
| 89 |
+
fetch = max(self.fetch_k, k * 4)
|
| 90 |
+
|
| 91 |
+
# --- dense retrieval: key by global_idx (FAISS vector position) ---
|
| 92 |
+
dense_hits = faiss_search(self.index, self.chunks, self.embedder, query, k=fetch)
|
| 93 |
+
dense_rank: dict[int, int] = {h.global_idx: rank for rank, h in enumerate(dense_hits)}
|
| 94 |
+
|
| 95 |
+
# --- sparse retrieval: BM25 returns (list_position, score) = (global_idx, score) ---
|
| 96 |
+
sparse_results = self._bm25.search(query, k=fetch)
|
| 97 |
+
sparse_rank: dict[int, int] = {gidx: rank for rank, (gidx, _) in enumerate(sparse_results)}
|
| 98 |
+
|
| 99 |
+
# --- RRF fusion over global_idx ---
|
| 100 |
+
candidates = set(dense_rank) | set(sparse_rank)
|
| 101 |
+
rrf: dict[int, float] = {}
|
| 102 |
+
for gidx in candidates:
|
| 103 |
+
score = 0.0
|
| 104 |
+
if gidx in dense_rank:
|
| 105 |
+
score += 1.0 / (_RRF_K + dense_rank[gidx])
|
| 106 |
+
if gidx in sparse_rank:
|
| 107 |
+
score += 1.0 / (_RRF_K + sparse_rank[gidx])
|
| 108 |
+
rrf[gidx] = score
|
| 109 |
+
|
| 110 |
+
top_k = sorted(rrf, key=rrf.__getitem__, reverse=True)[:k]
|
| 111 |
+
|
| 112 |
+
out: list[Hit] = []
|
| 113 |
+
for gidx in top_k:
|
| 114 |
+
c = self.chunks[gidx]
|
| 115 |
+
out.append(
|
| 116 |
+
Hit(
|
| 117 |
+
score=rrf[gidx],
|
| 118 |
+
arxiv_id=c["arxiv_id"],
|
| 119 |
+
page=c["page"],
|
| 120 |
+
chunk_idx=c["chunk_idx"],
|
| 121 |
+
global_idx=gidx,
|
| 122 |
+
text=c["text"],
|
| 123 |
+
)
|
| 124 |
+
)
|
| 125 |
+
return out
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
class Reranker:
|
| 129 |
+
"""Cross-encoder reranker: jointly scores (query, chunk) pairs.
|
| 130 |
+
|
| 131 |
+
Wraps any retriever (FAISS, BM25, or HybridRetriever): fetches `fetch_k`
|
| 132 |
+
candidates from the base retriever, then runs the cross-encoder on each
|
| 133 |
+
(query, chunk_text) pair to produce a final relevance score, returning
|
| 134 |
+
the top-k by reranked score.
|
| 135 |
+
"""
|
| 136 |
+
|
| 137 |
+
def __init__(
|
| 138 |
+
self,
|
| 139 |
+
base_retriever: HybridRetriever,
|
| 140 |
+
model_name: str = DEFAULT_RERANKER_MODEL,
|
| 141 |
+
fetch_k: int = 20,
|
| 142 |
+
) -> None:
|
| 143 |
+
from sentence_transformers import CrossEncoder
|
| 144 |
+
|
| 145 |
+
self.base = base_retriever
|
| 146 |
+
self.fetch_k = fetch_k
|
| 147 |
+
self._cross_encoder = CrossEncoder(model_name)
|
| 148 |
+
|
| 149 |
+
def search(self, query: str, k: int = 5) -> list[Hit]:
|
| 150 |
+
candidates = self.base.search(query, k=self.fetch_k)
|
| 151 |
+
if not candidates:
|
| 152 |
+
return []
|
| 153 |
+
|
| 154 |
+
pairs = [(query, h.text) for h in candidates]
|
| 155 |
+
scores = self._cross_encoder.predict(pairs)
|
| 156 |
+
|
| 157 |
+
ranked = sorted(zip(candidates, scores), key=lambda t: float(t[1]), reverse=True)
|
| 158 |
+
out: list[Hit] = []
|
| 159 |
+
for h, score in ranked[:k]:
|
| 160 |
+
out.append(
|
| 161 |
+
Hit(
|
| 162 |
+
score=float(score),
|
| 163 |
+
arxiv_id=h.arxiv_id,
|
| 164 |
+
page=h.page,
|
| 165 |
+
chunk_idx=h.chunk_idx,
|
| 166 |
+
global_idx=h.global_idx,
|
| 167 |
+
text=h.text,
|
| 168 |
+
)
|
| 169 |
+
)
|
| 170 |
+
return out
|
scripts/ask.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ask a question against the indexed RL corpus.
|
| 2 |
+
|
| 3 |
+
Usage:
|
| 4 |
+
uv run python scripts/ask.py "What is the main idea of PPO?"
|
| 5 |
+
uv run python scripts/ask.py --k 8 "How does Rainbow combine Double DQN and PER?"
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import argparse
|
| 10 |
+
import sys
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 14 |
+
|
| 15 |
+
from rich.console import Console
|
| 16 |
+
from rich.markdown import Markdown
|
| 17 |
+
from rich.panel import Panel
|
| 18 |
+
|
| 19 |
+
from researchpath.embeddings import Embedder
|
| 20 |
+
from researchpath.index import load_index, search
|
| 21 |
+
from researchpath.rag import answer
|
| 22 |
+
from researchpath.retrieval import HybridRetriever, Reranker
|
| 23 |
+
|
| 24 |
+
console = Console()
|
| 25 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 26 |
+
INDEX_PATH = ROOT / "data" / "index.faiss"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def main() -> int:
|
| 30 |
+
parser = argparse.ArgumentParser(description="Ask the RL corpus a question.")
|
| 31 |
+
parser.add_argument("question", help="Question to ask.")
|
| 32 |
+
parser.add_argument("--k", type=int, default=5, help="Top-k chunks to retrieve (default: 5).")
|
| 33 |
+
parser.add_argument("--hybrid", action="store_true", help="Use BM25+FAISS hybrid retrieval.")
|
| 34 |
+
parser.add_argument("--rerank", action="store_true", help="Apply cross-encoder reranking on retrieved candidates.")
|
| 35 |
+
parser.add_argument(
|
| 36 |
+
"--show-sources",
|
| 37 |
+
action="store_true",
|
| 38 |
+
help="Print the retrieved chunks before the answer.",
|
| 39 |
+
)
|
| 40 |
+
args = parser.parse_args()
|
| 41 |
+
|
| 42 |
+
if not INDEX_PATH.exists():
|
| 43 |
+
console.print(f"[red]No index at {INDEX_PATH}. Run scripts/build_index.py first.[/red]")
|
| 44 |
+
return 1
|
| 45 |
+
|
| 46 |
+
if args.rerank:
|
| 47 |
+
mode = "hybrid+rerank (BM25+FAISS+CrossEncoder)" if args.hybrid else "dense+rerank (FAISS+CrossEncoder)"
|
| 48 |
+
else:
|
| 49 |
+
mode = "hybrid (BM25+FAISS)" if args.hybrid else "dense (FAISS)"
|
| 50 |
+
console.print(f"[bold cyan]Q:[/bold cyan] {args.question} [dim][retrieval: {mode}][/dim]\n")
|
| 51 |
+
|
| 52 |
+
index, chunks = load_index(INDEX_PATH)
|
| 53 |
+
embedder = Embedder()
|
| 54 |
+
if args.rerank:
|
| 55 |
+
base = HybridRetriever(index, chunks, embedder) if args.hybrid else None
|
| 56 |
+
if base is None:
|
| 57 |
+
# Wrap dense FAISS in a hybrid-compatible interface for the reranker
|
| 58 |
+
base = HybridRetriever(index, chunks, embedder)
|
| 59 |
+
reranker = Reranker(base)
|
| 60 |
+
hits = reranker.search(args.question, k=args.k)
|
| 61 |
+
elif args.hybrid:
|
| 62 |
+
retriever = HybridRetriever(index, chunks, embedder)
|
| 63 |
+
hits = retriever.search(args.question, k=args.k)
|
| 64 |
+
else:
|
| 65 |
+
hits = search(index, chunks, embedder, args.question, k=args.k)
|
| 66 |
+
|
| 67 |
+
if args.show_sources:
|
| 68 |
+
for i, h in enumerate(hits, 1):
|
| 69 |
+
console.print(
|
| 70 |
+
Panel(
|
| 71 |
+
h.text,
|
| 72 |
+
title=f"#{i} [{h.arxiv_id}, p{h.page}] score={h.score:.3f}",
|
| 73 |
+
border_style="dim",
|
| 74 |
+
)
|
| 75 |
+
)
|
| 76 |
+
console.print()
|
| 77 |
+
|
| 78 |
+
result = answer(args.question, hits)
|
| 79 |
+
|
| 80 |
+
console.print(Panel(Markdown(result.answer), title="Answer", border_style="green"))
|
| 81 |
+
console.print(
|
| 82 |
+
f"\n[dim]Retrieved {len(hits)} chunks | "
|
| 83 |
+
f"model: {result.llm.model} | "
|
| 84 |
+
f"tokens: {result.llm.input_tokens} in / {result.llm.output_tokens} out[/dim]"
|
| 85 |
+
)
|
| 86 |
+
return 0
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
if __name__ == "__main__":
|
| 90 |
+
sys.exit(main())
|
scripts/build_index.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Embed every chunk in data/chunks.jsonl and persist a FAISS index.
|
| 2 |
+
|
| 3 |
+
Usage:
|
| 4 |
+
uv run python scripts/build_index.py
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import sys
|
| 10 |
+
import time
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 14 |
+
|
| 15 |
+
from rich.console import Console
|
| 16 |
+
|
| 17 |
+
from researchpath.embeddings import Embedder
|
| 18 |
+
from researchpath.index import build_index
|
| 19 |
+
|
| 20 |
+
console = Console()
|
| 21 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 22 |
+
CHUNKS_PATH = ROOT / "data" / "chunks.jsonl"
|
| 23 |
+
INDEX_PATH = ROOT / "data" / "index.faiss"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def main() -> int:
|
| 27 |
+
if not CHUNKS_PATH.exists():
|
| 28 |
+
console.print(f"[red]Missing {CHUNKS_PATH}. Run scripts/parse_corpus.py first.[/red]")
|
| 29 |
+
return 1
|
| 30 |
+
|
| 31 |
+
chunks: list[dict] = []
|
| 32 |
+
with open(CHUNKS_PATH, encoding="utf-8") as f:
|
| 33 |
+
for line in f:
|
| 34 |
+
line = line.strip()
|
| 35 |
+
if line:
|
| 36 |
+
chunks.append(json.loads(line))
|
| 37 |
+
|
| 38 |
+
console.print(f"[bold]Embedding {len(chunks)} chunks with BAAI/bge-small-en-v1.5[/bold]")
|
| 39 |
+
console.print("[dim](first run downloads ~133MB model from HF Hub; subsequent runs are cached)[/dim]\n")
|
| 40 |
+
|
| 41 |
+
t0 = time.time()
|
| 42 |
+
embedder = Embedder()
|
| 43 |
+
build_index(chunks, embedder, INDEX_PATH)
|
| 44 |
+
dt = time.time() - t0
|
| 45 |
+
|
| 46 |
+
console.print(f"\n[green]Wrote {INDEX_PATH.relative_to(ROOT)} in {dt:.1f}s[/green]")
|
| 47 |
+
console.print(f"[green]Wrote {INDEX_PATH.with_suffix('.chunks.json').relative_to(ROOT)}[/green]")
|
| 48 |
+
return 0
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
if __name__ == "__main__":
|
| 52 |
+
sys.exit(main())
|
scripts/fetch_corpus.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Download canonical RL papers from arXiv into data/papers/.
|
| 2 |
+
|
| 3 |
+
Idempotent: papers already on disk (with valid PDF content) are skipped.
|
| 4 |
+
Truncated/corrupt PDFs are auto-deleted and retried.
|
| 5 |
+
|
| 6 |
+
Uses `requests` with streaming + chunked writes — the `arxiv` library's
|
| 7 |
+
built-in downloader was truncating large PDFs at power-of-2 byte boundaries
|
| 8 |
+
on Windows.
|
| 9 |
+
|
| 10 |
+
Usage:
|
| 11 |
+
uv run python scripts/fetch_corpus.py
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import sys
|
| 16 |
+
import time
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 20 |
+
|
| 21 |
+
import pymupdf
|
| 22 |
+
import requests
|
| 23 |
+
from rich.console import Console
|
| 24 |
+
from rich.table import Table
|
| 25 |
+
|
| 26 |
+
from researchpath.corpus import CANONICAL_RL_PAPERS
|
| 27 |
+
|
| 28 |
+
console = Console()
|
| 29 |
+
DATA_DIR = Path(__file__).resolve().parents[1] / "data" / "papers"
|
| 30 |
+
|
| 31 |
+
USER_AGENT = "ResearchPath/0.1.0 (mailto:chetanchowdary01@gmail.com)"
|
| 32 |
+
MIN_PDF_BYTES = 50_000
|
| 33 |
+
INTER_REQUEST_DELAY = 12 # arXiv asks for >=3s; PDF endpoint rate-limits harder
|
| 34 |
+
MAX_ATTEMPTS = 3
|
| 35 |
+
RETRY_BACKOFF_SEC = 30
|
| 36 |
+
DOWNLOAD_TIMEOUT = 180
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def validate_pdf(path: Path) -> tuple[bool, str]:
|
| 40 |
+
if not path.exists():
|
| 41 |
+
return False, "file missing"
|
| 42 |
+
if path.stat().st_size < MIN_PDF_BYTES:
|
| 43 |
+
return False, f"too small ({path.stat().st_size} bytes)"
|
| 44 |
+
try:
|
| 45 |
+
with pymupdf.open(path) as doc:
|
| 46 |
+
if doc.page_count < 1:
|
| 47 |
+
return False, "0 pages"
|
| 48 |
+
_ = doc[0].get_text()
|
| 49 |
+
return True, "ok"
|
| 50 |
+
except Exception as e:
|
| 51 |
+
return False, f"parse error: {e}"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def download_pdf(arxiv_id: str, dest: Path) -> tuple[bool, str]:
|
| 55 |
+
"""Stream a single PDF from arXiv. Returns (success, message)."""
|
| 56 |
+
url = f"https://arxiv.org/pdf/{arxiv_id}.pdf"
|
| 57 |
+
headers = {"User-Agent": USER_AGENT}
|
| 58 |
+
try:
|
| 59 |
+
with requests.get(url, headers=headers, stream=True, timeout=DOWNLOAD_TIMEOUT) as r:
|
| 60 |
+
if r.status_code == 429:
|
| 61 |
+
return False, "rate limited (429)"
|
| 62 |
+
r.raise_for_status()
|
| 63 |
+
expected = int(r.headers.get("Content-Length", "0") or "0")
|
| 64 |
+
written = 0
|
| 65 |
+
with open(dest, "wb") as f:
|
| 66 |
+
for chunk in r.iter_content(chunk_size=64 * 1024):
|
| 67 |
+
if chunk:
|
| 68 |
+
f.write(chunk)
|
| 69 |
+
written += len(chunk)
|
| 70 |
+
if expected and written < expected:
|
| 71 |
+
dest.unlink(missing_ok=True)
|
| 72 |
+
return False, f"truncated: {written}/{expected} bytes"
|
| 73 |
+
return True, f"ok ({written} bytes)"
|
| 74 |
+
except requests.exceptions.HTTPError as e:
|
| 75 |
+
return False, f"http {e.response.status_code}"
|
| 76 |
+
except Exception as e:
|
| 77 |
+
return False, f"network: {e}"
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def fetch_with_retry(arxiv_id: str, dest: Path) -> tuple[bool, str]:
|
| 81 |
+
for attempt in range(1, MAX_ATTEMPTS + 1):
|
| 82 |
+
ok, msg = download_pdf(arxiv_id, dest)
|
| 83 |
+
if not ok:
|
| 84 |
+
if attempt < MAX_ATTEMPTS:
|
| 85 |
+
console.print(f" [dim]attempt {attempt}: {msg}; backing off {RETRY_BACKOFF_SEC}s[/dim]")
|
| 86 |
+
time.sleep(RETRY_BACKOFF_SEC)
|
| 87 |
+
continue
|
| 88 |
+
return False, msg
|
| 89 |
+
|
| 90 |
+
valid, vmsg = validate_pdf(dest)
|
| 91 |
+
if valid:
|
| 92 |
+
return True, msg
|
| 93 |
+
dest.unlink(missing_ok=True)
|
| 94 |
+
if attempt < MAX_ATTEMPTS:
|
| 95 |
+
console.print(f" [dim]attempt {attempt}: invalid pdf ({vmsg}); backing off {RETRY_BACKOFF_SEC}s[/dim]")
|
| 96 |
+
time.sleep(RETRY_BACKOFF_SEC)
|
| 97 |
+
return False, "exhausted retries"
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def main() -> int:
|
| 101 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 102 |
+
|
| 103 |
+
n_ok = 0
|
| 104 |
+
n_skip = 0
|
| 105 |
+
n_fail = 0
|
| 106 |
+
failures: list[tuple[str, str]] = []
|
| 107 |
+
|
| 108 |
+
pending = []
|
| 109 |
+
for paper in CANONICAL_RL_PAPERS:
|
| 110 |
+
target = DATA_DIR / f"{paper.arxiv_id}.pdf"
|
| 111 |
+
if target.exists():
|
| 112 |
+
ok, reason = validate_pdf(target)
|
| 113 |
+
if ok:
|
| 114 |
+
console.print(f"[yellow]SKIP[/yellow] {paper.arxiv_id} ({paper.tag})")
|
| 115 |
+
n_skip += 1
|
| 116 |
+
continue
|
| 117 |
+
console.print(f"[magenta]REDO[/magenta] {paper.arxiv_id} ({paper.tag}) — {reason}")
|
| 118 |
+
target.unlink()
|
| 119 |
+
pending.append(paper)
|
| 120 |
+
|
| 121 |
+
if not pending:
|
| 122 |
+
console.print("\n[bold green]All papers already present and valid.[/bold green]")
|
| 123 |
+
return 0
|
| 124 |
+
|
| 125 |
+
console.print(f"\n[bold]Need to download {len(pending)} paper(s). Spacing requests by {INTER_REQUEST_DELAY}s.[/bold]\n")
|
| 126 |
+
|
| 127 |
+
for i, paper in enumerate(pending):
|
| 128 |
+
target = DATA_DIR / f"{paper.arxiv_id}.pdf"
|
| 129 |
+
console.print(f"[cyan]FETCH[/cyan] {paper.arxiv_id} {paper.tag} - {paper.title[:60]}")
|
| 130 |
+
ok, msg = fetch_with_retry(paper.arxiv_id, target)
|
| 131 |
+
if ok:
|
| 132 |
+
console.print(f" [green]ok[/green] {msg}")
|
| 133 |
+
n_ok += 1
|
| 134 |
+
else:
|
| 135 |
+
console.print(f"[red]FAIL[/red] {paper.arxiv_id}: {msg}")
|
| 136 |
+
failures.append((paper.arxiv_id, msg))
|
| 137 |
+
n_fail += 1
|
| 138 |
+
# Spacing between successful papers (within retry, the backoff is already 30s)
|
| 139 |
+
if i + 1 < len(pending):
|
| 140 |
+
time.sleep(INTER_REQUEST_DELAY)
|
| 141 |
+
|
| 142 |
+
console.print()
|
| 143 |
+
table = Table(title="Corpus fetch summary")
|
| 144 |
+
table.add_column("Outcome", style="bold")
|
| 145 |
+
table.add_column("Count", justify="right")
|
| 146 |
+
table.add_row("[green]Downloaded[/green]", str(n_ok))
|
| 147 |
+
table.add_row("[yellow]Skipped (already valid)[/yellow]", str(n_skip))
|
| 148 |
+
table.add_row("[red]Failed[/red]", str(n_fail))
|
| 149 |
+
console.print(table)
|
| 150 |
+
|
| 151 |
+
if failures:
|
| 152 |
+
console.print("\n[bold red]Failures (re-run later to retry):[/bold red]")
|
| 153 |
+
for arxiv_id, err in failures:
|
| 154 |
+
console.print(f" {arxiv_id}: {err}")
|
| 155 |
+
|
| 156 |
+
return 0 if n_fail == 0 else 1
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
if __name__ == "__main__":
|
| 160 |
+
sys.exit(main())
|
scripts/parse_corpus.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Parse every valid PDF in data/papers/ into a single chunks JSONL.
|
| 2 |
+
|
| 3 |
+
Output: data/chunks.jsonl (one chunk per line, ready for embedding)
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
uv run python scripts/parse_corpus.py
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import sys
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 15 |
+
|
| 16 |
+
from rich.console import Console
|
| 17 |
+
from rich.table import Table
|
| 18 |
+
|
| 19 |
+
from researchpath.corpus import CANONICAL_RL_PAPERS
|
| 20 |
+
from researchpath.parsing import chunk_to_dict, parse_pdf
|
| 21 |
+
|
| 22 |
+
console = Console()
|
| 23 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 24 |
+
PAPERS_DIR = ROOT / "data" / "papers"
|
| 25 |
+
CHUNKS_PATH = ROOT / "data" / "chunks.jsonl"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def main() -> int:
|
| 29 |
+
by_id = {p.arxiv_id: p for p in CANONICAL_RL_PAPERS}
|
| 30 |
+
|
| 31 |
+
pdfs = sorted(PAPERS_DIR.glob("*.pdf"))
|
| 32 |
+
if not pdfs:
|
| 33 |
+
console.print("[red]No PDFs found in data/papers/. Run fetch_corpus.py first.[/red]")
|
| 34 |
+
return 1
|
| 35 |
+
|
| 36 |
+
table = Table(title="Parse summary")
|
| 37 |
+
table.add_column("Paper", style="bold")
|
| 38 |
+
table.add_column("Tag")
|
| 39 |
+
table.add_column("Chunks", justify="right")
|
| 40 |
+
table.add_column("Chars", justify="right")
|
| 41 |
+
|
| 42 |
+
total_chunks = 0
|
| 43 |
+
total_chars = 0
|
| 44 |
+
|
| 45 |
+
with open(CHUNKS_PATH, "w", encoding="utf-8") as out:
|
| 46 |
+
for pdf in pdfs:
|
| 47 |
+
paper_meta = by_id.get(pdf.stem)
|
| 48 |
+
tag = paper_meta.tag if paper_meta else "?"
|
| 49 |
+
n = 0
|
| 50 |
+
chars = 0
|
| 51 |
+
for chunk in parse_pdf(pdf):
|
| 52 |
+
out.write(json.dumps(chunk_to_dict(chunk), ensure_ascii=False) + "\n")
|
| 53 |
+
n += 1
|
| 54 |
+
chars += chunk.n_chars
|
| 55 |
+
table.add_row(pdf.stem, tag, str(n), f"{chars:,}")
|
| 56 |
+
total_chunks += n
|
| 57 |
+
total_chars += chars
|
| 58 |
+
|
| 59 |
+
console.print(table)
|
| 60 |
+
console.print(
|
| 61 |
+
f"\n[bold green]Wrote {total_chunks} chunks ({total_chars:,} chars) to {CHUNKS_PATH.relative_to(ROOT)}[/bold green]"
|
| 62 |
+
)
|
| 63 |
+
return 0
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
if __name__ == "__main__":
|
| 67 |
+
sys.exit(main())
|
scripts/plan.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compute a prerequisite reading path for a target RL paper.
|
| 2 |
+
|
| 3 |
+
Usage:
|
| 4 |
+
uv run python scripts/plan.py --target 1710.02298 # plan for Rainbow, no prior knowledge
|
| 5 |
+
uv run python scripts/plan.py --target 1710.02298 --known 1312.5602 # already know DQN
|
| 6 |
+
uv run python scripts/plan.py --target 1710.02298 --known DQN,A3C # use tags or arxiv IDs
|
| 7 |
+
uv run python scripts/plan.py --list # list all canonical papers
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import argparse
|
| 12 |
+
import sys
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 16 |
+
|
| 17 |
+
from rich.console import Console
|
| 18 |
+
from rich.panel import Panel
|
| 19 |
+
|
| 20 |
+
from researchpath.corpus import CANONICAL_RL_PAPERS
|
| 21 |
+
from researchpath.planning import plan_reading_path
|
| 22 |
+
|
| 23 |
+
console = Console()
|
| 24 |
+
_TAG_TO_ID = {p.tag.lower(): p.arxiv_id for p in CANONICAL_RL_PAPERS}
|
| 25 |
+
_ID_TO_PAPER = {p.arxiv_id: p for p in CANONICAL_RL_PAPERS}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _resolve(token: str) -> str:
|
| 29 |
+
"""Accept either an arxiv ID or a paper tag (case-insensitive)."""
|
| 30 |
+
token = token.strip()
|
| 31 |
+
if token in _ID_TO_PAPER:
|
| 32 |
+
return token
|
| 33 |
+
if token.lower() in _TAG_TO_ID:
|
| 34 |
+
return _TAG_TO_ID[token.lower()]
|
| 35 |
+
raise SystemExit(f"Unknown paper: {token!r}. Use --list to see options.")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _list_papers() -> None:
|
| 39 |
+
console.print("[bold cyan]Canonical RL papers:[/bold cyan]\n")
|
| 40 |
+
for p in CANONICAL_RL_PAPERS:
|
| 41 |
+
console.print(f" [{p.arxiv_id}] [bold]{p.tag:20s}[/bold] ({p.year}) {p.title}")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def main() -> int:
|
| 45 |
+
parser = argparse.ArgumentParser(description="Plan a prerequisite reading path.")
|
| 46 |
+
parser.add_argument("--target", help="arxiv ID or tag of the paper you want to read.")
|
| 47 |
+
parser.add_argument(
|
| 48 |
+
"--known",
|
| 49 |
+
default="",
|
| 50 |
+
help="Comma-separated arxiv IDs or tags you already understand (default: none).",
|
| 51 |
+
)
|
| 52 |
+
parser.add_argument("--list", action="store_true", help="List all canonical papers.")
|
| 53 |
+
args = parser.parse_args()
|
| 54 |
+
|
| 55 |
+
if args.list:
|
| 56 |
+
_list_papers()
|
| 57 |
+
return 0
|
| 58 |
+
|
| 59 |
+
if not args.target:
|
| 60 |
+
parser.error("--target is required (or pass --list).")
|
| 61 |
+
|
| 62 |
+
target_id = _resolve(args.target)
|
| 63 |
+
known_ids = {_resolve(t) for t in args.known.split(",") if t.strip()}
|
| 64 |
+
|
| 65 |
+
plan = plan_reading_path(target_id, known_ids=known_ids)
|
| 66 |
+
target = _ID_TO_PAPER[target_id]
|
| 67 |
+
|
| 68 |
+
header = f"Reading path for [{target.arxiv_id}] {target.tag} — {target.title}"
|
| 69 |
+
if known_ids:
|
| 70 |
+
known_tags = ", ".join(sorted(_ID_TO_PAPER[k].tag for k in known_ids))
|
| 71 |
+
header += f"\nAssuming you already know: {known_tags}"
|
| 72 |
+
|
| 73 |
+
console.print(Panel(header, border_style="cyan"))
|
| 74 |
+
console.print()
|
| 75 |
+
console.print(plan.render_text())
|
| 76 |
+
console.print()
|
| 77 |
+
console.print(
|
| 78 |
+
f"[dim]{len(plan.steps)} step(s). Static prerequisite graph; "
|
| 79 |
+
f"add LLM-extracted concept dependencies in a future iteration.[/dim]"
|
| 80 |
+
)
|
| 81 |
+
return 0
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
if __name__ == "__main__":
|
| 85 |
+
sys.exit(main())
|
scripts/run_eval.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run the full eval harness against the RAG system.
|
| 2 |
+
|
| 3 |
+
Usage:
|
| 4 |
+
uv run python scripts/run_eval.py --groq # FAISS-only, Groq LLM
|
| 5 |
+
uv run python scripts/run_eval.py --groq --hybrid # BM25+FAISS fusion
|
| 6 |
+
uv run python scripts/run_eval.py --groq --limit 5 # quick smoke test
|
| 7 |
+
uv run python scripts/run_eval.py --out data/eval_results_hybrid.json --groq --hybrid
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import argparse
|
| 12 |
+
import json
|
| 13 |
+
import sys
|
| 14 |
+
import time
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 18 |
+
|
| 19 |
+
from rich.console import Console
|
| 20 |
+
from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
|
| 21 |
+
from rich.table import Table
|
| 22 |
+
|
| 23 |
+
from researchpath.embeddings import Embedder
|
| 24 |
+
from researchpath.eval import (
|
| 25 |
+
EvalResult,
|
| 26 |
+
evaluate_example,
|
| 27 |
+
load_gold_dataset,
|
| 28 |
+
results_to_json,
|
| 29 |
+
summarize,
|
| 30 |
+
)
|
| 31 |
+
from researchpath.index import load_index, search
|
| 32 |
+
from researchpath.rag import answer as rag_answer, answer_groq
|
| 33 |
+
from researchpath.retrieval import HybridRetriever, Reranker
|
| 34 |
+
|
| 35 |
+
console = Console()
|
| 36 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 37 |
+
INDEX_PATH = ROOT / "data" / "index.faiss"
|
| 38 |
+
GOLD_PATH = ROOT / "data" / "gold_dataset.json"
|
| 39 |
+
DEFAULT_OUT = ROOT / "data" / "eval_results.json"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def main() -> int:
|
| 43 |
+
parser = argparse.ArgumentParser(description="Evaluate baseline RAG against gold dataset.")
|
| 44 |
+
parser.add_argument("--k", type=int, default=5, help="Top-k chunks to retrieve (default: 5).")
|
| 45 |
+
parser.add_argument("--limit", type=int, default=None, help="Evaluate only first N examples (for quick tests).")
|
| 46 |
+
parser.add_argument("--out", type=str, default=str(DEFAULT_OUT), help="Output JSON path.")
|
| 47 |
+
parser.add_argument("--groq", action="store_true", help="Use Groq for RAG generation (avoids Gemini rate limits).")
|
| 48 |
+
parser.add_argument("--hybrid", action="store_true", help="Use BM25+FAISS hybrid retrieval (RRF fusion).")
|
| 49 |
+
parser.add_argument("--rerank", action="store_true", help="Apply cross-encoder reranking on top of retrieval.")
|
| 50 |
+
parser.add_argument("--resume", action="store_true", help="Skip examples already in --out file (for retrying after rate limits).")
|
| 51 |
+
args = parser.parse_args()
|
| 52 |
+
|
| 53 |
+
if not INDEX_PATH.exists():
|
| 54 |
+
console.print(f"[red]No index at {INDEX_PATH}. Run scripts/build_index.py first.[/red]")
|
| 55 |
+
return 1
|
| 56 |
+
if not GOLD_PATH.exists():
|
| 57 |
+
console.print(f"[red]No gold dataset at {GOLD_PATH}.[/red]")
|
| 58 |
+
return 1
|
| 59 |
+
|
| 60 |
+
examples = load_gold_dataset(GOLD_PATH)
|
| 61 |
+
if args.limit:
|
| 62 |
+
examples = examples[: args.limit]
|
| 63 |
+
|
| 64 |
+
completed_ids: set[str] = set()
|
| 65 |
+
if args.resume:
|
| 66 |
+
out_path_check = Path(args.out) if Path(args.out).is_absolute() else ROOT / args.out
|
| 67 |
+
if out_path_check.exists():
|
| 68 |
+
try:
|
| 69 |
+
with open(out_path_check, encoding="utf-8") as f:
|
| 70 |
+
prior = json.load(f)
|
| 71 |
+
completed_ids = {r["id"] for r in prior.get("results", [])}
|
| 72 |
+
console.print(f"[yellow]Resuming: skipping {len(completed_ids)} already-evaluated examples.[/yellow]")
|
| 73 |
+
except Exception:
|
| 74 |
+
pass
|
| 75 |
+
examples = [e for e in examples if e.id not in completed_ids]
|
| 76 |
+
|
| 77 |
+
rag_fn = answer_groq if args.groq else rag_answer
|
| 78 |
+
rag_provider = "Groq / llama-3.3-70b" if args.groq else "Gemini / gemini-2.5-flash-lite"
|
| 79 |
+
if args.rerank:
|
| 80 |
+
retrieval_mode = "BM25+FAISS+CrossEncoder rerank" if args.hybrid else "FAISS+CrossEncoder rerank"
|
| 81 |
+
else:
|
| 82 |
+
retrieval_mode = "BM25+FAISS (RRF)" if args.hybrid else "FAISS dense"
|
| 83 |
+
|
| 84 |
+
console.print(f"\n[bold cyan]ResearchPath Eval Harness[/bold cyan]")
|
| 85 |
+
console.print(f" Gold examples : {len(examples)}")
|
| 86 |
+
console.print(f" Retrieval : {retrieval_mode} k={args.k}")
|
| 87 |
+
console.print(f" RAG provider : {rag_provider}")
|
| 88 |
+
console.print(f" Judge : Groq / llama-3.3-70b")
|
| 89 |
+
console.print(f" Index : {INDEX_PATH.relative_to(ROOT)}")
|
| 90 |
+
console.print()
|
| 91 |
+
|
| 92 |
+
console.print("[dim]Loading index and embedder...[/dim]")
|
| 93 |
+
index, chunks = load_index(INDEX_PATH)
|
| 94 |
+
embedder = Embedder()
|
| 95 |
+
hybrid = HybridRetriever(index, chunks, embedder) if args.hybrid or args.rerank else None
|
| 96 |
+
if args.rerank:
|
| 97 |
+
console.print("[dim]Loading cross-encoder reranker (first run downloads ~80MB)...[/dim]")
|
| 98 |
+
reranker = Reranker(hybrid)
|
| 99 |
+
else:
|
| 100 |
+
reranker = None
|
| 101 |
+
console.print("[green]Ready.[/green]\n")
|
| 102 |
+
|
| 103 |
+
results: list[EvalResult] = []
|
| 104 |
+
failures: list[str] = []
|
| 105 |
+
out_path = Path(args.out) if Path(args.out).is_absolute() else ROOT / args.out
|
| 106 |
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
| 107 |
+
|
| 108 |
+
prior_results: list[dict] = []
|
| 109 |
+
if args.resume and out_path.exists():
|
| 110 |
+
try:
|
| 111 |
+
with open(out_path, encoding="utf-8") as f:
|
| 112 |
+
prior_results = json.load(f).get("results", [])
|
| 113 |
+
except Exception:
|
| 114 |
+
prior_results = []
|
| 115 |
+
|
| 116 |
+
def _save_partial() -> None:
|
| 117 |
+
if not results and not prior_results:
|
| 118 |
+
return
|
| 119 |
+
partial_summary = summarize(results) if results else None
|
| 120 |
+
partial_payload = (
|
| 121 |
+
results_to_json(results, partial_summary)
|
| 122 |
+
if partial_summary
|
| 123 |
+
else {"summary": {}, "results": []}
|
| 124 |
+
)
|
| 125 |
+
# Merge resumed prior results with new ones (prior first, preserves order)
|
| 126 |
+
if prior_results:
|
| 127 |
+
new_ids = {r["id"] for r in partial_payload["results"]}
|
| 128 |
+
merged = [r for r in prior_results if r["id"] not in new_ids] + partial_payload["results"]
|
| 129 |
+
partial_payload["results"] = merged
|
| 130 |
+
partial_payload["summary"]["n"] = len(merged)
|
| 131 |
+
with open(out_path, "w", encoding="utf-8") as f:
|
| 132 |
+
json.dump(partial_payload, f, indent=2, ensure_ascii=False)
|
| 133 |
+
|
| 134 |
+
with Progress(
|
| 135 |
+
SpinnerColumn(),
|
| 136 |
+
TextColumn("[progress.description]{task.description}"),
|
| 137 |
+
TimeElapsedColumn(),
|
| 138 |
+
console=console,
|
| 139 |
+
) as progress:
|
| 140 |
+
task = progress.add_task("Evaluating...", total=len(examples))
|
| 141 |
+
|
| 142 |
+
for ex in examples:
|
| 143 |
+
progress.update(task, description=f"[cyan]{ex.id}[/cyan] — {ex.question[:55]}...")
|
| 144 |
+
try:
|
| 145 |
+
t0 = time.time()
|
| 146 |
+
if reranker:
|
| 147 |
+
hits = reranker.search(ex.question, k=args.k)
|
| 148 |
+
elif hybrid:
|
| 149 |
+
hits = hybrid.search(ex.question, k=args.k)
|
| 150 |
+
else:
|
| 151 |
+
hits = search(index, chunks, embedder, ex.question, k=args.k)
|
| 152 |
+
ra = rag_fn(ex.question, hits)
|
| 153 |
+
latency = time.time() - t0
|
| 154 |
+
if not args.groq:
|
| 155 |
+
time.sleep(3) # stay under Gemini's 20 RPM free-tier ceiling
|
| 156 |
+
|
| 157 |
+
result = evaluate_example(ex, hits, ra, latency)
|
| 158 |
+
results.append(result)
|
| 159 |
+
_save_partial() # checkpoint after every example so 429s don't lose work
|
| 160 |
+
|
| 161 |
+
status = "[green]OK[/green]" if result.answer_correct else "[yellow]MISS[/yellow]"
|
| 162 |
+
recall_str = f"recall={result.retrieval_recall:.0%}"
|
| 163 |
+
progress.console.print(
|
| 164 |
+
f" {status} {ex.id:20s} {recall_str} cite={'Y' if result.citation_present else 'N'} "
|
| 165 |
+
f"correct={'Y' if result.answer_correct else 'N'} {latency:.1f}s"
|
| 166 |
+
)
|
| 167 |
+
except Exception as exc:
|
| 168 |
+
failures.append(f"{ex.id}: {exc}")
|
| 169 |
+
progress.console.print(f" [red]ERR[/red] {ex.id}: {exc}")
|
| 170 |
+
|
| 171 |
+
progress.advance(task)
|
| 172 |
+
|
| 173 |
+
if not results:
|
| 174 |
+
console.print("[red]No results collected — check errors above.[/red]")
|
| 175 |
+
return 1
|
| 176 |
+
|
| 177 |
+
summary = summarize(results)
|
| 178 |
+
summary.print_table()
|
| 179 |
+
|
| 180 |
+
payload = results_to_json(results, summary)
|
| 181 |
+
with open(out_path, "w", encoding="utf-8") as f:
|
| 182 |
+
json.dump(payload, f, indent=2, ensure_ascii=False)
|
| 183 |
+
try:
|
| 184 |
+
display_path = out_path.relative_to(ROOT)
|
| 185 |
+
except ValueError:
|
| 186 |
+
display_path = out_path
|
| 187 |
+
console.print(f"[green]Saved detailed results to {display_path}[/green]")
|
| 188 |
+
|
| 189 |
+
if failures:
|
| 190 |
+
console.print(f"\n[red]{len(failures)} example(s) failed:[/red]")
|
| 191 |
+
for msg in failures:
|
| 192 |
+
console.print(f" {msg}")
|
| 193 |
+
|
| 194 |
+
_print_failures_table(results, console)
|
| 195 |
+
return 0
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _print_failures_table(results: list[EvalResult], console: Console) -> None:
|
| 199 |
+
misses = [r for r in results if not r.answer_correct]
|
| 200 |
+
if not misses:
|
| 201 |
+
console.print("[bold green]All examples answered correctly.[/bold green]")
|
| 202 |
+
return
|
| 203 |
+
|
| 204 |
+
console.print(f"\n[bold yellow]Incorrect answers ({len(misses)}/{len(results)}):[/bold yellow]")
|
| 205 |
+
table = Table(show_header=True, header_style="bold")
|
| 206 |
+
table.add_column("ID", style="cyan", width=22)
|
| 207 |
+
table.add_column("Difficulty", width=8)
|
| 208 |
+
table.add_column("Recall", width=7)
|
| 209 |
+
table.add_column("Cite", width=5)
|
| 210 |
+
table.add_column("Expected key claim (truncated)", width=55)
|
| 211 |
+
|
| 212 |
+
for r in misses:
|
| 213 |
+
table.add_row(
|
| 214 |
+
r.example.id,
|
| 215 |
+
r.example.difficulty,
|
| 216 |
+
f"{r.retrieval_recall:.0%}",
|
| 217 |
+
"Y" if r.citation_present else "N",
|
| 218 |
+
r.example.expected_key_claim[:55],
|
| 219 |
+
)
|
| 220 |
+
console.print(table)
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
if __name__ == "__main__":
|
| 224 |
+
sys.exit(main())
|
scripts/smoke_test.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Day 1 smoke test: verify both Gemini and Groq API keys work end-to-end.
|
| 2 |
+
|
| 3 |
+
Usage:
|
| 4 |
+
uv run python scripts/smoke_test.py
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import sys
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
# Make `researchpath` importable when running this file directly.
|
| 12 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 13 |
+
|
| 14 |
+
from rich.console import Console
|
| 15 |
+
|
| 16 |
+
from researchpath.llm import gemini_generate, groq_generate
|
| 17 |
+
|
| 18 |
+
console = Console()
|
| 19 |
+
|
| 20 |
+
PROMPT = "In one sentence, what is policy gradient in reinforcement learning?"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def run_provider(name: str, fn) -> bool:
|
| 24 |
+
console.print(f"[bold cyan]Testing {name}...[/bold cyan]")
|
| 25 |
+
try:
|
| 26 |
+
result = fn(PROMPT)
|
| 27 |
+
console.print(f"[green]OK[/green] {name} ({result.model}):")
|
| 28 |
+
console.print(f" {result.text.strip()}")
|
| 29 |
+
if result.input_tokens is not None:
|
| 30 |
+
console.print(
|
| 31 |
+
f" [dim]tokens: {result.input_tokens} in / {result.output_tokens} out[/dim]"
|
| 32 |
+
)
|
| 33 |
+
console.print()
|
| 34 |
+
return True
|
| 35 |
+
except Exception as e:
|
| 36 |
+
console.print(f"[red]FAIL {name}:[/red] {e}\n")
|
| 37 |
+
return False
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def main() -> int:
|
| 41 |
+
console.print("[bold]ResearchPath smoke test[/bold]\n")
|
| 42 |
+
results = [
|
| 43 |
+
run_provider("Gemini", gemini_generate),
|
| 44 |
+
run_provider("Groq", groq_generate),
|
| 45 |
+
]
|
| 46 |
+
if all(results):
|
| 47 |
+
console.print("[bold green]All providers working. You're ready for Day 2.[/bold green]")
|
| 48 |
+
return 0
|
| 49 |
+
console.print("[bold red]One or more providers failed. Fix and re-run.[/bold red]")
|
| 50 |
+
return 1
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
if __name__ == "__main__":
|
| 54 |
+
sys.exit(main())
|
tests/test_planning.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the prerequisite reading-path planner.
|
| 2 |
+
|
| 3 |
+
Run: uv run pytest tests/test_planning.py
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import pytest
|
| 8 |
+
|
| 9 |
+
from researchpath.planning import plan_reading_path
|
| 10 |
+
from researchpath.prerequisites import STATIC_PREREQUISITES
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def test_target_only_when_no_prereqs():
|
| 14 |
+
"""A paper with no incoming edges should yield a one-step plan (just itself)."""
|
| 15 |
+
plan = plan_reading_path("1312.5602") # DQN — no prereqs in the graph
|
| 16 |
+
assert len(plan.steps) == 1
|
| 17 |
+
assert plan.steps[0].paper.arxiv_id == "1312.5602"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def test_rainbow_full_chain():
|
| 21 |
+
"""Rainbow with no prior knowledge requires DQN, Double DQN, PER, Dueling DQN, Rainbow.
|
| 22 |
+
|
| 23 |
+
PER was added to the corpus and is one of Rainbow's six components, so it appears
|
| 24 |
+
between Double DQN and Dueling DQN (year 2015 vs 2016 topo tie-break).
|
| 25 |
+
"""
|
| 26 |
+
plan = plan_reading_path("1710.02298")
|
| 27 |
+
ids_in_order = [s.paper.arxiv_id for s in plan.steps]
|
| 28 |
+
assert ids_in_order == ["1312.5602", "1509.06461", "1511.05952", "1511.06581", "1710.02298"]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_known_set_truncates_chain():
|
| 32 |
+
"""Knowing DQN should drop it from the Rainbow plan."""
|
| 33 |
+
plan = plan_reading_path("1710.02298", known_ids={"1312.5602"})
|
| 34 |
+
ids = {s.paper.arxiv_id for s in plan.steps}
|
| 35 |
+
assert "1312.5602" not in ids
|
| 36 |
+
assert "1710.02298" in ids
|
| 37 |
+
# Double DQN, PER, Dueling DQN, Rainbow remain (PER now in corpus as Rainbow component)
|
| 38 |
+
assert len(plan.steps) == 4
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def test_known_target_yields_empty_plan():
|
| 42 |
+
"""If the target is already 'known', no reading is needed."""
|
| 43 |
+
plan = plan_reading_path("1710.02298", known_ids={"1710.02298"})
|
| 44 |
+
assert plan.steps == ()
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_topological_order_respects_edges():
|
| 48 |
+
"""For every edge (A -> B), A must appear before B in the plan."""
|
| 49 |
+
plan = plan_reading_path("1710.02298")
|
| 50 |
+
positions = {s.paper.arxiv_id: i for i, s in enumerate(plan.steps)}
|
| 51 |
+
for edge in STATIC_PREREQUISITES:
|
| 52 |
+
if edge.from_id in positions and edge.to_id in positions:
|
| 53 |
+
assert positions[edge.from_id] < positions[edge.to_id], (
|
| 54 |
+
f"{edge.from_id} should precede {edge.to_id}"
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_unknown_target_raises():
|
| 59 |
+
with pytest.raises(ValueError):
|
| 60 |
+
plan_reading_path("9999.99999")
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_render_text_contains_target_title():
|
| 64 |
+
plan = plan_reading_path("1710.02298")
|
| 65 |
+
text = plan.render_text()
|
| 66 |
+
assert "Rainbow" in text
|
| 67 |
+
assert "1710.02298" in text
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|