File size: 22,674 Bytes
2f25a40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
# ResearchPath β€” Project Log

A chronological record of decisions, work sessions, issues, and outcomes for the ResearchPath portfolio project.

**Schema for each entry:**

```
## YYYY-MM-DD HH:MM β€” [type] Title
**Type:** setup | decision | implementation | issue | resolution | discussion
**Status:** in-progress | complete | blocked | reverted
**Duration:** approx time

### Context
### Decisions
### Actions taken
### Issues encountered
### Files touched
### Outcome
### Next
```

---

## 2026-05-06 15:00 β€” [discussion] Project framing & scope

**Type:** decision
**Status:** complete
**Duration:** ~30 min

### Context
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.

### Decisions
- **Rejected** Underwriter Copilot (insurance) and Equity Research Analyst (SEC filings) β€” too generic / didn't match his preference for research-flavored work.
- **Rejected** generic-RAG-over-papers framing β€” already exists (Perplexity, Elicit, Consensus); not enough differentiation.
- **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.
- Demo domain: **Reinforcement Learning**. Architecture domain-agnostic.
- Stack: Gemini 2.5 Flash Lite + Groq Llama 3.3 70B (free tiers), BAAI/bge-small-en embeddings, FAISS, LangGraph, Streamlit, RAGAS.
- Eval-first methodology: gold dataset + ablation table is the differentiator from generic portfolios.

### Outcome
Project scope locked. 5-week plan in README.

### Next
Day 1 setup: env, deps, API keys, smoke test.

---

## 2026-05-06 15:20 β€” [setup] Day 1: Environment bootstrap

**Type:** setup
**Status:** complete
**Duration:** ~45 min

### Context
Stand up local dev env so the agent work in Week 2+ has a working LLM substrate.

### Decisions
- Use `uv` for Python + dependency management (already installed on user's machine).
- Default Gemini model: `gemini-2.5-flash-lite` (chosen after `gemini-2.5-flash` returned transient 503s during smoke test).

### Actions taken
- Scaffolded repo files: `.gitignore`, `.env.example`, `README.md`, `pyproject.toml`, `researchpath/__init__.py`, `researchpath/llm.py`, `scripts/smoke_test.py`.
- `uv python install 3.12` β†’ installed CPython 3.12.9.
- `uv sync` β†’ resolved 38 packages including `google-genai`, `groq`, `python-dotenv`, `rich`, `pytest`, `ruff`.
- User created `.env` from template and pasted Gemini + Groq API keys.
- `uv run python scripts/smoke_test.py` β†’ both providers returned valid completions.

### Issues encountered
- **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.
- **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`.

### Files touched
- 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`.
- User-created (gitignored): `.env`.

### Outcome
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).

### Next
Day 2: git init β†’ arxiv corpus fetch script β†’ download ~17 canonical RL papers β†’ first commit.

---

## 2026-05-06 16:00 β€” [implementation] Day 2: Corpus pipeline + first commit

**Type:** implementation
**Status:** complete (with one outstanding follow-up β€” see "Outcome")
**Duration:** ~75 min

### Context
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.

### Decisions
- **17-paper canon** chosen across 5 eras (value-based, policy-gradient, actor-critic, model-based, RLHF). See `researchpath/corpus.py`.
- **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.
- **PDF validation step** (open with pymupdf, read page 1) gates every download; truncated files auto-deleted and retried.
- **Chunking**: 800-char windows, 100-char overlap, sentence-boundary aware, hyphenation-aware.
- **Accept partial corpus (10/17)** rather than grind on rate-limited retries β€” script is idempotent so missing papers can be picked up later.

