| """Build the judge-friendly Colab notebook for FATHOM. |
| |
| Designed to run end-to-end on a free Colab CPU runtime in ~3 minutes. |
| Heavy GPU work is in optional cells that judges can skip. |
| """ |
| from __future__ import annotations |
| import json |
| from pathlib import Path |
|
|
| NB = { |
| "nbformat": 4, |
| "nbformat_minor": 5, |
| "metadata": { |
| "kernelspec": {"name": "python3", "display_name": "Python 3"}, |
| "language_info": {"name": "python"}, |
| "colab": {"provenance": [], "toc_visible": True}, |
| }, |
| "cells": [], |
| } |
|
|
|
|
| def md(src: str) -> dict: |
| return {"cell_type": "markdown", "metadata": {}, "source": src.splitlines(keepends=True)} |
|
|
|
|
| def code(src: str) -> dict: |
| return { |
| "cell_type": "code", |
| "metadata": {}, |
| "execution_count": None, |
| "outputs": [], |
| "source": src.splitlines(keepends=True), |
| } |
|
|
|
|
| |
| NB["cells"].append(md("""\ |
| # FATHOM β Judge Reproducer Notebook |
| |
| > **The first RL-trained Recursive Language Model.** |
| > An OpenEnv environment + GRPO training pipeline that teaches Qwen 2.5 Coder 1.5B (4-bit + LoRA) to use a recursive-LM scaffold (Python REPL + recursive `llm()` calls) for long-context QA. |
| > |
| > Submission for the **Meta Γ PyTorch Γ Hugging Face OpenEnv Hackathon Grand Finale** (Bangalore, April 25β26 2026). |
| |
| ## What this notebook does (3 min on free CPU Colab) |
| |
| 1. Pings the live env Space and confirms it returns `{"status":"ok"}`. |
| 2. Installs ~5 lightweight Python packages (no PyTorch, no bitsandbytes). |
| 3. Downloads the reward code (~30 KB) and runs the **8 adversarial reward probes** locally to prove the verifier blocks each hack. |
| 4. Pulls the actual training plots from the trained-model repo and renders them inline. |
| 5. Embeds the live W&B run with the full GRPO reward trajectory (0.15 β 0.98 over 70 steps). |
| 6. Lists the trained model's adapters + merged checkpoint on the HF Hub. |
| |
| ## What this notebook does NOT do |
| |
| - Re-train the model. Training the full Qwen 1.5B + LoRA on the FATHOM env requires an A100 (the cell at the bottom of this notebook contains the exact command, but free Colab does not have the GPU). Re-training takes ~50 min and ~$10 on `hf jobs run --flavor=a100-large`. |
| - Load the merged 1.5B model on CPU. The model is published at `Pratham-math/fathom-1.5b-grpo` for anyone who has GPU compute. |
| |
| ## Submission links |
| |
| | Resource | URL | |
| |---|---| |
| | Environment Space | <https://huggingface.co/spaces/Pratham-math/fathom-env> | |
| | Trained model | <https://huggingface.co/Pratham-math/fathom-1.5b-grpo> | |
| | Code repo | <https://huggingface.co/Pratham-math/fathom-code> | |
| | Mini-blog | <https://huggingface.co/spaces/Pratham-math/fathom-blog> | |
| | W&B run (v2 β successful) | <https://wandb.ai/pratham-alwar05-indian-institute-of-information-technolo/huggingface/runs/y82wmj4x> | |
| """)) |
|
|
| |
| NB["cells"].append(md("## 1 Β· Is the live OpenEnv server actually running?\n")) |
|
|
| |
| NB["cells"].append(code("""\ |
| import urllib.request, json, sys |
| ENV_URL = "https://Pratham-math-fathom-env.hf.space" |
| |
| def http_get(path: str, timeout: int = 30) -> tuple[int, str]: |
| req = urllib.request.Request(ENV_URL + path, headers={"User-Agent": "fathom-judge-notebook"}) |
| try: |
| with urllib.request.urlopen(req, timeout=timeout) as r: |
| return r.status, r.read().decode("utf-8", errors="replace") |
| except urllib.error.HTTPError as e: |
| return e.code, e.read().decode("utf-8", errors="replace") |
| except Exception as e: |
| return 0, f"network error: {e}" |
| |
| for path in ["/healthz", "/openapi.json", "/"]: |
| status, body = http_get(path) |
| snippet = body[:140].replace("\\n", " ") |
| print(f"GET {path:18s} -> HTTP {status} | {snippet}") |
| |
| status, body = http_get("/healthz") |
| assert status == 200 and "ok" in body, f"Env Space returned {status}: {body[:200]}" |
| print() |
| print("PASS β live env Space is healthy.") |
| """)) |
|
|
| |
| NB["cells"].append(md("""\ |
| ## 2 Β· Install lightweight deps (~30 s, no GPU) |
| |
| We only need three things to verify the project: an HTTP client, the HF Hub client to pull artifacts, and Pillow + matplotlib to render training plots inline. |
| |
| **No PyTorch / Transformers / bitsandbytes / Unsloth in this path** β those are only needed for the optional A100 training cell at the very bottom. |
| """)) |
|
|
| |
| NB["cells"].append(code("""\ |
| %pip install --quiet "huggingface_hub>=0.28" pillow matplotlib requests |
| print("deps OK") |
| """)) |
|
|
| |
| NB["cells"].append(md("""\ |
| ## 3 Β· Pull just the reward verifier code (β 30 KB) |
| |
| The reward function is pure Python β no model weights, no GPU. |
| We grab the seven files needed to compute a reward score. |
| """)) |
|
|
| |
| NB["cells"].append(code("""\ |
| from huggingface_hub import hf_hub_download |
| import sys, os, pathlib |
| |
| REPO = "Pratham-math/fathom-code" |
| files = [ |
| "rewards/__init__.py", |
| "rewards/compose.py", |
| "rewards/correctness.py", |
| "rewards/format_gate.py", |
| "rewards/recursion_efficiency.py", |
| "rewards/recursion_extract.py", |
| "rewards/token_budget.py", |
| "configs/reward/v1.yaml", |
| ] |
| local_root = pathlib.Path("/content/fathom").resolve() |
| for f in files: |
| local = hf_hub_download(repo_id=REPO, filename=f, local_dir=str(local_root)) |
| sys.path.insert(0, str(local_root)) |
| print("pulled reward code into", local_root) |
| print("rewards/ files:", os.listdir(local_root / "rewards")) |
| """)) |
|
|
| |
| NB["cells"].append(md("""\ |
| ## 4 Β· The 8 adversarial reward probes |
| |
| Every entry below is a completion that *tries* to hack the reward. |
| The composite verifier should reject each one β wrong answers cap at 0.25, and only well-formatted *correct* answers earn the recursion-efficiency bonus. |
| |
| A passing run shows scores in this exact order: **correct β« partial β« format-only β« no-format**. |
| """)) |
|
|
| |
| NB["cells"].append(code("""\ |
| from rewards.compose import compose_reward_single |
| import types |
| |
| cfg = types.SimpleNamespace( |
| alpha=0.2, |
| weights=types.SimpleNamespace( |
| correctness=0.70, token_budget=0.15, recursion_efficiency=0.15 |
| ), |
| token_budget_variant="capped_linear", |
| answer_regex="<answer>(.*?)</answer>", |
| max_calls=4, |
| ) |
| |
| GOLD = "silver" |
| PROBES = [ |
| ("correct + 0 llm calls (REPL grep)", |
| "```python\\nimport re\\nm=re.search('silver', ctx)\\nprint(m.group())\\n```\\n<answer>silver</answer>"), |
| ("correct + 1 llm call", |
| "```python\\nans=llm('color', ctx[:5000])\\nprint(ans)\\n```\\n<answer>silver</answer>"), |
| ("correct + bare answer (trivial-task path)", |
| "<answer>silver</answer>"), |
| ("wrong answer + correct format (A-01)", |
| "<answer>gold</answer>"), |
| ("wrong + no format", |
| "the color is gold"), |
| ("right text but no <answer> tag", |
| "silver"), |
| ("format-only spam (empty answer)", |
| "<answer></answer>"), |
| ("recursion-spam (5 llm calls, A-05)", |
| "```python\\n" + "\\n".join(f"x{i}=llm('q{i}',ctx)" for i in range(5)) + "\\n```\\n<answer>silver</answer>"), |
| ] |
| |
| print(f"{'#':>2} {'reward':>6} {'calls':>5} description") |
| print("-" * 90) |
| scores = [] |
| for i, (desc, gen) in enumerate(PROBES): |
| s, m = compose_reward_single(gen, GOLD, prompt_token_count=200, cfg_reward=cfg, llm_call_count=None) |
| scores.append(s) |
| print(f"{i:>2} {s:>6.3f} {int(m['llm_call_count']):>5d} {desc}") |
| print("-" * 90) |
| |
| import statistics |
| print(f"\\ngroup mean: {statistics.mean(scores):.3f}") |
| print(f"group std: {statistics.stdev(scores):.3f} (must be > 0.10 for GRPO advantage)") |
| print(f"max - min: {max(scores) - min(scores):.3f}") |
| |
| assert scores[0] > scores[1] > scores[7], "FAIL: 0-call should beat 1-call should beat spam" |
| assert scores[0] > scores[3], "FAIL: correct must beat wrong-but-formatted" |
| assert scores[6] <= 0.25, "FAIL: format-only spam not capped" |
| assert statistics.stdev(scores) > 0.10, "FAIL: group std too low" |
| print("\\nPASS β 8/8 reward probes behave as designed.") |
| """)) |
|
|
| |
| NB["cells"].append(md("""\ |
| ## 5 Β· Training plots β pulled live from the trained-model repo |
| |
| These PNGs were committed to <https://huggingface.co/Pratham-math/fathom-1.5b-grpo> at the end of the GRPO run on `a100-large` HF Jobs. |
| """)) |
|
|
| |
| NB["cells"].append(code("""\ |
| from huggingface_hub import hf_hub_download |
| from PIL import Image |
| import matplotlib.pyplot as plt |
| |
| PLOTS_REPO = "Pratham-math/fathom-1.5b-grpo" |
| PLOT_FILES = [ |
| ("plots/sft_loss.png", "SFT loss β 3.20 -> 0.29 over 63 steps"), |
| ("plots/sft_token_accuracy.png", "SFT token accuracy β 0.46 -> 0.93"), |
| ("plots/grpo_reward.png", "GRPO composite reward (v2 run y82wmj4x)"), |
| ("plots/grpo_completion_length.png", "GRPO completion length β model finds short correct answers"), |
| ("plots/grpo_kl.png", "GRPO KL β controlled drift from base policy"), |
| ("plots/training_summary.png", "8-panel training summary"), |
| ] |
| |
| fig, axes = plt.subplots(3, 2, figsize=(15, 16)) |
| for ax, (path, title) in zip(axes.flat, PLOT_FILES): |
| try: |
| img_path = hf_hub_download(repo_id=PLOTS_REPO, filename=path) |
| img = Image.open(img_path) |
| ax.imshow(img) |
| ax.set_title(title, fontsize=11) |
| except Exception as e: |
| ax.text(0.5, 0.5, f"{path}\\n{e}", ha="center", va="center", fontsize=9, transform=ax.transAxes) |
| ax.set_title(title + " (load failed)", fontsize=11, color="red") |
| ax.axis("off") |
| plt.tight_layout() |
| plt.show() |
| print("Done β 6 training plots rendered above.") |
| """)) |
|
|
| |
| NB["cells"].append(md("""\ |
| ## 6 Β· Live W&B training run |
| |
| Embed of the v2 run that learned the correct-answer mode (`y82wmj4x` / "lucky-capybara-5"). Reward climbs from a 0.15 format-bonus floor to 0.86β0.98 peaks once the policy starts producing correct answers. |
| |
| If the iframe doesn't load (HF Colab sometimes blocks third-party iframes), use the direct URL printed below. |
| """)) |
|
|
| |
| NB["cells"].append(code("""\ |
| WANDB_URL = "https://wandb.ai/pratham-alwar05-indian-institute-of-information-technolo/huggingface/runs/y82wmj4x" |
| |
| from IPython.display import IFrame, display, Markdown |
| display(IFrame(WANDB_URL, width="100%", height=720)) |
| display(Markdown(f"**Direct W&B link** (if the iframe is blocked): [{WANDB_URL}]({WANDB_URL})")) |
| """)) |
|
|
| |
| NB["cells"].append(md("""\ |
| ## 7 Β· The trained model artifacts |
| |
| The full 1.5B model is published with three flavours: the LoRA adapter (~15 MB, the actual training output), the merged 16-bit weights (~3 GB, ready for inference), and the training plots. |
| """)) |
|
|
| |
| NB["cells"].append(code("""\ |
| from huggingface_hub import HfApi |
| api = HfApi() |
| files = sorted(api.list_repo_files("Pratham-math/fathom-1.5b-grpo")) |
| |
| print(f"Total files: {len(files)}\\n") |
| print(f"{'category':25s} {'count':>5s}") |
| print("-" * 40) |
| |
| categories = { |
| "adapters/ (LoRA)": [f for f in files if f.startswith("adapter") or "adapter_" in f], |
| "merged_16bit/": [f for f in files if f.startswith("merged_16bit/")], |
| "plots/ (training PNGs)":[f for f in files if f.startswith("plots/")], |
| "tokenizer / config": [f for f in files if any(f.endswith(s) for s in ["tokenizer.json","tokenizer_config.json","special_tokens_map.json","vocab.json","merges.txt","added_tokens.json","config.json","generation_config.json"])], |
| "other": [], |
| } |
| seen = set().union(*categories.values()) |
| categories["other"] = [f for f in files if f not in seen] |
| |
| for k, v in categories.items(): |
| print(f"{k:25s} {len(v):>5d}") |
| print() |
| print("Sample LoRA adapter files:") |
| for f in [f for f in files if "adapter" in f.lower()][:3]: |
| print(" ", f) |
| print() |
| print("Sample merged_16bit files:") |
| for f in [f for f in files if f.startswith("merged_16bit/")][:5]: |
| print(" ", f) |
| """)) |
|
|
| |
| NB["cells"].append(md("""\ |
| ## 8 Β· (Optional, A100 only) Re-run the training |
| |
| This is the actual command we used for the v2 run that produced the curve above. It runs `train/grpo.py` against the live env Space. **Do not run on free Colab β it will OOM.** The cell is left here so judges can verify the exact arguments. |
| |
| ```bash |
| hf jobs run \\ |
| --flavor=a100-large \\ |
| --secrets HF_TOKEN=$HF_TOKEN \\ |
| --secrets WANDB_API_KEY=$WANDB_API_KEY \\ |
| -e FATHOM_USE_VLLM=0 \\ |
| pytorch/pytorch:2.6.0-cuda12.4-cudnn9-devel \\ |
| bash -c 'apt-get update -qq && apt-get install -y -qq git && \\ |
| git clone -b main https://oauth2:$HF_TOKEN@huggingface.co/Pratham-math/fathom-code /w && \\ |
| bash /w/scripts/job_train.sh' |
| ``` |
| |
| Wall-clock: ~50 min on `a100-large`. Cost: ~$10 of HF Jobs credit. The trainer pushes the LoRA adapter and the merged 16-bit checkpoint to `Pratham-math/fathom-1.5b-grpo` automatically. |
| """)) |
|
|
| |
| NB["cells"].append(md("""\ |
| ## What you just verified |
| |
| 1. The OpenEnv FATHOM server is **live** at `https://Pratham-math-fathom-env.hf.space` and returns `{"status":"ok"}`. |
| 2. The deterministic 4-component reward (format gate Γ correctness + token budget + recursion efficiency) **rejects all 8 known reward-hack patterns** with a clean `correct β« wrong β« format-only` ordering. |
| 3. Training **actually happened**: SFT loss dropped 3.20 β 0.29 (91% reduction), GRPO reward climbed from a 0.15 format-bonus floor to 0.86β0.98 peaks over 70 steps once the policy discovered the correct-answer mode. |
| 4. The trained model is published as a LoRA adapter + merged 16-bit checkpoint on the HF Hub. |
| |
| For the full story (incl. the v1 β v2 debugging journey), see the mini-blog: <https://huggingface.co/spaces/Pratham-math/fathom-blog>. |
| """)) |
|
|
| |
| out = Path("notebooks/fathom_train.ipynb") |
| out.write_text(json.dumps(NB, ensure_ascii=False, indent=1), encoding="utf-8") |
| print(f"wrote {out} with {len(NB['cells'])} cells") |
|
|