### Actions taken
- Added deps: `arxiv`, `pymupdf`. `uv sync` resolved 5 new packages.
- `researchpath/corpus.py` β€” 17-paper canon with `era` tag.
- `scripts/fetch_corpus.py` β€” streaming downloader with per-paper validate-and-retry, 12s inter-request spacing, 30s backoff.
- `researchpath/parsing.py` β€” PDF text extraction + sentence-boundary chunking.
- `scripts/parse_corpus.py` β€” drives parsing across all valid PDFs into `data/chunks.jsonl`.
- Ran fetcher: 10 papers landed cleanly. Deleted 3 truncated stragglers (`1801.01290`, `1911.08265`, `2301.04104`).
- Ran parser: **1,093 chunks, 778,995 chars** across 10 papers.
- `git init -b main` β†’ first commit `270993c` ("Bootstrap ResearchPath: scaffold + corpus pipeline"). 13 files, 1,611 insertions.

### Issues encountered
- **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`.
- **`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.)
- **Unicode `β†’` in console output** broke on cp1252 (already known from Day 1; reused `PYTHONIOENCODING=utf-8`).

### Files touched
- Added: `researchpath/corpus.py`, `researchpath/parsing.py`, `scripts/fetch_corpus.py`, `scripts/parse_corpus.py`.
- Modified: `pyproject.toml` (+arxiv, +pymupdf).
- Generated (gitignored): `data/papers/*.pdf` (10 files), `data/chunks.jsonl`.

### Outcome
End-to-end corpus path works: arxiv ID β†’ validated PDF β†’ chunked JSONL. Repo under git, first commit landed.

**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).

### Next
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.

---

## 2026-05-06 17:00 β€” [implementation] Day 3: Embeddings, FAISS index, baseline RAG

**Type:** implementation
**Status:** complete
**Duration:** ~50 min

### Context
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.

### Decisions
- **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.
- **FAISS `IndexFlatIP`** with L2-normalized embeddings β€” exact cosine search, fast enough for ~1k chunks. Will swap to HNSW if corpus grows past ~50k.
- **Citation format `[arxiv_id, p<page>]`** β€” parseable by future eval code, human-readable in output.
- **System prompt is strict**: only-from-sources, no speculation. Will tighten further once we have eval numbers.

### Actions taken
- Added deps: `sentence-transformers`, `faiss-cpu`, `numpy`. `uv sync` resolved 29 new packages (~330MB inc. torch CPU).
- `researchpath/embeddings.py` β€” `Embedder` wrapper with separate `embed_documents` / `embed_query` (BGE wants different prefixes).
- `researchpath/index.py` β€” `build_index`, `load_index`, `search`. `Hit` dataclass carries score + citation metadata.
- `researchpath/rag.py` β€” `build_prompt`, `answer`. Strict system prompt enforcing citations + grounding.
- `scripts/build_index.py` β€” drives end-to-end embed + persist.
- `scripts/ask.py` β€” CLI for question β†’ answer with citations. `--show-sources` flag for debugging retrieval.
- Built index: 1,093 chunks embedded in **148s** on CPU. Persisted `data/index.faiss` + `data/index.chunks.json`.
- **Smoke test query**: *"How does Rainbow combine multiple deep Q-learning improvements like Double DQN, Dueling networks, and prioritized replay?"*
  - Answer correctly cited [1710.02298, p1] for Rainbow components and [1511.06581, p4] for Dueling architecture details.
  - Listed all 6 Rainbow components accurately. No hallucination observed.
  - 1,106 input / 196 output tokens (β‰ˆ free under Gemini quota).

### Issues encountered
- **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).
- **`VIRTUAL_ENV` mismatch warning from uv** β€” leftover env var from a prior shell session pointing at a different Python. Harmless; `.venv` is used regardless.

### Files touched
- Added: `researchpath/embeddings.py`, `researchpath/index.py`, `researchpath/rag.py`, `scripts/build_index.py`, `scripts/ask.py`.
- Modified: `pyproject.toml` (+sentence-transformers, +faiss-cpu, +numpy), `uv.lock`.
- Generated (gitignored): `data/index.faiss`, `data/index.chunks.json`.

### Outcome
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.

### Next
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.

---

## 2026-05-06 18:00 β€” [implementation] Day 4: Eval harness + gold dataset + baseline numbers

**Type:** implementation
**Status:** complete
**Duration:** ~90 min

### Context
Build a rigorous eval harness before optimizing anything. Eval-first is the portfolio differentiator β€” ablation table beats vibes.

### Decisions
- **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}`.
- **Three metrics** (no citation faithfulness per-claim β€” too expensive on free tier):
  - `retrieval_recall@k`: fraction of expected arxiv IDs found in top-k hits
  - `citation_presence`: answer cites at least one expected paper (string check)
  - `answer_correctness`: LLM-as-judge (Groq llama-3.3-70b), YES/NO prompt
- **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.
- **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.
- **Retry logic** added to `gemini_generate()` β€” parses retry delay from 429 error body, sleeps and retries up to 5 times.

### Actions taken
- `data/gold_dataset.json` β€” 30 QA triples (hand-authored from Spinning Up + paper reading).
- `researchpath/eval.py` β€” `GoldExample`, `EvalResult`, `EvalSummary` dataclasses + `evaluate_example`, `summarize`, `results_to_json`.
- `scripts/run_eval.py` β€” full pipeline: load index β†’ batch retrieve + generate β†’ judge β†’ print table + save JSON.
- Updated `researchpath/rag.py` β€” added `generate_fn` injection param + `answer_groq()` convenience wrapper.
- Updated `researchpath/llm.py` β€” retry-with-backoff on Gemini 429 (parses `retryDelay` from error body).
- Ran full 30-question eval (`--groq`). Duration: ~3.5 min. Results saved to `data/eval_results.json`.

### Issues encountered
- **Gemini 20 req/day free-tier limit** β€” hit on second run (smoke test + partial full run = 20 calls). Fix: route eval to Groq entirely.
- **Retry delay detection**: Gemini's 429 body contains `retryDelay: '10s'` in the Details array. Regex extracts it; if missing, defaults to 15s sleep.

### Files touched
- Added: `data/gold_dataset.json`, `researchpath/eval.py`, `scripts/run_eval.py`.
- Modified: `researchpath/rag.py` (generate_fn injection, answer_groq), `researchpath/llm.py` (retry logic), `README.md` (eval table row 1).
- Generated (gitignored): `data/eval_results.json`.

### Outcome

**Baseline RAG numbers (Groq llama-3.3-70b, k=5, n=30):**

| Metric | Value |
|---|---|
| Retrieval Recall@5 | **90.0%** |
| Citation Presence | **86.7%** |
| Answer Correctness | **36.7%** |
| Avg Latency | **5.07s** |
| RAG Tokens | 33,498 in / 7,000 out |

**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:
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.
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.

Difficulty breakdown: easy=28.6%, medium=53.3%, hard=12.5% answer correctness β€” harder questions show compounding retrieval + precision failures.

### Next
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%+.

---

## 2026-05-06 19:30 β€” [implementation] Day 5: Hybrid BM25+FAISS retrieval (RRF fusion)

**Type:** implementation
**Status:** complete
**Duration:** ~90 min

### Context
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.

### Decisions
- **BM25Okapi** (rank-bm25 library) for sparse retrieval over the same 1,093 chunks.
- **Reciprocal Rank Fusion** (RRF, k=60) to combine BM25 and FAISS rankings without normalizing incompatible score spaces.
- **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".
- **fetch_k=20** from each retriever before fusion, then top-k=5 from combined candidate set.

### Actions taken
- `researchpath/retrieval.py` β€” `BM25Retriever`, `HybridRetriever` with RRF fusion. Stop-word list added to tokenizer.
- `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.
- `scripts/ask.py` β€” added `--hybrid` flag.
- `scripts/run_eval.py` β€” added `--hybrid` flag, fixed `Path.relative_to()` crash on relative paths.
- Ran full hybrid eval (`--groq --hybrid`): 29/30 questions (cross_04 errored on Groq 100K daily TPD limit).

### Issues encountered
- **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.
- **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.
- **Groq 100K daily TPD**: Exhausted daily token limit on the 30th question (cross_04). n=29 eval still valid; cross_04 retry deferred.

### Files touched
- Added: `researchpath/retrieval.py`.
- 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`.
- Generated (gitignored): results in memory only (save failed on path bug, now fixed).

### Outcome

**Hybrid BM25+FAISS results (Groq llama-3.3-70b, k=5, n=29):**

| Metric | Baseline | Hybrid | Delta |
|---|---|---|---|
| Retrieval Recall@5 | 90.0% | 89.7% | -0.3 pp |
| Citation Presence | 86.7% | 86.2% | -0.5 pp |
| **Answer Correctness** | **36.7%** | **72.4%** | **+35.7 pp** |
| Avg Latency | 5.07s | 4.58s | -0.5s |

By difficulty: easy 100%, medium 73.3%, hard 42.9%. Hard questions remain the weak spot.

**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.

### Next
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.

---

## 2026-05-07 β€” [implementation] Day 5 cont.: Agentic planning loop (offline prerequisite-chain planner)

**Type:** implementation
**Status:** complete
**Duration:** ~60 min

### Context
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.

### Decisions
- **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.
- **BFS backwards** from target, stopping at `known_ids` β€” avoids pulling in papers the user already understands.
- **Kahn's algorithm** for topological sort with `(year, arxiv_id)` tie-breaking β€” deterministic + chronologically sensible for RL literature.
- **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.

### Actions taken
- `researchpath/prerequisites.py` β€” `Prerequisite` dataclass + `STATIC_PREREQUISITES` tuple (10 hand-curated edges) + `edges_into` / `edges_from` accessors.
- `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.
- `scripts/plan.py` β€” CLI: `--target` (arxiv ID or tag, case-insensitive), `--known` (comma-separated), `--list`. Rich Panel output.
- `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.

### Smoke tests (pre-pytest, verified manually):
- Rainbow (`1710.02298`, no prior knowledge): `1312.5602 β†’ 1509.06461 β†’ 1511.06581 β†’ 1710.02298` βœ“
- Rainbow knowing DQN: `1509.06461 β†’ 1511.06581 β†’ 1710.02298` (3 steps) βœ“
- DPO (`2305.18290`) knowing InstructGPT: 1 step (DPO itself) βœ“
- DDPG (`1509.02971`): `1312.5602 β†’ 1509.02971` (2 steps) βœ“

### Issues encountered
None. BFS + Kahn's on the static graph is deterministic and small enough that correctness is straightforward to verify.

### Files touched
- Added: `researchpath/prerequisites.py`, `researchpath/planning.py`, `scripts/plan.py`, `tests/test_planning.py`.

### Outcome
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.

**Sample CLI output** (`uv run python scripts/plan.py --target Rainbow`):
```
1. [1312.5602] DQN (2013) β€” Playing Atari with Deep Reinforcement Learning
     bridges: -> 1509.06461, 1511.06581, 1710.02298
     concepts: Q-learning, experience replay, target network

2. [1509.06461] DoubleDQN (2015) β€” Deep Reinforcement Learning with Double Q-learning
     bridges: -> 1511.06581, 1710.02298
     concepts: overestimation bias, decoupled action selection/evaluation, Double Q-learning

3. [1511.06581] DuelingDQN (2015) β€” Dueling Network Architectures for Deep Reinforcement Learning
     bridges: -> 1710.02298
     concepts: dueling networks

4. [1710.02298] Rainbow (2017) β€” Rainbow: Combining Improvements in Deep Reinforcement Learning
     bridges: <- target
```

### Next
- Retry cross_04 when Groq TPD resets β†’ update README hybrid result from n=29 to n=30
- Run reranker full eval (column 3 in ablation table): `--hybrid --rerank`
- LLM annotation layer: map free-text user background β†’ `known_ids` set; per-step "what to focus on" rationale
- Fetch 4 missing papers: PER (1511.05952), PPO (1707.06347), IMPALA (1802.01561), Decision Transformer (2106.01345)

---