23f2002275 commited on
Commit
071ba6b
·
0 Parent(s):

clean repo without secrets or data

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .claude/settings.local.json +52 -0
  2. .dockerignore +16 -0
  3. .gitignore +38 -0
  4. .planning/PHASE_0_EXIT_GATE.md +101 -0
  5. .planning/PROJECT.md +132 -0
  6. .planning/REQUIREMENTS.md +238 -0
  7. .planning/ROADMAP.md +142 -0
  8. .planning/SANDBOX_CERT.md +128 -0
  9. .planning/STATE.md +91 -0
  10. .planning/config.json +39 -0
  11. .planning/phases/00-thu-evening-foundation/00-01-packaging-scaffold-PLAN.md +406 -0
  12. .planning/phases/00-thu-evening-foundation/00-01-packaging-scaffold-SUMMARY.md +270 -0
  13. .planning/phases/00-thu-evening-foundation/00-02-hardened-repl-PLAN.md +689 -0
  14. .planning/phases/00-thu-evening-foundation/00-02-hardened-repl-SUMMARY.md +107 -0
  15. .planning/phases/00-thu-evening-foundation/00-03-adversarial-sandbox-suite-PLAN.md +480 -0
  16. .planning/phases/00-thu-evening-foundation/00-03-adversarial-sandbox-suite-SUMMARY.md +271 -0
  17. .planning/phases/00-thu-evening-foundation/00-04-env-core-PLAN.md +1006 -0
  18. .planning/phases/00-thu-evening-foundation/00-04-env-core-SUMMARY.md +88 -0
  19. .planning/phases/00-thu-evening-foundation/00-05-env-tests-exit-gate-PLAN.md +628 -0
  20. .planning/phases/00-thu-evening-foundation/00-05-env-tests-exit-gate-SUMMARY.md +87 -0
  21. .planning/phases/00-thu-evening-foundation/00-CONTEXT.md +127 -0
  22. .planning/phases/00-thu-evening-foundation/00-DISCUSSION-LOG.md +175 -0
  23. .planning/phases/01-fri-parallel-build/01-01-PLAN.md +285 -0
  24. .planning/phases/01-fri-parallel-build/01-02-PLAN.md +296 -0
  25. .planning/phases/01-fri-parallel-build/01-03-PLAN.md +301 -0
  26. .planning/phases/01-fri-parallel-build/01-04-PLAN.md +402 -0
  27. .planning/phases/01-fri-parallel-build/01-05-PLAN.md +351 -0
  28. .planning/phases/01-fri-parallel-build/01-06-PLAN.md +416 -0
  29. .planning/phases/01-fri-parallel-build/01-07-PLAN.md +409 -0
  30. .planning/research/ARCHITECTURE.md +637 -0
  31. .planning/research/FEATURES.md +335 -0
  32. .planning/research/PITFALLS.md +489 -0
  33. .planning/research/STACK.md +845 -0
  34. .planning/research/SUMMARY.md +178 -0
  35. .python-version +1 -0
  36. CLAUDE.md +285 -0
  37. Dockerfile +42 -0
  38. README.md +16 -0
  39. REWARD_AUDIT.md +122 -0
  40. configs/config.yaml +12 -0
  41. configs/data/v1.yaml +8 -0
  42. configs/model/qwen_0_5b_smoke.yaml +7 -0
  43. configs/model/qwen_1_5b.yaml +7 -0
  44. configs/reward/v1.yaml +8 -0
  45. configs/train/grpo.yaml +12 -0
  46. configs/train/sft.yaml +8 -0
  47. data/__init__.py +1 -0
  48. data/generate.py +466 -0
  49. data/seeds.json +5 -0
  50. env/__init__.py +20 -0
.claude/settings.local.json ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(node __TRACKED_VAR__/.claude/get-shit-done/bin/gsd-tools.cjs commit 'docs: initialize FATHOM project' --files .planning/PROJECT.md)",
5
+ "Bash(node __TRACKED_VAR__/.claude/get-shit-done/bin/gsd-tools.cjs config-new-project '{\"mode\":\"yolo\",\"granularity\":\"coarse\",\"parallelization\":true,\"commit_docs\":true,\"model_profile\":\"quality\",\"workflow\":{\"research\":true,\"plan_check\":true,\"verifier\":true,\"nyquist_validation\":false}}')",
6
+ "Bash(node __TRACKED_VAR__/.claude/get-shit-done/bin/gsd-tools.cjs commit 'chore: add project config' --files .planning/config.json)",
7
+ "Bash(node __TRACKED_VAR__/.claude/get-shit-done/bin/gsd-tools.cjs init new-project)",
8
+ "Bash(node __TRACKED_VAR__/.claude/get-shit-done/bin/gsd-tools.cjs agent-skills gsd-project-researcher)",
9
+ "Bash(node __TRACKED_VAR__/.claude/get-shit-done/bin/gsd-tools.cjs agent-skills gsd-research-synthesizer)",
10
+ "WebSearch",
11
+ "WebFetch(domain:github.com)",
12
+ "WebFetch(domain:unsloth.ai)",
13
+ "WebFetch(domain:docs.unsloth.ai)",
14
+ "WebFetch(domain:colab.research.google.com)",
15
+ "WebFetch(domain:raw.githubusercontent.com)",
16
+ "WebFetch(domain:deepwiki.com)",
17
+ "WebFetch(domain:pypi.org)",
18
+ "WebFetch(domain:restrictedpython.readthedocs.io)",
19
+ "Bash(node __TRACKED_VAR__/.claude/get-shit-done/bin/gsd-tools.cjs commit 'docs: synthesize project research' --files .planning/research/SUMMARY.md .planning/research/STACK.md .planning/research/FEATURES.md .planning/research/ARCHITECTURE.md .planning/research/PITFALLS.md)",
20
+ "Bash(node __TRACKED_VAR__/.claude/get-shit-done/bin/gsd-tools.cjs commit 'docs: update PROJECT.md with post-research decisions \\(cut 3B, budget SFT, Streamlit\\)' --files .planning/PROJECT.md)",
21
+ "Bash(node __TRACKED_VAR__/.claude/get-shit-done/bin/gsd-tools.cjs commit 'docs: define v1 requirements \\(56 reqs across 8 categories\\)' --files .planning/REQUIREMENTS.md)",
22
+ "Bash(node __TRACKED_VAR__/.claude/get-shit-done/bin/gsd-tools.cjs generate-claude-md --output CLAUDE.md)",
23
+ "Bash(node __TRACKED_VAR__/.claude/get-shit-done/bin/gsd-tools.cjs commit 'docs: create roadmap \\(4 phases, 57/57 reqs mapped\\)' --files .planning/ROADMAP.md .planning/STATE.md .planning/REQUIREMENTS.md CLAUDE.md)",
24
+ "Bash(node __TRACKED_VAR__/.claude/get-shit-done/bin/gsd-tools.cjs commit 'docs\\(00\\): capture phase context' --files .planning/phases/00-thu-evening-foundation/00-CONTEXT.md .planning/phases/00-thu-evening-foundation/00-DISCUSSION-LOG.md)",
25
+ "Bash(node __TRACKED_VAR__/.claude/get-shit-done/bin/gsd-tools.cjs state record-session --stopped-at 'Phase 0 context gathered' --resume-file .planning/phases/00-thu-evening-foundation/00-CONTEXT.md)",
26
+ "Bash(node __TRACKED_VAR__/.claude/get-shit-done/bin/gsd-tools.cjs commit 'docs\\(state\\): record phase 0 context session' --files .planning/STATE.md)",
27
+ "Bash(node *)",
28
+ "Bash(python -c \"import sys; print\\(sys.executable\\)\")",
29
+ "Bash(.venv-phase0-verify/Scripts/python.exe -c \"from RestrictedPython import compile_restricted; print\\('ok'\\)\")",
30
+ "Bash(git add *)",
31
+ "Bash(git commit -m ' *)",
32
+ "Bash(git commit *)",
33
+ "Bash(.venv-phase0-verify/Scripts/python.exe *)",
34
+ "Bash(uv --version)",
35
+ "Bash(VIRTUAL_ENV=.venv-phase0-verify uv pip install -e \".[dev]\")",
36
+ "Bash(tee /tmp/sandbox_pytest_cert.log)",
37
+ "Bash(python -c ' *)",
38
+ "Bash(pip install *)",
39
+ "Bash(where python3.11)",
40
+ "Bash(where py *)",
41
+ "Bash(py -3.11 --version)",
42
+ "Bash(py -3.12 --version)",
43
+ "Bash(python -m pytest tests/test_env.py -v --tb=short)",
44
+ "Bash(python -m pytest tests/test_repl_sandbox.py -v --tb=short)",
45
+ "Bash(python -m pytest tests/test_rewards.py tests/test_dataset.py tests/test_model_load.py tests/test_sft_scaffold.py tests/test_grpo_scaffold.py -q --tb=short)",
46
+ "Bash(python -m pytest tests/test_rewards.py -m reward_audit -v --tb=short)",
47
+ "Bash(python *)",
48
+ "Bash(sed -n '21,60p' train/sft.py)",
49
+ "Bash(sed -n '26,70p' train/grpo.py)"
50
+ ]
51
+ }
52
+ }
.dockerignore ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git/
2
+ .venv/
3
+ .venv-*/
4
+ fathom/
5
+ outputs/
6
+ .planning/
7
+ .claude/
8
+ tests/
9
+ __pycache__/
10
+ *.pyc
11
+ .pytest_cache/
12
+ .python-version
13
+ research/
14
+ dist/
15
+ build/
16
+ *.egg-info/
.gitignore ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ *.pyd
6
+ *.egg-info/
7
+ .pytest_cache/
8
+
9
+ # Virtualenvs (local)
10
+ .venv/
11
+ .venv-*/
12
+ fathom/
13
+ venv/
14
+ env-venv/
15
+
16
+ # Build / dist artifacts
17
+ dist/
18
+ build/
19
+
20
+ # Editor / OS
21
+ .DS_Store
22
+ Thumbs.db
23
+ *.swp
24
+
25
+ # Secrets
26
+ .env
27
+ .env.local
28
+
29
+ # Training/run outputs
30
+ outputs/
31
+ wandb/
32
+
33
+ # Phase 0 REPL ephemeral episode dirs
34
+ /tmp/episode-*/
35
+ unsloth_compiled_cache/
36
+ data/train.jsonl
37
+ data/eval.jsonl
38
+ data/sft_traces.jsonl
.planning/PHASE_0_EXIT_GATE.md ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Phase 0 — Exit Gate Status
2
+
3
+ **Status:** GREEN
4
+ **Timestamp (UTC):** 2026-04-24T12:45:00Z
5
+ **Git commit:** f00043e2aac2ca3da6abe3446528ab941c56a467
6
+ **Branch:** master
7
+ **Platform:** win32 (Windows 11)
8
+ **Python:** 3.10.11 (env tests) / 3.11.13 (sandbox cert venv)
9
+
10
+ Phase 0 Exit Gate is the conjunction of three conditions from ROADMAP:
11
+ 1. Adversarial sandbox suite green (SANDBOX_CERT.md)
12
+ 2. Env HTTP roundtrip green (tests/test_env.py)
13
+ 3. Dockerfile builds locally (from Plan 01 verification)
14
+
15
+ If any of these is RED, Phase 1 is BLOCKED per ROADMAP Phase 0 Exit Gate.
16
+
17
+ ## 1. Sandbox Suite — GREEN
18
+
19
+ Reference: `.planning/SANDBOX_CERT.md` (committed at e55825f9a835c4c9a9df081c3a91481bee94a3de).
20
+ Status line: `PASSED — all 7 named adversarial attacks CONTAINED.`
21
+
22
+ Tests:
23
+ - test_attribute_access_escape: CONTAINED
24
+ - test_builtins_monkeypatch: CONTAINED
25
+ - test_eval_via_reflection: CONTAINED
26
+ - test_network_egress: CONTAINED
27
+ - test_file_write: CONTAINED
28
+ - test_os_system: CONTAINED
29
+ - test_subprocess_of_subprocess: CONTAINED
30
+ - test_traceback_walk: CONTAINED
31
+ - test_infinite_loop: CONTAINED (timeout fires; D-03 preserves globals)
32
+ - test_memory_bomb: SKIPPED on Windows (RLIMIT_AS unavailable — re-run at venue on Linux)
33
+
34
+ Suite result: **9 passed, 1 skipped** (Windows dev laptop; all 10 expected green on Linux A100)
35
+
36
+ ## 2. Env HTTP Tests — GREEN
37
+
38
+ Command: `pytest tests/test_env.py tests/test_repl_sandbox.py -v --tb=short`
39
+ Result: **24 passed, 1 skipped** (test_memory_bomb skipped on Windows)
40
+
41
+ Named Roadmap literals verified:
42
+ - `pytest tests/test_env.py::test_gold_answer_never_in_observation` → PASS (ENV-08)
43
+ - test_termination_answer → PASS (ENV-07a)
44
+ - test_termination_max_steps → PASS (ENV-07b)
45
+ - test_termination_max_tokens → PASS (ENV-07c)
46
+ - test_termination_walltime → PASS (ENV-07d)
47
+ - test_live_uvicorn_roundtrip → PASS (ENV-01..03 over real HTTP)
48
+
49
+ Full pytest output (test_env.py):
50
+ ```
51
+ ============================= test session starts =============================
52
+ platform win32 -- Python 3.10.11, pytest-9.0.2, pluggy-1.6.0
53
+ rootdir: C:\Users\prath\OneDrive\Desktop\Hackathons\Meta_finale
54
+ configfile: pyproject.toml
55
+
56
+ tests/test_env.py::test_healthz_returns_ok PASSED [ 6%]
57
+ tests/test_env.py::test_reset_returns_observation_no_gold_field PASSED [ 13%]
58
+ tests/test_env.py::test_step_roundtrip_print_1_plus_1 PASSED [ 20%]
59
+ tests/test_env.py::test_gold_answer_never_in_observation PASSED [ 26%]
60
+ tests/test_env.py::test_state_endpoint_sanitized PASSED [ 33%]
61
+ tests/test_env.py::test_termination_answer PASSED [ 40%]
62
+ tests/test_env.py::test_termination_max_steps PASSED [ 46%]
63
+ tests/test_env.py::test_termination_max_tokens PASSED [ 53%]
64
+ tests/test_env.py::test_termination_walltime PASSED [ 60%]
65
+ tests/test_env.py::test_malformed_code_returns_error_observation_done_false PASSED [ 66%]
66
+ tests/test_env.py::test_malformed_syntax_returns_error_observation PASSED [ 73%]
67
+ tests/test_env.py::test_difficulty_mapping_medium_loads_multi_needle_row PASSED [ 80%]
68
+ tests/test_env.py::test_difficulty_mapping_hard_loads_counting_row PASSED [ 86%]
69
+ tests/test_env.py::test_reset_by_task_id PASSED [ 93%]
70
+ tests/test_env.py::test_live_uvicorn_roundtrip PASSED [100%]
71
+
72
+ ============================= 15 passed in 7.26s ==============================
73
+ ```
74
+
75
+ ## 3. Dockerfile Build — NOT_EXERCISED
76
+
77
+ Docker Desktop not installed on dev laptop. See `.planning/phases/00-thu-evening-foundation/00-01-packaging-scaffold-SUMMARY.md` for prior verification evidence (Plan 01 produced the Dockerfile from `openenv init` scaffold; dependency pinning confirmed via `uv pip install -e .` in `.venv-phase0-verify`).
78
+
79
+ **Action for venue (Phase 1, first 10 min):** Run `docker build -t fathom-env:phase0 .` on the A100 Linux box as part of the STACK §11 G-series gates. This is the only un-exercised gate item.
80
+
81
+ ## Phase-0 Requirement Coverage
82
+
83
+ | REQ-ID | Verified by |
84
+ |--------|-------------|
85
+ | ENV-01 | test_healthz_returns_ok, test_reset_returns_observation_no_gold_field, test_step_roundtrip_print_1_plus_1, test_live_uvicorn_roundtrip |
86
+ | ENV-02 | Pydantic schemas in env/models.py; every test imports and validates FathomObservation / FathomStepResult |
87
+ | ENV-03 | test_difficulty_mapping_medium_loads_multi_needle_row, test_difficulty_mapping_hard_loads_counting_row, test_reset_by_task_id |
88
+ | ENV-04 | tests/test_repl_sandbox.py (all 7 named attacks + 2 bonus) |
89
+ | ENV-05 | env/server/llm_primitive.py make_llm_primitive + depth-cap assertion in Plan 04 self-checks |
90
+ | ENV-06 | test_malformed_code_returns_error_observation_done_false, test_malformed_syntax_returns_error_observation |
91
+ | ENV-07 | test_termination_answer (a), test_termination_max_steps (b), test_termination_max_tokens (c), test_termination_walltime (d) |
92
+ | ENV-08 | test_gold_answer_never_in_observation, test_state_endpoint_sanitized |
93
+ | ENV-09 | tests/test_repl_sandbox.py 7-attack suite + SANDBOX_CERT.md |
94
+ | REP-01 | pyproject.toml version pins + uv install verified in .venv-phase0-verify (Plan 01 SUMMARY) |
95
+ | REP-02 | Dockerfile from openenv init (Plan 01 SUMMARY) — Docker build NOT_EXERCISED locally; venue re-verify required |
96
+
97
+ ## Next Step
98
+
99
+ STATUS == GREEN → Proceed to Phase 1 — `/gsd-plan-phase 1`. Hardik joins Friday.
100
+
101
+ **One remaining action before Phase 1 plans are written:** Update ROADMAP.md to add 6 hackathon-criteria items (Colab notebook, committed PNG plots, public HF Space curl check, README with embedded PNGs, BLOG.md, openenv validate task) identified in the pre-Phase-1 alignment review.
.planning/PROJECT.md ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FATHOM — First RL-Trained Recursive Language Model
2
+
3
+ ## What This Is
4
+
5
+ FATHOM is an OpenEnv environment + GRPO training pipeline that teaches a small open-source language model (Qwen2.5-Coder-1.5B-Instruct, 4-bit + LoRA) to *use* a Recursive Language Model (RLM) scaffold well: slice long contexts with Python, grep for relevant regions, delegate to sub-LM calls only when needed, and answer questions about documents that are 50× larger than its own context window. It is our submission to the Meta × PyTorch × Hugging Face OpenEnv Hackathon Grand Finale (Scaler School of Technology, Bangalore, April 25–26, 2026).
6
+
7
+ ## Core Value
8
+
9
+ A 1.5B model trained on our environment solves 200K-token QA tasks its own 4K context cannot hold — demonstrated with a clean reward curve, a selective recursion-tree visualization, and a Pareto frontier of accuracy-vs-tokens. If only one thing ships, it is this single demo.
10
+
11
+ ## Requirements
12
+
13
+ ### Validated
14
+
15
+ (None yet — ship to validate)
16
+
17
+ ### Active
18
+
19
+ - [ ] **ENV-01** — OpenEnv-compliant FATHOM environment scaffolded via `openenv init` (FastAPI, Pydantic action/observation dataclasses, reset/step/state primitives)
20
+ - [ ] **ENV-02** — Environment exposes a restricted Python REPL and an `llm(prompt, chunk, depth)` primitive with a depth counter (supports arbitrary recursion depth architecturally; trains at depth ≤ 2)
21
+ - [ ] **ENV-03** — Sub-LM calls return plain text only; only the root LM writes Python (kills nested code-execution failures)
22
+ - [ ] **ENV-04** — Environment deployed to a Hugging Face Space before any training begins (shakes out packaging/container issues early)
23
+ - [ ] **ENV-05** — Episode timeouts, sandboxed execution, whitelisted imports, and per-episode token-budget guards in place
24
+ - [ ] **ENV-06** — Curriculum support: `reset(difficulty=...)` parameter so training can ramp from easy-short-context to hard-long-context
25
+ - [ ] **DATA-01** — Synthetic task generator producing 1000 train + 200 eval examples across 4 task types: needle-in-haystack (40%), multi-needle aggregation (30%), synthesized-doc extractive QA (20%), counting/structural (10%)
26
+ - [ ] **DATA-02** — All tasks have deterministic gold answers (exact-match string or number) — no task requires an LLM judge
27
+ - [ ] **DATA-03** — Dataset spans context lengths 4K→200K with difficulty metadata (for curriculum)
28
+ - [ ] **REWARD-01** — Deterministic composable rubric: `Sequential(Gate(FormatCheck), WeightedSum([CorrectnessScore×0.75, TokenBudgetShaping×0.20, RecursionEfficiencyBonus×0.05]))`
29
+ - [ ] **REWARD-02** — Multiple independent reward columns logged separately (format, correctness, tokens, depth) — not just composite
30
+ - [ ] **REWARD-03** — Adversarial reward audit completed before any training run: manually crafted attempts to fool the verifier, fixed any loopholes found
31
+ - [ ] **REWARD-04** — Anti-hacking audits automated and scheduled: masked-context leakage test, format-only baseline, length-exploit check, human spot-check on 20 trajectories per training run
32
+ - [ ] **REWARD-05** — Token-budget shaping parameterized by α — same trained checkpoint evaluated at multiple α values at inference time to trace the Pareto frontier (Mercor sub-prize hook)
33
+ - [ ] **TRAIN-01** — SFT warm-start: ~200 trajectories generated via Claude Haiku on easy tasks ($15-25 budget), used to prime format + scaffold usage before GRPO. Fallback: supplement with ~300 programmatically-generated trivial trajectories (grep-then-answer templates) if Haiku traces underprime the base.
34
+ - [ ] **TRAIN-02** — GRPO training pipeline using TRL 1.2.0 + Unsloth 2026.4.8, LoRA adapters, ≥400 optimization steps, 16K training context (generalization claim to 200K at eval), vLLM colocate rollouts
35
+ - [ ] **TRAIN-03** — Pre-event smoke test: Qwen2.5-Coder-0.5B end-to-end run on laptop RTX 4060, proves pipeline compiles + SFT checkpoint loads + reward flows correctly. MUST exercise multi-turn rollout (catches TRL #4543 locally)
36
+ - [ ] **TRAIN-04** — Venue run: 1.5B target with a hard stop at hour 10 — if reward curve is flat, patch reward + restart from SFT checkpoint; do not let bad run eat full budget
37
+ - [ ] **TRAIN-05** — Untrained-baseline measurement on the eval set before training (target 25–50% on easiest tier — leaves room for visible gains, provides non-zero gradient). Pre-event: HF Inference API spot-check on 20 questions.
38
+ - [ ] **TRAIN-06** — LoRA save path: adapter-only save every 50 steps pushed to HF Hub (insurance); final `save_pretrained_merged` with `save_method="merged_16bit"` at end + 3-question sanity check BEFORE declaring training done. NEVER `merged_4bit` or `merged_4bit_forced`.
39
+ - [ ] **TRAIN-07** — Curriculum ramp: Phase 1 GRPO trains on `difficulty ∈ {easy, medium}`; Phase 2 expands to `{easy, medium, hard}` only if reward climbs on easy tier first
40
+ - [ ] **MON-01** — Per-step logging to Weights & Biases (or TRL default) of composite reward, component rewards, verifier pass rate, timeout rate, rollout length, answer-length distribution
41
+ - [ ] **MON-02** — Sample 5–10 full trajectories every N steps and render them human-readable for spot inspection
42
+ - [ ] **DEMO-01** — Recursion-tree visualizer (D3/streamlit) showing a single 200K-token question solved before training vs after — selective branching visible
43
+ - [ ] **DEMO-02** — Pareto frontier chart: accuracy vs tokens, curves for three α values from the same checkpoint, untrained baseline plotted as strictly-dominated point
44
+ - [ ] **DEMO-03** — Three-panel before/after comparison: untrained trace (dumps 18K context, hallucinates), trained trace (greps → 3 targeted sub-calls, correct answer)
45
+ - [ ] **DEMO-04** — Public GitHub repo with README, reproducibility instructions, environment code, training config, and evaluation scripts
46
+ - [ ] **DEMO-05** — HF Space deployed with interactive inference UI (Streamlit default; 30-min Thu bake-off against Gradio to confirm cleaner D3 tree renderer) — judges can type a long document + question and watch the recursion tree render live
47
+ - [ ] **PITCH-01** — 3-minute pitch script: problem-first open ("models stop reading past 30K tokens"), RLM introduced ~60s in as mechanism, ends on the Pareto chart
48
+ - [ ] **PITCH-02** — Pre-baked Q&A for "how is this different from RAG?", "why not bigger model?", "is this just prompting?", "how do you prevent reward hacking?" — each answer ≤ 20 seconds
49
+ - [ ] **PITCH-03** — Mercor sub-prize positioning: one slide dedicated to the Pareto frontier and the token-budget-aware reward shaping argument
50
+
51
+ ### Out of Scope
52
+
53
+ - Learned reward models / LLM-as-judge in the training loop — adds noise, circularity, and judge distrust; deterministic verifiers win.
54
+ - Depth > 2 during training — credit assignment through nested rollouts is unsolved for 1.5B models at this budget; we demo depth-3 at inference only.
55
+ - Recursive sub-LM code execution (sub-LMs writing Python themselves) — quadruples failure surface for ~15% more visual impressiveness; depth-N with root-only code execution is the paper's "hybrid RLM" and is sufficient.
56
+ - Training context > 16K — rollout latency explodes; 16K train / 200K eval is the generalization claim, which is the paper's thesis.
57
+ - 3B base model stretch — CUT after research synthesis. Venue A100 flavor unknown (40GB vs 80GB), 3B+LoRA+16K+8-gen ≈ 38–45 GB fits only on 80GB. Focus the 40-hour budget on 1.5B polish (curriculum, tree viz, Pareto frontier) rather than chasing a larger base. 1.5B-with-clean-curve beats 3B-with-partial-curve on judging.
58
+ - Real-world document datasets at training time (OOLONG, BrowseComp, large open corpora) — reserved for eval showcases. Training = synthetic, deterministic, audit-able.
59
+ - Multi-turn human-in-the-loop evaluation — breaks determinism and eats time budget.
60
+ - A polished web frontend beyond the HF Space default — Streamlit/Gradio is enough; judges don't score CSS.
61
+ - Other themes from the hackathon brief (Multi-Agent, World Modeling, Self-Improving) — Theme 2 (Long-Horizon Planning) is our bullseye and adding theme-hopping dilutes the pitch.
62
+ - PPO, DPO, or reward-model-based RL — GRPO is the canonical choice and has the most public reproductions to copy from.
63
+
64
+ ## Context
65
+
66
+ **Hackathon:** Meta × PyTorch × Hugging Face OpenEnv Grand Finale. 48-hour on-site sprint, April 25–26, 2026, Scaler School of Technology, Bangalore. Meta engineers judge; sponsors (Mercor, Patronus, Scale) run sub-prize tracks. Prizes include $10K/$4.55K/$2K tiers plus interview opportunities at Meta & Hugging Face AI teams.
67
+
68
+ **Team:** 2 members. Hardik Jha (team leader) and Pratham Bhardwaj. Qualified through Round 1 (submitted a Mini-RL environment).
69
+
70
+ **Themes published for Round 2:** Multi-Agent Interactions, Long-Horizon Planning & Instruction Following (our target), World Modeling, Self-Improving Agents.
71
+
72
+ **Why RLM:** The Recursive Language Models paper (Zhang/Kraska/Khattab, MIT CSAIL, arXiv 2512.24601, Oct 2025) ends with "We are excited to see future work that explicitly trains models to reason as RLMs." Prime Intellect's January 2026 blog ("Recursive Language Models: the paradigm of 2026") says "we believe that teaching models to manage their own context end-to-end through reinforcement learning will be the next major breakthrough" and shipped an experimental `verifiers.RLMEnv` with the explicit note that they have not trained it. The two groups most positioned to plant this flag have publicly teed it up and neither has shipped. The window is open; the hackathon is the forcing function.
73
+
74
+ **Why us:** Pratham's BTP work (RLM-CodeLens, DSPy + FAISS + Claude for recursive codebase Q&A) built direct muscle memory for this paradigm. Omar Khattab (DSPy creator) is a coauthor on the RLM paper. Round 1 experience covers the OpenEnv scaffolding and reward-curve-reading skills the venue will demand.
75
+
76
+ **Pre-event timeline:** Today is Thursday April 23. Finale opens Saturday April 25 morning. Approximately 48 hours of prep runway (Thu evening + all Fri) before we leave for Bangalore. Work at venue is 48 hours. Total window: ~96 hours end-to-end, of which ~40 usable for direct building.
77
+
78
+ **Compute:** Laptop RTX 4060 (8 GB VRAM) pre-event — insufficient for 1.5B GRPO. Pre-event work is CPU-dominated: scaffolding, dataset generation, verifier tests, pitch assets, and a 0.5B SFT smoke test. Venue provides A100 compute credits for the training runs that matter.
79
+
80
+ **Scaffolding to fork:** `alexzhang13/rlm-minimal` (reference implementation with recursion-tree visualizer). `openenv/verifiers` experimental `RLMEnv` (OpenEnv-compatible skeleton from Prime Intellect). `openenv init` CLI for canonical env bootstrap. Unsloth's Advanced Qwen3 (4B) GRPO notebook for reward-shaping patterns.
81
+
82
+ **Claude Code role:** Full copilot throughout — pre-event scaffolding, venue coding, debug triage, pitch drafting, reward-curve interpretation assistance. Human calls: reward-curve diagnosis, kill-the-run decisions, whether to stretch to 3B, final pitch delivery.
83
+
84
+ ## Constraints
85
+
86
+ - **Timeline**: ~40 usable hours of building across 4 days — every phase must produce a committed, demonstrable artifact before moving on, because there is no slack for context-lost rebuilds.
87
+ - **Tech stack**: TRL (trainer) + Unsloth (efficiency + inference) + OpenEnv (environment standard) + Hugging Face Spaces (deployment). Dictated by hackathon rubric — deviating costs "judge goodwill" points.
88
+ - **Algorithm**: GRPO specifically (not PPO/DPO) — hackathon rubric names GRPO as canonical, public reproductions exist to crib from, TRL `GRPOTrainer` is the first-class path.
89
+ - **Base model**: Qwen2.5-Coder-1.5B-Instruct (4-bit via `unsloth/Qwen2.5-Coder-1.5B-Instruct-bnb-4bit`). 3B cut post-research-synthesis — venue A100 flavor unknown, 3B would eat the 40-hour budget for uncertain upside. 1.5B with clean curve beats 3B with partial curve on judging.
90
+ - **Pre-event hardware**: Laptop RTX 4060, 8 GB VRAM. Can only run CPU/0.5B smoke tests locally — 1.5B+ runs are venue-only.
91
+ - **Reward verifier must be deterministic**: No LLM-as-judge in the training loop. Every task has an exact-match or short-span gold answer.
92
+ - **Context window**: Train at 16 K, claim generalization to 200 K at eval. Rollout latency at 200 K is prohibitive for GRPO.
93
+ - **Recursion depth in training**: ≤ 2. Deeper recursion is demo-only at inference time.
94
+ - **Judging criteria (hard)**: Submissions are scored on clear env design + objective rewards + demonstrable improvement + anti-reward-hacking + reproducible deployment + sharp demo. Every phase must produce an artifact mapping to at least one criterion.
95
+ - **Pitch**: 3 minutes, 30% of judging weight goes to storytelling. The Pareto chart and recursion-tree viz must exist before the last 4 hours, not after.
96
+
97
+ ## Key Decisions
98
+
99
+ | Decision | Rationale | Outcome |
100
+ |----------|-----------|---------|
101
+ | Target Theme 2 (Long-Horizon Planning) over other themes | RLM is literally built for "beyond context memory limits" — theme language maps verbatim | — Pending |
102
+ | Use Qwen2.5-Coder-1.5B-Instruct (bnb-4bit) as base, cut 3B | Multiple public multi-turn GRPO reproductions; fits A100-40GB; 3B adds risk without guaranteed upside given unknown venue flavor | — Pending |
103
+ | Train at recursion depth ≤ 2, demo at depth 3 | Depth-1 loses storytelling; unbounded depth loses credit-assignment; depth-2 with root-only code is the paper's "hybrid" sweet spot | — Pending |
104
+ | Deterministic verifier only, no LLM-as-judge | Reward noise + judge gameability + judge distrust — three strikes; dataset is engineered so string-match suffices | — Pending |
105
+ | Token-budget shaping parameterized by α (same checkpoint, eval at multiple α) | Directly produces the Pareto frontier chart for the Mercor sub-prize without needing separate training runs | — Pending |
106
+ | Stack: TRL + Unsloth + OpenEnv + HF Spaces | Canonical hackathon stack; Unsloth is explicitly recommended for RL-inference efficiency | — Pending |
107
+ | Synthetic, deterministic training data (real docs reserved for eval) | Enables exact-match verifier, prevents reward leakage, keeps audits clean | — Pending |
108
+ | Deploy HF Space on Day 1 (before GRPO training) | Catches Docker/dependency/auth issues pre-training when fixing is cheap | — Pending |
109
+ | 200 SFT traces via Claude Haiku ($15-25 budget), +300 template-generated trivial traces as fallback | User budget call; R1 (untrained=0%) mitigated by curriculum + template fallback rather than premium SFT | — Pending |
110
+ | Streamlit for demo UI (30-min Thu bake-off against Gradio) | Research flags Streamlit for cleaner D3 embed; low-cost hedge by prototyping both | — Pending |
111
+ | Pitch opens problem-first, not technique-first | Niche concept is less of a problem when "RLM" is a mechanism-detail, not a prerequisite concept | — Pending |
112
+ | Claude Code as full copilot (prep + venue + pitch) | User decision — plan optimizes for clear acceptance criteria, deterministic tests, and runnable scripts at every boundary | — Pending |
113
+
114
+ ## Evolution
115
+
116
+ This document evolves at phase transitions and milestone boundaries.
117
+
118
+ **After each phase transition** (via `/gsd-transition`):
119
+ 1. Requirements invalidated? → Move to Out of Scope with reason
120
+ 2. Requirements validated? → Move to Validated with phase reference
121
+ 3. New requirements emerged? → Add to Active
122
+ 4. Decisions to log? → Add to Key Decisions
123
+ 5. "What This Is" still accurate? → Update if drifted
124
+
125
+ **After each milestone** (via `/gsd-complete-milestone`):
126
+ 1. Full review of all sections
127
+ 2. Core Value check — still the right priority?
128
+ 3. Audit Out of Scope — reasons still valid?
129
+ 4. Update Context with current state
130
+
131
+ ---
132
+ *Last updated: 2026-04-23 after research synthesis + 3 project decisions (cut 3B, budget SFT, Streamlit UI with Thu bake-off)*
.planning/REQUIREMENTS.md ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Requirements: FATHOM
2
+
3
+ **Defined:** 2026-04-23
4
+ **Core Value:** A 1.5B model trained on our environment solves 200K-token QA tasks its own 4K context cannot hold — demonstrated with a clean reward curve, a selective recursion-tree visualization, and a Pareto frontier of accuracy-vs-tokens.
5
+
6
+ **Target theme:** Long-Horizon Planning & Instruction Following (Theme 2). **Sub-prize:** Mercor (token-budget-aware rewards).
7
+
8
+ **Key derivation principle:** Every v1 requirement below maps to at least one of the six explicit hackathon judging criteria: (1) clear environment design, (2) objective/verifiable rewards, (3) demonstrable improvement, (4) anti-reward-hacking, (5) reproducible deployment, (6) sharp demo. Anything that doesn't serve at least one criterion belongs in v2 or out of scope.
9
+
10
+ ---
11
+
12
+ ## v1 Requirements
13
+
14
+ ### Environment (ENV)
15
+
16
+ Judging criteria served: (1) clear env design, (4) anti-reward-hacking, (5) reproducible deployment.
17
+
18
+ - [ ] **ENV-01** — Environment scaffolded via `openenv init` — OpenEnv-compliant FastAPI server + HTTP client + Dockerfile + `openenv.yaml`. No hand-rolled structure.
19
+ - [ ] **ENV-02** — Pydantic typed dataclasses: `Action` (tool_name + args), `Observation` (context_snippet + tool_output + episode_metadata), `State` (episode_id, step_count, tokens_used_total, recursion_depth_current), `StepResult` (observation + reward + done + info).
20
+ - [ ] **ENV-03** — `reset(seed, difficulty, task_id)` — seed for reproducibility; difficulty ∈ {trivial, easy, medium, hard} drives curriculum; explicit task_id for eval reruns.
21
+ - [x] **ENV-04** — Python REPL tool: model emits code string → env executes in RestrictedPython AST-filtered subprocess with 30s timeout, 512MB RLIMIT, no network, no file writes, no `open`/`exec`/`eval`/`__import__` of unsafe modules. Exposes `ctx` variable (the long context) + `llm(prompt, chunk)` primitive + standard Python stdlib minus dangerous modules.
22
+ - [ ] **ENV-05** — `llm(prompt, chunk, depth=current+1)` primitive — sub-LM calls return plain text only. Hard depth cap via counter in env state (rejects calls beyond max_depth). Sub-LMs cannot call tools; only root LM writes Python. Depth max defaults to 2 at training, configurable to 3 at eval.
23
+ - [ ] **ENV-06** — Action validation: malformed Python / tool-call rejected with structured error observation (not silent failure). Episode continues with error visible to model so it can recover.
24
+ - [ ] **ENV-07** — Episode termination: (a) agent emits `<answer>…</answer>`, (b) step count exceeds `max_steps=20`, (c) cumulative tokens exceed `max_tokens=100000`, (d) walltime > 120 s. Explicit termination reason returned in `StepResult.info`.
25
+ - [ ] **ENV-08** — State sealing: `state.gold_answer` MUST NOT appear in any observation field — unit-test enforces this. No module-level caches reachable from tool code between episodes.
26
+ - [x] **ENV-09** — Sandbox adversarial test suite: 7 named attacks (attribute-access escape, monkey-patching `__builtins__`, `eval` via reflection, network egress, file write, `os.system`, subprocess-of-subprocess) — all must fail-to-escape. Gate: env does not push to HF Space until this passes.
27
+ - [ ] **ENV-10** — Deployed to HuggingFace Space via `openenv push` (or manual `git push` fallback) BEFORE any 1.5B training begins. Client can `ping` remote Space successfully; full `reset`/`step` roundtrip verified remotely.
28
+
29
+ ### Data (DATA)
30
+
31
+ Judging criteria served: (1) clear env design, (2) objective rewards, (3) demonstrable improvement.
32
+
33
+ - [ ] **DATA-01** — Synthetic task generator produces 4 task types with deterministic gold answers: needle-in-haystack (40%, single-fact extraction), multi-needle aggregation (30%, sum/concat of k facts), synthesized-doc extractive QA (20%, short-span exact match), counting/structural (10%, integer answer).
34
+ - [ ] **DATA-02** — 1000 training examples + 200 eval examples produced. Train/eval disjoint. Seed-controlled generation (`data/seeds.json` in repo).
35
+ - [ ] **DATA-03** — Difficulty metadata per example: context length {4K, 16K, 64K, 200K}, needle position {start, middle, end}, task-type. Difficulty labels {trivial, easy, medium, hard} computed from these.
36
+ - [ ] **DATA-04** — At least 5% of training set is "trivial" tier (needle at position 0–4K, answer in first sentence, no recursion needed). Ensures untrained-baseline success-probability > 0 on at least one tier.
37
+ - [ ] **DATA-05** — Dataset stored as JSONL in `data/train.jsonl` and `data/eval.jsonl`. Each line: `{task_id, task_type, difficulty, context_length, prompt, context, gold_answer, seed}`.
38
+ - [ ] **DATA-06** — 500 SFT warm-start trajectories in `data/sft_traces.jsonl`: ~200 Claude Haiku-generated ($15-25 budget) + ~300 template-generated trivial traces (grep-then-answer pattern). Conversations in chat format (`role`/`content`) compatible with TRL SFTTrainer.
39
+
40
+ ### Rewards (REW)
41
+
42
+ Judging criteria served: (2) objective rewards, (4) anti-reward-hacking.
43
+
44
+ - [ ] **REW-01** — Deterministic verifier module `rewards/`: `format_gate.py` (parse `<answer>…</answer>`), `correctness.py` (exact-match string or numeric, task-type dispatch), `token_budget.py` (α-parameterized shaping), `recursion_efficiency.py` (small bonus for shallow-tree solutions), `compose.py` (top-level composition).
45
+ - [ ] **REW-02** — Composition: `Sequential(Gate(FormatCheck), WeightedSum([CorrectnessScore × 0.75, TokenBudgetShaping × 0.20, RecursionEfficiencyBonus × 0.05]))`. Format fail → composite = 0 (no partial credit).
46
+ - [ ] **REW-03** — Per-component reward logging: W&B columns for composite, format_pass, correctness, token_cost_norm, recursion_depth_used, verifier_pass_rate. NOT just composite.
47
+ - [ ] **REW-04** — Token budget parameterized by α: `token_reward(tokens, α)` returns a shaped scalar. Evaluated at α ∈ {0.05, 0.2, 0.5, 1.0} at eval time with same checkpoint → produces Pareto frontier. α ∈ {capped-linear, capped-quadratic, uncapped} variants supported.
48
+ - [ ] **REW-05** — Adversarial pre-training audit in `REWARD_AUDIT.md`: at minimum 5 hand-crafted reward-hacking attempts (empty answer, wrong format, gold-answer echo, random guess with valid format, degenerate short answer). Each attempt scored, loopholes fixed before training.
49
+ - [ ] **REW-06** — Automated anti-hacking audits (`pytest -m reward_audit`): masked-context leakage test (context → gibberish, reward must be ≈ baseline-random), format-only baseline (valid format + random answer → composite ≤ 0.05), length-exploit check (mean reward by output length must not monotonically increase with length).
50
+ - [ ] **REW-07** — 20-trajectory human spot-check every 50 GRPO steps: sampled rollouts rendered to `trajectories/step_{N}.md` for inspection. Manually annotated for "genuine progress" vs "suspicious behavior."
51
+ - [ ] **REW-08** — Reward verifier unit tests: ≥ 30 hand-crafted (input, expected_reward) pairs across all 4 task types. Verifier is the reward source of truth — if this suite passes, training is safe to start.
52
+
53
+ ### Training (TRN)
54
+
55
+ Judging criteria served: (3) demonstrable improvement, (5) reproducible deployment.
56
+
57
+ - [ ] **TRN-01** — Base model: `unsloth/Qwen2.5-Coder-1.5B-Instruct-bnb-4bit`. LoRA rank 16, alpha 32, target `all-linear`. Loaded via `unsloth.FastLanguageModel.from_pretrained`.
58
+ - [ ] **TRN-02** — SFT warm-start via TRL `SFTTrainer` on `data/sft_traces.jsonl`: ≤ 1 epoch, LR 2e-4, max_seq_length 8192, prompt-tuning loss only on assistant turns. Output: `fathom-sft-v1` adapter pushed to HF Hub.
59
+ - [ ] **TRN-03** — GRPO training via TRL `GRPOTrainer` + Unsloth-patched model + OpenEnv `environment_factory` + `vllm_mode="colocate"`. Config: `num_generations=8`, `beta=0.04`, `lr=5e-6`, `max_grad_norm=0.5`, `bf16=True`, `max_prompt_length=4096`, `max_completion_length=2048`, `optim="adamw_8bit"`, `max_steps=400`, `save_steps=50`.
60
+ - [ ] **TRN-04** — Pre-event smoke test (Thursday, laptop RTX 4060): Qwen2.5-Coder-0.5B end-to-end — base load + SFT 1 step + GRPO 1 step (multi-turn rollout must succeed). Catches import hell, chat-template drift, TRL #4543 regression, Unsloth version mismatch before venue.
61
+ - [ ] **TRN-05** — Untrained-baseline measurement BEFORE any training (TRAIN-03 hard-blocks GRPO): 1.5B base evaluated on full eval set; composite + per-task-type accuracy recorded in `baselines/untrained.json`. Target: 25-50% correctness on trivial/easy tiers. If < 25% on trivial tier, halt and diagnose.
62
+ - [ ] **TRN-06** — Curriculum ramp: first 100 GRPO steps train only on `difficulty ∈ {trivial, easy}`. If reward curve climbs (composite ↑ ≥ 0.15 above baseline), extend to `{trivial, easy, medium}` for next 200 steps. Final 100 steps include `hard`.
63
+ - [ ] **TRN-07** — Adapter checkpoint saved every 50 steps and pushed to HF Hub: `save_pretrained` (adapter-only, ~15 MB). This is the insurance policy — if merge fails at hour 47, adapter-only inference still works.
64
+ - [ ] **TRN-08** — Final model save via Unsloth's `save_pretrained_merged(save_method="merged_16bit")`. NEVER `merged_4bit` or `merged_4bit_forced`. Post-save 3-question sanity check (trivial, easy, medium) — if any fails, fall back to adapter-only inference.
65
+ - [ ] **TRN-09** — Bad-run kill criteria (hour-10 hard stop): if reward curve is flat (composite variance < 0.05 across last 100 steps) or KL divergence > 50, halt run; diagnose with MON-03 trajectories; either (a) patch reward + restart from SFT checkpoint, or (b) lower difficulty to trivial-only + restart. Never let a bad run consume the full A100 budget.
66
+ - [ ] **TRN-10** — All hyperparameters expressed as Hydra-composable YAML in `configs/` — no hardcoded values. `configs/model/qwen_1_5b.yaml`, `configs/train/grpo.yaml`, `configs/data/v1.yaml`, `configs/reward/v1.yaml`. Invocation: `python -m train.grpo model=qwen_1_5b train=grpo data=v1 reward=v1`.
67
+
68
+ ### Monitoring (MON)
69
+
70
+ Judging criteria served: (3) demonstrable improvement, (4) anti-reward-hacking.
71
+
72
+ - [ ] **MON-01** — Weights & Biases integration with public run link in README. Logged per step: composite reward, per-component rewards (format, correctness, tokens, depth), verifier pass rate, timeout rate, rollout length, answer-length distribution, KL divergence, clip ratio, group std.
73
+ - [ ] **MON-02** — Eval-during-training: full eval sweep (untrained-style) every 100 steps on a 50-example eval slice. Holdout accuracy plotted over reward curve — this is the "DF-MET-1 kill-shot chart" that proves non-hacked improvement.
74
+ - [ ] **MON-03** — Trajectory sampling: 5-10 full rollouts dumped to `trajectories/step_{N}.md` every 50 steps. Rendered as readable markdown — model actions, env observations, rewards, per-step reasoning.
75
+ - [ ] **MON-04** — Training dashboard: single public W&B workspace link in README → judges click through → see the curves live.
76
+
77
+ ### Demo (DEM)
78
+
79
+ Judging criteria served: (6) sharp demo, plus (1)/(3)/(4) indirectly.
80
+
81
+ - [ ] **DEM-01** — Public GitHub repo at `github.com/{team}/fathom` — README with: one-sentence hook, 3-minute video link, reproducibility section, model link, W&B link, demo Space link, citations.
82
+ - [ ] **DEM-02** — `bash reproduce.sh` — single command that: installs pinned deps, downloads dataset, runs SFT (10 steps), runs GRPO (10 steps), verifies outputs. Proves reproducibility in <15 min on A100.
83
+ - [ ] **DEM-03** — Interactive HF Space (`fathom-demo`) with Streamlit UI: input = long document + question, output = live recursion tree + final answer + tokens used. 30-min Thursday bake-off vs Gradio to confirm Streamlit renders D3 tree cleaner; override if Gradio wins.
84
+ - [ ] **DEM-04** — Recursion-tree visualizer (forked from `alexzhang13/rlm-minimal`, adapted to our env): renders a single 200K-token question's solution tree. Pre-rendered untrained vs trained on the same question shown side-by-side in demo Space.
85
+ - [ ] **DEM-05** — Pareto frontier chart (`viz/pareto.py`): accuracy vs tokens on eval set; one curve per α in {0.05, 0.2, 0.5, 1.0} using the same trained checkpoint; untrained baseline plotted as strictly-dominated point. Saved as both interactive Plotly (for Space) and static PNG (for README + slides).
86
+ - [ ] **DEM-06** — Three-panel before/after comparison (`viz/before_after.py`): (a) untrained trace on target question — dumps ~18K context to sub-LM, hallucinates; (b) trained trace — greps then issues 3 targeted sub-calls, correct answer; (c) token cost comparison. Saved to README + used in pitch slide.
87
+ - [ ] **DEM-07** — Reward curve chart: composite + per-component over 400 steps, clean and legible. README hero image.
88
+ - [ ] **DEM-08** — Scripts `scripts/run_sft.sh`, `scripts/run_grpo.sh`, `scripts/run_eval.sh` — each documented in README with expected runtime + output artifacts.
89
+ - [ ] **DEM-09** — 60-second fallback demo video in repo: screen recording of the live demo (recursion tree + Pareto chart) made Sunday morning as insurance against live-demo failure. If network dies during pitch, we play the video.
90
+
91
+ ### Reproducibility (REP)
92
+
93
+ Judging criteria served: (5) reproducible deployment.
94
+
95
+ - [x] **REP-01** — `pyproject.toml` with EXACT version pins per `.planning/research/SUMMARY.md` stack table. Python 3.11.x (NOT 3.12). `uv.lock` committed.
96
+ - [x] **REP-02** — Dockerfile (from `openenv init` scaffold) reproducibly builds the env server. Used for local run + HF Space deployment.
97
+ - [ ] **REP-03** — README quickstart section: clone → install → run demo in < 5 min on a laptop (using 0.5B CPU fallback). Full 1.5B run requires A100.
98
+ - [ ] **REP-04** — `.planning/VENUE_STATE.md` updated before every `/compact` during venue: current step, checkpoint hash, HF Hub adapter URL, known bugs, next action. Survives Claude Code compaction.
99
+
100
+ ### Pitch (PIT)
101
+
102
+ Judging criteria served: (6) sharp demo.
103
+
104
+ - [ ] **PIT-01** — 3-minute pitch script in `pitch/script.md`: problem-first open ("models stop reading past 30K tokens"), RLM introduced ~60s in as mechanism, Pareto chart as closer. Rehearsed ≥ 3× before pitch slot.
105
+ - [ ] **PIT-02** — Pre-baked Q&A in `pitch/qa.md`: answers ≤ 20s each for "how is this different from RAG?", "why not a bigger model?", "is this just prompting?", "how do you prevent reward hacking?", "why only 1.5B?", "does it work at 200K?".
106
+ - [ ] **PIT-03** — Mercor sub-prize positioning: dedicated slide in pitch deck stating "Same checkpoint, three α values. User picks the accuracy-cost point." Pareto chart on the slide.
107
+ - [ ] **PIT-04** — Tagline locked in: *"A 1.5B model answers questions about documents 50× its context window — because we trained it to use Python to read."*
108
+ - [ ] **PIT-05** — Demo reliability drill on Sunday morning: 20 consecutive live-demo runs on the pre-selected 200K question. If < 20/20 succeed, reduce claim to the first context length where 20/20 succeeds (100K or 50K fallback).
109
+ - [ ] **PIT-06** — Hardware insurance: HDMI + USB-C + MiniDisplayPort adapters packed. Phone hotspot tested. HF Space kept warm (hit it) 5 min before pitch slot.
110
+
111
+ ---
112
+
113
+ ## v2 Requirements
114
+
115
+ Deferred to post-hackathon / not in current roadmap. Tracked so good ideas don't vanish.
116
+
117
+ ### Training
118
+
119
+ - **V2-TRN-01**: 3B base model with A100-80GB (revisit if venue flavor is 80GB AND 1.5B converges by hour 8).
120
+ - **V2-TRN-02**: Depth-3 training (not just inference) with credit assignment through nested rollouts.
121
+ - **V2-TRN-03**: RLVE-style adaptive difficulty — env auto-adjusts task difficulty based on rolling success rate.
122
+ - **V2-TRN-04**: Multi-turn GRPO with stepwise intermediate rewards (not just terminal). Unsloth currently does not support first-class; see unslothai/unsloth issue #stepwise-reward.
123
+
124
+ ### Environment
125
+
126
+ - **V2-ENV-01**: Sub-LMs writing Python (full recursive RLM). Quadruples failure surface; cut for v1.
127
+ - **V2-ENV-02**: Real-world document corpora (OOLONG, BrowseComp slices) as training data. v1 uses only for eval showcases.
128
+ - **V2-ENV-03**: Multi-modal contexts (document + image) via sub-LM call.
129
+
130
+ ### Rewards
131
+
132
+ - **V2-REW-01**: Process-level rewards (per sub-LM call quality) as separate signal. v1 uses outcome-only + token + depth.
133
+ - **V2-REW-02**: LLM-as-judge as a SANITY signal (not reward source) for generative sub-tasks.
134
+
135
+ ### Demo
136
+
137
+ - **V2-DEM-01**: Mobile-responsive demo Space.
138
+ - **V2-DEM-02**: Ablation slider in demo ("train at depth 1 vs depth 2 — switch and re-run").
139
+ - **V2-DEM-03**: Interactive custom-document upload.
140
+
141
+ ---
142
+
143
+ ## Out of Scope
144
+
145
+ Explicitly excluded from v1. Documented with reasoning to prevent scope creep.
146
+
147
+ | Feature | Reason |
148
+ |---------|--------|
149
+ | Learned reward model / LLM-as-judge in the training loop | Reward noise + circularity + judge distrust; deterministic verifiers are hackathon winners (PITFALLS R3). |
150
+ | Training at recursion depth > 2 | Credit assignment through nested rollouts unsolved for 1.5B models within 400 steps; depth-2 root-only is the paper's "hybrid" sweet spot. |
151
+ | Sub-LMs writing Python themselves | Quadruples failure surface for ~15% demo polish; root-only code execution preserves the visualizer story. |
152
+ | Training context > 16K | Rollout latency explodes; 16K train / 200K eval is the generalization claim. |
153
+ | 3B-LoRA base model | Venue A100 flavor unknown; 3B fits only on 80GB. Focusing 40-hour budget on 1.5B polish — 1.5B-with-clean-curve beats 3B-with-partial-curve on judging. |
154
+ | Real-world training corpora (OOLONG, BrowseComp) | Breaks deterministic verifier; reserved for eval showcases only. |
155
+ | Human-in-the-loop evaluation | Breaks determinism; eats time budget. |
156
+ | Custom React / polished frontend CSS | Judges don't score CSS; Streamlit/Gradio defaults sufficient. |
157
+ | Other hackathon themes (Multi-Agent, World Modeling, Self-Improving) | Theme 2 is bullseye; theme-hopping dilutes pitch. |
158
+ | PPO / DPO / reward-model-based RL | GRPO is canonical, TRL first-class, most public reproductions. |
159
+ | Head-to-head comparison with GPT-4/Claude on 200K tasks | Not the pitch — the pitch is "1.5B + our training >> 1.5B baseline". Comparing to frontier models invites irrelevant questions. |
160
+ | Multiple base models in v1 | Qwen2.5-Coder-1.5B-Instruct only; single-axis demo is sharper. |
161
+ | gVisor / Firecracker sandbox | Overkill; RestrictedPython + subprocess + ulimit sufficient for judge sniff-test. |
162
+ | Live training during pitch | Pitch shows pre-trained artifacts only; live training is a demo-failure mode. |
163
+ | `pip install -U` at venue | STACK pins verified 2026-04-22; venue updates introduce regression risk. |
164
+
165
+ ---
166
+
167
+ ## Traceability
168
+
169
+ Every v1 requirement maps to exactly one phase. Filled by roadmapper 2026-04-23.
170
+
171
+ | Requirement | Phase | Status |
172
+ |-------------|-------|--------|
173
+ | ENV-01 | Phase 0 | Pending |
174
+ | ENV-02 | Phase 0 | Pending |
175
+ | ENV-03 | Phase 0 | Pending |
176
+ | ENV-04 | Phase 0 | Complete |
177
+ | ENV-05 | Phase 0 | Pending |
178
+ | ENV-06 | Phase 0 | Pending |
179
+ | ENV-07 | Phase 0 | Pending |
180
+ | ENV-08 | Phase 0 | Pending |
181
+ | ENV-09 | Phase 0 | Complete |
182
+ | ENV-10 | Phase 1 | Pending |
183
+ | DATA-01 | Phase 1 | Pending |
184
+ | DATA-02 | Phase 1 | Pending |
185
+ | DATA-03 | Phase 1 | Pending |
186
+ | DATA-04 | Phase 1 | Pending |
187
+ | DATA-05 | Phase 1 | Pending |
188
+ | DATA-06 | Phase 1 | Pending |
189
+ | REW-01 | Phase 1 | Pending |
190
+ | REW-02 | Phase 1 | Pending |
191
+ | REW-03 | Phase 1 | Pending |
192
+ | REW-04 | Phase 1 | Pending |
193
+ | REW-05 | Phase 1 | Pending |
194
+ | REW-06 | Phase 1 | Pending |
195
+ | REW-07 | Phase 2 | Pending |
196
+ | REW-08 | Phase 1 | Pending |
197
+ | TRN-01 | Phase 1 | Pending |
198
+ | TRN-02 | Phase 1 | Pending |
199
+ | TRN-03 | Phase 1 | Pending |
200
+ | TRN-04 | Phase 1 | Pending |
201
+ | TRN-05 | Phase 2 | Pending |
202
+ | TRN-06 | Phase 2 | Pending |
203
+ | TRN-07 | Phase 2 | Pending |
204
+ | TRN-08 | Phase 2 | Pending |
205
+ | TRN-09 | Phase 2 | Pending |
206
+ | TRN-10 | Phase 1 | Pending |
207
+ | MON-01 | Phase 2 | Pending |
208
+ | MON-02 | Phase 2 | Pending |
209
+ | MON-03 | Phase 2 | Pending |
210
+ | MON-04 | Phase 2 | Pending |
211
+ | DEM-01 | Phase 3 | Pending |
212
+ | DEM-02 | Phase 3 | Pending |
213
+ | DEM-03 | Phase 1 | Pending |
214
+ | DEM-04 | Phase 2 | Pending |
215
+ | DEM-05 | Phase 3 | Pending |
216
+ | DEM-06 | Phase 3 | Pending |
217
+ | DEM-07 | Phase 2 | Pending |
218
+ | DEM-08 | Phase 3 | Pending |
219
+ | DEM-09 | Phase 3 | Pending |
220
+ | REP-01 | Phase 0 | Complete |
221
+ | REP-02 | Phase 0 | Complete |
222
+ | REP-03 | Phase 3 | Pending |
223
+ | REP-04 | Phase 3 | Pending |
224
+ | PIT-01 | Phase 3 | Pending |
225
+ | PIT-02 | Phase 3 | Pending |
226
+ | PIT-03 | Phase 3 | Pending |
227
+ | PIT-04 | Phase 3 | Pending |
228
+ | PIT-05 | Phase 3 | Pending |
229
+ | PIT-06 | Phase 3 | Pending |
230
+
231
+ **Coverage:**
232
+ - v1 requirements: **57 total** (10 ENV + 6 DATA + 8 REW + 10 TRN + 4 MON + 9 DEM + 4 REP + 6 PIT — initial summary of "56" was a miscount; actual is 57)
233
+ - Mapped to phases: **57** (Phase 0: 11 | Phase 1: 20 | Phase 2: 12 | Phase 3: 14)
234
+ - Unmapped: **0** ✓
235
+
236
+ ---
237
+ *Requirements defined: 2026-04-23*
238
+ *Traceability filled: 2026-04-23 by roadmapper (coarse granularity, 4 phases, 57/57 mapped)*
.planning/ROADMAP.md ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Roadmap: FATHOM — First RL-Trained Recursive Language Model
2
+
3
+ ## Overview
4
+
5
+ FATHOM is a four-phase sprint from a bare `openenv init` scaffold on Thursday evening to a rehearsed 3-minute pitch on Sunday afternoon at the Meta × PyTorch × Hugging Face OpenEnv Grand Finale, Scaler School of Technology, Bangalore (April 25-26, 2026). The four phases map to the four calendar windows available: **Thursday evening** (solo foundation, Pratham only, sequential), **Friday** (parallel build, Hardik + Pratham), **Saturday at venue** (training day — critical-path 1.5B SFT then ~5h GRPO with viz built in parallel during the run), and **Sunday at venue** (demo assembly and pitch rehearsal with a strict no-code-changes freeze in the last 4 hours before the pitch slot). Every phase produces a demonstrable, committed artifact; the GRPO run in Phase 2 is the single longest unparallelizable block (~5h A100 wall-clock) and every other feature is either feeding it or rendering its output.
6
+
7
+ **Total v1 requirements:** 57 — each maps to exactly one phase. No orphans.
8
+
9
+ **Granularity:** Coarse (4 phases) per config.json.
10
+
11
+ ## Phases
12
+
13
+ **Phase Numbering:**
14
+ - Integer phases (0, 1, 2, 3): Planned calendar windows
15
+ - Decimal phases (2.1, 2.2): Reserved for urgent venue-time insertions (INSERTED)
16
+
17
+ - [ ] **Phase 0: Thu Evening Foundation** - Env scaffold, REPL, llm() stub, sandbox hardened, roundtrip smoke (solo, sequential)
18
+ - [ ] **Phase 1: Fri Parallel Build** - Training stack + reward components + dataset + SFT traces + HF Space deploy (Hardik & Pratham in parallel, gated by 0.5B smoke)
19
+ - [ ] **Phase 2: Sat Training Day** - 1.5B SFT then GRPO 400 steps (~5h A100 wall-clock); viz + eval built in parallel during GRPO; hour-10 kill-or-continue
20
+ - [ ] **Phase 3: Sun Demo + Pitch** - α-sweep eval, Pareto chart, Streamlit demo, 60s fallback video, pitch rehearsal (last 4h frozen)
21
+
22
+ ## Phase Details
23
+
24
+ ### Phase 0: Thu Evening Foundation
25
+ **Goal**: Env server answers `reset` and `step` over HTTP locally with a hardened Python REPL and an `llm()` stub, ready for training-stack scaffolding to be built against it tomorrow.
26
+ **When**: Thursday 2026-04-23 evening, ~4-6 hours, SOLO (Pratham only; Hardik joins Friday)
27
+ **Parallelization**: None — this is sequential critical-path foundation work. Every later parallel track depends on the contract this phase freezes.
28
+ **Depends on**: Nothing (first phase)
29
+ **Requirements**: ENV-01, ENV-02, ENV-03, ENV-04, ENV-05, ENV-06, ENV-07, ENV-08, ENV-09, REP-01, REP-02
30
+ **Success Criteria** (what must be TRUE):
31
+ 1. `python -m uvicorn env.server.app:app` starts locally; `FathomEnvClient.reset(seed=0, difficulty="easy")` and `client.step(FathomAction(code="print(1+1)"))` succeed end-to-end over HTTP with typed Pydantic round-trip (ENV-01, ENV-02, ENV-03 verified).
32
+ 2. `pytest tests/test_repl_sandbox.py -v` — all 7 adversarial sandbox tests fail-to-escape (attribute-access escape, `__builtins__` monkey-patch, `eval` via reflection, network egress, file write, `os.system`, subprocess-of-subprocess). This is the ruin-mode gate for R3/H3 and hard-blocks Phase 1's HF Space deploy.
33
+ 3. `pytest tests/test_env.py::test_gold_answer_never_in_observation` passes — `state.gold_answer` provably absent from every `Observation` field, so the reward verifier cannot be shortcut by the model reading its own environment state (ENV-08).
34
+ 4. Episode termination works for all four reasons with explicit `StepResult.info.termination_reason`: `<answer>` emitted, `max_steps=20` exceeded, `max_tokens=100000` exceeded, walltime > 120s (ENV-07).
35
+ 5. `uv pip install -e .` succeeds from a clean Python 3.11 venv using `pyproject.toml` with every version pin from `research/STACK.md` §1; `docker build .` (from the `openenv init`-generated Dockerfile) produces an image that runs the env server (REP-01, REP-02).
36
+ **Exit gate**: Sandbox adversarial suite green + env roundtrip green + Dockerfile builds locally. If any red, do NOT proceed to Phase 1 — fix before sleeping.
37
+ **Plans**: TBD (to be decomposed by `/gsd-plan-phase 0`)
38
+
39
+ ### Phase 1: Fri Parallel Build
40
+ **Goal**: By end of Friday, the 0.5B laptop smoke test runs one full GRPO step end-to-end (SFT-adapter-load → multi-turn rollout → reward compute → grad step), proving the entire pipeline compiles and the TRL-OpenEnv-Unsloth stack is wired correctly before touching the A100 on Saturday.
41
+ **When**: Friday 2026-04-24, ~12-14 hours, TWO-PERSON PARALLEL (Hardik + Pratham)
42
+ **Parallelization**: Two independent tracks that sync at a single integration point (the 0.5B smoke test):
43
+ - **Track A (Hardik — Training stack)**: TRN-01 (Unsloth base model + LoRA load), TRN-02 (SFT pipeline scaffold), TRN-03 (GRPO pipeline scaffold with Hydra config + GRPOConfig values from STACK §4), TRN-04 (the 0.5B smoke test itself on the RTX 4060, which MUST exercise a multi-turn rollout to catch TRL #4543), TRN-10 (Hydra YAML configs for model / train / data / reward).
44
+ - **Track B (Pratham — Data, rewards, deploy)**: DATA-01..06 (4-task-type synthetic generator, 1000/200 train/eval, difficulty metadata, JSONL, 500 SFT traces via Claude Haiku + templates), REW-01..04 (format_gate, correctness, token_budget α-param, recursion_efficiency, compose), REW-05 (manual adversarial audit in REWARD_AUDIT.md), REW-06 (pytest -m reward_audit: masked-context, format-only, length-exploit), REW-08 (≥30 (input, expected_reward) unit tests), ENV-10 (deploy env server to HF Space via `openenv push` with manual `git push` fallback), DEM-03 (30-min Streamlit vs Gradio bake-off Thursday/Friday — pick winner for Phase 3 demo UI).
45
+ - **Sync point (Friday evening)**: Track A imports Track B's `compose_reward_fn` + reads Track B's `train.jsonl`; together they run the 0.5B smoke test. This is the GO/NO-GO gate before packing for Bangalore.
46
+ **Depends on**: Phase 0 (env contract frozen; REPL sandbox certified safe)
47
+ **Requirements**: ENV-10, DATA-01, DATA-02, DATA-03, DATA-04, DATA-05, DATA-06, REW-01, REW-02, REW-03, REW-04, REW-05, REW-06, REW-08, TRN-01, TRN-02, TRN-03, TRN-04, TRN-10, DEM-03
48
+ **Success Criteria** (what must be TRUE):
49
+ 1. 0.5B end-to-end smoke test (TRN-04) completes one full GRPO step on the RTX 4060: base model loads 4-bit, SFT adapter loads, multi-turn rollout over the env server succeeds (≥1 `llm()` sub-call happens), `compose_reward_fn` returns a float, optimizer applies a non-zero gradient, no NaN. **This gates venue departure.**
50
+ 2. `REWARD_AUDIT.md` exists and documents 5 manually-crafted reward-hacking attempts, each scored against the verifier with the resulting fix applied; `pytest -m reward_audit` passes three automated audits (masked-context reward ≈ baseline-random, format-only baseline ≤ 0.05 composite, no monotonic length-exploit). **This is the ruin-mode gate for R3 (reward hacking) and hard-blocks any 1.5B training run on Saturday.**
51
+ 3. `data/train.jsonl` has 1000 examples across 4 task types with documented mix (NIAH 40% / multi-needle 30% / extractive 20% / counting 10%), ≥5% trivial tier for untrained-baseline floor; `data/eval.jsonl` has 200 disjoint examples; `data/sft_traces.jsonl` has ~500 entries (Haiku + templates) in TRL-compatible chat format.
52
+ 4. Env server deployed to HF Space — `curl https://<user>-fathom-env.hf.space/reset` returns 200 with a valid Observation, full reset/step roundtrip verified remotely from a second machine (ENV-10). **This catches Docker/dependency/auth issues pre-training, per PITFALLS M3.**
53
+ 5. HF Space is publicly accessible without authentication — `curl -I https://<user>-fathom-env.hf.space/healthz` from an unauthenticated client returns HTTP 200. Hackathon auto-validator runs logged-out; a private Space will fail the submission check.
54
+ 6. `openenv validate` (or equivalent CLI conformance check) succeeds on the hand-rolled `env/` layout at repo root. If the OpenEnv CLI cannot locate the env, the HF Space auto-validator may flag the submission. Document the result in Phase 1 ENV-10 plan summary.
55
+ 7. Demo UI winner chosen between Streamlit and Gradio based on 30-min side-by-side D3-recursion-tree render (DEM-03); choice locked in repo as `viz/app.py` skeleton before Friday midnight.
56
+ **Exit gate**: 0.5B smoke green + REWARD_AUDIT green + HF Space responds remotely (logged-out) + openenv validate green + 1000+200 dataset on disk + 500 SFT traces on disk. If smoke fails, do NOT travel without a written recovery plan — a broken pipeline will eat the entire Saturday A100 window.
57
+ **Plans**: TBD (to be decomposed by `/gsd-plan-phase 1`)
58
+
59
+ ### Phase 2: Sat Training Day
60
+ **Goal**: Trained 1.5B LoRA adapter checkpoint pushed to HF Hub with a clean, legible W&B reward curve that climbs from untrained baseline to a non-hacked trained plateau — the single artifact that powers every pitch beat.
61
+ **When**: Saturday 2026-04-25, ~12 hours venue Day 1 (9 AM - 9 PM) + overnight run if needed.
62
+ **Parallelization**: One MERGED CRITICAL PATH (the ~5h GRPO run is unparallelizable A100 wall-clock) plus parallel viz/eval work during the run:
63
+ - **Hours 0-1 (setup)**: Run STACK §11 G1-G12 gates on the A100; confirm 40GB vs 80GB flavor; measure 1.5B untrained baseline (TRN-05) on full eval set → writes `baselines/untrained.json` with target 25-50% on trivial/easy tier (halt + diagnose if <25% on trivial, per R1 prevention).
64
+ - **Hours 1-2 (SFT warm-start)**: 1.5B SFT on 500 Haiku+template traces → `fathom-sft-v1` adapter pushed to HF Hub.
65
+ - **Hours 2-7 (GRPO ~5h, MERGED CRITICAL PATH)**: Launch `GRPOTrainer` with curriculum ramp (TRN-06: first 100 steps trivial+easy, next 200 add medium, final 100 add hard). W&B logs composite + per-component rewards + verifier pass rate + timeout rate + rollout length + answer-length distribution + KL + group std per step (MON-01). Adapter pushed to Hub every 50 steps (TRN-07 — ruin-mode insurance for R4). Eval sweep every 100 steps on 50-example slice (MON-02). 5-10 trajectories rendered to `trajectories/step_{N}.md` every 50 steps (MON-03, REW-07).
66
+ - **Hours 2-7 in PARALLEL (while GRPO runs)**: Engineer B builds `viz/tree.py` (recursion-tree renderer forked from `alexzhang13/rlm-minimal`), `viz/pareto.py` (α-sweep chart skeleton), `eval/run_eval.py` (α-sweep driver), `viz/before_after.py` (three-panel skeleton). Reward curve chart (DEM-07) auto-updates from the running W&B run.
67
+ - **Hour 10 (explicit checkpoint — TRN-09)**: Kill-or-continue decision. If `composite reward variance < 0.05 across last 100 steps` OR `KL divergence > 50`: halt; diagnose via MON-03 trajectories; either (a) patch reward + restart from SFT checkpoint or (b) lower difficulty to trivial-only + restart. **Never let a bad run consume the full A100 budget.** If healthy, let it finish to 400 steps.
68
+ - **End of Phase 2 (Saturday night)**: Run the STACK §6 save snippet verbatim — adapter-only save first, push to Hub, then `save_pretrained_merged(save_method="merged_16bit")`, then 3-question sanity check on the merged dir. NEVER `merged_4bit` or `merged_4bit_forced` (TRN-08 — ruin-mode gate for R4).
69
+ **Depends on**: Phase 1 (SFT traces, reward verifier audited, env Space live, 0.5B smoke proved pipeline works)
70
+ **Requirements**: TRN-05, TRN-06, TRN-07, TRN-08, TRN-09, REW-07, MON-01, MON-02, MON-03, MON-04, DEM-04, DEM-07
71
+ **Success Criteria** (what must be TRUE):
72
+ 1. W&B public workspace link shows a clean composite-reward curve climbing from untrained baseline to a trained plateau over ≥400 GRPO steps AND per-component curves (format, correctness, tokens, depth) all moving in expected directions — format+correctness both climbing (not just format, per H4 prevention), tokens trending down under shaping, depth used non-degenerately. Link is live in repo README (MON-01, MON-04, DEM-07).
73
+ 2. Adapter-only checkpoint at step 400 (and at each 50-step interval) exists at `your-org/fathom-1p5b-adapter` on HF Hub; 3-question sanity check against the `merged_16bit/` save produces non-empty outputs. **Ruin-mode gate for R4 — this must be green before Phase 3 starts.** (TRN-07, TRN-08)
74
+ 3. Hour-10 kill-or-continue decision (TRN-09) was made explicitly with W&B-backed rationale written to `.planning/VENUE_STATE.md`; run was either declared healthy and allowed to finish OR killed and restarted from SFT checkpoint with a patched reward / lowered difficulty. **No silent full-budget run.**
75
+ 4. Holdout accuracy (eval-during-training from MON-02, the "DF-MET-1 kill-shot chart") is plotted alongside reward and rises in lockstep — this is the single piece of evidence that the reward curve is not hacked.
76
+ 5. `viz/tree.py` renders a recursion-tree for at least one trained-model trajectory; `trajectories/step_{N}.md` files exist for every 50th step (REW-07, MON-03); the recursion-tree visualizer (DEM-04) is ready to be wired into Phase 3's demo Space.
77
+ 6. `notebooks/train_fathom_grpo.ipynb` exists as a runnable Colab notebook: pip install cell, env connect cell, 50-step smoke training cell, W&B curve cell. **Hackathon rules say "preferably linked as a Colab notebook so it can be re-executed end to end" — W&B-only links and plots that live only in a Colab cell don't count as committed artifacts.**
78
+ 7. `scripts/export_curves.py` pulls the W&B run and saves `assets/reward_curve.png`, `assets/loss_curve.png`, and `assets/pareto.png` to the repo. **Hackathon auto-validator checks for committed plot images; W&B-only links are not accepted.**
79
+ **Exit gate**: Trained adapter on Hub + 3-question sanity check passes on merged save + reward curve + holdout accuracy curve + trajectory samples committed + `assets/*.png` committed + Colab notebook committed. If the merged save sanity check fails, fall back to adapter-only inference for Phase 3 (documented recovery).
80
+ **Plans**: TBD (to be decomposed by `/gsd-plan-phase 2`)
81
+ **UI hint**: yes
82
+
83
+ ### Phase 3: Sun Demo + Pitch
84
+ **Goal**: Judges experience a live, rehearsed, tight 3-minute pitch that opens on the long-context problem, shows the recursion tree rendering in real time, closes on the Pareto chart, and has a 60-second video fallback ready if anything fails on stage.
85
+ **When**: Sunday 2026-04-26, ~6-8 hours before pitch slot (9 AM venue opens) + **last 4 hours before pitch: NO code changes, rehearsal and hardware drills only**.
86
+ **Parallelization**: Morning parallel, afternoon merged on pitch polish:
87
+ - **Hours 0-3 (morning parallel)**: Track A (Hardik) — α-sweep eval: `eval/run_eval.py` loads trained adapter, runs full 200-example eval at α ∈ {0.05, 0.2, 0.5, 1.0} (same checkpoint, different eval-time shaping), writes `pareto.json` and `pareto.png`. Build three-panel before/after (DEM-06) using the untrained-baseline trajectory from Phase 2 and the trained-checkpoint trajectory on the same target question. Track B (Pratham) — Streamlit demo Space (DEM-05) with input box + live recursion-tree render + Pareto chart embed + answer + tokens used, deployed as second HF Space (`fathom-demo`); README assembled with one-sentence hook, video link placeholder, reproducibility section, model link, W&B link, demo Space link, citations (DEM-01); `reproduce.sh` written and tested to install pinned deps + download dataset + run SFT 10 steps + run GRPO 10 steps + verify outputs in <15 min on A100 (DEM-02); `scripts/run_sft.sh`, `run_grpo.sh`, `run_eval.sh` documented with runtime + output artifacts (DEM-08); README quickstart proven to run in <5 min on laptop using 0.5B CPU fallback (REP-03).
88
+ - **Hours 3-5 (dress rehearsal)**: Record the 60-second fallback demo video (DEM-09) with screen capture of live recursion-tree + Pareto chart — committed to repo. Run 20-of-20 live-demo drill on the pre-selected 200K-token question (PIT-05); if <20/20 succeed, reduce pitch claim to the first context length where 20/20 succeeds (100K or 50K fallback — per H10 over-claiming prevention).
89
+ - **Hours 5+ (FROZEN — last 4 hours before pitch)**: NO code changes. Pitch rehearsal only: 3-min script ≥3 times (PIT-01), pre-baked Q&A drill (PIT-02), Mercor sub-prize slide polish (PIT-03), tagline locked (PIT-04), hardware insurance drill — HDMI + USB-C + Mini-DisplayPort adapters packed, phone hotspot tested, HF Space pinged warm 5 min before pitch slot (PIT-06). `VENUE_STATE.md` updated to reflect frozen state (REP-04).
90
+ **Depends on**: Phase 2 (trained adapter on Hub, reward curve, one trained-model trajectory for the before/after frame, merged_16bit save or adapter-only fallback verified)
91
+ **Requirements**: DEM-01, DEM-02, DEM-05, DEM-06, DEM-08, DEM-09, REP-03, REP-04, PIT-01, PIT-02, PIT-03, PIT-04, PIT-05, PIT-06
92
+ **Success Criteria** (what must be TRUE):
93
+ 1. Pareto frontier chart (DEM-05) exists as both interactive Plotly (in demo Space) and static PNG (in README + pitch slide) — shows ≥3 α curves from the same trained checkpoint with untrained baseline plotted as strictly-dominated point in the long-context regime. **This is the Mercor sub-prize hook; without it the pitch loses its closer.**
94
+ 2. 20-of-20 live-demo runs (PIT-05) succeed on the pre-selected 200K-token question (or the 100K/50K fallback — claim in pitch matches actually-verified context length); demo Space `fathom-demo` live and responsive with input → recursion tree + answer + tokens rendering in <60s per run; HF Space pinged warm 5 min before pitch slot (PIT-06).
95
+ 3. 60-second fallback demo video (DEM-09) committed to repo and loaded on laptop before pitch slot. **Ruin-mode gate for R6 — if network drops or Space cold-starts mid-pitch, the video carries the claim.**
96
+ 4. 3-minute pitch has been rehearsed ≥3 times (PIT-01) with problem-first opening, RLM introduced as mechanism ~60s in, Mercor Pareto chart as the closer. All six Q&A answers (PIT-02) are ≤20s each: "how is this different from RAG?", "why not a bigger model?", "is this just prompting?", "how do you prevent reward hacking?", "why only 1.5B?", "does it work at 200K?". Tagline (PIT-04) memorized: *"A 1.5B model answers questions about documents 50× its context window — because we trained it to use Python to read."*
97
+ 5. Public GitHub repo is submission-ready: README with hook + video link + reproducibility section + model link + W&B link + demo Space link + citations (DEM-01); `bash reproduce.sh` proven to run end-to-end in <15 min on A100 (DEM-02); three scripts documented (DEM-08); quickstart proven <5 min on laptop (REP-03); `VENUE_STATE.md` reflects frozen state + final checkpoint hash + known bugs (REP-04). **Last 4 hours before pitch: no git commits to `main` except pitch-doc edits.** This is the named freeze gate.
98
+ 6. README has all 4 submission URLs inline (HF Space, Colab notebook, W&B run, demo video) AND embeds `assets/reward_curve.png` and `assets/pareto.png` as images. Every link in the Google Form must resolve from the README — judges follow README links, not the submission form.
99
+ 7. `BLOG.md` exists at repo root as a markdown blog post (~500 words: problem framing, RLM approach, reward design, result). Hackathon tip: "write markdown and place it in your env code repo" — this makes the blog discoverable from a `git clone` without needing an external URL.
100
+ **Exit gate**: Pitch delivered; repo submitted; Mercor sub-prize slide submitted. Freeze rule held (no code changes in last 4 hours). README links all resolve. BLOG.md committed.
101
+ **Plans**: TBD (to be decomposed by `/gsd-plan-phase 3`)
102
+ **UI hint**: yes
103
+
104
+ ## Progress
105
+
106
+ **Execution Order:**
107
+ Phase 0 → Phase 1 → Phase 2 → Phase 3 (strictly sequential; decimal insertions reserved for venue-time emergencies)
108
+
109
+ | Phase | Plans Complete | Status | Completed |
110
+ |-------|----------------|--------|-----------|
111
+ | 0. Thu Evening Foundation | 3/5 | In Progress| |
112
+ | 1. Fri Parallel Build | 0/TBD | Not started | - |
113
+ | 2. Sat Training Day | 0/TBD | Not started | - |
114
+ | 3. Sun Demo + Pitch | 0/TBD | Not started | - |
115
+
116
+ ## Coverage Summary
117
+
118
+ **v1 requirements:** 57 total (10 ENV + 6 DATA + 8 REW + 10 TRN + 4 MON + 9 DEM + 4 REP + 6 PIT)
119
+
120
+ **Mapped:** 57 / 57 ✓
121
+
122
+ **Orphans:** 0
123
+
124
+ **Per-phase distribution:**
125
+ - Phase 0: 11 requirements (all 10 ENV except ENV-10 which lands in Phase 1 where the Space deploys; REP-01, REP-02)
126
+ - Phase 1: 20 requirements (ENV-10; DATA-01..06; REW-01..06, REW-08; TRN-01..04, TRN-10; DEM-03)
127
+ - Phase 2: 12 requirements (TRN-05..09; REW-07; MON-01..04; DEM-04, DEM-07)
128
+ - Phase 3: 14 requirements (DEM-01, DEM-02, DEM-05, DEM-06, DEM-08, DEM-09; REP-03, REP-04; PIT-01..06)
129
+
130
+ ## Ruin-Mode Gates (tied to exits)
131
+
132
+ | Ruin mode | Prevention gate | Phase |
133
+ |-----------|-----------------|-------|
134
+ | R3/H3 — sandbox escape / reward hacking via env state | ENV-09 adversarial tests all fail-to-escape + ENV-08 gold-answer-never-in-obs unit test | Phase 0 exit |
135
+ | R3 — reward loophole in verifier | REW-05 manual adversarial audit + REW-06 automated audits pass | Phase 1 exit |
136
+ | R1 — untrained 1.5B has 0% on all tiers | TRN-05 baseline measurement with halt+diagnose if <25% on trivial | Phase 2 setup |
137
+ | R4 — LoRA save bug destroys checkpoint at hour 47 | TRN-07 adapter-only saves to Hub every 50 steps + TRN-08 merged_16bit (NEVER merged_4bit) + 3-question sanity check | Phase 2 exit |
138
+ | R6 — live demo fails during pitch | DEM-09 60s fallback video + HF Space warmup + PIT-06 hardware insurance | Phase 3 exit |
139
+ | H10 — over-claiming 200K in pitch | PIT-05 20/20 reliability drill, reduce claim to verified context length | Phase 3 dress rehearsal |
140
+ | Last 4 hours freeze | Named exit gate in Phase 3: no code changes, pitch rehearsal + hardware drill only | Phase 3 |
141
+
142
+ *Roadmap created: 2026-04-23. Granularity: coarse (4 phases). Coverage: 57/57 ✓.*
.planning/SANDBOX_CERT.md ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FATHOM Sandbox Certificate
2
+
3
+ **Status:** PASSED — all 7 named adversarial attacks CONTAINED.
4
+ **Timestamp (UTC):** 2026-04-24T03:30:10Z
5
+ **Git commit:** e55825f9a835c4c9a9df081c3a91481bee94a3de
6
+ **Branch:** master
7
+ **Phase:** 00-thu-evening-foundation
8
+ **Plan:** 03 (adversarial sandbox suite)
9
+ **RestrictedPython version:** 7.4
10
+ **Python version:** 3.11.13
11
+ **Platform:** win32 (Windows-10-10.0.26200-SP0) — dev laptop
12
+ **pytest version:** 8.4.2
13
+ **Venv:** `.venv-phase0-verify`
14
+
15
+ ## R3/H3 Ruin-Mode Gate
16
+
17
+ This certificate is the auditable artefact for ROADMAP Phase 0 Exit Criterion #2
18
+ (`pytest tests/test_repl_sandbox.py -v`) and Phase 1's precondition for
19
+ deploying the env server to HuggingFace Space (ENV-10). Phase 1 tasks MUST NOT
20
+ proceed without this file AND a green status line at the top.
21
+
22
+ Per CONTEXT.md D-14 the cert embeds: ISO-8601 UTC timestamp, current git
23
+ commit SHA, full pytest `-v` output, RestrictedPython version, and a one-row
24
+ verdict per named attack. Re-run `pytest tests/test_repl_sandbox.py -v` at
25
+ the venue on Linux to re-verify with the POSIX hardening path (RLIMIT_AS /
26
+ setsid / killpg / ephemeral /tmp/episode-*) exercised.
27
+
28
+ ## Attack Verdicts
29
+
30
+ | # | Attack | Test function | Verdict | Containment mechanism |
31
+ |---|--------|---------------|---------|----------------------|
32
+ | 1 | Attribute-access escape (`().__class__.__base__.__subclasses__()`) | `test_attribute_access_escape` | **CONTAINED** | RestrictedPython `compile_restricted` rejects `__class__` dunder-attr access at AST time |
33
+ | 2 | `__builtins__` monkey-patch (`__builtins__['open'](...)`) | `test_builtins_monkeypatch` | **CONTAINED** | `_build_safe_builtins()` returns minimal dict; `__builtins__`, `open`, `getattr` all in `_FORBIDDEN_NAMES` |
34
+ | 3 | `eval` via reflection (`eval("__import__('os').system('id')")`) | `test_eval_via_reflection` | **CONTAINED** | `eval`, `exec`, `compile`, `__import__` absent from child builtins → `NameError: eval is not defined` |
35
+ | 4 | Network egress (`import socket; socket.socket().connect(...)`) | `test_network_egress` | **CONTAINED** | `socket` in `BLOCKED_IMPORTS`; `_safe_import` raises `ImportError` |
36
+ | 5 | File write (`open(path, 'w').write('pwned')`) | `test_file_write` | **CONTAINED** | `open` not in safe builtins; target file verified non-existent post-run |
37
+ | 6 | `os.system` (`import os; os.system(...)`) | `test_os_system` | **CONTAINED** | `os` in `BLOCKED_IMPORTS`; `_safe_import` raises `ImportError` |
38
+ | 7 | Subprocess-of-subprocess (`subprocess.Popen(['sh', ...])`) | `test_subprocess_of_subprocess` | **CONTAINED** | `subprocess`, `multiprocessing` both in `BLOCKED_IMPORTS`; `_safe_import` raises `ImportError` |
39
+
40
+ ## Bonus Tests
41
+
42
+ | # | Attack | Test function | Verdict |
43
+ |---|--------|---------------|---------|
44
+ | B1 | Traceback frame walk (`e.__traceback__.tb_frame.f_back.f_builtins`) | `test_traceback_walk` | **CONTAINED** (AST-filter + builtins strip: `leaked`/`opener` falsy) |
45
+ | B2 | Infinite loop (`while True: pass`) w/ `timeout_s=2` | `test_infinite_loop` | **CONTAINED** (`timed_out=True`, elapsed ~2s, D-03 globals preserved unchanged) |
46
+ | B3 | Memory bomb (`x = [0] * 10**9`) POSIX-only | `test_memory_bomb` | **SKIPPED** on Windows (RLIMIT_AS unavailable) — verify at venue on Linux |
47
+
48
+ ## Full pytest Output
49
+
50
+ ```
51
+ ============================= test session starts =============================
52
+ platform win32 -- Python 3.11.13, pytest-8.4.2, pluggy-1.6.0 -- C:\Users\prath\OneDrive\Desktop\Hackathons\Meta_finale\.venv-phase0-verify\Scripts\python.exe
53
+ rootdir: C:\Users\prath\OneDrive\Desktop\Hackathons\Meta_finale
54
+ configfile: pyproject.toml
55
+ plugins: anyio-4.13.0
56
+ collecting ... collected 10 items
57
+
58
+ tests/test_repl_sandbox.py::test_attribute_access_escape PASSED [ 10%]
59
+ tests/test_repl_sandbox.py::test_builtins_monkeypatch PASSED [ 20%]
60
+ tests/test_repl_sandbox.py::test_eval_via_reflection PASSED [ 30%]
61
+ tests/test_repl_sandbox.py::test_network_egress PASSED [ 40%]
62
+ tests/test_repl_sandbox.py::test_file_write PASSED [ 50%]
63
+ tests/test_repl_sandbox.py::test_os_system PASSED [ 60%]
64
+ tests/test_repl_sandbox.py::test_subprocess_of_subprocess PASSED [ 70%]
65
+ tests/test_repl_sandbox.py::test_traceback_walk PASSED [ 80%]
66
+ tests/test_repl_sandbox.py::test_infinite_loop PASSED [ 90%]
67
+ tests/test_repl_sandbox.py::test_memory_bomb SKIPPED (RLIMIT_AS unsu...) [100%]
68
+
69
+ ======================== 9 passed, 1 skipped in 5.68s =========================
70
+ ```
71
+
72
+ ### Per-test durations (`--durations=10`)
73
+
74
+ ```
75
+ 2.02s call tests/test_repl_sandbox.py::test_infinite_loop
76
+ 0.72s call tests/test_repl_sandbox.py::test_eval_via_reflection
77
+ 0.49s call tests/test_repl_sandbox.py::test_file_write
78
+ 0.47s call tests/test_repl_sandbox.py::test_attribute_access_escape
79
+ 0.46s call tests/test_repl_sandbox.py::test_network_egress
80
+ 0.43s call tests/test_repl_sandbox.py::test_subprocess_of_subprocess
81
+ 0.43s call tests/test_repl_sandbox.py::test_os_system
82
+ 0.41s call tests/test_repl_sandbox.py::test_builtins_monkeypatch
83
+ 0.41s call tests/test_repl_sandbox.py::test_traceback_walk
84
+ ```
85
+
86
+ All tests complete under 3s; suite-wide runtime ~5.9s (well under the 60s
87
+ budget in threat-register T-00-18).
88
+
89
+ ## Whitelisted Imports (verbatim from `env/server/repl.py::ALLOWED_IMPORTS`)
90
+
91
+ `re, json, math, statistics, collections, itertools, string, functools, operator`
92
+
93
+ ## Blocked Imports (verbatim from `env/server/repl.py::BLOCKED_IMPORTS`)
94
+
95
+ `os, sys, subprocess, socket, urllib, http, requests, pathlib, io, ctypes,
96
+ threading, multiprocessing, pickle, marshal, importlib`
97
+
98
+ ## Forbidden Builtins (verbatim from `env/server/repl.py::_FORBIDDEN_NAMES`)
99
+
100
+ `open, exec, eval, compile, __import__, input, breakpoint, help, exit, quit,
101
+ getattr, setattr, delattr, hasattr, __builtins__, globals, locals, vars, dir`
102
+
103
+ ## Platform Caveat — Windows-on-Dev-Laptop vs POSIX-at-Venue
104
+
105
+ This cert was generated on the dev laptop (Windows 11, Python 3.11.13).
106
+ `env/server/repl.py` has layered defenses: the RestrictedPython AST filter,
107
+ the restricted-builtins dict, the `_safe_import` whitelist, subprocess
108
+ isolation with `-I`, minimal env (`PATH` + `PYTHONDONTWRITEBYTECODE` only),
109
+ ephemeral cwd wipe, and the wall-clock timeout are **active on both OSes**.
110
+
111
+ The POSIX-only layers (`RLIMIT_AS=512MiB`, `RLIMIT_CPU=5s`, `RLIMIT_FSIZE=0`,
112
+ `RLIMIT_NOFILE=64`, `os.setsid` + `os.killpg` on timeout) are **not
113
+ exercised on Windows**. `test_memory_bomb` is correctly skipped with a
114
+ `pytest.mark.skipif sys.platform == "win32"` guard because the RLIMIT_AS
115
+ kill is the mechanism being tested.
116
+
117
+ **Action for venue (Phase 1 ENV-10 pre-deploy):** re-run `pytest
118
+ tests/test_repl_sandbox.py -v` inside the Linux A100 container and
119
+ regenerate this cert with the new SHA. All 10 tests should pass there
120
+ (no skips). Document that re-verification in Phase 1's plan summary.
121
+
122
+ ## Next Step
123
+
124
+ Phase 1 may proceed with ENV-10 (HF Space deploy). This file will be
125
+ re-verified in the first Phase 1 plan as part of the deploy-gate
126
+ precondition check. If the venue re-run yields any FAIL, revert deploy and
127
+ patch `env/server/repl.py` before the cert is re-written with a new commit
128
+ SHA (T-00-16 repudiation mitigation).
.planning/STATE.md ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ gsd_state_version: 1.0
3
+ milestone: v1.0
4
+ milestone_name: milestone
5
+ status: executing
6
+ stopped_at: Completed 00-03-adversarial-sandbox-suite-PLAN.md
7
+ last_updated: "2026-04-24T03:37:03.299Z"
8
+ last_activity: 2026-04-24
9
+ progress:
10
+ total_phases: 4
11
+ completed_phases: 0
12
+ total_plans: 5
13
+ completed_plans: 3
14
+ percent: 60
15
+ ---
16
+
17
+ # Project State
18
+
19
+ ## Project Reference
20
+
21
+ See: .planning/PROJECT.md (updated 2026-04-23)
22
+
23
+ **Core value:** A 1.5B model trained on our environment solves 200K-token QA tasks its own 4K context cannot hold — clean reward curve + selective recursion-tree viz + Pareto frontier of accuracy-vs-tokens.
24
+ **Current focus:** Phase 0 — Thu Evening Foundation
25
+
26
+ ## Current Position
27
+
28
+ Phase: 0 (Thu Evening Foundation) — EXECUTING
29
+ Plan: 3 of 5
30
+ Status: Ready to execute
31
+ Last activity: 2026-04-24
32
+
33
+ Progress: [██░░░░░░░░] 20%
34
+
35
+ ## Performance Metrics
36
+
37
+ **Velocity:**
38
+
39
+ - Total plans completed: 0
40
+ - Average duration: — min
41
+ - Total execution time: 0.0 hours
42
+
43
+ **By Phase:**
44
+
45
+ | Phase | Plans | Total | Avg/Plan |
46
+ |-------|-------|-------|----------|
47
+ | 0. Thu Evening Foundation | 0 | — | — |
48
+ | 1. Fri Parallel Build | 0 | — | — |
49
+ | 2. Sat Training Day | 0 | — | — |
50
+ | 3. Sun Demo + Pitch | 0 | — | — |
51
+
52
+ **Recent Trend:**
53
+
54
+ - Last 5 plans: none
55
+ - Trend: N/A (just starting)
56
+
57
+ *Updated after each plan completion.*
58
+ | Phase 00-thu-evening-foundation P01-packaging-scaffold | 7 | 3 tasks | 13 files |
59
+ | Phase 00-thu-evening-foundation P03-adversarial-sandbox-suite | 20 | 2 tasks | 2 files |
60
+
61
+ ## Accumulated Context
62
+
63
+ ### Decisions
64
+
65
+ Full decision log in PROJECT.md Key Decisions table. Recent decisions affecting Phase 0:
66
+
67
+ - Pre-phase: Cut 3B stretch (venue A100 flavor unknown, 40-hour budget focused on 1.5B polish).
68
+ - Pre-phase: $15-25 Claude Haiku SFT trace budget approved (200 traces) + 300 template-generated fallback = 500 total SFT traces.
69
+ - Pre-phase: Streamlit chosen as demo UI default; 30-min Thu/Fri bake-off against Gradio to confirm D3 tree renderer.
70
+ - Pre-phase: Stack pinned per research/STACK.md §1 — TRL 1.2.0, Unsloth 2026.4.8, OpenEnv 0.2.3, vLLM 0.7.3 colocate mode, Qwen2.5-Coder-1.5B-Instruct-bnb-4bit, Python 3.11. No `pip install -U` at venue.
71
+ - [Phase 00-thu-evening-foundation]: Hand-rolled env/ scaffold at repo root (not src/envs/fathom/) to match ROADMAP literal env.server.app:app; training deps moved to [train] optional extra; tiktoken promoted to base for D-07 Phase 0 llm() stub
72
+ - [Phase 00-thu-evening-foundation]: Dockerfile USER fathom switch must come AFTER uv pip install --system (EACCES on root-owned /usr/local site-packages); runtime still drops to uid 1000 before CMD
73
+ - [Phase 00-thu-evening-foundation]: Plan 03 — Adversarial suite runs in Plan 01 verify venv; pytest added via uv pip install -e .[dev] (only pytest/iniconfig/pluggy added, zero pin drift)
74
+ - [Phase 00-thu-evening-foundation]: Plan 03 — _assert_contained accepts both exception-raised AND timed_out as containment signals; matches run_repl's two-channel failure model (D-03 timeout or child exception)
75
+ - [Phase 00-thu-evening-foundation]: Plan 03 — test_memory_bomb pytest.mark.skipif Windows (RLIMIT_AS POSIX-only); Phase 1 must re-run on Linux at venue for 10/10 verdict
76
+
77
+ ### Pending Todos
78
+
79
+ None yet (none captured via `/gsd-add-todo`).
80
+
81
+ ### Blockers/Concerns
82
+
83
+ - **Venue A100 flavor unknown (40GB vs 80GB)** — confirm first 10 min at venue Saturday morning; drives 3B go/no-go (currently cut, but config-flip available via Hydra).
84
+ - **`openenv push` stability** — new CLI; test Thursday during Phase 1 ENV-10. Manual `git push` fallback documented in STACK §8.4.
85
+ - **Hardik joins Friday** — Phase 0 is strict solo work for Pratham; Phase 1 is the first two-person sync.
86
+
87
+ ## Session Continuity
88
+
89
+ Last session: 2026-04-24T03:37:03.292Z
90
+ Stopped at: Completed 00-03-adversarial-sandbox-suite-PLAN.md
91
+ Resume file: None
.planning/config.json ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_profile": "balanced",
3
+ "commit_docs": true,
4
+ "parallelization": true,
5
+ "search_gitignored": false,
6
+ "brave_search": false,
7
+ "firecrawl": false,
8
+ "exa_search": false,
9
+ "git": {
10
+ "branching_strategy": "none",
11
+ "phase_branch_template": "gsd/phase-{phase}-{slug}",
12
+ "milestone_branch_template": "gsd/{milestone}-{slug}",
13
+ "quick_branch_template": null
14
+ },
15
+ "workflow": {
16
+ "research": true,
17
+ "plan_check": true,
18
+ "verifier": true,
19
+ "nyquist_validation": false,
20
+ "auto_advance": false,
21
+ "node_repair": true,
22
+ "node_repair_budget": 2,
23
+ "ui_phase": true,
24
+ "ui_safety_gate": true,
25
+ "text_mode": false,
26
+ "research_before_questions": false,
27
+ "discuss_mode": "discuss",
28
+ "skip_discuss": false,
29
+ "_auto_chain_active": false
30
+ },
31
+ "hooks": {
32
+ "context_warnings": true
33
+ },
34
+ "project_code": null,
35
+ "phase_naming": "sequential",
36
+ "agent_skills": {},
37
+ "mode": "yolo",
38
+ "granularity": "coarse"
39
+ }
.planning/phases/00-thu-evening-foundation/00-01-packaging-scaffold-PLAN.md ADDED
@@ -0,0 +1,406 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ phase: 00-thu-evening-foundation
3
+ plan: 01
4
+ type: execute
5
+ wave: 1
6
+ depends_on: []
7
+ files_modified:
8
+ - pyproject.toml
9
+ - .python-version
10
+ - env/__init__.py
11
+ - env/server/__init__.py
12
+ - env/server/app.py
13
+ - env/models.py
14
+ - env/client.py
15
+ - Dockerfile
16
+ - openenv.yaml
17
+ - README.md
18
+ - .gitignore
19
+ autonomous: true
20
+ requirements: [REP-01, REP-02, ENV-09]
21
+ must_haves:
22
+ truths:
23
+ - "`uv pip install -e .` succeeds from a clean Python 3.11 venv"
24
+ - "`docker build -t fathom-env .` exits 0 and produces a runnable image"
25
+ - "`python -m uvicorn env.server.app:app --host 0.0.0.0 --port 8001` starts without import errors"
26
+ artifacts:
27
+ - path: pyproject.toml
28
+ provides: "pinned dependencies per STACK.md §1"
29
+ contains: "openenv-core[core]>=0.2.3,<0.3"
30
+ - path: Dockerfile
31
+ provides: "reproducible env-server image (REP-02)"
32
+ contains: "python:3.11-slim"
33
+ - path: env/server/app.py
34
+ provides: "FastAPI entry point module"
35
+ exports: ["app"]
36
+ - path: env/__init__.py
37
+ provides: "package root for `env.server.app:app`"
38
+ key_links:
39
+ - from: pyproject.toml
40
+ to: Dockerfile
41
+ via: "`pip install -e .` inside image"
42
+ pattern: "pip install.*-e .*"
43
+ - from: env/server/app.py
44
+ to: openenv-core
45
+ via: "import of create_app / Environment primitives"
46
+ pattern: "from openenv"
47
+ ---
48
+
49
+ <objective>
50
+ Bootstrap the FATHOM repository with the locked dependency set from STACK.md §1, scaffold the OpenEnv FastAPI layout at `env/` (NOT `src/envs/fathom/`), and prove `uv pip install -e .` + `docker build` both work. This is the reproducibility floor — REP-01 and REP-02 — and every subsequent plan depends on the `env.server.app:app` import path being resolvable.
51
+
52
+ Purpose: Before any REPL, Pydantic model, or HTTP handler is written, the packaging and scaffold must resolve. Discovering a missing pin or a broken Dockerfile at Phase 1 would cost the HF Space deploy gate.
53
+ Output: A repo that imports cleanly, has pinned deps, and builds a Docker image. Empty-but-importable `env/server/app.py` with a FastAPI `app` placeholder wired to the OpenEnv `create_app` factory stub.
54
+ </objective>
55
+
56
+ <execution_context>
57
+ @$HOME/.claude/get-shit-done/workflows/execute-plan.md
58
+ @$HOME/.claude/get-shit-done/templates/summary.md
59
+ </execution_context>
60
+
61
+ <context>
62
+ @.planning/PROJECT.md
63
+ @.planning/ROADMAP.md
64
+ @.planning/STATE.md
65
+ @.planning/phases/00-thu-evening-foundation/00-CONTEXT.md
66
+ @.planning/research/STACK.md
67
+ @.planning/research/ARCHITECTURE.md
68
+ @CLAUDE.md
69
+ </context>
70
+
71
+ <tasks>
72
+
73
+ <task type="auto">
74
+ <name>Task 1: Write pyproject.toml with STACK.md §1 pins verbatim</name>
75
+ <files>pyproject.toml, .python-version, .gitignore</files>
76
+ <read_first>
77
+ - .planning/research/STACK.md §1 (version pins table) and §2 (pyproject.toml snippet)
78
+ - .planning/phases/00-thu-evening-foundation/00-CONTEXT.md `<canonical_refs>` section
79
+ - CLAUDE.md "Technology Stack" section for version cross-check
80
+ </read_first>
81
+ <action>
82
+ Create `pyproject.toml` with PEP 621 layout using `hatchling` as build backend. Use this EXACT dependency list, copied verbatim from STACK.md §2:
83
+
84
+ ```toml
85
+ [project]
86
+ name = "fathom"
87
+ version = "0.1.0"
88
+ description = "FATHOM — First RL-Trained Recursive Language Model"
89
+ requires-python = ">=3.11,<3.12"
90
+ dependencies = [
91
+ "openenv-core[core]>=0.2.3,<0.3",
92
+ "fastapi>=0.115,<0.120",
93
+ "uvicorn[standard]>=0.32,<1.0",
94
+ "pydantic>=2.9,<3.0",
95
+ "RestrictedPython>=7.4,<8.0",
96
+ "tiktoken>=0.7,<1.0",
97
+ ]
98
+
99
+ [project.optional-dependencies]
100
+ train = [
101
+ "torch==2.6.0",
102
+ "transformers==4.49.0",
103
+ "accelerate==1.5.2",
104
+ "peft==0.14.0",
105
+ "bitsandbytes==0.45.1",
106
+ "trl==1.2.0",
107
+ "unsloth==2026.4.8",
108
+ "unsloth-zoo",
109
+ "vllm==0.7.3",
110
+ "datasets==3.3.2",
111
+ "huggingface_hub>=0.28,<1.0",
112
+ "wandb>=0.18,<1.0",
113
+ ]
114
+ viz = [
115
+ "streamlit>=1.39,<2.0",
116
+ "plotly>=5.24,<6.0",
117
+ "matplotlib>=3.9,<4.0",
118
+ ]
119
+ data = [
120
+ "anthropic>=0.40,<1.0",
121
+ ]
122
+ dev = [
123
+ "pytest>=8.0,<9.0",
124
+ "httpx>=0.27,<1.0",
125
+ ]
126
+
127
+ [build-system]
128
+ requires = ["hatchling"]
129
+ build-backend = "hatchling.build"
130
+
131
+ [tool.hatch.build.targets.wheel]
132
+ packages = ["env"]
133
+ ```
134
+
135
+ Phase 0 scope: the base dependency list is runtime-only for the env server + REPL + tests. Training extras (`.[train]`) and viz (`.[viz]`) are optional installs for Phase 1+ tracks. `tiktoken>=0.7,<1.0` is ADDED to the pinned list because D-07 in CONTEXT.md mandates cl100k_base token counting in the `llm()` stub.
136
+
137
+ Also create `.python-version` containing just `3.11` (for pyenv/uv auto-select), and `.gitignore` containing at minimum:
138
+ ```
139
+ __pycache__/
140
+ *.pyc
141
+ .venv/
142
+ fathom/
143
+ outputs/
144
+ *.egg-info/
145
+ .pytest_cache/
146
+ /tmp/episode-*/
147
+ dist/
148
+ build/
149
+ ```
150
+ </action>
151
+ <verify>
152
+ <automated>grep -q "openenv-core\[core\]>=0.2.3,<0.3" pyproject.toml && grep -q "RestrictedPython>=7.4,<8.0" pyproject.toml && grep -q "fastapi>=0.115,<0.120" pyproject.toml && grep -q "pydantic>=2.9,<3.0" pyproject.toml && grep -q "tiktoken>=0.7,<1.0" pyproject.toml && grep -q "uvicorn\[standard\]>=0.32" pyproject.toml</automated>
153
+ </verify>
154
+ <acceptance_criteria>
155
+ - `pyproject.toml` exists at repo root
156
+ - `grep -c "openenv-core\[core\]>=0.2.3,<0.3" pyproject.toml` returns `1`
157
+ - `grep -c "RestrictedPython>=7.4,<8.0" pyproject.toml` returns `1`
158
+ - `grep -c "fastapi>=0.115,<0.120" pyproject.toml` returns `1`
159
+ - `grep -c "pydantic>=2.9,<3.0" pyproject.toml` returns `1`
160
+ - `grep -c "tiktoken>=0.7,<1.0" pyproject.toml` returns `1`
161
+ - `grep -q 'requires-python = ">=3.11,<3.12"' pyproject.toml` succeeds
162
+ - `grep -q 'build-backend = "hatchling.build"' pyproject.toml` succeeds
163
+ - `.python-version` contains the literal `3.11`
164
+ - `.gitignore` contains `__pycache__/` and `/tmp/episode-*/`
165
+ </acceptance_criteria>
166
+ <done>Pyproject pins match STACK.md §1 verbatim; hatchling build backend declares `env` as the wheel package; Python 3.11 constraint enforced.</done>
167
+ </task>
168
+
169
+ <task type="auto">
170
+ <name>Task 2: Scaffold env/ package layout and FastAPI entry point</name>
171
+ <files>env/__init__.py, env/server/__init__.py, env/server/app.py, env/models.py, env/client.py, env/data/phase0_fixture.jsonl, openenv.yaml, README.md</files>
172
+ <read_first>
173
+ - .planning/research/ARCHITECTURE.md §1 (component table) and §2 (file layout)
174
+ - .planning/research/STACK.md §8 (`openenv init` scaffold workflow)
175
+ - .planning/phases/00-thu-evening-foundation/00-CONTEXT.md `<code_context>` (note: ROADMAP literal is `env.server.app:app`, NOT `src.envs.fathom.server.app:app` — we use `env/` at repo root)
176
+ - pyproject.toml (just written — hatchling packages=["env"] must match)
177
+ </read_first>
178
+ <action>
179
+ CONTEXT.md `<code_context>` clarifies that `openenv init` scaffolds `src/envs/fathom/server/` by default but the ROADMAP success criteria literal is `env.server.app:app`. We do NOT run `openenv init`; instead we write the scaffold by hand at `env/` at repo root to match the contract. The Dockerfile (next task) and openenv.yaml still follow OpenEnv conventions.
180
+
181
+ Create the following files:
182
+
183
+ 1. `env/__init__.py` — empty file (placeholder; Plan 04 will add re-exports of `FathomAction`, `FathomObservation`, etc.)
184
+
185
+ 2. `env/server/__init__.py` — empty file
186
+
187
+ 3. `env/server/app.py` — minimal FastAPI app placeholder. Plan 04 will fill in `create_app(factory=..., action_cls=..., observation_cls=..., max_concurrent_envs=64)`. For Phase 0 Plan 01, write:
188
+ ```python
189
+ """FATHOM env server entry point. Wired in Plan 04."""
190
+ from fastapi import FastAPI
191
+
192
+ app = FastAPI(title="fathom-env", version="0.1.0")
193
+
194
+
195
+ @app.get("/healthz")
196
+ def healthz() -> dict[str, str]:
197
+ return {"status": "ok"}
198
+ ```
199
+ This satisfies the ROADMAP literal `python -m uvicorn env.server.app:app` immediately. Plan 04 replaces the body with the OpenEnv `create_app` wiring once the `FathomEnvironment` class exists.
200
+
201
+ 4. `env/models.py` — stub file that Plan 04 will populate:
202
+ ```python
203
+ """Pydantic schemas for FATHOM env. Populated in Plan 04 (ENV-02)."""
204
+ ```
205
+
206
+ 5. `env/client.py` — stub file that Plan 04 will populate:
207
+ ```python
208
+ """FathomEnvClient — trainer-side HTTP client. Populated in Plan 04."""
209
+ ```
210
+
211
+ 6. `env/data/phase0_fixture.jsonl` — placeholder with empty file (Plan 04 writes the 3 fixture rows per D-08/D-09).
212
+
213
+ 7. `openenv.yaml` at repo root (OpenEnv metadata manifest per STACK.md §8.1):
214
+ ```yaml
215
+ name: fathom-env
216
+ version: 0.1.0
217
+ description: "FATHOM RLM env — Python REPL + llm() primitive for long-context QA"
218
+ entrypoint: env.server.app:app
219
+ max_concurrent_envs: 64
220
+ ```
221
+
222
+ 8. `README.md` — a minimal repo README with:
223
+ ```markdown
224
+ # FATHOM — First RL-Trained Recursive Language Model
225
+
226
+ OpenEnv environment + GRPO training pipeline that teaches Qwen2.5-Coder-1.5B to use a Python REPL + sub-LM calls to answer questions about documents 50x its context window.
227
+
228
+ ## Phase 0 Local Run
229
+
230
+ ```bash
231
+ uv venv fathom --python 3.11
232
+ source fathom/bin/activate # On Windows: fathom\Scripts\activate
233
+ uv pip install -e .
234
+ python -m uvicorn env.server.app:app --host 0.0.0.0 --port 8001
235
+ ```
236
+
237
+ Check health: `curl http://localhost:8001/healthz` → `{"status":"ok"}`
238
+
239
+ See `.planning/ROADMAP.md` for the four-phase plan.
240
+ ```
241
+
242
+ CRITICAL: Do NOT invoke `openenv init` — it scaffolds into `src/envs/fathom/` and would conflict with our `env/` layout. We are deliberately hand-rolling the OpenEnv-compliant structure at `env/` because ROADMAP Success Criterion #1 uses `env.server.app:app` as its literal.
243
+ </action>
244
+ <verify>
245
+ <automated>python -c "import env.server.app; print(type(env.server.app.app).__name__)"</automated>
246
+ </verify>
247
+ <acceptance_criteria>
248
+ - `env/__init__.py` exists
249
+ - `env/server/__init__.py` exists
250
+ - `env/server/app.py` exists and contains the literal `app = FastAPI`
251
+ - `env/models.py` exists (can be a stub)
252
+ - `env/client.py` exists (can be a stub)
253
+ - `env/data/phase0_fixture.jsonl` exists (can be empty for now)
254
+ - `openenv.yaml` contains `entrypoint: env.server.app:app`
255
+ - `README.md` contains the literal command `python -m uvicorn env.server.app:app`
256
+ - After `uv pip install -e .`, `python -c "from env.server.app import app"` exits 0
257
+ - After starting uvicorn, `curl http://localhost:8001/healthz` returns JSON containing `"status":"ok"`
258
+ </acceptance_criteria>
259
+ <done>Importable `env` package with a FastAPI `app` at `env.server.app:app`, `openenv.yaml` metadata manifest, README with local-run quickstart. Scaffold is ready for Plan 02 (REPL) and Plan 04 (env core) to populate.</done>
260
+ </task>
261
+
262
+ <task type="auto">
263
+ <name>Task 3: Write Dockerfile and verify `docker build` + fresh-venv install both succeed</name>
264
+ <files>Dockerfile, .dockerignore</files>
265
+ <read_first>
266
+ - .planning/research/STACK.md §8 (OpenEnv scaffold; Docker uses python:3.11-slim + uv)
267
+ - .planning/research/ARCHITECTURE.md §1 (env server is FastAPI on port configurable, HF Space defaults to 7860)
268
+ - pyproject.toml (written in Task 1 — Dockerfile installs from it)
269
+ - openenv.yaml (written in Task 2 — entrypoint reference)
270
+ </read_first>
271
+ <action>
272
+ Create `Dockerfile` at repo root with a reproducible build (REP-02). Target HF Space default port 7860 (ARCHITECTURE.md §8 + STACK.md §8.4). Use python:3.11-slim and `uv` for install speed. Non-root user matches STACK §7 requirement for the REPL sandbox to run as uid 1000.
273
+
274
+ ```dockerfile
275
+ # syntax=docker/dockerfile:1.7
276
+ FROM python:3.11-slim AS base
277
+
278
+ # System deps: gcc for any wheel builds, procps for ps (useful for REPL debug), tini for PID 1
279
+ RUN apt-get update && apt-get install -y --no-install-recommends \
280
+ gcc \
281
+ g++ \
282
+ procps \
283
+ tini \
284
+ && rm -rf /var/lib/apt/lists/*
285
+
286
+ # Install uv (fast Python package installer)
287
+ RUN pip install --no-cache-dir uv==0.5.11
288
+
289
+ # Create non-root user (uid 1000) per STACK §7
290
+ RUN useradd --create-home --shell /bin/bash --uid 1000 fathom
291
+ USER fathom
292
+ ENV PATH="/home/fathom/.local/bin:${PATH}"
293
+ WORKDIR /home/fathom/app
294
+
295
+ # Copy pyproject first for layer-cache efficiency
296
+ COPY --chown=fathom:fathom pyproject.toml ./
297
+ COPY --chown=fathom:fathom README.md ./
298
+
299
+ # Install base deps (NO [train]/[viz] extras for env-server image — those are venue-side)
300
+ RUN uv pip install --system --no-cache .
301
+
302
+ # Copy the rest of the package
303
+ COPY --chown=fathom:fathom env/ ./env/
304
+ COPY --chown=fathom:fathom openenv.yaml ./
305
+
306
+ # HF Space default port
307
+ EXPOSE 7860
308
+
309
+ ENTRYPOINT ["/usr/bin/tini", "--"]
310
+ CMD ["python", "-m", "uvicorn", "env.server.app:app", "--host", "0.0.0.0", "--port", "7860"]
311
+ ```
312
+
313
+ Also create `.dockerignore` to keep the build context small:
314
+ ```
315
+ .git/
316
+ .venv/
317
+ fathom/
318
+ outputs/
319
+ .planning/
320
+ .claude/
321
+ tests/
322
+ __pycache__/
323
+ *.pyc
324
+ .pytest_cache/
325
+ .python-version
326
+ research/
327
+ dist/
328
+ build/
329
+ *.egg-info/
330
+ ```
331
+
332
+ Validation: after writing the Dockerfile, the executor MUST:
333
+ 1. Create a clean venv: `uv venv .venv-phase0-verify --python 3.11 && source .venv-phase0-verify/bin/activate` (or Windows equivalent)
334
+ 2. Run `uv pip install -e .` — must succeed
335
+ 3. Run `python -c "from env.server.app import app; import fastapi, pydantic, RestrictedPython, tiktoken; print('ok')"` — must print `ok`
336
+ 4. Run `docker build -t fathom-env:phase0 .` — must exit 0
337
+ 5. Run `docker run --rm -d -p 7860:7860 --name fathom-env-smoke fathom-env:phase0` then `sleep 3 && curl http://localhost:7860/healthz` — must return `{"status":"ok"}`. Stop container: `docker stop fathom-env-smoke`.
338
+
339
+ If Docker is not available in the execution environment, skip steps 4-5 but DO document in the summary that docker build was not exercised. The fresh-venv install (steps 1-3) is the non-negotiable gate.
340
+ </action>
341
+ <verify>
342
+ <automated>test -f Dockerfile && test -f .dockerignore && grep -q "python:3.11-slim" Dockerfile && grep -q "uvicorn" Dockerfile && grep -q "env.server.app:app" Dockerfile && grep -q "EXPOSE 7860" Dockerfile && grep -q "uid 1000" Dockerfile && python -c "from env.server.app import app" 2>/dev/null</automated>
343
+ </verify>
344
+ <acceptance_criteria>
345
+ - `Dockerfile` exists at repo root
346
+ - `.dockerignore` exists at repo root
347
+ - `grep -q "FROM python:3.11-slim" Dockerfile` succeeds
348
+ - `grep -q "uv pip install" Dockerfile` succeeds
349
+ - `grep -q "EXPOSE 7860" Dockerfile` succeeds
350
+ - `grep -q "uvicorn.*env.server.app:app" Dockerfile` succeeds
351
+ - `grep -q "uid 1000" Dockerfile` succeeds (non-root user)
352
+ - `grep -q "tini" Dockerfile` succeeds (PID 1 signal handling)
353
+ - Clean venv + `uv pip install -e .` succeeds (exit 0)
354
+ - `python -c "from env.server.app import app"` exits 0 after install
355
+ - If Docker available: `docker build -t fathom-env:phase0 .` exits 0 AND `curl` to /healthz on port 7860 returns `{"status":"ok"}`
356
+ </acceptance_criteria>
357
+ <done>Dockerfile builds a self-contained env-server image on python:3.11-slim with pinned STACK §1 deps, non-root uid 1000, tini PID 1, port 7860, default command runs `uvicorn env.server.app:app`. Fresh-venv install verified to pick up all pinned versions.</done>
358
+ </task>
359
+
360
+ </tasks>
361
+
362
+ <threat_model>
363
+ ## Trust Boundaries
364
+
365
+ | Boundary | Description |
366
+ |----------|-------------|
367
+ | developer → repo | local files; trusted write path |
368
+ | Docker build context → image | files under build context; controlled by .dockerignore |
369
+ | Dockerfile FROM base → runtime | upstream python:3.11-slim; accept Debian security posture |
370
+
371
+ ## STRIDE Threat Register
372
+
373
+ | Threat ID | Category | Component | Disposition | Mitigation Plan |
374
+ |-----------|----------|-----------|-------------|-----------------|
375
+ | T-00-01 | T (Tampering) | pyproject.toml pins | mitigate | Pins copied verbatim from STACK.md §1; `grep` checks in acceptance criteria catch accidental drift. |
376
+ | T-00-02 | I (Information disclosure) | Docker image | mitigate | `.dockerignore` excludes `.planning/`, `.claude/`, `research/`, `.git/` so drafts/secrets don't leak into the HF Space image. |
377
+ | T-00-03 | E (Elevation of privilege) | Docker runtime | mitigate | Dockerfile creates uid 1000 `fathom` user and switches `USER fathom` before installing or running code; prevents the REPL subprocess (Plan 02) from inheriting root. |
378
+ | T-00-04 | D (Denial of service) | Dockerfile ENTRYPOINT | mitigate | `tini` as PID 1 reaps orphaned REPL subprocesses; prevents zombie accumulation over long rollouts. |
379
+ | T-00-05 | S (Spoofing) | openenv-core version | accept | Pinned range `>=0.2.3,<0.3`; CLI is young and judges fetch via `openenv pull` — no identity trust model beyond pin + HF Hub integrity. |
380
+ </threat_model>
381
+
382
+ <verification>
383
+ All three tasks together prove:
384
+ 1. `grep` the 6 pinned versions in pyproject.toml (Task 1 acceptance criteria) — REP-01 verified
385
+ 2. `python -c "from env.server.app import app"` succeeds — scaffold is importable
386
+ 3. `curl http://localhost:8001/healthz → {"status":"ok"}` — FastAPI app serves
387
+ 4. `docker build -t fathom-env:phase0 . && docker run --rm -d -p 7860:7860 fathom-env:phase0 && curl localhost:7860/healthz` — REP-02 verified (Dockerfile builds a runnable image)
388
+ </verification>
389
+
390
+ <success_criteria>
391
+ - `pyproject.toml` contains all 6 Phase 0 runtime pins from STACK.md §1 verbatim (grep-verified)
392
+ - `env/server/app.py` exports `app` (FastAPI instance); `python -c "from env.server.app import app"` exits 0
393
+ - `openenv.yaml` declares `entrypoint: env.server.app:app`
394
+ - `README.md` has the exact local-run command `python -m uvicorn env.server.app:app`
395
+ - `Dockerfile` builds without error on python:3.11-slim; runs as uid 1000; exposes 7860; `/healthz` responds 200
396
+ - Fresh-venv `uv pip install -e .` on Python 3.11 succeeds end-to-end
397
+ </success_criteria>
398
+
399
+ <output>
400
+ After completion, create `.planning/phases/00-thu-evening-foundation/00-01-packaging-scaffold-SUMMARY.md` documenting:
401
+ - Every file created (pyproject.toml, Dockerfile, env/ scaffold)
402
+ - Pin verification grep outputs
403
+ - Fresh-venv install log excerpt
404
+ - Docker build log excerpt (if Docker was available; note otherwise)
405
+ - Any deviations from the verbatim STACK.md §1 pins (there should be zero)
406
+ </output>
.planning/phases/00-thu-evening-foundation/00-01-packaging-scaffold-SUMMARY.md ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ phase: 00-thu-evening-foundation
3
+ plan: 01
4
+ subsystem: infra
5
+ tags: [pyproject, hatchling, fastapi, uvicorn, openenv, docker, python-3.11, uv]
6
+
7
+ requires: []
8
+ provides:
9
+ - Pinned `pyproject.toml` with STACK.md §1 base deps (REP-01)
10
+ - Importable `env/` package at repo root with `env.server.app:app` FastAPI entrypoint
11
+ - `openenv.yaml` metadata manifest declaring `entrypoint: env.server.app:app` and `max_concurrent_envs: 64`
12
+ - Reproducible `Dockerfile` on python:3.11-slim, uid 1000 non-root, tini PID 1, port 7860 (REP-02)
13
+ - `.gitignore` / `.dockerignore` covering venvs, caches, episode dirs, planning drafts
14
+ - `README.md` with local-run quickstart
15
+ - Stub files for Plan 04 (`env/models.py`, `env/client.py`, `env/data/phase0_fixture.jsonl`)
16
+ affects: [00-02-hardened-repl, 00-03-adversarial-sandbox-suite, 00-04-env-core, 00-05-env-tests-exit-gate, 01-*-hf-space-deploy]
17
+
18
+ tech-stack:
19
+ added:
20
+ - "openenv-core[core]>=0.2.3,<0.3"
21
+ - "fastapi>=0.115,<0.120"
22
+ - "uvicorn[standard]>=0.32,<1.0"
23
+ - "pydantic>=2.9,<3.0"
24
+ - "RestrictedPython>=7.4,<8.0"
25
+ - "tiktoken>=0.7,<1.0"
26
+ - "hatchling (build backend)"
27
+ patterns:
28
+ - "env/ at repo root (NOT src/envs/fathom/) to match ROADMAP literal `env.server.app:app`"
29
+ - "PEP 621 pyproject with optional-dependencies extras: [train], [viz], [data], [dev]"
30
+ - "Docker layer cache: pyproject + README + env/ copied before `uv pip install --system .`"
31
+ - "Non-root runtime (uid 1000 `fathom`) via USER switch AFTER system install"
32
+ - "tini as PID 1 for orphaned-subprocess reaping (Plan 02 REPL subprocess lifecycle)"
33
+ - ".dockerignore excludes .planning/, .claude/, research/, .git/ so drafts never leak into HF Space image"
34
+
35
+ key-files:
36
+ created:
37
+ - pyproject.toml
38
+ - .python-version
39
+ - .gitignore
40
+ - env/__init__.py
41
+ - env/server/__init__.py
42
+ - env/server/app.py
43
+ - env/models.py
44
+ - env/client.py
45
+ - env/data/phase0_fixture.jsonl
46
+ - openenv.yaml
47
+ - README.md
48
+ - Dockerfile
49
+ - .dockerignore
50
+ modified: []
51
+
52
+ key-decisions:
53
+ - "Hand-rolled env/ scaffold instead of `openenv init` (which generates src/envs/fathom/) to match ROADMAP success-criterion literal `env.server.app:app`"
54
+ - "Training stack (torch, trl, unsloth, vllm, wandb) moved to `[train]` optional extra so Phase 0 env-server image stays slim and Phase 0 install is fast on CPU-only laptops"
55
+ - "tiktoken added to base deps (not just Phase 2) because D-07 mandates cl100k_base token counting in the Phase 0 `llm()` stub"
56
+ - "USER fathom switch placed AFTER `uv pip install --system` in Dockerfile because --system writes to root-owned /usr/local/lib/python3.11/site-packages"
57
+
58
+ patterns-established:
59
+ - "Atomic per-task commits with conventional-commit scope `(00-01)`"
60
+ - "Package importability verified via `from env.server.app import app` in a clean venv before any Dockerfile work"
61
+ - "Docker smoke test: build → run -d -p 7860:7860 → curl /healthz → stop, all in one verification block"
62
+
63
+ requirements-completed: [REP-01, REP-02, ENV-09]
64
+
65
+ duration: 7min
66
+ completed: 2026-04-23
67
+ ---
68
+
69
+ # Phase 0 Plan 01: Packaging + Scaffold Summary
70
+
71
+ **FATHOM repo bootstrapped with STACK.md §1 pinned deps, `env/`-at-root FastAPI scaffold importable as `env.server.app:app`, and a python:3.11-slim Dockerfile that builds a 997 MB uid-1000 non-root image serving GET /healthz on port 7860.**
72
+
73
+ ## Performance
74
+
75
+ - **Duration:** ~7 min (first to last task commit wall-clock)
76
+ - **Started:** 2026-04-23T20:30:00Z (approx)
77
+ - **Completed:** 2026-04-23T20:37:48Z
78
+ - **Tasks:** 3 / 3
79
+ - **Files created:** 13
80
+ - **Files modified:** 0
81
+
82
+ ## Accomplishments
83
+
84
+ - Pinned base-runtime dependency surface in `pyproject.toml` matching STACK.md §1 verbatim; training/viz/data/dev dependencies sequestered in optional extras so Phase 0 install is CPU-laptop-runnable.
85
+ - `env/server/app.py` exposes a minimal FastAPI `app` with a `/healthz` probe — the ROADMAP literal `env.server.app:app` is now a live import target before Plan 02 touches it.
86
+ - `Dockerfile` produces a reproducible image on `python:3.11-slim` with tini as PID 1, non-root uid 1000 runtime, EXPOSE 7860, and `uv pip install --system .` for fast container builds. Smoke-tested: `docker run -d -p 7860:7860 fathom-env:phase0` → `curl /healthz` returns `{"status":"ok"}`.
87
+ - `openenv.yaml` + `.dockerignore` + `README.md` round out the packaging artifacts that Phase 1's HF Space deploy (ENV-10) will consume unchanged.
88
+
89
+ ## Task Commits
90
+
91
+ Each task was committed atomically on `master`:
92
+
93
+ 1. **Task 1: `pyproject.toml` + `.python-version` + `.gitignore`** — `4104345` (chore)
94
+ 2. **Task 2: `env/` package scaffold + FastAPI entrypoint + `openenv.yaml` + `README.md`** — `e2e17aa` (feat)
95
+ 3. **Task 3: `Dockerfile` + `.dockerignore`** — `37c8374` (feat)
96
+
97
+ _Plan metadata commit follows after this SUMMARY is written._
98
+
99
+ ## Files Created/Modified
100
+
101
+ ### Created
102
+
103
+ - `pyproject.toml` — PEP 621 layout; hatchling build backend; 6 base deps (`openenv-core[core]>=0.2.3,<0.3`, `fastapi>=0.115,<0.120`, `uvicorn[standard]>=0.32,<1.0`, `pydantic>=2.9,<3.0`, `RestrictedPython>=7.4,<8.0`, `tiktoken>=0.7,<1.0`); optional extras `train`, `viz`, `data`, `dev`.
104
+ - `.python-version` — literal `3.11` (uv/pyenv auto-select).
105
+ - `.gitignore` — covers `__pycache__/`, `.venv/`, `.venv-*/`, `fathom/`, `outputs/`, `*.egg-info/`, `.pytest_cache/`, `dist/`, `build/`, `/tmp/episode-*/`, `wandb/`.
106
+ - `env/__init__.py`, `env/server/__init__.py` — empty package roots (Plan 04 populates re-exports).
107
+ - `env/server/app.py` — minimal FastAPI app, `app = FastAPI(...)`, GET `/healthz` → `{"status":"ok"}`. Plan 04 replaces with `openenv-core.create_app(factory=..., action_cls=..., observation_cls=...)` once `FathomEnvironment` exists.
108
+ - `env/models.py` — docstring-only stub for Plan 04 (ENV-02 Pydantic schemas).
109
+ - `env/client.py` — docstring-only stub for Plan 04 (`FathomEnvClient`).
110
+ - `env/data/phase0_fixture.jsonl` — empty placeholder (Plan 04 writes 3 rows per D-08/D-09, matching final DATA-05 schema).
111
+ - `openenv.yaml` — `entrypoint: env.server.app:app`, `max_concurrent_envs: 64`.
112
+ - `README.md` — local-run quickstart with the exact `python -m uvicorn env.server.app:app` command.
113
+ - `Dockerfile` — python:3.11-slim base, `uv==0.5.11`, tini PID 1, uid 1000 `fathom` user at runtime, EXPOSE 7860, default CMD runs uvicorn on 0.0.0.0:7860.
114
+ - `.dockerignore` — keeps build context small (excludes `.planning/`, `.claude/`, `research/`, `.git/`, venvs, caches).
115
+
116
+ ### Modified
117
+
118
+ None.
119
+
120
+ ## Verification Evidence
121
+
122
+ ### Task 1 — pyproject grep checks (all passed)
123
+
124
+ ```
125
+ grep -c 'openenv-core\[core\]>=0.2.3,<0.3' pyproject.toml → 1
126
+ grep -c 'RestrictedPython>=7.4,<8.0' pyproject.toml → 1
127
+ grep -c 'fastapi>=0.115,<0.120' pyproject.toml → 1
128
+ grep -c 'pydantic>=2.9,<3.0' pyproject.toml → 1
129
+ grep -c 'tiktoken>=0.7,<1.0' pyproject.toml → 1
130
+ requires-python: OK
131
+ build-backend: OK
132
+ ```
133
+
134
+ ### Task 2 — fresh-venv install + import + uvicorn smoke
135
+
136
+ ```
137
+ uv venv .venv-phase0-verify --python 3.11
138
+ → Using CPython 3.11.13 (auto-downloaded)
139
+ VIRTUAL_ENV=.venv-phase0-verify uv pip install -e .
140
+ → Installed 114 packages (openenv-core 0.2.3, fastapi 0.119.1,
141
+ uvicorn 0.46.0, pydantic 2.13.3, restrictedpython 7.4,
142
+ tiktoken 0.12.0, fathom 0.1.0 editable)
143
+ python -c "from env.server.app import app; ...; print('ok')"
144
+ → ok
145
+ → app type: FastAPI
146
+ python -m uvicorn env.server.app:app --host 127.0.0.1 --port 8001 &
147
+ curl http://127.0.0.1:8001/healthz
148
+ → {"status":"ok"}
149
+ ```
150
+
151
+ ### Task 3 — docker build + run smoke
152
+
153
+ ```
154
+ docker build -t fathom-env:phase0 .
155
+ → #17 exporting manifest list sha256:9c3533099c9c92bcde92f45864027c165d431048036eb74c09a425743b639642
156
+ → naming to docker.io/library/fathom-env:phase0 done
157
+ → Image size: 997 MB
158
+ docker run --rm -d -p 7860:7860 --name fathom-env-smoke fathom-env:phase0
159
+ → d9a0c761e102...
160
+ curl http://127.0.0.1:7860/healthz
161
+ → {"status":"ok"}
162
+ Container logs:
163
+ → INFO: Uvicorn running on http://0.0.0.0:7860 (Press CTRL+C to quit)
164
+ → INFO: 172.17.0.1:53506 - "GET /healthz HTTP/1.1" 200 OK
165
+ docker stop fathom-env-smoke
166
+ → fathom-env-smoke
167
+ ```
168
+
169
+ ## Decisions Made
170
+
171
+ - **Hand-rolled `env/` scaffold over `openenv init`** — the CLI generates `src/envs/fathom/server/` by default (STACK §8.2), which contradicts the ROADMAP Phase 0 Success Criterion #1 literal `python -m uvicorn env.server.app:app`. Writing the OpenEnv-compliant structure by hand at repo root keeps the contract verbatim.
172
+ - **`[train]` / `[viz]` / `[data]` / `[dev]` as optional extras** — Phase 0's env-server image (and laptop install) does not need torch/trl/unsloth/vllm/wandb. Keeping them out of base dependencies makes the `Dockerfile` layer install complete in ~7 s on a warm cache and keeps the HF Space image slim.
173
+ - **`tiktoken` promoted to base deps** — D-07 in `00-CONTEXT.md` requires `cl100k_base` token counting in the Phase 0 `llm()` stub, so `tiktoken` runs in the env server (not just in training). Added to base pins explicitly.
174
+ - **`USER fathom` placed AFTER `uv pip install --system`** — the as-written plan ordering put `USER fathom` before the install, which EACCES'd on `/usr/local/lib/python3.11/site-packages/markdown_it_py-4.0.0.dist-info` (Rule 1 fix, see Deviations). Current ordering installs as root, then drops privileges before runtime, matching the STACK §7 threat model where uid 1000 only applies to the REPL subprocess lifecycle (which Plan 02 implements).
175
+
176
+ ## Deviations from Plan
177
+
178
+ ### Auto-fixed Issues
179
+
180
+ **1. [Rule 3 — Blocking] Dockerfile `COPY env/ ./env/` moved BEFORE `uv pip install .`**
181
+
182
+ - **Found during:** Task 3 first `docker build` attempt
183
+ - **Issue:** Plan wrote `COPY env/` AFTER `RUN uv pip install .`. Hatchling reads `[tool.hatch.build.targets.wheel] packages = ["env"]` during the install, so the `env/` directory must be present in the build context at install time. Install failed with "Unable to find package 'env' in the project".
184
+ - **Fix:** Reordered to copy `pyproject.toml`, `README.md`, `env/`, and `openenv.yaml` all before the `uv pip install --system .` step. Layer cache is still efficient because `pyproject.toml` rarely changes.
185
+ - **Files modified:** `Dockerfile`
186
+ - **Verification:** Second `docker build` proceeded past the install step.
187
+ - **Committed in:** `37c8374` (Task 3 commit — both fixes combined in single Dockerfile revision)
188
+
189
+ **2. [Rule 1 — Bug] `USER fathom` switch moved to AFTER `uv pip install --system`**
190
+
191
+ - **Found during:** Task 3 second `docker build` attempt
192
+ - **Issue:** Plan placed `USER fathom` and `WORKDIR /home/fathom/app` before the `uv pip install --system .` step. But `--system` writes into `/usr/local/lib/python3.11/site-packages/`, which is owned by root in the `python:3.11-slim` base image. Install failed with `failed to create directory .../markdown_it_py-4.0.0.dist-info: Permission denied (os error 13)`.
193
+ - **Fix:** Moved `USER fathom` + `ENV PATH=...` to AFTER the install RUN. Runtime still drops to uid 1000 before CMD executes (Dockerfile line 35), satisfying STACK §7's threat model. Comment added in-file to explain the ordering so Plan 02's REPL sandbox implementer does not "fix" it back.
194
+ - **Files modified:** `Dockerfile`
195
+ - **Verification:** Third `docker build` succeeded; `docker run` launched container as uid 1000 (verified by `docker exec fathom-env-smoke id` yielded `uid=1000(fathom)` during iteration — then container stopped for final smoke test).
196
+ - **Committed in:** `37c8374` (Task 3 commit — see note above)
197
+
198
+ ---
199
+
200
+ **Total deviations:** 2 auto-fixed (both Dockerfile ordering fixes in the same Task 3 commit — the Dockerfile was never committed in its plan-literal broken form).
201
+ **Impact on plan:** Both fixes necessary for correctness — without them the `docker build` step (non-negotiable per Task 3 acceptance criteria) would fail. Runtime security posture (uid 1000 at CMD time, tini PID 1) is preserved. No scope creep.
202
+
203
+ ## Issues Encountered
204
+
205
+ - **Docker Desktop daemon not running at session start** — resolved by launching `Docker Desktop.exe` in the background; daemon came up in ~6 s. Docker build + run smoke test proceeded on the main working tree.
206
+ - **System Python is 3.10 on the host** — not an issue; `uv venv --python 3.11` auto-downloaded CPython 3.11.13 into the venv (matches `.python-version` and `requires-python = ">=3.11,<3.12"` pin).
207
+
208
+ ## Known Stubs
209
+
210
+ The following files are **intentional stubs declared in the plan's `<action>` block** — they are not covert incomplete work. Plan 04 (ENV-04 env-core) populates them with production content.
211
+
212
+ | File | Current state | Filled by |
213
+ |------|---------------|-----------|
214
+ | `env/models.py` | Docstring-only stub | Plan 04 (Pydantic schemas: FathomAction, FathomObservation, FathomState, FathomStepResult) |
215
+ | `env/client.py` | Docstring-only stub | Plan 04 (`FathomEnvClient` trainer-side HTTP client) |
216
+ | `env/data/phase0_fixture.jsonl` | Empty file | Plan 04 (3 rows matching DATA-05 schema: trivial NIAH, medium multi-needle, counting) |
217
+ | `env/server/app.py` | Minimal FastAPI placeholder with `/healthz` | Plan 04 (replaces body with `openenv-core.create_app(...)`) |
218
+
219
+ Each stub exists because **the packaging and scaffold must resolve as a unit** before Plans 02–04 can populate the bodies — otherwise `uv pip install -e .` would fail on missing Python modules referenced by the hatchling wheel packaging. Plan 01's goal is *importability*, not behavior.
220
+
221
+ ## User Setup Required
222
+
223
+ None — no external services, no secrets, no dashboards. All work is local scaffolding.
224
+
225
+ ## Threat Flags
226
+
227
+ None — nothing created in this plan introduces network endpoints, auth paths, or file access outside the plan's declared `threat_model` (which already covers the Dockerfile + pyproject + scaffold surface). The `/healthz` endpoint is read-only, unauthenticated, and returns a static literal — documented in the plan's trust-boundary table and the `.dockerignore` mitigation for T-00-02.
228
+
229
+ ## Next Phase Readiness
230
+
231
+ **Ready for Plan 02 (hardened REPL):**
232
+ - `env/server/` package importable; Plan 02 adds `env/server/repl.py` + subprocess sandbox.
233
+ - `RestrictedPython>=7.4,<8.0` pinned and installed.
234
+ - `tiktoken>=0.7,<1.0` pinned for `llm()` token accounting.
235
+ - Dockerfile's uid 1000 runtime user is the user under which Plan 02's REPL subprocess will run — threat model aligned.
236
+
237
+ **Ready for Plan 04 (env-core):**
238
+ - `env/models.py`, `env/client.py`, `env/data/phase0_fixture.jsonl` stubs in place.
239
+ - `openenv.yaml` manifest ready for `openenv-core.create_app` factory wiring.
240
+
241
+ **No blockers or concerns** for subsequent Phase 0 plans.
242
+
243
+ ## Self-Check: PASSED
244
+
245
+ Verified after SUMMARY write:
246
+
247
+ ```
248
+ [ -f pyproject.toml ] → FOUND
249
+ [ -f .python-version ] → FOUND
250
+ [ -f .gitignore ] → FOUND
251
+ [ -f env/__init__.py ] → FOUND
252
+ [ -f env/server/__init__.py ] → FOUND
253
+ [ -f env/server/app.py ] → FOUND
254
+ [ -f env/models.py ] → FOUND
255
+ [ -f env/client.py ] → FOUND
256
+ [ -f env/data/phase0_fixture.jsonl ] → FOUND
257
+ [ -f openenv.yaml ] → FOUND
258
+ [ -f README.md ] → FOUND
259
+ [ -f Dockerfile ] → FOUND
260
+ [ -f .dockerignore ] → FOUND
261
+ git log --all | grep 4104345 → FOUND
262
+ git log --all | grep e2e17aa → FOUND
263
+ git log --all | grep 37c8374 → FOUND
264
+ ```
265
+
266
+ ---
267
+
268
+ *Phase: 00-thu-evening-foundation*
269
+ *Plan: 01-packaging-scaffold*
270
+ *Completed: 2026-04-23*
.planning/phases/00-thu-evening-foundation/00-02-hardened-repl-PLAN.md ADDED
@@ -0,0 +1,689 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ phase: 00-thu-evening-foundation
3
+ plan: 02
4
+ type: execute
5
+ wave: 2
6
+ depends_on: [00-01]
7
+ files_modified:
8
+ - env/server/repl.py
9
+ - env/server/__init__.py
10
+ - tests/__init__.py
11
+ autonomous: true
12
+ requirements: [ENV-04]
13
+ must_haves:
14
+ truths:
15
+ - "REPL accepts a Python code string + persistent globals_dict and returns stdout/stderr/globals as one JSON envelope"
16
+ - "RestrictedPython AST filter rejects attribute-access escapes at compile time"
17
+ - "Subprocess isolation enforces 512MB RLIMIT_AS, 5s CPU, no file writes, ephemeral /tmp/episode-{uuid} cwd"
18
+ - "30s wall-clock timeout kills subprocess without overwriting globals_dict (D-03)"
19
+ - "Whitelisted imports: re, json, math, statistics, collections, itertools, string, functools, operator"
20
+ - "Blocked imports: os, sys, subprocess, socket, urllib, http, requests, pathlib, io, ctypes, threading, multiprocessing, pickle, marshal, importlib"
21
+ artifacts:
22
+ - path: env/server/repl.py
23
+ provides: "RestrictedPython + subprocess-isolated REPL executor"
24
+ exports: ["run_repl", "ReplResult"]
25
+ contains: "from RestrictedPython import compile_restricted"
26
+ - path: tests/__init__.py
27
+ provides: "tests package marker so pytest can discover test_repl_sandbox.py in Plan 03"
28
+ key_links:
29
+ - from: env/server/repl.py
30
+ to: RestrictedPython library
31
+ via: "compile_restricted"
32
+ pattern: "from RestrictedPython import"
33
+ - from: env/server/repl.py
34
+ to: "subprocess.Popen child with preexec_fn (ulimit) on POSIX"
35
+ via: "pickled globals_dict over stdin, JSON envelope over stdout"
36
+ pattern: "subprocess\\.Popen"
37
+ - from: env/server/repl.py
38
+ to: "/tmp/episode-{uuid}"
39
+ via: "cwd argument on Popen, wiped on reset()"
40
+ pattern: "/tmp/episode-"
41
+ ---
42
+
43
+ <objective>
44
+ Implement the hardened Python REPL at `env/server/repl.py` per STACK.md §7 — the load-bearing security boundary for ENV-04 and the R3/H3 ruin-mode gate. The REPL must (a) compile submitted code with RestrictedPython to block attribute-access escapes, (b) execute in an isolated subprocess with strict rlimits, ephemeral cwd, and network deny, (c) carry a persistent `globals_dict` across steps per D-01/D-02/D-03, and (d) expose a `run_repl(code, globals_dict, llm_callable, timeout_s=30)` function that the env core (Plan 04) will call from `step()`.
45
+
46
+ Purpose: No adversarial test in Plan 03 can pass until this file exists. No `step()` handler in Plan 04 can run untrusted code without it. This plan writes the sandbox so Plan 03 can prove it.
47
+ Output: `env/server/repl.py` (~250-350 lines) + `tests/__init__.py` marker. No tests in this plan — tests live in Plan 03.
48
+ </objective>
49
+
50
+ <execution_context>
51
+ @$HOME/.claude/get-shit-done/workflows/execute-plan.md
52
+ @$HOME/.claude/get-shit-done/templates/summary.md
53
+ </execution_context>
54
+
55
+ <context>
56
+ @.planning/PROJECT.md
57
+ @.planning/ROADMAP.md
58
+ @.planning/STATE.md
59
+ @.planning/phases/00-thu-evening-foundation/00-CONTEXT.md
60
+ @.planning/research/STACK.md
61
+ @.planning/research/ARCHITECTURE.md
62
+ @.planning/research/PITFALLS.md
63
+ @CLAUDE.md
64
+ @pyproject.toml
65
+ @env/server/__init__.py
66
+
67
+ <interfaces>
68
+ <!-- Contracts downstream plans (03 adversarial tests, 04 env core) rely on. -->
69
+ <!-- Executor should export exactly these shapes. -->
70
+
71
+ From env/server/repl.py (to be created):
72
+ ```python
73
+ from dataclasses import dataclass
74
+ from typing import Callable, Any
75
+
76
+ @dataclass
77
+ class ReplResult:
78
+ stdout: str
79
+ stderr: str
80
+ exception: str | None # exception class name + message, or None
81
+ globals_dict: dict[str, Any] # updated globals after successful exec; UNCHANGED on timeout/exception
82
+ timed_out: bool
83
+ wall_time_s: float
84
+
85
+ def run_repl(
86
+ code: str,
87
+ globals_dict: dict[str, Any],
88
+ llm_callable: Callable[[str, str, int], str],
89
+ timeout_s: float = 30.0,
90
+ episode_id: str = "default",
91
+ ) -> ReplResult:
92
+ """Execute `code` in a RestrictedPython-compiled subprocess.
93
+
94
+ - `globals_dict` is pickled to the child via stdin; child returns updated dict via stdout JSON envelope.
95
+ - `llm_callable` is injected into the child's builtins as `llm`. Implementation detail: the child subprocess
96
+ cannot pickle a live callable bound to the server's model, so the child calls back to the parent via a
97
+ named pipe / stdin-stdout RPC protocol (see implementation notes below), OR the llm_callable is passed
98
+ as a string of Python source that the child imports. For Phase 0, because the `llm` stub is pure and
99
+ deterministic (D-05), it is acceptable to inject the stub's source code as a string and exec it in the
100
+ child's globals before running user code. See action for exact approach.
101
+ - On timeout or exception, `globals_dict` in the result is the UNCHANGED original (D-03 preserves prior state).
102
+ """
103
+ ```
104
+ </interfaces>
105
+ </context>
106
+
107
+ <tasks>
108
+
109
+ <task type="auto" tdd="true">
110
+ <name>Task 1: Write RestrictedPython compile wrapper with explicit builtins/imports whitelist</name>
111
+ <files>env/server/repl.py</files>
112
+ <behavior>
113
+ - Compiling `"print(1+1)"` via compile_restricted returns a code object with no compile errors
114
+ - Compiling `"().__class__.__base__.__subclasses__()"` raises or flags a RestrictedPython error (attribute `__class__` banned)
115
+ - Compiling `"import os"` raises — import hook whitelist rejects `os`
116
+ - Compiling `"import re; print(re.findall(r'\\d+', 'a1b2'))"` succeeds — `re` is whitelisted
117
+ - The `safe_builtins` dict contains exactly: len, range, enumerate, zip, map, filter, sorted, min, max, sum, any, all, print, str, int, float, bool, list, dict, tuple, set, frozenset (plus None, True, False)
118
+ - `safe_builtins` does NOT contain: open, exec, eval, compile, __import__, input, breakpoint, help, exit, quit, __builtins__, getattr, setattr, delattr, hasattr
119
+ </behavior>
120
+ <read_first>
121
+ - .planning/research/STACK.md §7 (sandbox choice + 7-attack checklist + whitelist/blocklist — COPY VERBATIM)
122
+ - .planning/research/PITFALLS.md H3 (sandbox escape ruin modes)
123
+ - .planning/phases/00-thu-evening-foundation/00-CONTEXT.md D-12 (REPL stdlib whitelist — STACK §7 verbatim)
124
+ - https://restrictedpython.readthedocs.io/en/latest/usage/index.html (compile_restricted, safe_builtins)
125
+ - CLAUDE.md section 7 (REPL sandbox requirements)
126
+ </read_first>
127
+ <action>
128
+ Create `env/server/repl.py`. This task writes ONLY the compile-side of the REPL (the in-process pre-flight). Subprocess execution is Task 2.
129
+
130
+ At the top of the file:
131
+
132
+ ```python
133
+ """FATHOM Python REPL sandbox.
134
+
135
+ Security design (per STACK.md §7, CLAUDE.md §7, PITFALLS.md H3):
136
+ 1. RestrictedPython AST-level filter (compile_restricted) blocks attribute-access escapes
137
+ (__class__, __subclasses__, __globals__, traceback walks).
138
+ 2. Subprocess isolation with RLIMIT_AS=512MB, RLIMIT_CPU=5s, RLIMIT_FSIZE=0 (no file writes),
139
+ cwd=/tmp/episode-{uuid}, start_new_session=True, network denied via unshare -n or seccomp.
140
+ 3. Whitelisted builtins + whitelisted import hook.
141
+ 4. 30s wall-clock hard kill; on timeout, caller's globals_dict is NOT overwritten (D-03).
142
+
143
+ Threat model: see Plan 02's <threat_model> block.
144
+ """
145
+ from __future__ import annotations
146
+
147
+ import json
148
+ import os
149
+ import pickle
150
+ import resource
151
+ import subprocess
152
+ import sys
153
+ import tempfile
154
+ import time
155
+ import uuid
156
+ from dataclasses import dataclass, field
157
+ from pathlib import Path
158
+ from typing import Any, Callable
159
+
160
+ from RestrictedPython import compile_restricted, safe_builtins as _rp_safe_builtins
161
+ from RestrictedPython.Guards import safe_globals as _rp_safe_globals
162
+ ```
163
+
164
+ Then define the exact whitelists (copy VERBATIM from STACK.md §7 and CONTEXT.md D-12):
165
+
166
+ ```python
167
+ # Allowed imports (STACK.md §7 + CLAUDE.md §7 verbatim; D-12)
168
+ ALLOWED_IMPORTS: frozenset[str] = frozenset({
169
+ "re", "json", "math", "statistics", "collections",
170
+ "itertools", "string", "functools", "operator",
171
+ })
172
+
173
+ # Explicitly blocked imports (STACK.md §7 + CLAUDE.md §7 verbatim)
174
+ BLOCKED_IMPORTS: frozenset[str] = frozenset({
175
+ "os", "sys", "subprocess", "socket", "urllib", "http", "requests",
176
+ "pathlib", "io", "ctypes", "threading", "multiprocessing",
177
+ "pickle", "marshal", "importlib",
178
+ })
179
+
180
+ # Builtins whitelist (CONTEXT.md D-12 + STACK.md §7)
181
+ _SAFE_BUILTIN_NAMES: tuple[str, ...] = (
182
+ "len", "range", "enumerate", "zip", "map", "filter",
183
+ "sorted", "min", "max", "sum", "any", "all", "print",
184
+ "str", "int", "float", "bool",
185
+ "list", "dict", "tuple", "set", "frozenset",
186
+ "abs", "round", "divmod", "pow",
187
+ "repr", "reversed", "iter", "next",
188
+ "isinstance", "issubclass", # required by RestrictedPython runtime
189
+ )
190
+
191
+ # Names explicitly removed even if RestrictedPython's safe_builtins includes them
192
+ _FORBIDDEN_NAMES: frozenset[str] = frozenset({
193
+ "open", "exec", "eval", "compile", "__import__",
194
+ "input", "breakpoint", "help", "exit", "quit",
195
+ "getattr", "setattr", "delattr", "hasattr", # attribute access is the #1 escape surface
196
+ "__builtins__", "globals", "locals", "vars", "dir",
197
+ })
198
+
199
+
200
+ def _build_safe_builtins() -> dict[str, Any]:
201
+ """Build a minimal builtins dict.
202
+
203
+ Starts from RestrictedPython.safe_builtins (pre-vetted) then narrows to our whitelist.
204
+ Returns a plain dict (NOT the module) so the user code cannot import through it
205
+ via `__builtins__.open(...)` — _FORBIDDEN_NAMES is the hard barrier against the
206
+ `getattr(__builtins__, 'open')` escape named in STACK.md §7 adversarial test #5.
207
+ """
208
+ b: dict[str, Any] = {}
209
+ # True / False / None are exposed via safe_builtins already
210
+ b.update({k: v for k, v in _rp_safe_builtins.items() if k in _SAFE_BUILTIN_NAMES})
211
+ # Add literals RestrictedPython expects
212
+ b["True"] = True
213
+ b["False"] = False
214
+ b["None"] = None
215
+ # Explicitly remove anything forbidden even if it slipped in
216
+ for k in _FORBIDDEN_NAMES:
217
+ b.pop(k, None)
218
+ return b
219
+
220
+
221
+ def _safe_import(name: str, globals=None, locals=None, fromlist=(), level=0):
222
+ """Whitelisted __import__ hook. Rejects anything not in ALLOWED_IMPORTS."""
223
+ if level != 0:
224
+ raise ImportError(f"Relative imports not allowed in sandbox (requested level={level})")
225
+ root = name.split(".")[0]
226
+ if root in BLOCKED_IMPORTS:
227
+ raise ImportError(f"Import of {name!r} is explicitly blocked in FATHOM REPL sandbox")
228
+ if root not in ALLOWED_IMPORTS:
229
+ raise ImportError(f"Import of {name!r} not in whitelist {sorted(ALLOWED_IMPORTS)}")
230
+ # Delegate to the real import only for whitelisted roots
231
+ import importlib # local import; NOT exposed to user code
232
+ return importlib.import_module(name)
233
+ ```
234
+
235
+ Then the compile wrapper:
236
+
237
+ ```python
238
+ class ReplCompileError(Exception):
239
+ """RestrictedPython rejected the source."""
240
+
241
+
242
+ def _compile_user_code(code: str) -> Any:
243
+ """Run RestrictedPython's AST filter. Raises ReplCompileError on policy violation.
244
+
245
+ STACK §7 adversarial test coverage:
246
+ - `().__class__.__base__.__subclasses__()` → compile_restricted flags `__class__` access.
247
+ - `import os` → Python compile succeeds but our _safe_import rejects at exec time.
248
+ - `__builtins__['open']` → compile_restricted flags subscript/attr on `__builtins__`.
249
+ """
250
+ compiled = compile_restricted(code, filename="<fathom_repl>", mode="exec")
251
+ if compiled is None:
252
+ raise ReplCompileError("RestrictedPython returned None (policy violation)")
253
+ return compiled
254
+ ```
255
+
256
+ Finally the result dataclass:
257
+
258
+ ```python
259
+ @dataclass
260
+ class ReplResult:
261
+ stdout: str = ""
262
+ stderr: str = ""
263
+ exception: str | None = None
264
+ globals_dict: dict[str, Any] = field(default_factory=dict)
265
+ timed_out: bool = False
266
+ wall_time_s: float = 0.0
267
+ ```
268
+
269
+ STOP after ReplResult is defined. Task 2 adds the subprocess executor (`run_repl`).
270
+ </action>
271
+ <verify>
272
+ <automated>python -c "from env.server.repl import _build_safe_builtins, _safe_import, _compile_user_code, ReplResult, ALLOWED_IMPORTS, BLOCKED_IMPORTS; b = _build_safe_builtins(); assert 'open' not in b and 'exec' not in b and 'eval' not in b and '__import__' not in b and 'getattr' not in b; assert 'len' in b and 'range' in b and 'print' in b; assert 'os' in BLOCKED_IMPORTS and 're' in ALLOWED_IMPORTS; _compile_user_code('print(1+1)'); import pytest"</automated>
273
+ </verify>
274
+ <acceptance_criteria>
275
+ - `env/server/repl.py` exists
276
+ - `grep -c "from RestrictedPython import compile_restricted" env/server/repl.py` returns `1`
277
+ - `grep -c "ALLOWED_IMPORTS" env/server/repl.py` returns at least `2` (definition + reference)
278
+ - `grep -q 'BLOCKED_IMPORTS' env/server/repl.py` — blocklist defined
279
+ - `grep -q '"re", "json", "math", "statistics", "collections"' env/server/repl.py` — whitelist verbatim
280
+ - `grep -q '"os", "sys", "subprocess", "socket"' env/server/repl.py` — blocklist verbatim
281
+ - `python -c "from env.server.repl import _build_safe_builtins; b = _build_safe_builtins(); assert 'open' not in b; assert 'exec' not in b; assert 'eval' not in b; assert '__import__' not in b; assert 'getattr' not in b"` exits 0
282
+ - `python -c "from env.server.repl import _compile_user_code; _compile_user_code('print(1+1)')"` exits 0
283
+ - `python -c "from env.server.repl import _compile_user_code, ReplCompileError;
284
+ try:
285
+ _compile_user_code('().__class__.__base__.__subclasses__()')
286
+ raise SystemExit(1)
287
+ except (ReplCompileError, SyntaxError):
288
+ pass"` exits 0 (the attribute-access escape is rejected at compile time)
289
+ - `tests/__init__.py` exists (empty file is fine)
290
+ </acceptance_criteria>
291
+ <done>RestrictedPython compile wrapper, builtins whitelist, import hook, and ReplResult dataclass are in place. Attribute-access escape is rejected at compile time; forbidden names (open/exec/eval/__import__/getattr) are absent from the safe builtins dict.</done>
292
+ </task>
293
+
294
+ <task type="auto" tdd="true">
295
+ <name>Task 2: Implement subprocess-isolated run_repl with rlimit + timeout + globals carryover</name>
296
+ <files>env/server/repl.py</files>
297
+ <behavior>
298
+ - `run_repl("x = 1 + 2", {}, stub_llm)` returns ReplResult with `exception is None` and `globals_dict['x'] == 3`
299
+ - `run_repl("print('hello')", {}, stub_llm)` returns `stdout == "hello\n"` and `exception is None`
300
+ - `run_repl("import time; time.sleep(60)", {}, stub_llm, timeout_s=2)` returns `timed_out=True` within ~2-3s and `globals_dict` equals the input globals unchanged (D-03)
301
+ - `run_repl("x = 1/0", {}, stub_llm)` returns `exception` containing "ZeroDivisionError" and `globals_dict` equals the input globals unchanged
302
+ - `run_repl("y = ctx[:10]", {"ctx": "hello world"}, stub_llm)` returns `globals_dict['y'] == "hello"` (persistent globals work across variables that were pre-populated)
303
+ - Invoking a second `run_repl` with the returned globals of a first call preserves state (stateful — D-01)
304
+ - After execution, `/tmp/episode-{uuid}` cwd does NOT contain any file (RLIMIT_FSIZE=0 prevents writes and cleanup removes the dir)
305
+ </behavior>
306
+ <read_first>
307
+ - env/server/repl.py (Task 1 output — builds on ALLOWED_IMPORTS, _build_safe_builtins, _compile_user_code, ReplResult)
308
+ - .planning/research/STACK.md §7 (subprocess-level defenses checklist — rlimits, start_new_session, cwd, network deny, timeout)
309
+ - .planning/research/ARCHITECTURE.md §1 Failure Modes (REPL timeout handling, line 269-271)
310
+ - .planning/phases/00-thu-evening-foundation/00-CONTEXT.md D-01, D-02, D-03 (stateful globals, pickle envelope, timeout semantics)
311
+ - CLAUDE.md section 7 (subprocess defenses, 30s timeout)
312
+ </read_first>
313
+ <action>
314
+ APPEND to `env/server/repl.py` (do NOT recreate the file — Task 1's content stays). Add:
315
+
316
+ ```python
317
+ # ───────────────────────────────────────────────────────────────────────────
318
+ # Subprocess-isolated executor
319
+ # ───────────────────────────────────────────────────────────────────────────
320
+ # The child subprocess receives (pickled globals, code string) on stdin,
321
+ # runs compile_restricted + exec in a locked-down namespace, and writes a JSON
322
+ # envelope {stdout, stderr, exception, globals_pickle_b64, timed_out=false} to stdout.
323
+ # Parent enforces the 30s wall-clock via subprocess.wait(timeout=...) and kills on
324
+ # expiry (D-03 — globals NOT overwritten on timeout).
325
+
326
+ _CHILD_RUNNER = r'''
327
+ import base64, json, pickle, sys, traceback, io, contextlib
328
+
329
+ # Re-derive the sandbox inside the child — we cannot trust anything from the parent
330
+ # getting imported here because we must NOT transitively expose `os`, `subprocess`, etc.
331
+ # to user code.
332
+ from RestrictedPython import compile_restricted, safe_builtins as _rp_safe_builtins
333
+
334
+ ALLOWED_IMPORTS = {__ALLOWED__}
335
+ BLOCKED_IMPORTS = {__BLOCKED__}
336
+ _SAFE_NAMES = {__SAFE_NAMES__}
337
+ _FORBIDDEN = {__FORBIDDEN__}
338
+
339
+ def _safe_import(name, globals=None, locals=None, fromlist=(), level=0):
340
+ if level != 0:
341
+ raise ImportError("Relative imports not allowed")
342
+ root = name.split(".")[0]
343
+ if root in BLOCKED_IMPORTS or root not in ALLOWED_IMPORTS:
344
+ raise ImportError(f"Import of {{name!r}} not in whitelist")
345
+ import importlib
346
+ return importlib.import_module(name)
347
+
348
+ def _stub_llm(prompt, chunk, depth=1):
349
+ """Child-side llm() stub. Real stub lives in env/server/llm_primitive.py (Plan 04).
350
+ For Phase 0 REPL task 2, the llm callable passed in must be serializable — we inject
351
+ a deterministic regex-echo source here so unit tests can run without Plan 04.
352
+ Plan 04 will replace this with the real injection mechanism once llm_primitive.py exists.
353
+ """
354
+ import re
355
+ m = re.search(r"\\b\\w{{3,}}\\b", prompt)
356
+ kw = m.group(0) if m else ""
357
+ if kw and kw in chunk:
358
+ idx = chunk.index(kw)
359
+ return chunk[idx:idx + 80]
360
+ return chunk[:200]
361
+
362
+ def _build_builtins():
363
+ b = {{k: v for k, v in _rp_safe_builtins.items() if k in _SAFE_NAMES}}
364
+ b["True"] = True; b["False"] = False; b["None"] = None
365
+ for k in _FORBIDDEN:
366
+ b.pop(k, None)
367
+ b["__import__"] = _safe_import
368
+ return b
369
+
370
+ def main():
371
+ payload = json.loads(sys.stdin.read())
372
+ code = payload["code"]
373
+ globals_pickle = base64.b64decode(payload["globals_b64"])
374
+ try:
375
+ user_globals = pickle.loads(globals_pickle)
376
+ except Exception as e:
377
+ print(json.dumps({{"stdout": "", "stderr": "", "exception": f"globals unpickle failed: {{e}}", "globals_b64": payload["globals_b64"]}}))
378
+ return
379
+
380
+ # Install our safe builtins and llm stub
381
+ user_globals["__builtins__"] = _build_builtins()
382
+ user_globals.setdefault("llm", _stub_llm)
383
+
384
+ stdout_buf = io.StringIO()
385
+ stderr_buf = io.StringIO()
386
+ exception_str = None
387
+ try:
388
+ compiled = compile_restricted(code, filename="<fathom_repl_child>", mode="exec")
389
+ if compiled is None:
390
+ raise SyntaxError("RestrictedPython rejected source")
391
+ with contextlib.redirect_stdout(stdout_buf), contextlib.redirect_stderr(stderr_buf):
392
+ exec(compiled, user_globals, user_globals)
393
+ except BaseException as e:
394
+ exception_str = f"{{type(e).__name__}}: {{e}}"
395
+
396
+ # Strip non-picklable values (builtins dict, functions we injected)
397
+ user_globals.pop("__builtins__", None)
398
+ user_globals.pop("llm", None)
399
+ try:
400
+ updated_b64 = base64.b64encode(pickle.dumps(user_globals)).decode("ascii")
401
+ except Exception as e:
402
+ # If user created unpicklable state, fall back to original
403
+ updated_b64 = payload["globals_b64"]
404
+ exception_str = (exception_str or "") + f" | globals pickle failed: {{e}}"
405
+
406
+ sys.stdout.write(json.dumps({{
407
+ "stdout": stdout_buf.getvalue(),
408
+ "stderr": stderr_buf.getvalue(),
409
+ "exception": exception_str,
410
+ "globals_b64": updated_b64,
411
+ }}))
412
+
413
+ if __name__ == "__main__":
414
+ main()
415
+ '''
416
+
417
+
418
+ def _render_child_runner() -> str:
419
+ import json as _json
420
+ return (_CHILD_RUNNER
421
+ .replace("{__ALLOWED__}", _json.dumps(sorted(ALLOWED_IMPORTS)))
422
+ .replace("{__BLOCKED__}", _json.dumps(sorted(BLOCKED_IMPORTS)))
423
+ .replace("{__SAFE_NAMES__}", _json.dumps(list(_SAFE_BUILTIN_NAMES)))
424
+ .replace("{__FORBIDDEN__}", _json.dumps(sorted(_FORBIDDEN_NAMES))))
425
+
426
+
427
+ def _set_rlimits_posix() -> None:
428
+ """preexec_fn for POSIX — enforces memory/CPU/file-size limits on the child.
429
+
430
+ Per STACK.md §7 subprocess-level defenses:
431
+ RLIMIT_AS = 512 MiB (hard cap on virtual address space)
432
+ RLIMIT_CPU = 5 s (hard cap on CPU time — 30s wall-clock is the outer bound)
433
+ RLIMIT_FSIZE = 0 (no file writes — hard block even if `open` slips through)
434
+ RLIMIT_NOFILE = 64 (cap file descriptors)
435
+ """
436
+ _BYTES_512_MIB = 512 * 1024 * 1024
437
+ resource.setrlimit(resource.RLIMIT_AS, (_BYTES_512_MIB, _BYTES_512_MIB))
438
+ resource.setrlimit(resource.RLIMIT_CPU, (5, 5))
439
+ resource.setrlimit(resource.RLIMIT_FSIZE, (0, 0))
440
+ resource.setrlimit(resource.RLIMIT_NOFILE, (64, 64))
441
+ # Detach from parent process group so SIGKILL on timeout kills the whole tree
442
+ os.setsid()
443
+
444
+
445
+ def run_repl(
446
+ code: str,
447
+ globals_dict: dict[str, Any],
448
+ llm_callable: Callable[[str, str, int], str] | None = None,
449
+ timeout_s: float = 30.0,
450
+ episode_id: str | None = None,
451
+ ) -> ReplResult:
452
+ """Execute `code` in an isolated subprocess with stateful globals.
453
+
454
+ Args:
455
+ code: Python source submitted by the model.
456
+ globals_dict: persistent globals from prior steps (may contain `ctx`, user-defined vars).
457
+ llm_callable: ignored in Phase 0 (child uses embedded regex-echo stub). Phase 1+ injects
458
+ the real Qwen-backed callable via a side channel.
459
+ timeout_s: wall-clock limit; default 30 (STACK §7).
460
+ episode_id: used to build the ephemeral cwd `/tmp/episode-{id}`; auto-generated if None.
461
+
462
+ Returns:
463
+ ReplResult. On timeout or exception, `globals_dict` in the result is the UNCHANGED
464
+ input dict (D-03 — prior state preserved).
465
+ """
466
+ eid = episode_id or uuid.uuid4().hex
467
+ # Ephemeral cwd (STACK §7). On Windows, tempfile.gettempdir() is cross-platform.
468
+ ep_cwd = Path(tempfile.gettempdir()) / f"episode-{eid}"
469
+ ep_cwd.mkdir(parents=True, exist_ok=True)
470
+
471
+ # Serialize globals for the child
472
+ import base64 as _b64
473
+ try:
474
+ globals_b64 = _b64.b64encode(pickle.dumps(globals_dict)).decode("ascii")
475
+ except Exception as e:
476
+ return ReplResult(
477
+ stderr=f"Parent failed to pickle globals_dict: {e}",
478
+ exception=f"PickleError: {e}",
479
+ globals_dict=dict(globals_dict),
480
+ )
481
+
482
+ payload = json.dumps({"code": code, "globals_b64": globals_b64})
483
+ runner_src = _render_child_runner()
484
+
485
+ # On POSIX, use preexec_fn for rlimits. On Windows (dev laptop), preexec_fn is unsupported;
486
+ # the rlimits become a no-op and we rely on the timeout + RestrictedPython layers.
487
+ preexec = _set_rlimits_posix if sys.platform != "win32" else None
488
+
489
+ # Network deny: prefer `unshare -n`. If not available (Windows, macOS, rootless),
490
+ # fall back to subprocess-level defenses only. The seccomp path is a Linux-only
491
+ # stretch that we do NOT implement in Phase 0 — blocklisted imports + RestrictedPython
492
+ # already remove the `socket` / `urllib` surface.
493
+ args = [sys.executable, "-I", "-c", runner_src]
494
+
495
+ started = time.monotonic()
496
+ try:
497
+ proc = subprocess.Popen(
498
+ args,
499
+ stdin=subprocess.PIPE,
500
+ stdout=subprocess.PIPE,
501
+ stderr=subprocess.PIPE,
502
+ cwd=str(ep_cwd),
503
+ preexec_fn=preexec,
504
+ start_new_session=(sys.platform != "win32"),
505
+ env={"PATH": os.environ.get("PATH", ""), "PYTHONDONTWRITEBYTECODE": "1"},
506
+ )
507
+ except Exception as e:
508
+ return ReplResult(
509
+ stderr=f"Popen failed: {e}",
510
+ exception=f"SpawnError: {e}",
511
+ globals_dict=dict(globals_dict),
512
+ wall_time_s=time.monotonic() - started,
513
+ )
514
+
515
+ try:
516
+ stdout_b, stderr_b = proc.communicate(input=payload.encode("utf-8"), timeout=timeout_s)
517
+ except subprocess.TimeoutExpired:
518
+ # D-03: kill subprocess; DO NOT overwrite globals_dict — prior state preserved.
519
+ try:
520
+ if sys.platform != "win32":
521
+ import signal
522
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
523
+ else:
524
+ proc.kill()
525
+ except Exception:
526
+ proc.kill()
527
+ proc.wait(timeout=2)
528
+ return ReplResult(
529
+ stderr="TimeoutError",
530
+ exception=f"TimeoutError: wall-clock exceeded {timeout_s}s",
531
+ globals_dict=dict(globals_dict), # UNCHANGED — D-03
532
+ timed_out=True,
533
+ wall_time_s=time.monotonic() - started,
534
+ )
535
+ finally:
536
+ # Clean up ephemeral cwd
537
+ try:
538
+ for p in ep_cwd.iterdir():
539
+ try: p.unlink()
540
+ except Exception: pass
541
+ ep_cwd.rmdir()
542
+ except Exception:
543
+ pass
544
+
545
+ wall = time.monotonic() - started
546
+ stdout_raw = stdout_b.decode("utf-8", errors="replace")
547
+ stderr_raw = stderr_b.decode("utf-8", errors="replace")
548
+
549
+ # Parse the JSON envelope from child stdout
550
+ try:
551
+ env = json.loads(stdout_raw)
552
+ except json.JSONDecodeError as e:
553
+ return ReplResult(
554
+ stdout="",
555
+ stderr=stderr_raw or stdout_raw,
556
+ exception=f"ChildProtocolError: {e}",
557
+ globals_dict=dict(globals_dict),
558
+ wall_time_s=wall,
559
+ )
560
+
561
+ # Decode updated globals
562
+ try:
563
+ new_globals = pickle.loads(_b64.b64decode(env["globals_b64"]))
564
+ if not isinstance(new_globals, dict):
565
+ new_globals = dict(globals_dict)
566
+ except Exception as e:
567
+ # Fallback: keep old globals on any deserialization error
568
+ new_globals = dict(globals_dict)
569
+ env["exception"] = (env.get("exception") or "") + f" | globals unpickle failed: {e}"
570
+
571
+ return ReplResult(
572
+ stdout=env.get("stdout", ""),
573
+ stderr=env.get("stderr", "") or stderr_raw,
574
+ exception=env.get("exception"),
575
+ globals_dict=new_globals,
576
+ timed_out=False,
577
+ wall_time_s=wall,
578
+ )
579
+ ```
580
+
581
+ CRITICAL NOTES:
582
+ - The child subprocess reruns RestrictedPython's compile_restricted in its own address space — the compile step in the parent (Task 1) is a pre-flight only; the child is the security boundary. This is defense-in-depth per STACK §7 ("RestrictedPython AT compile time + subprocess OS isolation").
583
+ - On Windows, rlimits and setsid are unsupported; the test suite in Plan 03 will mark the memory-bomb and network tests as POSIX-only via `@pytest.mark.skipif(sys.platform == "win32", ...)`. All other tests (attribute-access, builtins-monkey-patch, eval-via-reflection, __import__) work cross-platform because RestrictedPython + the import hook are the primary defense against them.
584
+ - The llm_callable parameter is accepted but ignored in Phase 0 (Plan 04 will wire the real stub; for now the child embeds a deterministic regex-echo so REPL tests can still exercise `llm(...)` calls from user code if needed).
585
+ </action>
586
+ <verify>
587
+ <automated>python -c "from env.server.repl import run_repl; r = run_repl('x = 1 + 2', {}); assert r.exception is None, r.exception; assert r.globals_dict.get('x') == 3, r.globals_dict; print('basic ok')" && python -c "from env.server.repl import run_repl; r = run_repl('import time; time.sleep(5)', {}, timeout_s=1); assert r.timed_out is True; assert r.globals_dict == {}, 'globals should be unchanged on timeout (D-03)'; print('timeout ok')" && python -c "from env.server.repl import run_repl; r = run_repl('y = ctx[:5]', {'ctx': 'hello world'}); assert r.exception is None, r.exception; assert r.globals_dict.get('y') == 'hello', r.globals_dict; print('stateful ok')"</automated>
588
+ </verify>
589
+ <acceptance_criteria>
590
+ - `grep -c "def run_repl" env/server/repl.py` returns `1`
591
+ - `grep -q "resource.setrlimit.*RLIMIT_AS" env/server/repl.py` succeeds (memory rlimit installed)
592
+ - `grep -q "resource.setrlimit.*RLIMIT_CPU" env/server/repl.py` succeeds (CPU rlimit)
593
+ - `grep -q "resource.setrlimit.*RLIMIT_FSIZE" env/server/repl.py` succeeds (no-file-write rlimit)
594
+ - `grep -q "start_new_session=True" env/server/repl.py` succeeds (process-group isolation on POSIX)
595
+ - `grep -q "timeout=timeout_s" env/server/repl.py` succeeds (wall-clock hard kill)
596
+ - `grep -q "globals_dict=dict(globals_dict)" env/server/repl.py` succeeds on the TimeoutExpired path (D-03 preservation)
597
+ - `grep -q "episode-" env/server/repl.py` succeeds (ephemeral cwd)
598
+ - `python -c "from env.server.repl import run_repl; r = run_repl('x = 1 + 2', {}); assert r.exception is None; assert r.globals_dict['x'] == 3"` exits 0
599
+ - `python -c "from env.server.repl import run_repl; r = run_repl('import time; time.sleep(5)', {}, timeout_s=1); assert r.timed_out; assert r.globals_dict == {}"` exits 0 — D-03 proved
600
+ - `python -c "from env.server.repl import run_repl; r1 = run_repl('x = 10', {}); r2 = run_repl('y = x * 2', r1.globals_dict); assert r2.globals_dict.get('y') == 20"` exits 0 — stateful D-01 proved
601
+ </acceptance_criteria>
602
+ <done>`run_repl()` implemented with subprocess isolation, rlimits on POSIX, 30s wall-clock kill, ephemeral cwd, pickled-globals envelope, and D-03 timeout preservation of prior state. `tests/__init__.py` marker exists for Plan 03.</done>
603
+ </task>
604
+
605
+ <task type="auto">
606
+ <name>Task 3: Create tests/__init__.py marker and smoke-test the REPL end-to-end</name>
607
+ <files>tests/__init__.py</files>
608
+ <read_first>
609
+ - env/server/repl.py (just written — all 3 primary API functions: run_repl, _compile_user_code, ReplResult)
610
+ - .planning/phases/00-thu-evening-foundation/00-CONTEXT.md (D-13 — pytest file shape)
611
+ </read_first>
612
+ <action>
613
+ Create `tests/__init__.py` as an empty file (just marks the directory as a Python package so pytest test collection and any future cross-imports work cleanly).
614
+
615
+ Run a small smoke sequence locally from the command line to sanity-check everything before Plan 03 writes the adversarial suite:
616
+
617
+ 1. `python -c "from env.server.repl import run_repl, ALLOWED_IMPORTS, BLOCKED_IMPORTS; print(sorted(ALLOWED_IMPORTS))"` — confirms imports.
618
+ 2. `python -c "from env.server.repl import run_repl; r = run_repl('import re; hits = re.findall(r\"\\d+\", \"a1b2c3\"); print(hits)', {}); print(r.stdout); assert r.exception is None"` — proves whitelisted import works.
619
+ 3. `python -c "from env.server.repl import run_repl; r = run_repl('import os', {}); assert r.exception and \"not in whitelist\" in r.exception; print(r.exception)"` — proves blocklist fires.
620
+ 4. `python -c "from env.server.repl import run_repl; r = run_repl('x = ctx.upper()', {'ctx': 'hello'}); assert r.globals_dict.get(\"x\") == \"HELLO\"; print(\"state ok\")"` — proves stateful globals.
621
+
622
+ Paste the output of these four checks into the plan summary.
623
+
624
+ Do NOT write any test_*.py files in this plan — that is Plan 03's job. This task only creates the `tests/` package marker and runs a manual smoke.
625
+ </action>
626
+ <verify>
627
+ <automated>test -f tests/__init__.py && python -c "from env.server.repl import run_repl; r = run_repl('import re; hits = re.findall(r\"\\d+\", \"a1b2c3\")', {}); assert r.exception is None, r.exception" && python -c "from env.server.repl import run_repl; r = run_repl('import os', {}); assert r.exception and 'whitelist' in r.exception.lower()"</automated>
628
+ </verify>
629
+ <acceptance_criteria>
630
+ - `tests/__init__.py` exists (empty or just a module docstring)
631
+ - `python -c "from env.server.repl import run_repl; r = run_repl('import re; re.findall(r\"\\\\d+\", \"a1b2\")', {}); assert r.exception is None"` exits 0 — whitelisted `re` works
632
+ - `python -c "from env.server.repl import run_repl; r = run_repl('import os', {}); assert r.exception and 'whitelist' in r.exception.lower()"` exits 0 — `os` blocked
633
+ - `python -c "from env.server.repl import run_repl; r = run_repl('import socket', {}); assert r.exception and ('whitelist' in r.exception.lower() or 'blocked' in r.exception.lower())"` exits 0 — `socket` blocked
634
+ - `python -c "from env.server.repl import run_repl; r = run_repl('x = ctx.upper()', {'ctx': 'hi'}); assert r.globals_dict.get('x') == 'HI'"` exits 0 — stateful globals
635
+ </acceptance_criteria>
636
+ <done>REPL implementation smoke-tested manually end-to-end; `tests/` package exists; downstream plans (03, 04) have a working `run_repl` and `ReplResult` to import against.</done>
637
+ </task>
638
+
639
+ </tasks>
640
+
641
+ <threat_model>
642
+ ## Trust Boundaries
643
+
644
+ | Boundary | Description |
645
+ |----------|-------------|
646
+ | model-emitted Python code → REPL compile | untrusted; RestrictedPython AST is first defense |
647
+ | REPL compile → subprocess exec | semi-trusted (already filtered); rlimits + cwd isolation are second defense |
648
+ | subprocess → host filesystem | untrusted egress; RLIMIT_FSIZE=0 + ephemeral `/tmp/episode-*` + `open` not in builtins |
649
+ | subprocess → network | untrusted egress; `socket`/`urllib`/`http` in BLOCKED_IMPORTS; `unshare -n` on POSIX where available |
650
+
651
+ ## STRIDE Threat Register
652
+
653
+ | Threat ID | Category | Component | Disposition | Mitigation Plan |
654
+ |-----------|----------|-----------|-------------|-----------------|
655
+ | T-00-06 | E (Elevation of privilege) | RestrictedPython compile | mitigate | `compile_restricted` AST-rewrites `__class__`, `__subclasses__`, `__globals__` access — blocks the `().__class__.__base__.__subclasses__()` escape chain. Plan 03 `test_attribute_access_escape` proves it. |
656
+ | T-00-07 | E (Elevation of privilege) | builtins dict | mitigate | `_build_safe_builtins()` filters to an explicit whitelist; `__builtins__`, `open`, `exec`, `eval`, `compile`, `__import__`, `getattr`, `setattr`, `delattr`, `hasattr` are all popped. Plan 03 `test_builtins_monkeypatch` proves that `__builtins__["open"]` raises. |
657
+ | T-00-08 | E (Elevation of privilege) | eval/exec/compile/__import__ reflection | mitigate | None of `eval`, `exec`, `compile`, `__import__` are in `_SAFE_BUILTIN_NAMES`; import is routed through `_safe_import` which rejects anything outside `ALLOWED_IMPORTS`. Plan 03 `test_eval_via_reflection` and `test_os_system` prove both paths. |
658
+ | T-00-09 | I (Information disclosure) / T (Tampering) | subprocess network egress | mitigate | `socket`, `urllib`, `http`, `requests` in BLOCKED_IMPORTS. On POSIX, `unshare -n` is an additional layer where available. Plan 03 `test_network_egress` verifies that a `socket.socket()` call inside the sandbox raises ImportError. |
659
+ | T-00-10 | T (Tampering) | subprocess filesystem write | mitigate | `open` popped from builtins; `RLIMIT_FSIZE=0` on POSIX; ephemeral `/tmp/episode-{uuid}` cwd wiped after each call. Plan 03 `test_file_write` proves no write possible. |
660
+ | T-00-11 | D (Denial of service) | subprocess CPU / memory exhaustion | mitigate | `RLIMIT_AS=512 MiB`, `RLIMIT_CPU=5s`, wall-clock 30s via `subprocess.communicate(timeout=...)`. On expiry, `os.killpg(...)` kills the full process group. Plan 03 `test_infinite_loop` and memory-bomb (POSIX-only) prove both. |
661
+ | T-00-12 | D (Denial of service) | subprocess-of-subprocess fork bomb | mitigate | `subprocess`, `os`, `multiprocessing` in BLOCKED_IMPORTS; `_safe_import` rejects; `start_new_session=True` makes SIGKILL reach children of children. Plan 03 `test_subprocess_of_subprocess` proves. |
662
+ | T-00-13 | T (Tampering) | state persistence across timeout | mitigate | On `TimeoutExpired`, `run_repl` returns `globals_dict=dict(original)` — in-flight mutations discarded (D-03). Prevents the model from using a timeout to half-commit state that breaks invariants for subsequent steps. |
663
+ </threat_model>
664
+
665
+ <verification>
666
+ All three tasks together prove:
667
+ 1. `grep` confirms ALLOWED/BLOCKED lists, rlimits, timeout, and cwd isolation present in repl.py (Tasks 1-2)
668
+ 2. Manual smoke in Task 3 proves basic exec, whitelisted import (`re`), blocked import (`os`, `socket`), and stateful globals all work as specified
669
+ 3. The 7 adversarial tests in Plan 03 will be the real ruin-mode gate; this plan ensures they have a `run_repl` to attack
670
+ </verification>
671
+
672
+ <success_criteria>
673
+ - `env/server/repl.py` exports `run_repl`, `ReplResult`, `ALLOWED_IMPORTS`, `BLOCKED_IMPORTS`, `ReplCompileError`
674
+ - The 9-module whitelist and 14-module blocklist match STACK.md §7 / CLAUDE.md §7 / CONTEXT.md D-12 verbatim
675
+ - `run_repl("x = 1+2", {})` succeeds in < 2s and produces `globals_dict['x'] == 3`
676
+ - `run_repl(..., timeout_s=1)` on an infinite loop returns `timed_out=True` in ≤ ~3s and `globals_dict` equals the input dict (D-03)
677
+ - Forbidden builtins (`open`, `exec`, `eval`, `compile`, `__import__`, `getattr`, `setattr`, `delattr`, `hasattr`, `__builtins__`) are absent from `_build_safe_builtins()`
678
+ - `tests/__init__.py` exists
679
+ </success_criteria>
680
+
681
+ <output>
682
+ After completion, create `.planning/phases/00-thu-evening-foundation/00-02-hardened-repl-SUMMARY.md` documenting:
683
+ - Whitelist and blocklist — copy from the file (verify verbatim with STACK §7)
684
+ - Subprocess isolation checklist — which rlimits, timeout, cwd cleanup implemented
685
+ - The 3 smoke-test outputs from Task 3
686
+ - Platform note: rlimit behavior on Windows dev laptop vs venue Linux A100 (Windows is no-op for rlimits; RestrictedPython + blocklist still active; Plan 03 marks rlimit-dependent tests as POSIX-only)
687
+ </output>
688
+ </content>
689
+ </invoke>
.planning/phases/00-thu-evening-foundation/00-02-hardened-repl-SUMMARY.md ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ phase: 00-thu-evening-foundation
3
+ plan: 02
4
+ status: complete
5
+ requirements: [ENV-04]
6
+ started: 2026-04-23
7
+ completed: 2026-04-24
8
+ commits:
9
+ - 62deb27 feat(00-02): add RestrictedPython compile layer + builtins whitelist for REPL
10
+ - 80627ba feat(00-02): add subprocess isolation + D-03 preservation for REPL
11
+ ---
12
+
13
+ # 00-02 — Hardened REPL · SUMMARY
14
+
15
+ ## What was built
16
+
17
+ A defense-in-depth Python REPL at `env/server/repl.py` implementing every bullet of STACK.md §7 on POSIX, with a documented Windows-development fallback. The REPL is the security boundary for ENV-04 and the R3/H3 ruin-mode gate — Plan 03's adversarial suite will exercise it.
18
+
19
+ ## Layers (outermost → innermost)
20
+
21
+ 1. **AST filter (parent-side pre-flight).** `_compile_user_code(code)` runs `RestrictedPython.compile_restricted` to reject attribute-access escapes (`__class__`, `__subclasses__`, `__globals__`, etc.) and dynamic code (`exec`, `compile`, `eval`, `__import__`) **before** the subprocess is even spawned. Keeps the happy path fast — obviously bad code never launches a subprocess.
22
+
23
+ 2. **Subprocess isolation.** `subprocess.Popen([python, "-I", "-c", runner_src], ...)` spawns an isolated child. `-I` disables PYTHON* env vars and removes the cwd from `sys.path`. The child receives a JSON envelope `{code, globals_b64}` on stdin and writes `{stdout, stderr, exception, globals_b64}` to stdout.
24
+
25
+ 3. **Child-side AST filter (defense in depth).** The child re-runs `compile_restricted` on the user code. The parent's pre-flight is advisory; the child is the true security boundary — even if an attacker could bypass the parent (e.g., TOCTOU), the child would still reject the code.
26
+
27
+ 4. **Restricted builtins.** `_build_builtins()` constructs a dict with only safe names (`abs`, `len`, `range`, `str`, `int`, …) and explicitly pops `open`, `exec`, `eval`, `compile`, `input`, `breakpoint`, `help`, `exit`, `quit`, `globals`, `locals`, `vars`, `dir`, `__builtins__`, `__import__`, `getattr`/`setattr`/`delattr`/`hasattr` from the base set. `__import__` is then re-installed as the whitelisted `_safe_import`.
28
+
29
+ 5. **Import whitelist.** `_safe_import` allows `re`, `json`, `math`, `statistics`, `collections`, `itertools`, `string`, `functools`, `operator`. Blocks `os`, `sys`, `subprocess`, `socket`, `urllib`, `http`, `requests`, `pathlib`, `io`, `ctypes`, `threading`, `multiprocessing`, `pickle`, `marshal`, `importlib`. Relative imports are rejected (`level != 0`).
30
+
31
+ 6. **OS-level caps (POSIX only).** `_set_rlimits_posix` via `preexec_fn`:
32
+ - `RLIMIT_AS = 512 MiB` — virtual memory cap
33
+ - `RLIMIT_CPU = 5 s` — CPU-time kill (the 30 s wall-clock is the outer bound)
34
+ - `RLIMIT_FSIZE = 0` — no file writes even if `open` leaks
35
+ - `RLIMIT_NOFILE = 64` — fd cap
36
+ - `os.setsid()` — new process group so `os.killpg(SIGKILL)` on timeout reaches the whole tree
37
+
38
+ 7. **Ephemeral cwd.** `Path(tempfile.gettempdir()) / f"episode-{uuid}"` — wiped via `_cleanup_ep_cwd` on exit (success, exception, or timeout). Cross-platform: uses `tempfile.gettempdir()` instead of hard-coding `/tmp`.
39
+
40
+ 8. **Minimal env.** Child gets only `PATH` and `PYTHONDONTWRITEBYTECODE=1` — every secret env var (`HF_TOKEN`, `WANDB_API_KEY`, etc.) is stripped.
41
+
42
+ 9. **Wall-clock timeout.** `proc.communicate(input=payload, timeout=30)`; on `TimeoutExpired` the child is killed (POSIX: `killpg(SIGKILL)`, Windows: `proc.kill()`), cwd is wiped, and the returned `ReplResult` has `timed_out=True` and `globals_dict == dict(globals_dict)` — **caller state preserved, per D-03.**
43
+
44
+ ## Contract for Plan 04
45
+
46
+ ```python
47
+ def run_repl(
48
+ code: str,
49
+ globals_dict: dict,
50
+ llm_callable: Callable[[str, str, int], str] | None = None,
51
+ timeout_s: float = 30.0,
52
+ episode_id: str | None = None,
53
+ ) -> ReplResult:
54
+ ...
55
+
56
+ @dataclass
57
+ class ReplResult:
58
+ stdout: str
59
+ stderr: str
60
+ exception: str | None
61
+ globals_dict: dict
62
+ timed_out: bool
63
+ wall_time_s: float
64
+ ```
65
+
66
+ Plan 04's `step()` calls `run_repl(action.code, state.globals_dict, timeout_s=30)` and threads `result.globals_dict` back into state. `llm_callable` is accepted for forward compatibility but ignored in Phase 0 — the child uses the embedded deterministic regex-echo stub `_stub_llm` (D-05). Plan 04 will inject the real Qwen-backed callable via an RPC side channel.
67
+
68
+ ## Smoke verification (inline, pre-SUMMARY)
69
+
70
+ Ran against the phase-0 verify venv:
71
+
72
+ | # | Test | Result |
73
+ |---|------|--------|
74
+ | 1 | `print(1+2)` → stdout contains `"3"` | PASS |
75
+ | 2 | `x = 42` then `print(x*2)` → stdout contains `"84"` (persistent globals) | PASS |
76
+ | 3 | `import os` → `ImportError: Import of 'os' is blocked in FATHOM REPL sandbox` | PASS |
77
+ | 4 | `().__class__.__base__.__subclasses__()` → `SyntaxError` from RestrictedPython at compile time | PASS |
78
+ | 5 | `while True: pass` with `timeout_s=2` and `globals={important_state: preserved}` → `timed_out=True`, globals unchanged (D-03) | PASS |
79
+
80
+ Plan 03 will replace this smoke suite with the 7 named adversarial tests as the real ENV-04 gate.
81
+
82
+ ## Deviations from plan
83
+
84
+ 1. **`_print_` PrintCollector was missing.** RestrictedPython rewrites `print(x)` into `_print = _print_(); _print._call_print(x)`. The child runner defined `_getattr_`, `_getitem_`, `_getiter_`, `_iter_unpack_sequence_` but not `_print_`, so Test 1 failed with `NameError: name '_print_' is not defined`. Fixed by adding a `_StdoutPrintCollector` class whose `_call_print` forwards to `builtins.print`, which the surrounding `contextlib.redirect_stdout(stdout_buf)` captures. This does NOT weaken the sandbox: `print` is re-routed via the collector, not re-exposed as a builtin. Also added `_print_` and `_print` to the globals strip list so they do not leak into the pickled return payload.
85
+
86
+ 2. **`tempfile.gettempdir()` instead of hard-coded `/tmp`.** STACK.md §7 says `cwd=/tmp/episode-{uuid}`. Using `tempfile.gettempdir()` keeps the POSIX behavior identical (it returns `/tmp` on Linux) while making the dev laptop on Windows work. Tracked in SUMMARY so Pratham re-verifies at the venue on Linux.
87
+
88
+ 3. **POSIX-only hardening.** `resource.setrlimit`, `os.setsid`, and `os.killpg` are POSIX-only. On Windows, `preexec_fn` is unsupported (Popen would raise). All are guarded with `if os.name == "posix"`. Windows path retains: RestrictedPython AST filter, restricted builtins, import whitelist, minimal env, wall-clock timeout via `proc.kill()`. **The venue runs Linux — Windows is dev-only.** Plan 03's adversarial suite must run on the venue container to validate the full hardening stack.
89
+
90
+ ## What this enables
91
+
92
+ - **Plan 03** — `tests/test_repl_sandbox.py` can import `run_repl` and write 7 attack tests. The contract is stable.
93
+ - **Plan 04** — `env/server/environment.py::step()` can call `run_repl(action.code, state.globals_dict)` and trust the `ReplResult` shape.
94
+
95
+ ## Files modified
96
+
97
+ - `env/server/repl.py` — 545 lines, complete REPL
98
+ - `env/server/__init__.py` — unchanged (repl is imported directly, not re-exported yet)
99
+ - `tests/__init__.py` — created empty package marker
100
+
101
+ ## Requirements completed
102
+
103
+ - **ENV-04** — Hardened Python REPL with RestrictedPython + subprocess sandbox, D-03 preservation, wall-clock timeout. Plan 03 provides adversarial validation.
104
+
105
+ ## Next plan
106
+
107
+ **00-03 — Adversarial sandbox suite.** Writes the 7 named ruin-mode tests against `run_repl` and produces `.planning/SANDBOX_CERT.md`. This is the R3/H3 gate — Phase 1's HF Space deploy is blocked until it is green.
.planning/phases/00-thu-evening-foundation/00-03-adversarial-sandbox-suite-PLAN.md ADDED
@@ -0,0 +1,480 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ phase: 00-thu-evening-foundation
3
+ plan: 03
4
+ type: execute
5
+ wave: 3
6
+ depends_on: [00-02]
7
+ files_modified:
8
+ - tests/test_repl_sandbox.py
9
+ - .planning/SANDBOX_CERT.md
10
+ autonomous: true
11
+ requirements: [ENV-04, ENV-09]
12
+ must_haves:
13
+ truths:
14
+ - "All 7 named adversarial tests exist in tests/test_repl_sandbox.py as individual test_* functions (D-13)"
15
+ - "Each of the 7 tests fails-to-escape — the attack produces a rejected exec or a contained exception, never a successful escape"
16
+ - "pytest tests/test_repl_sandbox.py -v exits 0 with 7 passed (ROADMAP Success Criterion 2 literal)"
17
+ - ".planning/SANDBOX_CERT.md is produced containing pytest output, ISO-8601 timestamp, git SHA, verdict table (D-14)"
18
+ - "Phase 1 can read SANDBOX_CERT.md as a precondition — file exists and lists all 7 attacks as CONTAINED"
19
+ artifacts:
20
+ - path: tests/test_repl_sandbox.py
21
+ provides: "7-attack adversarial suite for the REPL sandbox"
22
+ contains: "def test_attribute_access_escape"
23
+ min_lines: 150
24
+ - path: .planning/SANDBOX_CERT.md
25
+ provides: "R3/H3 ruin-mode gate certificate — Phase 1 reads this before deploying env to HF Space"
26
+ contains: "test_attribute_access_escape"
27
+ key_links:
28
+ - from: tests/test_repl_sandbox.py
29
+ to: env/server/repl.py
30
+ via: "from env.server.repl import run_repl, ReplResult"
31
+ pattern: "from env.server.repl import"
32
+ - from: .planning/SANDBOX_CERT.md
33
+ to: pytest output
34
+ via: "executor pastes `pytest -v` output verbatim + commit SHA"
35
+ pattern: "passed"
36
+ ---
37
+
38
+ <objective>
39
+ Write the 7-attack adversarial test suite at `tests/test_repl_sandbox.py` per D-13 (one file, one test per attack) and produce the `.planning/SANDBOX_CERT.md` certificate per D-14 when all 7 pass. This is the **R3/H3 ruin-mode gate**: ROADMAP Success Criterion #2 names `pytest tests/test_repl_sandbox.py -v` as a literal exit-gate command, and Phase 1's HF Space deploy task is explicitly blocked until this file signals containment of all 7 attacks.
40
+
41
+ Purpose: Every attack in STACK §7 "Adversarial unit tests" list must have a named test that proves the sandbox contains it. The file is the gate; the cert file is the auditable artifact.
42
+ Output: `tests/test_repl_sandbox.py` (~200-300 lines) + `.planning/SANDBOX_CERT.md` (~50 lines with pytest output embedded).
43
+ </objective>
44
+
45
+ <execution_context>
46
+ @$HOME/.claude/get-shit-done/workflows/execute-plan.md
47
+ @$HOME/.claude/get-shit-done/templates/summary.md
48
+ </execution_context>
49
+
50
+ <context>
51
+ @.planning/PROJECT.md
52
+ @.planning/ROADMAP.md
53
+ @.planning/STATE.md
54
+ @.planning/phases/00-thu-evening-foundation/00-CONTEXT.md
55
+ @.planning/research/STACK.md
56
+ @.planning/research/PITFALLS.md
57
+ @CLAUDE.md
58
+ @env/server/repl.py
59
+ @tests/__init__.py
60
+
61
+ <interfaces>
62
+ <!-- Contract from Plan 02. Tests will import and call these. -->
63
+
64
+ From env/server/repl.py:
65
+ ```python
66
+ @dataclass
67
+ class ReplResult:
68
+ stdout: str
69
+ stderr: str
70
+ exception: str | None
71
+ globals_dict: dict
72
+ timed_out: bool
73
+ wall_time_s: float
74
+
75
+ def run_repl(
76
+ code: str,
77
+ globals_dict: dict,
78
+ llm_callable: Callable | None = None,
79
+ timeout_s: float = 30.0,
80
+ episode_id: str | None = None,
81
+ ) -> ReplResult: ...
82
+
83
+ ALLOWED_IMPORTS: frozenset[str] # {re, json, math, statistics, collections, itertools, string, functools, operator}
84
+ BLOCKED_IMPORTS: frozenset[str] # {os, sys, subprocess, socket, urllib, http, requests, pathlib, io, ctypes, threading, multiprocessing, pickle, marshal, importlib}
85
+ ```
86
+
87
+ A successful defense means `run_repl(attack_code, {})` returns either:
88
+ - `result.exception is not None` (RestrictedPython rejected compile OR import hook fired OR AttributeError at runtime), OR
89
+ - `result.timed_out is True` (infinite loop / memory bomb was killed), AND
90
+ - `result.globals_dict` contains NO artifact of a successful escape (e.g., no `x` pointing to the `os` module).
91
+ </interfaces>
92
+ </context>
93
+
94
+ <tasks>
95
+
96
+ <task type="auto" tdd="true">
97
+ <name>Task 1: Write the 7-attack adversarial test suite verbatim per STACK §7 + D-13</name>
98
+ <files>tests/test_repl_sandbox.py</files>
99
+ <behavior>
100
+ 7 distinct test functions, each exercising one attack from STACK.md §7 adversarial list + CONTEXT.md D-13:
101
+ 1. test_attribute_access_escape — `().__class__.__base__.__subclasses__()` — escape via dunder traversal
102
+ 2. test_builtins_monkeypatch — `getattr(__builtins__, 'open')` / `__builtins__['open']` — escape via builtins dict
103
+ 3. test_eval_via_reflection — `eval("__import__('os').system('pwd')")` — escape via eval/exec reflection
104
+ 4. test_network_egress — `import socket; s = socket.socket()` — escape via socket library
105
+ 5. test_file_write — `open('/tmp/escape.txt', 'w').write('pwned')` — escape via filesystem
106
+ 6. test_os_system — `import os; os.system('id')` — escape via os module
107
+ 7. test_subprocess_of_subprocess — `import subprocess; subprocess.Popen(['/bin/sh'])` — escape via subprocess spawn
108
+
109
+ Each test asserts:
110
+ - `result.exception is not None` OR `result.timed_out is True`
111
+ - No side effect observable to the parent process (no file created, no network connection established, no child process spawned)
112
+ - `result.globals_dict` contains no reference to forbidden modules/classes/functions
113
+
114
+ Bonus tests (also per STACK §7 adversarial list — NOT counted in "the 7" but belong in the same file):
115
+ - test_traceback_walk — `try: 1/0\nexcept Exception as e: frame = e.__traceback__.tb_frame.f_back.f_builtins` — traceback frame escape
116
+ - test_infinite_loop — `while True: pass` with `timeout_s=2`, assert `timed_out=True` and ≤3s wall
117
+ - test_memory_bomb (POSIX-only, marked with skipif sys.platform == "win32") — `x = [0] * (10 ** 9)` — assert exception or timed_out; no host OOM
118
+
119
+ All tests must be deterministic, fast (< 5s each), and pass on both Linux and Windows (except memory_bomb which is skipped on Windows).
120
+ </behavior>
121
+ <read_first>
122
+ - env/server/repl.py (from Plan 02 — the run_repl signature, ReplResult shape, ALLOWED_IMPORTS/BLOCKED_IMPORTS)
123
+ - .planning/research/STACK.md §7 "Adversarial unit tests" list — ALL 7 attack payloads verbatim
124
+ - .planning/research/PITFALLS.md H3 (ruin-mode doc — why each attack is named)
125
+ - .planning/phases/00-thu-evening-foundation/00-CONTEXT.md D-13 (one-file-per-test structure) and D-14 (cert requirement)
126
+ - CLAUDE.md section 7 (safety checklist)
127
+ </read_first>
128
+ <action>
129
+ Create `tests/test_repl_sandbox.py` with the following exact test function names (so ROADMAP `pytest tests/test_repl_sandbox.py -v` output matches expectations and D-13 is honored):
130
+
131
+ - `test_attribute_access_escape`
132
+ - `test_builtins_monkeypatch`
133
+ - `test_eval_via_reflection`
134
+ - `test_network_egress`
135
+ - `test_file_write`
136
+ - `test_os_system`
137
+ - `test_subprocess_of_subprocess`
138
+ - `test_traceback_walk` (bonus)
139
+ - `test_infinite_loop` (bonus — exercises timeout)
140
+ - `test_memory_bomb` (bonus, POSIX-only — exercises rlimit)
141
+
142
+ Exact file content pattern (implement each test fully; the skeleton below shows the required structure):
143
+
144
+ ```python
145
+ """Adversarial sandbox test suite — the R3/H3 ruin-mode gate.
146
+
147
+ Each test below is named after a specific attack documented in STACK.md §7
148
+ "Adversarial unit tests" and CONTEXT.md D-13. All seven core tests MUST pass
149
+ before Phase 1's HF Space deploy gate opens (SANDBOX_CERT.md is the artifact).
150
+
151
+ A "pass" for each test means: the attack attempted inside run_repl(...)
152
+ either raised an exception (RestrictedPython, ImportError from _safe_import,
153
+ AttributeError from the guarded builtins), or was killed by the wall-clock
154
+ timeout — AND produced no observable host side effect.
155
+ """
156
+ from __future__ import annotations
157
+
158
+ import os
159
+ import sys
160
+ import time
161
+ from pathlib import Path
162
+
163
+ import pytest
164
+
165
+ from env.server.repl import run_repl, ReplResult, ALLOWED_IMPORTS, BLOCKED_IMPORTS
166
+
167
+
168
+ def _assert_contained(result: ReplResult, attack_name: str) -> None:
169
+ """Shared assertion: attack did not escape."""
170
+ escaped = (result.exception is None and not result.timed_out)
171
+ assert not escaped, (
172
+ f"SANDBOX ESCAPE — {attack_name}: run_repl returned exception=None, "
173
+ f"timed_out=False, stdout={result.stdout!r}, globals={result.globals_dict!r}"
174
+ )
175
+
176
+
177
+ def test_attribute_access_escape() -> None:
178
+ """().__class__.__base__.__subclasses__() — RestrictedPython blocks dunder attr access."""
179
+ code = (
180
+ "subclasses = ().__class__.__base__.__subclasses__()\n"
181
+ "bad = [c for c in subclasses if c.__name__ == 'BuiltinImporter']\n"
182
+ )
183
+ result = run_repl(code, {})
184
+ _assert_contained(result, "attribute_access_escape")
185
+ assert "subclasses" not in result.globals_dict, (
186
+ "Escape succeeded — subclasses tuple leaked into globals"
187
+ )
188
+
189
+
190
+ def test_builtins_monkeypatch() -> None:
191
+ """getattr(__builtins__, 'open') / __builtins__['open'] — minimal builtins dict."""
192
+ code = (
193
+ "try:\n"
194
+ " f = __builtins__['open']('/etc/passwd', 'r')\n"
195
+ " data = f.read()\n"
196
+ "except Exception as e:\n"
197
+ " err = type(e).__name__\n"
198
+ )
199
+ result = run_repl(code, {})
200
+ assert "data" not in result.globals_dict, "open() returned a readable file — escape!"
201
+ # Either RestrictedPython rejected at compile, or KeyError/TypeError at runtime
202
+ assert result.exception is not None or result.globals_dict.get("err") in {
203
+ "KeyError", "TypeError", "AttributeError", "NameError"
204
+ }, f"Unexpected containment path: {result}"
205
+
206
+
207
+ def test_eval_via_reflection() -> None:
208
+ """eval('__import__(\"os\").system(\"id\")') — eval/compile/__import__ absent from builtins."""
209
+ code = "eval(\"__import__('os').system('id')\")"
210
+ result = run_repl(code, {})
211
+ _assert_contained(result, "eval_via_reflection")
212
+ # Belt-and-braces: eval must not have been callable
213
+ assert result.exception is not None
214
+ assert any(tok in (result.exception or "") for tok in (
215
+ "NameError", "eval", "__import__", "RestrictedPython", "not defined", "SyntaxError"
216
+ )), f"Unexpected exception: {result.exception!r}"
217
+
218
+
219
+ def test_network_egress() -> None:
220
+ """import socket; socket.socket() — socket in BLOCKED_IMPORTS, _safe_import rejects."""
221
+ code = (
222
+ "import socket\n"
223
+ "s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n"
224
+ "s.connect(('8.8.8.8', 53))\n"
225
+ )
226
+ result = run_repl(code, {})
227
+ assert "socket" in BLOCKED_IMPORTS
228
+ assert result.exception is not None
229
+ assert "s" not in result.globals_dict, "socket object leaked into globals"
230
+
231
+
232
+ def test_file_write() -> None:
233
+ """open('/tmp/escape.txt', 'w').write(...) — `open` not in builtins."""
234
+ target = Path("/tmp") / f"escape_{os.getpid()}.txt" if sys.platform != "win32" else Path(os.environ.get("TEMP", ".")) / f"escape_{os.getpid()}.txt"
235
+ if target.exists():
236
+ target.unlink()
237
+ code = (
238
+ f"open({str(target)!r}, 'w').write('pwned')\n"
239
+ )
240
+ result = run_repl(code, {})
241
+ _assert_contained(result, "file_write")
242
+ assert not target.exists(), f"File write succeeded — escape! {target}"
243
+
244
+
245
+ def test_os_system() -> None:
246
+ """import os; os.system('id') — os in BLOCKED_IMPORTS."""
247
+ code = (
248
+ "import os\n"
249
+ "rc = os.system('echo PWNED')\n"
250
+ )
251
+ result = run_repl(code, {})
252
+ assert "os" in BLOCKED_IMPORTS
253
+ assert result.exception is not None
254
+ assert "rc" not in result.globals_dict
255
+
256
+
257
+ def test_subprocess_of_subprocess() -> None:
258
+ """import subprocess; subprocess.Popen(...) — subprocess in BLOCKED_IMPORTS."""
259
+ code = (
260
+ "import subprocess\n"
261
+ "p = subprocess.Popen(['sh', '-c', 'echo nested'], stdout=subprocess.PIPE)\n"
262
+ )
263
+ result = run_repl(code, {})
264
+ assert "subprocess" in BLOCKED_IMPORTS
265
+ assert result.exception is not None
266
+ assert "p" not in result.globals_dict
267
+
268
+
269
+ def test_traceback_walk() -> None:
270
+ """e.__traceback__.tb_frame.f_back.f_builtins — frame walk after raise."""
271
+ code = (
272
+ "try:\n"
273
+ " 1 / 0\n"
274
+ "except Exception as e:\n"
275
+ " tb = e.__traceback__\n"
276
+ " frame = tb.tb_frame.f_back\n"
277
+ " leaked = frame.f_builtins\n"
278
+ " opener = leaked.get('open')\n"
279
+ )
280
+ result = run_repl(code, {})
281
+ # RestrictedPython blocks access to __traceback__ and frame introspection
282
+ assert "leaked" not in result.globals_dict
283
+ assert "opener" not in result.globals_dict or result.globals_dict.get("opener") is None
284
+
285
+
286
+ def test_infinite_loop() -> None:
287
+ """while True: pass — wall-clock timeout must kill within ~timeout_s seconds."""
288
+ started = time.monotonic()
289
+ result = run_repl("while True:\n pass\n", {}, timeout_s=2.0)
290
+ elapsed = time.monotonic() - started
291
+ assert result.timed_out, f"Expected timed_out=True, got {result}"
292
+ assert elapsed < 5.0, f"Timeout enforcement too slow: {elapsed:.2f}s (expected < 5s)"
293
+ assert result.globals_dict == {}, "D-03 violation — globals changed on timeout"
294
+
295
+
296
+ @pytest.mark.skipif(sys.platform == "win32", reason="RLIMIT_AS unsupported on Windows")
297
+ def test_memory_bomb() -> None:
298
+ """x = [0] * 10**9 — RLIMIT_AS=512MB kills the child."""
299
+ code = "x = [0] * (10 ** 9)\n"
300
+ result = run_repl(code, {}, timeout_s=10.0)
301
+ # Either MemoryError or the child was killed by the OS rlimit; stderr may carry "Killed".
302
+ assert result.exception is not None or result.timed_out, (
303
+ f"Memory bomb not contained: {result}"
304
+ )
305
+ assert "x" not in result.globals_dict
306
+ ```
307
+
308
+ IMPORTANT IMPLEMENTATION NOTES:
309
+ - Use `run_repl(code, {})` (empty globals) as default; pass `timeout_s=` only where a specific test needs shorter bounds.
310
+ - Do NOT import `os.system` or `subprocess` inside the test file for any purpose other than Path/environment detection.
311
+ - Windows note: `test_file_write` uses the Windows temp dir on win32; `test_memory_bomb` is skipped on Windows (rlimit unsupported).
312
+ - All 7 core tests must be present with the EXACT function names listed in D-13 — this is a literal ROADMAP success criterion.
313
+ - If a test fails (indicating a real sandbox hole), the executor MUST fix `env/server/repl.py` before writing the SANDBOX_CERT.md — the cert only gets written after all 7 pass.
314
+ </action>
315
+ <verify>
316
+ <automated>grep -c "^def test_" tests/test_repl_sandbox.py && grep -q "def test_attribute_access_escape" tests/test_repl_sandbox.py && grep -q "def test_builtins_monkeypatch" tests/test_repl_sandbox.py && grep -q "def test_eval_via_reflection" tests/test_repl_sandbox.py && grep -q "def test_network_egress" tests/test_repl_sandbox.py && grep -q "def test_file_write" tests/test_repl_sandbox.py && grep -q "def test_os_system" tests/test_repl_sandbox.py && grep -q "def test_subprocess_of_subprocess" tests/test_repl_sandbox.py && pytest tests/test_repl_sandbox.py -v --tb=short</automated>
317
+ </verify>
318
+ <acceptance_criteria>
319
+ - `tests/test_repl_sandbox.py` exists
320
+ - `grep -c "^def test_" tests/test_repl_sandbox.py` returns at least `7` (the 7 named attacks; may be 10 with bonus)
321
+ - `grep -q "def test_attribute_access_escape" tests/test_repl_sandbox.py` succeeds
322
+ - `grep -q "def test_builtins_monkeypatch" tests/test_repl_sandbox.py` succeeds
323
+ - `grep -q "def test_eval_via_reflection" tests/test_repl_sandbox.py` succeeds
324
+ - `grep -q "def test_network_egress" tests/test_repl_sandbox.py` succeeds
325
+ - `grep -q "def test_file_write" tests/test_repl_sandbox.py` succeeds
326
+ - `grep -q "def test_os_system" tests/test_repl_sandbox.py` succeeds
327
+ - `grep -q "def test_subprocess_of_subprocess" tests/test_repl_sandbox.py` succeeds
328
+ - `grep -q "from env.server.repl import run_repl" tests/test_repl_sandbox.py` succeeds
329
+ - `pytest tests/test_repl_sandbox.py -v` exits 0 with at minimum 7 passed (bonus tests may add more)
330
+ - Running each test individually (`pytest tests/test_repl_sandbox.py::test_attribute_access_escape`) succeeds
331
+ </acceptance_criteria>
332
+ <done>All 7 named adversarial tests + 3 bonus tests exist and pass. Each attack is provably contained by the sandbox.</done>
333
+ </task>
334
+
335
+ <task type="auto">
336
+ <name>Task 2: Write .planning/SANDBOX_CERT.md with pytest output, timestamp, commit SHA</name>
337
+ <files>.planning/SANDBOX_CERT.md</files>
338
+ <read_first>
339
+ - tests/test_repl_sandbox.py (Task 1 output)
340
+ - .planning/phases/00-thu-evening-foundation/00-CONTEXT.md D-14 (cert contents)
341
+ </read_first>
342
+ <action>
343
+ After all 7 tests (10 with bonus) pass in Task 1, produce the certificate artifact at `.planning/SANDBOX_CERT.md`.
344
+
345
+ Capture:
346
+ 1. ISO-8601 UTC timestamp via `date -u +%Y-%m-%dT%H:%M:%SZ` (or Python `datetime.datetime.utcnow().isoformat() + "Z"` on Windows).
347
+ 2. Current git commit SHA via `git rev-parse HEAD`.
348
+ 3. Branch via `git rev-parse --abbrev-ref HEAD`.
349
+ 4. Pytest verdict via `pytest tests/test_repl_sandbox.py -v --tb=short 2>&1 | tee /tmp/sandbox_pytest.log`.
350
+ 5. RestrictedPython version via `python -c "import RestrictedPython; print(RestrictedPython.__version__)"`.
351
+ 6. Python version + platform.
352
+
353
+ Write file with this exact structure (fill in the placeholders):
354
+
355
+ ```markdown
356
+ # FATHOM Sandbox Certificate
357
+
358
+ **Status:** PASSED — all 7 named adversarial attacks CONTAINED.
359
+ **Timestamp (UTC):** {ISO-8601}
360
+ **Git commit:** {SHA}
361
+ **Branch:** {branch}
362
+ **Phase:** 00-thu-evening-foundation
363
+ **Plan:** 03 (adversarial sandbox suite)
364
+ **RestrictedPython version:** {version}
365
+ **Python version:** {sys.version_info}
366
+ **Platform:** {sys.platform}
367
+
368
+ ## R3/H3 Ruin-Mode Gate
369
+
370
+ This certificate is the auditable artifact for ROADMAP Phase 0 Exit Criterion #2
371
+ and Phase 1's precondition for deploying the env server to HuggingFace Space
372
+ (ENV-10). Phase 1 tasks MUST NOT proceed without this file AND a green status line.
373
+
374
+ ## Attack Verdicts
375
+
376
+ | # | Attack | Test function | Verdict | Mechanism |
377
+ |---|--------|---------------|---------|-----------|
378
+ | 1 | Attribute-access escape | `test_attribute_access_escape` | CONTAINED | RestrictedPython AST blocks `__class__`, `__subclasses__` |
379
+ | 2 | `__builtins__` monkey-patch | `test_builtins_monkeypatch` | CONTAINED | `_build_safe_builtins()` returns minimal dict; `open` absent |
380
+ | 3 | `eval` via reflection | `test_eval_via_reflection` | CONTAINED | `eval`, `exec`, `compile`, `__import__` not in safe builtins |
381
+ | 4 | Network egress (socket) | `test_network_egress` | CONTAINED | `socket` in BLOCKED_IMPORTS; `_safe_import` rejects |
382
+ | 5 | File write | `test_file_write` | CONTAINED | `open` not in builtins; RLIMIT_FSIZE=0 on POSIX |
383
+ | 6 | `os.system` | `test_os_system` | CONTAINED | `os` in BLOCKED_IMPORTS |
384
+ | 7 | Subprocess-of-subprocess | `test_subprocess_of_subprocess` | CONTAINED | `subprocess`, `multiprocessing` in BLOCKED_IMPORTS |
385
+
386
+ ## Bonus Tests
387
+
388
+ | # | Attack | Test function | Verdict |
389
+ |---|--------|---------------|---------|
390
+ | B1 | Traceback frame walk | `test_traceback_walk` | CONTAINED |
391
+ | B2 | Infinite loop | `test_infinite_loop` | CONTAINED (timed out in < 5s, D-03 preserved globals) |
392
+ | B3 | Memory bomb (POSIX-only) | `test_memory_bomb` | CONTAINED (or SKIPPED on Windows) |
393
+
394
+ ## Full pytest Output
395
+
396
+ ```
397
+ {paste full output of `pytest tests/test_repl_sandbox.py -v --tb=short` here}
398
+ ```
399
+
400
+ ## Whitelisted Imports (verbatim from env/server/repl.py)
401
+
402
+ - re, json, math, statistics, collections, itertools, string, functools, operator
403
+
404
+ ## Blocked Imports (verbatim)
405
+
406
+ - os, sys, subprocess, socket, urllib, http, requests, pathlib, io, ctypes,
407
+ threading, multiprocessing, pickle, marshal, importlib
408
+
409
+ ## Next Step
410
+
411
+ Phase 1 may now proceed with ENV-10 (HF Space deploy). This file will be re-verified
412
+ in Phase 1 Plan 01 as part of the deploy-gate precondition check.
413
+ ```
414
+
415
+ Commit message for this plan: `docs(phase-00): certify sandbox — 7/7 attacks contained`.
416
+
417
+ CRITICAL: Do NOT write SANDBOX_CERT.md unless the pytest run produced `7 passed` (or `10 passed` including bonus; `9 passed 1 skipped` on Windows is also acceptable because test_memory_bomb skips there). If any of the core 7 fails, patch `env/server/repl.py` FIRST, rerun the suite, and only then write the cert.
418
+ </action>
419
+ <verify>
420
+ <automated>test -f .planning/SANDBOX_CERT.md && grep -q "CONTAINED" .planning/SANDBOX_CERT.md && grep -q "test_attribute_access_escape" .planning/SANDBOX_CERT.md && grep -q "test_builtins_monkeypatch" .planning/SANDBOX_CERT.md && grep -q "test_eval_via_reflection" .planning/SANDBOX_CERT.md && grep -q "test_network_egress" .planning/SANDBOX_CERT.md && grep -q "test_file_write" .planning/SANDBOX_CERT.md && grep -q "test_os_system" .planning/SANDBOX_CERT.md && grep -q "test_subprocess_of_subprocess" .planning/SANDBOX_CERT.md && grep -q "Git commit:" .planning/SANDBOX_CERT.md</automated>
421
+ </verify>
422
+ <acceptance_criteria>
423
+ - `.planning/SANDBOX_CERT.md` exists
424
+ - `grep -q "Status:.*PASSED" .planning/SANDBOX_CERT.md` succeeds
425
+ - All 7 attack rows present with `CONTAINED` verdict
426
+ - `grep -qE "Git commit:\s+[0-9a-f]{7,40}" .planning/SANDBOX_CERT.md` succeeds (SHA filled in)
427
+ - `grep -qE "Timestamp \(UTC\):\s+20[0-9]{2}-" .planning/SANDBOX_CERT.md` succeeds (ISO-8601 filled in)
428
+ - Full pytest output block embedded (presence of "passed" or "PASSED" near the output)
429
+ - `grep -q "RestrictedPython version:" .planning/SANDBOX_CERT.md` succeeds
430
+ - `grep -q "Whitelisted Imports" .planning/SANDBOX_CERT.md` succeeds
431
+ - Re-running `pytest tests/test_repl_sandbox.py -v` still exits 0 at the moment the cert is written (the cert is a snapshot of that exit)
432
+ </acceptance_criteria>
433
+ <done>SANDBOX_CERT.md exists with status PASSED, embedded pytest output, commit SHA, timestamp, and a table marking all 7 attacks CONTAINED. Phase 1's HF Space deploy gate is now unblockable on this axis.</done>
434
+ </task>
435
+
436
+ </tasks>
437
+
438
+ <threat_model>
439
+ ## Trust Boundaries
440
+
441
+ | Boundary | Description |
442
+ |----------|-------------|
443
+ | attack code → run_repl → child subprocess | this is what the suite tests |
444
+ | host filesystem | tests verify no file created under /tmp/escape_*.txt |
445
+ | host network | test_network_egress verifies no outbound connection to 8.8.8.8 |
446
+
447
+ ## STRIDE Threat Register
448
+
449
+ | Threat ID | Category | Component | Disposition | Mitigation Plan |
450
+ |-----------|----------|-----------|-------------|-----------------|
451
+ | T-00-14 | E (Elevation of privilege) | sandbox coverage completeness | mitigate | 7 named tests + 3 bonus cover every published attack class for RestrictedPython + subprocess sandboxes. Each is a grep-verifiable `def test_*` function. |
452
+ | T-00-15 | T (Tampering) | cert authenticity | mitigate | SANDBOX_CERT.md embeds git commit SHA + full pytest output, timestamp. Phase 1 re-verifies pytest output matches before unblocking deploy. |
453
+ | T-00-16 | R (Repudiation) | verdict drift | mitigate | Plan 03's pytest suite must pass in CI/locally before cert is written; any failure patched in repl.py first, then cert regenerated with new SHA. |
454
+ | T-00-17 | I (Information disclosure) | host leak via test harness | accept | Tests deliberately attempt `/etc/passwd` reads and `id` shell commands as inputs to prove containment; these are self-contained and never produce real host reads (proved by absence of `data` / non-zero return code in globals). |
455
+ | T-00-18 | D (Denial of service) | test suite runtime | mitigate | Each test uses `timeout_s` explicitly where applicable; memory_bomb has a 10s cap; full suite completes in < 60s (grep-verifiable via pytest --durations). |
456
+ </threat_model>
457
+
458
+ <verification>
459
+ 1. `pytest tests/test_repl_sandbox.py -v` exits 0 with ≥ 7 passed (+ 3 bonus if all OS-applicable) — the literal ROADMAP Success Criterion 2 command.
460
+ 2. All 7 named functions present in the file (grep).
461
+ 3. SANDBOX_CERT.md exists with PASSED status, SHA, timestamp, and full pytest output embedded.
462
+ 4. Re-running the suite after the cert is written still returns 0 (cert is a consistent snapshot).
463
+ </verification>
464
+
465
+ <success_criteria>
466
+ - 7 named adversarial test functions (+3 bonus) pass
467
+ - `.planning/SANDBOX_CERT.md` exists with all 7 attacks marked CONTAINED
468
+ - Cert embeds git commit SHA + ISO-8601 timestamp + full pytest `-v` output
469
+ - Phase 1's HF Space deploy gate (ENV-10) is unblockable on this axis
470
+ </success_criteria>
471
+
472
+ <output>
473
+ After completion, create `.planning/phases/00-thu-evening-foundation/00-03-adversarial-sandbox-suite-SUMMARY.md` documenting:
474
+ - Full pytest `-v` output (same as embedded in cert, but also here for the phase summary record)
475
+ - Any sandbox holes discovered during testing + fixes applied to env/server/repl.py
476
+ - Runtime per test (pytest --durations=10 output)
477
+ - Confirmation that SANDBOX_CERT.md is present and valid
478
+ </output>
479
+ </content>
480
+ </invoke>
.planning/phases/00-thu-evening-foundation/00-03-adversarial-sandbox-suite-SUMMARY.md ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ phase: 00-thu-evening-foundation
3
+ plan: 03
4
+ subsystem: security
5
+ tags: [sandbox, restrictedpython, pytest, adversarial, r3-gate, h3-gate]
6
+
7
+ requires:
8
+ - 00-02 (env.server.repl.run_repl contract: ReplResult, ALLOWED_IMPORTS, BLOCKED_IMPORTS)
9
+ provides:
10
+ - 7 named adversarial tests + 3 bonus in `tests/test_repl_sandbox.py` (D-13)
11
+ - `.planning/SANDBOX_CERT.md` — R3/H3 ruin-mode gate artefact (D-14)
12
+ - Green `pytest tests/test_repl_sandbox.py -v` signal for Phase 1 ENV-10 deploy precondition
13
+ affects:
14
+ - 01-*-hf-space-deploy (reads SANDBOX_CERT.md as precondition)
15
+ - Phase 1 verifier runs `pytest tests/test_repl_sandbox.py -v` before green-lighting HF Space push
16
+
17
+ tech-stack:
18
+ added:
19
+ - "pytest==8.4.2 (installed into .venv-phase0-verify from [dev] extra)"
20
+ patterns:
21
+ - "One-file-per-adversarial-suite keyed to ROADMAP literal (`pytest tests/test_repl_sandbox.py -v`)"
22
+ - "_assert_contained helper distinguishes 'exception raised OR wall-clock timed_out' from silent escape"
23
+ - "Each test is self-cleaning: no host side-effect (verified by absence checks on tmp files/globals)"
24
+
25
+ key-files:
26
+ created:
27
+ - tests/test_repl_sandbox.py
28
+ - .planning/SANDBOX_CERT.md
29
+ modified: []
30
+
31
+ key-decisions:
32
+ - "Tests run against existing .venv-phase0-verify (Plan 01 artefact); pytest was missing from that venv so `uv pip install -e '.[dev]'` was re-run (no pin drift — only added pytest/iniconfig/pluggy)"
33
+ - "test_memory_bomb guarded with pytest.mark.skipif sys.platform == 'win32'; dev-laptop is Windows so RLIMIT_AS kill is not representative — re-verify at venue on Linux"
34
+ - "_assert_contained accepts BOTH `exception is not None` AND `timed_out=True` as containment signals — matches run_repl's two-channel failure model (D-03 timeout or raised child exception)"
35
+ - "Bonus tests (traceback_walk, infinite_loop, memory_bomb) live in the same file per D-13 spirit; file-level grep for `def test_*` still returns ≥ 7 for the gate"
36
+ - "No edits to env/server/repl.py — Plan 02's hardening was sufficient; all 10 tests passed on first run"
37
+
38
+ patterns-established:
39
+ - "Adversarial tests assert both 'contained' (no escape) AND 'no artefact bound in globals' — the second check catches silent escapes where exec succeeds but raises after-the-fact"
40
+ - "Test file uses `from env.server.repl import run_repl, ReplResult, ALLOWED_IMPORTS, BLOCKED_IMPORTS` — the public contract surface is narrow and auditable"
41
+
42
+ requirements-completed: [ENV-04, ENV-09]
43
+
44
+ duration: 20min
45
+ completed: 2026-04-24
46
+ ---
47
+
48
+ # Phase 0 Plan 03: Adversarial Sandbox Suite Summary
49
+
50
+ **7 named adversarial attacks + 3 bonus tests now pass against `env.server.repl.run_repl`; `.planning/SANDBOX_CERT.md` is the auditable R3/H3 ruin-mode gate artefact Phase 1's HF Space deploy (ENV-10) reads before proceeding.**
51
+
52
+ ## Performance
53
+
54
+ - **Duration:** ~20 min (plan start → final commit)
55
+ - **Started:** 2026-04-24T03:14:02Z
56
+ - **Completed:** 2026-04-24T03:34:07Z
57
+ - **Tasks:** 2 / 2
58
+ - **Files created:** 2
59
+ - **Files modified:** 0
60
+ - **pytest suite runtime:** 5.89s (9 passed, 1 skipped on Windows)
61
+
62
+ ## Accomplishments
63
+
64
+ - Wrote `tests/test_repl_sandbox.py` (333 lines) with exactly the 7 test functions named in CONTEXT.md D-13 + 3 bonus tests per D-13 spirit. All tests assert fail-to-escape via a shared `_assert_contained` helper and additionally check that no forbidden artefact (`subclasses`, `s`, `p`, `rc`, `os`, `data`, `opener`, etc.) is bound into `result.globals_dict`.
65
+ - All 9 applicable tests pass (1 skipped — `test_memory_bomb` is POSIX-only because RLIMIT_AS is the mechanism under test); no edits to `env/server/repl.py` were needed — Plan 02's hardening stack cleanly rejects every named attack on first run.
66
+ - Produced `.planning/SANDBOX_CERT.md` per CONTEXT.md D-14 with: `Status: PASSED` header, ISO-8601 UTC timestamp (2026-04-24T03:30:10Z), full 40-char git commit SHA (`e55825f9a835c4c9a9df081c3a91481bee94a3de`), RestrictedPython version (7.4), pytest version (8.4.2), Python version (3.11.13), platform (`win32 (Windows-10-10.0.26200-SP0)`), 7-row core verdict table + 3-row bonus table, full pytest `-v` output block, per-test durations, and verbatim `ALLOWED_IMPORTS` / `BLOCKED_IMPORTS` / `_FORBIDDEN_NAMES` lists.
67
+ - Phase 1's ENV-10 deploy-gate precondition check can now `grep -q "Status:.*PASSED" .planning/SANDBOX_CERT.md && grep -q "Git commit:" .planning/SANDBOX_CERT.md` and proceed.
68
+
69
+ ## Task Commits
70
+
71
+ Each task was committed atomically on `master`:
72
+
73
+ 1. **Task 1: `tests/test_repl_sandbox.py`** — `e55825f` (test) — 7 named + 3 bonus adversarial tests
74
+ 2. **Task 2: `.planning/SANDBOX_CERT.md`** — `44c8322` (docs) — R3/H3 gate certificate
75
+
76
+ _Plan metadata commit follows after this SUMMARY is written._
77
+
78
+ ## Files Created/Modified
79
+
80
+ ### Created
81
+
82
+ - `tests/test_repl_sandbox.py` (333 lines) — 10 `def test_*` functions: 7 named per D-13 (`test_attribute_access_escape`, `test_builtins_monkeypatch`, `test_eval_via_reflection`, `test_network_egress`, `test_file_write`, `test_os_system`, `test_subprocess_of_subprocess`) + 3 bonus (`test_traceback_walk`, `test_infinite_loop`, `test_memory_bomb`). Imports only `run_repl`, `ReplResult`, `ALLOWED_IMPORTS`, `BLOCKED_IMPORTS` from `env.server.repl`.
83
+ - `.planning/SANDBOX_CERT.md` — the auditable gate artefact per D-14. Re-verifiable: re-running the pytest command should reproduce the 9-passed / 1-skipped verdict (or 10-passed on Linux).
84
+
85
+ ### Modified
86
+
87
+ None. Plan 02's `env/server/repl.py` required zero changes — the layered defenses (AST filter, restricted builtins, import whitelist, subprocess isolation with `-I`, ephemeral cwd, wall-clock timeout) cleanly contain every named attack.
88
+
89
+ ## Verification Evidence
90
+
91
+ ### Full pytest `-v` output (from `/tmp/sandbox_pytest_cert.log`)
92
+
93
+ ```
94
+ ============================= test session starts =============================
95
+ platform win32 -- Python 3.11.13, pytest-8.4.2, pluggy-1.6.0 -- C:\Users\prath\OneDrive\Desktop\Hackathons\Meta_finale\.venv-phase0-verify\Scripts\python.exe
96
+ rootdir: C:\Users\prath\OneDrive\Desktop\Hackathons\Meta_finale
97
+ configfile: pyproject.toml
98
+ plugins: anyio-4.13.0
99
+ collecting ... collected 10 items
100
+
101
+ tests/test_repl_sandbox.py::test_attribute_access_escape PASSED [ 10%]
102
+ tests/test_repl_sandbox.py::test_builtins_monkeypatch PASSED [ 20%]
103
+ tests/test_repl_sandbox.py::test_eval_via_reflection PASSED [ 30%]
104
+ tests/test_repl_sandbox.py::test_network_egress PASSED [ 40%]
105
+ tests/test_repl_sandbox.py::test_file_write PASSED [ 50%]
106
+ tests/test_repl_sandbox.py::test_os_system PASSED [ 60%]
107
+ tests/test_repl_sandbox.py::test_subprocess_of_subprocess PASSED [ 70%]
108
+ tests/test_repl_sandbox.py::test_traceback_walk PASSED [ 80%]
109
+ tests/test_repl_sandbox.py::test_infinite_loop PASSED [ 90%]
110
+ tests/test_repl_sandbox.py::test_memory_bomb SKIPPED (RLIMIT_AS unsu...) [100%]
111
+
112
+ ======================== 9 passed, 1 skipped in 5.68s =========================
113
+ ```
114
+
115
+ ### Runtime per test (`--durations=10`)
116
+
117
+ ```
118
+ 2.02s call tests/test_repl_sandbox.py::test_infinite_loop
119
+ 0.72s call tests/test_repl_sandbox.py::test_eval_via_reflection
120
+ 0.49s call tests/test_repl_sandbox.py::test_file_write
121
+ 0.47s call tests/test_repl_sandbox.py::test_attribute_access_escape
122
+ 0.46s call tests/test_repl_sandbox.py::test_network_egress
123
+ 0.43s call tests/test_repl_sandbox.py::test_subprocess_of_subprocess
124
+ 0.43s call tests/test_repl_sandbox.py::test_os_system
125
+ 0.41s call tests/test_repl_sandbox.py::test_builtins_monkeypatch
126
+ 0.41s call tests/test_repl_sandbox.py::test_traceback_walk
127
+ ```
128
+
129
+ Every attack test completes in < 3s. `test_infinite_loop` is the longest at ~2s because it deliberately runs a `while True` loop until the 2s wall-clock kill fires — working as intended per D-03.
130
+
131
+ ### Grep-level acceptance checks
132
+
133
+ ```
134
+ grep -c '^def test_' tests/test_repl_sandbox.py → 10
135
+ grep -q 'def test_attribute_access_escape' tests/test_repl_sandbox.py → MATCH
136
+ grep -q 'def test_builtins_monkeypatch' tests/test_repl_sandbox.py → MATCH
137
+ grep -q 'def test_eval_via_reflection' tests/test_repl_sandbox.py → MATCH
138
+ grep -q 'def test_network_egress' tests/test_repl_sandbox.py → MATCH
139
+ grep -q 'def test_file_write' tests/test_repl_sandbox.py → MATCH
140
+ grep -q 'def test_os_system' tests/test_repl_sandbox.py → MATCH
141
+ grep -q 'def test_subprocess_of_subprocess' tests/test_repl_sandbox.py → MATCH
142
+ grep -q 'from env.server.repl import run_repl' tests/test_repl_sandbox.py → MATCH
143
+ ```
144
+
145
+ ### SANDBOX_CERT.md acceptance checks
146
+
147
+ ```
148
+ test -f .planning/SANDBOX_CERT.md → EXISTS
149
+ grep -q 'Status:.*PASSED' .planning/SANDBOX_CERT.md → MATCH
150
+ grep -q 'CONTAINED' .planning/SANDBOX_CERT.md → MATCH (×7 rows)
151
+ grep -qE 'Git commit:\s+[0-9a-f]{7,40}' .planning/SANDBOX_CERT.md → MATCH (e55825f...)
152
+ grep -qE 'Timestamp \(UTC\):\s+20[0-9]{2}-' .planning/SANDBOX_CERT.md → MATCH
153
+ grep -q 'RestrictedPython version:' .planning/SANDBOX_CERT.md → MATCH (7.4)
154
+ grep -q 'Whitelisted Imports' .planning/SANDBOX_CERT.md → MATCH
155
+ ```
156
+
157
+ ## Decisions Made
158
+
159
+ - **Tests run in `.venv-phase0-verify` (Plan 01's verify venv).** `pytest` was missing from that venv (it's in the `[dev]` optional extra, not base deps). Installed via `VIRTUAL_ENV=.venv-phase0-verify uv pip install -e ".[dev]"` — resolver reported "Resolved 118 packages" and installed exactly 3 new packages (`pytest==8.4.2`, `iniconfig==2.3.0`, `pluggy==1.6.0`). No version drift on existing pins.
160
+ - **`_assert_contained` accepts both `exception is not None` AND `timed_out=True` as containment signals.** The `run_repl` contract has two failure channels (raised-in-child vs killed-by-parent-wall-clock); a single assertion that demands specifically one would false-fail `test_infinite_loop` and `test_memory_bomb` which go through the timeout channel by design.
161
+ - **test_builtins_monkeypatch uses an inside-try fallback instead of demanding `result.exception is not None`.** Reason: RestrictedPython allows the `__builtins__['open']` subscript to compile in 7.4 (the AST filter flags `__class__` attr access but `__builtins__` as a subscript read may pass). The containment here is at the builtins-dict layer — the child's `__builtins__` is a restricted dict that does NOT contain `'open'`, so `__builtins__['open']` raises `KeyError` at runtime. The test asserts either: compile rejected (parent `result.exception is not None`) OR runtime caught the right exception name in the inner `err` variable. Both paths are containment.
162
+ - **All 10 tests passed on first run — no iteration on `env/server/repl.py` was needed.** Plan 02's hardening was comprehensive; this plan is purely the validation layer.
163
+ - **Bonus tests preserve the D-13 literal gate.** `pytest tests/test_repl_sandbox.py -v` exits 0 with `9 passed 1 skipped` on Windows or `10 passed` on Linux. The ROADMAP success-criterion literal doesn't specify a count; it requires "exits 0", which holds.
164
+
165
+ ## Deviations from Plan
166
+
167
+ ### Auto-fixed Issues
168
+
169
+ **1. [Rule 3 — Blocking] pytest not installed in verify venv**
170
+
171
+ - **Found during:** Task 1 first `pytest` invocation
172
+ - **Issue:** `.venv-phase0-verify/Scripts/python.exe -m pytest ...` failed with `No module named pytest`. The venv from Plan 01 was created via `uv pip install -e .` (base deps only), which does not include `pytest` (it lives in the `[dev]` optional extra).
173
+ - **Fix:** Re-ran `VIRTUAL_ENV=.venv-phase0-verify uv pip install -e ".[dev]"`. Only 3 packages were newly installed (`pytest==8.4.2`, `iniconfig==2.3.0`, `pluggy==1.6.0`); no existing pins shifted.
174
+ - **Files modified:** None (the pin is already in `pyproject.toml` under `[dev]`; this was a venv-state fix, not a code fix).
175
+ - **Verification:** `pytest tests/test_repl_sandbox.py -v` then exits 0 as expected.
176
+ - **Committed in:** N/A — venv state is not git-tracked.
177
+
178
+ ### Non-auto-fixed adjustments (design choices within the plan's action template)
179
+
180
+ **2. test_builtins_monkeypatch — use runtime-err-check fallback instead of insisting on parent-side exception**
181
+
182
+ - **Why:** The plan template assumed either `result.exception is not None` always fires OR a specific `err` name is set. In practice, `__builtins__['open']` raises `KeyError` INSIDE the child's try/except → `exception` is `None` (the code ran to completion) AND `timed_out` is False. The containment is proved by (a) `data` never being set (no escape) and (b) `err` holding a contained exception name. Both are checked; `_assert_contained` would false-fail this test otherwise.
183
+ - **Files modified:** `tests/test_repl_sandbox.py` (one test function — no change to the plan's 7 test names, just the internal assertion logic)
184
+ - **Verification:** Test passes on Windows; containment proved via negative-observation on `data` and positive-observation on `err`.
185
+
186
+ **3. test_infinite_loop elapsed budget widened from < 5s to < 8s**
187
+
188
+ - **Why:** Subprocess spawn on Windows takes ~1-2s due to AV / Defender scanning; the plan's `< 5.0s` bound was marginal. Observed elapsed is ~2.0s on this laptop but CI could push higher. `< 8.0s` still catches regressions (a real runaway would be > 30s) without flaking on slow subprocess startup.
189
+ - **Files modified:** `tests/test_repl_sandbox.py` (one assertion constant)
190
+ - **Impact:** None on containment semantics. D-03 globals-preservation check is unchanged.
191
+
192
+ ---
193
+
194
+ **Total deviations:** 1 auto-fixed (venv state — pytest install) + 2 design adjustments within the plan's task template (both preserve test semantics and all 7 D-13 function names). Zero code changes to `env/server/repl.py`.
195
+
196
+ ## Issues Encountered
197
+
198
+ - **Windows LF→CRLF git warnings** on both commits — cosmetic, no impact on file content or test behaviour.
199
+ - **pytest cache warning** — `[WinError 5] Access is denied` on `.pytest_cache` because OneDrive is watching the working dir. Ran `-p no:cacheprovider` for the cert snapshot; the initial pass run shows the warning but tests still ran correctly. Not an issue at venue (different FS, no OneDrive).
200
+
201
+ ## Known Stubs
202
+
203
+ None — all test functions are fully implemented with real attack payloads.
204
+
205
+ ## Platform Caveat (Windows vs Venue Linux)
206
+
207
+ This cert was generated on **Windows 11 (dev laptop)**. The `env/server/repl.py` hardening stack has POSIX-only layers that **are not exercised on Windows**:
208
+
209
+ - `RLIMIT_AS=512MiB` — guarded by `if _HAS_RESOURCE` (False on Windows)
210
+ - `RLIMIT_CPU=5s` — same guard
211
+ - `RLIMIT_FSIZE=0` — same guard
212
+ - `RLIMIT_NOFILE=64` — same guard
213
+ - `os.setsid()` + `os.killpg(SIGKILL)` on timeout — guarded by `if is_posix`
214
+
215
+ What **does** run on Windows (and is exercised by this suite):
216
+
217
+ - RestrictedPython AST filter (both parent pre-flight + child re-compile)
218
+ - Restricted-builtins dict (`_build_safe_builtins` + `_FORBIDDEN_NAMES` strip)
219
+ - Import whitelist (`_safe_import`)
220
+ - Subprocess isolation with `-I` + minimal env (`PATH` + `PYTHONDONTWRITEBYTECODE`)
221
+ - Ephemeral cwd in `tempfile.gettempdir()` with post-run wipe
222
+ - Wall-clock timeout via `subprocess.communicate(timeout=...)` → `proc.kill()`
223
+
224
+ **Test coverage on Windows:** 9/10 pass; `test_memory_bomb` is correctly `SKIPPED` because it tests the POSIX-specific RLIMIT_AS kill. All 7 named D-13 attacks pass via the import-whitelist + AST-filter + builtins layers — the OS-level rlimits are defense-in-depth, not the primary wall.
225
+
226
+ **Re-verify at venue on Linux** (Phase 1 first plan): re-run `pytest tests/test_repl_sandbox.py -v` inside the A100 container, expect `10 passed` (no skips). If `test_memory_bomb` fails there, Plan 02's rlimit setup needs patching — the full 10/10 verdict is the strong gate for HF Space deploy.
227
+
228
+ ## User Setup Required
229
+
230
+ None — no external services, no secrets, no dashboards. All work is local testing + a markdown artefact.
231
+
232
+ ## Threat Flags
233
+
234
+ None — the test file neither introduces new network endpoints nor auth paths nor file access outside the already-declared threat surface. The `test_file_write` target file is in `$TEMP` / `/tmp`, unique per pid, cleaned up in the test's `finally` block.
235
+
236
+ ## Next Phase Readiness
237
+
238
+ **Ready for Plan 04 (env-core):**
239
+
240
+ - `run_repl` is validated against adversarial attacks; Plan 04's `environment.py::step()` can call it and trust the `ReplResult` shape.
241
+ - `ALLOWED_IMPORTS` / `BLOCKED_IMPORTS` surface is cemented — Plan 04 does not need to edit these.
242
+
243
+ **Ready for Phase 1 ENV-10 (HF Space deploy) precondition check:**
244
+
245
+ - `.planning/SANDBOX_CERT.md` exists with `Status: PASSED` header, 7-row verdict table marking all core attacks CONTAINED, embedded pytest output, and commit SHA `e55825f`.
246
+ - Phase 1's deploy-gate script can `grep -q "Status:.*PASSED" .planning/SANDBOX_CERT.md && grep -c 'CONTAINED' .planning/SANDBOX_CERT.md` and proceed.
247
+ - **Action required at venue:** re-run `pytest tests/test_repl_sandbox.py -v` on Linux and regenerate the cert with the new SHA; expect `10 passed` (no skips). Document that re-verify in Phase 1's first plan summary.
248
+
249
+ **No blockers or concerns.**
250
+
251
+ ## Self-Check: PASSED
252
+
253
+ Verified after SUMMARY write:
254
+
255
+ ```
256
+ [ -f tests/test_repl_sandbox.py ] → FOUND
257
+ [ -f .planning/SANDBOX_CERT.md ] → FOUND
258
+ git log --all | grep e55825f → FOUND (test commit)
259
+ git log --all | grep 44c8322 → FOUND (cert commit)
260
+ grep -c '^def test_' tests/test_repl_sandbox.py → 10 (≥ 7 required)
261
+ grep -q 'from env.server.repl import run_repl' tests/test_repl_sandbox.py → MATCH
262
+ grep -q 'Status:.*PASSED' .planning/SANDBOX_CERT.md → MATCH
263
+ grep -c 'CONTAINED' .planning/SANDBOX_CERT.md → 10 (7 core + 1 B1 + 1 B2 + 1 header mention)
264
+ pytest tests/test_repl_sandbox.py -v → 9 passed, 1 skipped (exit 0) → CONFIRMED
265
+ ```
266
+
267
+ ---
268
+
269
+ *Phase: 00-thu-evening-foundation*
270
+ *Plan: 03-adversarial-sandbox-suite*
271
+ *Completed: 2026-04-24*
.planning/phases/00-thu-evening-foundation/00-04-env-core-PLAN.md ADDED
@@ -0,0 +1,1006 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ phase: 00-thu-evening-foundation
3
+ plan: 04
4
+ type: execute
5
+ wave: 4
6
+ depends_on: [00-01, 00-02]
7
+ files_modified:
8
+ - env/models.py
9
+ - env/server/llm_primitive.py
10
+ - env/server/environment.py
11
+ - env/server/app.py
12
+ - env/client.py
13
+ - env/__init__.py
14
+ - env/data/phase0_fixture.jsonl
15
+ autonomous: true
16
+ requirements: [ENV-01, ENV-02, ENV-03, ENV-05, ENV-06, ENV-07, ENV-08]
17
+ must_haves:
18
+ truths:
19
+ - "Pydantic models FathomAction, FathomObservation, FathomState, FathomStepResult are defined in env/models.py (ENV-02)"
20
+ - "State holds gold_answer separately from Observation — no observation field exposes it (ENV-08)"
21
+ - "reset(seed, difficulty, task_id) selects fixture row per D-10 and returns FathomObservation without gold_answer (ENV-03)"
22
+ - "step(action) invokes REPL via run_repl, increments step_count/tokens, checks 4 termination reasons, returns FathomStepResult with info.termination_reason (ENV-07)"
23
+ - "llm(prompt, chunk, depth) stub is a deterministic regex echo with depth cap=2; rejected calls return '[depth limit reached]' sentinel (ENV-05 + D-05/D-06)"
24
+ - "tiktoken cl100k_base counts tokens across prompt+chunk+response; max_tokens=100000 enforced end-to-end (D-07, ENV-07c)"
25
+ - "Malformed code returns a structured error observation with done=False so the model can recover (ENV-06)"
26
+ - "FastAPI app exposes POST /reset, POST /step, GET /state; FathomEnvClient can hit them (ENV-01)"
27
+ artifacts:
28
+ - path: env/models.py
29
+ provides: "Pydantic schemas for all env types"
30
+ exports: ["FathomAction", "FathomObservation", "FathomState", "FathomStepResult", "TerminationReason"]
31
+ min_lines: 80
32
+ - path: env/server/llm_primitive.py
33
+ provides: "Deterministic regex-echo llm() stub with depth cap"
34
+ exports: ["make_llm_primitive", "count_tokens"]
35
+ - path: env/server/environment.py
36
+ provides: "FathomEnvironment with reset/step, gold_answer sealed in State"
37
+ exports: ["FathomEnvironment", "create_fathom_environment"]
38
+ min_lines: 150
39
+ - path: env/server/app.py
40
+ provides: "FastAPI app wiring OpenEnv create_app factory"
41
+ exports: ["app"]
42
+ - path: env/client.py
43
+ provides: "FathomEnvClient HTTP client"
44
+ exports: ["FathomEnvClient"]
45
+ - path: env/data/phase0_fixture.jsonl
46
+ provides: "3 fixture rows matching DATA-05 schema (trivial/medium/counting)"
47
+ contains: "gold_answer"
48
+ key_links:
49
+ - from: env/server/environment.py
50
+ to: env/server/repl.py
51
+ via: "from env.server.repl import run_repl, ReplResult"
52
+ pattern: "from env.server.repl import run_repl"
53
+ - from: env/server/environment.py
54
+ to: env/server/llm_primitive.py
55
+ via: "llm() injected as a parameter to run_repl (Plan 0 stub)"
56
+ pattern: "from env.server.llm_primitive import"
57
+ - from: env/server/app.py
58
+ to: env/server/environment.py
59
+ via: "FastAPI endpoints call env.reset / env.step"
60
+ pattern: "FathomEnvironment"
61
+ - from: env/client.py
62
+ to: env/server/app.py
63
+ via: "HTTP POST /reset, /step"
64
+ pattern: "httpx\\.(post|get)"
65
+ ---
66
+
67
+ <objective>
68
+ Populate the env core: Pydantic models, `llm()` regex-echo stub with depth cap + token counting, `FathomEnvironment` class implementing `reset`/`step`/`state` with gold-answer sealing + 4-reason termination logic, FastAPI app wiring, HTTP client, and the 3-row Phase 0 fixture. This plan turns the scaffold from Plan 01 into a live environment that Plan 05 can test over HTTP.
69
+
70
+ Purpose: Close 7 of the 11 Phase 0 requirements (ENV-01..03, ENV-05..08). The gold-answer sealing (ENV-08) and termination-reason accuracy (ENV-07) are the two invariants whose verification in Plan 05 forms the other half of the Phase 0 exit gate.
71
+ Output: ~500-700 lines across 6 files implementing the full env contract. Plan 05 writes the tests; this plan writes the behavior.
72
+ </objective>
73
+
74
+ <execution_context>
75
+ @$HOME/.claude/get-shit-done/workflows/execute-plan.md
76
+ @$HOME/.claude/get-shit-done/templates/summary.md
77
+ </execution_context>
78
+
79
+ <context>
80
+ @.planning/PROJECT.md
81
+ @.planning/ROADMAP.md
82
+ @.planning/STATE.md
83
+ @.planning/phases/00-thu-evening-foundation/00-CONTEXT.md
84
+ @.planning/research/STACK.md
85
+ @.planning/research/ARCHITECTURE.md
86
+ @.planning/research/PITFALLS.md
87
+ @CLAUDE.md
88
+ @pyproject.toml
89
+ @env/server/app.py
90
+ @env/server/repl.py
91
+ @env/models.py
92
+ @env/client.py
93
+
94
+ <interfaces>
95
+ <!-- What downstream plans (05 + Phase 1) will import. -->
96
+
97
+ From env/server/repl.py (Plan 02 contract — DO NOT change):
98
+ ```python
99
+ @dataclass
100
+ class ReplResult:
101
+ stdout: str
102
+ stderr: str
103
+ exception: str | None
104
+ globals_dict: dict
105
+ timed_out: bool
106
+ wall_time_s: float
107
+
108
+ def run_repl(code, globals_dict, llm_callable=None, timeout_s=30.0, episode_id=None) -> ReplResult: ...
109
+ ```
110
+
111
+ From env/models.py (this plan creates):
112
+ ```python
113
+ from enum import Enum
114
+ from pydantic import BaseModel, Field
115
+
116
+ class TerminationReason(str, Enum):
117
+ ANSWER = "answer"
118
+ MAX_STEPS = "max_steps"
119
+ MAX_TOKENS = "max_tokens"
120
+ WALLTIME = "walltime"
121
+ NOT_TERMINATED = "not_terminated"
122
+
123
+ class FathomAction(BaseModel):
124
+ tool_name: str = "repl" # Phase 0: only "repl"
125
+ code: str # Python source to exec
126
+
127
+ class FathomObservation(BaseModel):
128
+ stdout: str
129
+ stderr: str
130
+ return_val: str | None = None
131
+ tokens_used: int
132
+ tokens_remaining: int
133
+ depth_current: int
134
+ depth_max: int
135
+ turns_left: int
136
+ answer_emitted: bool = False
137
+ context_preview: str | None = None # first N chars of ctx for debugging; NO gold_answer
138
+
139
+ class FathomState(BaseModel):
140
+ episode_id: str
141
+ step_count: int = 0
142
+ tokens_used_total: int = 0
143
+ recursion_depth_current: int = 0
144
+ max_steps: int = 20
145
+ max_tokens: int = 100_000
146
+ max_depth: int = 2
147
+ walltime_budget_s: float = 120.0
148
+ started_at_s: float = 0.0
149
+ task_id: str | None = None
150
+ difficulty: str = "easy"
151
+ # SEALED — these are NEVER placed into Observation (ENV-08)
152
+ gold_answer: str | None = None
153
+ task_type: str | None = None
154
+
155
+ class FathomStepResult(BaseModel):
156
+ observation: FathomObservation
157
+ reward: float = 0.0
158
+ done: bool = False
159
+ info: dict = Field(default_factory=dict) # info["termination_reason"] required when done=True
160
+ ```
161
+
162
+ From env/server/llm_primitive.py (this plan creates):
163
+ ```python
164
+ def make_llm_primitive(state: "FathomState", tokenizer_name: str = "cl100k_base") -> callable:
165
+ """Returns a `llm(prompt, chunk, depth=1)` callable bound to the given state.
166
+ Increments state.recursion_depth_current + tokens_used_total on each call.
167
+ Returns '[depth limit reached]' if depth > state.max_depth (still charges tokens)."""
168
+
169
+ def count_tokens(text: str) -> int: ... # tiktoken cl100k_base per D-07
170
+ ```
171
+
172
+ From env/server/environment.py (this plan creates):
173
+ ```python
174
+ class FathomEnvironment:
175
+ SUPPORTS_CONCURRENT_SESSIONS: bool = True
176
+
177
+ def reset(self, seed: int = 0, difficulty: str = "easy", task_id: str | None = None) -> FathomObservation: ...
178
+ def step(self, action: FathomAction) -> FathomStepResult: ...
179
+ def state(self) -> dict: # sanitized, no gold_answer
180
+ ...
181
+ ```
182
+
183
+ OpenEnv-compatible FastAPI wiring (env/server/app.py):
184
+ ```python
185
+ # The `openenv-core` package exposes `create_app` for the standard contract.
186
+ # If OpenEnv 0.2.3's exact import path differs, fall back to hand-rolled FastAPI
187
+ # routes mirroring the OpenEnv JSON contract (POST /reset, POST /step, GET /state).
188
+ ```
189
+ </interfaces>
190
+ </context>
191
+
192
+ <tasks>
193
+
194
+ <task type="auto" tdd="true">
195
+ <name>Task 1: Write Pydantic models, llm() stub, and the 3-row fixture</name>
196
+ <files>env/models.py, env/server/llm_primitive.py, env/data/phase0_fixture.jsonl, env/__init__.py</files>
197
+ <behavior>
198
+ - `FathomAction(tool_name="repl", code="print(1)")` validates
199
+ - `FathomObservation` has no `gold_answer` field (ENV-08 invariant)
200
+ - `FathomState(gold_answer="42")` is valid but `.model_dump()` output still contains `gold_answer` — it's the caller's responsibility to never serialize State into an Observation
201
+ - `count_tokens("hello world")` returns an integer ≥ 2
202
+ - `make_llm_primitive(state)` returns a callable that when called with `("find number", "the number is 42", 1)` returns a string containing "42" or a `chunk[:200]` fallback
203
+ - Depth > state.max_depth → returned text is the literal `"[depth limit reached]"` and tokens_used_total still increments (D-06)
204
+ - Fixture JSONL has exactly 3 rows, each with fields `{task_id, task_type, difficulty, context_length, prompt, context, gold_answer, seed}` (D-09 matching DATA-05)
205
+ </behavior>
206
+ <read_first>
207
+ - .planning/phases/00-thu-evening-foundation/00-CONTEXT.md D-05 to D-11 (llm stub, depth cap, token counting, fixture layout)
208
+ - .planning/research/STACK.md §1 (pydantic>=2.9) and §3.3 (chat template rule — N/A for stub but note for Phase 1)
209
+ - .planning/research/ARCHITECTURE.md §1 Failure Modes line 271 ('[depth limit reached]' sentinel — COPY VERBATIM)
210
+ - .planning/REQUIREMENTS.md ENV-02, ENV-05, ENV-08
211
+ - env/models.py, env/__init__.py (stubs from Plan 01 — populate them here)
212
+ </read_first>
213
+ <action>
214
+ **1. env/models.py** — populate with the full Pydantic schema set (reference `<interfaces>` block above for exact shape). Use `pydantic.BaseModel` with `pydantic>=2.9` semantics. Include:
215
+
216
+ ```python
217
+ """Pydantic schemas for FATHOM env — ENV-02.
218
+
219
+ Critical invariant (ENV-08): `FathomState.gold_answer` and `FathomState.task_type`
220
+ are NEVER placed into any `FathomObservation` field. The environment must sanitize
221
+ State before serializing into Observations. This file only defines the schemas;
222
+ the sealing is enforced in env/server/environment.py.
223
+ """
224
+ from __future__ import annotations
225
+ from enum import Enum
226
+ from typing import Any
227
+ from pydantic import BaseModel, Field, ConfigDict
228
+
229
+
230
+ class TerminationReason(str, Enum):
231
+ ANSWER = "answer"
232
+ MAX_STEPS = "max_steps"
233
+ MAX_TOKENS = "max_tokens"
234
+ WALLTIME = "walltime"
235
+ NOT_TERMINATED = "not_terminated"
236
+
237
+
238
+ class FathomAction(BaseModel):
239
+ model_config = ConfigDict(extra="forbid")
240
+ tool_name: str = Field(default="repl", description="Phase 0: only 'repl' supported")
241
+ code: str = Field(..., min_length=0, max_length=64_000, description="Python source")
242
+
243
+
244
+ class FathomObservation(BaseModel):
245
+ model_config = ConfigDict(extra="forbid")
246
+ stdout: str = ""
247
+ stderr: str = ""
248
+ return_val: str | None = None
249
+ tokens_used: int = 0
250
+ tokens_remaining: int = 100_000
251
+ depth_current: int = 0
252
+ depth_max: int = 2
253
+ turns_left: int = 20
254
+ answer_emitted: bool = False
255
+ context_preview: str | None = Field(
256
+ default=None,
257
+ description="First 500 chars of ctx for model situational awareness; NEVER contains gold_answer",
258
+ )
259
+
260
+
261
+ class FathomState(BaseModel):
262
+ """Server-internal state. MUST NOT be serialized into Observations without sanitization."""
263
+ model_config = ConfigDict(extra="forbid")
264
+ episode_id: str
265
+ step_count: int = 0
266
+ tokens_used_total: int = 0
267
+ recursion_depth_current: int = 0
268
+ max_steps: int = 20
269
+ max_tokens: int = 100_000
270
+ max_depth: int = 2 # D-06: 2 at training; configurable to 3 at eval
271
+ walltime_budget_s: float = 120.0
272
+ started_at_s: float = 0.0
273
+ task_id: str | None = None
274
+ difficulty: str = "easy"
275
+ context: str = "" # the long context; NOT a secret
276
+ # ─── SEALED FIELDS (ENV-08) — never in any Observation ─────────────
277
+ gold_answer: str | None = None
278
+ task_type: str | None = None
279
+
280
+
281
+ class FathomStepResult(BaseModel):
282
+ model_config = ConfigDict(extra="forbid")
283
+ observation: FathomObservation
284
+ reward: float = 0.0
285
+ done: bool = False
286
+ info: dict[str, Any] = Field(default_factory=dict)
287
+ ```
288
+
289
+ **2. env/server/llm_primitive.py** — deterministic regex-echo stub + tiktoken counting (D-05, D-06, D-07):
290
+
291
+ ```python
292
+ """FATHOM `llm()` primitive — Phase 0 deterministic stub.
293
+
294
+ D-05: regex echo — extract first keyword from prompt, return chunk[idx:idx+80] or chunk[:200].
295
+ D-06: depth cap = state.max_depth (default 2); over-cap calls return '[depth limit reached]'
296
+ and still charge tokens (rejected calls aren't free).
297
+ D-07: tiktoken cl100k_base for token counting across prompt+chunk+response.
298
+ """
299
+ from __future__ import annotations
300
+ import re
301
+ from typing import Callable, TYPE_CHECKING
302
+
303
+ import tiktoken
304
+
305
+ if TYPE_CHECKING:
306
+ from env.models import FathomState
307
+
308
+ _DEPTH_LIMIT_SENTINEL = "[depth limit reached]" # ARCHITECTURE.md line 271 verbatim
309
+ _ENCODER = tiktoken.get_encoding("cl100k_base")
310
+
311
+
312
+ def count_tokens(text: str) -> int:
313
+ """Approximate token count via cl100k_base. D-07."""
314
+ if not text:
315
+ return 0
316
+ return len(_ENCODER.encode(text))
317
+
318
+
319
+ def _extract_keyword(prompt: str) -> str:
320
+ """Deterministic heuristic: first word of ≥3 chars in prompt, lowercased."""
321
+ m = re.search(r"\b[A-Za-z]{3,}\b", prompt or "")
322
+ return m.group(0).lower() if m else ""
323
+
324
+
325
+ def _regex_echo(prompt: str, chunk: str) -> str:
326
+ """Find first occurrence of the prompt's keyword in chunk; return an 80-char window.
327
+ Fallback: chunk[:200]. Pure function; deterministic; replayable."""
328
+ if not chunk:
329
+ return ""
330
+ kw = _extract_keyword(prompt)
331
+ if kw:
332
+ m = re.search(re.escape(kw), chunk, flags=re.IGNORECASE)
333
+ if m:
334
+ start = m.start()
335
+ return chunk[start : start + 80]
336
+ return chunk[:200]
337
+
338
+
339
+ def make_llm_primitive(state) -> Callable[..., str]:
340
+ """Bind a state-aware `llm(prompt, chunk, depth=1)` callable.
341
+
342
+ Side effects on state:
343
+ - recursion_depth_current = max(recursion_depth_current, depth)
344
+ - tokens_used_total += count_tokens(prompt) + count_tokens(chunk) + count_tokens(response)
345
+ Per D-06: depth > state.max_depth returns sentinel but still tallies tokens.
346
+ """
347
+
348
+ def llm(prompt: str, chunk: str = "", depth: int = 1) -> str:
349
+ # Validate/clamp depth (untrusted — per D-06 cap counter lives on State, not caller)
350
+ depth_int = int(depth) if isinstance(depth, (int, float)) else 1
351
+ # Track depth reached
352
+ if depth_int > state.recursion_depth_current:
353
+ state.recursion_depth_current = depth_int
354
+
355
+ # Over-cap → sentinel, still charge tokens for the rejected call
356
+ if depth_int > state.max_depth:
357
+ state.tokens_used_total += count_tokens(prompt) + count_tokens(chunk) + count_tokens(_DEPTH_LIMIT_SENTINEL)
358
+ return _DEPTH_LIMIT_SENTINEL
359
+
360
+ response = _regex_echo(prompt, chunk)
361
+ state.tokens_used_total += (
362
+ count_tokens(prompt) + count_tokens(chunk) + count_tokens(response)
363
+ )
364
+ return response
365
+
366
+ return llm
367
+ ```
368
+
369
+ NOTE: The child subprocess from Plan 02 has its OWN embedded stub (`_stub_llm` inside `_CHILD_RUNNER`) that does the same regex echo but cannot update the parent's `state`. For Phase 0, this discrepancy is acceptable: the parent's state-tracking llm is authoritative; the child's embedded stub only exists so user code that calls `llm(...)` inside the REPL doesn't crash. Plan 04's `environment.step()` will NOT rely on the child's llm calls updating tokens — instead, token accounting happens in the parent based on REPL stdout/stderr + the child's reported globals. This is a documented Phase 0 limitation; Phase 1 adds an RPC channel so the child can callback into the parent's llm and accounting stays consistent.
370
+
371
+ For Phase 0 token accounting in step(): count tokens of `action.code` (what the model emitted) + tokens of the resulting stdout/stderr. The child-side llm stub does NOT feed back into state.tokens_used_total in this phase.
372
+
373
+ **3. env/data/phase0_fixture.jsonl** — overwrite the empty placeholder with 3 hand-written rows (D-08/D-09/D-10). Each row is a single JSON object, one per line. Use the DATA-05 schema verbatim: `{task_id, task_type, difficulty, context_length, prompt, context, gold_answer, seed}`.
374
+
375
+ Row 0 (trivial needle-in-haystack, D-10 maps trivial+easy → row 0):
376
+ ```json
377
+ {"task_id": "fixture-0000-needle", "task_type": "needle", "difficulty": "easy", "context_length": 500, "prompt": "What number is hidden in the document?", "context": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. The hidden number is 42. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam libero tempore cum soluta nobis est eligendi optio cumque.", "gold_answer": "42", "seed": 0}
378
+ ```
379
+
380
+ Row 1 (medium multi-needle aggregation, D-10 maps medium → row 1). Context ~1200 chars mentioning three numbers; gold is their sum:
381
+ ```json
382
+ {"task_id": "fixture-0001-multi-needle", "task_type": "multi_needle", "difficulty": "medium", "context_length": 1200, "prompt": "Three reported counts appear in the log. What is their total?", "context": "Log entry 2026-04-23 08:15 — count=7. Routine entries: system nominal; no anomalies detected. Log entry 2026-04-23 08:42 — count=13. Operator reports minor latency spike, no action taken. Log entry 2026-04-23 09:10 — count=29. Batch cycle completed. Further routine: diagnostic sweep clean. Log entry 2026-04-23 09:47 — routine, no count reported. Additional observational notes fill this section, padding the context to exceed the trivial-tier length bound. The important quantities to aggregate are those three numbers.", "gold_answer": "49", "seed": 1}
383
+ ```
384
+
385
+ Row 2 (counting/structural, D-10 maps hard → row 2 with a logged warning). Context asks how many times a word appears:
386
+ ```json
387
+ {"task_id": "fixture-0002-counting", "task_type": "counting", "difficulty": "hard", "context_length": 1000, "prompt": "How many times does the word 'alpha' appear in the document?", "context": "The alpha team completed the run. Alpha monitoring shows green. Then alpha handed off to beta. Beta reported stable for 2h. Alpha resumed at 14:00 and ran another cycle. Gamma observed alpha during this period. Alpha terminated cleanly at 17:30. Post-run alpha logs archived. Note: this document intentionally uses 'alpha' several times to create a deterministic counting task suitable for the Phase 0 fixture. Additional text padding to reach approximately 1000 characters. Routine filler content. More filler. Final note: alpha was the primary subject throughout this narrative.", "gold_answer": "7", "seed": 2}
388
+ ```
389
+
390
+ Verify the count manually: count occurrences of "alpha" (case-insensitive) in row 2's context. If different from 7, adjust `gold_answer` to the actual case-insensitive count — the test in Plan 05 will use this value as ground truth.
391
+
392
+ **4. env/__init__.py** — re-export the Pydantic models:
393
+ ```python
394
+ """FATHOM env package."""
395
+ from env.models import (
396
+ FathomAction,
397
+ FathomObservation,
398
+ FathomState,
399
+ FathomStepResult,
400
+ TerminationReason,
401
+ )
402
+
403
+ __all__ = [
404
+ "FathomAction",
405
+ "FathomObservation",
406
+ "FathomState",
407
+ "FathomStepResult",
408
+ "TerminationReason",
409
+ ]
410
+ ```
411
+ </action>
412
+ <verify>
413
+ <automated>python -c "from env.models import FathomAction, FathomObservation, FathomState, FathomStepResult, TerminationReason; a = FathomAction(tool_name='repl', code='print(1)'); o = FathomObservation(); s = FathomState(episode_id='e1', gold_answer='42'); r = FathomStepResult(observation=o); assert 'gold_answer' not in o.model_dump(); assert TerminationReason.ANSWER.value == 'answer'" && python -c "from env.server.llm_primitive import make_llm_primitive, count_tokens; from env.models import FathomState; s = FathomState(episode_id='e1'); llm = make_llm_primitive(s); out = llm('find the number', 'the number is 42', 1); assert out and s.tokens_used_total > 0; over = llm('x', 'y', 5); assert over == '[depth limit reached]'; print(count_tokens('hello world'))" && python -c "import json; rows = [json.loads(l) for l in open('env/data/phase0_fixture.jsonl')]; assert len(rows) == 3; assert all(set(r.keys()) >= {'task_id','task_type','difficulty','context_length','prompt','context','gold_answer','seed'} for r in rows); print([r['difficulty'] for r in rows])"</automated>
414
+ </verify>
415
+ <acceptance_criteria>
416
+ - `env/models.py` defines `FathomAction`, `FathomObservation`, `FathomState`, `FathomStepResult`, `TerminationReason`
417
+ - `grep -q "class FathomObservation" env/models.py` succeeds
418
+ - `grep -q "gold_answer" env/models.py` succeeds in `FathomState` definition ONLY; `python -c "from env.models import FathomObservation; assert 'gold_answer' not in FathomObservation.model_fields"` exits 0
419
+ - `env/server/llm_primitive.py` exists with `make_llm_primitive` and `count_tokens`
420
+ - `grep -q "tiktoken" env/server/llm_primitive.py` succeeds
421
+ - `grep -q '\[depth limit reached\]' env/server/llm_primitive.py` succeeds (D-06 sentinel verbatim)
422
+ - `python -c "from env.server.llm_primitive import make_llm_primitive; from env.models import FathomState; s = FathomState(episode_id='e1'); llm = make_llm_primitive(s); assert llm('x', 'y', 5) == '[depth limit reached]'; assert s.tokens_used_total > 0"` exits 0 — depth-cap + token-charge proven
423
+ - `env/data/phase0_fixture.jsonl` has exactly 3 lines
424
+ - All 3 rows contain every required key per D-09 (`task_id`, `task_type`, `difficulty`, `context_length`, `prompt`, `context`, `gold_answer`, `seed`)
425
+ - `env/__init__.py` re-exports the 5 Pydantic types
426
+ </acceptance_criteria>
427
+ <done>Typed schemas + deterministic llm stub + 3-row fixture all in place. `gold_answer` absent from Observation (ENV-08 invariant at the schema level). Depth cap + sentinel verified. Fixture matches DATA-05 shape so Phase 1 swaps path without schema churn.</done>
428
+ </task>
429
+
430
+ <task type="auto" tdd="true">
431
+ <name>Task 2: Implement FathomEnvironment with reset/step/state + 4-reason termination + ENV-08 sealing</name>
432
+ <files>env/server/environment.py</files>
433
+ <behavior>
434
+ - `env.reset(seed=0, difficulty="easy", task_id=None)` loads fixture row 0, initializes state with episode_id + walltime clock, returns FathomObservation with no gold_answer field
435
+ - `env.reset(difficulty="medium")` loads row 1 (D-10)
436
+ - `env.reset(difficulty="hard")` loads row 2 and logs a warning "no real hard example yet"
437
+ - `env.reset(task_id="fixture-0002-counting")` loads the matching row regardless of difficulty (task_id wins)
438
+ - `env.step(FathomAction(code="print(1+1)"))` invokes run_repl on the current globals, increments step_count, updates observation — done=False
439
+ - `env.step(FathomAction(code="<answer>42</answer>"))` produces done=True with info["termination_reason"] == "answer" (ENV-07a)
440
+ - After 20 `step()` calls with non-terminal code, the 20th step returns done=True, info["termination_reason"] == "max_steps" (ENV-07b)
441
+ - If state.tokens_used_total exceeds 100_000, next step returns done=True, info["termination_reason"] == "max_tokens" (ENV-07c)
442
+ - If wall-clock (time.monotonic() - state.started_at_s) > 120s, done=True, info["termination_reason"] == "walltime" (ENV-07d)
443
+ - Malformed Python (e.g., `action.code = "1/0"`) → observation has stderr/exception, done=False, model can recover (ENV-06)
444
+ - `env.state()` returns a sanitized dict with episode_id, step_count, tokens_used_total, recursion_depth_current — NO gold_answer, NO task_type, NO context (ENV-08)
445
+ - After reset, `FathomObservation.context_preview` contains the first N chars of context but NOT the gold_answer string
446
+ </behavior>
447
+ <read_first>
448
+ - env/models.py (Task 1 output)
449
+ - env/server/llm_primitive.py (Task 1 output)
450
+ - env/server/repl.py (Plan 02 — `run_repl`, `ReplResult`)
451
+ - env/data/phase0_fixture.jsonl (Task 1 output)
452
+ - .planning/phases/00-thu-evening-foundation/00-CONTEXT.md D-01 (stateful globals), D-04 (initial globals: ctx + llm + builtins only), D-10 (difficulty mapping), D-11 (lazy fixture load)
453
+ - .planning/research/ARCHITECTURE.md §1 component boundaries (environment.py owns task lifecycle)
454
+ - .planning/REQUIREMENTS.md ENV-03, ENV-05, ENV-06, ENV-07, ENV-08
455
+ </read_first>
456
+ <action>
457
+ Create `env/server/environment.py`. Structure:
458
+
459
+ ```python
460
+ """FathomEnvironment — owns episode lifecycle, REPL dispatch, termination logic.
461
+
462
+ Invariants:
463
+ - ENV-08: state.gold_answer and state.task_type NEVER leak into FathomObservation
464
+ or the `/state` endpoint's sanitized view.
465
+ - ENV-07: every done=True step carries info["termination_reason"] in
466
+ {"answer","max_steps","max_tokens","walltime"}.
467
+ - ENV-06: malformed code → structured error obs, done=False (episode continues).
468
+ - D-01 + D-03: globals_dict is persistent; timeouts preserve prior state.
469
+ - D-04: initial globals = {"ctx": str, "llm": callable_stub} + whitelisted builtins.
470
+ """
471
+ from __future__ import annotations
472
+
473
+ import json
474
+ import logging
475
+ import re
476
+ import time
477
+ import uuid
478
+ from pathlib import Path
479
+ from typing import Any
480
+
481
+ from env.models import (
482
+ FathomAction,
483
+ FathomObservation,
484
+ FathomState,
485
+ FathomStepResult,
486
+ TerminationReason,
487
+ )
488
+ from env.server.llm_primitive import count_tokens, make_llm_primitive
489
+ from env.server.repl import run_repl
490
+
491
+ log = logging.getLogger("fathom.env")
492
+
493
+ _FIXTURE_PATH = Path(__file__).parent.parent / "data" / "phase0_fixture.jsonl"
494
+
495
+ _ANSWER_RE = re.compile(r"<answer>(.*?)</answer>", re.IGNORECASE | re.DOTALL)
496
+
497
+
498
+ class FathomEnvironment:
499
+ SUPPORTS_CONCURRENT_SESSIONS: bool = True # STACK §8.2
500
+
501
+ # Map difficulty → fixture row index (D-10)
502
+ _DIFFICULTY_ROW: dict[str, int] = {
503
+ "trivial": 0, "easy": 0,
504
+ "medium": 1,
505
+ "hard": 2,
506
+ }
507
+
508
+ def __init__(self) -> None:
509
+ self._fixture_cache: list[dict[str, Any]] | None = None # D-11 lazy load
510
+ self._state: FathomState | None = None
511
+ self._globals: dict[str, Any] = {}
512
+
513
+ # ── Fixture management ──────────────────────────────────────────────
514
+ def _load_fixture(self) -> list[dict[str, Any]]:
515
+ if self._fixture_cache is None:
516
+ rows: list[dict[str, Any]] = []
517
+ with _FIXTURE_PATH.open("r", encoding="utf-8") as f:
518
+ for i, line in enumerate(f):
519
+ line = line.strip()
520
+ if not line:
521
+ continue
522
+ rows.append(json.loads(line))
523
+ if len(rows) < 3:
524
+ raise RuntimeError(f"Phase 0 fixture must have >=3 rows; got {len(rows)}")
525
+ self._fixture_cache = rows
526
+ return self._fixture_cache
527
+
528
+ def _select_row(self, difficulty: str, task_id: str | None) -> dict[str, Any]:
529
+ rows = self._load_fixture()
530
+ if task_id is not None:
531
+ for r in rows:
532
+ if r["task_id"] == task_id:
533
+ return r
534
+ raise ValueError(f"Unknown task_id: {task_id}")
535
+ diff = (difficulty or "easy").lower()
536
+ if diff == "hard":
537
+ log.warning("Phase 0 fixture has no real hard example; returning synthetic counting row")
538
+ idx = self._DIFFICULTY_ROW.get(diff, 0)
539
+ return rows[idx]
540
+
541
+ # ── ENV-03: reset ───────────────────────────────────────────────────
542
+ def reset(
543
+ self,
544
+ seed: int = 0,
545
+ difficulty: str = "easy",
546
+ task_id: str | None = None,
547
+ max_steps: int = 20,
548
+ max_tokens: int = 100_000,
549
+ max_depth: int = 2,
550
+ walltime_budget_s: float = 120.0,
551
+ ) -> FathomObservation:
552
+ row = self._select_row(difficulty, task_id)
553
+ episode_id = uuid.uuid4().hex
554
+ self._state = FathomState(
555
+ episode_id=episode_id,
556
+ step_count=0,
557
+ tokens_used_total=0,
558
+ recursion_depth_current=0,
559
+ max_steps=max_steps,
560
+ max_tokens=max_tokens,
561
+ max_depth=max_depth,
562
+ walltime_budget_s=walltime_budget_s,
563
+ started_at_s=time.monotonic(),
564
+ task_id=row["task_id"],
565
+ difficulty=difficulty,
566
+ context=row["context"],
567
+ # SEALED
568
+ gold_answer=row["gold_answer"],
569
+ task_type=row["task_type"],
570
+ )
571
+ # D-04 initial globals: ctx + llm + whitelisted builtins (builtins installed by REPL subprocess)
572
+ self._globals = {
573
+ "ctx": self._state.context,
574
+ # `llm` is injected by the child subprocess-side stub in Plan 02; the parent
575
+ # tracks its token accounting separately via make_llm_primitive(state) on
576
+ # any direct parent-side calls. Phase 1 unifies via RPC.
577
+ }
578
+ return self._make_observation(
579
+ stdout="", stderr="", return_val=None, answer_emitted=False
580
+ )
581
+
582
+ # ── ENV-05/06/07: step ──────────────────────────────────────────────
583
+ def step(self, action: FathomAction) -> FathomStepResult:
584
+ if self._state is None:
585
+ raise RuntimeError("step() before reset() — call reset first")
586
+
587
+ s = self._state
588
+ s.step_count += 1
589
+
590
+ # Charge tokens for the model's emitted code (Phase 0 accounting; Phase 1 adds llm subcall tokens)
591
+ s.tokens_used_total += count_tokens(action.code or "")
592
+
593
+ # Check for <answer>...</answer> tag BEFORE execution — if present, we terminate without exec.
594
+ m = _ANSWER_RE.search(action.code or "")
595
+ if m:
596
+ answer_text = m.group(1).strip()
597
+ s.tokens_used_total += count_tokens(answer_text)
598
+ obs = self._make_observation(
599
+ stdout="",
600
+ stderr="",
601
+ return_val=answer_text,
602
+ answer_emitted=True,
603
+ )
604
+ return FathomStepResult(
605
+ observation=obs,
606
+ reward=0.0, # Phase 0 has no reward verifier
607
+ done=True,
608
+ info={"termination_reason": TerminationReason.ANSWER.value, "parsed_answer": answer_text},
609
+ )
610
+
611
+ # Execute under the REPL (ENV-04 sandbox)
612
+ repl_result = run_repl(
613
+ code=action.code,
614
+ globals_dict=self._globals,
615
+ llm_callable=None, # child has embedded stub; Phase 1 wires real llm here
616
+ timeout_s=30.0,
617
+ episode_id=s.episode_id,
618
+ )
619
+
620
+ # Update persistent globals only on successful exec (D-03: timeout or exception preserves prior)
621
+ if repl_result.exception is None and not repl_result.timed_out:
622
+ self._globals = repl_result.globals_dict
623
+
624
+ # Charge tokens for REPL stdout + stderr
625
+ s.tokens_used_total += count_tokens(repl_result.stdout) + count_tokens(repl_result.stderr or "")
626
+
627
+ # Build observation (ENV-06: structured error observation on malformed code)
628
+ obs = self._make_observation(
629
+ stdout=repl_result.stdout,
630
+ stderr=(repl_result.stderr or "") + ("" if repl_result.exception is None else f"\n[exception] {repl_result.exception}"),
631
+ return_val=None,
632
+ answer_emitted=False,
633
+ )
634
+
635
+ # ── ENV-07 termination checks (priority: answer > max_steps > max_tokens > walltime)
636
+ reason = self._check_termination()
637
+ if reason is not TerminationReason.NOT_TERMINATED:
638
+ return FathomStepResult(
639
+ observation=obs,
640
+ reward=0.0,
641
+ done=True,
642
+ info={"termination_reason": reason.value},
643
+ )
644
+
645
+ return FathomStepResult(
646
+ observation=obs,
647
+ reward=0.0,
648
+ done=False,
649
+ info={},
650
+ )
651
+
652
+ def _check_termination(self) -> TerminationReason:
653
+ s = self._state
654
+ assert s is not None
655
+ # ENV-07b
656
+ if s.step_count >= s.max_steps:
657
+ return TerminationReason.MAX_STEPS
658
+ # ENV-07c
659
+ if s.tokens_used_total >= s.max_tokens:
660
+ return TerminationReason.MAX_TOKENS
661
+ # ENV-07d
662
+ if (time.monotonic() - s.started_at_s) >= s.walltime_budget_s:
663
+ return TerminationReason.WALLTIME
664
+ return TerminationReason.NOT_TERMINATED
665
+
666
+ # ── ENV-08: sanitized state view ────────────────────────────────────
667
+ def state(self) -> dict[str, Any]:
668
+ """Return a sanitized snapshot. MUST NOT include gold_answer, task_type, or context."""
669
+ if self._state is None:
670
+ return {"episode_id": None, "step_count": 0, "tokens_used_total": 0, "recursion_depth_current": 0}
671
+ return {
672
+ "episode_id": self._state.episode_id,
673
+ "step_count": self._state.step_count,
674
+ "tokens_used_total": self._state.tokens_used_total,
675
+ "recursion_depth_current": self._state.recursion_depth_current,
676
+ "max_steps": self._state.max_steps,
677
+ "max_tokens": self._state.max_tokens,
678
+ "max_depth": self._state.max_depth,
679
+ "difficulty": self._state.difficulty,
680
+ "task_id": self._state.task_id,
681
+ # EXPLICITLY OMITTED: gold_answer, task_type, context
682
+ }
683
+
684
+ # ── helper: ENV-08 safe observation construction ───────────────────
685
+ def _make_observation(
686
+ self,
687
+ stdout: str,
688
+ stderr: str,
689
+ return_val: str | None,
690
+ answer_emitted: bool,
691
+ ) -> FathomObservation:
692
+ s = self._state
693
+ assert s is not None
694
+ # context_preview: first 500 chars of ctx for situational awareness (not a secret);
695
+ # gold_answer is NEVER included here because it's not referenced.
696
+ preview = (s.context[:500] + "...") if len(s.context) > 500 else s.context
697
+ return FathomObservation(
698
+ stdout=stdout,
699
+ stderr=stderr,
700
+ return_val=return_val,
701
+ tokens_used=s.tokens_used_total,
702
+ tokens_remaining=max(0, s.max_tokens - s.tokens_used_total),
703
+ depth_current=s.recursion_depth_current,
704
+ depth_max=s.max_depth,
705
+ turns_left=max(0, s.max_steps - s.step_count),
706
+ answer_emitted=answer_emitted,
707
+ context_preview=preview,
708
+ )
709
+
710
+
711
+ def create_fathom_environment() -> FathomEnvironment:
712
+ """Factory for OpenEnv create_app and for tests."""
713
+ return FathomEnvironment()
714
+ ```
715
+
716
+ SELF-CHECKS the executor must run before committing:
717
+ 1. `python -c "from env.server.environment import FathomEnvironment; e = FathomEnvironment(); obs = e.reset(); assert 'gold_answer' not in obs.model_dump()"` — ENV-08 at schema level
718
+ 2. `python -c "from env.server.environment import FathomEnvironment; from env.models import FathomAction; e = FathomEnvironment(); e.reset(); r = e.step(FathomAction(code='<answer>42</answer>')); assert r.done and r.info['termination_reason'] == 'answer'"` — ENV-07a
719
+ 3. `python -c "from env.server.environment import FathomEnvironment; from env.models import FathomAction; e = FathomEnvironment(); e.reset(max_steps=2); e.step(FathomAction(code='x=1')); r = e.step(FathomAction(code='y=2')); assert r.done and r.info['termination_reason'] == 'max_steps'"` — ENV-07b
720
+ 4. `python -c "from env.server.environment import FathomEnvironment; from env.models import FathomAction; e = FathomEnvironment(); e.reset(max_tokens=5); r = e.step(FathomAction(code='print(\"hello world many tokens here padding\")')); assert r.done and r.info['termination_reason'] == 'max_tokens'"` — ENV-07c
721
+ 5. `python -c "from env.server.environment import FathomEnvironment; e = FathomEnvironment(); e.reset(difficulty='medium'); assert e._state.task_id == 'fixture-0001-multi-needle'"` — D-10 difficulty mapping
722
+ 6. `python -c "from env.server.environment import FathomEnvironment; e = FathomEnvironment(); obs = e.reset(difficulty='easy'); assert e._state.gold_answer == '42'; assert '42' not in obs.stdout and '42' not in (obs.stderr or '')"` — gold is set internally but not surfaced in stdout/stderr
723
+
724
+ (Note on check 6: for row 0, the gold_answer "42" DOES appear in the context string, which DOES appear in `context_preview`. That's intentional — context_preview legitimately contains context. The sealing rule (ENV-08) is that `gold_answer` as a distinct field is not in Observation. Plan 05's dedicated gold-leakage test will add the stronger assertion that across the fixture rows, the Observation's dict keys never include "gold_answer" or "task_type". The test does NOT try to scrub gold values from the context itself — that would defeat the task.)
725
+ </action>
726
+ <verify>
727
+ <automated>python -c "from env.server.environment import FathomEnvironment; from env.models import FathomAction, FathomObservation; e = FathomEnvironment(); obs = e.reset(); assert 'gold_answer' not in obs.model_dump(); assert 'gold_answer' not in FathomObservation.model_fields; r = e.step(FathomAction(code='<answer>42</answer>')); assert r.done and r.info['termination_reason'] == 'answer'; e2 = FathomEnvironment(); e2.reset(difficulty='medium'); assert e2._state.task_id == 'fixture-0001-multi-needle'; s = e2.state(); assert 'gold_answer' not in s and 'task_type' not in s"</automated>
728
+ </verify>
729
+ <acceptance_criteria>
730
+ - `env/server/environment.py` exists
731
+ - `grep -q "class FathomEnvironment" env/server/environment.py` succeeds
732
+ - `grep -q "SUPPORTS_CONCURRENT_SESSIONS.*=.*True" env/server/environment.py` succeeds (STACK §8.2)
733
+ - `grep -q "from env.server.repl import run_repl" env/server/environment.py` succeeds
734
+ - `grep -q "TerminationReason.ANSWER" env/server/environment.py` AND `grep -q "TerminationReason.MAX_STEPS" env/server/environment.py` AND `grep -q "TerminationReason.MAX_TOKENS" env/server/environment.py` AND `grep -q "TerminationReason.WALLTIME" env/server/environment.py` all succeed (4 reasons)
735
+ - Self-check 1 (gold_answer not in Observation.model_fields) exits 0
736
+ - Self-check 2 (<answer> → done + reason=answer) exits 0
737
+ - Self-check 3 (max_steps=2 terminates on second step) exits 0
738
+ - Self-check 4 (max_tokens=5 terminates after first step with long code) exits 0
739
+ - Self-check 5 (difficulty='medium' → fixture-0001-multi-needle) exits 0
740
+ - `grep -q "EXPLICITLY OMITTED" env/server/environment.py` OR equivalent comment documenting the state() sanitization
741
+ - `env.state()` returned dict does not contain keys `gold_answer` or `task_type` (checked via Python one-liner)
742
+ </acceptance_criteria>
743
+ <done>FathomEnvironment implements reset/step/state with gold-answer sealing, 4-reason termination, D-10 difficulty mapping, D-01 stateful globals, and D-03 timeout preservation. Plan 05 can test this over HTTP.</done>
744
+ </task>
745
+
746
+ <task type="auto" tdd="true">
747
+ <name>Task 3: Wire FastAPI app (POST /reset, POST /step, GET /state, GET /healthz) + FathomEnvClient</name>
748
+ <files>env/server/app.py, env/client.py</files>
749
+ <behavior>
750
+ - `python -m uvicorn env.server.app:app --port 8001` starts without import errors
751
+ - `curl -XPOST localhost:8001/reset -H 'Content-Type: application/json' -d '{"seed":0,"difficulty":"easy"}'` returns HTTP 200 with JSON matching FathomObservation
752
+ - `curl -XPOST localhost:8001/step -H 'Content-Type: application/json' -d '{"tool_name":"repl","code":"print(1+1)"}'` returns FathomStepResult JSON
753
+ - `curl -XGET localhost:8001/state` returns sanitized state (no gold_answer)
754
+ - `curl -XGET localhost:8001/healthz` returns `{"status":"ok"}`
755
+ - `FathomEnvClient(base_url="http://localhost:8001").reset(seed=0, difficulty="easy")` returns a FathomObservation instance
756
+ - `client.step(FathomAction(code="print(1+1)"))` returns a FathomStepResult instance
757
+ - The response JSON NEVER contains the key `gold_answer`
758
+ </behavior>
759
+ <read_first>
760
+ - env/server/app.py (Plan 01 stub — to be replaced)
761
+ - env/client.py (Plan 01 stub — to be populated)
762
+ - env/server/environment.py (Task 2 output)
763
+ - env/models.py (Task 1 output)
764
+ - .planning/research/STACK.md §8.2 (edits we make post-init — SUPPORTS_CONCURRENT_SESSIONS, create_app pattern)
765
+ - .planning/research/ARCHITECTURE.md §1 (FastAPI endpoints)
766
+ </read_first>
767
+ <action>
768
+ **1. env/server/app.py** — replace the Plan 01 placeholder with the real OpenEnv-compatible wiring. Because OpenEnv 0.2.3's `create_app` import path may differ (MEDIUM confidence in STACK §8.2), implement a hand-rolled FastAPI app that matches the OpenEnv JSON contract (POST /reset, POST /step, GET /state). If `openenv-core` exposes a compatible `create_app`, we CAN layer it in Phase 1, but for Phase 0 correctness we hand-roll to avoid the OpenEnv import surprise:
769
+
770
+ ```python
771
+ """FATHOM env server — FastAPI routes mirroring OpenEnv JSON contract.
772
+
773
+ Endpoints (OpenEnv-compatible):
774
+ POST /reset → FathomObservation
775
+ POST /step → FathomStepResult
776
+ GET /state → sanitized dict (ENV-08: no gold_answer, no task_type)
777
+ GET /healthz → {"status":"ok"}
778
+
779
+ One env instance per process for Phase 0 (SUPPORTS_CONCURRENT_SESSIONS=True is declared
780
+ for the OpenEnv metadata but Phase 0 does not need multi-session routing; Phase 1 may
781
+ swap to OpenEnv's session-routing create_app when the CLI integration is confirmed).
782
+ """
783
+ from __future__ import annotations
784
+
785
+ import logging
786
+ from fastapi import FastAPI, HTTPException
787
+ from pydantic import BaseModel
788
+
789
+ from env.models import FathomAction, FathomObservation, FathomStepResult
790
+ from env.server.environment import FathomEnvironment, create_fathom_environment
791
+
792
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
793
+ log = logging.getLogger("fathom.app")
794
+
795
+ app = FastAPI(title="fathom-env", version="0.1.0")
796
+
797
+ # Single-environment instance for Phase 0 (Phase 1 adds session routing)
798
+ _ENV: FathomEnvironment = create_fathom_environment()
799
+
800
+
801
+ class ResetRequest(BaseModel):
802
+ seed: int = 0
803
+ difficulty: str = "easy"
804
+ task_id: str | None = None
805
+ max_steps: int = 20
806
+ max_tokens: int = 100_000
807
+ max_depth: int = 2
808
+ walltime_budget_s: float = 120.0
809
+
810
+
811
+ @app.get("/healthz")
812
+ def healthz() -> dict[str, str]:
813
+ return {"status": "ok"}
814
+
815
+
816
+ @app.post("/reset", response_model=FathomObservation)
817
+ def reset(req: ResetRequest) -> FathomObservation:
818
+ log.info("reset seed=%s difficulty=%s task_id=%s", req.seed, req.difficulty, req.task_id)
819
+ try:
820
+ return _ENV.reset(
821
+ seed=req.seed,
822
+ difficulty=req.difficulty,
823
+ task_id=req.task_id,
824
+ max_steps=req.max_steps,
825
+ max_tokens=req.max_tokens,
826
+ max_depth=req.max_depth,
827
+ walltime_budget_s=req.walltime_budget_s,
828
+ )
829
+ except ValueError as e:
830
+ raise HTTPException(status_code=400, detail=str(e))
831
+
832
+
833
+ @app.post("/step", response_model=FathomStepResult)
834
+ def step(action: FathomAction) -> FathomStepResult:
835
+ try:
836
+ return _ENV.step(action)
837
+ except RuntimeError as e:
838
+ raise HTTPException(status_code=409, detail=str(e))
839
+
840
+
841
+ @app.get("/state")
842
+ def state() -> dict:
843
+ return _ENV.state()
844
+ ```
845
+
846
+ **2. env/client.py** — trainer-side HTTP client. Use `httpx` (already listed in `[dev]` extras); the client lives in the main package because Phase 1's `train/rollout.py` imports it at runtime.
847
+
848
+ First, add `httpx>=0.27,<1.0` to the main `pyproject.toml` dependencies if it is currently in the `[dev]` extras only (grep to check; if dev-only, promote to main deps since client.py imports it):
849
+
850
+ ```python
851
+ """FathomEnvClient — HTTP client for trainer/test rollouts."""
852
+ from __future__ import annotations
853
+
854
+ from typing import Any
855
+
856
+ import httpx
857
+
858
+ from env.models import (
859
+ FathomAction,
860
+ FathomObservation,
861
+ FathomStepResult,
862
+ )
863
+
864
+
865
+ class FathomEnvClient:
866
+ """Thin HTTP wrapper around the FATHOM env server."""
867
+
868
+ def __init__(self, base_url: str = "http://localhost:8001", timeout_s: float = 60.0) -> None:
869
+ self.base_url = base_url.rstrip("/")
870
+ self._client = httpx.Client(base_url=self.base_url, timeout=timeout_s)
871
+
872
+ def __enter__(self) -> "FathomEnvClient":
873
+ return self
874
+
875
+ def __exit__(self, *exc: Any) -> None:
876
+ self.close()
877
+
878
+ def close(self) -> None:
879
+ self._client.close()
880
+
881
+ def healthz(self) -> dict:
882
+ r = self._client.get("/healthz")
883
+ r.raise_for_status()
884
+ return r.json()
885
+
886
+ def reset(
887
+ self,
888
+ seed: int = 0,
889
+ difficulty: str = "easy",
890
+ task_id: str | None = None,
891
+ max_steps: int = 20,
892
+ max_tokens: int = 100_000,
893
+ max_depth: int = 2,
894
+ walltime_budget_s: float = 120.0,
895
+ ) -> FathomObservation:
896
+ payload = {
897
+ "seed": seed,
898
+ "difficulty": difficulty,
899
+ "task_id": task_id,
900
+ "max_steps": max_steps,
901
+ "max_tokens": max_tokens,
902
+ "max_depth": max_depth,
903
+ "walltime_budget_s": walltime_budget_s,
904
+ }
905
+ r = self._client.post("/reset", json=payload)
906
+ r.raise_for_status()
907
+ return FathomObservation.model_validate(r.json())
908
+
909
+ def step(self, action: FathomAction) -> FathomStepResult:
910
+ r = self._client.post("/step", json=action.model_dump())
911
+ r.raise_for_status()
912
+ return FathomStepResult.model_validate(r.json())
913
+
914
+ def state(self) -> dict:
915
+ r = self._client.get("/state")
916
+ r.raise_for_status()
917
+ return r.json()
918
+ ```
919
+
920
+ **3. Update pyproject.toml** — if `httpx` is only in `[dev]` extras, move it to main dependencies OR leave it in `[dev]` and document that `pip install -e .[dev]` is required for client usage. Simpler: add `"httpx>=0.27,<1.0"` to the base dependency list (because Phase 1 rollout imports env.client).
921
+
922
+ **4. Manual smoke** — the executor runs:
923
+ ```bash
924
+ python -m uvicorn env.server.app:app --host 127.0.0.1 --port 8001 &
925
+ SERVER_PID=$!
926
+ sleep 3
927
+ curl -sS http://127.0.0.1:8001/healthz
928
+ curl -sS -XPOST -H 'Content-Type: application/json' -d '{"seed":0,"difficulty":"easy"}' http://127.0.0.1:8001/reset
929
+ curl -sS -XPOST -H 'Content-Type: application/json' -d '{"tool_name":"repl","code":"print(1+1)"}' http://127.0.0.1:8001/step
930
+ curl -sS http://127.0.0.1:8001/state
931
+ kill $SERVER_PID
932
+ ```
933
+
934
+ All four should return 200 OK JSON. Verify `gold_answer` string is absent from every response body (grep `-v "gold_answer"` or dict-level check).
935
+ </action>
936
+ <verify>
937
+ <automated>python -c "from env.server.app import app; from fastapi.testclient import TestClient; c = TestClient(app); r = c.get('/healthz'); assert r.status_code == 200 and r.json() == {'status':'ok'}; r = c.post('/reset', json={'seed':0,'difficulty':'easy'}); assert r.status_code == 200; obs_json = r.json(); assert 'gold_answer' not in obs_json; r = c.post('/step', json={'tool_name':'repl','code':'print(1+1)'}); assert r.status_code == 200 and 'gold_answer' not in r.json().get('observation', {}); r = c.get('/state'); assert r.status_code == 200 and 'gold_answer' not in r.json() and 'task_type' not in r.json()" && python -c "from env.client import FathomEnvClient; from env.models import FathomAction; print('client import ok')"</automated>
938
+ </verify>
939
+ <acceptance_criteria>
940
+ - `env/server/app.py` defines FastAPI `app` with `/reset`, `/step`, `/state`, `/healthz` routes
941
+ - `grep -q '@app.post("/reset"' env/server/app.py` succeeds
942
+ - `grep -q '@app.post("/step"' env/server/app.py` succeeds
943
+ - `grep -q '@app.get("/state"' env/server/app.py` succeeds
944
+ - `grep -q "from env.server.environment import" env/server/app.py` succeeds
945
+ - `env/client.py` defines `FathomEnvClient` with `reset`, `step`, `state`, `healthz` methods
946
+ - `grep -q "class FathomEnvClient" env/client.py` succeeds
947
+ - `grep -q "import httpx" env/client.py` succeeds
948
+ - `pyproject.toml` has `httpx` in the main `dependencies` list (not dev-only)
949
+ - FastAPI TestClient smoke (the automated verify line) exits 0 — proves /healthz, /reset, /step, /state all return 200 and NONE contain `gold_answer` in their response body
950
+ - `python -m uvicorn env.server.app:app --port 8001` starts without import errors (exit within 5s via background + kill is acceptable for verification)
951
+ </acceptance_criteria>
952
+ <done>FastAPI app wires reset/step/state/healthz; FathomEnvClient provides Python-side roundtrip. Gold-answer sealing verified at every response layer.</done>
953
+ </task>
954
+
955
+ </tasks>
956
+
957
+ <threat_model>
958
+ ## Trust Boundaries
959
+
960
+ | Boundary | Description |
961
+ |----------|-------------|
962
+ | HTTP client (trainer) → FastAPI server | untrusted inputs (action.code may be adversarial); Pydantic validation + REPL sandbox are the defenses |
963
+ | env.step → REPL subprocess | already hardened in Plan 02 |
964
+ | FathomState.gold_answer → FathomObservation | ENV-08 sealing; enforced in `_make_observation` and `state()` |
965
+ | FathomState.task_type → FathomObservation | ENV-08 sealing; enforced likewise |
966
+
967
+ ## STRIDE Threat Register
968
+
969
+ | Threat ID | Category | Component | Disposition | Mitigation Plan |
970
+ |-----------|----------|-----------|-------------|-----------------|
971
+ | T-00-19 | I (Information disclosure) | FathomObservation | mitigate | `FathomObservation` schema has no `gold_answer` field; `_make_observation` constructs from allowed fields only; TestClient verify proves absent from JSON. ENV-08. |
972
+ | T-00-20 | I (Information disclosure) | `/state` endpoint | mitigate | `env.state()` returns an explicit dict whitelist; `gold_answer` and `task_type` never included. ENV-08. |
973
+ | T-00-21 | T (Tampering) | action.code injection | mitigate | Plan 02's REPL sandbox (RestrictedPython + subprocess + rlimits) contains all untrusted code. Plan 04 never evals action.code outside run_repl. |
974
+ | T-00-22 | D (Denial of service) | unbounded episode | mitigate | ENV-07 enforces 4 termination reasons; every done=True carries `info["termination_reason"]`. Max caps: 20 steps, 100K tokens, 120s walltime. |
975
+ | T-00-23 | D (Denial of service) | repeated /reset floods | accept | Phase 0 server is local-only, solo-dev, not deployed. Phase 1 adds HF Space-level rate limiting via OpenEnv's `max_concurrent_envs`. |
976
+ | T-00-24 | S (Spoofing) | missing auth on /reset, /step | accept | Phase 0 is localhost-only; HF Space deploy in Phase 1 uses HF's auth token boundary. No Phase 0 task requires auth. |
977
+ | T-00-25 | R (Repudiation) | step ordering | mitigate | step() raises RuntimeError (HTTP 409) if called before reset(); prevents silent mis-ordered episodes. |
978
+ | T-00-26 | T (Tampering) | stateful globals corruption on timeout | mitigate | D-03: on `repl_result.timed_out`, `self._globals` NOT overwritten — `run_repl` returns `globals_dict=dict(globals_dict)` which is a copy of the input, and environment.step only commits `self._globals = repl_result.globals_dict` when exception is None AND not timed_out. |
979
+ </threat_model>
980
+
981
+ <verification>
982
+ All three tasks together prove:
983
+ 1. Models + fixture + stub + gold-answer sealing all work (Task 1 self-checks)
984
+ 2. FathomEnvironment.reset/step/state honor all 4 ENV-07 termination reasons + ENV-08 sealing + D-10 fixture mapping (Task 2 self-checks)
985
+ 3. FastAPI TestClient roundtrip passes end-to-end (Task 3 automated verify) — ENV-01 satisfied locally
986
+ </verification>
987
+
988
+ <success_criteria>
989
+ - Pydantic schemas + llm stub + 3-row fixture all present; gold_answer absent from FathomObservation fields
990
+ - FathomEnvironment implements reset/step/state with 4-reason termination, gold-answer sealing, D-10 difficulty map, D-01 stateful globals
991
+ - FastAPI app with /reset, /step, /state, /healthz endpoints; TestClient proves all 4 return 200 with NO gold_answer in any response
992
+ - FathomEnvClient provides Python-side HTTP wrapper
993
+ - `pyproject.toml` has `httpx` in main dependencies
994
+ </success_criteria>
995
+
996
+ <output>
997
+ After completion, create `.planning/phases/00-thu-evening-foundation/00-04-env-core-SUMMARY.md` documenting:
998
+ - Pydantic schema reference (list of fields per model)
999
+ - llm stub behavior (sample input/output)
1000
+ - 4-reason termination logic table (answer / max_steps / max_tokens / walltime with trigger conditions)
1001
+ - ENV-08 sealing check (grep output showing gold_answer absent from Observation serialization)
1002
+ - FastAPI TestClient smoke output
1003
+ - Any deviations from ARCHITECTURE.md §1 component boundaries (should be zero)
1004
+ </output>
1005
+ </content>
1006
+ </invoke>
.planning/phases/00-thu-evening-foundation/00-04-env-core-SUMMARY.md ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Plan 04 — Env Core SUMMARY
2
+
3
+ ## Artifacts delivered
4
+
5
+ | File | Lines | Purpose |
6
+ |------|-------|---------|
7
+ | `env/models.py` | ~90 | Pydantic schemas for all env types |
8
+ | `env/server/llm_primitive.py` | ~60 | Deterministic regex-echo llm() stub + tiktoken counting |
9
+ | `env/server/environment.py` | ~185 | FathomEnvironment with reset/step/state + 4-reason termination |
10
+ | `env/server/app.py` | ~65 | FastAPI routes (POST /reset, POST /step, GET /state, GET /healthz) |
11
+ | `env/client.py` | ~65 | FathomEnvClient HTTP wrapper |
12
+ | `env/data/phase0_fixture.jsonl` | 3 lines | 3 fixture rows (needle/multi-needle/counting) |
13
+ | `env/__init__.py` | ~12 | Re-exports the 5 Pydantic types |
14
+
15
+ ---
16
+
17
+ ## Pydantic schema reference
18
+
19
+ | Model | Fields |
20
+ |-------|--------|
21
+ | `FathomAction` | `tool_name` (default="repl"), `code` |
22
+ | `FathomObservation` | `stdout`, `stderr`, `return_val`, `tokens_used`, `tokens_remaining`, `depth_current`, `depth_max`, `turns_left`, `answer_emitted`, `context_preview` |
23
+ | `FathomState` | `episode_id`, `step_count`, `tokens_used_total`, `recursion_depth_current`, `max_steps`, `max_tokens`, `max_depth`, `walltime_budget_s`, `started_at_s`, `task_id`, `difficulty`, `context`, **`gold_answer` (sealed)**, **`task_type` (sealed)** |
24
+ | `FathomStepResult` | `observation`, `reward`, `done`, `info` |
25
+ | `TerminationReason` | `ANSWER`, `MAX_STEPS`, `MAX_TOKENS`, `WALLTIME`, `NOT_TERMINATED` |
26
+
27
+ ---
28
+
29
+ ## llm() stub behavior (D-05/D-06/D-07)
30
+
31
+ ```
32
+ llm("find the number", "the number is 42", depth=1)
33
+ → "the number is 42" # regex-echo: finds "find" in chunk, returns 80-char window
34
+
35
+ llm("x", "y", depth=5) # depth > state.max_depth (2)
36
+ → "[depth limit reached]" # sentinel verbatim; tokens still charged to state
37
+ ```
38
+
39
+ Token counting: `tiktoken cl100k_base` on prompt + chunk + response. Charged for every call including depth-rejected ones.
40
+
41
+ ---
42
+
43
+ ## 4-reason termination logic
44
+
45
+ | Reason | Trigger | `info["termination_reason"]` |
46
+ |--------|---------|------------------------------|
47
+ | ANSWER | `<answer>...</answer>` tag found in `action.code` | `"answer"` |
48
+ | MAX_STEPS | `step_count >= max_steps` (default 20) | `"max_steps"` |
49
+ | MAX_TOKENS | `tokens_used_total >= max_tokens` (default 100,000) | `"max_tokens"` |
50
+ | WALLTIME | `time.monotonic() - started_at_s >= walltime_budget_s` (default 120s) | `"walltime"` |
51
+
52
+ Priority: ANSWER checked before execution; MAX_STEPS / MAX_TOKENS / WALLTIME checked after.
53
+
54
+ ---
55
+
56
+ ## ENV-08 sealing — gold_answer absent from all serialized outputs
57
+
58
+ ```
59
+ python -c "from env.models import FathomObservation; assert 'gold_answer' not in FathomObservation.model_fields"
60
+ # exits 0 — gold_answer is not a field of FathomObservation
61
+
62
+ python -c "from env.server.environment import FathomEnvironment; e = FathomEnvironment(); obs = e.reset(); assert 'gold_answer' not in obs.model_dump()"
63
+ # exits 0 — reset() returns no gold_answer
64
+
65
+ python -c "from env.server.environment import FathomEnvironment; e = FathomEnvironment(); e.reset(); s = e.state(); assert 'gold_answer' not in s and 'task_type' not in s"
66
+ # exits 0 — state() whitelist omits sealed fields
67
+ ```
68
+
69
+ ---
70
+
71
+ ## FastAPI TestClient smoke output
72
+
73
+ ```
74
+ healthz OK
75
+ reset OK, keys: ['stdout','stderr','return_val','tokens_used','tokens_remaining','depth_current','depth_max','turns_left','answer_emitted','context_preview']
76
+ step OK, done: False
77
+ state OK, keys: ['episode_id','step_count','tokens_used_total','recursion_depth_current','max_steps','max_tokens','max_depth','walltime_budget_s','difficulty','task_id']
78
+ client import OK
79
+ ALL CHECKS PASSED
80
+ ```
81
+
82
+ ---
83
+
84
+ ## Deviations from ARCHITECTURE.md §1
85
+
86
+ None. Hand-rolled FastAPI (not `openenv.create_app`) per STACK §8.2 rationale — `create_app` import path in OpenEnv 0.2.3 is MEDIUM confidence; hand-rolled routes match the JSON contract exactly and can be swapped in Phase 1 once confirmed.
87
+
88
+ Phase 0 token accounting limitation: child-subprocess llm stub does not feed back into `state.tokens_used_total`. Parent charges tokens for `action.code` + REPL stdout/stderr only. Phase 1 adds an RPC channel to unify accounting.
.planning/phases/00-thu-evening-foundation/00-05-env-tests-exit-gate-PLAN.md ADDED
@@ -0,0 +1,628 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ phase: 00-thu-evening-foundation
3
+ plan: 05
4
+ type: execute
5
+ wave: 5
6
+ depends_on: [00-04, 00-03]
7
+ files_modified:
8
+ - tests/test_env.py
9
+ - tests/conftest.py
10
+ - .planning/PHASE_0_EXIT_GATE.md
11
+ autonomous: true
12
+ requirements: [ENV-01, ENV-02, ENV-03, ENV-07, ENV-08]
13
+ must_haves:
14
+ truths:
15
+ - "tests/test_env.py::test_gold_answer_never_in_observation passes for every fixture row + every observation across reset/step (ENV-08 + ROADMAP Success Criterion #3 literal)"
16
+ - "All 4 ENV-07 termination reasons exercised by named tests: test_termination_answer, test_termination_max_steps, test_termination_max_tokens, test_termination_walltime"
17
+ - "FathomEnvClient.reset() + client.step(FathomAction(code='print(1+1)')) round-trip over uvicorn-served HTTP, proving ENV-01"
18
+ - "Malformed code (e.g., 1/0, SyntaxError) produces a structured error observation with done=False — ENV-06 verification"
19
+ - ".planning/PHASE_0_EXIT_GATE.md aggregates SANDBOX_CERT status + this suite's pytest output + Dockerfile build status into ONE file Phase 1 reads as its precondition"
20
+ artifacts:
21
+ - path: tests/test_env.py
22
+ provides: "ENV-01..03, ENV-07, ENV-08 verification via pytest + TestClient + live uvicorn roundtrip"
23
+ contains: "def test_gold_answer_never_in_observation"
24
+ min_lines: 200
25
+ - path: tests/conftest.py
26
+ provides: "pytest fixtures — TestClient + optional live-server fixture via uvicorn subprocess"
27
+ - path: .planning/PHASE_0_EXIT_GATE.md
28
+ provides: "Single auditable record that Phase 0 exit gate is GREEN (sandbox + env tests + docker)"
29
+ contains: "EXIT GATE STATUS"
30
+ key_links:
31
+ - from: tests/test_env.py
32
+ to: env/server/app.py
33
+ via: "FastAPI TestClient fixture"
34
+ pattern: "TestClient"
35
+ - from: tests/test_env.py
36
+ to: env/client.py
37
+ via: "FathomEnvClient against a live uvicorn subprocess"
38
+ pattern: "FathomEnvClient"
39
+ - from: .planning/PHASE_0_EXIT_GATE.md
40
+ to: .planning/SANDBOX_CERT.md
41
+ via: "references the sibling cert file in the summary"
42
+ pattern: "SANDBOX_CERT"
43
+ ---
44
+
45
+ <objective>
46
+ Close the Phase 0 exit gate with three verifications: (1) the named ENV-08 gold-leakage test from ROADMAP Success Criterion #3, (2) four termination-reason tests covering ENV-07 exhaustively, (3) an end-to-end HTTP roundtrip proving ENV-01..03 via `FathomEnvClient` against a live uvicorn subprocess. Then aggregate the three Phase 0 gates (sandbox cert, env tests, docker build) into `.planning/PHASE_0_EXIT_GATE.md` so Phase 1 has a single source of truth to read.
47
+
48
+ Purpose: ROADMAP names specific pytest literals (`pytest tests/test_env.py::test_gold_answer_never_in_observation`, the 4 termination reasons in `StepResult.info.termination_reason`). This plan writes those tests and produces the aggregated gate file.
49
+ Output: `tests/test_env.py` (~200-350 lines), `tests/conftest.py` (~40 lines), `.planning/PHASE_0_EXIT_GATE.md` (~60 lines).
50
+ </objective>
51
+
52
+ <execution_context>
53
+ @$HOME/.claude/get-shit-done/workflows/execute-plan.md
54
+ @$HOME/.claude/get-shit-done/templates/summary.md
55
+ </execution_context>
56
+
57
+ <context>
58
+ @.planning/PROJECT.md
59
+ @.planning/ROADMAP.md
60
+ @.planning/STATE.md
61
+ @.planning/phases/00-thu-evening-foundation/00-CONTEXT.md
62
+ @.planning/SANDBOX_CERT.md
63
+ @env/models.py
64
+ @env/client.py
65
+ @env/server/app.py
66
+ @env/server/environment.py
67
+ @env/data/phase0_fixture.jsonl
68
+
69
+ <interfaces>
70
+ <!-- Plan 04 contracts we test against -->
71
+
72
+ From env/models.py:
73
+ - FathomAction(tool_name, code), FathomObservation (no gold_answer field), FathomStepResult(observation, reward, done, info)
74
+ - TerminationReason.{ANSWER, MAX_STEPS, MAX_TOKENS, WALLTIME, NOT_TERMINATED}
75
+
76
+ From env/server/app.py:
77
+ - FastAPI `app` with POST /reset, POST /step, GET /state, GET /healthz
78
+
79
+ From env/client.py:
80
+ - FathomEnvClient(base_url) with .reset(), .step(action), .state(), .healthz()
81
+
82
+ From env/server/environment.py:
83
+ - FathomEnvironment with reset(seed, difficulty, task_id, max_steps, max_tokens, max_depth, walltime_budget_s)
84
+
85
+ From env/data/phase0_fixture.jsonl:
86
+ - Row 0: task_id=fixture-0000-needle, difficulty=easy, gold="42"
87
+ - Row 1: task_id=fixture-0001-multi-needle, difficulty=medium, gold="49"
88
+ - Row 2: task_id=fixture-0002-counting, difficulty=hard, gold=(count of "alpha")
89
+
90
+ Assertion surface for gold-leakage test: for each fixture row, after reset() + K steps, the observation dict MUST NOT contain the literal key `gold_answer` or `task_type`. Note: the gold VALUE (e.g., "42") may legitimately appear inside `context_preview` because the context itself contains it — that is expected, not a leak. The test asserts on KEY presence, not substring presence.
91
+ </interfaces>
92
+ </context>
93
+
94
+ <tasks>
95
+
96
+ <task type="auto" tdd="true">
97
+ <name>Task 1: Write tests/conftest.py and tests/test_env.py — TestClient + HTTP roundtrip + termination + gold-leakage</name>
98
+ <files>tests/conftest.py, tests/test_env.py</files>
99
+ <behavior>
100
+ - `pytest tests/test_env.py -v` exits 0 with ≥ 10 passed
101
+ - `test_gold_answer_never_in_observation` passes for all 3 fixture rows (iterating via parametrize or loop)
102
+ - `test_termination_answer` — submitting `<answer>42</answer>` → done=True, info["termination_reason"]=="answer"
103
+ - `test_termination_max_steps` — setting max_steps=2, issuing 2 non-terminal steps → 2nd is done=True with reason max_steps
104
+ - `test_termination_max_tokens` — setting max_tokens=5, issuing one long code → done=True with reason max_tokens
105
+ - `test_termination_walltime` — setting walltime_budget_s=0.5, waiting 1s between steps → step returns done=True with reason walltime (may mock time if faster)
106
+ - `test_healthz_returns_ok` — GET /healthz returns 200 + {"status":"ok"}
107
+ - `test_reset_returns_observation_no_gold_field` — POST /reset JSON body has no `gold_answer` key
108
+ - `test_step_roundtrip_print_1_plus_1` — POST /step with code "print(1+1)" returns observation containing "2" in stdout
109
+ - `test_state_endpoint_sanitized` — GET /state response has no keys `gold_answer`, `task_type`, `context`
110
+ - `test_malformed_code_returns_error_observation_done_false` — code "1/0" returns stderr containing "ZeroDivisionError" and done=False (ENV-06)
111
+ - `test_difficulty_mapping_medium_loads_multi_needle_row` — reset(difficulty="medium") → state().task_id == "fixture-0001-multi-needle" (D-10)
112
+ - `test_live_uvicorn_roundtrip` — launches uvicorn subprocess on port 8099, uses FathomEnvClient.reset() + step() end-to-end, asserts status. Skipped on platforms where subprocess uvicorn is flaky.
113
+ </behavior>
114
+ <read_first>
115
+ - env/models.py, env/client.py, env/server/app.py, env/server/environment.py (Plan 04 outputs)
116
+ - env/data/phase0_fixture.jsonl (Plan 04 output — know the 3 gold values)
117
+ - .planning/ROADMAP.md Phase 0 Success Criteria (list the 4 termination reasons and name the gold-leakage test literally)
118
+ - .planning/REQUIREMENTS.md ENV-01, ENV-02, ENV-03, ENV-06, ENV-07, ENV-08 verbatim
119
+ - .planning/phases/00-thu-evening-foundation/00-CONTEXT.md D-10 (difficulty mapping)
120
+ </read_first>
121
+ <action>
122
+ **1. tests/conftest.py** — pytest fixtures:
123
+
124
+ ```python
125
+ """Shared pytest fixtures for FATHOM env tests."""
126
+ from __future__ import annotations
127
+
128
+ import json
129
+ import os
130
+ import signal
131
+ import socket
132
+ import subprocess
133
+ import sys
134
+ import time
135
+ from pathlib import Path
136
+ from typing import Iterator
137
+
138
+ import pytest
139
+ from fastapi.testclient import TestClient
140
+
141
+
142
+ @pytest.fixture()
143
+ def fresh_test_client() -> Iterator[TestClient]:
144
+ """A TestClient bound to a FRESH FastAPI app — avoids single-_ENV state bleed between tests."""
145
+ # Reimport env.server.app to get a fresh module-level environment
146
+ import importlib
147
+ import env.server.app as app_mod
148
+ importlib.reload(app_mod)
149
+ yield TestClient(app_mod.app)
150
+
151
+
152
+ @pytest.fixture(scope="session")
153
+ def fixture_rows() -> list[dict]:
154
+ """Parse env/data/phase0_fixture.jsonl once per test session."""
155
+ path = Path("env/data/phase0_fixture.jsonl")
156
+ rows = []
157
+ with path.open("r", encoding="utf-8") as f:
158
+ for line in f:
159
+ line = line.strip()
160
+ if line:
161
+ rows.append(json.loads(line))
162
+ assert len(rows) >= 3, f"Expected >=3 fixture rows, got {len(rows)}"
163
+ return rows
164
+
165
+
166
+ def _find_free_port() -> int:
167
+ with socket.socket() as s:
168
+ s.bind(("127.0.0.1", 0))
169
+ return s.getsockname()[1]
170
+
171
+
172
+ @pytest.fixture()
173
+ def live_uvicorn_server() -> Iterator[str]:
174
+ """Launch uvicorn in a subprocess; yield base_url; terminate on teardown.
175
+
176
+ Skipped on Windows when subprocess + uvicorn reload interact poorly; the TestClient
177
+ fixture covers equivalent functionality except the real HTTP wire transit.
178
+ """
179
+ port = _find_free_port()
180
+ env = os.environ.copy()
181
+ env["PYTHONDONTWRITEBYTECODE"] = "1"
182
+ proc = subprocess.Popen(
183
+ [sys.executable, "-m", "uvicorn", "env.server.app:app",
184
+ "--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"],
185
+ stdout=subprocess.PIPE,
186
+ stderr=subprocess.PIPE,
187
+ env=env,
188
+ )
189
+ base_url = f"http://127.0.0.1:{port}"
190
+ try:
191
+ # Poll for readiness — up to 10s
192
+ deadline = time.monotonic() + 10.0
193
+ ready = False
194
+ import httpx
195
+ while time.monotonic() < deadline:
196
+ try:
197
+ r = httpx.get(base_url + "/healthz", timeout=0.5)
198
+ if r.status_code == 200:
199
+ ready = True
200
+ break
201
+ except Exception:
202
+ time.sleep(0.2)
203
+ if not ready:
204
+ out = proc.stdout.read().decode("utf-8", errors="replace")[:2000] if proc.stdout else ""
205
+ err = proc.stderr.read().decode("utf-8", errors="replace")[:2000] if proc.stderr else ""
206
+ pytest.skip(f"uvicorn failed to come up in 10s. stderr: {err}. stdout: {out}")
207
+ yield base_url
208
+ finally:
209
+ if proc.poll() is None:
210
+ try:
211
+ if sys.platform != "win32":
212
+ proc.send_signal(signal.SIGTERM)
213
+ else:
214
+ proc.terminate()
215
+ proc.wait(timeout=5)
216
+ except Exception:
217
+ proc.kill()
218
+ ```
219
+
220
+ **2. tests/test_env.py** — the verification suite. EXACT test function names that Plan 05 acceptance depends on:
221
+
222
+ ```python
223
+ """Phase 0 env verification suite — ENV-01..03, ENV-07, ENV-08.
224
+
225
+ ROADMAP Success Criteria literals:
226
+ - `pytest tests/test_env.py::test_gold_answer_never_in_observation` passes (ENV-08)
227
+ - All 4 termination reasons appear as named tests covering ENV-07 (a/b/c/d)
228
+ - End-to-end HTTP roundtrip via FathomEnvClient proves ENV-01..03
229
+ """
230
+ from __future__ import annotations
231
+
232
+ import time
233
+
234
+ import pytest
235
+ from fastapi.testclient import TestClient
236
+
237
+ from env.client import FathomEnvClient
238
+ from env.models import FathomAction, FathomObservation, FathomStepResult, TerminationReason
239
+
240
+
241
+ # ─── ENV-01: scaffold + HTTP roundtrip ────────────────────────────────
242
+ def test_healthz_returns_ok(fresh_test_client: TestClient) -> None:
243
+ r = fresh_test_client.get("/healthz")
244
+ assert r.status_code == 200
245
+ assert r.json() == {"status": "ok"}
246
+
247
+
248
+ def test_reset_returns_observation_no_gold_field(fresh_test_client: TestClient) -> None:
249
+ r = fresh_test_client.post("/reset", json={"seed": 0, "difficulty": "easy"})
250
+ assert r.status_code == 200, r.text
251
+ obs_json = r.json()
252
+ assert "gold_answer" not in obs_json, f"ENV-08 violation: gold_answer key in observation JSON: {obs_json}"
253
+ assert "task_type" not in obs_json
254
+ # Observation should validate against schema
255
+ obs = FathomObservation.model_validate(obs_json)
256
+ assert obs.tokens_remaining == 100_000
257
+ assert obs.depth_max == 2
258
+ assert obs.turns_left == 20
259
+
260
+
261
+ def test_step_roundtrip_print_1_plus_1(fresh_test_client: TestClient) -> None:
262
+ fresh_test_client.post("/reset", json={"seed": 0, "difficulty": "easy"})
263
+ r = fresh_test_client.post("/step", json={"tool_name": "repl", "code": "print(1 + 1)"})
264
+ assert r.status_code == 200
265
+ body = r.json()
266
+ assert "gold_answer" not in body.get("observation", {})
267
+ sr = FathomStepResult.model_validate(body)
268
+ assert "2" in sr.observation.stdout
269
+ assert sr.done is False
270
+
271
+
272
+ # ─── ENV-08: gold-answer sealing (ROADMAP Success Criterion #3 literal) ─
273
+ def test_gold_answer_never_in_observation(fresh_test_client: TestClient, fixture_rows: list[dict]) -> None:
274
+ """For every fixture row, after reset() + several steps, no observation field contains gold_answer or task_type as a key.
275
+
276
+ This is the LITERAL test that ROADMAP Phase 0 Exit Gate Success Criterion #3 names.
277
+ It inspects the serialized observation dict for the presence of `gold_answer` / `task_type`
278
+ as keys. The gold VALUE appearing inside context_preview (because context contains it) is
279
+ NOT a violation — that is the task's input data, not a leak.
280
+ """
281
+ difficulties = ["easy", "medium", "hard"] # maps to rows 0, 1, 2 per D-10
282
+ for diff in difficulties:
283
+ r = fresh_test_client.post("/reset", json={"seed": 0, "difficulty": diff})
284
+ assert r.status_code == 200
285
+ obs = r.json()
286
+ assert "gold_answer" not in obs, f"diff={diff}: gold_answer leaked: keys={list(obs.keys())}"
287
+ assert "task_type" not in obs, f"diff={diff}: task_type leaked: keys={list(obs.keys())}"
288
+
289
+ # Take 3 benign steps and re-check
290
+ for code in ("x = 1", "y = x + 2", "z = y * 3"):
291
+ r2 = fresh_test_client.post("/step", json={"tool_name": "repl", "code": code})
292
+ assert r2.status_code == 200
293
+ body = r2.json()
294
+ assert "gold_answer" not in body.get("observation", {}), f"step leak on diff={diff}, code={code}"
295
+ assert "task_type" not in body.get("observation", {})
296
+
297
+
298
+ def test_state_endpoint_sanitized(fresh_test_client: TestClient) -> None:
299
+ """ENV-08: /state returns a whitelist dict — no gold_answer, task_type, or raw context."""
300
+ fresh_test_client.post("/reset", json={"seed": 0, "difficulty": "easy"})
301
+ r = fresh_test_client.get("/state")
302
+ assert r.status_code == 200
303
+ s = r.json()
304
+ assert "gold_answer" not in s
305
+ assert "task_type" not in s
306
+ assert "context" not in s
307
+ # But debug fields are present
308
+ assert "episode_id" in s
309
+ assert "step_count" in s
310
+ assert "tokens_used_total" in s
311
+
312
+
313
+ # ─── ENV-07: 4 termination reasons ─────────────────────────────────────
314
+ def test_termination_answer(fresh_test_client: TestClient) -> None:
315
+ """ENV-07a — <answer>...</answer> emits done=True with reason=answer."""
316
+ fresh_test_client.post("/reset", json={"seed": 0, "difficulty": "easy"})
317
+ r = fresh_test_client.post("/step", json={"tool_name": "repl", "code": "<answer>42</answer>"})
318
+ sr = FathomStepResult.model_validate(r.json())
319
+ assert sr.done is True
320
+ assert sr.info.get("termination_reason") == TerminationReason.ANSWER.value
321
+ assert sr.observation.answer_emitted is True
322
+ assert sr.observation.return_val == "42"
323
+
324
+
325
+ def test_termination_max_steps(fresh_test_client: TestClient) -> None:
326
+ """ENV-07b — exceeding max_steps terminates with reason=max_steps."""
327
+ fresh_test_client.post("/reset", json={"seed": 0, "difficulty": "easy", "max_steps": 2})
328
+ # First step: not terminal
329
+ r1 = fresh_test_client.post("/step", json={"tool_name": "repl", "code": "a = 1"})
330
+ sr1 = FathomStepResult.model_validate(r1.json())
331
+ assert sr1.done is False
332
+ # Second step: reaches max_steps threshold
333
+ r2 = fresh_test_client.post("/step", json={"tool_name": "repl", "code": "b = 2"})
334
+ sr2 = FathomStepResult.model_validate(r2.json())
335
+ assert sr2.done is True
336
+ assert sr2.info.get("termination_reason") == TerminationReason.MAX_STEPS.value
337
+
338
+
339
+ def test_termination_max_tokens(fresh_test_client: TestClient) -> None:
340
+ """ENV-07c — exceeding max_tokens terminates with reason=max_tokens."""
341
+ # Tight budget: 5 tokens. The first step emits code with more than that.
342
+ fresh_test_client.post("/reset", json={"seed": 0, "difficulty": "easy", "max_tokens": 5})
343
+ r = fresh_test_client.post(
344
+ "/step",
345
+ json={"tool_name": "repl", "code": "print('this is a long string designed to exceed five tokens')"},
346
+ )
347
+ sr = FathomStepResult.model_validate(r.json())
348
+ assert sr.done is True, f"Expected done=True on max_tokens, got {sr}"
349
+ assert sr.info.get("termination_reason") == TerminationReason.MAX_TOKENS.value
350
+
351
+
352
+ def test_termination_walltime(fresh_test_client: TestClient) -> None:
353
+ """ENV-07d — walltime budget exceeded terminates with reason=walltime."""
354
+ # Tight walltime budget — 0.3s
355
+ fresh_test_client.post("/reset", json={"seed": 0, "difficulty": "easy", "walltime_budget_s": 0.3})
356
+ # Sleep past the budget before stepping
357
+ time.sleep(0.5)
358
+ r = fresh_test_client.post("/step", json={"tool_name": "repl", "code": "x = 1"})
359
+ sr = FathomStepResult.model_validate(r.json())
360
+ assert sr.done is True, f"Expected walltime termination, got {sr}"
361
+ assert sr.info.get("termination_reason") == TerminationReason.WALLTIME.value
362
+
363
+
364
+ # ─── ENV-06: malformed code → structured error, done=False ─────────────
365
+ def test_malformed_code_returns_error_observation_done_false(fresh_test_client: TestClient) -> None:
366
+ fresh_test_client.post("/reset", json={"seed": 0, "difficulty": "easy"})
367
+ r = fresh_test_client.post("/step", json={"tool_name": "repl", "code": "1 / 0"})
368
+ sr = FathomStepResult.model_validate(r.json())
369
+ assert sr.done is False, "ENV-06: division by zero should NOT terminate the episode"
370
+ assert "ZeroDivisionError" in sr.observation.stderr or "ZeroDivisionError" in (sr.observation.return_val or "")
371
+
372
+
373
+ def test_malformed_syntax_returns_error_observation(fresh_test_client: TestClient) -> None:
374
+ fresh_test_client.post("/reset", json={"seed": 0, "difficulty": "easy"})
375
+ r = fresh_test_client.post("/step", json={"tool_name": "repl", "code": "def (:"})
376
+ sr = FathomStepResult.model_validate(r.json())
377
+ assert sr.done is False
378
+ assert sr.observation.stderr, "syntax error should surface in stderr"
379
+
380
+
381
+ # ─── ENV-03 / D-10: difficulty mapping ─────────────────────────────────
382
+ def test_difficulty_mapping_medium_loads_multi_needle_row(fresh_test_client: TestClient) -> None:
383
+ fresh_test_client.post("/reset", json={"seed": 0, "difficulty": "medium"})
384
+ r = fresh_test_client.get("/state")
385
+ assert r.status_code == 200
386
+ s = r.json()
387
+ assert s["task_id"] == "fixture-0001-multi-needle", f"D-10 violation: got {s['task_id']}"
388
+
389
+
390
+ def test_difficulty_mapping_hard_loads_counting_row(fresh_test_client: TestClient) -> None:
391
+ fresh_test_client.post("/reset", json={"seed": 0, "difficulty": "hard"})
392
+ r = fresh_test_client.get("/state")
393
+ s = r.json()
394
+ assert s["task_id"] == "fixture-0002-counting"
395
+
396
+
397
+ def test_reset_by_task_id(fresh_test_client: TestClient) -> None:
398
+ fresh_test_client.post("/reset", json={"seed": 0, "task_id": "fixture-0002-counting"})
399
+ r = fresh_test_client.get("/state")
400
+ s = r.json()
401
+ assert s["task_id"] == "fixture-0002-counting"
402
+
403
+
404
+ # ─── ENV-01: live HTTP roundtrip via FathomEnvClient ──────────────────
405
+ def test_live_uvicorn_roundtrip(live_uvicorn_server: str) -> None:
406
+ """End-to-end HTTP roundtrip matching ROADMAP Success Criterion #1.
407
+
408
+ Spawns uvicorn in a subprocess, uses FathomEnvClient over the real HTTP wire,
409
+ verifies reset() + step(print(1+1)) + state() all succeed with no gold leak.
410
+ """
411
+ with FathomEnvClient(base_url=live_uvicorn_server) as client:
412
+ assert client.healthz() == {"status": "ok"}
413
+ obs = client.reset(seed=0, difficulty="easy")
414
+ assert isinstance(obs, FathomObservation)
415
+ sr = client.step(FathomAction(tool_name="repl", code="print(1+1)"))
416
+ assert isinstance(sr, FathomStepResult)
417
+ assert "2" in sr.observation.stdout
418
+ assert sr.done is False
419
+ s = client.state()
420
+ assert "gold_answer" not in s
421
+ assert "task_type" not in s
422
+ assert s.get("step_count") == 1
423
+ ```
424
+
425
+ The total test count after this file:
426
+ - TestClient (fresh_test_client): 13 tests
427
+ - Live uvicorn: 1 test
428
+ - Total: 14 tests
429
+
430
+ Minimum pass requirement: 12 tests must pass (allowing live_uvicorn_roundtrip to be skipped on Windows if uvicorn subprocess is flaky, and one termination-reason-timing test to be flaky). In practice, the executor should aim for all 14 green; investigate and fix any failure before proceeding to Task 2.
431
+
432
+ If `test_termination_max_tokens` is flaky because the long-string code didn't exceed 5 tokens (tiktoken counts), the executor may tighten the budget to `max_tokens=2` or lengthen the code further — the test is verifying the MECHANISM (termination with reason=max_tokens), not a specific numeric count.
433
+ </action>
434
+ <verify>
435
+ <automated>pytest tests/test_env.py -v --tb=short</automated>
436
+ </verify>
437
+ <acceptance_criteria>
438
+ - `tests/test_env.py` exists
439
+ - `tests/conftest.py` exists
440
+ - `grep -c "^def test_" tests/test_env.py` returns at least `12`
441
+ - `grep -q "def test_gold_answer_never_in_observation" tests/test_env.py` succeeds (ROADMAP literal)
442
+ - `grep -q "def test_termination_answer" tests/test_env.py` AND `grep -q "def test_termination_max_steps" tests/test_env.py` AND `grep -q "def test_termination_max_tokens" tests/test_env.py` AND `grep -q "def test_termination_walltime" tests/test_env.py` all succeed (ENV-07 four reasons)
443
+ - `grep -q "def test_live_uvicorn_roundtrip" tests/test_env.py` succeeds (ENV-01 literal)
444
+ - `grep -q "def test_malformed_code_returns_error_observation_done_false" tests/test_env.py` succeeds (ENV-06)
445
+ - `grep -q "def test_state_endpoint_sanitized" tests/test_env.py` succeeds (ENV-08)
446
+ - `grep -q "TerminationReason.MAX_STEPS" tests/test_env.py` succeeds (all 4 reasons referenced by enum)
447
+ - `grep -q "from env.client import FathomEnvClient" tests/test_env.py` succeeds
448
+ - `pytest tests/test_env.py -v` exits 0 with at minimum 12 passed (live_uvicorn + one timing test may be skipped)
449
+ - `pytest tests/test_env.py::test_gold_answer_never_in_observation -v` exits 0 on its own (ROADMAP Success Criterion #3 literal command)
450
+ </acceptance_criteria>
451
+ <done>12-14 tests pass; gold-leakage and all 4 termination reasons verified; HTTP roundtrip via live uvicorn proven.</done>
452
+ </task>
453
+
454
+ <task type="auto">
455
+ <name>Task 2: Aggregate Phase 0 exit gate into .planning/PHASE_0_EXIT_GATE.md</name>
456
+ <files>.planning/PHASE_0_EXIT_GATE.md</files>
457
+ <read_first>
458
+ - .planning/SANDBOX_CERT.md (Plan 03 output — sandbox gate)
459
+ - .planning/phases/00-thu-evening-foundation/00-01-packaging-scaffold-SUMMARY.md (Plan 01 output — Docker gate)
460
+ - tests/test_env.py + tests/test_repl_sandbox.py (Plan 03 + Plan 05 outputs)
461
+ - .planning/ROADMAP.md Phase 0 Exit Gate (named 3-part literal: sandbox suite green + env roundtrip green + Dockerfile builds locally)
462
+ </read_first>
463
+ <action>
464
+ Aggregate three pieces of evidence into `.planning/PHASE_0_EXIT_GATE.md`:
465
+ 1. Sandbox suite: reference `.planning/SANDBOX_CERT.md` + embed its status line
466
+ 2. Env tests: run `pytest tests/test_env.py tests/test_repl_sandbox.py -v --tb=short` and embed a summary (pass count + any skips)
467
+ 3. Docker build: if Docker available locally, run `docker build -t fathom-env:phase0 .` and confirm exit 0; embed the last 10 lines of output. If Docker not available, document that and point to Plan 01's SUMMARY for prior build verification.
468
+
469
+ Capture:
470
+ - ISO-8601 UTC timestamp
471
+ - Current git commit SHA (`git rev-parse HEAD`)
472
+ - Branch name
473
+ - Python version + platform
474
+
475
+ File template:
476
+
477
+ ```markdown
478
+ # Phase 0 — Exit Gate Status
479
+
480
+ **Status:** {GREEN | RED}
481
+ **Timestamp (UTC):** {ISO-8601}
482
+ **Git commit:** {SHA}
483
+ **Branch:** {branch}
484
+ **Platform:** {sys.platform}
485
+ **Python:** {sys.version_info}
486
+
487
+ Phase 0 Exit Gate is the conjunction of three conditions from ROADMAP:
488
+ 1. Adversarial sandbox suite green (SANDBOX_CERT.md)
489
+ 2. Env HTTP roundtrip green (tests/test_env.py)
490
+ 3. Dockerfile builds locally (from Plan 01 verification)
491
+
492
+ If any of these is RED, Phase 1 is BLOCKED per ROADMAP Phase 0 Exit Gate.
493
+
494
+ ## 1. Sandbox Suite — {GREEN | RED}
495
+
496
+ Reference: `.planning/SANDBOX_CERT.md` (committed at {sandbox_cert_sha}).
497
+ Status line: `{first line after "**Status:**" from SANDBOX_CERT.md}`.
498
+
499
+ Tests:
500
+ - test_attribute_access_escape: CONTAINED
501
+ - test_builtins_monkeypatch: CONTAINED
502
+ - test_eval_via_reflection: CONTAINED
503
+ - test_network_egress: CONTAINED
504
+ - test_file_write: CONTAINED
505
+ - test_os_system: CONTAINED
506
+ - test_subprocess_of_subprocess: CONTAINED
507
+ - test_traceback_walk: CONTAINED
508
+ - test_infinite_loop: CONTAINED (timeout fires; D-03 preserves globals)
509
+ - test_memory_bomb: CONTAINED (or SKIPPED on Windows — rlimit unsupported)
510
+
511
+ ## 2. Env HTTP Tests — {GREEN | RED}
512
+
513
+ Command: `pytest tests/test_env.py tests/test_repl_sandbox.py -v --tb=short`
514
+ Result: {N passed, M skipped, K failed}
515
+
516
+ Named Roadmap literals verified:
517
+ - `pytest tests/test_env.py::test_gold_answer_never_in_observation` → PASS (ENV-08)
518
+ - test_termination_answer → PASS (ENV-07a)
519
+ - test_termination_max_steps → PASS (ENV-07b)
520
+ - test_termination_max_tokens → PASS (ENV-07c)
521
+ - test_termination_walltime → PASS (ENV-07d)
522
+ - test_live_uvicorn_roundtrip → PASS (ENV-01..03 over real HTTP)
523
+
524
+ Full pytest output (truncated to 120 lines):
525
+ ```
526
+ {paste `pytest -v --tb=short` output here}
527
+ ```
528
+
529
+ ## 3. Dockerfile Build — {GREEN | RED | NOT_EXERCISED}
530
+
531
+ Command: `docker build -t fathom-env:phase0 .`
532
+ Result: {exit code + last 10 lines OR "Docker not available — see 00-01-packaging-scaffold-SUMMARY.md"}
533
+
534
+ ## Phase-0 Requirement Coverage
535
+
536
+ | REQ-ID | Verified by |
537
+ |--------|-------------|
538
+ | ENV-01 | test_healthz_returns_ok, test_reset_returns_observation_no_gold_field, test_step_roundtrip_print_1_plus_1, test_live_uvicorn_roundtrip |
539
+ | ENV-02 | Pydantic schemas in env/models.py; every test imports and validates |
540
+ | ENV-03 | test_difficulty_mapping_medium_loads_multi_needle_row, test_difficulty_mapping_hard_loads_counting_row, test_reset_by_task_id |
541
+ | ENV-04 | tests/test_repl_sandbox.py (all 7 attacks) |
542
+ | ENV-05 | env/server/llm_primitive.py + depth-cap check via run_repl smoke |
543
+ | ENV-06 | test_malformed_code_returns_error_observation_done_false, test_malformed_syntax_returns_error_observation |
544
+ | ENV-07 | test_termination_{answer,max_steps,max_tokens,walltime} |
545
+ | ENV-08 | test_gold_answer_never_in_observation, test_state_endpoint_sanitized |
546
+ | ENV-09 | tests/test_repl_sandbox.py 7-attack suite + SANDBOX_CERT.md |
547
+ | REP-01 | pyproject.toml pins + 00-01-SUMMARY grep verification |
548
+ | REP-02 | Dockerfile from Plan 01 + build step above |
549
+
550
+ ## Next Step
551
+
552
+ IF STATUS == GREEN:
553
+ Proceed to Phase 1 — `/gsd-plan-phase 1`. Hardik joins Friday.
554
+ ELSE:
555
+ DO NOT SLEEP. Fix the RED gate before any Phase 1 work. Per ROADMAP: "If any red, do NOT proceed to Phase 1 — fix before sleeping."
556
+ ```
557
+
558
+ Fill in every `{...}` placeholder with real values from the live pytest run and git state. If any of the three gates is RED, the Status line MUST be RED and the file MUST instruct the next step to fix before Phase 1.
559
+
560
+ Commit message: `docs(phase-00): phase 0 exit gate {green|red}`.
561
+ </action>
562
+ <verify>
563
+ <automated>test -f .planning/PHASE_0_EXIT_GATE.md && grep -qE "Status:.*GREEN|Status:.*RED" .planning/PHASE_0_EXIT_GATE.md && grep -q "test_gold_answer_never_in_observation" .planning/PHASE_0_EXIT_GATE.md && grep -q "test_termination_answer" .planning/PHASE_0_EXIT_GATE.md && grep -q "test_termination_max_steps" .planning/PHASE_0_EXIT_GATE.md && grep -q "test_termination_max_tokens" .planning/PHASE_0_EXIT_GATE.md && grep -q "test_termination_walltime" .planning/PHASE_0_EXIT_GATE.md && grep -q "ENV-01" .planning/PHASE_0_EXIT_GATE.md && grep -q "ENV-08" .planning/PHASE_0_EXIT_GATE.md && grep -q "SANDBOX_CERT" .planning/PHASE_0_EXIT_GATE.md && grep -qE "Git commit:\s+[0-9a-f]{7,40}" .planning/PHASE_0_EXIT_GATE.md</automated>
564
+ </verify>
565
+ <acceptance_criteria>
566
+ - `.planning/PHASE_0_EXIT_GATE.md` exists
567
+ - `grep -qE "Status:\s+(GREEN|RED)" .planning/PHASE_0_EXIT_GATE.md` — a status is declared
568
+ - `grep -q "SANDBOX_CERT" .planning/PHASE_0_EXIT_GATE.md` — sandbox gate referenced
569
+ - `grep -q "test_gold_answer_never_in_observation" .planning/PHASE_0_EXIT_GATE.md` — ROADMAP literal named
570
+ - All 4 termination-reason test names present
571
+ - `grep -qE "Git commit:\s+[0-9a-f]{7,40}" .planning/PHASE_0_EXIT_GATE.md` — SHA embedded
572
+ - Requirement coverage table lists every phase REQ-ID (ENV-01..09, REP-01, REP-02)
573
+ - Command `docker build` either shows exit 0 or explicitly documents "NOT_EXERCISED" with a pointer
574
+ - If Status == GREEN, the file exists with a "Proceed to Phase 1" next-step line; if RED, the file instructs to fix before Phase 1
575
+ </acceptance_criteria>
576
+ <done>Phase 0 has a single-file status beacon (.planning/PHASE_0_EXIT_GATE.md) aggregating sandbox + env + docker gates, with full requirement coverage table. Phase 1 reads this file as its precondition.</done>
577
+ </task>
578
+
579
+ </tasks>
580
+
581
+ <threat_model>
582
+ ## Trust Boundaries
583
+
584
+ | Boundary | Description |
585
+ |----------|-------------|
586
+ | Test harness → FastAPI app | trusted inputs but exercise adversarial paths (e.g., 1/0, syntax errors) |
587
+ | Test harness → live uvicorn subprocess | verifies real HTTP-over-TCP contract (not just TestClient stub) |
588
+ | Plan 05 assertions → ENV-08 enforcement | the test IS the verification of the invariant |
589
+
590
+ ## STRIDE Threat Register
591
+
592
+ | Threat ID | Category | Component | Disposition | Mitigation Plan |
593
+ |-----------|----------|-----------|-------------|-----------------|
594
+ | T-00-27 | I (Information disclosure) | gold-leakage coverage | mitigate | `test_gold_answer_never_in_observation` iterates ALL 3 fixture difficulties + 3 post-reset steps each = 12 observation checks; asserts `gold_answer` and `task_type` absent as KEYS in serialized JSON. ENV-08. |
595
+ | T-00-28 | T (Tampering) | termination-reason ambiguity | mitigate | 4 named tests — one per ENV-07 reason — each asserts `info["termination_reason"]` matches the exact enum value. Prevents ambiguous terminations that downstream Phase 2 reward composition would mishandle. |
596
+ | T-00-29 | E (Elevation of privilege) | malformed code crashing env | mitigate | `test_malformed_code_returns_error_observation_done_false` + `test_malformed_syntax_returns_error_observation` prove ENV-06: malformed inputs never crash the env; episode continues with structured stderr. |
597
+ | T-00-30 | D (Denial of service) | slow walltime test | mitigate | `walltime_budget_s=0.3` + `time.sleep(0.5)` ensures termination fires deterministically within < 2s total; test itself does not hang. |
598
+ | T-00-31 | R (Repudiation) | exit gate silent pass | mitigate | `.planning/PHASE_0_EXIT_GATE.md` embeds git commit SHA + full pytest output + ISO-8601 timestamp. Phase 1 can verify the gate was green at a specific commit, not just implicitly. |
599
+ | T-00-32 | S (Spoofing) | fake sandbox cert | accept | Cert is committed to git; tampering would require a rewritten SHA. Not a serious threat model at solo-dev scale, but the SHA provides audit trail. |
600
+ </threat_model>
601
+
602
+ <verification>
603
+ 1. `pytest tests/test_env.py -v` exits 0 with ≥ 12 tests passed
604
+ 2. `pytest tests/test_env.py::test_gold_answer_never_in_observation` on its own exits 0 — ROADMAP Success Criterion #3 literal
605
+ 3. All four termination-reason tests pass
606
+ 4. `pytest tests/test_env.py::test_live_uvicorn_roundtrip` passes (or documents a skip reason on Windows)
607
+ 5. `.planning/PHASE_0_EXIT_GATE.md` exists with Status: GREEN, requirement coverage table, and commit SHA
608
+ </verification>
609
+
610
+ <success_criteria>
611
+ - All 12+ tests in tests/test_env.py pass; all 7 (+ bonus) tests in tests/test_repl_sandbox.py pass
612
+ - `test_gold_answer_never_in_observation` — the ROADMAP-literal named test — passes
613
+ - 4 termination-reason tests (ENV-07 a/b/c/d) each pass
614
+ - Live uvicorn HTTP roundtrip test passes (or skipped with documented reason)
615
+ - .planning/PHASE_0_EXIT_GATE.md is GREEN with embedded pytest output, SHA, and requirement coverage
616
+ - Phase 0 is ready to hand off to Phase 1 (Hardik joins Friday)
617
+ </success_criteria>
618
+
619
+ <output>
620
+ After completion, create `.planning/phases/00-thu-evening-foundation/00-05-env-tests-exit-gate-SUMMARY.md` documenting:
621
+ - Full `pytest tests/ -v --durations=10` output
622
+ - Which tests skipped and why (platform-specific)
623
+ - Any mid-flight fixes to env/server/*.py required to get tests green
624
+ - .planning/PHASE_0_EXIT_GATE.md contents (truncated)
625
+ - Confirmation Phase 1 is unblocked: Sandbox CONTAINED + Env tests GREEN + Docker GREEN (or NOT_EXERCISED with justification)
626
+ </output>
627
+ </content>
628
+ </invoke>
.planning/phases/00-thu-evening-foundation/00-05-env-tests-exit-gate-SUMMARY.md ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Plan 05 — Env Tests + Phase 0 Exit Gate SUMMARY
2
+
3
+ ## Artifacts delivered
4
+
5
+ | File | Lines | Purpose |
6
+ |------|-------|---------|
7
+ | `tests/conftest.py` | ~75 | pytest fixtures: fresh_test_client, fixture_rows, live_uvicorn_server |
8
+ | `tests/test_env.py` | ~200 | 15 tests covering ENV-01..03, ENV-07 (×4), ENV-08, ENV-06, D-10 |
9
+ | `.planning/PHASE_0_EXIT_GATE.md` | ~80 | Aggregated Phase 0 status beacon with SHA + requirement coverage |
10
+ | `env/server/repl.py` (patch) | +8 lines | Windows subprocess fix: pass SystemRoot + explicit PYTHONPATH to child |
11
+
12
+ ---
13
+
14
+ ## Full pytest output
15
+
16
+ ```
17
+ ============================= test session starts =============================
18
+ platform win32 -- Python 3.10.11, pytest-9.0.2, pluggy-1.6.0
19
+ rootdir: C:\Users\prath\OneDrive\Desktop\Hackathons\Meta_finale
20
+ configfile: pyproject.toml
21
+
22
+ tests/test_env.py::test_healthz_returns_ok PASSED [ 6%]
23
+ tests/test_env.py::test_reset_returns_observation_no_gold_field PASSED [ 13%]
24
+ tests/test_env.py::test_step_roundtrip_print_1_plus_1 PASSED [ 20%]
25
+ tests/test_env.py::test_gold_answer_never_in_observation PASSED [ 26%]
26
+ tests/test_env.py::test_state_endpoint_sanitized PASSED [ 33%]
27
+ tests/test_env.py::test_termination_answer PASSED [ 40%]
28
+ tests/test_env.py::test_termination_max_steps PASSED [ 46%]
29
+ tests/test_env.py::test_termination_max_tokens PASSED [ 53%]
30
+ tests/test_env.py::test_termination_walltime PASSED [ 60%]
31
+ tests/test_env.py::test_malformed_code_returns_error_observation_done_false PASSED [ 66%]
32
+ tests/test_env.py::test_malformed_syntax_returns_error_observation PASSED [ 73%]
33
+ tests/test_env.py::test_difficulty_mapping_medium_loads_multi_needle_row PASSED [ 80%]
34
+ tests/test_env.py::test_difficulty_mapping_hard_loads_counting_row PASSED [ 86%]
35
+ tests/test_env.py::test_reset_by_task_id PASSED [ 93%]
36
+ tests/test_env.py::test_live_uvicorn_roundtrip PASSED [100%]
37
+
38
+ ============================= 15 passed in 7.26s ==============================
39
+ ```
40
+
41
+ Sandbox suite (test_repl_sandbox.py): 9 passed, 1 skipped (test_memory_bomb — RLIMIT_AS Windows-only).
42
+
43
+ **Combined: 24 passed, 1 skipped. Zero failures.**
44
+
45
+ ---
46
+
47
+ ## Skipped tests and reason
48
+
49
+ | Test | Reason | Action |
50
+ |------|--------|--------|
51
+ | `test_memory_bomb` | `pytest.mark.skipif(sys.platform == "win32")` — RLIMIT_AS is POSIX-only | Re-run at venue on Linux A100; expect 10/10 (no skips) |
52
+
53
+ ---
54
+
55
+ ## Mid-flight fix to env/server/repl.py
56
+
57
+ Two issues encountered when running the child subprocess on Windows dev laptop:
58
+
59
+ 1. **`_Py_HashRandomization_Init` crash** — fixed by passing `SystemRoot`, `SystemDrive`, `TEMP`, `TMP`, `WINDIR` from parent env to child. BCryptGenRandom (Python's hash seed init on Windows) requires SystemRoot to locate system DLLs.
60
+
61
+ 2. **`ModuleNotFoundError: No module named 'RestrictedPython'`** — the `-I` flag (isolated mode) ignores `PYTHONPATH`, so the child process cannot find packages installed in site-packages. Fixed by:
62
+ - Switching from `-I` to `-s` (skip user site-packages only; does not suppress `PYTHONPATH`)
63
+ - Passing explicit `PYTHONPATH` built from `site.getsitepackages()` in child_env
64
+
65
+ Security assessment: the child env still strips all secrets (HF_TOKEN, WANDB_API_KEY). `PYTHONPATH` points to the parent's known site-packages; user code cannot use it to escape because the `_safe_import` whitelist runs inside the child and restricts what user code can actually import. This fix is Windows-dev-only; the venue Linux A100 does not hit either issue.
66
+
67
+ ---
68
+
69
+ ## PHASE_0_EXIT_GATE.md status
70
+
71
+ **Status: GREEN**
72
+ - Sandbox: PASSED (9 passed, 1 skipped — Windows)
73
+ - Env tests: PASSED (15 passed)
74
+ - Dockerfile: NOT_EXERCISED (no Docker Desktop on dev laptop; re-verify at venue)
75
+ - Git commit: `f00043e2aac2ca3da6abe3446528ab941c56a467`
76
+
77
+ ---
78
+
79
+ ## Phase 1 unblocked
80
+
81
+ All three ROADMAP Phase 0 exit criteria are met:
82
+ - Sandbox CONTAINED (7/7 named attacks) ✓
83
+ - Env roundtrip GREEN (15/15 tests) ✓
84
+ - Dockerfile NOT_EXERCISED locally — venue re-verify required (does not block Phase 1 planning)
85
+
86
+ **Phase 1 is unblocked. Next: `/gsd-plan-phase 1`.**
87
+ Hardik joins Friday. ROADMAP has been patched with 6 hackathon-criteria items for Phases 1-3.
.planning/phases/00-thu-evening-foundation/00-CONTEXT.md ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Phase 0: Thu Evening Foundation - Context
2
+
3
+ **Gathered:** 2026-04-23
4
+ **Status:** Ready for planning
5
+
6
+ <domain>
7
+ ## Phase Boundary
8
+
9
+ Env server answers `reset` and `step` over HTTP locally with a hardened Python REPL and an `llm()` stub, ready for training-stack scaffolding to be built against it tomorrow. Delivers the contract every downstream phase builds on: typed Pydantic actions/observations, stateful whitelisted REPL, depth-capped sub-LM primitive, gold-answer-sealed State, hand-written fixture for `reset(difficulty=...)`, 7-attack adversarial sandbox suite, pinned `pyproject.toml`, and `openenv init`-generated Dockerfile that builds.
10
+
11
+ **In scope:** ENV-01 through ENV-09, REP-01, REP-02 (11 requirements).
12
+ **Out of scope this phase:** ENV-10 (HF Space deploy — Phase 1), real dataset (DATA-* — Phase 1), rewards (REW-* — Phase 1), real model inference (TRN-* — Phase 1/2).
13
+
14
+ </domain>
15
+
16
+ <decisions>
17
+ ## Implementation Decisions
18
+
19
+ ### REPL execution state
20
+ - **D-01:** Stateful persistent `globals_dict` across steps. Variables the model defines in step N (e.g., `chunks = ctx.split(...)`) survive to step N+1. Matches the RLM paper's "model builds intermediate slices" semantics.
21
+ - **D-02:** Each step runs the code in a fresh subprocess for isolation, but `globals_dict` is pickled to stdin, updated inside the subprocess, and returned via stdout alongside stdout/stderr/exception as a single JSON envelope. `pickle` is used inside the env server only — it is never exposed to REPL code.
22
+ - **D-03:** Mid-step timeout (30s wall-clock, 5s CPU via `resource.setrlimit`) → subprocess killed, `globals_dict` NOT overwritten (prior state preserved), Observation returns `stderr="TimeoutError"`, `done=False` so the episode continues. Episode-level termination reasons remain the four listed in ENV-07.
23
+ - **D-04:** Initial `globals_dict` on reset contains exactly `ctx: str` (the long context), `llm: callable` (the stub/real sub-LM primitive), and the whitelisted builtins. No pre-baked helpers (`chunk`, `grep`, `tokens`) — model learns to compose `re.findall` + slicing. No task metadata dict — prevents shortcut-learning on task_type.
24
+
25
+ ### `llm()` stub behavior (Phase 0 only — Phase 2 swaps in real Qwen)
26
+ - **D-05:** `llm(prompt, chunk, depth)` stub is a **deterministic regex echo** of `chunk`: extracts the first word matching a keyword derived from `prompt`, falls back to `chunk[:200]` verbatim if no match. Deterministic, no network, replayable in tests. Exercises the depth counter, token accounting, and reward-path plumbing meaningfully enough that Phase 0's smoke test can assert non-trivial behavior.
27
+ - **D-06:** Depth cap = 2 at training (configurable to 3 at eval via `reset(max_depth=3)`). Depth counter lives on `State`, not passed by caller (untrusted). Calls that exceed the cap return the literal sentinel string `"[depth limit reached]"` (per ARCHITECTURE.md line 271) and still tally tokens — rejected calls aren't free.
28
+ - **D-07:** Token counting uses `tiktoken` with the `cl100k_base` encoder (approximate Qwen-compatible) summed across `prompt + chunk + response`. `tiktoken` is added to `pyproject.toml`. Phase 1/2 may swap to the real Qwen tokenizer once the model is loaded, but `max_tokens=100000` (ENV-07) is enforceable end-to-end in Phase 0.
29
+
30
+ ### Phase 0 data fixture
31
+ - **D-08:** A **3-example hand-written JSONL fixture** at `env/data/phase0_fixture.jsonl`. Rows: (a) trivial needle-in-haystack (500-token ctx, single-fact extraction), (b) medium multi-needle aggregation (2K-token ctx, sum/concat of 3 facts), (c) counting/structural (1K-token ctx, integer answer). Covers 3 of the 4 Phase 1 task types. Extractive QA deferred to Phase 1.
32
+ - **D-09:** Fixture schema is the **final DATA-05 shape** — `{task_id, task_type, difficulty, context_length, prompt, context, gold_answer, seed}` — so Phase 1's generator swaps into this schema without interface churn. The fixture file is kept after Phase 1 as a test asset for adversarial/ENV-08 tests; `data/train.jsonl` is the full dataset.
33
+ - **D-10:** `difficulty` parameter on `reset()` **maps to fixture row selection** in Phase 0: `trivial` → row 0, `easy` → row 0, `medium` → row 1, `hard` → row 2 (with a logged warning that no real "hard" example exists yet). This plumbs ENV-06 end-to-end — Phase 0's smoke test verifies the parameter flows into row selection — and Phase 1 rewires the mapping to a difficulty index without changing the call signature.
34
+ - **D-11:** Fixture loaded **lazily on first `reset()`** (not at app startup). Path inside `env/` package so Docker builds include it automatically. Phase 1 replaces the path with `data/train.jsonl` via env var `FATHOM_DATASET_PATH` (default: bundled fixture). Server starts in <1s.
35
+
36
+ ### REPL stdlib whitelist (sandbox scope)
37
+ - **D-12:** **STACK §7 list verbatim** for allowed imports: `re, json, math, statistics, collections, itertools, string, functools, operator`. Allowed builtins: `len, range, enumerate, zip, map, filter, sorted, min, max, sum, any, all, print, str, int, float, bool, list, dict, tuple, set, frozenset`. Explicitly blocked: `os, sys, subprocess, socket, urllib, http, requests, pathlib, io, ctypes, threading, multiprocessing, pickle, marshal, importlib` (from CLAUDE.md technology-stack §7).
38
+ - **D-13:** Seven named adversarial tests in **one file** `tests/test_repl_sandbox.py` — each as its own named `test_*` function: `test_attribute_access_escape`, `test_builtins_monkeypatch`, `test_eval_via_reflection`, `test_network_egress`, `test_file_write`, `test_os_system`, `test_subprocess_of_subprocess`. One file matches ROADMAP exit-gate literal (`pytest tests/test_repl_sandbox.py -v`); per-test naming gives clean CI output and lets each attack be debugged in isolation.
39
+ - **D-14:** Phase 0 exit-gate artifact: **`.planning/SANDBOX_CERT.md`** is written when all 7 tests pass. Contents: pytest output pasted verbatim, ISO-8601 timestamp, git commit SHA, list of attack names + verdicts. Phase 1's HF Space deploy task reads this file as a precondition — the R3/H3 ruin-mode gate from ROADMAP is auditable, not just implicit.
40
+
41
+ ### Claude's Discretion
42
+ - Exact `pyproject.toml` structure (PEP 621 layout, which build backend — `hatchling` is fine)
43
+ - Specific FastAPI app lifecycle wiring (lifespan handlers vs startup events)
44
+ - Pydantic validator strictness level and custom error messages
45
+ - Log format and level for the env server (json logs recommended, level=INFO default)
46
+ - How the regex-echo stub picks the "first keyword" — any deterministic heuristic that passes unit tests
47
+ - Exact ulimit values within ranges specified by STACK §7 (e.g., 512MB vs 256MB RLIMIT_AS)
48
+ - Whether to use `subprocess.Popen` directly or `subprocess.run`; Claude picks whichever satisfies the 7 adversarial tests most cleanly
49
+
50
+ </decisions>
51
+
52
+ <canonical_refs>
53
+ ## Canonical References
54
+
55
+ **Downstream agents MUST read these before planning or implementing.**
56
+
57
+ ### Architecture (component layout, Phase 0 build order)
58
+ - `.planning/research/ARCHITECTURE.md` §1 — Component diagram, file layout `env/server/{app,environment,repl,llm_primitive,models,client}.py`, REPL treats context as variable
59
+ - `.planning/research/ARCHITECTURE.md` §4 Phase 0 — 7-step build order with hour estimates
60
+ - `.planning/research/ARCHITECTURE.md` §Failure Modes — REPL timeout handling, subprocess isolation, depth-cap behavior (line 269-271)
61
+
62
+ ### Technology stack (pinned versions, sandbox pattern)
63
+ - `.planning/research/STACK.md` §1 — Version pins for `openenv-core`, `trl`, `unsloth`, `torch`, `transformers`, `pydantic`, `fastapi`, `restrictedpython`, `tiktoken` — copy verbatim into `pyproject.toml` for REP-01
64
+ - `.planning/research/STACK.md` §7 — REPL sandbox whitelist (module list + blocked list + 7-attack safety checklist); the D-12/D-13 decisions are the authority
65
+ - `.planning/research/STACK.md` §8 — `openenv init` scaffold workflow, edits we make post-init
66
+ - `.planning/research/STACK.md` §11 G1-G12 — Pre-event venue bootstrap commands (Phase 0 runs G1-G4 locally)
67
+
68
+ ### Pitfalls (ruin modes Phase 0 must prevent)
69
+ - `.planning/research/PITFALLS.md` H3 — Sandbox escape via attribute access / builtins trick / traceback walk; seven named attacks
70
+ - `.planning/research/PITFALLS.md` R3 — Reward hacking via env state leakage; motivates ENV-08 gold-answer sealing
71
+ - `.planning/research/PITFALLS.md` H6 — Chat template drift (not Phase 0, but Pydantic schema decisions here affect Phase 1 SFT)
72
+
73
+ ### Requirements (success criteria for Phase 0)
74
+ - `.planning/REQUIREMENTS.md` ENV-01 through ENV-09 — Exact acceptance criteria per requirement
75
+ - `.planning/REQUIREMENTS.md` REP-01, REP-02 — `pyproject.toml` pins, Dockerfile build
76
+ - `.planning/ROADMAP.md` Phase 0 § — 5 named success criteria, exit gate, 11-requirement list
77
+ - `.planning/research/FEATURES.md` ENV-01..09 — Feature specs with examples
78
+
79
+ ### Project-level (read only if needed)
80
+ - `CLAUDE.md` — Technology stack table, Qwen specifics (Phase 2+), sandbox pattern (§7), anti-patterns (§10)
81
+ - `.planning/PROJECT.md` — Requirements list, out-of-scope, key decisions, constraints
82
+
83
+ </canonical_refs>
84
+
85
+ <code_context>
86
+ ## Existing Code Insights
87
+
88
+ ### Reusable Assets
89
+ - **None** — repo has only `CLAUDE.md`, `.planning/`, and `.claude/`. No `src/`, no `env/`, no `tests/`. Phase 0 is a green-field scaffolding phase.
90
+
91
+ ### Established Patterns
92
+ - **File layout is pre-decided** — ARCHITECTURE.md §1 locks `env/server/{app,environment,repl,llm_primitive,models,client}.py` and `env/models.py`. The `openenv init` scaffold generates `src/envs/fathom/server/` by default (STACK §8.2) — we will need to either (a) pass `--path env/` or equivalent to the CLI, or (b) rename the scaffold post-init. ROADMAP success criteria literal is `env.server.app:app` (not `src.envs.fathom.server.app:app`), so `env/` at repo root is the contract.
93
+
94
+ ### Integration Points
95
+ - Phase 1 imports from this phase:
96
+ - `env.models.FathomAction`, `FathomObservation`, `FathomState`, `FathomStepResult` (Pydantic schemas — Phase 1's `rewards/compose.py` reads `trajectory` with this shape)
97
+ - `env.client.FathomEnvClient` (Phase 1's `train/rollout.py` instantiates this)
98
+ - `env/server/llm_primitive.py` (Phase 1 swaps the stub for a real HTTP call to Unsloth/vLLM)
99
+ - `env/data/phase0_fixture.jsonl` (Phase 1 replaces via env var; schema stays the same)
100
+ - `.planning/SANDBOX_CERT.md` (Phase 1 deploy task reads this as precondition)
101
+
102
+ </code_context>
103
+
104
+ <specifics>
105
+ ## Specific Ideas
106
+
107
+ - Stateful REPL is how Zhang/Kraska/Khattab's RLM paper intends the scaffold to work — model iteratively narrows its slice of `ctx`. The stateless alternative would hurt demo quality (the recursion-tree viz in Phase 2 relies on the model reusing prior-step variables).
108
+ - The `[depth limit reached]` sentinel is copied verbatim from ARCHITECTURE.md line 271 — not invented here.
109
+ - Fixture schema matching DATA-05 final shape is deliberate interface-stability: Phase 1's generator writes `data/train.jsonl`, but every consumer (env `reset`, reward verifier unit tests in Phase 1, the Phase 0 smoke test) uses the same 8-field schema. No reshape between phases.
110
+
111
+ </specifics>
112
+
113
+ <deferred>
114
+ ## Deferred Ideas
115
+
116
+ - **Health-check / metrics endpoint** (`GET /health`, `GET /metrics`) — nice-to-have for HF Space monitoring; Phase 1 owns the HF Space deploy and can add it there without changing the Phase 0 contract.
117
+ - **Extended stdlib whitelist** (`textwrap, unicodedata, difflib, heapq, bisect, hashlib, base64, datetime`) — rejected for Phase 0. Revisit only if the untrained-baseline on trivial tier (TRN-05, Phase 2) is artificially floor-bound by expressiveness; most grep strategies fit within STACK §7 modules.
118
+ - **Subprocess pool / warm subprocess reuse** — a performance optimization if subprocess spawn latency (~50ms per step × 20 steps × 8 generations) dominates rollout time. Phase 2 can measure and add if needed.
119
+ - **Observation snapshot on `GET /state`** — Phase 0 exposes `state` for debugging but returns a sanitized view (no `gold_answer`). Full telemetry / W&B env-side logging is Phase 2's MON-* territory.
120
+ - **Real-world document corpora in Phase 0 fixture** — out of scope; v1 uses synthetic only (per PROJECT.md Out-of-Scope).
121
+
122
+ </deferred>
123
+
124
+ ---
125
+
126
+ *Phase: 00-thu-evening-foundation*
127
+ *Context gathered: 2026-04-23*
.planning/phases/00-thu-evening-foundation/00-DISCUSSION-LOG.md ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Phase 0: Thu Evening Foundation - Discussion Log
2
+
3
+ > **Audit trail only.** Do not use as input to planning, research, or execution agents.
4
+ > Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
5
+
6
+ **Date:** 2026-04-23
7
+ **Phase:** 00-thu-evening-foundation
8
+ **Areas discussed:** REPL state across steps, llm() stub behavior, Phase 0 data fixture, REPL stdlib whitelist
9
+
10
+ ---
11
+
12
+ ## REPL state across steps
13
+
14
+ ### Q1 — Which REPL execution-state model for Phase 0?
15
+
16
+ | Option | Description | Selected |
17
+ |--------|-------------|----------|
18
+ | Stateful persistent globals | Env maintains `globals_dict` in State; subprocess receives it pickled, runs code, returns updated globals. Model can slice `ctx` once and reuse. Matches RLM paper semantics. | ✓ |
19
+ | Stateless fresh subprocess per step | No cross-step carry. Every step starts with only `ctx` and `llm` injected. Simpler but forces recomputation each turn. | |
20
+ | Stateful in-process exec (no subprocess) | Fastest but breaks subprocess isolation gate (PITFALLS H3). Rejected by research. | |
21
+
22
+ **User's choice:** Stateful persistent globals
23
+ **Notes:** The RLM paper's whole point is iterative narrowing — stateless would hurt demo quality.
24
+
25
+ ### Q2 — If stateful: how does a mid-step timeout affect globals?
26
+
27
+ | Option | Description | Selected |
28
+ |--------|-------------|----------|
29
+ | Discard in-flight update, keep prior globals | Subprocess killed, globals_dict not overwritten. stderr="TimeoutError" but prior variables remain. done=False, episode continues. | ✓ |
30
+ | Done=True on timeout (end episode) | Strict read of ARCHITECTURE.md line 269. ENV-07 doesn't list step timeout as termination reason, so done=False is more consistent with spec. | |
31
+ | Wipe globals on timeout | Punish timeout by resetting globals. Too harsh — loses all state for one slow grep. | |
32
+
33
+ **User's choice:** Discard in-flight update, keep prior globals
34
+
35
+ ### Q3 — How is globals_dict passed between env server process and subprocess?
36
+
37
+ | Option | Description | Selected |
38
+ |--------|-------------|----------|
39
+ | pickle via stdin/stdout pipe (JSON envelope) | Subprocess reads pickled globals from stdin, writes updated globals + stdout + exception as single JSON envelope via stdout. Simple, cross-platform. | ✓ |
40
+ | tempfile on disk per step | Debug trail but disallowed by "no file writes" sandbox rule unless carved out. | |
41
+ | You decide | Leave to Claude. | |
42
+
43
+ **User's choice:** pickle via stdin/stdout pipe
44
+
45
+ ### Q4 — What goes into globals_dict on reset?
46
+
47
+ | Option | Description | Selected |
48
+ |--------|-------------|----------|
49
+ | `ctx: str`, `llm: callable`, plus whitelisted builtins | Minimum viable; model composes re.findall + slicing. Matches ARCHITECTURE.md line 600. | ✓ |
50
+ | Add helpers (chunk/grep/tokens) | Faster untrained baseline but narrows the action space. | |
51
+ | Add task metadata dict | Reward-hacking vector per ENV-08. Rejected. | |
52
+
53
+ **User's choice:** ctx + llm + builtins only
54
+
55
+ ---
56
+
57
+ ## llm() stub behavior
58
+
59
+ ### Q1 — What should `llm(prompt, chunk, depth)` return in the Phase 0 stub?
60
+
61
+ | Option | Description | Selected |
62
+ |--------|-------------|----------|
63
+ | Deterministic regex echo of chunk | Extracts first word adjacent to prompt keywords, falls back to chunk[:200]. Exercises depth counter AND future correctness rewards on fixtures. | ✓ |
64
+ | Fixed sentinel string | `"[stub_llm_response]"` regardless of input. Cheapest but not usable for reward-path sanity check. | |
65
+ | Pure echo of prompt prefix | `f"[stub] prompt={prompt[:60]}... chunk_len={len(chunk)}"`. Debug-friendly but not exercisable by rewards. | |
66
+
67
+ **User's choice:** Deterministic regex echo of chunk
68
+
69
+ ### Q2 — How is depth enforced and surfaced?
70
+
71
+ | Option | Description | Selected |
72
+ |--------|-------------|----------|
73
+ | Cap = 2, rejected calls return `"[depth limit reached]"` | Matches ARCHITECTURE.md line 271 + ENV-05. Counter on State, rejected calls still tally tokens. Configurable to 3 at eval. | ✓ |
74
+ | Cap = 2, rejected calls raise exception | Leaks cap to model via exception text — reward-hack vector. | |
75
+ | Cap = 2, rejected calls return empty string | Silent failure — hurts debuggability. | |
76
+
77
+ **User's choice:** Cap = 2, sentinel return
78
+
79
+ ### Q3 — Does the stub count tokens?
80
+
81
+ | Option | Description | Selected |
82
+ |--------|-------------|----------|
83
+ | Yes, tiktoken cl100k_base on prompt+chunk+response | Phase 0 already needs `max_tokens=100000` enforcement (ENV-07). tiktoken gives fast approximate count. | ✓ |
84
+ | Character count / 4 as proxy | No tiktoken dep, good enough for ENV-07 test. | |
85
+ | Skip in Phase 0, add in Phase 1 | ENV-07 termination test would be incomplete. | |
86
+
87
+ **User's choice:** tiktoken cl100k_base
88
+
89
+ ---
90
+
91
+ ## Phase 0 data fixture
92
+
93
+ ### Q1 — What data backs `reset()` in Phase 0?
94
+
95
+ | Option | Description | Selected |
96
+ |--------|-------------|----------|
97
+ | Hand-written 3-example JSONL fixture | 3 rows covering NIAH, multi-needle, counting. Schema matches final DATA-05 shape. | ✓ |
98
+ | Tiny built-in needle generator | 50-line mini version of Phase 1 generator. Risk: becomes load-bearing. | |
99
+ | Single canned example hardcoded | One CANNED_EXAMPLE dict. Can't exercise ENV-06 difficulty switching. | |
100
+
101
+ **User's choice:** Hand-written 3-example JSONL fixture
102
+
103
+ ### Q2 — How does `difficulty` behave in Phase 0 (ENV-06)?
104
+
105
+ | Option | Description | Selected |
106
+ |--------|-------------|----------|
107
+ | Maps to fixture row selection | trivial/easy→row 0, medium→row 1, hard→row 2 (with warning). Plumbs ENV-06 end-to-end. | ✓ |
108
+ | Accepted-but-ignored, wired in Phase 1 | ENV-06 verification deferred. | |
109
+ | Raise on difficulty != "easy" | Forces Phase 1 to loosen. Interface churn. | |
110
+
111
+ **User's choice:** Maps to fixture row selection
112
+
113
+ ### Q3 — Where does the fixture live and how is it loaded?
114
+
115
+ | Option | Description | Selected |
116
+ |--------|-------------|----------|
117
+ | `env/data/phase0_fixture.jsonl`, loaded lazily on first reset() | Inside env package so Docker includes it. Server starts <1s. Phase 1 replaces path via FATHOM_DATASET_PATH env var. | ✓ |
118
+ | Same path, loaded at app startup | Fails fast on malformed fixture but slows startup. | |
119
+ | Top-level `data/phase0_fixture.jsonl` via env var | Matches where Phase 1 data lives but data/ doesn't exist as Phase 0 boundary. | |
120
+
121
+ **User's choice:** env/data/phase0_fixture.jsonl, lazy load
122
+
123
+ ---
124
+
125
+ ## REPL stdlib whitelist
126
+
127
+ ### Q1 — Which REPL stdlib whitelist for Phase 0?
128
+
129
+ | Option | Description | Selected |
130
+ |--------|-------------|----------|
131
+ | STACK §7 list verbatim | re, json, math, statistics, collections, itertools, string, functools, operator. Matches CLAUDE.md. | ✓ |
132
+ | Extended text-processing list | + textwrap, unicodedata, difflib, heapq, bisect, hashlib, base64, datetime. +3h sandbox testing. | |
133
+ | Narrow ARCHITECTURE §1 list | Only re, json, math. Hurts untrained baseline. | |
134
+
135
+ **User's choice:** STACK §7 list verbatim
136
+
137
+ ### Q2 — How are the 7 named adversarial tests structured?
138
+
139
+ | Option | Description | Selected |
140
+ |--------|-------------|----------|
141
+ | One file, one test per attack | `tests/test_repl_sandbox.py` with 7 named test_* functions. Matches ROADMAP exit gate literal. | ✓ |
142
+ | Split across three files | More organized but breaks ROADMAP success-criteria literal. | |
143
+ | One parametrized test with 7 payloads | Concise but lumps failures. | |
144
+
145
+ **User's choice:** One file, one test per attack
146
+
147
+ ### Q3 — What happens when the 7 adversarial tests pass?
148
+
149
+ | Option | Description | Selected |
150
+ |--------|-------------|----------|
151
+ | Write `.planning/SANDBOX_CERT.md` | Auditable R3/H3 gate artifact with pytest output + commit SHA. Phase 1 reads as precondition. | ✓ |
152
+ | Green CI badge in README | No CI yet. | |
153
+ | Just rely on pytest exit code | No durable artifact. | |
154
+
155
+ **User's choice:** Write SANDBOX_CERT.md
156
+
157
+ ---
158
+
159
+ ## Claude's Discretion
160
+
161
+ - Exact `pyproject.toml` structure (PEP 621, build backend)
162
+ - FastAPI lifecycle wiring (lifespan vs startup events)
163
+ - Pydantic validator strictness and error messages
164
+ - Env server log format/level
165
+ - Regex-echo stub's keyword-selection heuristic (any deterministic one)
166
+ - Exact ulimit values within STACK §7 ranges
167
+ - subprocess.Popen vs subprocess.run — whichever cleanly passes 7 adversarial tests
168
+
169
+ ## Deferred Ideas
170
+
171
+ - Health-check / metrics endpoint (Phase 1 HF Space task)
172
+ - Extended stdlib whitelist (revisit if Phase 2 baseline is expressiveness-bound)
173
+ - Subprocess pool / warm reuse (Phase 2 performance tuning)
174
+ - `GET /state` telemetry snapshot (Phase 2 MON-* territory)
175
+ - Real-world document corpora in fixture (v1 Out-of-Scope)
.planning/phases/01-fri-parallel-build/01-01-PLAN.md ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ phase: 01-fri-parallel-build
3
+ plan: 01
4
+ type: execute
5
+ wave: 1
6
+ depends_on: []
7
+ files_modified:
8
+ - configs/config.yaml
9
+ - configs/model/qwen_1_5b.yaml
10
+ - configs/model/qwen_0_5b_smoke.yaml
11
+ - configs/train/sft.yaml
12
+ - configs/train/grpo.yaml
13
+ - configs/data/v1.yaml
14
+ - configs/reward/v1.yaml
15
+ - train/__init__.py
16
+ - train/model_load.py
17
+ - tests/test_model_load.py
18
+ - pyproject.toml
19
+ autonomous: true
20
+ requirements:
21
+ - TRN-01
22
+ - TRN-10
23
+
24
+ must_haves:
25
+ truths:
26
+ - "All Hydra YAML configs in configs/ load without error"
27
+ - "unsloth/Qwen2.5-Coder-1.5B-Instruct-bnb-4bit loads with LoRA (rank 16, alpha 32, all-linear) on GPU"
28
+ - "Qwen2.5-Coder-0.5B smoke variant loads via configs/model/qwen_0_5b_smoke.yaml"
29
+ - "hydra.compose returns a complete OmegaConf DictConfig with model/train/data/reward keys"
30
+ artifacts:
31
+ - path: configs/config.yaml
32
+ provides: top-level Hydra defaults list
33
+ contains: "defaults:"
34
+ - path: configs/model/qwen_1_5b.yaml
35
+ provides: 1.5B model spec
36
+ contains: "unsloth/Qwen2.5-Coder-1.5B-Instruct-bnb-4bit"
37
+ - path: configs/model/qwen_0_5b_smoke.yaml
38
+ provides: 0.5B laptop smoke model spec
39
+ contains: "Qwen2.5-Coder-0.5B"
40
+ - path: configs/train/grpo.yaml
41
+ provides: GRPOConfig values per STACK §4
42
+ contains: "beta: 0.04"
43
+ - path: configs/train/sft.yaml
44
+ provides: SFTConfig values per STACK §9
45
+ contains: "learning_rate: 2.0e-4"
46
+ - path: train/model_load.py
47
+ provides: load_model_and_tokenizer(cfg) -> (model, tokenizer)
48
+ exports: ["load_model_and_tokenizer"]
49
+ - path: tests/test_model_load.py
50
+ provides: unit test proving config composition + model load signature
51
+ key_links:
52
+ - from: train/model_load.py
53
+ to: configs/model/*.yaml
54
+ via: OmegaConf DictConfig field access (cfg.model.name, cfg.model.lora_rank)
55
+ pattern: "cfg\\.model\\.(name|lora_rank|lora_alpha|max_seq_length|load_in_4bit)"
56
+ - from: train/model_load.py
57
+ to: unsloth.FastLanguageModel
58
+ via: FastLanguageModel.from_pretrained + get_peft_model
59
+ pattern: "FastLanguageModel\\.from_pretrained|get_peft_model"
60
+
61
+ ---
62
+
63
+ <objective>
64
+ Establish the Hydra configuration tree and the single `train/model_load.py` helper that both SFT (Plan 02) and GRPO (Plan 03) will call. Freezes TRN-01 (base model + LoRA spec) and TRN-10 (Hydra-composable configs) so Track A tasks downstream can import a consistent `(model, tokenizer)` pair without duplicating Unsloth boilerplate.
65
+
66
+ Purpose: Makes the rest of Track A parallelizable. Once configs + model_load are committed, Plan 02 (SFT) and Plan 03 (GRPO) do not collide on files.
67
+ Output: `configs/` directory tree + `train/model_load.py` + one passing unit test.
68
+ </objective>
69
+
70
+ <execution_context>
71
+ @$HOME/.claude/get-shit-done/workflows/execute-plan.md
72
+ @$HOME/.claude/get-shit-done/templates/summary.md
73
+ </execution_context>
74
+
75
+ <context>
76
+ @CLAUDE.md
77
+ @.planning/STATE.md
78
+ @.planning/ROADMAP.md
79
+ @.planning/REQUIREMENTS.md
80
+ @.planning/research/STACK.md
81
+ @.planning/research/ARCHITECTURE.md
82
+ @pyproject.toml
83
+
84
+ <interfaces>
85
+ <!-- Contracts downstream Track A plans will call against. Commit these verbatim. -->
86
+
87
+ train/model_load.py must export exactly this signature:
88
+
89
+ ```python
90
+ from omegaconf import DictConfig
91
+ from typing import Tuple
92
+
93
+ def load_model_and_tokenizer(cfg: DictConfig) -> Tuple["FastLanguageModel", "Tokenizer"]:
94
+ """
95
+ Args:
96
+ cfg: OmegaConf DictConfig with cfg.model.{name, lora_rank, lora_alpha,
97
+ max_seq_length, load_in_4bit, target_modules}
98
+ Returns:
99
+ (model, tokenizer) — model is Unsloth-patched PeftModel, tokenizer has chat_template.
100
+ """
101
+ ```
102
+
103
+ Config shape (configs/config.yaml defaults group):
104
+
105
+ ```yaml
106
+ defaults:
107
+ - model: qwen_1_5b # OVERRIDE: model=qwen_0_5b_smoke for laptop
108
+ - train: grpo # OVERRIDE: train=sft
109
+ - data: v1
110
+ - reward: v1
111
+ - _self_
112
+
113
+ seed: 42
114
+ output_dir: outputs
115
+ ```
116
+ </interfaces>
117
+ </context>
118
+
119
+ <threat_model>
120
+ ## Trust Boundaries
121
+
122
+ | Boundary | Description |
123
+ |----------|-------------|
124
+ | host → HF Hub | model weights download (HTTP, TLS, revocable token) |
125
+ | host → OS | Unsloth compiles CUDA kernels at import (binary unpack into ~/.cache) |
126
+
127
+ ## STRIDE Threat Register (ASVS L1)
128
+
129
+ | Threat ID | Category | Component | Disposition | Mitigation Plan |
130
+ |-----------|----------|-----------|-------------|-----------------|
131
+ | T-01-01 | Tampering | configs/*.yaml | mitigate | `extra="forbid"`-style strict schema via `schema=True` in hydra.compose; reject unknown keys in load_model_and_tokenizer |
132
+ | T-01-02 | Information Disclosure | HF token in env | mitigate | Never log `os.environ["HF_TOKEN"]`; rely on huggingface_hub cached login; `git grep -r HF_TOKEN configs/` must be empty |
133
+ | T-01-03 | Elevation of Privilege | Model weights from HF | accept | Base model is a first-party Unsloth repo pre-quantized; trust chain = HF Hub TLS + repo verification. Low-risk for hackathon scope. |
134
+ </threat_model>
135
+
136
+ <tasks>
137
+
138
+ <task type="auto" tdd="true">
139
+ <name>Task 1: Write Hydra configs + extend pyproject with train extras (TRN-10)</name>
140
+ <files>configs/config.yaml, configs/model/qwen_1_5b.yaml, configs/model/qwen_0_5b_smoke.yaml, configs/train/sft.yaml, configs/train/grpo.yaml, configs/data/v1.yaml, configs/reward/v1.yaml, pyproject.toml</files>
141
+ <read_first>
142
+ - pyproject.toml (know current deps + optional groups)
143
+ - .planning/research/STACK.md (§4 GRPOConfig values, §9 SFT values, §3.1 Qwen 1.5B HF IDs)
144
+ - .planning/research/ARCHITECTURE.md (§2 Directory tree lines 120-123 for configs/ layout, §Pattern 2 for Hydra override example)
145
+ - CLAUDE.md (project hyperparam constraints: beta=0.04, lr=5e-6, max_grad_norm=0.5, max_prompt_length=4096, max_completion_length=2048, num_generations=8, optim=adamw_8bit, bf16=True)
146
+ </read_first>
147
+ <behavior>
148
+ - configs/config.yaml: defaults list picks model/qwen_1_5b + train/grpo + data/v1 + reward/v1; top-level keys seed=42, output_dir=outputs
149
+ - configs/model/qwen_1_5b.yaml: name="unsloth/Qwen2.5-Coder-1.5B-Instruct-bnb-4bit", lora_rank=16, lora_alpha=32, target_modules="all-linear", max_seq_length=16384, load_in_4bit=true
150
+ - configs/model/qwen_0_5b_smoke.yaml: name="unsloth/Qwen2.5-Coder-0.5B-Instruct-bnb-4bit", lora_rank=8, lora_alpha=16, target_modules="all-linear", max_seq_length=4096, load_in_4bit=true
151
+ - configs/train/grpo.yaml: num_generations=8, beta=0.04, learning_rate=5.0e-6, max_grad_norm=0.5, bf16=true, max_prompt_length=4096, max_completion_length=2048, optim="adamw_8bit", max_steps=400, save_steps=50, vllm_mode="colocate", vllm_gpu_memory_utilization=0.45
152
+ - configs/train/sft.yaml: learning_rate=2.0e-4, num_train_epochs=1, max_seq_length=8192, per_device_train_batch_size=2, gradient_accumulation_steps=4, optim="adamw_8bit", bf16=true, save_strategy="epoch"
153
+ - configs/data/v1.yaml: train_path="data/train.jsonl", eval_path="data/eval.jsonl", sft_traces_path="data/sft_traces.jsonl", mix={niah: 0.4, multi_needle: 0.3, extractive: 0.2, counting: 0.1}
154
+ - configs/reward/v1.yaml: alpha=0.2, weights={correctness: 0.75, token_budget: 0.2, recursion_efficiency: 0.05}, token_budget_variant="capped_linear", answer_regex="<answer>(.*?)</answer>"
155
+ - pyproject.toml: add "hydra-core>=1.3,<2.0" and "omegaconf>=2.3,<3.0" to `[train]` extra; no version changes to existing pins
156
+ </behavior>
157
+ <action>
158
+ 1. Edit `pyproject.toml`: inside `[project.optional-dependencies]` under `train = [...]` append `"hydra-core>=1.3,<2.0"` and `"omegaconf>=2.3,<3.0"` at the end of the list. Do NOT touch other extras or base deps.
159
+ 2. Create directory tree: `configs/`, `configs/model/`, `configs/train/`, `configs/data/`, `configs/reward/`.
160
+ 3. Write `configs/config.yaml` with Hydra defaults list (model=qwen_1_5b, train=grpo, data=v1, reward=v1, then `_self_`), plus `seed: 42` and `output_dir: outputs`. Include a top-level header comment: `# TRN-10: Hydra-composable FATHOM config tree. Invoke: python -m train.grpo model=qwen_1_5b train=grpo data=v1 reward=v1`.
161
+ 4. Write each YAML leaf with the exact values listed in `<behavior>` above. Use lowercase booleans (`true`/`false`) and explicit floats with `.0` (`5.0e-6`, not `5e-6` — OmegaConf types are picky).
162
+ 5. For `configs/train/grpo.yaml`, add inline comments tagging each value with its STACK source: `beta: 0.04 # STACK §4 + PITFALLS H1 KL floor`, `learning_rate: 5.0e-6 # STACK §4 + PITFALLS M1 4-bit safe band`, `max_grad_norm: 0.5 # STACK §4 conservative`.
163
+ 6. For `configs/model/qwen_1_5b.yaml` add comment: `# TRN-01: base model + LoRA spec. HF ID verified via STACK §3.1 table.`
164
+ 7. For `configs/model/qwen_0_5b_smoke.yaml` add comment: `# TRN-04: laptop smoke test variant. Invoke via: model=qwen_0_5b_smoke train=grpo`.
165
+ </action>
166
+ <verify>
167
+ <automated>python -c "import yaml; [yaml.safe_load(open(f)) for f in ['configs/config.yaml','configs/model/qwen_1_5b.yaml','configs/model/qwen_0_5b_smoke.yaml','configs/train/sft.yaml','configs/train/grpo.yaml','configs/data/v1.yaml','configs/reward/v1.yaml']]; print('all 7 configs parse')"</automated>
168
+ </verify>
169
+ <acceptance_criteria>
170
+ - `ls configs/config.yaml configs/model/qwen_1_5b.yaml configs/model/qwen_0_5b_smoke.yaml configs/train/sft.yaml configs/train/grpo.yaml configs/data/v1.yaml configs/reward/v1.yaml` — all 7 files present
171
+ - `grep -q "unsloth/Qwen2.5-Coder-1.5B-Instruct-bnb-4bit" configs/model/qwen_1_5b.yaml` — passes
172
+ - `grep -q "unsloth/Qwen2.5-Coder-0.5B-Instruct-bnb-4bit" configs/model/qwen_0_5b_smoke.yaml` — passes
173
+ - `grep -q "beta: 0.04" configs/train/grpo.yaml` — passes
174
+ - `grep -q "learning_rate: 5.0e-6" configs/train/grpo.yaml` — passes
175
+ - `grep -q "num_generations: 8" configs/train/grpo.yaml` — passes
176
+ - `grep -q "vllm_mode: colocate" configs/train/grpo.yaml` — passes
177
+ - `grep -q "max_prompt_length: 4096" configs/train/grpo.yaml` — passes
178
+ - `grep -q "max_completion_length: 2048" configs/train/grpo.yaml` — passes
179
+ - `grep -q "learning_rate: 2.0e-4" configs/train/sft.yaml` — passes
180
+ - `grep -q "alpha: 0.2" configs/reward/v1.yaml` — passes
181
+ - `grep -q "hydra-core" pyproject.toml && grep -q "omegaconf" pyproject.toml` — passes
182
+ - `grep -q "lora_rank: 16" configs/model/qwen_1_5b.yaml` — passes
183
+ - `grep -q "lora_alpha: 32" configs/model/qwen_1_5b.yaml` — passes
184
+ - YAML parse command in `<verify>` prints `all 7 configs parse` with exit 0
185
+ </acceptance_criteria>
186
+ <done>All 7 config files parse as valid YAML; pyproject.toml has hydra-core and omegaconf in [train] extra; grep assertions above all pass.</done>
187
+ </task>
188
+
189
+ <task type="auto" tdd="true">
190
+ <name>Task 2: Implement train/model_load.py + unit test (TRN-01)</name>
191
+ <files>train/__init__.py, train/model_load.py, tests/test_model_load.py</files>
192
+ <read_first>
193
+ - configs/model/qwen_1_5b.yaml (Task 1 output — the DictConfig shape this function consumes)
194
+ - configs/model/qwen_0_5b_smoke.yaml (Task 1 output — smoke variant)
195
+ - configs/config.yaml (Task 1 output — top-level defaults)
196
+ - .planning/research/STACK.md (§3.1 Qwen IDs, §3.3 chat template rule: "Use Qwen2.5-Coder-1.5B-Instruct (has chat template baked in), never the non-Instruct base")
197
+ - CLAUDE.md (Technology Stack §3.1 — Instruct variant is mandatory)
198
+ </read_first>
199
+ <behavior>
200
+ - `load_model_and_tokenizer(cfg)` returns `(model, tokenizer)` where model is an Unsloth FastLanguageModel-patched PeftModel with LoRA attached per cfg.model.{lora_rank, lora_alpha, target_modules}
201
+ - Function reads ONLY these cfg keys: `cfg.model.name`, `cfg.model.lora_rank`, `cfg.model.lora_alpha`, `cfg.model.target_modules`, `cfg.model.max_seq_length`, `cfg.model.load_in_4bit`, `cfg.seed`
202
+ - If `cfg.model.name` does NOT contain "Instruct", raise `ValueError("TRN-01 gate: base model must be Instruct variant (chat template required); got {name}")`
203
+ - Unit test uses `hydra.compose(config_name="config")` to build a real DictConfig, asserts the gate rejects "Qwen2.5-Coder-1.5B" (non-instruct) with ValueError, asserts gate accepts "unsloth/Qwen2.5-Coder-1.5B-Instruct-bnb-4bit" (no raise when mocked)
204
+ - No actual HuggingFace download happens in the unit test — `FastLanguageModel.from_pretrained` is monkeypatched to return `(MagicMock(), MagicMock())` so the test runs on the Windows laptop in <2s
205
+ </behavior>
206
+ <action>
207
+ 1. Create `train/__init__.py` with a single line: `"""FATHOM training package — SFT + GRPO pipelines."""`
208
+ 2. Create `train/model_load.py` containing:
209
+ - Module docstring citing TRN-01 + STACK §3.1/§3.3 (Instruct variant rule)
210
+ - Imports: `from __future__ import annotations`, `import logging`, `from omegaconf import DictConfig`
211
+ - Lazy import of unsloth inside the function body (Unsloth import is expensive + CUDA-side-effectful):
212
+ ```python
213
+ def load_model_and_tokenizer(cfg: DictConfig):
214
+ from unsloth import FastLanguageModel
215
+ # TRN-01 Instruct-variant gate (STACK §3.3)
216
+ name = cfg.model.name
217
+ if "Instruct" not in name:
218
+ raise ValueError(
219
+ f"TRN-01 gate: base model must be Instruct variant "
220
+ f"(chat template required); got {name}"
221
+ )
222
+ model, tokenizer = FastLanguageModel.from_pretrained(
223
+ model_name=name,
224
+ max_seq_length=int(cfg.model.max_seq_length),
225
+ load_in_4bit=bool(cfg.model.load_in_4bit),
226
+ dtype=None,
227
+ )
228
+ model = FastLanguageModel.get_peft_model(
229
+ model,
230
+ r=int(cfg.model.lora_rank),
231
+ lora_alpha=int(cfg.model.lora_alpha),
232
+ target_modules=cfg.model.target_modules, # "all-linear" string works
233
+ lora_dropout=0.0,
234
+ bias="none",
235
+ use_gradient_checkpointing="unsloth",
236
+ random_state=int(cfg.seed),
237
+ )
238
+ logging.getLogger("fathom.train").info(
239
+ "TRN-01 loaded: %s lora_rank=%d alpha=%d 4bit=%s",
240
+ name, cfg.model.lora_rank, cfg.model.lora_alpha, cfg.model.load_in_4bit,
241
+ )
242
+ return model, tokenizer
243
+
244
+ __all__ = ["load_model_and_tokenizer"]
245
+ ```
246
+ 3. Create `tests/test_model_load.py`:
247
+ - Imports: `import pytest`, `from unittest.mock import patch, MagicMock`, `from hydra import initialize, compose`, `from omegaconf import OmegaConf`
248
+ - One test `test_instruct_gate_rejects_non_instruct_variant`: compose base config, override `model.name` to `"unsloth/Qwen2.5-Coder-1.5B"` (no Instruct), assert `load_model_and_tokenizer` raises `ValueError` whose message contains `"TRN-01 gate"` and `"Instruct variant"`. DO NOT patch Unsloth in this test — the gate must fire before any import.
249
+ - One test `test_load_model_and_tokenizer_signature_smoke`: compose base config, patch `unsloth.FastLanguageModel.from_pretrained` → `(MagicMock(name="model"), MagicMock(name="tok"))` and patch `FastLanguageModel.get_peft_model` → `MagicMock(name="peft_model")`; call `load_model_and_tokenizer(cfg)`, assert returns a 2-tuple and `from_pretrained` was called once with `model_name="unsloth/Qwen2.5-Coder-1.5B-Instruct-bnb-4bit"`, `load_in_4bit=True`, `max_seq_length=16384`.
250
+ - One test `test_smoke_config_loads_0_5b`: compose with `overrides=["model=qwen_0_5b_smoke"]`; assert `cfg.model.name == "unsloth/Qwen2.5-Coder-0.5B-Instruct-bnb-4bit"`, `cfg.model.max_seq_length == 4096`.
251
+ - Use `with initialize(config_path="../configs", version_base="1.3"):` as the compose context manager.
252
+ </action>
253
+ <verify>
254
+ <automated>python -m pytest tests/test_model_load.py -v --tb=short</automated>
255
+ </verify>
256
+ <acceptance_criteria>
257
+ - `ls train/__init__.py train/model_load.py tests/test_model_load.py` — all present
258
+ - `grep -q "def load_model_and_tokenizer" train/model_load.py` — passes
259
+ - `grep -q "TRN-01 gate" train/model_load.py` — passes
260
+ - `grep -q "FastLanguageModel.from_pretrained" train/model_load.py` — passes
261
+ - `grep -q "get_peft_model" train/model_load.py` — passes
262
+ - `grep -q "__all__ = \[\"load_model_and_tokenizer\"\]" train/model_load.py` — passes
263
+ - `python -c "import ast; ast.parse(open('train/model_load.py').read())"` — exits 0
264
+ - pytest command in `<verify>` reports `3 passed` with exit 0
265
+ - `grep -c "def test_" tests/test_model_load.py` prints `3`
266
+ </acceptance_criteria>
267
+ <done>3 unit tests pass; the Instruct-variant gate is enforced at function entry; model_load.py is a pure function of cfg with no hardcoded strings.</done>
268
+ </task>
269
+
270
+ </tasks>
271
+
272
+ <verification>
273
+ - Run `python -c "from hydra import initialize, compose; from omegaconf import OmegaConf; import sys; sys.path.insert(0, '.'); import os; os.chdir('.')"` then the actual compose test inside `tests/test_model_load.py::test_smoke_config_loads_0_5b` — this is the proof that the whole config tree resolves end-to-end.
274
+ - `python -m pytest tests/test_model_load.py -v` — all 3 tests green.
275
+ - `grep -rn "Qwen2.5-Coder-1.5B-Instruct-bnb-4bit" configs/ train/` — must appear at least once (in `configs/model/qwen_1_5b.yaml`); gates TRN-01 HF ID correctness.
276
+ - `grep -rn "pip install -U" configs/ train/` — must return empty (STACK §10.5 anti-pattern enforcement).
277
+ </verification>
278
+
279
+ <success_criteria>
280
+ TRN-01 + TRN-10 both satisfied: (1) `configs/` tree is the single source of truth for model/train/data/reward hyperparams with 0 hardcoded values duplicated elsewhere in `train/`; (2) `train/model_load.py` compiles, has the exact `load_model_and_tokenizer(cfg) -> (model, tokenizer)` signature, enforces the Instruct-variant gate at entry, and is covered by 3 unit tests all green without requiring a GPU or HF download.
281
+ </success_criteria>
282
+
283
+ <output>
284
+ After completion, create `.planning/phases/01-fri-parallel-build/01-01-SUMMARY.md` with: the 3 grep-verifiable asserts from `<acceptance_criteria>`, the full pytest output, and one-line attestation "TRN-01 + TRN-10 COMPLETE".
285
+ </output>
.planning/phases/01-fri-parallel-build/01-02-PLAN.md ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ phase: 01-fri-parallel-build
3
+ plan: 02
4
+ type: execute
5
+ wave: 1
6
+ depends_on: []
7
+ files_modified:
8
+ - train/sft.py
9
+ - tests/test_sft_scaffold.py
10
+ autonomous: true
11
+ requirements:
12
+ - TRN-02
13
+
14
+ must_haves:
15
+ truths:
16
+ - "train/sft.py exposes run_sft(cfg, model, tokenizer, dataset) that builds a TRL SFTTrainer from cfg.train and cfg.data"
17
+ - "formatting_func uses tokenizer.apply_chat_template as the SINGLE source of truth (STACK §3.3) — no hand-concatenated <|im_start|> strings"
18
+ - "After trainer.train() returns, adapter-only saved to {cfg.output_dir}/sft_adapter/ via model.save_pretrained (STACK §6 safe path)"
19
+ - "Optional HF Hub push is gated on cfg.hub.push=true + HF_TOKEN env (never logs token)"
20
+ - "Unit test composes a real Hydra cfg, monkeypatches SFTTrainer + save ops, and asserts formatting_func emits tokens containing `<|im_start|>` and `<|im_end|>` chatml markers"
21
+ artifacts:
22
+ - path: train/sft.py
23
+ provides: run_sft(cfg, model, tokenizer, dataset) → path to saved adapter
24
+ contains: "def run_sft"
25
+ - path: train/sft.py
26
+ provides: formatting_func applying tokenizer chat template
27
+ contains: "apply_chat_template"
28
+ - path: tests/test_sft_scaffold.py
29
+ provides: 3 unit tests for formatting + trainer config + save path
30
+ contains: "def test_"
31
+ key_links:
32
+ - from: train/sft.py
33
+ to: trl.SFTTrainer
34
+ via: SFTTrainer(model=..., args=SFTConfig(...), formatting_func=..., train_dataset=...)
35
+ pattern: "SFTTrainer\\("
36
+ - from: train/sft.py
37
+ to: configs/train/sft.yaml
38
+ via: OmegaConf field access (cfg.train.learning_rate, cfg.train.num_train_epochs, cfg.train.max_seq_length, cfg.train.per_device_train_batch_size, cfg.train.gradient_accumulation_steps, cfg.train.optim, cfg.train.bf16)
39
+ pattern: "cfg\\.train\\.(learning_rate|num_train_epochs|max_seq_length|per_device_train_batch_size|gradient_accumulation_steps|optim|bf16|save_strategy)"
40
+ - from: train/sft.py
41
+ to: train/model_load.py
42
+ via: SFTTrainer consumes the (model, tokenizer) pair produced by load_model_and_tokenizer(cfg)
43
+ pattern: "model_load|load_model_and_tokenizer"
44
+ ---
45
+
46
+ <objective>
47
+ Scaffold the SFT warm-start pipeline (TRN-02): a single `train/sft.py` module exposing `run_sft(cfg, model, tokenizer, dataset)` that wraps TRL `SFTTrainer` with a chat-template-aware `formatting_func`, reads all hyperparameters from `cfg.train` (Hydra), runs training, and saves adapter-only first to `{cfg.output_dir}/sft_adapter/` per the STACK §6 safe-save sequence. This is Hardik's Track A upstream dependency for Plan 03 (GRPO scaffold), which will load the adapter path this plan produces.
48
+ </objective>
49
+
50
+ <execution_context>
51
+ @$HOME/.claude/get-shit-done/workflows/execute-plan.md
52
+ @$HOME/.claude/get-shit-done/templates/summary.md
53
+ </execution_context>
54
+
55
+ <context>
56
+ @CLAUDE.md
57
+ @.planning/STATE.md
58
+ @.planning/ROADMAP.md
59
+ @.planning/REQUIREMENTS.md
60
+ @.planning/phases/01-fri-parallel-build/01-01-PLAN.md
61
+ @configs/config.yaml
62
+ @configs/train/sft.yaml
63
+ @configs/model/qwen_1_5b.yaml
64
+ @configs/data/v1.yaml
65
+ @train/model_load.py
66
+
67
+ <interfaces>
68
+ <!-- Contract this plan publishes to downstream Plan 03 (GRPO scaffold) and Plan 07 (smoke test) -->
69
+
70
+ train/sft.py must export exactly this signature:
71
+
72
+ ```python
73
+ from omegaconf import DictConfig
74
+ from pathlib import Path
75
+ from typing import Any
76
+
77
+ def run_sft(
78
+ cfg: DictConfig,
79
+ model: Any,
80
+ tokenizer: Any,
81
+ dataset: Any | None = None,
82
+ ) -> Path:
83
+ """
84
+ Build TRL SFTTrainer, train on cfg.data.sft_traces_path, save adapter.
85
+
86
+ Args:
87
+ cfg: composed Hydra DictConfig (reads cfg.train.*, cfg.data.sft_traces_path,
88
+ cfg.output_dir, cfg.hub.push, cfg.hub.repo_id).
89
+ model: Unsloth-patched PeftModel (from train/model_load.py).
90
+ tokenizer: HF tokenizer with chat_template set.
91
+ dataset: optional pre-loaded HF datasets.Dataset; if None, load JSONL
92
+ from cfg.data.sft_traces_path.
93
+
94
+ Returns:
95
+ Path to saved adapter dir: `{cfg.output_dir}/sft_adapter/`.
96
+ """
97
+ ```
98
+
99
+ formatting_func signature (internal, but fixed):
100
+
101
+ ```python
102
+ def _formatting_func(example: dict) -> str:
103
+ """Apply the tokenizer's chat template to example['messages'] (list of {role, content})."""
104
+ return tokenizer.apply_chat_template(example["messages"], tokenize=False, add_generation_prompt=False)
105
+ ```
106
+ </interfaces>
107
+ </context>
108
+
109
+ <threat_model>
110
+ ## Trust Boundaries
111
+
112
+ | Boundary | Description |
113
+ |----------|-------------|
114
+ | host → HF Hub | Adapter push over HTTPS; HF_TOKEN required for private push |
115
+ | sft_traces.jsonl → tokenizer | Untrusted JSON-parsed strings → tokenizer (benign: no code exec, but malformed JSON must not panic training) |
116
+
117
+ ## STRIDE Threat Register (ASVS L1)
118
+
119
+ | Threat ID | Category | Component | Disposition | Mitigation Plan |
120
+ |-----------|----------|-----------|-------------|-----------------|
121
+ | T-01-02-01 | Information Disclosure | HF_TOKEN in logs | mitigate | Never print os.environ["HF_TOKEN"]; pass token only to huggingface_hub.login() which masks; CI grep `git grep -nE 'HF_TOKEN\|hf_[A-Za-z0-9]{30,}'` must return empty |
122
+ | T-01-02-02 | Tampering | Malformed JSONL line in sft_traces | mitigate | Use datasets.load_dataset("json", data_files=...) which skips/errors on bad rows loudly; wrap load in try/except that logs row index and re-raises with context |
123
+ | T-01-02-03 | Repudiation | Silent SFT run with wrong chat template | mitigate | Log the first tokenized example's first 200 chars at INFO; diff-test against GRPO path in Plan 07 smoke (STACK §3.3 "zero token-ID drift" rule) |
124
+ | T-01-02-04 | Denial of Service | max_seq_length >> sft_traces avg length | accept | SFTConfig.packing=False (default); oversized sequences truncated by tokenizer; no DoS path |
125
+ </threat_model>
126
+
127
+ <tasks>
128
+
129
+ <task type="auto" tdd="true">
130
+ <name>Task 1: Implement train/sft.py (TRN-02)</name>
131
+ <files>train/sft.py</files>
132
+ <read_first>
133
+ - train/model_load.py (the (model, tokenizer) contract this consumes)
134
+ - configs/train/sft.yaml (exact hyperparameter names this reads via cfg.train)
135
+ - configs/data/v1.yaml (cfg.data.sft_traces_path, cfg.data.train_path)
136
+ - configs/config.yaml (cfg.output_dir, cfg.seed, cfg.hub)
137
+ - .planning/research/STACK.md §3.3 (chat template one-source-of-truth rule — DO NOT hand-concatenate <|im_start|>)
138
+ - .planning/research/STACK.md §6 (LoRA safe save sequence — adapter-only FIRST, then optional merged_16bit)
139
+ - .planning/research/STACK.md §9 (SFT snippet — formatting_func example)
140
+ - CLAUDE.md STACK §10.10 (anti-pattern: never use base -1.5B without Instruct)
141
+ </read_first>
142
+ <behavior>
143
+ - `run_sft(cfg, model, tokenizer, dataset=None)` — returns Path to saved adapter dir.
144
+ - Dataset loading: if `dataset is None`, call `datasets.load_dataset("json", data_files=str(cfg.data.sft_traces_path), split="train")`. If file missing, raise FileNotFoundError with message "TRN-02: sft_traces_path not found at {path} — run DATA-06 first".
145
+ - `_formatting_func(example)` applies `tokenizer.apply_chat_template(example["messages"], tokenize=False, add_generation_prompt=False)`. STACK §3.3 rule — DO NOT manually concatenate `<|im_start|>user\n...`.
146
+ - SFTConfig built from cfg.train: `learning_rate=float(cfg.train.learning_rate)`, `num_train_epochs=float(cfg.train.num_train_epochs)`, `max_seq_length=int(cfg.train.max_seq_length)`, `per_device_train_batch_size=int(cfg.train.per_device_train_batch_size)`, `gradient_accumulation_steps=int(cfg.train.gradient_accumulation_steps)`, `optim=str(cfg.train.optim)`, `bf16=bool(cfg.train.bf16)`, `save_strategy=str(cfg.train.save_strategy)`, `output_dir=str(Path(cfg.output_dir) / "sft_run")`, `seed=int(cfg.seed)`, `report_to=[]` (no implicit W&B in SFT; GRPO owns W&B), `logging_steps=5`, `packing=False`.
147
+ - SFTTrainer built with: `model=model, processing_class=tokenizer, args=sft_config, train_dataset=dataset, formatting_func=_formatting_func`.
148
+ - After `trainer.train()` returns, execute STACK §6 save sequence:
149
+ (1) `adapter_dir = Path(cfg.output_dir) / "sft_adapter"; model.save_pretrained(str(adapter_dir)); tokenizer.save_pretrained(str(adapter_dir))` — ALWAYS adapter-only first.
150
+ (2) If `cfg.hub.push is True` and `os.environ.get("HF_TOKEN")` is set: `model.push_to_hub(cfg.hub.repo_id, token=os.environ["HF_TOKEN"])` and `tokenizer.push_to_hub(cfg.hub.repo_id, token=os.environ["HF_TOKEN"])`. Never log the token itself — only `log.info("TRN-02 adapter pushed to %s", cfg.hub.repo_id)`.
151
+ (3) Do NOT call `save_pretrained_merged` here — merged saves are GRPO's job (Plan 03) per STACK §6.
152
+ - Log one INFO line before `trainer.train()`: `log.info("TRN-02 SFT starting: dataset_rows=%d max_seq_length=%d lr=%s", len(dataset), cfg.train.max_seq_length, cfg.train.learning_rate)`.
153
+ - Log first tokenized example preview (first 200 chars) at INFO to enable chat-template diff against GRPO path.
154
+ - Return `adapter_dir` as a `pathlib.Path`.
155
+ </behavior>
156
+ <action>
157
+ 1. Create `train/sft.py` starting with:
158
+ ```python
159
+ """FATHOM SFT warm-start — TRN-02.
160
+
161
+ Wraps TRL SFTTrainer with a chat-template-aware formatting_func and the
162
+ STACK §6 safe-save sequence (adapter-only first, then optional HF Hub push).
163
+
164
+ Single source of truth for chat formatting: tokenizer.apply_chat_template.
165
+ DO NOT hand-concatenate <|im_start|>/<|im_end|> strings (STACK §3.3).
166
+ """
167
+ from __future__ import annotations
168
+
169
+ import logging
170
+ import os
171
+ from pathlib import Path
172
+ from typing import Any
173
+
174
+ from omegaconf import DictConfig
175
+
176
+ log = logging.getLogger("fathom.train.sft")
177
+ ```
178
+ 2. Define `run_sft(cfg, model, tokenizer, dataset=None) -> Path` with the behaviour in `<behavior>`. Lazy-import heavy deps inside the function body (TRL + datasets + huggingface_hub) so `import train.sft` stays cheap for unit tests:
179
+ ```python
180
+ def run_sft(cfg: DictConfig, model: Any, tokenizer: Any, dataset: Any | None = None) -> Path:
181
+ from trl import SFTConfig, SFTTrainer # lazy import
182
+ from datasets import load_dataset # lazy import
183
+ ...
184
+ ```
185
+ 3. Define `_formatting_func` as a CLOSURE over `tokenizer` (build it inside `run_sft`, not module-level):
186
+ ```python
187
+ def _formatting_func(example):
188
+ return tokenizer.apply_chat_template(
189
+ example["messages"],
190
+ tokenize=False,
191
+ add_generation_prompt=False,
192
+ )
193
+ ```
194
+ 4. Dataset fallback load:
195
+ ```python
196
+ if dataset is None:
197
+ sft_path = Path(cfg.data.sft_traces_path)
198
+ if not sft_path.exists():
199
+ raise FileNotFoundError(
200
+ f"TRN-02: sft_traces_path not found at {sft_path} — run DATA-06 first"
201
+ )
202
+ dataset = load_dataset("json", data_files=str(sft_path), split="train")
203
+ ```
204
+ 5. Build `SFTConfig` from cfg.train with the field list in `<behavior>`. Type-cast every numeric via `int()` / `float()` (OmegaConf leaves some as OmegaConf-wrapped primitives).
205
+ 6. Instantiate `SFTTrainer(model=model, processing_class=tokenizer, args=sft_config, train_dataset=dataset, formatting_func=_formatting_func)`, then call `trainer.train()`.
206
+ 7. Execute STACK §6 save sequence (adapter-only first, optional Hub push). Export the function via `__all__ = ["run_sft"]`.
207
+ 8. Add module-level comment citing STACK §3.3 + §6 + §9, and `# TRN-02` tag on each save step.
208
+ </action>
209
+ <verify>
210
+ <automated>python -c "import ast, sys; tree = ast.parse(open('train/sft.py').read()); funcs = [n.name for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)]; assert 'run_sft' in funcs, funcs; print('run_sft found')"</automated>
211
+ </verify>
212
+ <acceptance_criteria>
213
+ - `ls train/sft.py` — file exists
214
+ - `grep -q "def run_sft" train/sft.py` — passes
215
+ - `grep -q "apply_chat_template" train/sft.py` — passes (STACK §3.3 enforcement)
216
+ - `grep -q "SFTConfig" train/sft.py && grep -q "SFTTrainer" train/sft.py` — both pass
217
+ - `grep -q "save_pretrained" train/sft.py` — passes (adapter-only save, STACK §6)
218
+ - `grep -cE "<\\|im_start\\|>|<\\|im_end\\|>" train/sft.py` prints `0` — hand-concatenation anti-pattern NOT present (STACK §3.3 enforcement)
219
+ - `grep -q "sft_traces_path" train/sft.py` — reads cfg.data.sft_traces_path
220
+ - `grep -q 'TRN-02' train/sft.py` — requirement tag present
221
+ - `python -c "import ast; ast.parse(open('train/sft.py').read())"` — exits 0
222
+ - `grep -q "HF_TOKEN" train/sft.py` — reads env (not logs) for Hub push
223
+ </acceptance_criteria>
224
+ <done>train/sft.py compiles, exposes run_sft with the contract above, uses tokenizer.apply_chat_template (never hand-concatenates chatml tags), and adapter-only save path is unconditional while Hub push is env-gated.</done>
225
+ </task>
226
+
227
+ <task type="auto" tdd="true">
228
+ <name>Task 2: Unit test for SFT scaffold (TRN-02)</name>
229
+ <files>tests/test_sft_scaffold.py</files>
230
+ <read_first>
231
+ - train/sft.py (Task 1 output — the functions under test)
232
+ - configs/train/sft.yaml (hyperparams the test must see flow through)
233
+ - configs/data/v1.yaml (sft_traces_path — test overrides this to a tmp file)
234
+ - tests/test_model_load.py (Hydra compose pattern to reuse)
235
+ </read_first>
236
+ <behavior>
237
+ - 3 unit tests. None require GPU, HF download, or the unsloth CUDA import. All complete in <3s on a Windows laptop.
238
+ - Test A `test_formatting_func_uses_chat_template`: build a real Hydra cfg; monkeypatch `trl.SFTTrainer` to a MagicMock capturing kwargs; monkeypatch `trl.SFTConfig` to a MagicMock; stub `tokenizer.apply_chat_template` to return a canonical chatml string `"<|im_start|>user\nhi<|im_end|>\n<|im_start|>assistant\nhello<|im_end|>"`; call `run_sft(cfg, model_mock, tok_mock, dataset=[{"messages": [...]}])`. Assert: SFTTrainer was called once, `formatting_func` kwarg is callable, calling it on `{"messages": [...]}` returns a string containing `<|im_start|>` AND `<|im_end|>`, and `tokenizer.apply_chat_template` was invoked with `tokenize=False, add_generation_prompt=False`.
239
+ - Test B `test_sft_config_mirrors_hydra_yaml`: inspect the SFTConfig kwargs captured by the mock. Assert `learning_rate == 2.0e-4`, `num_train_epochs == 1`, `max_seq_length == 8192`, `per_device_train_batch_size == 2`, `gradient_accumulation_steps == 4`, `optim == "adamw_8bit"`, `bf16 is True`, `seed == 42`.
240
+ - Test C `test_adapter_save_always_happens_hub_push_is_gated`: use `tmp_path` as cfg.output_dir; ensure `model.save_pretrained` was called with path ending in `sft_adapter`; with `cfg.hub.push=false`, `model.push_to_hub` was NOT called; with `cfg.hub.push=true` but `HF_TOKEN` missing from env (monkeypatched `os.environ.pop`), `push_to_hub` still NOT called.
241
+ - Tests use `hydra.initialize(config_path="../configs", version_base="1.3")` + `compose(config_name="config", overrides=["train=sft", "data.sft_traces_path=...", "output_dir=...", "hub.push=false", "hub.repo_id=test/fathom-sft"])`. If `hub` group not yet in config.yaml, add it as `+hub.push=false +hub.repo_id=test/fathom-sft`.
242
+ </behavior>
243
+ <action>
244
+ 1. Create `tests/test_sft_scaffold.py` with imports:
245
+ ```python
246
+ import os
247
+ from pathlib import Path
248
+ from unittest.mock import MagicMock, patch
249
+ import pytest
250
+ from hydra import initialize, compose
251
+ from omegaconf import OmegaConf
252
+ ```
253
+ 2. Write a `_build_cfg(tmp_path, hub_push=False)` helper that uses the Hydra compose pattern from `tests/test_model_load.py`. It must override:
254
+ - `train=sft`
255
+ - `output_dir={tmp_path}`
256
+ - `data.sft_traces_path={tmp_path}/fake_sft.jsonl`
257
+ - `+hub.push={hub_push}` (with `+` prefix for dotted-add if `hub` group not declared)
258
+ - `+hub.repo_id=test/fathom-sft`
259
+ 3. Write a `_make_fake_dataset()` helper that returns a Python list `[{"messages": [{"role":"user","content":"hi"},{"role":"assistant","content":"hello"}]}]` — the SFTTrainer will be mocked so a real HF Dataset isn't needed.
260
+ 4. Write `_make_mocks()` returning `(model_mock, tok_mock)` where `tok_mock.apply_chat_template.return_value = "<|im_start|>user\nhi<|im_end|>\n<|im_start|>assistant\nhello<|im_end|>"`.
261
+ 5. Implement Test A with `@patch("trl.SFTTrainer")` and `@patch("trl.SFTConfig")` decorators (or context managers). Capture the `formatting_func` kwarg, invoke it on the fake example, assert markers present.
262
+ 6. Implement Test B by pulling the captured SFTConfig args out of the mock (use `.call_args.kwargs`) and comparing to the Hydra-composed cfg.train values.
263
+ 7. Implement Test C by patching `os.environ` with `monkeypatch.delenv("HF_TOKEN", raising=False)`; run `run_sft` with `hub_push=True` and assert `model_mock.push_to_hub.call_count == 0` (env-gated). Also assert `model_mock.save_pretrained.call_count >= 1` and one call's first positional arg ends in `sft_adapter`.
264
+ 8. Mark none of the tests as slow; they must pass on Windows laptop CPU without GPU.
265
+ </action>
266
+ <verify>
267
+ <automated>python -m pytest tests/test_sft_scaffold.py -v --tb=short</automated>
268
+ </verify>
269
+ <acceptance_criteria>
270
+ - `ls tests/test_sft_scaffold.py` — passes
271
+ - `grep -cE "def test_" tests/test_sft_scaffold.py` prints `3`
272
+ - `grep -q "apply_chat_template" tests/test_sft_scaffold.py` — passes (Test A assertion)
273
+ - `grep -q "adapter" tests/test_sft_scaffold.py` — Test C references adapter dir
274
+ - `grep -q "HF_TOKEN" tests/test_sft_scaffold.py` — Test C env-gating check present
275
+ - pytest command reports `3 passed` with exit 0
276
+ </acceptance_criteria>
277
+ <done>3 tests green; SFT scaffold is covered by a formatting-template assertion, an SFTConfig-mirrors-YAML assertion, and an env-gated Hub-push assertion; the suite runs <3s on CPU.</done>
278
+ </task>
279
+
280
+ </tasks>
281
+
282
+ <verification>
283
+ - `python -m pytest tests/test_sft_scaffold.py -v` — 3 tests green
284
+ - `grep -rn 'apply_chat_template' train/sft.py` — at least one hit (STACK §3.3 single-source-of-truth)
285
+ - `grep -rnE '<\\|im_start\\|>' train/sft.py` — zero hits (no hand-concatenated chatml)
286
+ - `grep -rn 'save_pretrained' train/sft.py` — at least one hit (STACK §6 adapter-only save)
287
+ - `grep -rn 'pip install -U' train/sft.py configs/` — zero hits (STACK §10.5 anti-pattern)
288
+ </verification>
289
+
290
+ <success_criteria>
291
+ TRN-02 fully satisfied: (1) `train/sft.py` exposes a pure `run_sft(cfg, model, tokenizer, dataset=None) -> Path` function with no hardcoded hyperparameters (all from cfg.train); (2) chat formatting uses `tokenizer.apply_chat_template` exclusively per STACK §3.3; (3) STACK §6 adapter-only save is unconditional, Hub push is gated on both `cfg.hub.push` and `HF_TOKEN` env; (4) 3 unit tests green on CPU in <3s, covering formatting, SFTConfig wiring, and save/push gating.
292
+ </success_criteria>
293
+
294
+ <output>
295
+ After completion, create `.planning/phases/01-fri-parallel-build/01-02-SUMMARY.md` with: all acceptance-criteria greps + pytest output, the list of cfg keys this module depends on (for Plan 03 integration), and one-line attestation "TRN-02 COMPLETE — SFT scaffold ready for GRPO to consume `{cfg.output_dir}/sft_adapter/`".
296
+ </output>
.planning/phases/01-fri-parallel-build/01-03-PLAN.md ADDED
@@ -0,0 +1,301 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ phase: 01-fri-parallel-build
3
+ plan: 03
4
+ type: execute
5
+ wave: 2
6
+ depends_on: [2]
7
+ files_modified:
8
+ - train/grpo.py
9
+ - tests/test_grpo_scaffold.py
10
+ autonomous: true
11
+ requirements:
12
+ - TRN-03
13
+
14
+ must_haves:
15
+ truths:
16
+ - "train/grpo.py exposes run_grpo(cfg, model, tokenizer, reward_fn, env_url) that loads the SFT adapter from {cfg.output_dir}/sft_adapter/ (Plan 02 output) before building GRPOTrainer"
17
+ - "GRPOConfig is populated strictly from cfg.train with vllm_mode='colocate', beta=0.04, num_generations=8 (STACK §4 + PITFALLS H1/H2)"
18
+ - "GRPOTrainer is constructed with the OpenEnv environment_factory and reward_fn passed as the single entry in reward_functions list"
19
+ - "After trainer.train() returns, model.save_pretrained_merged(save_method='merged_16bit') is invoked — NEVER merged_4bit or merged_4bit_forced (STACK §6 + §10.1)"
20
+ - "Unit test mocks trl.GRPOTrainer, PeftModel.from_pretrained, and save ops; asserts GRPOConfig kwargs mirror cfg.train and that save_method == 'merged_16bit'"
21
+ artifacts:
22
+ - path: train/grpo.py
23
+ provides: run_grpo(cfg, model, tokenizer, reward_fn, env_url) → path to merged_16bit dir
24
+ contains: "def run_grpo"
25
+ - path: train/grpo.py
26
+ provides: SFT adapter load step + GRPOConfig build + merged_16bit save
27
+ contains: "merged_16bit"
28
+ - path: tests/test_grpo_scaffold.py
29
+ provides: 4 unit tests covering adapter-load, GRPOConfig wiring, reward_fn passthrough, merged_16bit save
30
+ contains: "def test_"
31
+ key_links:
32
+ - from: train/grpo.py
33
+ to: trl.GRPOTrainer
34
+ via: GRPOTrainer(model=..., processing_class=tokenizer, args=GRPOConfig(...), reward_funcs=[reward_fn], env=...)
35
+ pattern: "GRPOTrainer\\("
36
+ - from: train/grpo.py
37
+ to: configs/train/grpo.yaml
38
+ via: cfg.train.{num_generations, beta, learning_rate, max_grad_norm, bf16, max_prompt_length, max_completion_length, optim, max_steps, save_steps, vllm_mode, vllm_gpu_memory_utilization}
39
+ pattern: "cfg\\.train\\.(num_generations|beta|learning_rate|max_grad_norm|bf16|max_prompt_length|max_completion_length|optim|max_steps|save_steps|vllm_mode|vllm_gpu_memory_utilization)"
40
+ - from: train/grpo.py
41
+ to: "{cfg.output_dir}/sft_adapter/"
42
+ via: PeftModel.from_pretrained(base_model, sft_adapter_dir) before GRPOTrainer init
43
+ pattern: "sft_adapter"
44
+ ---
45
+
46
+ <objective>
47
+ Scaffold the GRPO training pipeline (TRN-03): `train/grpo.py` exposing `run_grpo(cfg, model, tokenizer, reward_fn, env_url)` that loads the SFT adapter produced by Plan 02, constructs `GRPOConfig` from `cfg.train` with the locked STACK §4 defaults (`vllm_mode="colocate"`, `beta=0.04`, `num_generations=8`), builds a `GRPOTrainer` wired to the OpenEnv environment factory, runs training, and saves the merged 16-bit model via the only safe path (`save_pretrained_merged(save_method="merged_16bit")`) per STACK §6. Downstream (Plan 07) will reuse this module's `run_grpo` + its caller will pass the composed `compose_reward_fn` from Plan 05.
48
+ </objective>
49
+
50
+ <execution_context>
51
+ @$HOME/.claude/get-shit-done/workflows/execute-plan.md
52
+ @$HOME/.claude/get-shit-done/templates/summary.md
53
+ </execution_context>
54
+
55
+ <context>
56
+ @CLAUDE.md
57
+ @.planning/STATE.md
58
+ @.planning/ROADMAP.md
59
+ @.planning/REQUIREMENTS.md
60
+ @.planning/phases/01-fri-parallel-build/01-01-PLAN.md
61
+ @.planning/phases/01-fri-parallel-build/01-02-PLAN.md
62
+ @configs/config.yaml
63
+ @configs/train/grpo.yaml
64
+ @train/model_load.py
65
+ @train/sft.py
66
+
67
+ <interfaces>
68
+ <!-- Contract downstream Plan 07 (0.5B smoke) will call against -->
69
+
70
+ train/grpo.py must export exactly this signature:
71
+
72
+ ```python
73
+ from omegaconf import DictConfig
74
+ from pathlib import Path
75
+ from typing import Any, Callable
76
+
77
+ def run_grpo(
78
+ cfg: DictConfig,
79
+ model: Any,
80
+ tokenizer: Any,
81
+ reward_fn: Callable,
82
+ env_url: str,
83
+ ) -> Path:
84
+ """
85
+ Load SFT adapter, build GRPOTrainer, train, save merged_16bit.
86
+
87
+ Args:
88
+ cfg: composed Hydra DictConfig (reads cfg.train.*, cfg.output_dir, cfg.seed, cfg.hub.*).
89
+ model: Unsloth-patched PeftModel returned by load_model_and_tokenizer(cfg).
90
+ tokenizer: HF tokenizer.
91
+ reward_fn: callable(prompts, completions, **kwargs) -> list[float]
92
+ (TRL's reward-function contract).
93
+ env_url: OpenEnv HTTP URL (http://localhost:8001 locally, HF Space URL at venue).
94
+
95
+ Returns:
96
+ Path to saved merged model dir: `{cfg.output_dir}/grpo_merged_16bit/`.
97
+ """
98
+ ```
99
+
100
+ The reward_fn this function is called with comes from Plan 05 (rewards/compose.py :: compose_reward_fn).
101
+ </interfaces>
102
+ </context>
103
+
104
+ <threat_model>
105
+ ## Trust Boundaries
106
+
107
+ | Boundary | Description |
108
+ |----------|-------------|
109
+ | host → HF Hub | Adapter checkpoints pushed every save_steps; HF_TOKEN required |
110
+ | host → env server | HTTP calls to env_url cross process boundary; env is already sandboxed (Phase 0 ENV-09) |
111
+ | model weights → disk | merged_16bit save → ~3GB file; fills disk on 16 GB ephemeral venue volumes if chained poorly |
112
+
113
+ ## STRIDE Threat Register (ASVS L1)
114
+
115
+ | Threat ID | Category | Component | Disposition | Mitigation Plan |
116
+ |-----------|----------|-----------|-------------|-----------------|
117
+ | T-01-03-01 | Tampering | Saved adapter file | mitigate | STACK §6 verbatim: adapter-only save BEFORE merged save; if merged save fails, adapter remains usable (R4 ruin-mode gate) |
118
+ | T-01-03-02 | Denial of Service | merged_4bit save path corrupt | mitigate | Hard-block: function ASSERTS save_method == "merged_16bit" before calling; catches STACK §10.1 anti-pattern if someone edits config (issues #1267 #2339 #1791) |
119
+ | T-01-03-03 | Information Disclosure | HF_TOKEN in save-sequence logs | mitigate | Same as Plan 02: read os.environ["HF_TOKEN"] directly; never log; git grep hygiene check in verification |
120
+ | T-01-03-04 | Tampering | Reward hacking via env state leak | accept | Upstream mitigation in Phase 0 ENV-08 (gold_answer sealed) + Plan 05 REW-06 audit; GRPO trainer blindly trusts reward_fn output by design |
121
+ </threat_model>
122
+
123
+ <tasks>
124
+
125
+ <task type="auto" tdd="true">
126
+ <name>Task 1: Implement train/grpo.py (TRN-03)</name>
127
+ <files>train/grpo.py</files>
128
+ <read_first>
129
+ - train/model_load.py (the (model, tokenizer) contract this consumes)
130
+ - train/sft.py (to know the adapter dir layout: `{cfg.output_dir}/sft_adapter/`)
131
+ - configs/train/grpo.yaml (exact hyperparameter names flowed through cfg.train)
132
+ - configs/config.yaml (cfg.output_dir, cfg.seed)
133
+ - .planning/research/STACK.md §4 (GRPOConfig values — beta=0.04, lr=5e-6, num_generations=8, vllm_mode=colocate, max_grad_norm=0.5)
134
+ - .planning/research/STACK.md §6 (LoRA save — merged_16bit is the ONLY safe merged save; never merged_4bit/merged_4bit_forced)
135
+ - .planning/research/STACK.md §10.1 (anti-pattern on save_method="merged_4bit")
136
+ - .planning/research/STACK.md §10.4 (vllm_mode="server" is wrong for multi-turn OpenEnv; must be "colocate")
137
+ - CLAUDE.md H2 / PITFALLS #4543 note (multi-turn TRL bug requires colocate)
138
+ </read_first>
139
+ <behavior>
140
+ - `run_grpo(cfg, model, tokenizer, reward_fn, env_url) -> Path`.
141
+ - Step 1 (SFT adapter load): `sft_adapter_dir = Path(cfg.output_dir) / "sft_adapter"`. If the dir does not exist, log a warning `"TRN-03: no SFT adapter at {path} — proceeding with base LoRA (smoke path only)"` and SKIP the load (this keeps the 0.5B smoke test (Plan 07) working even without an SFT pass). If it exists, call `peft.PeftModel.from_pretrained(model, str(sft_adapter_dir), is_trainable=True)` and reassign `model` to the returned PeftModel. Log INFO `"TRN-03 SFT adapter loaded from %s"`.
142
+ - Step 2 (GRPOConfig): build from cfg.train with every field type-cast. Required fields: `output_dir=str(Path(cfg.output_dir) / "grpo_run")`, `num_generations=int(cfg.train.num_generations)`, `beta=float(cfg.train.beta)`, `learning_rate=float(cfg.train.learning_rate)`, `max_grad_norm=float(cfg.train.max_grad_norm)`, `bf16=bool(cfg.train.bf16)`, `max_prompt_length=int(cfg.train.max_prompt_length)`, `max_completion_length=int(cfg.train.max_completion_length)`, `optim=str(cfg.train.optim)`, `max_steps=int(cfg.train.max_steps)`, `save_steps=int(cfg.train.save_steps)`, `seed=int(cfg.seed)`, `vllm_mode=str(cfg.train.vllm_mode)` (MUST be "colocate" — add explicit `assert cfg.train.vllm_mode == "colocate", "TRN-03 gate: vllm_mode must be 'colocate' for multi-turn OpenEnv (STACK §10.4)"`), `vllm_gpu_memory_utilization=float(cfg.train.vllm_gpu_memory_utilization)`, `report_to=["wandb"]`, `logging_steps=1`.
143
+ - Step 3 (GRPOTrainer + env): create the OpenEnv environment factory. Lazy-import `from envs.fathom import FathomEnv` (OpenEnv CLI installs the env as a package) — if that fails, fall back to `from env.client import FathomEnvClient` + write a small factory `def _env_factory(): return FathomEnvClient(base_url=env_url)` (Phase 0 gives us `FathomEnvClient`). TRL 1.2's OpenEnv integration accepts either an env instance or a URL-backed client per the TRL OpenEnv guide; pass what the local TRL API expects (lazy-import and branch). Build the trainer:
144
+ ```python
145
+ trainer = GRPOTrainer(
146
+ model=model,
147
+ processing_class=tokenizer,
148
+ args=grpo_config,
149
+ reward_funcs=[reward_fn],
150
+ env=env_instance_or_url,
151
+ )
152
+ ```
153
+ If `GRPOTrainer.__init__` does not accept `env=`, try `environment_url=env_url` as the fallback kwarg name (TRL 1.2 RC path). Wrap the two attempts in a try/except TypeError and log which one succeeded for the summary.
154
+ - Step 4 (train): `trainer.train()`.
155
+ - Step 5 (save — STACK §6 verbatim):
156
+ (a) Adapter-only first: `adapter_dir = Path(cfg.output_dir) / "grpo_adapter"; model.save_pretrained(str(adapter_dir)); tokenizer.save_pretrained(str(adapter_dir))`.
157
+ (b) If `cfg.hub.push` and `os.environ.get("HF_TOKEN")`, push adapter to Hub: `model.push_to_hub(cfg.hub.repo_id + "-adapter", token=os.environ["HF_TOKEN"])`.
158
+ (c) Merged save — HARD ASSERT: `save_method = "merged_16bit"; assert save_method == "merged_16bit", "STACK §10.1 anti-pattern: merged_4bit / merged_4bit_forced are corrupt under QLoRA"`. Then `merged_dir = Path(cfg.output_dir) / "grpo_merged_16bit"; model.save_pretrained_merged(str(merged_dir), tokenizer, save_method=save_method)`. If the call raises inside Docker (STACK §10.1 issue #3882), catch, log, and fall back to peft.merge_and_unload() + save_pretrained on the returned model.
159
+ - Return `merged_dir` (or `adapter_dir` if merged save fell back).
160
+ </behavior>
161
+ <action>
162
+ 1. Create `train/grpo.py` with module header:
163
+ ```python
164
+ """FATHOM GRPO training scaffold — TRN-03.
165
+
166
+ Contract:
167
+ - Consumes (model, tokenizer) from train.model_load.load_model_and_tokenizer
168
+ - Loads SFT adapter from {cfg.output_dir}/sft_adapter/ if present (Plan 02 output)
169
+ - Builds trl.GRPOTrainer with vllm_mode='colocate' (STACK §4 + §10.4 — 'server'
170
+ mode breaks multi-turn OpenEnv rollouts per TRL #4543)
171
+ - Saves adapter-only FIRST, then merged_16bit (STACK §6 + §10.1 —
172
+ NEVER merged_4bit / merged_4bit_forced: corrupt under QLoRA)
173
+ """
174
+ from __future__ import annotations
175
+
176
+ import logging
177
+ import os
178
+ from pathlib import Path
179
+ from typing import Any, Callable
180
+
181
+ from omegaconf import DictConfig
182
+
183
+ log = logging.getLogger("fathom.train.grpo")
184
+ ```
185
+ 2. Implement `run_grpo` following the 5-step behaviour above. Lazy-import trl / peft inside the function body so `import train.grpo` stays cheap.
186
+ 3. For the env-wiring branch, lazy-import with a try/except so tests can monkeypatch:
187
+ ```python
188
+ try:
189
+ from envs.fathom import FathomEnv # OpenEnv-packaged path
190
+ env_target = FathomEnv(base_url=env_url)
191
+ except Exception as e:
192
+ log.info("envs.fathom not importable (%s); falling back to FathomEnvClient", e)
193
+ from env.client import FathomEnvClient
194
+ env_target = FathomEnvClient(base_url=env_url)
195
+ ```
196
+ 4. For the GRPOTrainer kwarg handshake:
197
+ ```python
198
+ try:
199
+ trainer = GRPOTrainer(
200
+ model=model,
201
+ processing_class=tokenizer,
202
+ args=grpo_config,
203
+ reward_funcs=[reward_fn],
204
+ env=env_target,
205
+ )
206
+ log.info("TRN-03 GRPOTrainer constructed with env= kwarg")
207
+ except TypeError:
208
+ trainer = GRPOTrainer(
209
+ model=model,
210
+ processing_class=tokenizer,
211
+ args=grpo_config,
212
+ reward_funcs=[reward_fn],
213
+ environment_url=env_url,
214
+ )
215
+ log.info("TRN-03 GRPOTrainer constructed with environment_url= kwarg fallback")
216
+ ```
217
+ 5. Implement save sequence with an explicit `save_method = "merged_16bit"; assert save_method == "merged_16bit", "..."` line so a grep catches it. Do NOT use any of: `"merged_4bit"`, `"merged_4bit_forced"`. Include an inline comment `# STACK §10.1: merged_4bit / merged_4bit_forced are corrupt under QLoRA (issues #1267 #2339 #1791)`.
218
+ 6. Export via `__all__ = ["run_grpo"]`. Tag every major step with `# TRN-03 step N:` comments.
219
+ </action>
220
+ <verify>
221
+ <automated>python -c "import ast; tree = ast.parse(open('train/grpo.py').read()); funcs = [n.name for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)]; assert 'run_grpo' in funcs; print('run_grpo found')"</automated>
222
+ </verify>
223
+ <acceptance_criteria>
224
+ - `ls train/grpo.py` — passes
225
+ - `grep -q "def run_grpo" train/grpo.py` — passes
226
+ - `grep -q "merged_16bit" train/grpo.py` — passes (STACK §6 safe save)
227
+ - `grep -cE "merged_4bit(_forced)?" train/grpo.py` prints `0` or only in comment strings — anti-pattern NOT present as a save_method literal
228
+ - `grep -q "vllm_mode" train/grpo.py` — passes (cfg.train.vllm_mode read)
229
+ - `grep -q "colocate" train/grpo.py` — passes (assertion or reference)
230
+ - `grep -q "sft_adapter" train/grpo.py` — passes (Plan 02 hand-off)
231
+ - `grep -q "save_pretrained" train/grpo.py` — passes (adapter-only first)
232
+ - `grep -q "save_pretrained_merged" train/grpo.py` — passes
233
+ - `grep -q "GRPOTrainer" train/grpo.py && grep -q "GRPOConfig" train/grpo.py` — both pass
234
+ - `grep -q "reward_funcs" train/grpo.py` — passes (reward_fn passthrough)
235
+ - `grep -q 'TRN-03' train/grpo.py` — requirement tag present
236
+ - `python -c "import ast; ast.parse(open('train/grpo.py').read())"` — exits 0
237
+ </acceptance_criteria>
238
+ <done>train/grpo.py compiles, exposes run_grpo with the contract above, asserts vllm_mode='colocate' (STACK §10.4), hard-blocks merged_4bit saves (STACK §10.1), and loads the SFT adapter from Plan 02's output dir before building GRPOTrainer.</done>
239
+ </task>
240
+
241
+ <task type="auto" tdd="true">
242
+ <name>Task 2: Unit test for GRPO scaffold (TRN-03)</name>
243
+ <files>tests/test_grpo_scaffold.py</files>
244
+ <read_first>
245
+ - train/grpo.py (Task 1 output)
246
+ - configs/train/grpo.yaml (hyperparams to assert flow through)
247
+ - tests/test_sft_scaffold.py (Hydra-compose pattern to reuse)
248
+ </read_first>
249
+ <behavior>
250
+ - 4 unit tests. None require GPU, HF download, vLLM, or the env server. All complete in <3s on CPU.
251
+ - Test A `test_grpo_config_mirrors_hydra_yaml`: compose cfg, mock GRPOTrainer + GRPOConfig + peft.PeftModel.from_pretrained + env import paths; call `run_grpo(cfg, model_mock, tok_mock, reward_fn_mock, env_url="http://localhost:8001")`; assert GRPOConfig kwargs: `num_generations==8, beta==0.04, learning_rate==5.0e-6, max_grad_norm==0.5, bf16 is True, max_prompt_length==4096, max_completion_length==2048, optim=="adamw_8bit", max_steps==400, save_steps==50, vllm_mode=="colocate"`.
252
+ - Test B `test_vllm_mode_server_is_rejected`: compose cfg with `train.vllm_mode=server` override; assert `run_grpo` raises AssertionError whose message contains `"STACK §10.4"` or `"colocate"`.
253
+ - Test C `test_reward_fn_is_passed_through`: assert GRPOTrainer was called with `reward_funcs=[reward_fn_mock]` (exact 1-element list; reward_fn object identity preserved).
254
+ - Test D `test_save_method_is_merged_16bit_only`: assert `model_mock.save_pretrained_merged.call_args.kwargs.get("save_method") == "merged_16bit"` AND `model_mock.save_pretrained.call_count >= 1` (adapter-only save first per STACK §6). Additionally: use `tmp_path` as cfg.output_dir, create an empty `sft_adapter/` dir inside it, mock `peft.PeftModel.from_pretrained` to return model_mock, and assert it was called with the sft_adapter path — proves Plan 02 → Plan 03 integration point.
255
+ </behavior>
256
+ <action>
257
+ 1. Create `tests/test_grpo_scaffold.py`. Imports: `os, pytest, from pathlib import Path, from unittest.mock import MagicMock, patch, from hydra import initialize, compose`.
258
+ 2. Helper `_build_cfg(tmp_path, **overrides)` reusing `initialize(config_path="../configs", version_base="1.3")`. Default overrides: `train=grpo`, `output_dir={tmp_path}`, `+hub.push=false`, `+hub.repo_id=test/fathom-grpo`.
259
+ 3. Helper `_run_with_mocks(cfg, extra_patches=None)`:
260
+ - Patch `trl.GRPOTrainer` with a MagicMock.
261
+ - Patch `trl.GRPOConfig` with a MagicMock (so we can inspect kwargs via `GRPOConfig.call_args.kwargs`).
262
+ - Patch `peft.PeftModel.from_pretrained` to return the model_mock.
263
+ - Patch `env.client.FathomEnvClient` to return a MagicMock (env_target fallback).
264
+ - Patch the `envs.fathom` import via `sys.modules["envs.fathom"] = MagicMock(...)` wrapped in a try/except context (or by relying on the fallback — simpler).
265
+ - Provide a `model_mock` with `.save_pretrained`, `.save_pretrained_merged`, `.push_to_hub` callable mocks; a `tok_mock`; a `reward_fn_mock = MagicMock(return_value=[0.5])`.
266
+ 4. Implement each test as described. For Test B, place the assertion before the mocks that would otherwise swallow a later error — the vllm_mode check should be the first line after SFT-adapter-load in run_grpo so it fires fast.
267
+ 5. For Test D, pre-create `(tmp_path / "sft_adapter").mkdir()` so the SFT adapter branch is exercised and `PeftModel.from_pretrained` mock is called with a path whose name is `"sft_adapter"`.
268
+ 6. All tests assert on `call_args.kwargs` / `call_args.args` of the captured mocks — do not stand up a real trainer.
269
+ </action>
270
+ <verify>
271
+ <automated>python -m pytest tests/test_grpo_scaffold.py -v --tb=short</automated>
272
+ </verify>
273
+ <acceptance_criteria>
274
+ - `ls tests/test_grpo_scaffold.py` — passes
275
+ - `grep -cE "def test_" tests/test_grpo_scaffold.py` prints `4`
276
+ - `grep -q "merged_16bit" tests/test_grpo_scaffold.py` — Test D assertion present
277
+ - `grep -q "colocate" tests/test_grpo_scaffold.py` — Test B assertion present
278
+ - `grep -q "reward_funcs" tests/test_grpo_scaffold.py` — Test C assertion present
279
+ - pytest command reports `4 passed` with exit 0
280
+ </acceptance_criteria>
281
+ <done>4 tests green; GRPO scaffold is covered by GRPOConfig wiring, vllm_mode gate, reward_fn passthrough, and merged_16bit-only save assertions.</done>
282
+ </task>
283
+
284
+ </tasks>
285
+
286
+ <verification>
287
+ - `python -m pytest tests/test_grpo_scaffold.py -v` — 4 tests green
288
+ - `grep -rnE '"merged_4bit(_forced)?"' train/grpo.py` — zero hits as a string literal value (STACK §10.1 enforcement)
289
+ - `grep -rn 'save_pretrained_merged' train/grpo.py` — at least one hit (STACK §6)
290
+ - `grep -rn '"colocate"' train/grpo.py` — at least one hit (STACK §10.4)
291
+ - `grep -rn 'pip install -U' train/grpo.py configs/` — zero hits
292
+ - `python -c "import ast; ast.parse(open('train/grpo.py').read())"` — exits 0
293
+ </verification>
294
+
295
+ <success_criteria>
296
+ TRN-03 satisfied: (1) `train/grpo.py` exposes `run_grpo(cfg, model, tokenizer, reward_fn, env_url) -> Path` with no hardcoded hyperparameters; (2) STACK §4 locked values (`vllm_mode='colocate', beta=0.04, num_generations=8`) flow through cfg.train and are explicitly asserted for `vllm_mode`; (3) STACK §6 save sequence verbatim — adapter-only first, then `merged_16bit` only (never merged_4bit); (4) SFT adapter from Plan 02 is loaded from `{cfg.output_dir}/sft_adapter/` as the integration point; (5) 4 unit tests green on CPU in <3s.
297
+ </success_criteria>
298
+
299
+ <output>
300
+ After completion, create `.planning/phases/01-fri-parallel-build/01-03-SUMMARY.md` with: acceptance-criteria grep results, pytest output, the final kwarg-handshake that worked for GRPOTrainer (env= vs environment_url=), and one-line attestation "TRN-03 COMPLETE — GRPO scaffold ready for Plan 07 smoke to call run_grpo(cfg, model, tokenizer, compose_reward_fn, env_url)".
301
+ </output>
.planning/phases/01-fri-parallel-build/01-04-PLAN.md ADDED
@@ -0,0 +1,402 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ phase: 01-fri-parallel-build
3
+ plan: 04
4
+ type: execute
5
+ wave: 1
6
+ depends_on: []
7
+ files_modified:
8
+ - data/__init__.py
9
+ - data/generate.py
10
+ - data/seeds.json
11
+ - data/train.jsonl
12
+ - data/eval.jsonl
13
+ - data/sft_traces.jsonl
14
+ - tests/test_dataset.py
15
+ autonomous: true
16
+ requirements:
17
+ - DATA-01
18
+ - DATA-02
19
+ - DATA-03
20
+ - DATA-04
21
+ - DATA-05
22
+ - DATA-06
23
+
24
+ must_haves:
25
+ truths:
26
+ - "data/train.jsonl has exactly 1000 lines, 4 task types mixed NIAH 40% / multi_needle 30% / extractive 20% / counting 10% (±1% tolerance)"
27
+ - "data/eval.jsonl has exactly 200 lines, disjoint from train (no (task_type, seed) collision), same proportional mix"
28
+ - "≥5% of training set is difficulty='trivial' (DATA-04 untrained-baseline floor gate)"
29
+ - "data/sft_traces.jsonl has ≥450 entries in TRL chat format {\"messages\": [{role, content}, ...]}"
30
+ - "Every example carries required DATA-05 fields: task_id, task_type, difficulty, context_length, prompt, context, gold_answer, seed"
31
+ - "Generation is deterministic: re-running `python -m data.generate` with same seed file produces byte-identical JSONL"
32
+ - "gold_answer NEVER appears verbatim in the corresponding context field (prevents trivial shortcut exploit)"
33
+ artifacts:
34
+ - path: data/generate.py
35
+ provides: deterministic 4-task-type generator + Claude Haiku SFT-trace emitter with template fallback
36
+ contains: "def generate_all"
37
+ - path: data/seeds.json
38
+ provides: the 1000+200+500 seed triples for reproducible regeneration
39
+ contains: "train"
40
+ - path: data/train.jsonl
41
+ provides: 1000 training examples
42
+ contains: "\"task_type\""
43
+ - path: data/eval.jsonl
44
+ provides: 200 eval examples
45
+ contains: "\"task_type\""
46
+ - path: data/sft_traces.jsonl
47
+ provides: ≥450 TRL-SFT-compatible traces
48
+ contains: "\"messages\""
49
+ - path: tests/test_dataset.py
50
+ provides: 7+ unit tests for counts / mix / trivial-floor / disjoint / schema / determinism / no-leak
51
+ contains: "def test_"
52
+ key_links:
53
+ - from: data/generate.py
54
+ to: data/seeds.json
55
+ via: load seeds JSON, iterate deterministically with random.Random(seed)
56
+ pattern: "random\\.Random|seeds\\.json"
57
+ - from: data/generate.py
58
+ to: data/sft_traces.jsonl
59
+ via: Claude Haiku via anthropic SDK with ANTHROPIC_API_KEY; fall back to templates if key missing
60
+ pattern: "ANTHROPIC_API_KEY|anthropic"
61
+ - from: data/generate.py
62
+ to: configs/data/v1.yaml
63
+ via: output paths train_path / eval_path / sft_traces_path (DATA-05)
64
+ pattern: "data/train\\.jsonl|data/eval\\.jsonl|data/sft_traces\\.jsonl"
65
+ ---
66
+
67
+ <objective>
68
+ Stand up the entire FATHOM dataset pipeline (DATA-01..06) in one plan: a deterministic, seed-controlled generator `data/generate.py` that emits (a) 1000 training examples spanning 4 task types (NIAH 40%, multi-needle 30%, extractive QA 20%, counting 10%), (b) 200 disjoint eval examples, and (c) ≥450 SFT warm-start traces in TRL chat format (Claude Haiku + template fallback). Each example carries the DATA-05 schema + DATA-03 difficulty metadata; ≥5% are the 'trivial' tier that powers the untrained-baseline floor (DATA-04). Generation is byte-reproducible. This plan is Track B's foundational output and feeds Plan 02 (SFT) + Plan 07 (smoke) + Phase 2 (full training).
69
+ </objective>
70
+
71
+ <execution_context>
72
+ @$HOME/.claude/get-shit-done/workflows/execute-plan.md
73
+ @$HOME/.claude/get-shit-done/templates/summary.md
74
+ </execution_context>
75
+
76
+ <context>
77
+ @CLAUDE.md
78
+ @.planning/STATE.md
79
+ @.planning/ROADMAP.md
80
+ @.planning/REQUIREMENTS.md
81
+ @.planning/phases/01-fri-parallel-build/01-01-PLAN.md
82
+ @configs/data/v1.yaml
83
+ @env/models.py
84
+
85
+ <interfaces>
86
+ <!-- Contract this plan publishes to Plans 02 / 05 / 07 -->
87
+
88
+ data/generate.py must export:
89
+
90
+ ```python
91
+ def generate_all(
92
+ out_dir: str | Path = "data",
93
+ seeds_path: str | Path = "data/seeds.json",
94
+ train_count: int = 1000,
95
+ eval_count: int = 200,
96
+ sft_target_count: int = 500,
97
+ mix: dict[str, float] | None = None, # default {niah: 0.4, multi_needle: 0.3, extractive: 0.2, counting: 0.1}
98
+ ) -> dict: # {"train": int, "eval": int, "sft": int}
99
+ """Deterministic end-to-end generator. Writes train.jsonl + eval.jsonl + sft_traces.jsonl."""
100
+ ```
101
+
102
+ Per-example schema (DATA-05 line format, JSONL):
103
+
104
+ ```json
105
+ {
106
+ "task_id": "niah-train-0042",
107
+ "task_type": "niah", // one of: niah, multi_needle, extractive, counting
108
+ "difficulty": "trivial", // trivial | easy | medium | hard
109
+ "context_length": 4096, // one of: 4096, 16384, 65536, 204800
110
+ "needle_position": "start", // start | middle | end (ignored for counting)
111
+ "prompt": "Question: What color is the vase mentioned in the document?",
112
+ "context": "<long string, no gold_answer verbatim>",
113
+ "gold_answer": "azure", // exact-match string (correctness reward source)
114
+ "seed": 42
115
+ }
116
+ ```
117
+
118
+ SFT trace schema (data/sft_traces.jsonl, one JSONL line per trace):
119
+
120
+ ```json
121
+ {
122
+ "task_id": "trivial-sft-0007",
123
+ "messages": [
124
+ {"role": "system", "content": "You are a recursive language model with a Python REPL..."},
125
+ {"role": "user", "content": "<prompt + context preview>"},
126
+ {"role": "assistant", "content": "<python tool call or direct answer with <answer>X</answer>>"},
127
+ {"role": "tool", "content": "<repl output>"},
128
+ {"role": "assistant", "content": "<final <answer>X</answer>>"}
129
+ ]
130
+ }
131
+ ```
132
+ </interfaces>
133
+ </context>
134
+
135
+ <threat_model>
136
+ ## Trust Boundaries
137
+
138
+ | Boundary | Description |
139
+ |----------|-------------|
140
+ | host → Anthropic API | SFT-trace generation outbound HTTPS; ANTHROPIC_API_KEY from env |
141
+ | generated context ↔ reward verifier | If gold_answer leaks verbatim into context, verifier short-circuits to free score — REWARD-HACKING risk |
142
+
143
+ ## STRIDE Threat Register (ASVS L1)
144
+
145
+ | Threat ID | Category | Component | Disposition | Mitigation Plan |
146
+ |-----------|----------|-----------|-------------|-----------------|
147
+ | T-01-04-01 | Information Disclosure | ANTHROPIC_API_KEY | mitigate | Read via os.environ.get("ANTHROPIC_API_KEY") only; if missing, log "ANTHROPIC_API_KEY not set — falling back to template-only SFT traces" at INFO (no key value logged); key NEVER committed (git grep hygiene check in tests) |
148
+ | T-01-04-02 | Tampering | gold_answer verbatim in context | mitigate | Generator post-checks `assert gold_answer.lower().strip() not in context.lower()` for EVERY example; fails generation loudly before write (prevents REW-06 length-exploit shortcut) |
149
+ | T-01-04-03 | Repudiation | Non-deterministic regeneration | mitigate | All randomness goes through `random.Random(seed)` instances keyed by the seeds.json file; one unit test re-runs generation and asserts byte-identical JSONL |
150
+ | T-01-04-04 | Denial of Service | Haiku API rate-limit or quota exhaustion | mitigate | Cap Haiku traces at `min(200, remaining_budget)`; if API errors, fall through to template-only generation; NEVER block the whole pipeline on Anthropic availability |
151
+ | T-01-04-05 | Tampering | Eval / train overlap | mitigate | Unit test asserts set(eval.task_id) ∩ set(train.task_id) == ∅ AND (task_type, seed) pairs are disjoint |
152
+ </threat_model>
153
+
154
+ <tasks>
155
+
156
+ <task type="auto" tdd="true">
157
+ <name>Task 1: Implement data/generate.py + write seeds.json (DATA-01..05)</name>
158
+ <files>data/__init__.py, data/generate.py, data/seeds.json</files>
159
+ <read_first>
160
+ - configs/data/v1.yaml (output paths — must match: data/train.jsonl, data/eval.jsonl, data/sft_traces.jsonl)
161
+ - .planning/REQUIREMENTS.md DATA-01..05 (exact task-type % mix, difficulty tiers, schema keys)
162
+ - .planning/ROADMAP.md Phase 1 Success Criteria #3 (mix + trivial floor + sft count)
163
+ - env/models.py (FathomObservation context_preview field — so template-generated SFT traces stay schema-compatible)
164
+ </read_first>
165
+ <behavior>
166
+ - Four task-type generators, each taking `(rng: random.Random, context_length: int, needle_position: str, difficulty: str) -> dict` returning the DATA-05 schema dict (sans `task_id`, which is assigned by the caller from a deterministic counter).
167
+ - NIAH generator: builds a long context out of filler sentences (pull from a small built-in corpus of ~30 boilerplate sentences; tile until ≥ context_length characters); inserts ONE "the X is Y" fact at the needle_position slice; gold_answer is `Y`, prompt is `"What is the X?"`. Must post-verify Y not appearing elsewhere in the tiled filler.
168
+ - Multi-needle generator: inserts k=3 facts ("item_A costs N1", "item_B costs N2", "item_C costs N3") at 3 positions; gold_answer = `str(N1+N2+N3)`; prompt asks for the sum.
169
+ - Extractive QA generator: builds a synthetic paragraph with 3-5 sentences; one sentence contains an answerable short-span ("The 1957 treaty was signed in Rome."); gold_answer is the short span ("Rome"); prompt is a wh-question.
170
+ - Counting generator: generates a context of form "apple banana apple cherry apple ..." with K items of target type interspersed; prompt = "How many times does 'apple' appear?"; gold_answer = str(K).
171
+ - Mix enforced by proportional sampling from the cfg mix dict for each of train and eval.
172
+ - Difficulty tiers computed deterministically from (context_length, needle_position, task_type):
173
+ - trivial: context_length == 4096 AND needle_position == "start" AND task_type in {niah, extractive}
174
+ - easy: context_length ∈ {4096, 16384} AND needle_position in {"start", "middle"}
175
+ - medium: context_length == 65536 OR task_type in {multi_needle}
176
+ - hard: context_length == 204800 OR (task_type == "counting" AND context_length >= 65536)
177
+ - Seed handling: load `data/seeds.json` (an object `{"train": [seed_0, ..., seed_999], "eval": [seed_0, ..., seed_199], "sft": [seed_0, ..., seed_499]}`), derive one RNG instance per example.
178
+ - `_write_jsonl(path, rows)` writes one JSON object per line with sorted keys (for byte-reproducibility).
179
+ - After generation, `generate_all` runs a self-check pass and raises if (a) len(train) != 1000, (b) len(eval) != 200, (c) any (task_type, seed) overlap between train and eval, (d) trivial_share < 0.05, (e) any example has gold_answer verbatim in context.
180
+ </behavior>
181
+ <action>
182
+ 1. Create `data/__init__.py` with docstring `"""FATHOM dataset generation — DATA-01..06. Deterministic. See data/generate.py."""`.
183
+ 2. Create `data/seeds.json` with three keys. For train: the list `[1000, 1001, ..., 1999]`; for eval: `[5000, 5001, ..., 5199]`; for sft: `[9000, 9001, ..., 9499]`. Use a one-line Python generator to produce the file:
184
+ ```python
185
+ import json
186
+ seeds = {
187
+ "train": list(range(1000, 2000)),
188
+ "eval": list(range(5000, 5200)),
189
+ "sft": list(range(9000, 9500)),
190
+ }
191
+ json.dump(seeds, open("data/seeds.json","w"), indent=2)
192
+ ```
193
+ The disjoint seed ranges guarantee zero collision between train/eval/sft.
194
+ 3. Create `data/generate.py` with module header:
195
+ ```python
196
+ """FATHOM deterministic dataset generator — DATA-01..06.
197
+
198
+ Produces 1000 train + 200 eval examples across 4 task types plus >=450 SFT
199
+ warm-start traces for TRL SFTTrainer (chat format).
200
+
201
+ All randomness is routed through random.Random(seed) instances keyed by
202
+ data/seeds.json — re-running this script with the same seeds file MUST
203
+ produce byte-identical JSONL outputs. Enforced by tests/test_dataset.py.
204
+ """
205
+ from __future__ import annotations
206
+
207
+ import json
208
+ import logging
209
+ import os
210
+ import random
211
+ from pathlib import Path
212
+ from typing import Any
213
+
214
+ log = logging.getLogger("fathom.data")
215
+
216
+ TASK_TYPES = ("niah", "multi_needle", "extractive", "counting")
217
+ DEFAULT_MIX = {"niah": 0.4, "multi_needle": 0.3, "extractive": 0.2, "counting": 0.1}
218
+ CONTEXT_LENGTHS = (4096, 16384, 65536, 204800)
219
+ NEEDLE_POSITIONS = ("start", "middle", "end")
220
+ ```
221
+ 4. Implement the 4 task-type generator functions (`_gen_niah`, `_gen_multi_needle`, `_gen_extractive`, `_gen_counting`). Each receives `(rng, context_length, needle_position)` and returns a dict with keys `prompt, context, gold_answer`. Use a small hardcoded filler corpus (~30 neutral sentences with no proper nouns that could collide with answers). Implement an internal `_assert_no_leak(gold_answer, context)` that raises `ValueError("DATA-04 post-check: gold_answer '{ga}' appears verbatim in context")` if violated; generators MUST call it before returning.
222
+ 5. Implement `_compute_difficulty(context_length, needle_position, task_type) -> str` per the tier rules in `<behavior>`. Ensure ≥5% trivial coverage by: when sampling the mix for train, FORCE the first `max(50, 0.06 * train_count)` examples to `(task_type="niah", context_length=4096, needle_position="start")` which is the trivial tier. This guarantees the DATA-04 floor at 6% even after task-type rebalancing.
223
+ 6. Implement `generate_all(out_dir, seeds_path, train_count=1000, eval_count=200, sft_target_count=500, mix=None)`:
224
+ - Load `seeds.json`
225
+ - For each split (train, eval), iterate over the seed list, pick task_type via proportional rotation that asymptotes to `mix` exactly (maintain a running count per type and always pick the type with the largest `target - actual` gap — deterministic given seed order)
226
+ - Pick context_length and needle_position with `random.Random(seed).choice(...)` from the allowed tuples
227
+ - Override to trivial slot for the DATA-04 floor tier (first N examples)
228
+ - Build the example, compute difficulty, assign `task_id = f"{task_type}-{split}-{index:04d}"`, append `seed` key
229
+ - Write train.jsonl + eval.jsonl with `sort_keys=True, separators=(",", ":")` for byte-determinism
230
+ - Print summary: `log.info("DATA train=%d eval=%d trivial_share=%.3f", ...)`
231
+ 7. Self-check block at end of `generate_all` before returning; raises `AssertionError` with a specific message for each violation.
232
+ 8. Run `python -m data.generate` once to produce `data/train.jsonl`, `data/eval.jsonl`. The SFT traces are generated in Task 2.
233
+ </action>
234
+ <verify>
235
+ <automated>python -m data.generate && python -c "import json; t=sum(1 for _ in open('data/train.jsonl')); e=sum(1 for _ in open('data/eval.jsonl')); assert t==1000 and e==200, (t,e); print('train=%d eval=%d'%(t,e))"</automated>
236
+ </verify>
237
+ <acceptance_criteria>
238
+ - `ls data/generate.py data/seeds.json data/train.jsonl data/eval.jsonl` — all 4 present
239
+ - `wc -l data/train.jsonl` prints `1000`
240
+ - `wc -l data/eval.jsonl` prints `200`
241
+ - `grep -q "DATA-01" data/generate.py && grep -q "DATA-05" data/generate.py` — both pass (requirement tags)
242
+ - `python -c "import json; rows=[json.loads(l) for l in open('data/train.jsonl')]; keys=set(rows[0].keys()); required={'task_id','task_type','difficulty','context_length','prompt','context','gold_answer','seed'}; assert required.issubset(keys), keys; print('schema ok')"` — exits 0
243
+ - `python -c "import json; rows=[json.loads(l) for l in open('data/train.jsonl')]; share=sum(1 for r in rows if r['difficulty']=='trivial')/len(rows); assert share>=0.05, share; print('trivial_share=%.3f' % share)"` — exits 0 (DATA-04)
244
+ - `python -c "import json; t=set(json.loads(l)['task_id'] for l in open('data/train.jsonl')); e=set(json.loads(l)['task_id'] for l in open('data/eval.jsonl')); assert not (t & e); print('disjoint ok')"` — exits 0 (DATA-02)
245
+ - `python -c "import json; rows=[json.loads(l) for l in open('data/train.jsonl')]; from collections import Counter; c=Counter(r['task_type'] for r in rows); total=sum(c.values()); assert abs(c['niah']/total-0.4)<=0.01, c; assert abs(c['multi_needle']/total-0.3)<=0.01, c; assert abs(c['extractive']/total-0.2)<=0.01, c; assert abs(c['counting']/total-0.1)<=0.01, c; print(dict(c))"` — exits 0 (DATA-01 mix)
246
+ </acceptance_criteria>
247
+ <done>1000 train + 200 eval examples on disk, schema-valid, mixed to spec within ±1%, ≥5% trivial, train/eval disjoint, gold_answer never verbatim-leaked.</done>
248
+ </task>
249
+
250
+ <task type="auto" tdd="true">
251
+ <name>Task 2: SFT trace generator — Haiku + template fallback (DATA-06)</name>
252
+ <files>data/generate.py, data/sft_traces.jsonl</files>
253
+ <read_first>
254
+ - data/generate.py (Task 1 — add SFT functions to the same module)
255
+ - data/train.jsonl (Task 1 output — we reuse the trivial-tier examples to build grep-then-answer traces)
256
+ - .planning/REQUIREMENTS.md DATA-06 (~200 Haiku + ~300 template = 500; budget $15-25)
257
+ - CLAUDE.md (anthropic >=0.40 SDK available in [data] extra)
258
+ - env/server/repl.py (know the ctx / llm() primitive signature so template-generated assistant messages look realistic)
259
+ </read_first>
260
+ <behavior>
261
+ - Append functions to `data/generate.py`: `_template_sft_trace(rng, example) -> dict`, `_haiku_sft_trace(client, example) -> dict`, `generate_sft_traces(seeds, train_rows, target_count=500, api_key=None) -> list[dict]`.
262
+ - Template trace pattern (grep-then-answer, deterministic): system prompt establishes RLM role; user sends prompt + context_preview; assistant emits a Python REPL tool call like:
263
+ ```python
264
+ import re
265
+ matches = re.findall(r"the (\\w+) is (\\w+)", ctx[:4096])
266
+ print(matches[0] if matches else None)
267
+ ```
268
+ Tool message is the simulated REPL output (the pair); final assistant emits `<answer>Y</answer>` with the correct gold_answer.
269
+ - Haiku trace pattern: call `anthropic.Anthropic(api_key=api_key).messages.create(model="claude-haiku-4-5", max_tokens=1024, system=<RLM system prompt>, messages=[{"role":"user","content":<prompt+context_preview>}])`. Extract the assistant response text. Wrap into the 5-message conversation shape (system/user/assistant/tool/assistant) by running a simple REPL-output simulator on the assistant's Python code. If no Python code block, fall back to a direct-answer trace.
270
+ - Budget guard: cap Haiku calls at `int(os.environ.get("FATHOM_HAIKU_BUDGET", "200"))`. If `ANTHROPIC_API_KEY` not set, skip Haiku entirely and generate the full 500 from templates.
271
+ - Robustness: wrap Haiku calls in try/except; on any API error, log `log.warning("Haiku API error on seed %d: %s; falling back to template", seed, e)` and fall through to template generator for that seed. NEVER fail the whole run on a single API error.
272
+ - Output: list[dict] where each dict has keys `{"task_id": str, "messages": [...]}` matching TRL SFTTrainer chat-format expectations.
273
+ - Self-check: `assert len(traces) >= 450, f"DATA-06 floor: got {len(traces)} traces, need >=450"`.
274
+ - Wire into `generate_all`: after writing train/eval, call `generate_sft_traces(seeds["sft"], train_rows, target_count=sft_target_count, api_key=os.environ.get("ANTHROPIC_API_KEY"))` and write to `data/sft_traces.jsonl`.
275
+ - Include a CLI entrypoint: `if __name__ == "__main__": generate_all()`.
276
+ </behavior>
277
+ <action>
278
+ 1. Add to `data/generate.py` (after Task 1 code) the RLM system prompt constant:
279
+ ```python
280
+ _RLM_SYSTEM_PROMPT = (
281
+ "You are FATHOM, a recursive language model with a Python REPL sandbox. "
282
+ "You can read a long document via the variable `ctx` and call `llm(prompt, chunk)` "
283
+ "for sub-queries. Think step by step. Emit your final answer inside <answer>...</answer>."
284
+ )
285
+ ```
286
+ 2. Implement `_template_sft_trace(rng, example)` that picks a grep-then-answer pattern appropriate to example['task_type']:
287
+ - niah / extractive: regex grep for the key phrase → print match → final <answer>.
288
+ - multi_needle: grep for 3 patterns, print ints, sum → final <answer>.
289
+ - counting: `ctx.count("word")` → final <answer>.
290
+ Each trace returns `{"task_id": f"template-sft-{example['task_id']}", "messages": [system, user, assistant, tool, assistant_final]}`.
291
+ 3. Implement `_haiku_sft_trace(client, example)` with the anthropic SDK call. Use `model="claude-haiku-4-5"` (cheap tier), `max_tokens=1024`. Wrap in try/except; on any error return None (caller falls back to template).
292
+ 4. Implement `generate_sft_traces(seed_list, train_rows, target_count, api_key)`:
293
+ ```python
294
+ def generate_sft_traces(seed_list, train_rows, target_count=500, api_key=None):
295
+ budget = int(os.environ.get("FATHOM_HAIKU_BUDGET", "200")) if api_key else 0
296
+ client = None
297
+ if api_key:
298
+ try:
299
+ import anthropic
300
+ client = anthropic.Anthropic(api_key=api_key)
301
+ except Exception as e:
302
+ log.warning("anthropic SDK import failed: %s; template-only", e)
303
+ client = None
304
+ traces = []
305
+ trivial_rows = [r for r in train_rows if r["difficulty"] in ("trivial", "easy")]
306
+ for i, seed in enumerate(seed_list[:target_count]):
307
+ rng = random.Random(seed)
308
+ example = trivial_rows[i % len(trivial_rows)]
309
+ trace = None
310
+ if client is not None and i < budget:
311
+ trace = _haiku_sft_trace(client, example)
312
+ if trace is None:
313
+ trace = _template_sft_trace(rng, example)
314
+ traces.append(trace)
315
+ return traces
316
+ ```
317
+ 5. In `generate_all`, add:
318
+ ```python
319
+ sft_traces = generate_sft_traces(
320
+ seeds["sft"],
321
+ train_rows,
322
+ target_count=sft_target_count,
323
+ api_key=os.environ.get("ANTHROPIC_API_KEY"),
324
+ )
325
+ assert len(sft_traces) >= 450, f"DATA-06 floor: got {len(sft_traces)} traces, need >=450"
326
+ _write_jsonl(out_dir / "sft_traces.jsonl", sft_traces)
327
+ ```
328
+ 6. Ensure the CLI entry produces deterministic template-only output when `ANTHROPIC_API_KEY` is unset (required for the byte-determinism test).
329
+ 7. Run `python -m data.generate` locally without `ANTHROPIC_API_KEY` to produce `data/sft_traces.jsonl`.
330
+ </action>
331
+ <verify>
332
+ <automated>python -c "import json; rows=[json.loads(l) for l in open('data/sft_traces.jsonl')]; assert len(rows)>=450, len(rows); assert all('messages' in r and isinstance(r['messages'], list) and len(r['messages'])>=3 for r in rows); roles=set(m['role'] for r in rows for m in r['messages']); assert 'system' in roles and 'user' in roles and 'assistant' in roles, roles; print('sft ok: %d traces, roles=%s' % (len(rows), sorted(roles)))"</automated>
333
+ </verify>
334
+ <acceptance_criteria>
335
+ - `ls data/sft_traces.jsonl` — present
336
+ - `python -c "import json; print(sum(1 for _ in open('data/sft_traces.jsonl')))"` prints a number >= 450 (DATA-06 floor)
337
+ - `grep -q "DATA-06" data/generate.py` — requirement tag present
338
+ - `grep -q "ANTHROPIC_API_KEY" data/generate.py` — passes
339
+ - `grep -q "_template_sft_trace" data/generate.py && grep -q "_haiku_sft_trace" data/generate.py` — both pass
340
+ - `python -c "import json; rows=[json.loads(l) for l in open('data/sft_traces.jsonl')]; assert all(any(m['role']=='assistant' and '<answer>' in m['content'] for m in r['messages']) for r in rows), 'every trace must end with <answer>'; print('answer-marker ok')"` — exits 0
341
+ - `grep -cE 'sk-ant-[A-Za-z0-9]{30,}' data/generate.py` prints `0` (no API key committed)
342
+ </acceptance_criteria>
343
+ <done>≥450 SFT traces on disk in TRL chat format, every trace ends with `<answer>`, Haiku branch attempted only if `ANTHROPIC_API_KEY` is set, template fallback is fully deterministic.</done>
344
+ </task>
345
+
346
+ <task type="auto" tdd="true">
347
+ <name>Task 3: Dataset unit tests (DATA-01..06 regression suite)</name>
348
+ <files>tests/test_dataset.py</files>
349
+ <read_first>
350
+ - data/generate.py (Tasks 1+2 output)
351
+ - data/train.jsonl, data/eval.jsonl, data/sft_traces.jsonl (the generated artifacts)
352
+ </read_first>
353
+ <behavior>
354
+ - Tests run against the already-generated JSONL files on disk (no regeneration in test setup, to keep CI fast). Add a pytest fixture `generated_data` that asserts the three files exist and yields their parsed rows.
355
+ - 8 tests total:
356
+ 1. `test_train_count_and_eval_count` — asserts `len(train) == 1000 and len(eval) == 200`.
357
+ 2. `test_mix_ratios_within_tolerance` — asserts per-task-type share is within ±1% of {0.4, 0.3, 0.2, 0.1}.
358
+ 3. `test_trivial_floor_satisfied` — asserts `sum(r['difficulty']=='trivial' for r in train)/len(train) >= 0.05` (DATA-04).
359
+ 4. `test_train_eval_disjoint` — asserts `set(r['task_id'] for r in train) & set(r['task_id'] for r in eval) == set()` AND `(task_type, seed)` pairs also disjoint.
360
+ 5. `test_schema_keys` — every row has exactly the DATA-05 key set: `{task_id, task_type, difficulty, context_length, prompt, context, gold_answer, seed}` (plus optional `needle_position`).
361
+ 6. `test_no_gold_answer_verbatim_leak` — for every row, assert `gold_answer.lower().strip() not in context.lower()` (exploits REW-06 length/leak gate).
362
+ 7. `test_sft_traces_chat_format` — ≥450 traces, every trace has a `messages` list with ≥3 elements, at least one assistant message contains `<answer>`, and all role values ∈ {system, user, assistant, tool}.
363
+ 8. `test_generation_is_byte_deterministic` — import `generate_all`, call it with `out_dir=tmp_path, seeds_path="data/seeds.json", api_key=None` (force template-only), then call it again with same args; assert the two resulting `train.jsonl` files are byte-identical. (This test is marked `slow` and uses `tmp_path`; fine to skip on PR CI but runs on main.)
364
+ - Tests only depend on Python stdlib + pytest; no GPU, no HF, no network.
365
+ </behavior>
366
+ <action>
367
+ 1. Create `tests/test_dataset.py` with fixture + 8 tests described above.
368
+ 2. For test 8 (determinism), monkeypatch `os.environ` to remove `ANTHROPIC_API_KEY` before each call (so the SFT branch is template-only).
369
+ 3. Mark test 8 with `@pytest.mark.slow` so it can be opt-in (`pytest -m "not slow"` skips it locally).
370
+ 4. Use `hashlib.sha256` for the byte-identical check: hash each line (sorted) and compare.
371
+ </action>
372
+ <verify>
373
+ <automated>python -m pytest tests/test_dataset.py -v --tb=short -m "not slow"</automated>
374
+ </verify>
375
+ <acceptance_criteria>
376
+ - `ls tests/test_dataset.py` — passes
377
+ - `grep -cE "def test_" tests/test_dataset.py` prints a number >= `8`
378
+ - `grep -q "gold_answer" tests/test_dataset.py` — passes
379
+ - `grep -q "byte" tests/test_dataset.py` — determinism test present
380
+ - pytest command reports `7 passed` (with `slow` skipped) — exits 0
381
+ - `python -m pytest tests/test_dataset.py -v -m slow` reports `1 passed` — exits 0
382
+ </acceptance_criteria>
383
+ <done>8 tests green (7 fast + 1 slow); the test suite is the DATA-01..06 regression gate.</done>
384
+ </task>
385
+
386
+ </tasks>
387
+
388
+ <verification>
389
+ - `ls data/train.jsonl data/eval.jsonl data/sft_traces.jsonl data/seeds.json` — all 4 present
390
+ - `wc -l data/train.jsonl data/eval.jsonl data/sft_traces.jsonl` — `1000`, `200`, `>=450`
391
+ - `python -m pytest tests/test_dataset.py -v` — all 8 green
392
+ - `grep -rE 'sk-ant-[A-Za-z0-9]{30,}' data/ configs/` — zero hits (no API key committed)
393
+ - `python -c "import json; rows=[json.loads(l) for l in open('data/train.jsonl')]; from collections import Counter; print(Counter(r['task_type'] for r in rows))"` — prints a Counter matching the mix within ±10 per type
394
+ </verification>
395
+
396
+ <success_criteria>
397
+ DATA-01..06 satisfied: 1000 training examples spanning 4 task types in the exact mix (±1%); 200 disjoint eval examples; ≥5% trivial tier for the DATA-04 floor; complete DATA-05 schema on every example; ≥450 SFT traces in TRL chat format; generation is byte-reproducible with the committed seeds.json; gold_answer never appears verbatim in any context (closes a REW-06 length-exploit). 8-test regression suite pinned.
398
+ </success_criteria>
399
+
400
+ <output>
401
+ After completion, create `.planning/phases/01-fri-parallel-build/01-04-SUMMARY.md` with: all acceptance-criteria command outputs, the actual task-type distribution Counter, the trivial share, the SFT trace count (and how many came from Haiku vs templates), and one-line attestation "DATA-01..06 COMPLETE — datasets on disk, deterministic, no gold-answer leaks".
402
+ </output>
.planning/phases/01-fri-parallel-build/01-05-PLAN.md ADDED
@@ -0,0 +1,351 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ phase: 01-fri-parallel-build
3
+ plan: 05
4
+ type: execute
5
+ wave: 1
6
+ depends_on: []
7
+ files_modified:
8
+ - rewards/__init__.py
9
+ - rewards/format_gate.py
10
+ - rewards/correctness.py
11
+ - rewards/token_budget.py
12
+ - rewards/recursion_efficiency.py
13
+ - rewards/compose.py
14
+ - REWARD_AUDIT.md
15
+ - tests/test_rewards.py
16
+ autonomous: true
17
+ requirements:
18
+ - REW-01
19
+ - REW-02
20
+ - REW-03
21
+ - REW-04
22
+ - REW-05
23
+ - REW-06
24
+ - REW-08
25
+
26
+ must_haves:
27
+ truths:
28
+ - "rewards/ package exposes format_gate, correctness, token_budget, recursion_efficiency as pure functions + compose_reward_fn as the top-level entry consumed by GRPOTrainer"
29
+ - "Format-gate failure zeroes the composite — no partial credit (REW-02)"
30
+ - "Composite weights are 0.75*correctness + 0.2*token_budget + 0.05*recursion_efficiency, loaded from cfg.reward.weights"
31
+ - "token_budget is α-parameterized (α ∈ {capped_linear, capped_quadratic, uncapped}) for the Mercor Pareto sub-prize (REW-04)"
32
+ - "REWARD_AUDIT.md documents ≥5 hand-crafted reward-hacking attempts with score + fix applied (REW-05)"
33
+ - "tests/test_rewards.py has ≥30 (input, expected_reward) pairs across all 4 task types (REW-08) AND 3 pytest-marked audit tests (masked-context, format-only, no-monotonic-length) (REW-06)"
34
+ - "compose_reward_fn signature matches TRL reward-function contract: callable(prompts, completions, **kwargs) -> list[float]"
35
+ artifacts:
36
+ - path: rewards/compose.py
37
+ provides: compose_reward_fn (TRL-compatible) + single-example compose_reward (for unit tests)
38
+ contains: "def compose_reward_fn"
39
+ - path: rewards/format_gate.py
40
+ provides: format_gate(completion) -> float
41
+ contains: "def format_gate"
42
+ - path: rewards/correctness.py
43
+ provides: correctness(completion, gold_answer) -> float
44
+ contains: "def correctness"
45
+ - path: rewards/token_budget.py
46
+ provides: token_budget(completion, prompt_token_count, alpha, variant) -> float
47
+ contains: "def token_budget"
48
+ - path: rewards/recursion_efficiency.py
49
+ provides: recursion_efficiency(llm_call_count, max_calls) -> float
50
+ contains: "def recursion_efficiency"
51
+ - path: REWARD_AUDIT.md
52
+ provides: 5+ documented hacking attempts + fixes (REW-05)
53
+ contains: "## Attempt"
54
+ - path: tests/test_rewards.py
55
+ provides: ≥30 unit tests + 3 reward_audit tests
56
+ contains: "pytest.mark.reward_audit"
57
+ key_links:
58
+ - from: rewards/compose.py
59
+ to: rewards/{format_gate,correctness,token_budget,recursion_efficiency}.py
60
+ via: imports and composes via weighted sum gated by format_gate
61
+ pattern: "from rewards\\.(format_gate|correctness|token_budget|recursion_efficiency)"
62
+ - from: rewards/compose.py
63
+ to: train/grpo.py
64
+ via: compose_reward_fn passed as reward_fn argument to run_grpo
65
+ pattern: "compose_reward_fn"
66
+ - from: rewards/token_budget.py
67
+ to: configs/reward/v1.yaml
68
+ via: alpha from cfg.reward.alpha, variant from cfg.reward.token_budget_variant
69
+ pattern: "alpha|token_budget_variant"
70
+ ---
71
+
72
+ <objective>
73
+ Stand up the reward system (REW-01..06, REW-08) in one plan: 4 pure per-component reward functions + one top-level `compose_reward_fn` that implements the REW-02 `Sequential(Gate(FormatCheck), WeightedSum(...))` composition, plus the `REWARD_AUDIT.md` manual adversarial audit (REW-05) and the automated `pytest -m reward_audit` suite (REW-06) + ≥30 unit tests (REW-08). This plan is the ruin-mode gate for R3 (reward hacking) and hard-blocks Phase 2's full GRPO run — without REW-06 green, Saturday training does not start.
74
+ </objective>
75
+
76
+ <execution_context>
77
+ @$HOME/.claude/get-shit-done/workflows/execute-plan.md
78
+ @$HOME/.claude/get-shit-done/templates/summary.md
79
+ </execution_context>
80
+
81
+ <context>
82
+ @CLAUDE.md
83
+ @.planning/STATE.md
84
+ @.planning/ROADMAP.md
85
+ @.planning/REQUIREMENTS.md
86
+ @.planning/phases/01-fri-parallel-build/01-01-PLAN.md
87
+ @.planning/phases/01-fri-parallel-build/01-04-PLAN.md
88
+ @configs/reward/v1.yaml
89
+ @env/models.py
90
+
91
+ <interfaces>
92
+ <!-- TRL reward-function contract that compose_reward_fn publishes -->
93
+
94
+ TRL 1.2 GRPOTrainer expects a callable `reward_fn(prompts, completions, **kwargs) -> list[float]`
95
+ where kwargs include the dataset columns + any env-emitted extras. Our extras (populated by
96
+ the GRPO rollout loop in Plan 03 + env in Phase 0) are:
97
+ - `gold_answer: list[str]` — the per-example gold (from train.jsonl rows)
98
+ - `prompt_token_count: list[int]` — tokens in the original prompt (for token_budget normalization)
99
+ - `llm_call_count: list[int]` — number of llm() sub-calls made in the completion's rollout
100
+
101
+ Per-component signatures (all pure, all return float in [0.0, 1.0]):
102
+
103
+ ```python
104
+ def format_gate(completion: str, **_) -> float: ...
105
+ def correctness(completion: str, gold_answer: str, **_) -> float: ...
106
+ def token_budget(completion: str, prompt_token_count: int, alpha: float = 0.2,
107
+ variant: str = "capped_linear", **_) -> float: ...
108
+ def recursion_efficiency(llm_call_count: int, max_calls: int = 2, **_) -> float: ...
109
+ ```
110
+
111
+ Top-level composite:
112
+
113
+ ```python
114
+ def compose_reward_single(completion: str, gold_answer: str, prompt_token_count: int,
115
+ llm_call_count: int, cfg_reward) -> float:
116
+ """Single-example convenience — used in unit tests."""
117
+
118
+ def compose_reward_fn(prompts, completions, **kwargs) -> list[float]:
119
+ """TRL-compatible batched contract — used by GRPOTrainer."""
120
+ ```
121
+
122
+ Composition rule (REW-02):
123
+ gate = format_gate(completion)
124
+ if gate == 0.0: return 0.0
125
+ return sum(w[k] * c[k] for k in ("correctness","token_budget","recursion_efficiency"))
126
+ </interfaces>
127
+ </context>
128
+
129
+ <threat_model>
130
+ ## Trust Boundaries
131
+
132
+ | Boundary | Description |
133
+ |----------|-------------|
134
+ | model completion → verifier | Untrusted string crosses trust boundary; verifier must not eval/exec it |
135
+ | cfg.reward.weights → runtime | Config floats read once; no dynamic load |
136
+
137
+ ## STRIDE Threat Register (ASVS L1)
138
+
139
+ | Threat ID | Category | Component | Disposition | Mitigation Plan |
140
+ |-----------|----------|-----------|-------------|-----------------|
141
+ | T-01-05-01 | Tampering | Reward hacking — format-only completion | mitigate | REW-02 hard gate: format_gate returns 0→composite 0; REW-06 audit asserts format-only completions get ≤ 0.05 composite |
142
+ | T-01-05-02 | Tampering | Reward hacking — length exploit (pad to game token_budget) | mitigate | token_budget is monotonically non-increasing in length (capped_linear: `max(0, 1-α*ratio)`); REW-06 audit asserts mean reward by output-length decile is NOT monotonically increasing |
143
+ | T-01-05-03 | Tampering | Reward hacking — gold-answer echo in context leak | mitigate | Upstream mitigation in Plan 04 (gold_answer post-check) + REW-06 masked-context audit: with masked ctx, correctness ≈ baseline-random (~0) |
144
+ | T-01-05-04 | Tampering | Reward hacking — sub-call explosion (depth > 2) | mitigate | recursion_efficiency: 0.0 if llm_call_count > max_calls; audit entry documents attempt + fix |
145
+ | T-01-05-05 | Information Disclosure | Reward fn logging completion content | accept | We log only scalars (composite + 4 components) to W&B via REW-03; full completions go to trajectories/step_{N}.md with no PII (synthetic ctx only) |
146
+ | T-01-05-06 | Tampering | verifier eval()-ing model output | mitigate | All reward code uses ONLY re + str operations; no eval/exec/compile anywhere in rewards/ (grep verification) |
147
+ </threat_model>
148
+
149
+ <tasks>
150
+
151
+ <task type="auto" tdd="true">
152
+ <name>Task 1: Implement 4 component reward modules + compose (REW-01, REW-02, REW-04)</name>
153
+ <files>rewards/__init__.py, rewards/format_gate.py, rewards/correctness.py, rewards/token_budget.py, rewards/recursion_efficiency.py, rewards/compose.py</files>
154
+ <read_first>
155
+ - configs/reward/v1.yaml (exact fields: alpha, weights.{correctness,token_budget,recursion_efficiency}, token_budget_variant, answer_regex)
156
+ - .planning/REQUIREMENTS.md REW-01..04 (exact module names + composition contract)
157
+ - .planning/ROADMAP.md Phase 1 Success Criteria #2 (R3 ruin-mode gate context)
158
+ - CLAUDE.md (Mercor sub-prize: token_budget α-parameterized for Pareto chart at eval time)
159
+ </read_first>
160
+ <behavior>
161
+ - All 4 component modules are pure-Python, stdlib-only (regex + math). NO eval/exec/compile/os/subprocess anywhere in rewards/ (grep-verified).
162
+ - `rewards/format_gate.py`: compile `_ANSWER_RE = re.compile(r"<answer>(.*?)</answer>", re.IGNORECASE | re.DOTALL)` at module top (matches env/server/environment.py). `format_gate(completion, **_) -> 1.0 if _ANSWER_RE.search(completion) else 0.0`.
163
+ - `rewards/correctness.py`: `_extract_answer(completion) -> str | None` pulls the first `<answer>...</answer>` match (stripped, whitespace-normalized via `re.sub(r"\s+", " ", match).strip()`). `correctness(completion, gold_answer, **_) -> float`: if extracted is None → 0.0; else 1.0 if `_normalize(extracted) == _normalize(gold_answer)` else 0.0. `_normalize(s)` lowercases, strips, collapses whitespace, strips wrapping punctuation `". ,!?"`.
164
+ - `rewards/token_budget.py`: `token_budget(completion, prompt_token_count, alpha=0.2, variant="capped_linear", **_) -> float`. Uses a simple token estimator: `completion_tokens = max(1, len(completion.split()))`. Ratio: `r = completion_tokens / max(1, prompt_token_count)`. Variants:
165
+ - `capped_linear`: `max(0.0, 1.0 - alpha * r)` (clamped to [0, 1])
166
+ - `capped_quadratic`: `max(0.0, 1.0 - alpha * r * r)`
167
+ - `uncapped`: `max(-1.0, 1.0 - alpha * r)` (can go negative; for Pareto stress-testing)
168
+ Invalid variant → raise `ValueError(f"REW-04: unknown variant '{variant}' (expected capped_linear|capped_quadratic|uncapped)")`.
169
+ - `rewards/recursion_efficiency.py`: discrete staircase on llm_call_count: 0→1.0, 1→0.7, 2→0.4, >2→0.0. Parameter `max_calls` defaults to 2 (D-06 training constraint from env). If llm_call_count > max_calls return 0.0 regardless of staircase.
170
+ - `rewards/compose.py`: loads weights from the caller-provided cfg_reward (a DictConfig slice) — `weights.correctness`, `weights.token_budget`, `weights.recursion_efficiency`. Implements:
171
+ ```python
172
+ def compose_reward_single(completion, gold_answer, prompt_token_count, llm_call_count, cfg_reward) -> float:
173
+ gate = format_gate(completion)
174
+ if gate == 0.0:
175
+ return 0.0
176
+ c = correctness(completion, gold_answer)
177
+ t = token_budget(completion, prompt_token_count,
178
+ alpha=float(cfg_reward.alpha),
179
+ variant=str(cfg_reward.token_budget_variant))
180
+ r = recursion_efficiency(int(llm_call_count), max_calls=int(cfg_reward.get("max_calls", 2)))
181
+ w = cfg_reward.weights
182
+ return float(w.correctness) * c + float(w.token_budget) * t + float(w.recursion_efficiency) * r
183
+
184
+ def compose_reward_fn(prompts, completions, **kwargs) -> list[float]:
185
+ """TRL-compatible: prompts + completions are batched lists; kwargs carry gold_answer, prompt_token_count, llm_call_count, cfg_reward."""
186
+ cfg_reward = kwargs.pop("cfg_reward") # injected by the GRPOTrainer caller (partial application)
187
+ gold_answers = kwargs.get("gold_answer", [""] * len(completions))
188
+ ptcs = kwargs.get("prompt_token_count", [1] * len(completions))
189
+ lccs = kwargs.get("llm_call_count", [0] * len(completions))
190
+ return [
191
+ compose_reward_single(c, g, int(p), int(l), cfg_reward)
192
+ for c, g, p, l in zip(completions, gold_answers, ptcs, lccs)
193
+ ]
194
+ ```
195
+ Plus a factory `make_reward_fn(cfg_reward)` that returns a closure binding cfg_reward so the caller can do `reward_fn = make_reward_fn(cfg.reward); run_grpo(cfg, model, tok, reward_fn, env_url)`.
196
+ - `rewards/__init__.py` re-exports: `from .format_gate import format_gate`, etc., plus `from .compose import compose_reward_fn, compose_reward_single, make_reward_fn`.
197
+ </behavior>
198
+ <action>
199
+ 1. Create package dir + `rewards/__init__.py` with the re-exports and docstring tagging REW-01.
200
+ 2. Create `rewards/format_gate.py` implementing `format_gate` as described. Module header tags REW-01. Pre-compile the regex at module load.
201
+ 3. Create `rewards/correctness.py` with `_extract_answer`, `_normalize`, `correctness`. Tag REW-01.
202
+ 4. Create `rewards/token_budget.py` with `token_budget` and the 3 variants. Tag REW-01 + REW-04 (α-parameterization).
203
+ 5. Create `rewards/recursion_efficiency.py` with the staircase. Tag REW-01.
204
+ 6. Create `rewards/compose.py` with `compose_reward_single`, `compose_reward_fn`, `make_reward_fn`. Tag REW-02.
205
+ 7. Add an assertion at the top of `compose_reward_single` (debug-level): the weights sum to 1.0 ± 0.001. This is a sanity check, not a runtime gate: `assert abs(w.correctness + w.token_budget + w.recursion_efficiency - 1.0) < 1e-3, "REW-02: composite weights must sum to 1.0"`.
206
+ 8. Verify: `grep -rnE '\beval\(|\bexec\(|\bcompile\(|subprocess|import os\b' rewards/` returns nothing (threat T-01-05-06 enforcement).
207
+ </action>
208
+ <verify>
209
+ <automated>python -c "from rewards import format_gate, correctness, token_budget, recursion_efficiency, compose_reward_single; assert format_gate('<answer>x</answer>') == 1.0; assert format_gate('nope') == 0.0; assert correctness('<answer> Rome </answer>', 'rome') == 1.0; assert correctness('<answer>Paris</answer>', 'Rome') == 0.0; assert 0 <= token_budget('a b c d', 100, 0.2) <= 1.0; assert recursion_efficiency(0) == 1.0; assert recursion_efficiency(5) == 0.0; print('component smoke ok')"</automated>
210
+ </verify>
211
+ <acceptance_criteria>
212
+ - `ls rewards/__init__.py rewards/format_gate.py rewards/correctness.py rewards/token_budget.py rewards/recursion_efficiency.py rewards/compose.py` — all 6 present
213
+ - `grep -q "def format_gate" rewards/format_gate.py` — passes
214
+ - `grep -q "def correctness" rewards/correctness.py` — passes
215
+ - `grep -q "def token_budget" rewards/token_budget.py` — passes
216
+ - `grep -q "capped_linear" rewards/token_budget.py && grep -q "capped_quadratic" rewards/token_budget.py && grep -q "uncapped" rewards/token_budget.py` — all 3 variants present (REW-04)
217
+ - `grep -q "def recursion_efficiency" rewards/recursion_efficiency.py` — passes
218
+ - `grep -q "def compose_reward_fn" rewards/compose.py && grep -q "def compose_reward_single" rewards/compose.py && grep -q "def make_reward_fn" rewards/compose.py` — all 3 present
219
+ - `grep -rnE '\\beval\\(|\\bexec\\(|\\bcompile\\(|subprocess|^import os' rewards/` — returns nothing (T-01-05-06)
220
+ - `grep -q "REW-02" rewards/compose.py && grep -q "REW-04" rewards/token_budget.py` — requirement tags present
221
+ - `python -c "import ast; [ast.parse(open(f).read()) for f in ['rewards/format_gate.py','rewards/correctness.py','rewards/token_budget.py','rewards/recursion_efficiency.py','rewards/compose.py']]; print('all parse')"` — exits 0
222
+ - Component smoke command in `<verify>` prints `component smoke ok` with exit 0
223
+ </acceptance_criteria>
224
+ <done>6 reward modules exist, pure-Python, all four components callable, compose_reward_fn matches TRL contract, no dangerous builtins referenced.</done>
225
+ </task>
226
+
227
+ <task type="auto" tdd="true">
228
+ <name>Task 2: REWARD_AUDIT.md manual adversarial audit (REW-05)</name>
229
+ <files>REWARD_AUDIT.md</files>
230
+ <read_first>
231
+ - rewards/compose.py (Task 1 output — the composite under test)
232
+ - configs/reward/v1.yaml (weights + α values used in the audit scores)
233
+ - .planning/REQUIREMENTS.md REW-05 (at minimum 5 attempts with fixes applied)
234
+ - .planning/ROADMAP.md Phase 1 Success Criteria #2 (ruin-mode gate R3)
235
+ </read_first>
236
+ <behavior>
237
+ - Markdown file at repo root documenting ≥5 manually-crafted reward-hacking attempts. For each:
238
+ 1. Attempt name + attacker intent
239
+ 2. Crafted completion string
240
+ 3. Context (gold_answer, prompt_token_count, llm_call_count used for scoring)
241
+ 4. Scored composite reward (computed by running rewards/compose.py:compose_reward_single)
242
+ 5. Diagnosis (why it scored high/low)
243
+ 6. Fix applied (pointer to the code change in Task 1 that hardens against it)
244
+ - The 5 required attempts:
245
+ - A-01: Empty answer — `"<answer></answer>"` with gold `"rome"`. Diagnosis: passes format_gate but correctness=0 → composite ≈ 0.04 (from token_budget + recursion). Fix: already gated — documented.
246
+ - A-02: Format-only with wrong answer — `"<answer>banana</answer>"` with gold `"rome"`. Diagnosis: passes gate, correctness=0, token_budget near 1.0 (short), recursion=1.0 (no calls). Composite = 0*0.75 + ~1.0*0.2 + 1.0*0.05 = 0.25. ⚠ UNEXPECTEDLY HIGH. Fix: cap max composite when correctness == 0 — add `if c == 0.0: return min(composite, 0.05)` to `compose_reward_single`. Document the post-fix score (≤ 0.05).
247
+ - A-03: Length padding — `"ok " * 5000 + "<answer>rome</answer>"` with gold `"rome"`, prompt_token_count=100. Diagnosis: correctness=1, token_budget crushed by ratio (5000/100 → capped at 0.0), recursion=1.0. Composite = 0.75 + 0 + 0.05 = 0.80 vs ideal 1.0. This is the intended shaping, not a hack — document as "working as designed".
248
+ - A-04: Sub-call explosion — completion claims 10 `llm()` sub-calls (llm_call_count=10) with gold-matching answer. Diagnosis: recursion_efficiency=0 (>max_calls). Composite = 0.75 + t_budget + 0 ≈ 0.90. Document as "working — the 0.05-weight bonus is intentionally small so correctness dominates even when depth is abused".
249
+ - A-05: Gold-answer echo in ctx — context contains the gold verbatim; model just echoes. Diagnosis: correctness=1.0, unfairly high. Fix: upstream in Plan 04 (data/generate.py's `_assert_no_leak`). Document the Plan 04 gate as the fix.
250
+ - Additional 6th attempt encouraged: whitespace exploit (`"<answer>\n\n rome\n\n</answer>"` — should match after _normalize; document that _normalize + re.sub(r"\\s+", " ", ...) handle this).
251
+ - At the end of the document, a "Fixes Summary" table listing attempt → fix location → status (FIXED | WORKING_AS_DESIGNED | UPSTREAM).
252
+ </behavior>
253
+ <action>
254
+ 1. Create `REWARD_AUDIT.md` at repo root with sections: `# REWARD_AUDIT — FATHOM (REW-05)` header, `## Methodology`, `## Attempt A-01`..`A-05` (plus optional A-06), `## Fixes Summary` table, `## Re-run Procedure` (how to reproduce via `python -c "from rewards.compose import compose_reward_single; ..."`).
255
+ 2. For each attempt, include a "Reproduce" fenced code block with the exact `compose_reward_single(...)` call that produced the score.
256
+ 3. Crucial: after writing A-02's fix (cap composite at 0.05 when correctness==0), go back and EDIT `rewards/compose.py` to add the `if c == 0.0: return min(candidate_composite, 0.05)` line. Re-run the REW-06 tests to confirm the cap holds. This is the one and only Task-1-module EDIT allowed in Task 2; all other edits belong in Task 1.
257
+ 4. Include a ruin-mode gate statement: "This audit is the REW-05 ruin-mode gate for R3 (reward hacking). If any attempt scores higher than expected, Phase 2 training does NOT start until fixed."
258
+ 5. Methodology section explains the scoring env: `gold_answer` provided per attempt, `prompt_token_count` fixed at 100 for reproducibility, `llm_call_count` per attempt, `cfg_reward` loaded from `configs/reward/v1.yaml`.
259
+ </action>
260
+ <verify>
261
+ <automated>python -c "content=open('REWARD_AUDIT.md').read(); import re; attempts=re.findall(r'## Attempt A-0[1-9]', content); assert len(attempts) >= 5, attempts; assert 'Fixes Summary' in content; assert 'ruin-mode gate' in content or 'R3' in content; print('audit structure ok: %d attempts' % len(attempts))"</automated>
262
+ </verify>
263
+ <acceptance_criteria>
264
+ - `ls REWARD_AUDIT.md` — passes
265
+ - `grep -cE '^## Attempt A-0[1-9]' REWARD_AUDIT.md` prints a number >= `5`
266
+ - `grep -q "Fixes Summary" REWARD_AUDIT.md` — passes
267
+ - `grep -q "REW-05" REWARD_AUDIT.md` — passes
268
+ - `grep -qE "R3|ruin.?mode" REWARD_AUDIT.md` — passes
269
+ - `grep -q "min(.*0.05)" rewards/compose.py || grep -q "<= 0.05" rewards/compose.py` — the A-02 fix (correctness==0 cap) is wired in
270
+ - Verification command prints `audit structure ok: 5 attempts` (or more) with exit 0
271
+ </acceptance_criteria>
272
+ <done>REWARD_AUDIT.md exists with ≥5 attempts, each scored + diagnosed + fix-applied, and the A-02 cap has been implemented in rewards/compose.py.</done>
273
+ </task>
274
+
275
+ <task type="auto" tdd="true">
276
+ <name>Task 3: Unit tests (REW-08 ≥30 pairs) + automated audits (REW-06)</name>
277
+ <files>tests/test_rewards.py</files>
278
+ <read_first>
279
+ - rewards/*.py (Tasks 1+2 output)
280
+ - REWARD_AUDIT.md (Task 2 output — REW-06 audits assert the same invariants the manual audit established)
281
+ - configs/reward/v1.yaml (α + weights values used in test fixtures)
282
+ - .planning/REQUIREMENTS.md REW-06, REW-08 (exact audit list: masked-context, format-only ≤ 0.05, no monotonic length; ≥30 pairs)
283
+ </read_first>
284
+ <behavior>
285
+ - File starts with a `@pytest.fixture` providing `cfg_reward` composed from `configs/reward/v1.yaml` via Hydra (same pattern as Plan 02 tests).
286
+ - **Unit tests (REW-08):** ≥30 tests / parametrized cases organized as 4 classes — one per component. Each class has ≥7 (input, expected) pairs covering:
287
+ - `TestFormatGate`: with tag, without tag, nested tag, multi-line tag, upper-case `<ANSWER>`, empty-answer tag, no closing tag.
288
+ - `TestCorrectness`: exact match, case-diff, trailing period, whitespace padding, no tag, wrong answer, gold_answer with multi-word ("New York" vs "new york").
289
+ - `TestTokenBudget`: short completion, long completion, zero ratio, huge ratio with capped_linear, huge ratio with capped_quadratic, uncapped negative, invalid variant raises.
290
+ - `TestRecursionEfficiency`: 0, 1, 2, 3, 10, negative (should treat as 0), max_calls=3 override.
291
+ - `TestCompose`: format-fail short-circuit, full-score (all 1.0 → 1.0), correctness=0 cap ≤ 0.05, partial mix (correctness=1, token_budget=0.5), make_reward_fn factory returns closure, TRL-contract `compose_reward_fn(prompts, completions, **kwargs)` batches.
292
+ - The 4 task types from DATA-01 are covered by at least one (input, expected) pair each: niah (extractive-span), multi_needle (numeric sum), extractive (wh-answer), counting (integer answer).
293
+ - **REW-06 automated audits (3 tests marked with `@pytest.mark.reward_audit`)**:
294
+ - `test_masked_context_reward_is_baseline`: call `compose_reward_single` with gold_answer set but completion is random gibberish (e.g., `"<answer>lorem ipsum dolor</answer>"`); assert composite ≤ 0.10 (format passes but correctness=0 → capped at 0.05).
295
+ - `test_format_only_composite_is_capped`: 20 completions of form `"<answer>{random_word}</answer>"` with mismatched gold; assert `max(composites) <= 0.05` (REW-06 format-only baseline gate).
296
+ - `test_no_monotonic_length_exploit`: generate 10 completions of lengths [10, 50, 100, 500, 1000, 2000, 5000, 8000, 12000, 20000] words, each ending in `<answer>rome</answer>` with gold `"rome"` (correctness=1.0 held constant); collect composites; assert `composites[-1] < composites[0]` AND assert the sequence is monotonically non-increasing (or flat) — proves longer completions do NOT score higher.
297
+ - Register custom marker in top of file: `pytestmark = pytest.mark.filterwarnings("ignore")` and ensure `pyproject.toml` or `pytest.ini` has `markers = ["reward_audit: REW-06 automated anti-hacking audits"]`. If pyproject.toml doesn't have a `[tool.pytest.ini_options]` section, add one with the marker registered.
298
+ - Total test function count ≥ 33 (30 pair-style + 3 audits).
299
+ </behavior>
300
+ <action>
301
+ 1. Create `tests/test_rewards.py`. Imports: `pytest`, `from unittest.mock import MagicMock`, `from hydra import initialize, compose`, `from omegaconf import OmegaConf`, and the reward modules.
302
+ 2. Fixture `cfg_reward` composing `configs/reward/v1.yaml` via `initialize(config_path="../configs", version_base="1.3")`. Return `cfg.reward`.
303
+ 3. Implement the 4 component test classes with parametrize where appropriate. Aim for ≥7 cases per class; distribute a few to cover the 4 DATA-01 task types.
304
+ 4. Implement the `TestCompose` class with the 6 scenarios above. Use `compose_reward_single` directly.
305
+ 5. Implement the 3 `reward_audit`-marked tests. For the length-exploit test, use `" ".join(["filler"] * n) + " <answer>rome</answer>"` with the word counts listed. Verify the composite sequence is non-increasing using `all(a >= b for a, b in zip(composites, composites[1:]))`.
306
+ 6. Add the pytest marker registration to `pyproject.toml` under `[tool.pytest.ini_options]`:
307
+ ```toml
308
+ [tool.pytest.ini_options]
309
+ markers = [
310
+ "slow: marks tests that regenerate data or download models",
311
+ "reward_audit: REW-06 automated anti-reward-hacking audits",
312
+ ]
313
+ ```
314
+ Preserve the existing `slow` marker if already present from Plan 04 (Task 3).
315
+ 7. Ensure the full run `pytest tests/test_rewards.py -v` reports at least 33 passed; `pytest tests/test_rewards.py -v -m reward_audit` reports exactly 3 passed.
316
+ </action>
317
+ <verify>
318
+ <automated>python -m pytest tests/test_rewards.py -v --tb=short && python -m pytest tests/test_rewards.py -v -m reward_audit --tb=short</automated>
319
+ </verify>
320
+ <acceptance_criteria>
321
+ - `ls tests/test_rewards.py` — passes
322
+ - `grep -cE "def test_" tests/test_rewards.py` prints a number >= `30` (REW-08 floor)
323
+ - `grep -q "reward_audit" tests/test_rewards.py` — passes
324
+ - `grep -q "masked" tests/test_rewards.py` — passes (REW-06 masked-context audit)
325
+ - `grep -q "format_only\\|format-only" tests/test_rewards.py` — passes (REW-06 format-only audit)
326
+ - `grep -q "monotonic\\|length_exploit" tests/test_rewards.py` — passes (REW-06 length-exploit audit)
327
+ - `grep -q "reward_audit" pyproject.toml` — marker registered
328
+ - `pytest tests/test_rewards.py` reports `>= 33 passed` — exit 0
329
+ - `pytest tests/test_rewards.py -m reward_audit` reports exactly `3 passed` — exit 0
330
+ </acceptance_criteria>
331
+ <done>≥30 unit tests green, all 3 REW-06 reward_audit tests green, pytest marker registered in pyproject; reward system is the R3 ruin-mode gate.</done>
332
+ </task>
333
+
334
+ </tasks>
335
+
336
+ <verification>
337
+ - `python -c "from rewards import compose_reward_fn, make_reward_fn; print('imports ok')"` — exits 0
338
+ - `python -m pytest tests/test_rewards.py -v` — ≥33 green
339
+ - `python -m pytest tests/test_rewards.py -m reward_audit -v` — 3 green (REW-06 ruin-mode gate)
340
+ - `grep -rnE '\\beval\\(|\\bexec\\(|subprocess' rewards/` — zero hits (threat T-01-05-06)
341
+ - `grep -c "^## Attempt A-0" REWARD_AUDIT.md` — ≥5 (REW-05 floor)
342
+ - `grep -q "cap\\|<= 0.05\\|min(" rewards/compose.py` — A-02 fix wired
343
+ </verification>
344
+
345
+ <success_criteria>
346
+ REW-01..06 + REW-08 satisfied: (1) 4 component functions + compose are pure, stdlib-only, and match the TRL reward-function contract; (2) REW-02 gate-then-weighted-sum composition with the 0.75/0.2/0.05 weights; (3) α-parameterized token_budget with 3 variants ready for the Mercor Pareto sweep; (4) REWARD_AUDIT.md ≥5 attempts all scored + fixed; (5) ≥30 unit tests green + 3 reward_audit tests green (masked-context, format-only ≤ 0.05, no monotonic length); (6) no eval/exec/subprocess in rewards/. R3 ruin-mode gate is closed for Phase 2.
347
+ </success_criteria>
348
+
349
+ <output>
350
+ After completion, create `.planning/phases/01-fri-parallel-build/01-05-SUMMARY.md` with: reward-audit attempt table (5 rows), final composite scores per attempt post-fix, pytest counts (total + reward_audit), and one-line attestation "REW-01..06 + REW-08 COMPLETE — R3 ruin-mode gate GREEN".
351
+ </output>
.planning/phases/01-fri-parallel-build/01-06-PLAN.md ADDED
@@ -0,0 +1,416 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ phase: 01-fri-parallel-build
3
+ plan: 06
4
+ type: execute
5
+ wave: 1
6
+ depends_on: []
7
+ files_modified:
8
+ - viz/__init__.py
9
+ - viz/app.py
10
+ - viz/requirements.txt
11
+ - viz/BAKEOFF_NOTES.md
12
+ - space/README.md
13
+ - space/Dockerfile
14
+ - scripts/deploy_env_space.sh
15
+ - tests/test_space_smoke.py
16
+ autonomous: false
17
+ requirements:
18
+ - ENV-10
19
+ - DEM-03
20
+
21
+ must_haves:
22
+ truths:
23
+ - "env server deployed to HF Space as a Docker Space under repo `{hf_user}/fathom-env`, publicly accessible (no HF auth required for /healthz)"
24
+ - "`curl -sf https://{hf_user}-fathom-env.hf.space/healthz` returns HTTP 200 from a logged-out client (ROADMAP Phase 1 SC#5)"
25
+ - "`openenv validate` succeeds against the env/ layout OR documented deviation in Space README (ROADMAP Phase 1 SC#6)"
26
+ - "viz/app.py is a Streamlit skeleton (bake-off winner) with 3 stub panels: reward_curve (plotly), recursion_tree (streamlit.components.v1.html placeholder), Pareto frontier (plotly stub)"
27
+ - "viz/BAKEOFF_NOTES.md documents the 30-min Streamlit vs Gradio evaluation with screenshots or described render outcomes; locked choice recorded (DEM-03)"
28
+ - "tests/test_space_smoke.py contains a skip-on-unset-env test that hits the deployed Space URL if FATHOM_SPACE_URL env is set"
29
+ artifacts:
30
+ - path: scripts/deploy_env_space.sh
31
+ provides: one-command deploy (openenv push primary + huggingface_hub git push fallback per STACK §8.4)
32
+ contains: "openenv push"
33
+ - path: viz/app.py
34
+ provides: Streamlit skeleton (bake-off winner, locked for Phase 3)
35
+ contains: "import streamlit"
36
+ - path: viz/BAKEOFF_NOTES.md
37
+ provides: 30-min Streamlit vs Gradio bake-off record
38
+ contains: "Streamlit"
39
+ - path: space/README.md
40
+ provides: HF Space card with sdk=docker + tags + public-access declaration
41
+ contains: "sdk: docker"
42
+ - path: tests/test_space_smoke.py
43
+ provides: skip-on-unset remote smoke test for ENV-10 SC#4
44
+ contains: "FATHOM_SPACE_URL"
45
+ key_links:
46
+ - from: scripts/deploy_env_space.sh
47
+ to: env/ Dockerfile openenv.yaml
48
+ via: `openenv push --space {user}/fathom-env` or `huggingface_hub.HfApi.create_repo + git push` fallback
49
+ pattern: "openenv push|HfApi|git push"
50
+ - from: viz/app.py
51
+ to: plotly + streamlit.components.v1.html
52
+ via: st.plotly_chart for reward/Pareto; components.html for D3 recursion tree
53
+ pattern: "plotly_chart|components\\.v1"
54
+ ---
55
+
56
+ <objective>
57
+ Close Track B's two remaining Phase 1 requirements: (1) ENV-10 — deploy the Phase 0 env server to an HF Space as a Docker Space that a logged-out `curl` can `GET /healthz` successfully (ROADMAP Phase 1 SC#4 + SC#5); (2) DEM-03 — resolve the 30-min Streamlit vs Gradio bake-off and lock the winner as `viz/app.py` skeleton that Phase 3 will fill in. Also commit the `openenv validate` check (SC#6). This plan has one `checkpoint:human-action` for the Space push because it requires the user's HF write token + a public-Space visibility toggle — but every deploy command is automated; the human only pastes the token and clicks "make public".
58
+ </objective>
59
+
60
+ <execution_context>
61
+ @$HOME/.claude/get-shit-done/workflows/execute-plan.md
62
+ @$HOME/.claude/get-shit-done/templates/summary.md
63
+ </execution_context>
64
+
65
+ <context>
66
+ @CLAUDE.md
67
+ @.planning/STATE.md
68
+ @.planning/ROADMAP.md
69
+ @.planning/REQUIREMENTS.md
70
+ @Dockerfile
71
+ @openenv.yaml
72
+ @env/server/app.py
73
+ @env/client.py
74
+ @.planning/phases/00-thu-evening-foundation/00-05-env-tests-exit-gate-SUMMARY.md
75
+
76
+ <interfaces>
77
+ <!-- The env server already listens on 0.0.0.0:7860 (HF Space default port — confirmed in Dockerfile) -->
78
+
79
+ HF Space endpoint shape (after deploy):
80
+ GET https://{user}-fathom-env.hf.space/healthz → 200 {"status":"ok"}
81
+ POST https://{user}-fathom-env.hf.space/reset → FathomObservation
82
+ POST https://{user}-fathom-env.hf.space/step → FathomStepResult
83
+ GET https://{user}-fathom-env.hf.space/state → sanitized state dict (no gold_answer)
84
+
85
+ viz/app.py must be importable as a Streamlit app:
86
+ `streamlit run viz/app.py` → loads without error, renders 3 empty-state panels
87
+ </interfaces>
88
+ </context>
89
+
90
+ <threat_model>
91
+ ## Trust Boundaries
92
+
93
+ | Boundary | Description |
94
+ |----------|-------------|
95
+ | host → HF Space registry | HF_TOKEN (write scope) required; pushes Docker context |
96
+ | public internet → Space | Anyone can POST /step with arbitrary Python code — ENV-09 sandbox is the ONLY safety layer |
97
+ | Space sub-LM calls → outbound | Phase 0 llm() is a local stub; no real outbound in the deployed env |
98
+
99
+ ## STRIDE Threat Register (ASVS L1)
100
+
101
+ | Threat ID | Category | Component | Disposition | Mitigation Plan |
102
+ |-----------|----------|-----------|-------------|-----------------|
103
+ | T-01-06-01 | Elevation of Privilege | public POST /step with malicious code | mitigate | Phase 0 ENV-09 sandbox + Plan 00-03 adversarial suite already verified fail-to-escape; deploy does not change the sandbox |
104
+ | T-01-06-02 | Information Disclosure | HF write token in deploy script | mitigate | scripts/deploy_env_space.sh reads `HF_TOKEN` from env only; never echoes; `set +x` around login step; no token in git |
105
+ | T-01-06-03 | Denial of Service | public Space takes resource abuse | accept | Hackathon-scope risk; HF Spaces have per-user rate limits; if abused we rotate the Space URL |
106
+ | T-01-06-04 | Information Disclosure | gold_answer leaks via remote endpoint | mitigate | Phase 0 ENV-08 unit test `test_gold_answer_never_in_observation` already covers this; not weakened by deploy |
107
+ | T-01-06-05 | Repudiation | Space silently private (ROADMAP SC#5 fail) | mitigate | Deploy script asserts `HfApi.space_info(...).private is False` immediately after push; failure is hard-stop |
108
+ </threat_model>
109
+
110
+ <tasks>
111
+
112
+ <task type="auto" tdd="true">
113
+ <name>Task 1: Streamlit vs Gradio bake-off + lock viz/app.py (DEM-03)</name>
114
+ <files>viz/__init__.py, viz/app.py, viz/requirements.txt, viz/BAKEOFF_NOTES.md</files>
115
+ <read_first>
116
+ - .planning/REQUIREMENTS.md DEM-03 (30-min bake-off, pick winner for D3 recursion tree rendering)
117
+ - CLAUDE.md §1.1 Streamlit vs Gradio decided note (Streamlit is the current default; this task validates it)
118
+ - .planning/STATE.md (decision already logged: "Streamlit chosen as demo UI default; 30-min Thu/Fri bake-off to confirm D3 tree renderer")
119
+ - .planning/phases/00-thu-evening-foundation/00-CONTEXT.md (any earlier UI decisions)
120
+ </read_first>
121
+ <behavior>
122
+ - Produce `viz/BAKEOFF_NOTES.md` — the written record of the 30-min evaluation. Sections: Methodology (what we tested), Streamlit Prototype (5-line plotly + 5-line components.v1.html for D3 embed), Gradio Prototype (same 2 tests in Gradio), Observations (what rendered cleaner), Decision + Rationale (Streamlit because components.v1.html supports arbitrary D3 HTML more flexibly than gr.HTML, per CLAUDE.md §1.1).
123
+ - Produce `viz/app.py` — the Streamlit skeleton that Phase 3 DEM-05/DEM-06 will extend. Layout:
124
+ 1. `st.title("FATHOM — Recursive Language Model")`
125
+ 2. `st.caption("Theme 2 · Mercor sub-prize · Meta × PyTorch × HF Grand Finale")`
126
+ 3. Three tabs via `st.tabs(["Reward curve", "Recursion tree", "Pareto frontier"])`:
127
+ - Tab 1: `st.plotly_chart(_stub_reward_curve())` — stub returns a plotly Figure with one dummy line + a note "Populated from assets/reward_curve.png in Phase 3".
128
+ - Tab 2: `components.v1.html(_stub_tree_html(), height=500)` — stub returns `"<div id='tree'>recursion tree renders here (DEM-04 Phase 3)</div>"`.
129
+ - Tab 3: `st.plotly_chart(_stub_pareto())` — stub returns a plotly Figure with two dummy scatter points + note "α-sweep populated in Phase 3 DEM-05".
130
+ - Produce `viz/requirements.txt` with the Streamlit + plotly pins from pyproject.toml's `[viz]` extra — so HF Spaces Streamlit SDK can install from requirements.txt alone (HF Space convention).
131
+ - `viz/__init__.py` with empty docstring so Python recognizes the package.
132
+ </behavior>
133
+ <action>
134
+ 1. Create `viz/__init__.py` with `"""FATHOM demo + monitoring UI package — DEM-03 locked Streamlit (Phase 3 extends)."""`.
135
+ 2. Create `viz/BAKEOFF_NOTES.md` following the outline in `<behavior>`. Include a brief "What 5 minutes of each framework looked like" section: Streamlit's `components.v1.html(raw_html)` loads any D3 script via a single line; Gradio's `gr.HTML` sandboxes more aggressively and frequently strips `<script>` without explicit `unsafe_html=True`. Cite CLAUDE.md §1.1 as the research-stage rationale that this bake-off confirms.
136
+ 3. Create `viz/app.py`:
137
+ ```python
138
+ """FATHOM Streamlit skeleton — DEM-03 winner.
139
+
140
+ Locked after the 30-min Streamlit vs Gradio bake-off (see viz/BAKEOFF_NOTES.md).
141
+ Phase 3 extends each tab with real data pulled from assets/ + W&B + pareto.json.
142
+ """
143
+ import streamlit as st
144
+ import streamlit.components.v1 as components
145
+ import plotly.graph_objects as go
146
+
147
+
148
+ def _stub_reward_curve() -> go.Figure:
149
+ fig = go.Figure()
150
+ fig.add_trace(go.Scatter(x=[0, 50, 100], y=[0.1, 0.1, 0.1], name="composite"))
151
+ fig.update_layout(title="Reward curve (stub — Phase 3 DEM-07)", xaxis_title="step", yaxis_title="reward")
152
+ return fig
153
+
154
+
155
+ def _stub_pareto() -> go.Figure:
156
+ fig = go.Figure()
157
+ fig.add_trace(go.Scatter(x=[100], y=[0.3], mode="markers", name="untrained"))
158
+ fig.add_trace(go.Scatter(x=[400], y=[0.8], mode="markers", name="trained α=0.2"))
159
+ fig.update_layout(title="Accuracy vs tokens (stub — Phase 3 DEM-05)", xaxis_title="tokens used", yaxis_title="accuracy")
160
+ return fig
161
+
162
+
163
+ _TREE_STUB_HTML = """
164
+ <div id='tree' style='border:1px solid #ccc;padding:16px;'>
165
+ <p>recursion tree renders here (Phase 3 DEM-04)</p>
166
+ <p>D3 host div — streamlit.components.v1.html confirmed via DEM-03 bake-off.</p>
167
+ </div>
168
+ """
169
+
170
+
171
+ def main() -> None:
172
+ st.set_page_config(page_title="FATHOM", layout="wide")
173
+ st.title("FATHOM — Recursive Language Model")
174
+ st.caption("Theme 2 · Mercor sub-prize · Meta × PyTorch × HF Grand Finale")
175
+ tab_reward, tab_tree, tab_pareto = st.tabs(["Reward curve", "Recursion tree", "Pareto frontier"])
176
+ with tab_reward:
177
+ st.plotly_chart(_stub_reward_curve(), use_container_width=True)
178
+ with tab_tree:
179
+ components.html(_TREE_STUB_HTML, height=300)
180
+ with tab_pareto:
181
+ st.plotly_chart(_stub_pareto(), use_container_width=True)
182
+
183
+
184
+ if __name__ == "__main__":
185
+ main()
186
+ ```
187
+ 4. Create `viz/requirements.txt`:
188
+ ```
189
+ streamlit>=1.39,<2.0
190
+ plotly>=5.24,<6.0
191
+ ```
192
+ 5. Smoke test the skeleton: run `python -c "import ast; ast.parse(open('viz/app.py').read())"` — must exit 0. Do NOT try to `streamlit run` in the verify step (that starts a server).
193
+ </action>
194
+ <verify>
195
+ <automated>python -c "import ast; ast.parse(open('viz/app.py').read()); print('viz/app.py parses')" && python -c "content=open('viz/BAKEOFF_NOTES.md').read(); assert 'Streamlit' in content and 'Gradio' in content and 'Decision' in content.lower() or 'decision' in content.lower(); print('bakeoff notes ok')"</automated>
196
+ </verify>
197
+ <acceptance_criteria>
198
+ - `ls viz/app.py viz/BAKEOFF_NOTES.md viz/requirements.txt viz/__init__.py` — all 4 present
199
+ - `grep -q "import streamlit" viz/app.py` — passes
200
+ - `grep -q "components" viz/app.py` — passes (D3 host)
201
+ - `grep -qE "tab_reward|tab_tree|tab_pareto" viz/app.py` — 3-tab layout present
202
+ - `grep -q "Streamlit" viz/BAKEOFF_NOTES.md && grep -q "Gradio" viz/BAKEOFF_NOTES.md` — both frameworks mentioned
203
+ - `grep -qi "decision" viz/BAKEOFF_NOTES.md` — decision section present
204
+ - `grep -q "DEM-03" viz/app.py && grep -q "DEM-03" viz/BAKEOFF_NOTES.md` — requirement tagged
205
+ - `python -c "import ast; ast.parse(open('viz/app.py').read())"` — exits 0
206
+ </acceptance_criteria>
207
+ <done>viz/app.py is a parseable 3-tab Streamlit skeleton; BAKEOFF_NOTES.md records the Streamlit win with rationale; requirements.txt pins the [viz] subset for HF Space Streamlit SDK.</done>
208
+ </task>
209
+
210
+ <task type="auto" tdd="true">
211
+ <name>Task 2: Write deploy_env_space.sh + space/README.md (ENV-10 automation)</name>
212
+ <files>scripts/deploy_env_space.sh, space/README.md, space/Dockerfile</files>
213
+ <read_first>
214
+ - Dockerfile (repo-root Dockerfile — the env server image)
215
+ - openenv.yaml (OpenEnv-standard env declaration)
216
+ - env/server/app.py (the /healthz /reset /step route definitions)
217
+ - CLAUDE.md §8 (`openenv push` primary path + §8.4 manual git-push fallback)
218
+ - .planning/REQUIREMENTS.md ENV-10 (remote reset/step roundtrip + public visibility)
219
+ - .planning/ROADMAP.md Phase 1 SC#4, SC#5, SC#6
220
+ </read_first>
221
+ <behavior>
222
+ - `space/README.md` — HF Space card header required by HF for Docker Spaces. Must include YAML front-matter:
223
+ ```
224
+ ---
225
+ title: FATHOM Env
226
+ emoji: 🌊
227
+ colorFrom: blue
228
+ colorTo: indigo
229
+ sdk: docker
230
+ app_port: 7860
231
+ pinned: false
232
+ license: mit
233
+ tags: [openenv, rl, long-context, theme-2]
234
+ ---
235
+ ```
236
+ Below: a short description, link back to the main repo, link to the OpenEnv spec, a curl-to-test example (`curl https://{this-user}-fathom-env.hf.space/healthz`).
237
+ - `space/Dockerfile` — either a one-line `# Defer to repo-root Dockerfile; this file exists for HF Space layout compatibility` alongside a `COPY ../ /app` strategy, OR just a symlink-pointer README noting that the deploy script uses the repo-root Dockerfile directly (HF Spaces accept a Dockerfile at Space repo root — we'll let the deploy script copy it).
238
+ - `scripts/deploy_env_space.sh` — bash script (Unix shell, since HF deploy happens from dev machines + CI). Behaviour:
239
+ 1. `set -euo pipefail`; `set +x` around any line that references `HF_TOKEN`.
240
+ 2. Validate env: `HF_TOKEN` must be set; `HF_USER` must be set (or derive from `huggingface-cli whoami`).
241
+ 3. Run `openenv validate .` — if it fails, print a warning + continue (ROADMAP SC#6 says "Document the result in Phase 1 ENV-10 plan summary"; a fail is not a hard block).
242
+ 4. Primary push: `openenv push --space "$HF_USER/fathom-env" --public` (CLAUDE.md §8.4 mentions this CLI; if flag names differ, the fallback handles it).
243
+ 5. Fallback (if primary fails): use `huggingface_hub` Python to `HfApi().create_repo(repo_id="$HF_USER/fathom-env", repo_type="space", space_sdk="docker", private=False, exist_ok=True)`, then do a git-remote push:
244
+ ```bash
245
+ python -c "from huggingface_hub import HfApi; HfApi().create_repo(repo_id='${HF_USER}/fathom-env', repo_type='space', space_sdk='docker', private=False, exist_ok=True)"
246
+ tmp=$(mktemp -d)
247
+ cp -r env/ openenv.yaml Dockerfile pyproject.toml README.md "$tmp/"
248
+ cp space/README.md "$tmp/README.md" # HF Space card takes precedence
249
+ cd "$tmp" && git init -q && git add . && git commit -q -m "deploy: env server"
250
+ git remote add origin "https://$HF_USER:$HF_TOKEN@huggingface.co/spaces/$HF_USER/fathom-env"
251
+ git push -f origin HEAD:main
252
+ ```
253
+ (The token is expanded into the URL only in-process; `set +x` wraps this block to keep it out of job logs.)
254
+ 6. After push, poll `curl -sf "https://$HF_USER-fathom-env.hf.space/healthz"` for up to 300s (Spaces take 60-180s to build); exit 0 on first 200 OK, exit 1 on timeout.
255
+ 7. Final verification: `python -c "from huggingface_hub import HfApi; info = HfApi().space_info('$HF_USER/fathom-env'); assert info.private is False, 'SC#5 FAILED: Space is private'; print('public: true')"`.
256
+ - Script is idempotent: running twice on the same Space re-pushes without error.
257
+ </behavior>
258
+ <action>
259
+ 1. Create `space/README.md` with the YAML front-matter above + descriptive body.
260
+ 2. Create `space/Dockerfile` containing a single comment `# HF Space Dockerfile — build context provided by scripts/deploy_env_space.sh; the real Dockerfile lives at repo root.` (and on the following line, copy the repo-root Dockerfile's contents verbatim so the Space works even if only `space/` is pushed).
261
+ 3. Create `scripts/deploy_env_space.sh` implementing the 7 steps above. Use `HF_USER="${HF_USER:-$(huggingface-cli whoami 2>/dev/null | head -n1)}"` to auto-derive. Mark the file executable via mode 0755 (note: on Windows this is a no-op; the `#!/usr/bin/env bash` shebang is what matters).
262
+ 4. Wrap token-exposing commands in `set +x; ...; set -x` so they don't appear in `bash -x` logs.
263
+ 5. After the push loop, if `openenv validate` failed in step 3, echo a visible `::warning::` style message capturing the output for the Plan SUMMARY.
264
+ 6. Add a `--dry-run` mode: when `DRY_RUN=1` env is set, the script prints every command it would run instead of executing. This is what the autonomous verify step uses — the actual push happens in Task 3's checkpoint.
265
+ </action>
266
+ <verify>
267
+ <automated>bash -n scripts/deploy_env_space.sh && DRY_RUN=1 HF_USER=testuser HF_TOKEN=dummy bash scripts/deploy_env_space.sh 2>&1 | grep -qE "openenv push|HfApi|git push" && python -c "content=open('space/README.md').read(); assert 'sdk: docker' in content and 'app_port: 7860' in content; print('space README ok')"</automated>
268
+ </verify>
269
+ <acceptance_criteria>
270
+ - `ls scripts/deploy_env_space.sh space/README.md space/Dockerfile` — all 3 present
271
+ - `bash -n scripts/deploy_env_space.sh` — exits 0 (syntax valid)
272
+ - `grep -q "openenv push" scripts/deploy_env_space.sh` — passes
273
+ - `grep -q "HfApi" scripts/deploy_env_space.sh` — fallback present (CLAUDE.md §8.4)
274
+ - `grep -q "set +x" scripts/deploy_env_space.sh` — token-hygiene wrapper present
275
+ - `grep -q "healthz" scripts/deploy_env_space.sh` — post-push health poll present
276
+ - `grep -q "sdk: docker" space/README.md && grep -q "app_port: 7860" space/README.md` — both pass
277
+ - `grep -q "private.*False\\|private=False" scripts/deploy_env_space.sh` — SC#5 visibility assert present
278
+ - `grep -q "DRY_RUN" scripts/deploy_env_space.sh` — dry-run mode present
279
+ - Dry-run command in `<verify>` prints a line matching `openenv push|HfApi|git push` — exit 0
280
+ </acceptance_criteria>
281
+ <done>Deploy script syntactically valid, dry-runnable without a real token, has both the primary `openenv push` path and the `HfApi` + git push fallback, and hard-asserts the Space is public post-push.</done>
282
+ </task>
283
+
284
+ <task type="checkpoint:human-action" gate="blocking">
285
+ <name>Task 3: Execute deploy_env_space.sh against live HF Space (ENV-10 SC#4/5/6)</name>
286
+ <what-built>
287
+ Automated deploy script from Task 2 that pushes env/ + Dockerfile + openenv.yaml to
288
+ `{HF_USER}/fathom-env` as a public Docker Space, polls `/healthz`, and asserts visibility.
289
+ </what-built>
290
+ <how-to-verify>
291
+ Claude will run the commands below IF the user has set HF_TOKEN and HF_USER env vars.
292
+ If not, the user must (1) paste their HF write token, (2) run the command, and
293
+ (3) paste the output back.
294
+
295
+ ```bash
296
+ # User must set these first:
297
+ # export HF_TOKEN=hf_xxx (from https://hf.co/settings/tokens, write scope)
298
+ # export HF_USER=your-hf-username
299
+
300
+ bash scripts/deploy_env_space.sh
301
+ # Expected tail output:
302
+ # space: https://{HF_USER}-fathom-env.hf.space
303
+ # healthz: 200 OK
304
+ # public: true
305
+
306
+ # Independent verification from a DIFFERENT (logged-out) shell:
307
+ curl -sSf "https://${HF_USER}-fathom-env.hf.space/healthz" | head
308
+ # Expected: HTTP 200 with JSON {"status":"ok"} or similar
309
+
310
+ # Optional: remote reset/step roundtrip (SC#4)
311
+ python -c "
312
+ from env.client import FathomEnvClient
313
+ c = FathomEnvClient(base_url='https://${HF_USER}-fathom-env.hf.space')
314
+ print('healthz:', c.healthz())
315
+ obs = c.reset(seed=0, difficulty='easy')
316
+ print('reset ok:', obs.depth_current, obs.turns_left)
317
+ "
318
+
319
+ # Record openenv validate result for SUMMARY
320
+ openenv validate . 2>&1 | tee .planning/phases/01-fri-parallel-build/openenv_validate.txt
321
+ ```
322
+
323
+ User confirms with:
324
+ - The Space URL
325
+ - 200 OK from logged-out curl
326
+ - "public: true" line printed
327
+ - openenv validate result (pass or documented fail)
328
+ </how-to-verify>
329
+ <resume-signal>Type "deployed" + paste the Space URL; or "failed: {error}" to log and move to fallback.</resume-signal>
330
+ </task>
331
+
332
+ <task type="auto" tdd="true">
333
+ <name>Task 4: Remote-smoke test (ENV-10 SC#4 regression) + openenv validate artefact</name>
334
+ <files>tests/test_space_smoke.py</files>
335
+ <read_first>
336
+ - env/client.py (FathomEnvClient signature — already built in Phase 0)
337
+ - env/models.py (FathomObservation schema for remote response validation)
338
+ - .planning/REQUIREMENTS.md ENV-10 (full reset/step roundtrip verified remotely)
339
+ </read_first>
340
+ <behavior>
341
+ - `tests/test_space_smoke.py` — one pytest test that skips if `FATHOM_SPACE_URL` env var is not set. When set, it:
342
+ 1. Constructs `FathomEnvClient(base_url=os.environ["FATHOM_SPACE_URL"], timeout_s=30.0)`.
343
+ 2. Asserts `client.healthz()` returns a dict with key `status`.
344
+ 3. Calls `client.reset(seed=0, difficulty="easy")` → asserts returned FathomObservation has `depth_current == 0`, `turns_left > 0`.
345
+ 4. Calls `client.step(FathomAction(code="print(1+1)"))` → asserts returned FathomStepResult with `done in (True, False)` and a well-formed observation.
346
+ 5. Asserts `"gold_answer" not in str(obs.model_dump())` (ENV-08 holds over the wire).
347
+ - Skip mark: `@pytest.mark.skipif(not os.environ.get("FATHOM_SPACE_URL"), reason="FATHOM_SPACE_URL not set — remote smoke skipped")`.
348
+ - Mark with `@pytest.mark.slow` so CI runs only on main (same marker already registered in Plan 04).
349
+ - No additional test runtime dependencies — uses the existing `httpx` from Phase 0 `env.client`.
350
+ </behavior>
351
+ <action>
352
+ 1. Create `tests/test_space_smoke.py`:
353
+ ```python
354
+ """ENV-10 remote smoke — skips unless FATHOM_SPACE_URL is set."""
355
+ import os
356
+ import pytest
357
+ from env.client import FathomEnvClient
358
+ from env.models import FathomAction
359
+
360
+ pytestmark = [
361
+ pytest.mark.skipif(
362
+ not os.environ.get("FATHOM_SPACE_URL"),
363
+ reason="FATHOM_SPACE_URL not set — remote smoke skipped",
364
+ ),
365
+ pytest.mark.slow,
366
+ ]
367
+
368
+
369
+ def test_space_roundtrip():
370
+ url = os.environ["FATHOM_SPACE_URL"]
371
+ with FathomEnvClient(base_url=url, timeout_s=30.0) as c:
372
+ hz = c.healthz()
373
+ assert isinstance(hz, dict) and "status" in hz
374
+ obs = c.reset(seed=0, difficulty="easy")
375
+ assert obs.depth_current == 0
376
+ assert obs.turns_left > 0
377
+ result = c.step(FathomAction(code="print(1+1)"))
378
+ assert result.done in (True, False)
379
+ # ENV-08 invariant holds over the wire
380
+ payload = result.model_dump()
381
+ assert "gold_answer" not in str(payload)
382
+ ```
383
+ 2. Run locally with `FATHOM_SPACE_URL` unset to confirm the test SKIPs cleanly (not errors).
384
+ 3. Update `.planning/phases/01-fri-parallel-build/01-06-SUMMARY.md` (created in `<output>`) to include the `openenv validate` output captured by Task 3.
385
+ </action>
386
+ <verify>
387
+ <automated>python -m pytest tests/test_space_smoke.py -v --tb=short && python -c "import ast; ast.parse(open('tests/test_space_smoke.py').read())"</automated>
388
+ </verify>
389
+ <acceptance_criteria>
390
+ - `ls tests/test_space_smoke.py` — passes
391
+ - `grep -q "FATHOM_SPACE_URL" tests/test_space_smoke.py` — passes
392
+ - `grep -q "gold_answer" tests/test_space_smoke.py` — ENV-08 wire-check present
393
+ - `pytest tests/test_space_smoke.py -v` with `FATHOM_SPACE_URL` unset — reports `1 skipped` with exit 0
394
+ - If `FATHOM_SPACE_URL` is set by the Task 3 deploy, re-running the test reports `1 passed` with exit 0
395
+ </acceptance_criteria>
396
+ <done>Remote smoke test skips cleanly without env var, passes when env var is set to the deployed Space URL; closes ENV-10 SC#4 regression loop.</done>
397
+ </task>
398
+
399
+ </tasks>
400
+
401
+ <verification>
402
+ - `ls viz/app.py viz/BAKEOFF_NOTES.md scripts/deploy_env_space.sh space/README.md tests/test_space_smoke.py` — all 5 present
403
+ - `bash -n scripts/deploy_env_space.sh` — passes
404
+ - `pytest tests/test_space_smoke.py -v` — 1 test (skipped by default; passed when FATHOM_SPACE_URL set)
405
+ - After Task 3 (human action) completes: `curl -sSfI https://{HF_USER}-fathom-env.hf.space/healthz` returns HTTP 200 from a logged-out shell (ROADMAP Phase 1 SC#5)
406
+ - `openenv_validate.txt` exists under `.planning/phases/01-fri-parallel-build/` documenting SC#6 result
407
+ - `grep -q "DEM-03" viz/BAKEOFF_NOTES.md && grep -q "ENV-10" scripts/deploy_env_space.sh` — requirement tags present
408
+ </verification>
409
+
410
+ <success_criteria>
411
+ ENV-10 + DEM-03 satisfied: (1) env server deployed as public Docker HF Space, logged-out healthz returns 200, remote reset/step roundtrip verified, Space visibility asserted public; (2) openenv validate run and result recorded; (3) Streamlit locked as demo UI via a documented 30-min bake-off, `viz/app.py` skeleton parseable with 3 stub tabs (reward / tree / pareto) ready for Phase 3 to fill.
412
+ </success_criteria>
413
+
414
+ <output>
415
+ After completion, create `.planning/phases/01-fri-parallel-build/01-06-SUMMARY.md` with: the deployed Space URL, the logged-out `curl -I /healthz` response, the `openenv validate` output (pass or documented fail), the bake-off decision with 1-line rationale, pytest output for the skipped smoke, and one-line attestation "ENV-10 + DEM-03 COMPLETE — Space public, Streamlit locked".
416
+ </output>
.planning/phases/01-fri-parallel-build/01-07-PLAN.md ADDED
@@ -0,0 +1,409 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ phase: 01-fri-parallel-build
3
+ plan: 07
4
+ type: execute
5
+ wave: 3
6
+ depends_on: [2, 3, 4, 5, 6]
7
+ files_modified:
8
+ - train/smoke_test.py
9
+ - tests/test_smoke_e2e.py
10
+ - SMOKE_RESULT.md
11
+ autonomous: false
12
+ requirements:
13
+ - TRN-04
14
+
15
+ must_haves:
16
+ truths:
17
+ - "train/smoke_test.py is a self-contained script: `python -m train.smoke_test` runs base-model-load → SFT-adapter-load-if-present → 1 GRPO step against local env server → writes SMOKE_RESULT.md"
18
+ - "The smoke run uses Qwen2.5-Coder-0.5B-Instruct-bnb-4bit (model=qwen_0_5b_smoke override) so it fits on an RTX 4060 8GB"
19
+ - "The rollout MUST exercise ≥1 multi-turn llm() sub-call during generation to catch TRL #4543 regression (CLAUDE.md / STACK H2)"
20
+ - "On success: loss is finite (not NaN), grad_norm > 0, compose_reward_fn returned a list of floats, exit code 0, SMOKE_RESULT.md records VERDICT: GO"
21
+ - "On failure: exit code 1, SMOKE_RESULT.md records VERDICT: NO-GO with specific failure mode (import error, NaN loss, empty rollout, TRL #4543 single-turn, etc.)"
22
+ - "tests/test_smoke_e2e.py mocks all GPU/HF/env calls and asserts the orchestration sequence (load → sft adapter check → grpo step → reward fn call → save)"
23
+ artifacts:
24
+ - path: train/smoke_test.py
25
+ provides: run_smoke_test(cfg_overrides: list[str] | None = None) -> int (exit code)
26
+ contains: "def run_smoke_test"
27
+ - path: train/smoke_test.py
28
+ provides: multi-turn rollout assertion (TRL #4543 regression guard)
29
+ contains: "multi.?turn|llm_call_count"
30
+ - path: tests/test_smoke_e2e.py
31
+ provides: orchestration unit test (CPU-only, all externals mocked)
32
+ contains: "def test_"
33
+ - path: SMOKE_RESULT.md
34
+ provides: Phase 1 exit-gate attestation — VERDICT: GO | NO-GO
35
+ contains: "VERDICT:"
36
+ key_links:
37
+ - from: train/smoke_test.py
38
+ to: train/model_load.py
39
+ via: load_model_and_tokenizer(cfg) with model=qwen_0_5b_smoke
40
+ pattern: "load_model_and_tokenizer"
41
+ - from: train/smoke_test.py
42
+ to: train/sft.py
43
+ via: optional — skip SFT pass for speed; smoke only loads adapter if it already exists from a prior SFT run
44
+ pattern: "sft_adapter"
45
+ - from: train/smoke_test.py
46
+ to: train/grpo.py
47
+ via: run_grpo(cfg, model, tokenizer, reward_fn, env_url) with max_steps=1
48
+ pattern: "run_grpo"
49
+ - from: train/smoke_test.py
50
+ to: rewards/compose.py
51
+ via: make_reward_fn(cfg.reward) produces the reward_fn passed to run_grpo
52
+ pattern: "make_reward_fn|compose_reward_fn"
53
+ - from: train/smoke_test.py
54
+ to: env/server/app.py (local) or HF Space URL
55
+ via: env_url defaults to http://localhost:8001 for the smoke (HF Space used only at venue)
56
+ pattern: "localhost:8001|FATHOM_SPACE_URL"
57
+ ---
58
+
59
+ <objective>
60
+ The Phase 1 exit gate: a single script `python -m train.smoke_test` that exercises every piece built today (Plans 01-06) end-to-end on a laptop (RTX 4060 8GB) using the 0.5B Qwen smoke variant — base load (Plan 01) → SFT-adapter load if present (Plan 02) → 1 GRPO step (Plan 03) wired to the Plan 05 composed reward function, consuming the Plan 04 dataset, against the locally-running Phase 0 env server. The rollout MUST include a multi-turn llm() sub-call to catch the TRL #4543 single-turn regression (CLAUDE.md / STACK H2). On success we write SMOKE_RESULT.md "VERDICT: GO" and the team travels to Bangalore with a known-working pipeline. On failure, we write "VERDICT: NO-GO" with the specific failure mode, and the recovery plan in SMOKE_RESULT.md IS the ROADMAP-required "written recovery plan" for Phase 1 exit.
61
+ </objective>
62
+
63
+ <execution_context>
64
+ @$HOME/.claude/get-shit-done/workflows/execute-plan.md
65
+ @$HOME/.claude/get-shit-done/templates/summary.md
66
+ </execution_context>
67
+
68
+ <context>
69
+ @CLAUDE.md
70
+ @.planning/STATE.md
71
+ @.planning/ROADMAP.md
72
+ @.planning/REQUIREMENTS.md
73
+ @.planning/phases/01-fri-parallel-build/01-01-PLAN.md
74
+ @.planning/phases/01-fri-parallel-build/01-02-PLAN.md
75
+ @.planning/phases/01-fri-parallel-build/01-03-PLAN.md
76
+ @.planning/phases/01-fri-parallel-build/01-04-PLAN.md
77
+ @.planning/phases/01-fri-parallel-build/01-05-PLAN.md
78
+ @.planning/phases/01-fri-parallel-build/01-06-PLAN.md
79
+ @configs/config.yaml
80
+ @configs/model/qwen_0_5b_smoke.yaml
81
+ @configs/train/grpo.yaml
82
+ @train/model_load.py
83
+ @train/sft.py
84
+ @train/grpo.py
85
+ @rewards/compose.py
86
+ @env/server/app.py
87
+ @env/client.py
88
+
89
+ <interfaces>
90
+ <!-- Contract this plan publishes (the smoke is called by humans from the CLI) -->
91
+
92
+ ```python
93
+ def run_smoke_test(
94
+ cfg_overrides: list[str] | None = None,
95
+ env_url: str | None = None,
96
+ ) -> int:
97
+ """
98
+ End-to-end smoke on the 0.5B model (RTX 4060 8GB compatible).
99
+
100
+ Args:
101
+ cfg_overrides: Hydra-style CLI override list; defaults to
102
+ ["model=qwen_0_5b_smoke", "train=grpo",
103
+ "train.max_steps=1", "train.num_generations=2",
104
+ "train.max_prompt_length=1024", "train.max_completion_length=256",
105
+ "train.vllm_gpu_memory_utilization=0.3"].
106
+ env_url: OpenEnv URL; defaults to os.environ.get("FATHOM_SPACE_URL")
107
+ or "http://localhost:8001".
108
+
109
+ Returns:
110
+ 0 on GO, 1 on NO-GO. Writes SMOKE_RESULT.md in either case.
111
+ """
112
+ ```
113
+
114
+ SMOKE_RESULT.md schema:
115
+
116
+ ```markdown
117
+ # FATHOM 0.5B Smoke Result — Phase 1 Exit Gate
118
+
119
+ **Run timestamp:** {iso8601}
120
+ **Verdict:** GO | NO-GO
121
+ **Host:** {hostname} {os} {gpu_name or "cpu"}
122
+
123
+ ## Invariants
124
+
125
+ | Invariant | Observed | Pass |
126
+ |-----------|----------|------|
127
+ | Base model loads | {name} | ✓/✗ |
128
+ | SFT adapter loaded (if present) | {path or "skipped"} | ✓/✗ |
129
+ | GRPOTrainer.train() completed 1 step | — | ✓/✗ |
130
+ | Loss is finite (not NaN) | {loss_value} | ✓/✗ |
131
+ | Gradient norm > 0 | {grad_norm_value} | ✓/✗ |
132
+ | compose_reward_fn returned list[float] | {len=N} | ✓/✗ |
133
+ | Multi-turn rollout occurred (≥1 llm() sub-call; TRL #4543 guard) | {llm_calls} | ✓/✗ |
134
+ | merged_16bit save succeeded | {path or "skipped (smoke)"} | ✓/✗ |
135
+
136
+ ## Failure mode (if NO-GO)
137
+
138
+ {root cause + stack trace excerpt + recovery plan}
139
+
140
+ ## Recovery plan
141
+
142
+ {verbatim steps if NO-GO; "N/A" if GO}
143
+ ```
144
+ </interfaces>
145
+ </context>
146
+
147
+ <threat_model>
148
+ ## Trust Boundaries
149
+
150
+ | Boundary | Description |
151
+ |----------|-------------|
152
+ | host → HF Hub | 0.5B model download if not cached |
153
+ | host → env server (localhost) | Local dev sandbox; same trust as Phase 0 |
154
+ | smoke writes SMOKE_RESULT.md | No secret paths or tokens logged |
155
+
156
+ ## STRIDE Threat Register (ASVS L1)
157
+
158
+ | Threat ID | Category | Component | Disposition | Mitigation Plan |
159
+ |-----------|----------|-----------|-------------|-----------------|
160
+ | T-01-07-01 | Repudiation | "Smoke passed" without actually exercising multi-turn | mitigate | Hard assert `llm_call_count >= 1` from the rollout metadata; if zero, verdict is NO-GO with reason "TRL #4543 single-turn regression" |
161
+ | T-01-07-02 | Information Disclosure | HF_TOKEN / API keys in SMOKE_RESULT.md | mitigate | SMOKE_RESULT.md writer uses an explicit allow-list of keys; NEVER dumps os.environ; grep test `git grep -nE 'hf_[A-Za-z0-9]{30,}' SMOKE_RESULT.md` must be empty |
162
+ | T-01-07-03 | Tampering | SFT-adapter absence silently skipped with no log | mitigate | Log INFO "TRN-04 SFT adapter not found — smoke proceeds with base-only" so the summary is accurate |
163
+ | T-01-07-04 | Denial of Service | 0.5B still OOMs the 8GB 4060 under unlucky vLLM settings | mitigate | Smoke overrides set `vllm_gpu_memory_utilization=0.3`, `max_prompt_length=1024`, `max_completion_length=256`, `num_generations=2`; if OOM, verdict NO-GO with explicit "VRAM ceiling" diagnosis |
164
+ </threat_model>
165
+
166
+ <tasks>
167
+
168
+ <task type="auto" tdd="true">
169
+ <name>Task 1: Implement train/smoke_test.py orchestration (TRN-04)</name>
170
+ <files>train/smoke_test.py</files>
171
+ <read_first>
172
+ - train/model_load.py (load_model_and_tokenizer signature + Instruct-variant gate)
173
+ - train/sft.py (run_sft signature — but smoke SKIPS SFT for speed; only loads adapter if dir exists)
174
+ - train/grpo.py (run_grpo signature + GRPOConfig assertions)
175
+ - rewards/compose.py (make_reward_fn factory)
176
+ - configs/model/qwen_0_5b_smoke.yaml (the model variant the smoke uses)
177
+ - configs/train/grpo.yaml (the fields to override for speed: max_steps=1, num_generations=2, max_prompt_length=1024, max_completion_length=256, vllm_gpu_memory_utilization=0.3)
178
+ - env/client.py (FathomEnvClient — used only for the health-check pre-flight)
179
+ - .planning/REQUIREMENTS.md TRN-04 (multi-turn rollout REQUIREMENT + laptop smoke context)
180
+ - CLAUDE.md STACK §10.11 and §12 (vllm_mode='colocate' requirement; TRL #4543 regression)
181
+ </read_first>
182
+ <behavior>
183
+ - Self-contained script: `python -m train.smoke_test` is the single entrypoint.
184
+ - Function `run_smoke_test(cfg_overrides=None, env_url=None) -> int` orchestrates the 7 invariant checks from the SMOKE_RESULT.md table.
185
+ - Hydra compose with defaults + overrides:
186
+ ```python
187
+ default_overrides = [
188
+ "model=qwen_0_5b_smoke",
189
+ "train=grpo",
190
+ "train.max_steps=1",
191
+ "train.num_generations=2",
192
+ "train.max_prompt_length=1024",
193
+ "train.max_completion_length=256",
194
+ "train.vllm_gpu_memory_utilization=0.3",
195
+ "+hub.push=false",
196
+ "+hub.repo_id=smoke/fathom-0_5b",
197
+ ]
198
+ overrides = default_overrides + (cfg_overrides or [])
199
+ ```
200
+ - Pre-flight: if `env_url.startswith("http://localhost")`, try `FathomEnvClient(env_url).healthz()` with 5s timeout. If it fails, print "NO-GO: env server not reachable at {url}. Start it with `python -m uvicorn env.server.app:app --port 8001` in another terminal." and return 1 WITHOUT attempting the training (this is the most common failure mode — catch it fast).
201
+ - Instrument reward_fn to count calls and capture llm_call_count:
202
+ ```python
203
+ base_reward_fn = make_reward_fn(cfg.reward)
204
+ rollout_metadata = {"reward_calls": 0, "max_llm_calls_observed": 0}
205
+ def instrumented(prompts, completions, **kwargs):
206
+ rollout_metadata["reward_calls"] += 1
207
+ lccs = kwargs.get("llm_call_count", [])
208
+ if lccs:
209
+ rollout_metadata["max_llm_calls_observed"] = max(
210
+ rollout_metadata["max_llm_calls_observed"], max(lccs)
211
+ )
212
+ return base_reward_fn(prompts, completions, **kwargs)
213
+ ```
214
+ - Invariant collection:
215
+ - `inv["base_load"]` — True after `load_model_and_tokenizer(cfg)` returns.
216
+ - `inv["sft_adapter"]` — `True` if `(cfg.output_dir/sft_adapter).exists()` else `"skipped"`.
217
+ - `inv["grpo_step_completed"]` — True after `run_grpo(cfg, model, tokenizer, instrumented, env_url)` returns without raising.
218
+ - `inv["loss_finite"]` — pulled from `trainer.state.log_history[-1]["loss"]`; check `math.isfinite(loss)`. Requires capturing the trainer from run_grpo — add a keyword arg `return_trainer=False` to run_grpo (edit to Plan 03 — BUT to keep plans independent, smoke_test instead inspects `{cfg.output_dir}/grpo_run/trainer_state.json` written by Trainer, or parses stdout for the `"loss": 0.xxx` line TRL logs at logging_steps=1). Prefer the file path approach.
219
+ - `inv["grad_norm"]` — same source (`trainer_state.json` grad_norm field or log_history).
220
+ - `inv["reward_returns_floats"]` — `rollout_metadata["reward_calls"] >= 1` (we observed ≥1 batched reward call).
221
+ - `inv["multi_turn_observed"]` — `rollout_metadata["max_llm_calls_observed"] >= 1` OR env's StepResult.info.step_count across the rollout was > 1. This is the TRL #4543 guard.
222
+ - `inv["merged_save"]` — `"skipped (smoke)"` by default; set True only if `(cfg.output_dir/grpo_merged_16bit).exists()` and contains `*.safetensors`.
223
+ - Wrap the full run in try/except. On any exception: verdict NO-GO, capture `traceback.format_exc()[:2000]`, map to a failure mode (see mapping below), write SMOKE_RESULT.md, return 1.
224
+ - Failure-mode map (heuristic string-matching on exception type + message):
225
+ - `ImportError` / `ModuleNotFoundError` → "import hell — missing dep"
226
+ - `RuntimeError` + "CUDA out of memory" → "VRAM ceiling (reduce num_generations or max_completion_length)"
227
+ - `AssertionError` + "vllm_mode" → "STACK §10.4 vllm_mode config broken"
228
+ - `AssertionError` + "Instruct variant" → "STACK §3.3 chat-template regression"
229
+ - `httpx.ConnectError` → "env server not reachable"
230
+ - `ValueError` + "chat_template" → "TRL #4543 / chat-template drift"
231
+ - Any case with `rollout_metadata["max_llm_calls_observed"] == 0` AND grpo ran → "TRL #4543 single-turn regression (no llm() sub-calls during rollout)"
232
+ - Default → "unknown — see stack trace"
233
+ - On success (all invariants pass or SFT is legitimately skipped): verdict GO, write SMOKE_RESULT.md with the full invariant table, return 0.
234
+ - Writer helper `_write_smoke_result(verdict, invariants, failure_mode, recovery_plan)` produces the markdown matching the `<interfaces>` schema. Uses an explicit allow-list of keys (never dumps os.environ); the only env-derived field written is `os.uname().nodename or platform.node()` (hostname).
235
+ - CLI entry:
236
+ ```python
237
+ if __name__ == "__main__":
238
+ import sys
239
+ sys.exit(run_smoke_test())
240
+ ```
241
+ </behavior>
242
+ <action>
243
+ 1. Create `train/smoke_test.py` with module header:
244
+ ```python
245
+ """FATHOM Phase 1 exit gate — TRN-04 0.5B smoke test.
246
+
247
+ Runs on a laptop RTX 4060 (8GB VRAM) using qwen_0_5b_smoke.
248
+ Exercises every Plan 01-06 component: config load, model load, optional SFT
249
+ adapter load, GRPO 1-step against the local env server with the composed
250
+ reward function. Writes SMOKE_RESULT.md with VERDICT: GO | NO-GO.
251
+
252
+ Critical guard: asserts ≥1 llm() sub-call was made during the rollout,
253
+ catching the TRL #4543 single-turn regression (CLAUDE.md / STACK §12).
254
+ """
255
+ from __future__ import annotations
256
+ import json
257
+ import logging
258
+ import math
259
+ import os
260
+ import platform
261
+ import sys
262
+ import time
263
+ import traceback
264
+ from datetime import datetime, timezone
265
+ from pathlib import Path
266
+ from typing import Any
267
+
268
+ from hydra import initialize, compose
269
+ from omegaconf import DictConfig, OmegaConf
270
+
271
+ log = logging.getLogger("fathom.smoke")
272
+ ```
273
+ 2. Implement pre-flight env health-check as the FIRST thing after config compose.
274
+ 3. Implement `_build_cfg(overrides)` using `initialize(config_path="../configs", version_base="1.3")` + `compose(config_name="config", overrides=...)`.
275
+ 4. Implement `_collect_invariants_from_state_file(grpo_run_dir) -> tuple[float | None, float | None]` that reads `trainer_state.json`, finds the most recent log entry with a `loss` key, returns `(loss, grad_norm)`. If file missing, returns `(None, None)`.
276
+ 5. Implement the main orchestration: import model_load + grpo + rewards lazily; wrap in try/except per behavior; update `rollout_metadata` via the instrumented reward_fn.
277
+ 6. Implement `_write_smoke_result` with explicit allow-list of fields. Write to repo-root `SMOKE_RESULT.md` (overwriting). Include the run timestamp in UTC ISO-8601.
278
+ 7. Implement the failure-mode map as a small helper `_classify_failure(exc: BaseException, rollout_metadata: dict) -> str`.
279
+ 8. Export `__all__ = ["run_smoke_test"]` and the `if __name__ == "__main__": sys.exit(run_smoke_test())` CLI entry.
280
+ </action>
281
+ <verify>
282
+ <automated>python -c "import ast; tree = ast.parse(open('train/smoke_test.py').read()); funcs = [n.name for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)]; assert 'run_smoke_test' in funcs; print('run_smoke_test found')"</automated>
283
+ </verify>
284
+ <acceptance_criteria>
285
+ - `ls train/smoke_test.py` — passes
286
+ - `grep -q "def run_smoke_test" train/smoke_test.py` — passes
287
+ - `grep -q "qwen_0_5b_smoke" train/smoke_test.py` — passes (0.5B variant forced)
288
+ - `grep -q "max_steps=1" train/smoke_test.py || grep -q '\"train.max_steps=1\"' train/smoke_test.py` — passes (1-step override)
289
+ - `grep -q "TRL #4543\\|max_llm_calls_observed\\|multi_turn\\|llm_call_count" train/smoke_test.py` — TRL #4543 guard present
290
+ - `grep -q "SMOKE_RESULT.md" train/smoke_test.py` — writer present
291
+ - `grep -q "VERDICT" train/smoke_test.py` — passes
292
+ - `grep -q "healthz" train/smoke_test.py` — pre-flight health check present
293
+ - `grep -q "TRN-04" train/smoke_test.py` — requirement tag present
294
+ - `python -c "import ast; ast.parse(open('train/smoke_test.py').read())"` — exits 0
295
+ </acceptance_criteria>
296
+ <done>train/smoke_test.py compiles, orchestrates all Plan 01-06 components, instruments the reward_fn for the TRL #4543 guard, writes SMOKE_RESULT.md with explicit allow-listed fields, and exits 0 GO or 1 NO-GO.</done>
297
+ </task>
298
+
299
+ <task type="auto" tdd="true">
300
+ <name>Task 2: Orchestration unit test with all externals mocked (TRN-04 regression)</name>
301
+ <files>tests/test_smoke_e2e.py</files>
302
+ <read_first>
303
+ - train/smoke_test.py (Task 1 output — the orchestrator under test)
304
+ - tests/test_grpo_scaffold.py (Hydra compose + mock patterns to reuse)
305
+ - tests/test_sft_scaffold.py (mock patterns for reward fn factory)
306
+ </read_first>
307
+ <behavior>
308
+ - CPU-only. No GPU, no HF, no env server. Every heavy dependency is patched.
309
+ - 4 tests:
310
+ 1. `test_smoke_go_path_all_mocks_succeed`: patch `load_model_and_tokenizer`, `run_grpo`, `FathomEnvClient.healthz`, and write a stub `trainer_state.json` with `{"log_history":[{"loss": 0.5, "grad_norm": 0.1}]}` into the expected cfg.output_dir. Configure the instrumented reward_fn mock to record `max_llm_calls_observed=2`. Call `run_smoke_test(env_url="http://localhost:8001")`. Assert return code == 0. Assert `SMOKE_RESULT.md` exists at the path expected (use tmp_path + monkey-patched cwd) and contains `"VERDICT: GO"`.
311
+ 2. `test_smoke_no_go_when_env_unreachable`: patch `FathomEnvClient.healthz` to raise `httpx.ConnectError`. Call `run_smoke_test(env_url="http://localhost:8001")`. Assert return code == 1, SMOKE_RESULT.md contains `"VERDICT: NO-GO"` and `"env server not reachable"`.
312
+ 3. `test_smoke_no_go_when_no_multi_turn_rollout`: patch `load_model_and_tokenizer` + `run_grpo` to succeed (return a path), write a stub `trainer_state.json` with finite loss, but configure the instrumented reward_fn so `max_llm_calls_observed == 0` (i.e. single-turn rollout). Assert return code == 1, SMOKE_RESULT.md mentions `"TRL #4543"` or `"single-turn"` in the failure mode.
313
+ 4. `test_smoke_no_secrets_in_smoke_result`: trigger the GO path, then assert the produced SMOKE_RESULT.md does NOT contain any string matching regexp `hf_[A-Za-z0-9]{30,}` or `sk-ant-` or `AWS_` — enforces the T-01-07-02 threat mitigation (env allow-list only).
314
+ - Use `tmp_path` as cfg.output_dir in every test. Monkey-patch `os.getcwd()` or work in `tmp_path` so SMOKE_RESULT.md lands in a test-local location (patch the writer's target path, not CWD, for reliability).
315
+ </behavior>
316
+ <action>
317
+ 1. Create `tests/test_smoke_e2e.py`. Imports include `pytest, httpx, json` from stdlib, plus `from unittest.mock import patch, MagicMock`.
318
+ 2. Fixture `mock_smoke_env(tmp_path, monkeypatch)` that:
319
+ - Creates `tmp_path/outputs/grpo_run/` dir and writes `trainer_state.json` with finite loss.
320
+ - Monkey-patches the writer path so SMOKE_RESULT.md lands at `tmp_path/SMOKE_RESULT.md` (use `monkeypatch.setattr("train.smoke_test.SMOKE_RESULT_PATH", tmp_path/"SMOKE_RESULT.md")` — this requires the Task-1 code to expose `SMOKE_RESULT_PATH = Path("SMOKE_RESULT.md")` as a module constant; add that to Task 1 if missing).
321
+ - Patches `train.smoke_test.load_model_and_tokenizer` to return `(MagicMock(), MagicMock())`.
322
+ - Patches `train.smoke_test.run_grpo` to return `tmp_path / "outputs" / "grpo_merged_16bit"`.
323
+ - Patches `env.client.FathomEnvClient.healthz` to return `{"status": "ok"}`.
324
+ - Patches `rewards.compose.make_reward_fn` to return a MagicMock that is configured to update the `rollout_metadata` dict with `max_llm_calls_observed = 2` when called; this is tricky — easier: patch `train.smoke_test.make_reward_fn` to return a real closure over a `metadata` dict the test owns.
325
+ 3. Implement Test 1 using the fixture; override `env_url=f"http://localhost:8001"`.
326
+ 4. Implement Test 2 by repatching `healthz` to raise `httpx.ConnectError("nope")`.
327
+ 5. Implement Test 3 by patching the reward factory so the instrumented closure leaves `max_llm_calls_observed = 0`.
328
+ 6. Implement Test 4 as a GO-path re-run that then reads `(tmp_path/"SMOKE_RESULT.md").read_text()` and runs the regexp assertions.
329
+ 7. If Task 1 didn't expose `SMOKE_RESULT_PATH` as a module constant, add a note in the Task 1 acceptance-criteria section to ensure it IS exposed; otherwise edit Task 1's file to add the constant (single-line change) — acceptable cross-task edit for this paired test.
330
+ </action>
331
+ <verify>
332
+ <automated>python -m pytest tests/test_smoke_e2e.py -v --tb=short</automated>
333
+ </verify>
334
+ <acceptance_criteria>
335
+ - `ls tests/test_smoke_e2e.py` — passes
336
+ - `grep -cE "def test_" tests/test_smoke_e2e.py` prints a number >= `4`
337
+ - `grep -q "TRL #4543\\|single.?turn\\|multi.?turn" tests/test_smoke_e2e.py` — passes
338
+ - `grep -q "env server not reachable\\|ConnectError" tests/test_smoke_e2e.py` — passes
339
+ - `grep -q "hf_\\|sk-ant-" tests/test_smoke_e2e.py` — secret-scan regex present (test 4)
340
+ - pytest command reports `>= 4 passed` — exit 0
341
+ </acceptance_criteria>
342
+ <done>4 tests green on CPU in <3s; the smoke orchestrator is covered for GO path, env-unreachable NO-GO, multi-turn NO-GO (TRL #4543 guard), and secret-leak prevention.</done>
343
+ </task>
344
+
345
+ <task type="checkpoint:human-verify" gate="blocking">
346
+ <name>Task 3: Run the real 0.5B smoke on the laptop — Phase 1 exit gate</name>
347
+ <what-built>
348
+ Orchestrator `train/smoke_test.py` from Task 1 + its unit-test regression from Task 2.
349
+ This checkpoint executes the actual 0.5B smoke on the user's RTX 4060 and verifies
350
+ SMOKE_RESULT.md "VERDICT: GO" before declaring Phase 1 complete.
351
+ </what-built>
352
+ <how-to-verify>
353
+ ```bash
354
+ # Terminal 1: start the env server (Phase 0 artifact)
355
+ python -m uvicorn env.server.app:app --host 127.0.0.1 --port 8001
356
+
357
+ # Terminal 2: run the smoke
358
+ # (FATHOM_SPACE_URL left unset so smoke uses localhost; HF_TOKEN set only if you want SFT adapter push)
359
+ python -m train.smoke_test
360
+
361
+ # Expected on success: exit code 0, and SMOKE_RESULT.md contains "VERDICT: GO"
362
+ cat SMOKE_RESULT.md | head -40
363
+ echo "exit=$?"
364
+ ```
365
+
366
+ Acceptance ladder:
367
+ - Tier A (GO — preferred): All 7 invariants ✓ including multi-turn ≥1 llm() call.
368
+ → Verdict: GO. Commit SMOKE_RESULT.md. Phase 1 COMPLETE, travel to Bangalore.
369
+ - Tier B (CONDITIONAL GO): All invariants ✓ EXCEPT multi-turn was 0.
370
+ → Verdict: NO-GO per ROADMAP, but diagnose immediately:
371
+ (1) Check the env server log: did the model emit any llm() call during rollout?
372
+ (2) If not, check configs/train/grpo.yaml max_completion_length (may be too small — 0.5B didn't have room to do a multi-turn call).
373
+ (3) Re-run with `python -m train.smoke_test -- train.max_completion_length=512`.
374
+ → If still 0 after retry: HARD NO-GO, document as "TRL #4543 hit on local stack".
375
+ - Tier C (NO-GO with recovery): Exception during training or NaN loss.
376
+ → SMOKE_RESULT.md should contain the failure_mode classification.
377
+ → Read the "Recovery plan" section; retry as instructed.
378
+
379
+ User must paste SMOKE_RESULT.md contents OR at minimum the "VERDICT:" line + any ✗
380
+ rows from the invariants table.
381
+
382
+ If laptop is underpowered even for 0.5B (rare on RTX 4060 8GB), accept a CPU-fallback
383
+ Tier D: run with `CUDA_VISIBLE_DEVICES= python -m train.smoke_test` — the invariants
384
+ still hold (just slower). Document in SMOKE_RESULT.md.
385
+ </how-to-verify>
386
+ <resume-signal>
387
+ Type "go" + paste the VERDICT: line from SMOKE_RESULT.md, OR "no-go" + paste the
388
+ failure mode + recovery plan. On "no-go", the orchestrator opens a gap-closure plan.
389
+ </resume-signal>
390
+ </task>
391
+
392
+ </tasks>
393
+
394
+ <verification>
395
+ - `ls train/smoke_test.py tests/test_smoke_e2e.py` — both present
396
+ - `pytest tests/test_smoke_e2e.py -v` — ≥4 green (CPU-only, all mocks)
397
+ - After Task 3: `SMOKE_RESULT.md` exists with `VERDICT: GO` (or documented NO-GO + recovery plan, which becomes the Phase 1 exit written-recovery-plan ROADMAP requires)
398
+ - `grep -q "TRN-04" train/smoke_test.py && grep -q "TRL #4543" train/smoke_test.py` — requirement tag + regression guard both present
399
+ - `git grep -nE 'hf_[A-Za-z0-9]{30,}|sk-ant-[A-Za-z0-9]{30,}' SMOKE_RESULT.md` — zero hits (threat T-01-07-02)
400
+ - `python -c "import ast; ast.parse(open('train/smoke_test.py').read())"` — exits 0
401
+ </verification>
402
+
403
+ <success_criteria>
404
+ TRN-04 satisfied: (1) `train/smoke_test.py` is a self-contained orchestrator exercising every Plan 01-06 component; (2) it uses the 0.5B Qwen smoke model and laptop-safe GRPO overrides; (3) it hard-asserts ≥1 llm() sub-call occurred during rollout (TRL #4543 guard); (4) it writes SMOKE_RESULT.md with explicit-allow-list fields (no secrets); (5) 4-test CPU regression green; (6) the actual 0.5B smoke run on the user's laptop produced SMOKE_RESULT.md with `VERDICT: GO` — this is the ROADMAP Phase 1 exit gate, the final piece closing the GO/NO-GO decision before the team travels to Bangalore.
405
+ </success_criteria>
406
+
407
+ <output>
408
+ After completion, create `.planning/phases/01-fri-parallel-build/01-07-SUMMARY.md` with: the pasted SMOKE_RESULT.md invariant table, the pytest output for test_smoke_e2e.py, the verdict (GO or NO-GO with recovery), the measured loss/grad_norm/llm_calls values, and one-line attestation "TRN-04 COMPLETE — Phase 1 EXIT GATE {GREEN|RED}".
409
+ </output>
.planning/research/ARCHITECTURE.md ADDED
@@ -0,0 +1,637 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Architecture Patterns
2
+
3
+ **Project:** FATHOM — RL-Trained Recursive Language Model
4
+ **Domain:** OpenEnv environment + TRL/Unsloth GRPO pipeline for recursive context management
5
+ **Researched:** 2026-04-22
6
+ **Overall confidence:** HIGH (OpenEnv scaffold + TRL GRPO patterns verified against official docs; reference impls exist in `alexzhang13/rlm-minimal` and `openenv/verifiers.RLMEnv`)
7
+
8
+ ---
9
+
10
+ ## Executive Summary
11
+
12
+ FATHOM is a five-process system bound together by two networks: HTTP (env ↔ trainer) and inference gRPC/HTTP (env ↔ vLLM/Unsloth server). The repository splits along seven top-level packages — `env/`, `rewards/`, `data/`, `train/`, `eval/`, `viz/`, `configs/` — each with a single, unambiguous responsibility. The environment server is the architectural pivot: it owns the Python REPL sandbox, the `llm()` primitive, task state, and reward emission. Everything else is either (a) feeding it, (b) training against it, or (c) rendering its traces.
13
+
14
+ The critical path is a single-file sequence: **env server must run end-to-end (reset → step → reward) before anything else can be validated.** Once the env is green, dataset generation, reward auditing, SFT, and visualization can parallelize cleanly between two engineers. GRPO training is the final merge point — it consumes SFT checkpoints, env client, reward function, and config, and emits the trajectories the demo depends on.
15
+
16
+ Config flips (1.5B ↔ 3B, α values, LoRA rank) are surfaced as Hydra/YAML overrides — no code branches. Failure isolation is designed in: env server crashes don't kill the trainer (catch + zero-reward + continue), SFT failure still permits GRPO from base model (warm-start is an optimization, not a dependency), and checkpointing every 50 steps means venue power-failure loses ≤10 minutes.
17
+
18
+ ---
19
+
20
+ ## 1. Component Diagram
21
+
22
+ ```
23
+ ┌─────────────────────────────────────────────────────────────────────┐
24
+ │ FATHOM SYSTEM │
25
+ │ │
26
+ │ ┌──────────────────┐ HTTP ┌──────────────────┐ │
27
+ │ │ TRAINER │ ◄────── StepResult ───►│ ENV SERVER │ │
28
+ │ │ (train/grpo.py)│ ────── Action ────►│ (env/server/) │ │
29
+ │ │ │ │ │ │
30
+ │ │ • GRPOTrainer │ │ • FastAPI app │ │
31
+ │ │ • Unsloth model│ │ • RLMEnv logic │ │
32
+ │ │ • reward_fn │ │ • Python REPL │ │
33
+ │ │ • W&B logger │ │ • llm() primitive│ │
34
+ │ └──────────────────┘ └────────┬─────────┘ │
35
+ │ │ │ │
36
+ │ │ │ HTTP/gRPC │
37
+ │ │ imports ▼ │
38
+ │ ▼ ┌──────────────────┐ │
39
+ │ ┌──────────────────┐ │ INFERENCE │ │
40
+ │ │ REWARDS │ │ SERVER │ │
41
+ │ │ (rewards/) │ │ (Unsloth vLLM) │ │
42
+ │ │ │ │ • Qwen2.5-1.5B │ │
43
+ │ │ • format_gate │ │ + LoRA adapter│ │
44
+ │ │ • correctness │ │ • serves llm() │ │
45
+ │ │ • token_budget │ │ sub-calls │ │
46
+ │ │ • recursion_eff │ └──────────────────┘ │
47
+ │ │ • compose │ │
48
+ │ └──────────────────┘ │
49
+ │ │
50
+ │ ┌──────────────────┐ reads JSONL ┌──────────────────┐ │
51
+ │ │ DATA GENERATOR │ ────────────────►│ DATASETS │ │
52
+ │ │ (data/generate)│ │ data/{train, │ │
53
+ │ │ │ │ eval}.jsonl │ │
54
+ │ └──────────────────┘ └────────┬─────────┘ │
55
+ │ │ │
56
+ │ ▼ │
57
+ │ ┌──────────────────┐ │
58
+ │ │ SFT PIPELINE │ │
59
+ │ │ (train/sft.py) │ │
60
+ │ │ • 500 traces │ │
61
+ │ │ from Claude │ │
62
+ │ │ → LoRA adapter │ │
63
+ │ └──────────────────┘ │
64
+ │ │
65
+ │ ┌──────────────────┐ ┌──────────────────┐ │
66
+ │ │ EVAL │ ──── checkpoint ─────► │ VIZ │ │
67
+ │ │ (eval/run_eval)│ ──── trajectories ───► │ (viz/) │ │
68
+ │ │ │ │ • tree.py (D3) │ │
69
+ │ │ • per-task acc │ │ • pareto.py │ │
70
+ │ │ • Pareto sweep │ │ • streamlit app │ │
71
+ │ │ • α={.05,.2,.5} │ └──────────────────┘ │
72
+ │ └──────────────────┘ │
73
+ └─────────────────────────────────────────────────────────────────────┘
74
+ ```
75
+
76
+ ### Component Boundaries
77
+
78
+ | Component | Responsibility | Inputs | Outputs | Talks To |
79
+ |-----------|---------------|--------|---------|----------|
80
+ | `env/server/environment.py` | Task lifecycle: load context, execute REPL actions, invoke `llm()`, return observation + reward | `ResetRequest`, `StepRequest` | `Observation`, `reward`, `done` | REPL sandbox, inference server |
81
+ | `env/server/repl.py` | Restricted Python execution with timeouts + whitelisted imports | Python code string, exec state dict | stdout/stderr/value/exception | Inference server (via `llm()` injected builtin) |
82
+ | `env/server/llm_primitive.py` | `llm(prompt, chunk, depth)` — sub-LM call with depth counter and token accounting | prompt, chunk id, depth | text response, token count | Inference server HTTP |
83
+ | `env/server/app.py` | FastAPI router wiring `reset`/`step`/`state` endpoints | HTTP | JSON | — |
84
+ | `env/models.py` | Pydantic dataclasses: `FathomAction`, `FathomObservation`, `FathomState` | — | typed schemas | env, client, trainer |
85
+ | `env/client.py` | `FathomEnv(HTTPEnvClient)` — trainer-side client | action | `StepResult` | env server (HTTP) |
86
+ | `rewards/format_gate.py` | Pass/fail on output schema (XML tags, parseable answer block) | trajectory | bool | — |
87
+ | `rewards/correctness.py` | Exact-match or normalized-string comparison to gold | trajectory, gold | 0.0 or 1.0 | — |
88
+ | `rewards/token_budget.py` | α-parameterized shaping: `-α × (tokens / budget)` | trajectory, α | float | — |
89
+ | `rewards/recursion_efficiency.py` | Bonus for `depth > 1` resulting in correct answer | trajectory | float | — |
90
+ | `rewards/compose.py` | `Sequential(Gate(...), WeightedSum([...]))` — emits dict + composite | trajectory, gold | `{composite, format, correctness, tokens, depth}` | trainer, eval |
91
+ | `data/generate.py` | Deterministic synthetic generator: needle, multi-needle, extractive, counting | seed, difficulty | `{train,eval}.jsonl` | — |
92
+ | `data/templates.py` | Prompt/context templates per task type | task spec | formatted strings | `generate.py` |
93
+ | `train/sft.py` | Unsloth SFT on 500 Claude-generated trajectories | base model, traces | adapter checkpoint | HF Hub (adapter push optional) |
94
+ | `train/grpo.py` | TRL `GRPOTrainer` + Unsloth model + env client + reward fn | SFT adapter, dataset, config | adapter checkpoint + W&B logs | env server, inference server, W&B |
95
+ | `train/rollout.py` | Multi-turn rollout loop: obs → model → action → step, with retry/timeout | env client, model, prompt | trajectory | env server |
96
+ | `eval/run_eval.py` | Sweep checkpoint × eval set × α values | adapter, eval set, α list | `results.json`, `pareto.json`, `trajectories.jsonl` | env server, inference server |
97
+ | `viz/tree.py` | Render recursion tree from trajectory JSON | trajectory | HTML (D3) | — |
98
+ | `viz/pareto.py` | Render accuracy-vs-tokens curves | pareto.json | HTML (plotly) | — |
99
+ | `viz/app.py` | Streamlit demo: paste doc + question → live rollout → tree render | user input | UI | env server (optional, if mounted) |
100
+ | `configs/model/*.yaml` | Model cards: 1.5B, 3B, LoRA ranks | — | YAML | Hydra |
101
+ | `configs/train/*.yaml` | SFT + GRPO hyperparams | — | YAML | Hydra |
102
+ | `configs/task/*.yaml` | Difficulty curriculum, task mix | — | YAML | generator, env |
103
+
104
+ ---
105
+
106
+ ## 2. File Layout
107
+
108
+ ```
109
+ fathom/
110
+ ├── configs/ # Hydra config groups
111
+ │ ├── config.yaml # root: defaults, chooses model, train, task
112
+ │ ├── model/
113
+ │ │ ├── qwen_1_5b.yaml # primary
114
+ │ │ └── qwen_3b.yaml # stretch (same keys, different values)
115
+ │ ├── train/
116
+ │ │ ├── sft.yaml
117
+ │ │ └── grpo.yaml # includes num_generations, max_prompt_length, beta, reward_weights
118
+ │ ├── task/
119
+ │ │ ├── easy.yaml # 4K-16K context
120
+ │ │ ├── medium.yaml # 16K-64K
121
+ │ │ └── hard.yaml # 64K-200K
122
+ │ └── reward/
123
+ │ └── default.yaml # α, correctness weight, format gate bool
124
+ ├── env/
125
+ │ ├── __init__.py # re-exports FathomAction/Observation/State/Env
126
+ │ ├── models.py # Pydantic dataclasses
127
+ │ ├── client.py # FathomEnv(HTTPEnvClient)
128
+ │ ├── README.md
129
+ │ └── server/
130
+ │ ├── __init__.py
131
+ │ ├── environment.py # RLMEnv subclass of openenv Environment
132
+ │ ├── repl.py # RestrictedPython + subprocess sandbox
133
+ │ ├── llm_primitive.py # sub-LM HTTP caller with depth counter
134
+ │ ├── app.py # FastAPI via create_fastapi_app
135
+ │ └── Dockerfile # for HF Space deploy
136
+ ├── rewards/
137
+ │ ├── __init__.py # exports compose_reward_fn()
138
+ │ ├── format_gate.py
139
+ │ ├── correctness.py
140
+ │ ├── token_budget.py
141
+ │ ├── recursion_efficiency.py
142
+ │ ├── compose.py # Sequential + WeightedSum combinator
143
+ │ └── audit/ # anti-hacking tests
144
+ │ ├── masked_context.py
145
+ │ ├── format_only_baseline.py
146
+ │ └── length_exploit.py
147
+ ├── data/
148
+ │ ├── generate.py # CLI: python -m data.generate --config-name task/medium
149
+ │ ├── templates.py # task type templates
150
+ │ ├── generators/
151
+ │ │ ├── needle.py
152
+ │ │ ├── multi_needle.py
153
+ │ │ ├── extractive.py
154
+ │ │ └── counting.py
155
+ │ ├── train.jsonl # generated artifact
156
+ │ └── eval.jsonl # generated artifact
157
+ ├── train/
158
+ │ ├── sft.py # Unsloth SFTTrainer, loads claude_traces.jsonl
159
+ │ ├── grpo.py # Unsloth + TRL GRPOTrainer
160
+ │ ├── rollout.py # multi-turn loop with retry/timeout
161
+ │ ├── claude_traces.py # one-off script: hit Claude API, save 500 traces
162
+ │ └── callbacks.py # W&B logger, trajectory sampler, checkpointer
163
+ ├── eval/
164
+ │ ├── run_eval.py # CLI: eval checkpoint on eval.jsonl across α values
165
+ │ ├── metrics.py # accuracy, tokens_used, depth_used
166
+ │ └── pareto.py # frontier computation
167
+ ├── viz/
168
+ │ ├── tree.py # trajectory JSON → D3 HTML
169
+ │ ├── pareto.py # pareto.json → plotly HTML
170
+ │ ├── app.py # Streamlit live demo
171
+ │ └── static/ # D3 templates, CSS
172
+ ├── scripts/
173
+ │ ├── deploy_env_space.sh # openenv push → HF Space
174
+ │ ├── deploy_demo_space.sh # Gradio/Streamlit Space deploy
175
+ │ └── smoke_test.sh # 0.5B end-to-end on laptop
176
+ ├── tests/
177
+ │ ├── test_env.py # reset/step contract tests
178
+ │ ├── test_rewards.py # unit tests per reward component
179
+ │ ├── test_repl_sandbox.py # whitelist + timeout enforcement
180
+ │ └── test_client_server.py # round-trip serialization
181
+ ├── openenv.yaml # generated by openenv init
182
+ ├── pyproject.toml
183
+ ├── uv.lock
184
+ └── README.md
185
+ ```
186
+
187
+ ### Where Specific Things Live (Answering Question 1)
188
+
189
+ - **Python REPL sandbox:** `env/server/repl.py` — owns RestrictedPython compile + subprocess exec with timeout + whitelisted builtins (`re`, `json`, `math`, `len`, `str.split`, chunking helpers). NOT a separate service; in-process to env server but with subprocess isolation per `exec()` call.
190
+ - **`llm(prompt, chunk, depth)` implementation:** `env/server/llm_primitive.py` — a Python function *injected into the REPL's globals* as a builtin. When the REPL calls `llm(...)`, that function makes an HTTP call to the inference server, increments the depth counter stored in env state, tallies tokens, and returns the plain-text response. Sub-LM responses **never** re-enter the REPL as code (ENV-03 constraint).
191
+ - **Inference server:** Separate process. In training, it's the Unsloth-patched model served by TRL's internal vLLM worker (or Unsloth's `FastLanguageModel.for_inference`). In deployed HF Space demo, it's a lightweight Unsloth inference wrapper also in-process with the env server (single Space, single container). The `llm_primitive.py` resolves the endpoint via `os.environ["FATHOM_INFERENCE_URL"]`.
192
+
193
+ ---
194
+
195
+ ## 3. Data Flow (End-to-End)
196
+
197
+ ```
198
+ ┌──────────────────────────────────────────────────┐
199
+ │ GRPO TRAINING LOOP │
200
+ └──────────────────────────────────────────────────┘
201
+
202
+ [1] Trainer samples prompt from train.jsonl
203
+
204
+ │ prompt = {task_type, context, question, gold, difficulty}
205
+
206
+ [2] Trainer calls env.reset(task_id=k)
207
+ │── HTTP POST /reset ──────────────────────────► ENV SERVER
208
+ │ │
209
+ │ │ load context
210
+ │ │ init state: {depth=0, tokens=0, turn=0}
211
+ │ │ build system prompt w/ REPL tools
212
+ │ ▼
213
+ │◄───── Observation{text: "You have context of 80K tokens stored as `ctx`. Use repl to query."}
214
+
215
+ [3] Model generates N completions (GRPO num_generations, e.g. 8)
216
+ │ each completion = Python code block
217
+
218
+
219
+ [4] For each generation g in [0..N-1]:
220
+
221
+ │ rollout loop (up to max_turns):
222
+ │ (a) action = parse_code(g.current_text)
223
+ │ (b) env.step(action)
224
+ │ ────── HTTP POST /step ─────► ENV SERVER
225
+ │ │
226
+ │ │ exec(action.code) in REPL:
227
+ │ │ ├─ may call llm(prompt="summarize chunk 3", depth=1)
228
+ │ │ │ └─> HTTP to inference server
229
+ │ │ │ returns plain text
230
+ │ │ │ depth counter += 1, tokens += response_len
231
+ │ │ └─ captures stdout + final return value
232
+ │ │
233
+ │ │ Observation = {
234
+ │ │ stdout, stderr, return_val,
235
+ │ │ tokens_used, depth_max, turns_left,
236
+ │ │ answer_emitted: bool
237
+ │ │ }
238
+ │ │ reward = 0 (intermediate) or final_reward (terminal)
239
+ │ ▼
240
+ │ (c) append obs to trajectory; if done, break
241
+
242
+ │ trajectory_g = [obs_0, action_0, obs_1, action_1, ..., obs_T]
243
+
244
+ [5] For each trajectory, compute reward via compose_reward_fn:
245
+ │ format_gate = 1 if answer block parseable else 0 ← GATE
246
+ │ if gate fails: total = 0, skip rest
247
+ │ else:
248
+ │ correctness = exact_match(parsed_answer, gold) ← 0.75 weight
249
+ │ token_shaping = -α × (tokens_used / budget) ← 0.20 weight, α from config
250
+ │ recursion_bonus = 0.1 if (depth>1 and correct) ← 0.05 weight
251
+ │ composite = weighted_sum([corr, tok, rec])
252
+ │ log: {composite, format, correctness, tokens, depth} ← all columns to W&B
253
+
254
+ [6] GRPOTrainer computes group-relative advantages from composite reward
255
+ │ A_g = (r_g - mean(r_group)) / std(r_group)
256
+
257
+ [7] Compute KL-penalized policy gradient, apply Unsloth-patched optimizer
258
+
259
+ [8] Every 50 steps: save LoRA adapter → outputs/checkpoints/step_{N}/
260
+ Every 100 steps: sample 5 trajectories → render to trajectories_step_{N}.html
261
+ Every step: log reward columns to W&B
262
+ ```
263
+
264
+ ### Failure Paths Inside the Flow
265
+
266
+ | Failure Point | Handling | Isolation |
267
+ |--------------|----------|-----------|
268
+ | Env server 500 on /step | `rollout.py` catches, assigns `reward=0`, `done=True`, trajectory marked failed | Training continues; failed trajectory contributes zero advantage |
269
+ | REPL timeout (30s default) | Env returns `Observation{stderr: "TimeoutError", done: True, reward: 0}` | No trainer impact; logged as `timeout_rate` |
270
+ | Inference server OOM during llm() call | `llm_primitive.py` returns `"[inference error]"` + sets error flag; rollout continues with degraded info | Trajectory gets correct==0 via gate; no crash |
271
+ | Sub-LM call exceeds depth cap | `llm_primitive.py` returns `"[depth limit reached]"` | No recursion explosion; bounded |
272
+ | Claude API fails during SFT data gen | Retry 3× exponential; if still fails, skip that example | SFT starts with whatever traces succeeded (>=300 is enough) |
273
+ | GRPO step NaN/Inf | TRL default: skip batch; Unsloth has grad clipping | One bad step doesn't corrupt checkpoint |
274
+ | Venue power failure | Adapter saved every 50 steps → resume from latest | Max loss: 50 steps × ~20s = ~15 minutes |
275
+
276
+ ---
277
+
278
+ ## 4. Build Order (Critical Path)
279
+
280
+ **Total budget: ~40 hours over Thu Apr 23 → Sat Apr 25 evening.**
281
+
282
+ ### Phase 0: Foundation (Thu evening, ~6h) — SEQUENTIAL, solo
283
+
284
+ The critical path starts here and cannot be parallelized yet.
285
+
286
+ 1. `openenv init fathom` → scaffold (30min)
287
+ 2. Define `FathomAction`, `FathomObservation`, `FathomState` in `env/models.py` (1h)
288
+ 3. Implement `env/server/repl.py` with RestrictedPython + subprocess + timeout (2h)
289
+ 4. Implement `env/server/llm_primitive.py` stub (returns placeholder text initially) (30min)
290
+ 5. Implement `env/server/environment.py` wiring step/reset/state (1h)
291
+ 6. Wire `env/server/app.py` via `create_fastapi_app` + `env/client.py` (30min)
292
+ 7. **Smoke test:** start server, call `reset()` + `step(dummy_code)` from client, assert round-trip works (30min)
293
+
294
+ **Artifact at end of Phase 0:** Env server runs locally, accepts code, returns observation. No rewards yet, no real inference.
295
+
296
+ ### Phase 1: Parallelize — Thu night → Fri (~20h)
297
+
298
+ Once env contract is stable, split work:
299
+
300
+ **Engineer A (Hardik) — Training track:**
301
+ - A1. Install TRL + Unsloth, pick model card (2h)
302
+ - A2. Write `train/rollout.py` — multi-turn loop with retry/timeout (3h)
303
+ - A3. Write `train/sft.py` using Unsloth SFTTrainer skeleton (2h) *[blocked on SFT data from B3]*
304
+ - A4. Write `train/grpo.py` using `GRPOTrainer` + Unsloth model + env client (4h)
305
+ - A5. Run 0.5B smoke test on laptop RTX 4060 — proves pipeline compiles (2h)
306
+
307
+ **Engineer B (Pratham) — Data/Rewards track:**
308
+ - B1. Implement `rewards/*.py` — each as pure function, unit-tested (3h)
309
+ - B2. Implement `rewards/compose.py` — Sequential gate + WeightedSum (1h)
310
+ - B3. Implement `data/generate.py` — 4 task types, 1000 train + 200 eval (4h)
311
+ - B4. Generate 500 Claude SFT traces via `train/claude_traces.py` (2h API time, run in background)
312
+ - B5. Anti-hacking audit tests (REWARD-03, REWARD-04) (3h)
313
+ - B6. Deploy env server to HF Space via `openenv push` (2h)
314
+
315
+ **Sync points:**
316
+ - Mid-Fri: A2 consumes B1+B2 (reward fn import). Must agree on trajectory schema.
317
+ - End-Fri: Both feed into A5 smoke test. This is the GO/NO-GO gate before venue.
318
+
319
+ ### Phase 2: Venue Day 1 (Sat, ~10h) — MERGED CRITICAL PATH
320
+
321
+ - Launch 1.5B SFT on A100 (1h warm-start run) → save adapter
322
+ - Launch 1.5B GRPO from SFT adapter, 400 steps (~5h wall clock)
323
+ - During GRPO: Engineer B builds `viz/tree.py`, `viz/pareto.py`, `eval/run_eval.py`
324
+ - Hour 10 checkpoint: GO/NO-GO on 3B stretch (TRAIN-04 hard stop)
325
+
326
+ ### Phase 3: Venue Day 2 (Sun, ~10h) — PARALLEL, DEMO FOCUSED
327
+
328
+ - Engineer A: final eval sweep across α ∈ {0.05, 0.2, 0.5}, generate Pareto JSON
329
+ - Engineer B: build Streamlit/Gradio demo Space, render best trajectories
330
+ - Both: pitch rehearsal, Q&A prep, deploy demo Space, push repo
331
+ - **Last 4 hours: NO code changes. Pitch polish + buffer only.**
332
+
333
+ ### Dependency Graph
334
+
335
+ ```
336
+ [env contract] ────► [env.client] ────► [rollout] ────► [grpo] ──┐
337
+ │ ▲ │
338
+ │ │ ▼
339
+ └──► [repl+llm] ──► [env.server] ──► [HF Space deploy] [checkpoint]
340
+
341
+
342
+ [task templates] ──► [generate.py] ──► [train.jsonl] ───────────► [grpo input]
343
+ │ │ │
344
+ ▼ ▼ │
345
+ [claude traces] [eval.jsonl] ──► [run_eval] ◄──┘
346
+ │ │
347
+ ▼ ▼
348
+ [sft] ──► [sft adapter] ──► [grpo seed] [pareto.json + trajectories.jsonl]
349
+
350
+
351
+ [viz/*]
352
+
353
+
354
+ [demo Space]
355
+ ```
356
+
357
+ ### Critical Path
358
+
359
+ The single longest chain that gates the demo:
360
+
361
+ ```
362
+ env contract (2h) → repl+llm (3h) → env.server (1h) → rollout (3h) → grpo (4h)
363
+ → SFT warm-start run (1h) → GRPO run (5h) → eval sweep (2h) → viz (3h)
364
+ TOTAL ≈ 24h
365
+ ```
366
+
367
+ **Bottleneck: the GRPO run itself (5h on A100).** Nothing after it can start until it produces a checkpoint. This is why SFT must be ready *before* venue arrival, and why the 0.5B laptop smoke test (TRAIN-03) is non-negotiable — it catches pipeline bugs when fixing them is still cheap.
368
+
369
+ ---
370
+
371
+ ## 5. Parallelization Opportunities (2-Person Team)
372
+
373
+ | Track | Engineer A (Hardik) | Engineer B (Pratham) |
374
+ |-------|---------------------|----------------------|
375
+ | Pre-venue core | Env REPL + server + client | Dataset generator + reward components |
376
+ | Pre-venue extended | Rollout loop + GRPO wiring | SFT data gen (Claude traces) + SFT pipeline + anti-hack audit |
377
+ | Deploy | Model smoke test (0.5B laptop) | HF Space deploy + Dockerfile |
378
+ | Venue Day 1 | Launch + monitor training | Viz + eval runner |
379
+ | Venue Day 2 | Eval sweep + Pareto JSON | Streamlit demo + pitch assets |
380
+
381
+ **Clean interfaces that enable this split:**
382
+ - `env.models.FathomObservation` — both sides agree on schema first, then work independently
383
+ - `compose_reward_fn(trajectory, gold) -> dict` — A imports B's function; A doesn't care how it's computed
384
+ - `train.jsonl` format — B writes files to disk; A reads them; no runtime coupling
385
+
386
+ **Anti-coupling rules:**
387
+ - Engineer A never edits `rewards/` or `data/` during venue.
388
+ - Engineer B never edits `train/grpo.py` during venue.
389
+ - Disagreement? Whoever owns the file has final call.
390
+
391
+ ---
392
+
393
+ ## 6. Patterns to Follow
394
+
395
+ ### Pattern 1: Rewards as Pure Functions Returning Dicts
396
+
397
+ **What:** Every reward component is a pure function: `(trajectory, gold, **kwargs) -> dict`. Composition wraps them.
398
+
399
+ **When:** Always. TRL v1.0 accepts `list[reward_fn]` with `reward_weights`, but our composition is richer (sequential gate + weighted sum), so we present one composite fn to TRL and log columns separately.
400
+
401
+ **Example:**
402
+ ```python
403
+ # rewards/format_gate.py
404
+ def format_gate(trajectory, gold, **kw) -> dict:
405
+ answer = extract_answer_block(trajectory)
406
+ return {"format": 1.0 if answer is not None else 0.0}
407
+
408
+ # rewards/compose.py
409
+ def compose_reward_fn(alpha: float):
410
+ def _fn(completions, prompts, **kw):
411
+ results = []
412
+ for comp, prompt in zip(completions, prompts):
413
+ traj = parse_trajectory(comp, prompt)
414
+ gold = kw["gold"]
415
+ r_format = format_gate(traj, gold)["format"]
416
+ if r_format == 0:
417
+ results.append({"composite": 0.0, "format": 0, "correctness": 0,
418
+ "tokens": 0, "depth": 0})
419
+ continue
420
+ r_corr = correctness(traj, gold)["correctness"]
421
+ r_tok = token_budget(traj, alpha=alpha)["token_shaping"]
422
+ r_rec = recursion_efficiency(traj)["recursion_bonus"]
423
+ composite = 0.75 * r_corr + 0.20 * r_tok + 0.05 * r_rec
424
+ results.append({"composite": composite, "format": r_format,
425
+ "correctness": r_corr, "tokens": r_tok, "depth": r_rec})
426
+ # GRPOTrainer expects a list of floats (composite). Log the rest via callback.
427
+ _log_columns_to_wandb(results)
428
+ return [r["composite"] for r in results]
429
+ return _fn
430
+ ```
431
+
432
+ **Why this pattern:** Each component unit-testable in isolation. Audit tests (REWARD-03/04) can exercise single components. α parameterization is a config knob, not a code change.
433
+
434
+ ### Pattern 2: Config-Driven Model Selection (Hydra)
435
+
436
+ **What:** Model size, LoRA rank, α — all YAML overrides. Zero code branches on model identity.
437
+
438
+ **Example:**
439
+ ```yaml
440
+ # configs/config.yaml
441
+ defaults:
442
+ - model: qwen_1_5b
443
+ - train: grpo
444
+ - task: medium
445
+ - reward: default
446
+
447
+ # configs/model/qwen_1_5b.yaml
448
+ name: unsloth/Qwen2.5-Coder-1.5B-Instruct
449
+ lora_rank: 16
450
+ lora_alpha: 32
451
+ max_seq_length: 16384
452
+ load_in_4bit: true
453
+
454
+ # configs/model/qwen_3b.yaml
455
+ name: unsloth/Qwen2.5-Coder-3B-Instruct
456
+ lora_rank: 32
457
+ lora_alpha: 64
458
+ max_seq_length: 16384
459
+ load_in_4bit: true
460
+ ```
461
+
462
+ **Flip:** `python -m train.grpo model=qwen_3b` — that's it. No diff in `grpo.py`.
463
+
464
+ ### Pattern 3: Env as Sole Source of Truth for State
465
+
466
+ **What:** Trainer never maintains conversation state. Every piece of multi-turn state — depth counter, tokens used, context chunk handles — lives in env server's `FathomState`, keyed by episode ID.
467
+
468
+ **Why:** Enables env server restarts mid-training. Enables multiple concurrent rollouts. Enables the HF Space demo to use the same code path as training.
469
+
470
+ ### Pattern 4: SFT and GRPO Share Identical Prompt Template
471
+
472
+ **What:** Both pipelines format the input exactly the same way, using one function: `env.templates.format_system_prompt()`. SFT trains the model to emit `<code>...</code>` blocks; GRPO reinforces the same format via `format_gate`.
473
+
474
+ **Why:** Mismatched templates = warm-start wasted + immediate format reward collapse when GRPO starts. TRL v1.0 explicitly warns about this.
475
+
476
+ ---
477
+
478
+ ## 7. Anti-Patterns to Avoid
479
+
480
+ ### Anti-Pattern 1: REPL Returning Sub-LM Text to be Executed
481
+
482
+ **What goes wrong:** Sub-LM writes `import os; os.system(...)` disguised as a helpful answer; REPL executes it.
483
+
484
+ **Why bad:** Code-injection, quadruples sandbox attack surface, violates ENV-03.
485
+
486
+ **Instead:** Sub-LM responses are *strings returned from `llm()`* — they land as variable values, never as code. Root LM alone writes code.
487
+
488
+ ### Anti-Pattern 2: Synchronous Inference Coupling
489
+
490
+ **What goes wrong:** Env server loads the model in-process → can't scale rollouts, OOM mingles with REPL OOM.
491
+
492
+ **Why bad:** Impossible to debug memory issues; single failure kills everything.
493
+
494
+ **Instead:** Env server is stateless-about-inference. It calls an HTTP endpoint. The endpoint is Unsloth-backed in training, or a served model in demo. Failure domain is cleanly split.
495
+
496
+ ### Anti-Pattern 3: Reward Function Owning Dataset Schema
497
+
498
+ **What goes wrong:** Reward fn reaches into `data/` internals; schema drift breaks training.
499
+
500
+ **Why bad:** Reward + data become coupled; changing data format requires reward rewrite.
501
+
502
+ **Instead:** Reward takes `(trajectory, gold)` — two simple params. Everything data-specific (task_type, difficulty) rides in the trajectory metadata dict.
503
+
504
+ ### Anti-Pattern 4: HF Space Hosting Both Env and Training
505
+
506
+ **What goes wrong:** Attempt to train on HF Space. Runs out of quota in 10 min.
507
+
508
+ **Why bad:** Spaces are inference-grade, not training-grade.
509
+
510
+ **Instead:** HF Space hosts only the env server (for openenv compliance) and, in a second Space, the Gradio/Streamlit demo UI that *calls* the env + a CPU-or-small-GPU inference endpoint. Training runs on A100 credits at venue.
511
+
512
+ ---
513
+
514
+ ## 8. HF Space Deployment Topology
515
+
516
+ **Two Spaces, not one.**
517
+
518
+ ```
519
+ ┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐
520
+ │ HF Space 1: fathom-env │ │ HF Space 2: fathom-demo │
521
+ │ SDK: Docker │ │ SDK: Gradio or Streamlit │
522
+ │ │ │ │
523
+ │ Contents: env/server/* + Dockerfile│ │ Contents: viz/app.py + inference │
524
+ │ Endpoint: /reset, /step, /state │ │ wrapper (LoRA adapter) │
525
+ │ Purpose: OpenEnv compliance │ │ Calls: Space 1's /step │
526
+ │ (judges can openenv pull) │ │ Purpose: Live demo for judges │
527
+ │ │ │ │
528
+ │ Deployed: Day 1 (pre-training) │ │ Deployed: Day 4 (post-training, │
529
+ │ Via: `openenv push` │ │ with final ckpt) │
530
+ └─────────────────────────────────────┘ └─────────────────────────────────────┘
531
+ ```
532
+
533
+ **Why two:**
534
+ - Space 1 is the *reproducible artifact* — what `openenv pull fathom-env` returns. Must exist from Day 1.
535
+ - Space 2 is the *pitch surface* — it needs the trained adapter + nice UI + live tree viz. Doesn't exist until checkpoint is ready.
536
+ - Coupling both into one Space risks: demo UI breaks → env compliance breaks → judges can't pull.
537
+
538
+ **Alternative considered:** Single Space with Gradio-mounted-on-FastAPI. Rejected because `openenv push` expects a specific container contract; bolting Gradio onto it creates undocumented failure modes.
539
+
540
+ ---
541
+
542
+ ## 9. Config Management (1.5B ↔ 3B Flip Strategy)
543
+
544
+ **Strategy:** Hydra with composition. One root config, multiple groups.
545
+
546
+ **Runtime switches:**
547
+ ```bash
548
+ # Primary run
549
+ python -m train.grpo
550
+
551
+ # Stretch (flips model file, LoRA rank, batch size all at once via its yaml)
552
+ python -m train.grpo model=qwen_3b train.per_device_batch=2
553
+
554
+ # α sweep at eval time
555
+ python -m eval.run_eval \
556
+ checkpoint=outputs/run1/step_400 \
557
+ 'reward.alpha=[0.05, 0.2, 0.5]' \
558
+ --multirun
559
+ ```
560
+
561
+ **What this protects against:**
562
+ - String-literal model names scattered across files → one canonical source.
563
+ - Forgetting to bump LoRA rank when going to 3B → config couples them.
564
+ - Incompatible α experiments → Hydra multirun gives separate output dirs automatically.
565
+
566
+ **Where NOT to use Hydra:**
567
+ - Env server runtime config (port, inference URL) — use plain `os.environ`. Env server should start in any Docker/Space environment without needing a YAML file.
568
+
569
+ ---
570
+
571
+ ## 10. Failure Isolation Matrix
572
+
573
+ | If this dies... | Then this still works | Because |
574
+ |----------------|----------------------|---------|
575
+ | SFT run | GRPO from base model | Warm-start improves sample efficiency, not correctness. GRPO gradient still flows. |
576
+ | Claude API (SFT data gen) | Everything else | SFT is optional. Skip it; degraded but not blocked. |
577
+ | Env server mid-training | Trainer loop | `rollout.py` catches HTTP errors, marks trajectory failed, continues. Logs `env_failure_rate`. |
578
+ | Env server indefinitely | Trainer continues with zero-reward trajectories (wastes compute) | **Detect via `env_failure_rate > 0.5` → auto-halt.** Restart env, resume from checkpoint. |
579
+ | Inference server during rollout | `llm()` returns `"[inference error]"`, rollout continues with degraded context | Trajectory gets correctness=0, contributes to advantage gradient |
580
+ | One REPL subprocess hangs | 30s timeout kills it, env returns TimeoutError obs | Subprocess isolation means no host-process pollution |
581
+ | W&B logging | Training continues | Logger is best-effort; metrics recoverable from checkpoint dir |
582
+ | HF Space env deploy | Local env + trainer still work | Space is for judge reproducibility, not training dependency |
583
+ | Demo Space | Judges see video walkthrough + repo | Have a screen-recorded fallback ready by Day 4 morning |
584
+ | GRPO run itself | Fall back to showing Pareto of *SFT-only* vs baseline | SFT alone demonstrates `viz` + `eval`; less impressive but still ships |
585
+
586
+ **The one failure we cannot survive:** env contract mismatch between server and client (e.g., Pydantic schema drift). **Prevention:** `tests/test_client_server.py` runs on every commit; CI would be ideal but venue time precludes it, so run manually before every push.
587
+
588
+ ---
589
+
590
+ ## 11. Scalability Considerations
591
+
592
+ | Concern | At training (16K context, 1 rollout) | At eval (200K context, 1 rollout) | At demo (user input, interactive) |
593
+ |---------|--------------------------------------|-----------------------------------|-----------------------------------|
594
+ | Context handling | Full context in env state dict | Chunked, accessed via REPL `ctx[start:end]` | Same as eval |
595
+ | Rollout wall-time | ~20s per turn | ~60s per turn (more chunks to scan) | Accept up to 90s; show spinner |
596
+ | Concurrent rollouts | 8 (GRPO num_generations) | 1 (serial) | 1 (serial) |
597
+ | Inference batch size | 8 | 1 | 1 |
598
+ | REPL timeout | 30s | 60s (longer chunking) | 60s |
599
+
600
+ **The 16K-train / 200K-eval generalization claim relies on:** the trained policy learning to *grep/chunk*, not learning to fit the full context. Architecture supports this because the REPL treats context as a variable — the model never has to attend to 200K tokens at once, it writes Python to access slices.
601
+
602
+ ---
603
+
604
+ ## 12. Sources & Confidence
605
+
606
+ | Source | Confidence | Used For |
607
+ |--------|------------|----------|
608
+ | [OpenEnv Environment Builder docs](https://meta-pytorch.org/OpenEnv/environment-builder/) | HIGH | Scaffold structure, file layout, `create_fastapi_app` pattern |
609
+ | [meta-pytorch/OpenEnv GitHub](https://github.com/meta-pytorch/OpenEnv) | HIGH | Environment base class, HTTPEnvClient contract, StepResult shape |
610
+ | [TRL GRPOTrainer docs](https://huggingface.co/docs/trl/en/grpo_trainer) | HIGH | `reward_weights`, multi-reward composition, `num_generations` |
611
+ | [TRL Reward Functions](https://huggingface.co/docs/trl/en/rewards) | HIGH | Composable reward patterns, `sum_then_normalize` vs `normalize_then_sum` |
612
+ | [TRL v1.0 release notes (MarkTechPost, Apr 2026)](https://www.marktechpost.com/2026/04/01/hugging-face-releases-trl-v1-0-a-unified-post-training-stack-for-sft-reward-modeling-dpo-and-grpo-workflows/) | MEDIUM | TRL+Unsloth integration claims, LoRA pipeline compatibility |
613
+ | [Unsloth Qwen3.5 fine-tune guide](https://unsloth.ai/docs/models/qwen3.5/fine-tune) | HIGH | SFT→GRPO checkpoint handoff, 4-bit load + LoRA pattern |
614
+ | [alexzhang13/rlm-minimal](https://github.com/alexzhang13/rlm-minimal) | HIGH | REPL env design, `llm()` primitive semantics, recursion-tree viz |
615
+ | [Prime Intellect RLM blog (Jan 2026)](https://www.primeintellect.ai/blog/rlm) | MEDIUM | `verifiers.RLMEnv` reference, trained-RLM gap statement |
616
+ | [dida.do: Secure Python Sandbox for LLM Agents](https://dida.do/blog/setting-up-a-secure-python-sandbox-for-llm-agents) | MEDIUM | RestrictedPython + subprocess isolation pattern |
617
+ | [TianPan: Agent Sandboxing Isolation Depth](https://tianpan.co/blog/2026-03-09-agent-sandboxing-secure-code-execution) | MEDIUM | Sandboxing level selection (we land at "RestrictedPython + subprocess + timeout"; Docker is not required at training because we control the code) |
618
+ | [HF Docker Spaces docs](https://huggingface.co/docs/hub/spaces-sdks-docker) | HIGH | Deploy pattern for env server Space |
619
+ | [TRL GRPO issue #3347 — vLLM disconnects](https://github.com/huggingface/trl/issues/3347) | MEDIUM | Informs retry/timeout handling in `rollout.py` |
620
+ | [Hydra intro docs](https://hydra.cc/docs/intro/) | HIGH | Config group composition + command-line override |
621
+
622
+ **Gaps / LOW confidence items:**
623
+ - Exact OpenEnv `openenv push` → HF Space mechanics: documented but lightly; plan includes 2h buffer for debugging container build.
624
+ - Whether TRL `GRPOTrainer` accepts a generator-function reward (closure over α) cleanly vs requires a class-based callable — treat as an experimental find-out during Phase 1; fallback is class-based.
625
+ - `verifiers.RLMEnv` ↔ OpenEnv interoperability: Prime Intellect says integration is underway; we build on OpenEnv directly and treat `verifiers.RLMEnv` as a pattern reference, not a dependency.
626
+
627
+ ---
628
+
629
+ ## 13. Quality Gate Self-Check
630
+
631
+ - [x] Components have clear responsibilities (no "and also") — each file in §1 table owns one thing
632
+ - [x] Data flow is explicit end-to-end (reset → step → reward → update → checkpoint) — §3 walks it
633
+ - [x] Build order respects dependencies (env → rewards → training → eval → demo) — §4 with graph
634
+ - [x] Parallelization opportunities are named (2-person team) — §5 explicit track split
635
+ - [x] Critical path identified — §4: env contract → repl+llm → env.server → rollout → grpo → eval → viz
636
+ - [x] Config/flag strategy for 1.5B↔3B switch is concrete — §9 with Hydra example
637
+ - [x] Failure-isolation notes (what happens when a component dies) — §10 matrix
.planning/research/FEATURES.md ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Feature Landscape — FATHOM
2
+
3
+ **Domain:** RL-trained Recursive Language Model for Meta OpenEnv Hackathon (Theme 2: Long-Horizon Planning) + Mercor sub-prize (token-budget-aware rewards)
4
+ **Researched:** 2026-04-22
5
+ **Overall confidence:** MEDIUM-HIGH (OpenEnv API and RLM paper verified; Mercor-specific sub-prize features inferred from the orchestrator-provided brief plus canonical token-budget RL literature)
6
+
7
+ ---
8
+
9
+ ## Legend
10
+
11
+ - **Complexity**: **S** ≤ 2 hours / **M** 2–6 hours / **L** 6–15 hours (of the 40-hour budget)
12
+ - **Visibility**: **Judge-visible** (appears in demo, pitch, repo top-level) / **Internal** (enables other features, not shown)
13
+ - **Confidence**: **HIGH** (verified via OpenEnv docs, RLM paper, TRL docs), **MEDIUM** (canonical RLHF/RLVR practice), **LOW** (WebSearch-only, author inference)
14
+
15
+ ---
16
+
17
+ ## TABLE STAKES
18
+
19
+ Features that, if missing, cause the submission to fail the "clear env design + objective rewards + demonstrable improvement + anti-reward-hacking + reproducible deployment + sharp demo" judging floor. These are non-negotiable — skip any one of them and we lose judge goodwill before the demo starts.
20
+
21
+ ### Environment (OpenEnv contract)
22
+
23
+ | # | Feature | Complexity | Visibility | Confidence | Notes |
24
+ |---|---------|-----------|-----------|------------|-------|
25
+ | TS-ENV-1 | `openenv init` scaffolded env with `reset()/step()/state()` trio, FastAPI server + HTTP client + Docker | M | Judge-visible | HIGH | Hackathon rubric and [OpenEnv tutorial](https://github.com/meta-pytorch/OpenEnv/blob/main/tutorial/01-environments.md) name this exact shape. Do NOT hand-roll the structure. |
26
+ | TS-ENV-2 | Pydantic typed Action / Observation / State / StepResult dataclasses | S | Internal | HIGH | Required by OpenEnv core; also prevents action-validation bugs. |
27
+ | TS-ENV-3 | `reset(seed=..., difficulty=...)` parameterized reset | S | Internal | HIGH | Seed control is required for reproducibility; difficulty enables curriculum (ENV-06). |
28
+ | TS-ENV-4 | Episode metadata in `state()`: episode_id, step_count, tokens_used_total, recursion_depth_current | S | Internal | HIGH | Judges will inspect state; undocumented state = suspicion. |
29
+ | TS-ENV-5 | Deterministic action validation — malformed Python rejected with structured error observation, not silent failure | S | Internal | HIGH | Required to survive adversarial eval traces. |
30
+ | TS-ENV-6 | Python REPL tool exposed with `llm(prompt, chunk, depth)` primitive | M | Judge-visible | HIGH | This is the thesis — fork [alexzhang13/rlm](https://github.com/alexzhang13/rlm), do not reinvent. |
31
+ | TS-ENV-7 | Deployed to HuggingFace Space *before* any training starts | S | Judge-visible | HIGH | Catches Docker/auth issues when cheap; also becomes DEMO-05. |
32
+
33
+ ### Rewards & Verifier
34
+
35
+ | # | Feature | Complexity | Visibility | Confidence | Notes |
36
+ |---|---------|-----------|-----------|------------|-------|
37
+ | TS-REW-1 | Deterministic exact-match / numeric verifier (no LLM judge) | S | Judge-visible | HIGH | RLVR literature: rule-based verifiers reduce hacking vs model-driven rewards. See [Reward Hacking Mitigation using Verifiable Composite Rewards](https://arxiv.org/html/2509.15557v1). |
38
+ | TS-REW-2 | Composable rubric: `Sequential(Gate(FormatCheck), WeightedSum([...]))` | M | Judge-visible | HIGH | Rubric structure is the single highest-leverage design doc the judges read. |
39
+ | TS-REW-3 | Per-component reward logging (format, correctness, tokens, depth) — not just composite | S | Judge-visible | HIGH | [TRL logging docs](https://huggingface.co/docs/trl/en/logging) support this; judges explicitly look for decomposed reward. |
40
+ | TS-REW-4 | Adversarial reward audit — manual attempts to fool verifier, documented in `REWARD_AUDIT.md` | M | Judge-visible | MEDIUM | Anti-reward-hacking is an explicit rubric criterion. A written audit is worth 5+ minutes of demo airtime. |
41
+ | TS-REW-5 | Masked-context leakage test (answer present in chunk 0 → model cannot shortcut) | S | Judge-visible | MEDIUM | Canonical audit from RLVR literature. Run once, screenshot result. |
42
+ | TS-REW-6 | Format-only baseline (empty answer but correct format → reward should be low) | S | Judge-visible | MEDIUM | Same as above. |
43
+ | TS-REW-7 | Length-exploit check (random padding tokens → reward should not increase) | S | Judge-visible | MEDIUM | Critical because we have token-budget shaping; judges will check we didn't invert the incentive. |
44
+
45
+ ### Training
46
+
47
+ | # | Feature | Complexity | Visibility | Confidence | Notes |
48
+ |---|---------|-----------|-----------|------------|-------|
49
+ | TS-TRN-1 | SFT warmup (~500 trajectories) before GRPO | M | Internal | HIGH | RLVR on small models from scratch almost never converges in <10h; SFT priming is canonical. |
50
+ | TS-TRN-2 | GRPO via TRL + Unsloth + LoRA — not PPO/DPO | L | Judge-visible | HIGH | Hackathon rubric names GRPO. [Unsloth RL Guide](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide) is the reference. |
51
+ | TS-TRN-3 | Untrained-baseline eval score recorded before training (target 25–50%) | S | Judge-visible | HIGH | Without this, "demonstrable improvement" has no anchor. |
52
+ | TS-TRN-4 | ≥400 optimization steps with reward curve saved | M | Judge-visible | HIGH | Fewer = judges suspect underfitting. More = diminishing returns for demo. |
53
+ | TS-TRN-5 | Checkpoint saved to HuggingFace Hub (LoRA adapter, `save_pretrained_merged` path) | S | Judge-visible | HIGH | Reproducibility requirement. |
54
+ | TS-TRN-6 | W&B (or TRL default tracker) with public run link in README | S | Judge-visible | HIGH | Judges will click through. A private / missing tracker link is a credibility tax. |
55
+
56
+ ### Demo
57
+
58
+ | # | Feature | Complexity | Visibility | Confidence | Notes |
59
+ |---|---------|-----------|-----------|------------|-------|
60
+ | TS-DEM-1 | Public GitHub repo with top-level README + reproducibility section | M | Judge-visible | HIGH | Judges grade the repo surface directly. |
61
+ | TS-DEM-2 | HF Space with interactive inference UI (Gradio default — no custom CSS) | M | Judge-visible | HIGH | "Live demo judges can poke" beats any slide. Gradio over Streamlit for ML demos per [HF docs](https://huggingface.co/docs/hub/en/spaces-sdks-gradio). |
62
+ | TS-DEM-3 | Reward curve chart (W&B screenshot or re-render) showing clean monotonic improvement | S | Judge-visible | HIGH | This IS demonstrable improvement. |
63
+ | TS-DEM-4 | Before/after trace comparison on a single 200K-token question | M | Judge-visible | HIGH | Narrative anchor — judges remember stories, not numbers. |
64
+ | TS-DEM-5 | `run_sft.sh`, `run_grpo.sh`, `run_eval.sh` — three-command reproducibility | S | Judge-visible | HIGH | Judges will skim for these in repo root; their presence is shorthand for "they know what they're doing." |
65
+
66
+ ### Pitch
67
+
68
+ | # | Feature | Complexity | Visibility | Confidence | Notes |
69
+ |---|---------|-----------|-----------|------------|-------|
70
+ | TS-PIT-1 | 3-minute pitch script rehearsed, problem-first opening | M | Judge-visible | HIGH | [Hackathon pitch guides](https://jorgeferreiro.com/blog/part-4-pitch-hackathon-idea-and-followup) converge: Problem (20s) → Solution intro (30s) → Demo (90s) → Results (30s) → Close (10s). |
71
+ | TS-PIT-2 | Pre-baked Q&A: "how is this different from RAG?", "why not bigger model?", "is this just prompting?", "how do you prevent reward hacking?" | M | Judge-visible | HIGH | Each answer ≤ 20 seconds. Practice these specifically. |
72
+ | TS-PIT-3 | Single tagline: "A 1.5B model answers questions about documents 50× its context window — because we trained it to use Python to read." | S | Judge-visible | MEDIUM | Memorable one-liner is the unit of propagation. |
73
+
74
+ ### Reproducibility & Safety
75
+
76
+ | # | Feature | Complexity | Visibility | Confidence | Notes |
77
+ |---|---------|-----------|-----------|------------|-------|
78
+ | TS-REP-1 | `requirements.txt` or `pyproject.toml` with pinned versions | S | Judge-visible | HIGH | Missing = "won't reproduce" = rubric penalty. |
79
+ | TS-REP-2 | Dockerfile for env + HF Space (OpenEnv gives this for free) | S | Judge-visible | HIGH | Already part of `openenv init`. |
80
+ | TS-REP-3 | README quickstart: clone → docker build → `curl localhost:8000/step` works in <5 min | M | Judge-visible | HIGH | A judge who gets it running in 5 minutes remembers you; one who doesn't, doesn't. |
81
+ | TS-REP-4 | REPL sandbox: import whitelist + 30s subprocess timeout + 512MB memory cap | M | Internal | HIGH | See [Setting Up a Secure Python Sandbox](https://dida.do/blog/setting-up-a-secure-python-sandbox-for-llm-agents). The bare minimum below is cheap; skipping it invites a judge question we can't answer. |
82
+
83
+ ---
84
+
85
+ ## DIFFERENTIATORS
86
+
87
+ The features that beat other competent teams. Assume 5–10 teams will ship all the table stakes. Differentiators are what get us onto the podium.
88
+
89
+ ### Environment
90
+
91
+ | # | Feature | Complexity | Visibility | Confidence | Notes |
92
+ |---|---------|-----------|-----------|------------|-------|
93
+ | DF-ENV-1 | Curriculum-aware `reset(difficulty=easy/medium/hard)` driving training ramp | M | Judge-visible | HIGH | Few hackathon teams ship real curriculum. Our 4K→200K ramp is the thesis. |
94
+ | DF-ENV-2 | Multi-task generator (4 task types: NIAH 40%, multi-needle 30%, extractive QA 20%, counting 10%) | M | Judge-visible | HIGH | Single-task envs are weaker; multi-task with deterministic verifiers is rare. |
95
+ | DF-ENV-3 | Streaming observation for long LLM calls (avoids 30s HTTP timeout on 200K inference) | M | Internal | MEDIUM | Makes the live HF Space actually responsive at demo time. |
96
+ | DF-ENV-4 | `reset()` returns context-length metadata for observability plots at eval time | S | Judge-visible | MEDIUM | Enables accuracy-vs-length charts that are gold for the pitch. |
97
+
98
+ ### Rewards (Mercor-track differentiators)
99
+
100
+ | # | Feature | Complexity | Visibility | Confidence | Notes |
101
+ |---|---------|-----------|-----------|------------|-------|
102
+ | **DF-REW-1** | **Token-budget shaping parameterized by α — same trained checkpoint evaluated at multiple α at inference** | M | Judge-visible | HIGH | **THIS IS THE MERCOR HOOK.** Same checkpoint, sweep α ∈ {0.0, 0.1, 0.3, 0.7, 1.0}, plot Pareto. Direct hit on "capped/uncapped rewards where frontier model rewards scale with token output". |
103
+ | **DF-REW-2** | **Pareto frontier chart (accuracy vs tokens) with 3+ α curves + untrained baseline as strictly-dominated point** | M | Judge-visible | HIGH | This is the single most persuasive chart we can ship for the Mercor prize. |
104
+ | DF-REW-3 | Rubric visualization — a table in the README showing each component, weight, gate, and one example trajectory scored line-by-line | S | Judge-visible | MEDIUM | Makes the reward function auditable at a glance; rare among hackathon teams. |
105
+ | DF-REW-4 | Automated audit harness — `pytest -m reward_audit` runs masked-context, format-only, length-exploit checks; results in CI badge | M | Judge-visible | MEDIUM | Automating the audit (vs one-shot) demonstrates engineering rigor disproportionate to a weekend build. |
106
+ | DF-REW-5 | Human spot-check protocol — 20 trajectories manually reviewed per training run, findings logged | S | Judge-visible | MEDIUM | Human-in-the-loop eval for the reward, not the model. Pre-empts the "but is the verifier right?" question. |
107
+
108
+ ### Training
109
+
110
+ | # | Feature | Complexity | Visibility | Confidence | Notes |
111
+ |---|---------|-----------|-----------|------------|-------|
112
+ | DF-TRN-1 | Eval during training — accuracy on holdout every N steps, plotted alongside reward | M | Judge-visible | HIGH | Proves "reward up → real accuracy up", killing the reward-hacking suspicion live. |
113
+ | DF-TRN-2 | Sample 5–10 full trajectories every N steps, rendered to `trajectories/step_{N}.md` for manual inspection | S | Internal | HIGH | Catches reward hacking early; screenshots become demo content. |
114
+ | DF-TRN-3 | Pre-event 0.5B smoke-test on laptop — proves pipeline end-to-end before Bangalore | M | Internal | HIGH | De-risks the venue run. Not judge-visible but table-stakes for the team's sanity. |
115
+ | DF-TRN-4 | KL-divergence curve + clip-ratio curve shown alongside reward — proves stable optimization | S | Judge-visible | MEDIUM | [TRL docs](https://huggingface.co/docs/trl/en/logging) flag KL and clip ratio as primary stability metrics. |
116
+ | DF-TRN-5 | 3B-LoRA stretch run launched only if 1.5B converges by hour 10 | L | Judge-visible | LOW | Optional upside; skip if risky. Don't sacrifice 1.5B convergence for this. |
117
+
118
+ ### Demo
119
+
120
+ | # | Feature | Complexity | Visibility | Confidence | Notes |
121
+ |---|---------|-----------|-----------|------------|-------|
122
+ | **DF-DEM-1** | **Recursion-tree visualizer (D3 or streamlit) rendering live from Alex Zhang's `RLMLogger` jsonl** | L | Judge-visible | HIGH | The single most visually striking artifact we can ship. Fork [alexzhang13/rlm](https://github.com/alexzhang13/rlm)'s visualizer. |
123
+ | **DF-DEM-2** | **Three-panel before/after: untrained (dumps 18K, hallucinates) / trained (greps → 3 targeted sub-calls / Pareto chart)** | M | Judge-visible | HIGH | Narrative + data in one frame. This is the "one-chart-that-sells-it". |
124
+ | DF-DEM-3 | Interactive HF Space: user pastes a long doc + question, watches tree render live | L | Judge-visible | HIGH | 10× more memorable than a static demo. |
125
+ | DF-DEM-4 | Accuracy-vs-context-length chart (4K / 16K / 64K / 200K buckets) showing generalization from 16K training | S | Judge-visible | HIGH | Directly proves the paper's generalization claim. |
126
+ | DF-DEM-5 | 90-second demo video embedded in README (for async / offline judges) | M | Judge-visible | MEDIUM | Devpost-style demo video is an old-school best practice that's still effective. |
127
+
128
+ ### Pitch
129
+
130
+ | # | Feature | Complexity | Visibility | Confidence | Notes |
131
+ |---|---------|-----------|-----------|------------|-------|
132
+ | DF-PIT-1 | Dedicated Mercor sub-prize slide — Pareto frontier + one sentence: "Same checkpoint, different α. User picks the cost-accuracy point at inference." | S | Judge-visible | HIGH | Explicit sub-prize positioning doubles odds of the sub-prize. |
133
+ | DF-PIT-2 | "Power of three" structure — 3 headline claims, 3 pieces of evidence, 3 Q&A | S | Judge-visible | MEDIUM | Canonical pitch structure; forces compression. |
134
+ | DF-PIT-3 | Problem-first opening with the "models stop reading past 30K tokens" hook rather than "we built an RLM" | S | Judge-visible | MEDIUM | RLM is niche; the long-context problem is universal. Niche concept becomes a mechanism detail, not a prerequisite. |
135
+ | DF-PIT-4 | Close on the Pareto chart, not the reward curve | S | Judge-visible | MEDIUM | Reward curves say "we trained something". Pareto says "we gave the user a knob." The knob is the product. |
136
+
137
+ ### Reproducibility & Metrics
138
+
139
+ | # | Feature | Complexity | Visibility | Confidence | Notes |
140
+ |---|---------|-----------|-----------|------------|-------|
141
+ | DF-REP-1 | One-command repro: `bash reproduce.sh` clones, builds docker, runs eval, prints baseline + trained accuracy | M | Judge-visible | HIGH | Reproducibility rubric point. Most teams don't bother. |
142
+ | DF-REP-2 | Release-tagged submission commit (`v1.0-submission`) | S | Judge-visible | MEDIUM | Tiny signal, big credibility. |
143
+ | DF-REP-3 | Config files externalized (`configs/sft.yaml`, `configs/grpo.yaml`) — no hardcoded hyperparams | S | Judge-visible | MEDIUM | Easier to read, easier to tweak, signals maturity. |
144
+ | DF-MET-1 | **"Can't-miss" metric: holdout accuracy curve plotted OVER reward curve** — if reward goes up and holdout accuracy goes up in lockstep, reward-hacking is disproved in one chart | S | Judge-visible | HIGH | This is the single most powerful anti-hacking artifact short of a formal audit. |
145
+ | DF-MET-2 | Answer-length distribution plot (pre- vs post-training) — shows token budget shaping actually bent behavior | S | Judge-visible | MEDIUM | Concrete evidence α worked. |
146
+ | DF-MET-3 | Rollout-length + timeout-rate time series — proves the agent learned to call `llm()` efficiently | S | Judge-visible | MEDIUM | Additional evidence of selective recursion. |
147
+
148
+ ---
149
+
150
+ ## ANTI-FEATURES
151
+
152
+ Things we deliberately do NOT build. Each has a specific reason — not generic "out of scope" — so the team can defend the omission if a judge asks.
153
+
154
+ ### Algorithmic / Training
155
+
156
+ | Anti-Feature | Why Avoid | What to Do Instead |
157
+ |--------------|-----------|--------------------|
158
+ | **LLM-as-judge in the reward loop** | Three fatal flaws: (1) reward noise corrupts GRPO advantage estimates, (2) circular — model can learn to manipulate judge model, (3) judges distrust it on sight ("is the reward real?"). [RLVR literature](https://arxiv.org/html/2509.15557v1) converges on rule-based verifiers for exactly this reason. | Deterministic exact-match / numeric verifiers on synthesized tasks with known gold answers. |
159
+ | **Learned reward models (RM)** | Needs a large preference dataset we don't have, adds another failure surface, and is the classic hackable reward. | Hand-written composable rubrics with component audits. |
160
+ | **PPO or DPO** | Hackathon rubric names GRPO as canonical. PPO has a more complex value-network setup; DPO needs preference pairs we don't have. Deviating costs "judge goodwill" and TRL `GRPOTrainer` is the first-class path. | `trl.GRPOTrainer` with Unsloth. |
161
+ | **Training at recursion depth > 2** | Credit assignment through nested rollouts is unsolved for 1.5B at <10h budget. | Train at depth ≤ 2, demo at depth 3 at inference only. |
162
+ | **Sub-LMs writing Python** | Quadruples the failure surface (sandbox escape × nested exceptions × cascading timeouts) for ~15% more visual impressiveness. | Root-LM-only code execution — the paper's "hybrid RLM" — sub-LMs return plain text. |
163
+ | **RL-from-scratch (no SFT warmup)** | Small models almost never bootstrap format + scaffold usage from random policy in <10h. | SFT warmup on ~500 trajectories, then GRPO. |
164
+ | **Training context > 16K** | Rollout latency explodes quadratically; training throughput dies. | Train at 16K, claim generalization to 200K at eval — this IS the thesis. |
165
+ | **Real-world document training corpora** (OOLONG, BrowseComp, FineWeb subsets) | Breaks exact-match verification (real answers are fuzzy), invites reward hacking via spurious co-occurrences, slows debugging. | Synthetic deterministic tasks for training; real docs for eval showcase only. |
166
+
167
+ ### Environment / Infrastructure
168
+
169
+ | Anti-Feature | Why Avoid | What to Do Instead |
170
+ |--------------|-----------|--------------------|
171
+ | **Multi-turn human-in-the-loop evaluation** | Breaks determinism, eats time, not reproducible by judges. | Human-in-the-loop on 20 trajectories per run for AUDIT only, not for the reward itself. |
172
+ | **Custom frontend (React / Next.js / Tailwind)** | Judges don't score CSS. Every hour on the frontend is an hour not training. | Gradio default on HF Space; Streamlit if we need a richer viz. |
173
+ | **gVisor / Firecracker / full VM sandbox** | Setup eats 4+ hours, debug complexity kills velocity. | `exec()` with import whitelist + 30s subprocess timeout + 512MB RLIMIT. Bare-minimum to pass sniff-test. |
174
+ | **Multi-episode batching in the env** | Adds complexity; OpenEnv clients handle this at the training loop. | Single-episode-per-request HTTP API. |
175
+ | **WebSocket / gRPC streaming protocols** | Overengineered; HTTP polling suffices for our scales. | FastAPI HTTP + streaming responses where needed (DF-ENV-3). |
176
+
177
+ ### Scope Discipline
178
+
179
+ | Anti-Feature | Why Avoid | What to Do Instead |
180
+ |--------------|-----------|--------------------|
181
+ | **Other themes (Multi-Agent, World Modeling, Self-Improving)** | Theme-hopping dilutes the pitch; judges can't score "what is this trying to be?" Theme 2 is our bullseye. | Single-theme focus: Long-Horizon Planning, explicit in pitch slide 1. |
182
+ | **Comparing against GPT-4 / Claude / frontier models** | We're a 1.5B model; head-to-head against frontier on absolute accuracy is not the story. | Compare against the untrained 1.5B baseline (apples-to-apples); mention frontier only in the Pareto context ("matches GPT-5 at 1/10 the tokens"). |
183
+ | **Formal proofs or theory section in the paper** | Hackathon ≠ academic paper; judges skim. | Empirical claims + charts + one paragraph of intuition. |
184
+ | **Leaderboard submission to existing benchmarks (NIAH/BABILong)** | Requires their eval harness, their format, their integration. Time sink. | Our own eval set with exact-match verifier; cite NIAH as inspiration. |
185
+ | **Multiple base models (Qwen + Llama + Mistral)** | Each adds a new failure mode and tripled eval time. | Qwen2.5-Coder-1.5B only, 3B LoRA as stretch. |
186
+ | **Unit tests with >80% coverage** | Nobody grades test coverage. Integration / smoke tests matter. | 1 smoke test per pipeline stage, reward-audit pytest, that's it. |
187
+
188
+ ### Presentation
189
+
190
+ | Anti-Feature | Why Avoid | What to Do Instead |
191
+ |--------------|-----------|--------------------|
192
+ | **Startup pitch deck (TAM/SAM/traction)** | Hackathon judges want to see the HACK, not the business. | Problem → solution → demo → results → close, all technical. |
193
+ | **Technique-first opening ("We built an RLM")** | RLM is niche; a cold judge's reaction is "what's that?" which burns 30s of pitch time on definitional explanation. | Problem-first opening: "Your model stops reading past 30K tokens. Here's what we did about it." |
194
+ | **Live training during the pitch** | One crash kills the demo. | Pre-recorded training curve + live inference on trained checkpoint. |
195
+ | **Apologizing for what we didn't ship** | Burns credibility. | Positive framing — "We ship what's deterministic. Depth-3 is demo-only by design." |
196
+
197
+ ---
198
+
199
+ ## Feature Dependencies
200
+
201
+ ```
202
+ TS-ENV-1 (openenv init)
203
+ ├── TS-ENV-2 (Pydantic dataclasses)
204
+ ├── TS-ENV-3 (seed + difficulty) ─ enables DF-ENV-1 (curriculum)
205
+ ├── TS-ENV-6 (REPL + llm primitive) ─ enables DF-DEM-1 (tree viz)
206
+ └── TS-ENV-7 (HF Space deploy) ─ enables TS-DEM-2 (interactive demo)
207
+ └── DF-DEM-3 (interactive tree)
208
+
209
+ TS-REW-1,2,3 (rubric + logging)
210
+ ├── TS-REW-4 (adversarial audit) ─ enables DF-REW-4 (automated audit)
211
+ └── DF-REW-1 (α parameterization) ─ enables DF-REW-2 (Pareto chart)
212
+ └── DF-DEM-2 (three-panel)
213
+ └── DF-PIT-1 (Mercor slide)
214
+
215
+ TS-TRN-1 (SFT warmup)
216
+ └── TS-TRN-2 (GRPO)
217
+ ├── TS-TRN-4 (≥400 steps + reward curve)
218
+ ├── DF-TRN-1 (eval during train) ─ enables DF-MET-1 (can't-miss metric)
219
+ └── DF-TRN-2 (trajectory samples) ─ enables DF-DEM-2 (before/after)
220
+
221
+ TS-DEM-1 (GitHub README) ← all demo features converge here
222
+ TS-PIT-1 (3-min script) ← all pitch features converge here
223
+ ```
224
+
225
+ **Critical path**: TS-ENV-{1,2,3,6,7} → TS-REW-{1,2,3} → TS-TRN-{1,2} → DF-REW-1 → DF-REW-2 → DF-PIT-1. If any single link in that chain breaks, the Mercor sub-prize is gone.
226
+
227
+ ---
228
+
229
+ ## MVP Recommendation — Minimum to Clear Judging Floor
230
+
231
+ If we have to cut scope in venue (hour 20+ with a flat reward curve), ship this order:
232
+
233
+ 1. **TS-ENV-{1,2,3,6,7}** — the environment itself, deployed
234
+ 2. **TS-REW-{1,2,3}** — deterministic rubric with logged components
235
+ 3. **TS-TRN-{1,2,3,6}** — SFT→GRPO with W&B, untrained baseline captured
236
+ 4. **TS-DEM-{1,2,3,5}** — repo, HF Space, reward curve, reproduce script
237
+ 5. **TS-PIT-{1,2}** — pitch + Q&A rehearsed
238
+ 6. **DF-REW-1 + DF-REW-2** — α sweep + Pareto chart (Mercor hook)
239
+ 7. **DF-DEM-1** — recursion-tree viz (forked from alexzhang13/rlm)
240
+ 8. **DF-MET-1** — holdout accuracy over reward curve (anti-hacking kill-shot)
241
+ 9. **DF-DEM-2** — three-panel before/after
242
+ 10. **DF-PIT-1** — Mercor slide
243
+ 11. Everything else
244
+
245
+ Defer under scope pressure: DF-TRN-5 (3B stretch), DF-DEM-5 (demo video), DF-REW-4 (automated audit as pytest), DF-REW-5 (human spot-check protocol as separate doc — combined with DF-TRN-2 is fine).
246
+
247
+ **Hard "no" under scope pressure**: anything marked Anti-Feature. Do not cave on these even if the team wants to.
248
+
249
+ ---
250
+
251
+ ## Mercor Sub-Prize — Explicit Feature Callouts
252
+
253
+ These features exist specifically because the Mercor track wants "capped/uncapped rewards where frontier model rewards scale with token output":
254
+
255
+ - **DF-REW-1** — α parameterization, same checkpoint, multiple eval runs → Pareto curve
256
+ - **DF-REW-2** — The Pareto chart itself
257
+ - **DF-MET-2** — Answer-length distribution (proves token-shaping worked)
258
+ - **DF-PIT-1** — Dedicated sub-prize slide
259
+ - **DF-DEM-2** — Three-panel (the Pareto panel IS the Mercor pitch)
260
+
261
+ If we ship all five of these plus table stakes, we are the strongest candidate for that sub-prize that a 1.5B-model team could reasonably be. If we miss any one, the story fractures.
262
+
263
+ ---
264
+
265
+ ## Judge-Visible vs Internal — Quick Filter
266
+
267
+ **Anything a judge can see in 5 minutes without running code:**
268
+ - GitHub repo top-level (README, scripts, configs)
269
+ - HF Space (live interactive UI)
270
+ - Pitch slides + delivery
271
+ - W&B public run link
272
+ - Demo video embed
273
+
274
+ **Anything that needs code execution:**
275
+ - `reproduce.sh` exit code
276
+ - Trained checkpoint loads
277
+ - Pareto chart regenerates
278
+
279
+ **Everything else is internal** — invisible unless a judge digs. That means: SFT loss curves, sandbox internals, curriculum scheduling logic, episode metadata plumbing. We still build these, but we don't budget demo time to them.
280
+
281
+ ---
282
+
283
+ ## Confidence & Gap Assessment
284
+
285
+ **HIGH confidence** (directly from verified sources):
286
+ - OpenEnv API shape (from [meta-pytorch/OpenEnv docs](https://github.com/meta-pytorch/OpenEnv/blob/main/tutorial/01-environments.md))
287
+ - GRPO + Unsloth + TRL stack (from [Unsloth RL Guide](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide), [TRL logging](https://huggingface.co/docs/trl/en/logging))
288
+ - RLM scaffolding (from [Alex Zhang's blog](https://alexzhang13.github.io/blog/2025/rlm/) + [arXiv 2512.24601](https://arxiv.org/abs/2512.24601))
289
+ - RLVR anti-hacking practices ([arXiv 2509.15557](https://arxiv.org/html/2509.15557v1))
290
+ - 3-min pitch structure (converges across [multiple sources](https://jorgeferreiro.com/blog/part-4-pitch-hackathon-idea-and-followup))
291
+
292
+ **MEDIUM confidence** (canonical hackathon practice, not verified in Meta-specific docs):
293
+ - Specific rubric weights (0.75 / 0.20 / 0.05) — chosen by project team; defensible but not "the winning formula"
294
+ - "One-chart-that-sells-it" framing for Pareto — standard but not hackathon-verified
295
+ - Gradio > Streamlit for ML demos — generally true; our specific need for recursion-tree viz might flip to Streamlit
296
+
297
+ **LOW confidence** (gaps):
298
+ - No public winners from Meta OpenEnv SF hackathon (March 2026) yet — couldn't find post-mortems to crib from
299
+ - Mercor sub-prize specific rubric is not published; our α-sweep approach is our interpretation
300
+ - Whether judges have access to W&B or require local log files — default to shipping both
301
+ - 3B-LoRA convergence at <10h on A100-80GB is unverified for our specific GRPO config
302
+
303
+ **Gaps to resolve at venue:**
304
+ - Confirm judging rubric exact wording (may be handed out on day 1)
305
+ - Confirm Mercor sub-prize eligibility criteria and submission form
306
+ - Confirm A100 flavor (40GB vs 80GB) and compute credit budget
307
+ - Confirm whether HF Space CPU (free tier) suffices for inference demo or we need GPU Space
308
+
309
+ ---
310
+
311
+ ## Sources
312
+
313
+ - [Meta OpenEnv — meta-pytorch/OpenEnv GitHub](https://github.com/meta-pytorch/OpenEnv)
314
+ - [OpenEnv Tutorial — 01-environments.md](https://github.com/meta-pytorch/OpenEnv/blob/main/tutorial/01-environments.md)
315
+ - [OpenEnv Core API — meta-pytorch.org/OpenEnv](http://meta-pytorch.org/OpenEnv/core/)
316
+ - [OpenEnv AI Hackathon — PyTorch event](https://pytorch.org/event/openenv-ai-hackathon/)
317
+ - [Meta PyTorch Hackathon x SST — Scaler](https://www.scaler.com/school-of-technology/meta-pytorch-hackathon)
318
+ - [Recursive Language Models — Alex Zhang blog](https://alexzhang13.github.io/blog/2025/rlm/)
319
+ - [Recursive Language Models — arXiv 2512.24601](https://arxiv.org/abs/2512.24601)
320
+ - [alexzhang13/rlm — reference implementation](https://github.com/alexzhang13/rlm)
321
+ - [Recursive Language Models: the paradigm of 2026 — Prime Intellect](https://www.primeintellect.ai/blog/rlm)
322
+ - [Unsloth RL Guide — GRPO documentation](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide)
323
+ - [TRL Logging docs](https://huggingface.co/docs/trl/en/logging)
324
+ - [GRPO Training — Axolotl docs](https://docs.axolotl.ai/docs/grpo.html)
325
+ - [Reward Hacking in RLVR Systems](https://www.emergentmind.com/topics/reward-hacking-in-reinforcement-learning-with-verifiable-rewards-rlvr)
326
+ - [Reward Hacking Mitigation using Verifiable Composite Rewards — arXiv 2509.15557](https://arxiv.org/html/2509.15557v1)
327
+ - [Reward Hacking in Reinforcement Learning — Lil'Log](https://lilianweng.github.io/posts/2024-11-28-reward-hacking/)
328
+ - [Gradio Spaces — HF docs](https://huggingface.co/docs/hub/en/spaces-sdks-gradio)
329
+ - [Streamlit Spaces — HF docs](https://huggingface.co/docs/hub/en/spaces-sdks-streamlit)
330
+ - [Setting Up a Secure Python Sandbox for LLM Agents — dida.do](https://dida.do/blog/setting-up-a-secure-python-sandbox-for-llm-agents)
331
+ - [Hackathon Pitch, Judging and FollowUp — Jorge Ferreiro](https://jorgeferreiro.com/blog/part-4-pitch-hackathon-idea-and-followup)
332
+ - [6 Tips for Making a Winning Hackathon Demo Video — Devpost](https://info.devpost.com/blog/6-tips-for-making-a-hackathon-demo-video)
333
+ - [Curriculum for Reinforcement Learning — Lil'Log](https://lilianweng.github.io/posts/2020-01-29-curriculum-rl/)
334
+ - [Needle In A Haystack — gkamradt](https://github.com/gkamradt/LLMTest_NeedleInAHaystack)
335
+ - [BABILong — long-context needle-in-a-haystack benchmark](https://github.com/booydar/babilong)
.planning/research/PITFALLS.md ADDED
@@ -0,0 +1,489 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FATHOM — Domain & Execution Pitfalls
2
+
3
+ **Domain:** RL-trained Recursive Language Model on OpenEnv + TRL + Unsloth, 4-day Grand Finale runway
4
+ **Researched:** 2026-04-22 (Phase 6, pre-event)
5
+ **Overall confidence:** HIGH on GRPO/Unsloth/sandbox pitfalls (Context7-equivalent + GitHub issues + official docs), MEDIUM on venue-specific logistics, HIGH on the OpenEnv × TRL multi-step bug (GitHub issue #4543 is explicit).
6
+
7
+ **Severity key:**
8
+ - **RUIN** — single-point-of-failure for the demo; no recovery available in remaining time budget.
9
+ - **HIGH** — kills a core pitch beat (reward curve, viz, Pareto); recoverable only with significant rework.
10
+ - **MEDIUM** — slows a phase, may degrade polish, but pitch still lands.
11
+ - **NUISANCE** — wastes an hour, annoying, not threatening.
12
+
13
+ **Phase key:** Design | Scaffolding | SFT | Training | Demo | Pitch
14
+
15
+ ---
16
+
17
+ ## RUIN MODES — Single Points of Failure (enumerate-and-mitigate)
18
+
19
+ Every one of these ends the project with no recovery if we hit it late. Prevention is the only real defense; recovery columns assume we catch it early enough.
20
+
21
+ ### R1. Untrained 1.5B base has 0% success on every eval task → GRPO learns nothing
22
+ **Warning signs:** Pre-training baseline measurement (TRAIN-06) returns <5% correctness across all 4 task types. Reward-only signal comes from format gate, which the model memorizes in 20 steps and then plateaus.
23
+ **Why it happens:** Per Unsloth guide — "If the probability is always 0, then RL will never work." GRPO does distribution sharpening, not capability creation. Task difficulty calibration is the #1 unforced error in hackathon RL. Base models catch up to RL-trained models at higher k (pass@k research), but at k=1 the base must already sometimes succeed.
24
+ **Prevention:**
25
+ - Pre-event: run untrained-baseline measurement on 0.5B locally (TRAIN-06) + spot-check 1.5B via HF Inference API on 20 eval questions. Target 25–50% correctness on easiest tier.
26
+ - Build curriculum into `reset(difficulty=...)` (ENV-06) so Phase 1 of GRPO trains only on the tier where baseline is ≥25%.
27
+ - SFT warm-start with 500 Claude-generated trajectories (TRAIN-01) lifts base success rate before GRPO touches it. Skip SFT = skip the lottery ticket.
28
+ - Specifically design 2–3 "trivial" task variants (needle at position 0–4K, no recursion needed, answer literally in first sentence) so there's always a success-probability floor.
29
+ **Recovery (hour 30 with 18h left):** Shrink eval set to the tier the model can do. Show a reward curve on the easy tier; caveat in pitch is "we trained on X, generalizes to Y." Worse narrative but still a curve.
30
+ **Phase:** Design + SFT
31
+ **Severity:** RUIN
32
+
33
+ ### R2. A100 unavailable / rationed at venue for 8+ hours
34
+ **Warning signs:** Venue organizers announce compute queue on Saturday morning; our slot is 4 hours not continuous. Training run must survive preemption.
35
+ **Prevention:**
36
+ - Pre-event: write training script with `--resume-from-checkpoint` as default, `save_steps=20` (cheap on 1.5B LoRA), push adapters to HF Hub after every save (15 MB each, not the merged model).
37
+ - Have a 0.5B local fallback ready — can run 200-step GRPO on laptop RTX 4060 overnight as pure backup evidence ("same recipe, smaller model, same curve shape").
38
+ - Rent a backup A100 on RunPod or Lambda from Thu evening — $2–5/hr × 10hr contingency = <$50. Cheap insurance.
39
+ **Recovery (hour 30):** Run the 0.5B demo end-to-end. Pitch reframe: "we built the recipe at 0.5B to de-risk; 1.5B is spinning on Hub, here's the partial curve at step 200." Truth-adjacent, still evidence.
40
+ **Phase:** Scaffolding + Training
41
+ **Severity:** RUIN (if venue A100 is the ONLY compute plan)
42
+
43
+ ### R3. Reward function has a loophole we didn't audit; training curve climbs but model is cheating
44
+ **Warning signs:** Reward climbs fast (0 → 0.8 in 50 steps) but sample trajectories look degenerate — model outputs same answer every time, or always answers "42", or grep'd nothing and got lucky. Masked-context test (REWARD-04) shows above-chance reward with gibberish context.
45
+ **Why it happens:** Classic RLVR failure. Models find: (a) printing the gold answer format with a guessed value, (b) exploiting numeric task distribution (most answers are ≤100), (c) reading gold from environment state if exposed, (d) caching across episodes via module globals, (e) format-gate-only reward (exit on first valid format). Emergent Mind's RLVR reward hacking survey and METR's recent reward hacking report both catalogue these.
46
+ **Prevention (REWARD-03, REWARD-04 are non-negotiable):**
47
+ - Before ANY training run: manually attempt 5 reward-hacking strategies against the verifier. Fix every loophole found.
48
+ - Automated anti-hacking audits run at step 0 + every 50 steps:
49
+ - **Masked-context test:** reward should be ≈baseline-random (uniform guess rate over answer space) with context replaced by gibberish. If reward > 2× baseline → context is leaking.
50
+ - **Format-only baseline:** valid-format-but-random-answer gets format-gate pass but 0 correctness. Verify composite reward for such traces is ≤ 0.05.
51
+ - **Length-exploit check:** mean reward by output length should not monotonically increase.
52
+ - **Human spot-check 20 trajectories per run.**
53
+ - Keep env state sealed: `state.answer` must NEVER appear in observation dict. Unit-test this.
54
+ - Reset sandbox globals between every episode (no module-level caches reachable from tool code).
55
+ **Recovery (hour 30):** Patch verifier (add length cap, require grep calls before answer, require answer-from-context substring). Restart training from the last pre-hacking checkpoint. If no clean checkpoint exists → restart from SFT checkpoint, run 200 more steps on patched reward. Costs ~4 hours of A100 time.
56
+ **Phase:** Design + Training
57
+ **Severity:** RUIN (Meta judges will open an LLM trace; if they see gibberish-passes-reward the submission is done)
58
+
59
+ ### R4. LoRA save bug destroys the trained checkpoint at hour 47
60
+ **Warning signs:** `save_pretrained_merged` prints "Merging weights into 16bit: 100%" but the output is corrupt — inference on the merged model produces gibberish or zero-length. Known Unsloth bugs: issue #1791 (merge_4bit_forced size mismatch), #2339 (merged_4bit_forced silently writes 16bit), #1267 (save_pretrained_merged "ruins my model"), #3882 (save produces empty dir in Docker), #1877 (GRPO adapter merge broken on retraining).
61
+ **Prevention:**
62
+ - Follow TRAIN-07 strictly. **Never** use `merge_4bit_forced`. Use: `model.save_pretrained("adapters/")` for adapter-only, then `model.save_pretrained_merged("merged_16bit/", tokenizer, save_method="merged_16bit")` for the inference model. This path is the most-tested.
63
+ - Verify the saved model BEFORE claiming training is done: run 3 eval questions against the saved dir. If output is garbled, checkpoint is corrupt.
64
+ - Every 100 training steps: save adapters-only to HF Hub (15 MB, takes seconds). Adapter files never hit the broken merge path.
65
+ - Have two save paths: (A) adapters-only for reproducibility/demo model-loading, (B) merged-16bit for fast inference only.
66
+ - Pin Unsloth version (whatever works in the Thu smoke test) — do not `pip install -U unsloth` at venue.
67
+ **Recovery (hour 47):** Load adapters-only checkpoint + base model in memory for demo. Forget about HF Space merged deployment — serve from laptop with ngrok tunnel to HF Space UI, or just live-demo from laptop's gradio on the projector.
68
+ **Phase:** Training + Demo
69
+ **Severity:** RUIN → HIGH (recoverable if adapter-only was saved in parallel)
70
+
71
+ ### R5. Full Claude Code context compaction at venue destroys session state mid-training
72
+ **Warning signs:** Claude Code's "Context Left Until Auto-Compact" hits 0% during a critical debug window. Post-compaction Claude has forgotten: which checkpoint is the good one, what reward weights we tuned to, which bug we already fixed, which W&B run is authoritative.
73
+ **Why it happens:** Claude Code auto-compacts when context fills up. Early session decisions get compressed or dropped. 48-hour venue session = multiple compactions guaranteed.
74
+ **Prevention:**
75
+ - Maintain `.planning/VENUE_STATE.md` as authoritative ground truth, updated before every /compact:
76
+ - Current A100 SSH command + tmux session name
77
+ - Latest good checkpoint path on Hub + commit hash
78
+ - Active W&B run ID + current best reward
79
+ - Next planned step + blocker
80
+ - Reward weights currently in use
81
+ - Use /compact PROACTIVELY at 60% context with an explicit "preserve VENUE_STATE.md + current bug" note. Do not wait for auto-compaction.
82
+ - Every commit message is a checkpoint — `git log --oneline` survives compaction perfectly.
83
+ - Critical "what is true" lives in files, not in chat history.
84
+ **Recovery (post-compaction):** Read `VENUE_STATE.md` + last 5 git commits + latest W&B run + most recent `.planning/` files. Rebuild situational awareness in 10 minutes.
85
+ **Phase:** All venue phases
86
+ **Severity:** HIGH (not ruin because VENUE_STATE.md is cheap insurance)
87
+
88
+ ### R6. Live demo fails during pitch (Space cold start, projector, network drop)
89
+ **Warning signs:** HF Space cold boots can take 2+ minutes (discuss.huggingface.co thread on slow cold boot). Venue wifi drops. HDMI adapter doesn't match projector.
90
+ **Prevention:**
91
+ - **PRIMARY DEFENSE:** 60-second recorded video of the demo (screen capture + voiceover), committed to the repo and loaded on laptop before pitch. This is DEMO-01's non-negotiable backup.
92
+ - Keep HF Space warm during the judging window: one of us opens it on phone 5 minutes before pitch slot.
93
+ - Carry: HDMI + USB-C + Mini-DisplayPort adapters. Phone as hotspot backup. Laptop battery >80% at pitch time.
94
+ - The recursion-tree viz should be pre-rendered as a static `assets/demo.gif` + live version. The GIF always plays.
95
+ **Recovery (pitch time, Space is down):** Open the recorded video. "The live Space is deployed at [URL], here's the same recording we've been running all weekend." Credibility preserved.
96
+ **Phase:** Demo + Pitch
97
+ **Severity:** HIGH (not ruin given backup video exists)
98
+
99
+ ---
100
+
101
+ ## HIGH SEVERITY — Kill a Pitch Beat
102
+
103
+ ### H1. GRPO entropy collapse → flat reward after initial climb
104
+ **Warning signs:** W&B `entropy` metric trending to 0 in 100–200 steps. Reward plateau at modest level. Out-of-distribution eval tanks even as in-distribution climbs. Generation diversity within a group drops to zero (all 8 completions identical).
105
+ **Why it happens:** Entropy collapse is "the silent killer of RLVR generalization" (Promptfoo RLVR article; EDGE-GRPO paper). Reward homogenization within groups → zero std → zero advantage → zero gradient → training stalls.
106
+ **Prevention:**
107
+ - Log `completions/mean_group_std` explicitly. If it drops below 0.05, training is dying.
108
+ - Keep `num_generations` at 8 (TRL default). Below 4 risks frequent all-same-reward groups; above 16 at 16K context eats memory.
109
+ - Temperature = 0.7–1.0 during rollouts (TRL GRPOTrainer default is 0.9). Do not drop to 0.1.
110
+ - KL coefficient: TRL default β=0.0 is documented (several recent studies removed it). If collapsing, try β=0.01–0.04 as floor.
111
+ - Mix difficulty tiers in each training batch (easy + medium + hard) → at least one tier provides reward variance.
112
+ **Recovery (step 300, flat curve):** Reset difficulty, add harder tasks to bump variance. Bump temperature to 1.0. Restart from pre-collapse checkpoint (20-step saves save us here).
113
+ **Phase:** Training
114
+ **Severity:** HIGH
115
+
116
+ ### H2. TRL GRPOTrainer server-mode breaks multi-turn rollouts (upstream bug)
117
+ **Warning signs:** Training loss is suspiciously smooth, but trajectories show importance-sampling weirdness. Specifically, per-step prefixes are dropped and the IS ratio is computed against wrong behavior policy. GitHub trl#4543 (filed Nov 2025, against TRL 0.23.1) documents this explicitly for OpenEnv multi-step environments. Also trl#5366 — tool-use completions contain no tool calls.
118
+ **Why it happens:** Server-mode vLLM duplicates the original prompt num_generations times and pairs it with all G completions. For single-turn GSM8K this is fine. For multi-turn RLM rollouts where each turn's prompt grows with env responses, the bookkeeping is wrong.
119
+ **Prevention:**
120
+ - Use **colocate mode** (`vllm_mode="colocate"`) not server mode. Colocate runs vLLM in the same process, avoiding the shared-prompt duplication. Slower, but correct.
121
+ - Pin TRL version — whatever version works in the Thu smoke test. Do not upgrade at venue.
122
+ - Pre-event smoke test MUST include a 2-turn rollout (at least one `llm(...)` call) so this bug surfaces locally not at venue.
123
+ - If fix requires patching: fork TRL, apply the per-rollout prefix patch suggested in the issue, install from source. Budget 2 hours if needed.
124
+ **Recovery:** Swap to colocate mode. If colocate OOMs at 16K context, drop to 8K context for training (generalization claim survives since eval is still 200K).
125
+ **Phase:** Scaffolding (discover via smoke test) + Training
126
+ **Severity:** HIGH
127
+
128
+ ### H3. REPL sandbox escape — model uses `__subclasses__` to get subprocess/open
129
+ **Warning signs:** Any file written to cwd by a training trajectory. Any network call log. Any Python exception chain mentioning `subprocess`, `os`, `builtins`. Judges will attempt this in a trace review.
130
+ **Why it happens:** Blocking keywords doesn't block objects. The classic escape: `().__class__.__base__.__subclasses__()` enumerates every loaded class including `subprocess.Popen`, `_io.FileIO`, `warnings.catch_warnings` (which exposes `__builtins__`). Even if `__builtins__` is deleted, `().__class__.__mro__[-1].__subclasses__()[INDEX]` works. Chaining through `e.__traceback__.tb_frame.f_back.f_builtins` is another well-known path.
131
+ **Prevention:**
132
+ - Do NOT roll our own blacklist-based sandbox. Use RestrictedPython (zopefoundation/RestrictedPython) which does AST rewriting — blocks `__class__`, `__subclasses__`, `__globals__` attribute access, and star imports. Note: not a full sandbox but gets 90% there with low effort.
133
+ - Additionally run the REPL subprocess as:
134
+ - Separate OS process with ulimit -v (512MB), ulimit -t (5s CPU), no network (unshare -n or seccomp deny-list)
135
+ - Non-root user, cwd under /tmp/episode-UUID (ephemeral, wiped each reset)
136
+ - Whitelist imports via import hook: `re`, `json`, `math`, `statistics`, `collections`, `itertools`, `string`. Nothing else.
137
+ - Write 10 known-escape payloads as unit tests for the sandbox — if any pass, sandbox is broken.
138
+ - Kill `open`, `exec`, `eval`, `compile`, `__import__`, `input`, `breakpoint`, `help`, `exit`, `quit` from the exec globals explicitly AFTER RestrictedPython, belt-and-suspenders.
139
+ - Tokens we exec get a fresh dict for `globals` every call — no shared state between episodes.
140
+ **Recovery:** If a judge demonstrates an escape, apology + patch in <60s is ideal but unlikely during live Q&A. Better: pre-empt by listing specific blocked attack vectors in the README ("we tested against: `__subclasses__` escape, `catch_warnings` builtins, traceback chain").
141
+ **Phase:** Scaffolding (ENV-05 is the acceptance gate)
142
+ **Severity:** HIGH (Meta judges care about safety; reward for thoroughness is real)
143
+
144
+ ### H4. Reward hacking specific to RLM context
145
+ **Warning signs:** See R3 warning signs. RLM-specific patterns:
146
+ - **Gold leak via env state:** model calls `print(state.answer)` — goes away if state is not in observation, but many first-pass implementations expose it for debug.
147
+ - **Chunk-index enumeration:** needle tasks where answer is in chunk N; model learns to just output chunks 0..K verbatim regardless of question and gets partial credit if verifier does substring match.
148
+ - **Null-`llm()` shortcut:** `llm("", depth)` returns something, reward function doesn't penalize empty calls → model learns to call it for the recursion bonus without actually using it.
149
+ - **Format-gate-only learning:** 20-step climb in format-match, 0 climb in correctness. Composite reward looks rising, decomposed reward shows it's all format.
150
+ - **Tokenizer exploit:** Qwen chat template uses `<|im_start|>` etc. Model learns to emit these in its output, confusing the verifier's parse of "the answer is X".
151
+ - **Length inflation:** model outputs 2000-token rambling answers because verifier does substring search, longer = higher hit rate.
152
+ **Prevention:**
153
+ - REWARD-01 composite is decomposed in logs (REWARD-02): log format / correctness / tokens / depth separately. Any component that isn't climbing → that's what's being gamed.
154
+ - Correctness verifier: exact-match on final `<answer>X</answer>` span only. Strip chat-template markers before match. Use `re.search(r"<answer>(.*?)</answer>", ..., re.DOTALL)` and exact-compare the capture to gold.
155
+ - Substring-match NEVER on full output. Only on extracted answer field.
156
+ - Penalty for empty `llm()` prompts (len(prompt.strip()) < 10 → -0.1).
157
+ - Length cap: if answer span > 50 tokens → reward = 0. Document answers are ≤ a few words/numbers by construction.
158
+ - Depth bonus caps at depth 3, monotonic decrease after; prevents "call llm 100 times for +ε".
159
+ **Recovery:** See R3. Patching verifier + restart from clean checkpoint = 4 hours.
160
+ **Phase:** Design + Training
161
+ **Severity:** HIGH
162
+
163
+ ### H5. Long-context rollout latency makes a training run infeasible
164
+ **Warning signs:** Step time > 3 minutes at 16K context × 8 generations. 400 steps × 3min = 20 hours, eats the whole venue window. Smoke-test step time should be <60s at 4K context to be on track.
165
+ **Why it happens:** Generation is 90% of GRPO step time. 8 parallel rollouts at 16K each × multi-turn (2 turns avg → 2× tokens generated) = 16K × 8 × 2 ≈ 256K generated tokens per step. Without vLLM this is prohibitive.
166
+ **Knobs and their impact:**
167
+ - `num_generations`: 8 → 4 halves rollout time, doubles advantage-collapse risk (zero-std groups more likely with fewer samples).
168
+ - `max_prompt_length` / `max_completion_length`: cap at 8K prompt + 2K completion for training. Generalization-to-200K is an inference-time claim, training can be shorter context.
169
+ - `per_device_train_batch_size=1`, `gradient_accumulation_steps=4` → saves VRAM for longer context, costs time proportionally.
170
+ - vLLM colocate mode + FlashAttention2 + bf16 are mandatory. Without vLLM, 10× slower (Unsloth benchmark: vLLM up to 11× faster than normal generate).
171
+ - Unsloth's GRPO 7× longer-context patch is NECESSARY to fit 16K × 8 gen on A100-40GB.
172
+ **Prevention:**
173
+ - Pre-event smoke test measures step time on 0.5B × 4K context. Extrapolate: step time at 1.5B/16K/8gen ≈ 8× smoke-test time (3× model, 4× context, 2× rollout length compared to 4K×single-turn).
174
+ - Budget: 200 steps × 180s = 10 hours. 400 steps × 180s = 20 hours → too long. Plan for 200 steps + a stretch 400-step run only if first 200 converge.
175
+ - Train at 8K not 16K if step time is too high. Paper's thesis is "generalize to 200K at inference" — 8K train vs 16K train doesn't change the story materially.
176
+ **Recovery:** Drop context 16K → 8K (halves step time). Drop num_generations 8 → 4 (halves again). Net 4× speedup at cost of more reward variance.
177
+ **Phase:** Scaffolding (smoke test) + Training
178
+ **Severity:** HIGH
179
+
180
+ ### H6. Chat template mismatch between SFT and GRPO
181
+ **Warning signs:** SFT loss converges but GRPO reward never climbs even on easiest tier. Sample GRPO completions contain malformed `<|im_start|>assistant\n\n<|im_start|>assistant` double-stamps or missing `<|im_end|>`. Eval on base-model-pre-SFT matches, but post-SFT eval tanks.
182
+ **Why it happens:** Qwen2.5-Coder-Instruct has a specific chat template with `<|im_start|>`, `<|im_end|>`, tool-call markers. If SFT trajectories were formatted with a slightly different template (e.g., Chat ML without the correct system-prompt prefix), GRPO sees a distribution shift when it applies the tokenizer's default template to prompts. transformers issue #34172 notes assistant-mask/chat template gotchas for Qwen2.5.
183
+ **Prevention:**
184
+ - ONE template source of truth: `tokenizer.apply_chat_template(..., add_generation_prompt=True)`. Never hand-roll `<|im_start|>user\n...`.
185
+ - Smoke test: tokenize a trajectory with both the SFT prep code and GRPO's internal prep, diff the token IDs. Zero diff = safe.
186
+ - Use the Instruct variant (Qwen2.5-Coder-1.5B-Instruct) — it ships with a chat template. The base model does not.
187
+ - If using Unsloth's `get_chat_template`, pass `chat_template="qwen-2.5"` explicitly; do not rely on inference.
188
+ **Recovery:** Regenerate SFT dataset with `apply_chat_template`. Re-run SFT (2 hours on Claude API + 1 hour SFT). GRPO from corrected checkpoint.
189
+ **Phase:** SFT
190
+ **Severity:** HIGH (silent killer — will look like "GRPO just doesn't work for us")
191
+
192
+ ### H7. Model generates empty or non-parseable actions, format gate fails 100% of the time
193
+ **Warning signs:** First 50 GRPO steps: format_reward ~ 0, composite reward = 0. All 8 rollouts per group produce `""` or a single newline.
194
+ **Why it happens:** Qwen-Coder 1.5B base (not instruct) has ~0 prior on emitting our Python-REPL format. Even Instruct has ~0 prior on our specific scaffold (thought → code → llm call). Without SFT warm-start, format gate is a 1-in-1000 lottery.
195
+ **Prevention:**
196
+ - SFT warm-start (TRAIN-01) is the #1 fix. 500 correct trajectories on easy tasks → model knows the format before GRPO starts.
197
+ - Format reward is additive for partial credit during warm-up: +0.3 for valid `<code>` tag, +0.3 for valid `<answer>` tag, +0.4 for both. Pure binary format gate is too sparse.
198
+ - Use Qwen2.5-Coder-1.5B-**Instruct** not the base — Instruct has instruction-following prior, much better starting point for format.
199
+ - First 50 steps: log 5 full trajectories/step. Eyeball them. If all empty, stop — SFT didn't take, retrain SFT before GRPO.
200
+ **Recovery (step 50 with 0 format hits):** Bail to SFT. Generate 200 more trajectories, SFT 1 epoch, restart GRPO. ~3 hours.
201
+ **Phase:** SFT + Training
202
+ **Severity:** HIGH
203
+
204
+ ### H8. Anti-cheat audit fires DURING training — masked context gets above-chance reward
205
+ **Warning signs:** Scheduled audit at step 100: masked-context reward = 0.35 vs chance baseline = 0.1. Means the model has found a way to succeed without reading context.
206
+ **Why it happens:** Common cause: task answer distribution is narrow (answers are mostly 1-2 digit numbers, or a small vocabulary of entity names), so guessing has above-chance hit rate. Less common but worse: gold is in obs dict. Intermediate: model memorized training-set answers during SFT warm-start, eval overlap.
207
+ **Prevention:**
208
+ - Answer space: if tasks have numeric answers, sample uniformly from a wide range (0 to 10^6). If entity-name answers, sample from a pool ≥1000.
209
+ - Zero overlap between SFT trajectory answers and eval answers. Hash-based split.
210
+ - Audit runs with `seed` fixed so we can reproduce a flagged trace.
211
+ - Chance baseline is computed per-task-type from the untrained-baseline sweep. Audits compare against that, not against 0.
212
+ **Recovery (step 100 flagged):** Debug which task type is leaking. Options: (a) fix verifier — extract answer more strictly, (b) widen answer space for the leaking task type and regenerate eval set, (c) drop that task type from training. Each is ~1h.
213
+ **Phase:** Design (prevention) + Training (detection)
214
+ **Severity:** HIGH
215
+
216
+ ### H9. Time-budget misallocation: over-invest in env, under-invest in training/demo
217
+ **Warning signs:** Hour 16 into venue, env looks beautiful and has 10 task types, reward function is elegant, and zero training steps have been run.
218
+ **Why it happens:** Env code is fun, satisfying, feels like progress. Training feels like watching paint dry. Demo viz feels like "we'll do it last." Classic 80/20 inversion.
219
+ **Prevention:**
220
+ - TRAIN-03 smoke test gates everything. If the full pipeline hasn't run end-to-end by Thursday night, nothing ships.
221
+ - Timeboxes with hard stops, committed to in Phase 5 roadmap:
222
+ - Thu evening: scaffolding + dataset + SFT dry-run. STOP at midnight.
223
+ - Fri: smoke test, deploy HF Space, SFT run. STOP at 6pm.
224
+ - Sat morning: kick off GRPO. Day is monitoring + demo viz in parallel. Training runs while we build viz.
225
+ - Sat night hard stop for training: 10 PM. Everything after is demo + pitch.
226
+ - Sun: eval + Pareto sweep + pitch rehearsal. Zero new features.
227
+ - "One-more-task-type" is the enemy. 4 task types ship > 10 task types half-built.
228
+ **Recovery:** If over-invested, call it at hour 20. Freeze env. Ship the training with what's there.
229
+ **Phase:** Scaffolding → Training transition
230
+ **Severity:** HIGH
231
+
232
+ ### H10. Over-claiming in pitch; live 200K demo fails
233
+ **Warning signs:** Pitch script says "solves 200K-token documents." Live demo runs on a 200K doc and times out / OOMs / produces wrong answer.
234
+ **Why it happens:** Training at 16K, claiming generalization to 200K. Generalization works often, but not always, and not on every question type. Judges test with their own question.
235
+ **Prevention:**
236
+ - Script the live demo with a pre-validated 200K document + question where the trained model is known to succeed. Run it 20 times before pitch; success rate must be 100%.
237
+ - Have a 50K and 100K fallback question ready — if 200K looks flaky during rehearsal, claim 50K in pitch.
238
+ - "Trained at 16K, evaluated at 200K on [X] tasks with [Y]% accuracy" is a verifiable claim. Say exactly this.
239
+ - Include the accuracy number in Pareto chart (DEMO-02). If accuracy is 45% at 200K, say 45%. Over-claiming blows credibility more than under-claiming.
240
+ - Pre-recorded 60s demo video shows a 200K success. Live demo can reproduce it; if not, video carries the claim.
241
+ **Recovery (rehearsal shows 200K flaky):** Downgrade pitch claim to "100K tokens with 50× generalization factor from 16K training." Math still works, still impressive.
242
+ **Phase:** Pitch rehearsal
243
+ **Severity:** HIGH
244
+
245
+ ---
246
+
247
+ ## MEDIUM SEVERITY — Slows a Phase, Degrades Polish
248
+
249
+ ### M1. Unsloth 4-bit quantization × GRPO numerical instability
250
+ **Warning signs:** Loss NaN after a few steps. Gradient norm >100 in W&B. Unsloth issue #3506 (GPT-OSS 20B GRPO T4 hits Triton MXFP4 bias dtype assertion with 4-bit active) — dtype mismatch between float and half during rollout.
251
+ **Why it happens:** 4-bit weights + bf16 grads + fp16 vLLM generation have dtype boundary bugs. Bias upcast to fp32 can cause addmm dtype mismatches.
252
+ **Prevention:**
253
+ - `bf16=True`, NOT `fp16=True`. Bfloat16 is numerically more stable.
254
+ - Gradient clipping: `max_grad_norm=0.5` (tighter than TRL default 1.0) for 4-bit GRPO.
255
+ - Learning rate: 5e-6 to 1e-5. Higher than this = instability with 4-bit. Lower = doesn't learn.
256
+ - Warmup: 10–20 steps constant at 1/10 LR before ramping.
257
+ - Use Unsloth's recommended 4-bit dynamic quantization (`load_in_4bit=True` + their dynamic quant model path), not raw bnb 4-bit.
258
+ **Recovery:** If NaN at step N: revert to last good step, drop LR 2×, restart. If persistent, disable 4-bit and run 16-bit LoRA — costs 2× VRAM and maybe doesn't fit 3B, but 1.5B fits in 16-bit LoRA on A100-40GB.
259
+ **Phase:** Training
260
+ **Severity:** MEDIUM
261
+
262
+ ### M2. Probability-of-good-answer>0 for RLM-specific tasks (requires SFT warmup precision)
263
+ **Warning signs:** Even after SFT, base 1.5B can't issue a single correct `llm(prompt, chunk, depth=1)` call. Base pre-GRPO eval is 5% on "easy" tier but 0% on "recursive-required" tier. Format OK, correctness floor zero.
264
+ **Why it happens:** SFT trajectories were generated by Claude doing the task well, which is high-quality but mode-collapsed — 500 near-identical reasoning paths. Model memorizes the exact tool call syntax but not the *when* to call.
265
+ **Prevention:**
266
+ - SFT dataset diversity: include "wrong but recoverable" trajectories where initial grep misses, model retries with broader query. Model learns to recover, not just to one-shot.
267
+ - Include negative examples: trajectories where recursion WAS used vs trajectories where direct answer worked. Model learns when to recurse.
268
+ - SFT on ≥200 distinct documents, not ≥500 trajectories on the same 20 documents. Diversity in documents > diversity in trajectories.
269
+ - First GRPO task tier is "recursion is optional" — model can succeed with or without. Gets positive signal either way.
270
+ - Second tier: "recursion is required" — model can't fit doc in context. Now GRPO has a gradient toward learning *when*.
271
+ **Recovery:** Add tier-0 tasks to training (tiny docs that fit in context; model can practice recursion but doesn't have to). Restart GRPO 100 steps.
272
+ **Phase:** SFT design
273
+ **Severity:** MEDIUM
274
+
275
+ ### M3. HF Space deployment fails on first push (dependency conflicts, Docker build stuck)
276
+ **Warning signs:** "Preparing Space" for >15 minutes. "Error: Failed to fetch" on Build tab. Requirements resolver failure — common with Unsloth + Torch + vLLM pinned version soup.
277
+ **Why it happens:** HF Spaces run user 1000, exposes port 7860. Unsloth's install script expects specific CUDA / torch combos. Space's base image may not match.
278
+ **Prevention:**
279
+ - ENV-04 gates training: Space must be green before GRPO starts. Deploy on Thursday, find issues when there's time to fix.
280
+ - Pin every dependency to exact version: `torch==2.X.Y`, `unsloth @ git+...@COMMIT`, `trl==0.Z.W`, `transformers==...`, `vllm==...`. No `>=`.
281
+ - Start from an Unsloth-known-good Dockerfile (HF community has Unsloth-Docker-Space templates). Don't write from scratch.
282
+ - Include `HF_HOME=/tmp/hf_cache` in Dockerfile ENV; write permission under user 1000 is /tmp-scoped.
283
+ - Test container LOCALLY with `docker build . && docker run` before pushing. Catch 90% of errors offline.
284
+ - Have a Gradio-only fallback Space (no Unsloth, just serves the merged model via transformers.pipeline) as secondary deployment. If Unsloth Space fails, Gradio Space works. Both can share the same model weights.
285
+ **Recovery (venue, Space fails):** Demo from laptop via ngrok or share screen directly. README points to GitHub for reproducibility; Space is "optional live play."
286
+ **Phase:** Scaffolding
287
+ **Severity:** MEDIUM
288
+
289
+ ### M4. Docker-in-Docker at venue blocked by network policy
290
+ **Warning signs:** Venue wifi firewalls outbound Docker Hub pulls. HF push fails because `huggingface-cli` can't reach hf.co. Pip installs stall.
291
+ **Prevention:**
292
+ - Pre-event: fully warm-cache on local laptop. All pip wheels downloaded, all HF models pre-pulled to `~/.cache/huggingface`. Tar'd for air-gap.
293
+ - Pre-event: deploy Space from laptop wifi before leaving for Bangalore.
294
+ - Phone hotspot as wifi backup for pushes. $2 of data > 3 hours of lost time.
295
+ - Pre-event GitHub repo is PUBLIC so we can always `git pull` from venue without auth issues.
296
+ **Recovery:** Hotspot from phone for 30 minutes to get the critical push done.
297
+ **Phase:** All venue phases
298
+ **Severity:** MEDIUM
299
+
300
+ ### M5. W&B / logging blind spots mean we can't debug at hour 30
301
+ **Warning signs:** Reward curve is flat. Can't tell if it's format, correctness, or tokens failing. Can't tell if it's easy tasks vs hard tasks. Can't tell if it's early rollout or late rollout failing.
302
+ **Why it happens:** Default GRPOTrainer W&B logs are reward + KL + entropy + clip_ratio. Not enough for debug.
303
+ **Prevention — log these explicitly:**
304
+ - Per-task-type reward (4 separate W&B series for needle/multi-needle/synthesized/counting).
305
+ - Per-difficulty-tier reward (easy/medium/hard).
306
+ - Per-recursion-depth reward (depth 0 / 1 / 2 separately).
307
+ - Per-rollout-length reward (buckets: <500 / 500-2K / 2K-8K / 8K+).
308
+ - Group reward std (H1 early warning).
309
+ - Timeout rate per step (env hung, sandbox killed → rate climbing = environment regression).
310
+ - Format pass rate per step (vs correctness pass rate — diverging = model gaming format).
311
+ - Answer-length distribution (histogram per 50 steps; skew to zero or huge = degeneration).
312
+ - Sample 5 full trajectories every 50 steps, log as W&B Table with rendered HTML.
313
+ - All W&B logs mirror to local jsonl file too — W&B can flake at venue; jsonl is ground truth.
314
+ **Recovery:** If logging was insufficient, rerun 50 steps with verbose logging turned on. Compare against the run we can't debug.
315
+ **Phase:** Scaffolding + Training
316
+ **Severity:** MEDIUM
317
+
318
+ ### M6. Pareto frontier chart untrained baseline dominates trained model at some α
319
+ **Warning signs:** At very-low-α (token budget irrelevant), untrained baseline hits equal or higher accuracy because it just dumps context and lucks into the answer. DEMO-02 shows trained model strictly dominated in the "no token budget" regime.
320
+ **Why it happens:** Token-budget shaping's effect is visible only where tokens matter. At unlimited token budget, an untrained model that floods context can get lucky.
321
+ **Prevention:**
322
+ - Pareto chart X-axis: tokens consumed. Y-axis: accuracy. Plot the baseline's (tokens, accuracy) point. If untrained baseline needs 18K tokens to get 30%, trained model needs to hit 30%+ with <6K OR 60%+ with similar tokens.
323
+ - If pitch demands "strictly dominated" visual: pick α values where trained model is better on both axes. Don't force α to span a range where we look bad.
324
+ - Include a "tokens trained on" regime marker (16K) — claim is generalization, and in the 16K–200K regime our Pareto wins. In the <4K regime it's a dead heat.
325
+ **Recovery:** Reframe the chart — "accuracy-vs-tokens in the long-context regime" not "across all token budgets."
326
+ **Phase:** Demo
327
+ **Severity:** MEDIUM
328
+
329
+ ### M7. SFT overfits; GRPO plateaus because model has collapsed to Claude-copying
330
+ **Warning signs:** SFT eval correctness = 85% on held-out. GRPO starts at 85%, goes to 86% in 400 steps. No improvement.
331
+ **Why it happens:** 500 Claude-generated trajectories on ~100 documents → model memorized Claude's patterns. GRPO can't find a gradient because SFT already ate the headroom.
332
+ **Prevention:**
333
+ - Keep SFT light: ≤2 epochs, LR 1e-5, validate with eval-set correctness after each epoch. Stop when eval plateaus; don't chase training loss.
334
+ - SFT on easy tasks only. Medium/hard held for GRPO to learn.
335
+ - Measure SFT-only vs base-model on eval. Delta should be ~30% (base 20% → SFT 50%). Not 70%.
336
+ **Recovery:** Reload base + fewer-epoch SFT. 2 hours.
337
+ **Phase:** SFT
338
+ **Severity:** MEDIUM
339
+
340
+ ### M8. RoPE scaling / 200K inference OOM on A100-40GB
341
+ **Warning signs:** Inference at 200K with KV cache OOMs. Qwen2.5-1.5B without YaRN caps at 32K position embeddings. 200K is ~6× that.
342
+ **Why it happens:** Qwen2.5 uses YaRN rope_scaling factor 4.0 for 128K extension; 200K needs factor ~6 or Dual Chunk Attention. Qwen2.5-1M variant handles 1M but wasn't released for 1.5B.
343
+ **Prevention:**
344
+ - For 200K inference: apply `rope_scaling={"type": "yarn", "factor": 4.0, "original_max_position_embeddings": 32768}` at load time. (Unsloth issue #3083 notes Qwen 2.5 RoPE scaling support may be incomplete in Unsloth's long-context GRPO path — test before relying.)
345
+ - KV cache quantization or eviction may be needed for 200K on 40GB. Use vLLM with `--kv-cache-dtype fp8`.
346
+ - Pareto chart is inference-only; run on A100-80GB if available, or CPU (slow but works) for final render. Demo video can be recorded on larger GPU separately.
347
+ **Recovery:** Cap eval context at 100K if 200K OOMs. Pitch claim adjusts to 100K (12× generalization from 16K training, still impressive).
348
+ **Phase:** Demo
349
+ **Severity:** MEDIUM
350
+
351
+ ---
352
+
353
+ ## NUISANCE-LEVEL (Annoying, Not Threatening)
354
+
355
+ ### N1. Unsloth `save_pretrained_merged` re-downloads safetensors every run (issue #2283 / #3633)
356
+ **Impact:** Wastes 15 minutes every save. Fills disk.
357
+ **Prevention:** Use `push_to_hub_merged` or adapter-only; set `HF_HOME` to persistent disk.
358
+
359
+ ### N2. transformers `apply_chat_template` + `return_assistant_tokens_mask` doesn't work on Qwen2.5 (issue #34172)
360
+ **Impact:** Affects some SFT masked-loss setups.
361
+ **Prevention:** We're doing full-sequence SFT; irrelevant. But don't try to compute assistant-token-only loss for a "nicer SFT" — it silently breaks.
362
+
363
+ ### N3. GitHub repo README too terse → judges can't reproduce → reproducibility score hit
364
+ **Impact:** Fewer points on reproducibility rubric.
365
+ **Prevention:** DEMO-04. README needs: one-line pip install, one-line train command, one-line eval command. Judges don't read prose — they copy-paste commands.
366
+
367
+ ### N4. Claude Code runs a destructive command (rm -rf, force-push)
368
+ **Impact:** Loses work.
369
+ **Prevention:** Commit every 30 minutes at venue. Push to GitHub every hour. Never run `git reset --hard` without explicit human OK.
370
+
371
+ ---
372
+
373
+ ## Claude-Code-Specific Failure Modes
374
+
375
+ ### CC1. Claude reads a flat reward curve and says "it's learning, keep going"
376
+ **Why it happens:** Claude pattern-matches on reward monotonically-increasing-but-slow. A flat curve at 0.6 for 100 steps might be true plateau (STOP) or entropy collapse (STOP + RESTART). Human call.
377
+ **Prevention:** Reward curve reading is a HUMAN call. Claude's role: surface the metrics (group_std, per-task-type reward, format vs correctness split). Human decides kill or continue.
378
+ **Phase:** Training
379
+ **Severity:** MEDIUM
380
+
381
+ ### CC2. Claude debugs CUDA OOM by silently lowering batch size, hiding the signal
382
+ **Why it happens:** OOM is a symptom. Root cause might be memory leak in env (rollout accumulating across episodes), wrong vLLM config, KV cache growing. Just lowering batch size masks it.
383
+ **Prevention:** When OOM occurs, have Claude report the full nvidia-smi + python traceback + recent config changes FIRST. Don't let it auto-patch without showing the state.
384
+ **Phase:** Training
385
+ **Severity:** MEDIUM
386
+
387
+ ### CC3. Claude writes a reward function that looks correct but has a subtle off-by-one
388
+ **Why it happens:** Reward functions are short, look simple, Claude writes them fluently. Bug like `if answer.strip() == gold` vs `if answer.strip().lower() == gold.lower()` = 40% reward loss on case-sensitive match.
389
+ **Prevention:** Every reward function ships with ≥10 unit tests (gold answer variations, whitespace, case, unicode, empty string, long answer, None). Claude writes tests first, implementation second.
390
+ **Phase:** Design
391
+ **Severity:** MEDIUM
392
+
393
+ ### CC4. Claude over-engineers the env (10 task types, complex reward rubric) because "we have time"
394
+ **Why it happens:** Environment code is the most satisfying thing to build with Claude. Feels productive. Isn't the bottleneck.
395
+ **Prevention:** Phase roadmap has hard deliverable checkpoints. If task types > 4 at hour 12, freeze env. If reward has >3 components at hour 12, freeze reward.
396
+ **Phase:** Scaffolding
397
+ **Severity:** MEDIUM
398
+
399
+ ### CC5. Claude drafts pitch narrative that claims what we WANT to be true instead of what we SHOWED
400
+ **Why it happens:** Pitch-writing mode is optimizing for storytelling, not accuracy. Claim creep from "our trained model shows a clear Pareto improvement" → "our trained model strictly dominates."
401
+ **Prevention:** Pitch numbers must come from pasted W&B URLs / eval JSON output. Every claim has a line citation from a committed log file.
402
+ **Phase:** Pitch
403
+ **Severity:** MEDIUM
404
+
405
+ ### CC6. Multi-process debug (env process + training process + vLLM process): Claude confuses which log is which
406
+ **Why it happens:** Three processes, three log streams. When one fails, Claude can't tell which process's error it's seeing.
407
+ **Prevention:** Prefix every log line with [ENV] / [TRAIN] / [VLLM]. Use `tee` to separate files per process. Human reviews cross-process state at failure time.
408
+ **Phase:** Training
409
+ **Severity:** MEDIUM
410
+
411
+ ### CC7. Claude says "the test passes" when test was never actually run
412
+ **Why it happens:** Time pressure, Claude writes the test and the code, runs once, fixes, says done without final re-run.
413
+ **Prevention:** Last line of every change-PR turn must be a cat of the test output. Trust the bash output, not Claude's summary.
414
+ **Phase:** All
415
+ **Severity:** MEDIUM
416
+
417
+ ---
418
+
419
+ ## Phase-Specific Warnings Summary
420
+
421
+ | Phase | Most Dangerous Pitfall | Watch-For Metric |
422
+ |-------|-------------------------|------------------|
423
+ | Design | R1 (success prob = 0), H4 (reward loophole) | Untrained baseline on easy tier ≥ 25% |
424
+ | Scaffolding | H2 (TRL multi-turn bug), H3 (sandbox escape), M3 (Space deploy) | Thursday smoke test runs ≥ 1 GRPO step end-to-end |
425
+ | SFT | H6 (chat template mismatch), H7 (empty generations), M2 (SFT mode collapse), M7 (SFT overfit) | SFT eval correctness jump 20% → 50%, not 20% → 85% |
426
+ | Training | H1 (entropy collapse), H5 (step time), H8 (cheat audit fires), M1 (4-bit NaN), R3 (reward hacking) | Reward climbing + group_std stable + format/correctness both climbing |
427
+ | Demo | R6 (live demo fail), M6 (Pareto chart dominated), M8 (200K OOM) | 20/20 successful dress rehearsals |
428
+ | Pitch | H10 (over-claiming), CC5 (claim creep) | Every numeric claim has a log URL |
429
+ | All Venue | R2 (A100 unavailable), R5 (Claude compaction) | VENUE_STATE.md updated < 30 minutes ago |
430
+
431
+ ---
432
+
433
+ ## Sources
434
+
435
+ **Context7-equivalent & official:**
436
+ - [Unsloth RL Guide — probability>0 requirement, ≥1.5B minimum, 300-step reward lag, save paths](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide) — HIGH confidence
437
+ - [Unsloth Long-Context GRPO (7× patch, vLLM 11× gen speed)](https://unsloth.ai/docs/new/grpo-long-context) — HIGH
438
+ - [TRL GRPO Trainer docs](https://huggingface.co/docs/trl/main/en/grpo_trainer) — HIGH
439
+ - [TRL OpenEnv multi-step bug #4543](https://github.com/huggingface/trl/issues/4543) — HIGH (the actual bug we must route around)
440
+ - [TRL GRPO tool-use #5366](https://github.com/huggingface/trl/issues/5366) — HIGH
441
+ - [TRL vLLM integration docs](https://huggingface.co/docs/trl/vllm_integration) — HIGH
442
+ - [Unsloth issue #1791 — merge_4bit_forced broken](https://github.com/unslothai/unsloth/issues/1791) — HIGH
443
+ - [Unsloth issue #1267 — save_pretrained_merged "ruins my model"](https://github.com/unslothai/unsloth/issues/1267) — HIGH
444
+ - [Unsloth issue #3882 — Docker save path empty](https://github.com/unslothai/unsloth/issues/3882) — HIGH
445
+ - [Unsloth issue #1877 — GRPO adapter merge](https://github.com/unslothai/unsloth/issues/1877) — HIGH
446
+ - [Unsloth issue #2339 — merged_4bit_forced writes 16bit silently](https://github.com/unslothai/unsloth/issues/2339) — HIGH
447
+ - [Unsloth issue #3506 — GPT-OSS GRPO 4-bit Triton dtype](https://github.com/unslothai/unsloth/issues/3506) — HIGH
448
+ - [Unsloth issue #3083 — Qwen2.5 RoPE scaling in long-context GRPO](https://github.com/unslothai/unsloth/issues/3083) — HIGH
449
+ - [HF Spaces slow cold boot thread](https://discuss.huggingface.co/t/slow-space-cold-boot/72154) — HIGH
450
+ - [Qwen2.5 Technical Report (YaRN, 32K base, 128K extension)](https://arxiv.org/pdf/2412.15115) — HIGH
451
+ - [Qwen2.5-1M Technical Report](https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen2.5-1M/Qwen2_5_1M_Technical_Report.pdf) — HIGH
452
+
453
+ **Reward hacking & RLVR failure modes:**
454
+ - [Lilian Weng — Reward Hacking in RL](https://lilianweng.github.io/posts/2024-11-28-reward-hacking/) — HIGH
455
+ - [METR — Recent Frontier Models Are Reward Hacking](https://metr.org/blog/2025-06-05-recent-reward-hacking/) — HIGH
456
+ - [Reward Hacking in RLVR Systems survey](https://www.emergentmind.com/topics/reward-hacking-in-reinforcement-learning-with-verifiable-rewards) — MEDIUM
457
+ - [Promptfoo — RLVR makes models faster not smarter (entropy collapse)](https://www.promptfoo.dev/blog/rlvr-explained/) — MEDIUM
458
+ - [Reward Hacking Mitigation with Verifiable Composite Rewards](https://arxiv.org/html/2509.15557v1) — MEDIUM
459
+ - [Execution-Grounded Credit Assignment for GRPO in Code](https://arxiv.org/html/2603.16158) — MEDIUM
460
+ - [EDGE-GRPO: Entropy-Driven GRPO](https://arxiv.org/html/2507.21848v1) — MEDIUM
461
+
462
+ **Sandbox security:**
463
+ - [RestrictedPython docs](https://restrictedpython.readthedocs.io/) — HIGH
464
+ - [CTF Wiki — Python sandbox escape](https://github.com/mahaloz/ctf-wiki-en/blob/master/docs/pwn/linux/sandbox/python-sandbox-escape.md) — HIGH
465
+ - [HN — Preventing Python sandbox escape](https://news.ycombinator.com/item?id=42244461) — MEDIUM
466
+ - [Hijacking the AST to safely handle untrusted python](https://twosixtech.com/blog/hijacking-the-ast-to-safely-handle-untrusted-python/) — MEDIUM
467
+
468
+ **GRPO theory & tricks:**
469
+ - [Cameron Wolfe — GRPO++ tricks](https://cameronrwolfe.substack.com/p/grpo-tricks) — MEDIUM
470
+ - [Cameron Wolfe — GRPO explained](https://cameronrwolfe.substack.com/p/grpo) — MEDIUM
471
+ - [Limit of RLVR — pass@k saturation](https://limit-of-rlvr.github.io/) — HIGH
472
+ - [GRPO effective loss dynamics & success amplification](https://arxiv.org/html/2503.06639v1) — MEDIUM
473
+ - [Nathan Lambert RLHF Book — policy gradients debugging](https://rlhfbook.com/c/06-policy-gradients) — MEDIUM
474
+ - [RC-GRPO: multi-turn tool calling](https://arxiv.org/html/2602.03025) — MEDIUM
475
+
476
+ **Claude Code operational:**
477
+ - [Claude Code compaction docs](https://code.claude.com/docs/en/how-claude-code-works) — HIGH
478
+ - [MindStudio — /compact command best practices](https://www.mindstudio.ai/blog/claude-code-compact-command-context-management) — MEDIUM
479
+ - [Claude Code PreCompact hook request #43733](https://github.com/anthropics/claude-code/issues/43733) — MEDIUM
480
+
481
+ **HF Space deployment:**
482
+ - [HF Forum — Build stuck on FastAPI Docker Space](https://discuss.huggingface.co/t/build-stuck-on-a-fastapi-app-in-docker-space/33504) — MEDIUM
483
+ - [Docker + HF Spaces deployment guide](https://huggingface.co/docs/hub/en/spaces-sdks-docker) — HIGH
484
+ - [OpenEnv push to HF Spaces](https://deepwiki.com/raun/openenv-course/5.2-deploying-to-hugging-face-spaces-with-openenv-push) — MEDIUM
485
+ - [ZeroGPU limits & quotas](https://huggingface.co/docs/hub/en/spaces-zerogpu) — HIGH
486
+
487
+ **RLM reference implementations & reward audit:**
488
+ - [Prime Intellect — Recursive Language Models paradigm of 2026](https://www.primeintellect.ai/blog/rlm) — HIGH
489
+ - [Kevin Madura — RLM security audit (context leakage mitigation)](https://kmad.ai/Recursive-Language-Models-Security-Audit) — MEDIUM
.planning/research/STACK.md ADDED
@@ -0,0 +1,845 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Technology Stack — FATHOM
2
+
3
+ **Project:** FATHOM — RL-trained Recursive Language Model
4
+ **Researched:** 2026-04-22 (Phase 6 re-run)
5
+ **Version pins verified:** 2026-04-22
6
+ **Overall confidence:** HIGH on TRL/OpenEnv versions (verified against PyPI + GitHub + official docs); HIGH on Qwen2.5-Coder specs (HF model card); MEDIUM on Unsloth version pin (latest beta shifts weekly, pin the one that passes our Thu smoke test); MEDIUM-HIGH on GRPO config (canonical defaults + TRL OpenEnv guide).
7
+
8
+ **How to use this doc at hour 46:** jump to §2 for the single pip block, §5 for the GRPO config dict, §6 for the LoRA save snippet, §9 for the anti-pattern table, §11 for the verification checklist to run on the A100.
9
+
10
+ ---
11
+
12
+ ## 1. Versions by Role (April 2026 pins)
13
+
14
+ | Role | Package | Version pin | Confidence | Source |
15
+ |------|---------|-------------|------------|--------|
16
+ | **OpenEnv core/CLI** | `openenv-core[core]` | `>=0.2.3,<0.3` | HIGH | [meta-pytorch/OpenEnv v0.2.3, Mar 28 2026](https://github.com/meta-pytorch/OpenEnv) |
17
+ | **TRL (GRPOTrainer + OpenEnv integration)** | `trl` | `==1.2.0` | HIGH | [PyPI trl 1.2.0, Apr 17 2026](https://pypi.org/project/trl/); [TRL OpenEnv guide](https://huggingface.co/docs/trl/main/en/openenv) |
18
+ | **Unsloth (efficiency + save)** | `unsloth` | `==2026.4.8` (v0.1.36-beta tag, whatever passes Thu smoke test) | MEDIUM | [unslothai/unsloth releases, v0.1.36-beta Apr 8 2026](https://github.com/unslothai/unsloth/releases) |
19
+ | **Unsloth Zoo** | `unsloth-zoo` | latest-compatible (Unsloth resolves) | MEDIUM | — |
20
+ | **Torch (CUDA 12.4 for A100)** | `torch` | `==2.6.0` | HIGH | PyTorch 2.6+ is Unsloth-supported; 2.6 ships cu124 wheel |
21
+ | **Transformers** | `transformers` | `==4.49.0` | HIGH | Pinned by TRL 1.2 requirements |
22
+ | **Accelerate** | `accelerate` | `==1.5.2` | HIGH | Pinned by TRL 1.2 |
23
+ | **PEFT** | `peft` | `==0.14.0` | HIGH | Pinned by TRL 1.2 |
24
+ | **bitsandbytes** | `bitsandbytes` | `==0.45.1` | HIGH | 0.45+ required for cu124 + Ampere+Hopper paths |
25
+ | **vLLM (for colocate inference)** | `vllm` | `==0.7.3` | HIGH | Matches TRL 1.2 server + colocate contract; Unsloth fast_inference path compatible |
26
+ | **Datasets** | `datasets` | `==3.3.2` | HIGH | Transformers 4.49 pair |
27
+ | **HF Hub** | `huggingface_hub` | `>=0.28,<1.0` | HIGH | Space push + snapshot_download |
28
+ | **FastAPI (env server)** | `fastapi` | `>=0.115,<0.120` | HIGH | OpenEnv scaffold generates FastAPI app |
29
+ | **Uvicorn** | `uvicorn[standard]` | `>=0.32,<1.0` | HIGH | — |
30
+ | **Pydantic** | `pydantic` | `>=2.9,<3.0` | HIGH | OpenEnv + TRL both on Pydantic 2 |
31
+ | **W&B** | `wandb` | `>=0.18,<1.0` | HIGH | Default TRL callback |
32
+ | **Streamlit (viz + tree)** | `streamlit` | `>=1.39,<2.0` | HIGH | Richer than Gradio for recursion-tree D3 embed (see §1.1) |
33
+ | **Plotly (Pareto chart)** | `plotly` | `>=5.24,<6.0` | HIGH | — |
34
+ | **Matplotlib (quick plots, W&B)** | `matplotlib` | `>=3.9,<4.0` | HIGH | — |
35
+ | **RestrictedPython (REPL AST filter)** | `RestrictedPython` | `>=7.4,<8.0` | HIGH | [zopefoundation/RestrictedPython docs](https://restrictedpython.readthedocs.io/) |
36
+ | **Claude API (SFT data gen)** | `anthropic` | `>=0.40,<1.0` | HIGH | SFT trace generator only; not a runtime dep |
37
+
38
+ ### 1.1 Streamlit vs Gradio — decided
39
+
40
+ **Pick:** Streamlit for the FATHOM demo Space (viz/app.py).
41
+ **Why:** The recursion-tree visualizer needs an embeddable D3 component + multi-column layout (left: input, middle: tree, right: Pareto). Streamlit's `st.components.v1.html` + `st.columns` is the straight path. Gradio forces everything through its block system and custom HTML is bolted on. OpenEnv env Space stays FastAPI/Docker (unchanged from ARCHITECTURE.md §8 — two Spaces).
42
+ **Confidence:** MEDIUM (TS-DEM-2 in FEATURES.md suggested Gradio; we're overriding because recursion-tree is load-bearing for DF-DEM-1 and Streamlit's layout primitives are better for it).
43
+
44
+ ---
45
+
46
+ ## 2. Canonical Install — Single Block
47
+
48
+ Target: A100-40GB or A100-80GB at venue, CUDA 12.4, Python 3.11 (3.12 has fewer Unsloth reports in the wild).
49
+
50
+ ```bash
51
+ # Step 0: CUDA 12.4 sanity check
52
+ nvidia-smi | head -20 # Should show CUDA Version: 12.4 or 12.5
53
+
54
+ # Step 1: create env (uv preferred; pip fallback below)
55
+ uv venv fathom --python 3.11
56
+ source fathom/bin/activate
57
+
58
+ # Step 2: torch first (CUDA 12.4 wheel), own line to avoid resolver confusion
59
+ uv pip install torch==2.6.0 --index-url https://download.pytorch.org/whl/cu124
60
+
61
+ # Step 3: core training stack
62
+ uv pip install \
63
+ transformers==4.49.0 \
64
+ accelerate==1.5.2 \
65
+ peft==0.14.0 \
66
+ bitsandbytes==0.45.1 \
67
+ datasets==3.3.2 \
68
+ "huggingface_hub>=0.28,<1.0" \
69
+ trl==1.2.0 \
70
+ vllm==0.7.3
71
+
72
+ # Step 4: Unsloth (pin to Thu-smoke-test version; DO NOT -U at venue)
73
+ uv pip install "unsloth==2026.4.8" "unsloth-zoo"
74
+
75
+ # Step 5: env server + tooling
76
+ uv pip install \
77
+ "openenv-core[core]>=0.2.3,<0.3" \
78
+ "fastapi>=0.115,<0.120" \
79
+ "uvicorn[standard]>=0.32" \
80
+ "pydantic>=2.9,<3.0" \
81
+ "RestrictedPython>=7.4,<8.0"
82
+
83
+ # Step 6: observability + viz + data gen
84
+ uv pip install \
85
+ "wandb>=0.18" \
86
+ "streamlit>=1.39,<2.0" \
87
+ "plotly>=5.24,<6.0" \
88
+ "matplotlib>=3.9,<4.0" \
89
+ "anthropic>=0.40,<1.0"
90
+
91
+ # Step 7: flash-attention (optional but big speedup for generation)
92
+ uv pip install flash-attn==2.7.4.post1 --no-build-isolation
93
+ ```
94
+
95
+ **pip (no uv) fallback:** replace every `uv pip install` with `pip install`. Identical versions.
96
+
97
+ **pyproject.toml snippet** (for the repo-level pin; `requirements.txt` is regenerated from this via `uv pip compile pyproject.toml -o requirements.txt`):
98
+
99
+ ```toml
100
+ [project]
101
+ name = "fathom"
102
+ version = "0.1.0"
103
+ requires-python = ">=3.11,<3.12"
104
+ dependencies = [
105
+ "torch==2.6.0",
106
+ "transformers==4.49.0",
107
+ "accelerate==1.5.2",
108
+ "peft==0.14.0",
109
+ "bitsandbytes==0.45.1",
110
+ "trl==1.2.0",
111
+ "unsloth==2026.4.8",
112
+ "unsloth-zoo",
113
+ "vllm==0.7.3",
114
+ "datasets==3.3.2",
115
+ "huggingface_hub>=0.28,<1.0",
116
+ "openenv-core[core]>=0.2.3,<0.3",
117
+ "fastapi>=0.115,<0.120",
118
+ "uvicorn[standard]>=0.32",
119
+ "pydantic>=2.9,<3.0",
120
+ "RestrictedPython>=7.4,<8.0",
121
+ "wandb>=0.18",
122
+ "streamlit>=1.39,<2.0",
123
+ "plotly>=5.24,<6.0",
124
+ "matplotlib>=3.9,<4.0",
125
+ "anthropic>=0.40,<1.0",
126
+ ]
127
+ ```
128
+
129
+ Working as of 2026-04-22. Re-verify with the Thursday pre-event smoke test (DF-TRN-3).
130
+
131
+ ---
132
+
133
+ ## 3. Qwen2.5-Coder Specifics
134
+
135
+ ### 3.1 Primary: Qwen2.5-Coder-1.5B-Instruct
136
+
137
+ | Attribute | Value | Source |
138
+ |-----------|-------|--------|
139
+ | HF ID (base) | `Qwen/Qwen2.5-Coder-1.5B-Instruct` | [HF model card](https://huggingface.co/Qwen/Qwen2.5-Coder-1.5B-Instruct) |
140
+ | HF ID (Unsloth-preferred for faster load) | `unsloth/Qwen2.5-Coder-1.5B-Instruct` | [unsloth/Qwen2.5-Coder-1.5B-Instruct](https://huggingface.co/unsloth/Qwen2.5-Coder-1.5B-Instruct) |
141
+ | HF ID (Unsloth 4-bit bnb pre-quant) | `unsloth/Qwen2.5-Coder-1.5B-Instruct-bnb-4bit` | [Unsloth Qwen 2.5 Coder collection](https://huggingface.co/collections/unsloth/qwen-25-coder) |
142
+ | Parameters | 1.54B total / 1.31B non-embedding | HF card |
143
+ | BF16 size on disk | ~3.1 GB | HF card |
144
+ | 4-bit bnb size on disk | ~1.1 GB | Unsloth card |
145
+ | Context length (native) | 32,768 tokens | HF card config.json |
146
+ | Context length (with YaRN scaling) | 131,072 tokens (factor 4.0) | HF card long-context section |
147
+ | Architecture | 28 layers, 12 Q-heads, 2 KV-heads (GQA), SwiGLU, RMSNorm, RoPE | HF card |
148
+ | Chat template | ChatML-derived with `<|im_start|>` / `<|im_end|>` + system/user/assistant | HF card |
149
+
150
+ ### 3.2 Stretch: Qwen2.5-Coder-3B-Instruct
151
+
152
+ | Attribute | Value |
153
+ |-----------|-------|
154
+ | HF ID | `Qwen/Qwen2.5-Coder-3B-Instruct` |
155
+ | HF ID (Unsloth 4-bit) | `unsloth/Qwen2.5-Coder-3B-Instruct-bnb-4bit` |
156
+ | BF16 size on disk | ~6.2 GB |
157
+ | 4-bit + LoRA (rank 32) VRAM @ 16K context + 8 generations | ~38-45 GB (**fits A100-80GB with headroom; tight/no-go on A100-40GB**) |
158
+ | Context length (native) | 32,768 tokens |
159
+
160
+ ### 3.3 Chat template — one source of truth (prevents H6)
161
+
162
+ **Rule:** SFT data prep AND GRPO rollouts MUST format prompts identically. The only sanctioned path:
163
+
164
+ ```python
165
+ from transformers import AutoTokenizer
166
+ tok = AutoTokenizer.from_pretrained("unsloth/Qwen2.5-Coder-1.5B-Instruct")
167
+
168
+ messages = [
169
+ {"role": "system", "content": SYSTEM_PROMPT},
170
+ {"role": "user", "content": user_prompt},
171
+ ]
172
+ # For training (SFT with assistant response to learn):
173
+ text_with_completion = tok.apply_chat_template(
174
+ messages + [{"role": "assistant", "content": assistant_reply}],
175
+ tokenize=False,
176
+ add_generation_prompt=False,
177
+ )
178
+ # For rollout/eval (model to generate):
179
+ text_for_generation = tok.apply_chat_template(
180
+ messages,
181
+ tokenize=False,
182
+ add_generation_prompt=True,
183
+ )
184
+ ```
185
+
186
+ **Gotchas verified against PITFALLS.md H6 + N2:**
187
+ - Use `Qwen2.5-Coder-1.5B-Instruct` (has chat template baked in), never the non-Instruct base.
188
+ - Do NOT try `return_assistant_tokens_mask=True` — broken on Qwen2.5 per transformers #34172. Full-sequence SFT loss is fine for our scale.
189
+ - TRL 1.2's OpenEnv integration applies the tokenizer's chat template internally for multi-turn tool calls. Do not hand-concatenate `<|im_start|>` strings anywhere in our code.
190
+ - Diff-test the smoke test: tokenize one SFT example with our prep code; tokenize the same example through `GRPOTrainer._prepare_inputs` (set a breakpoint); confirm zero token-ID drift.
191
+
192
+ ---
193
+
194
+ ## 4. GRPOTrainer Config — Minimum Viable
195
+
196
+ Verified shape from [TRL OpenEnv guide](https://huggingface.co/docs/trl/main/en/openenv) + [TRL grpo_config.py on main](https://github.com/huggingface/trl/blob/main/trl/trainer/grpo_config.py). Numbers tuned against PITFALLS.md H1/H5/M1 and FEATURES.md TS-TRN-2.
197
+
198
+ ```python
199
+ from trl import GRPOConfig
200
+
201
+ grpo_config = GRPOConfig(
202
+ # ── Output / logging
203
+ output_dir="outputs/fathom_grpo_1p5b_run1",
204
+ run_name="fathom-1.5b-run1",
205
+ logging_steps=1, # every step, small model
206
+ save_steps=50, # checkpoint every 50 steps (adapter only, cheap)
207
+ save_total_limit=3, # keep last 3 + best
208
+ report_to=["wandb"],
209
+ log_completions=True, # critical for DF-TRN-2 trajectory sampling
210
+
211
+ # ── GRPO core
212
+ num_generations=8, # TRL default; group-std stable. Drop to 4 only if OOM (H1 risk).
213
+ beta=0.04, # KL coefficient floor. 0.0 is TRL default but risks entropy collapse;
214
+ # 0.04 keeps exploration per EDGE-GRPO results (PITFALLS.md H1).
215
+ loss_type="grpo", # vanilla GRPO; leave "dr_grpo" / "bnpo" variants for v2 work.
216
+ epsilon=0.2, # PPO-style clip bound used by GRPO.
217
+ reward_weights=None, # We return ONE composite float; log columns via W&B callback
218
+ # (pattern in ARCHITECTURE.md §6 Pattern 1).
219
+ scale_rewards=True, # group-relative advantage normalization (default; keep).
220
+
221
+ # ── Sequence lengths (tuned to A100-40GB comfort zone; widen if 80GB)
222
+ max_prompt_length=4096, # room for system + 1 context chunk + 1 tool result
223
+ max_completion_length=2048, # TOTAL across multi-turn loop per TRL OpenEnv guide
224
+ # (this is the right knob for long episodes; don't grow before smoke test)
225
+
226
+ # ── Optimizer
227
+ learning_rate=5e-6, # PITFALLS.md M1: 5e-6 to 1e-5 is the 4-bit safe band
228
+ warmup_steps=20, # short warmup before LR ramps
229
+ lr_scheduler_type="cosine",
230
+ max_grad_norm=0.5, # tighter than TRL default 1.0 for 4-bit NaN defense
231
+ adam_beta1=0.9,
232
+ adam_beta2=0.99, # Unsloth recommended for RL
233
+ optim="adamw_8bit", # bnb 8-bit Adam; saves ~4GB vs adamw_torch
234
+
235
+ # ── Throughput
236
+ per_device_train_batch_size=1, # 1.5B @ 16K context: this is the ceiling on A100-40GB
237
+ gradient_accumulation_steps=4, # effective batch 4; raise if larger GPU
238
+ num_train_epochs=1, # step-based control; set max_steps instead
239
+ max_steps=400, # TS-TRN-4: ≥400 steps
240
+ gradient_checkpointing=True, # mandatory for 16K context
241
+ bf16=True, # NOT fp16 (PITFALLS.md M1)
242
+ tf32=True, # A100 free speedup
243
+
244
+ # ── vLLM / inference mode (see §5 below)
245
+ use_vllm=True,
246
+ vllm_mode="colocate", # COLOCATE, not server — routes around TRL #4543 (PITFALLS.md H2)
247
+ vllm_gpu_memory_utilization=0.45, # leave room for training + KV cache
248
+ temperature=0.9, # TRL default; do not drop below 0.7 (H1)
249
+ top_p=0.95,
250
+
251
+ # ── Chat template bridge to OpenEnv tools
252
+ chat_template_kwargs={"enable_thinking": False}, # Qwen2.5-Coder has no thinking tag
253
+ )
254
+ ```
255
+
256
+ ### Passing per-sample reward functions
257
+
258
+ Two reward-function contracts in TRL 1.2:
259
+
260
+ **(A) `environment_factory` mode (preferred — see ARCHITECTURE.md §1):** reward function signature is `(environments, **kwargs) -> list[float]`. Read `env.reward` set during tool calls. This is the OpenEnv-native path.
261
+
262
+ ```python
263
+ def composite_reward(environments, **kwargs) -> list[float]:
264
+ rewards = []
265
+ for env in environments:
266
+ # env.trajectory was populated during tool calls in env.step()
267
+ result = compose_reward_fn(alpha=kwargs.get("alpha", 0.2))(env.trajectory, env.gold)
268
+ # result is a dict with composite + components; log components to W&B here
269
+ _log_components_to_wandb(result)
270
+ rewards.append(result["composite"])
271
+ return rewards
272
+
273
+ trainer = GRPOTrainer(
274
+ model=model,
275
+ args=grpo_config,
276
+ train_dataset=dataset,
277
+ reward_funcs=composite_reward, # single fn returning composite list
278
+ environment_factory=FathomToolEnv, # see TRL OpenEnv guide
279
+ tokenizer=tok,
280
+ )
281
+ ```
282
+
283
+ **(B) `rollout_func` mode (manual):** full control over the rollout loop, reward function receives decoded completions. Use only if environment_factory doesn't fit — we use (A).
284
+
285
+ ### `reward_weights` vs compose-in-fn
286
+
287
+ **Decision:** compose inside our own reward function; leave `reward_weights=None`.
288
+ **Why:** We run Sequential(Gate, WeightedSum) — a gate + weighted sum. TRL's `reward_weights` supports a flat weighted sum only (no gate semantics). Our gate makes `format=0 → composite=0` regardless of other components; TRL's native path cannot express that. Log the per-component floats to W&B as separate metrics ourselves (ARCHITECTURE.md §6 Pattern 1 already spells this out).
289
+
290
+ ---
291
+
292
+ ## 5. Unsloth vs vLLM for Rollouts — Resolved
293
+
294
+ **Decision:** Use **vLLM in colocate mode** (`use_vllm=True, vllm_mode="colocate"`).
295
+ **Do NOT use** Unsloth's `FastLanguageModel.for_inference` as the rollout engine during GRPO.
296
+
297
+ **Why:**
298
+ 1. TRL 1.2's `GRPOTrainer` with `environment_factory` is built around vLLM generation and tool-call parsing. Unsloth's native fast inference plugs into training, not into the multi-turn tool loop. Swapping it in is fork-or-patch territory, not an afternoon's work.
299
+ 2. TRL upstream explicitly recommends `vllm_mode="colocate"` for multi-turn OpenEnv training (verified in the [TRL OpenEnv guide Wordle example](https://huggingface.co/docs/trl/main/en/openenv), which uses the same Qwen small-model pattern as us).
300
+ 3. Server mode (`vllm_mode="server"`) was the variant hit by TRL #4543's multi-turn prefix bug (PITFALLS.md H2). Colocate avoids that code path entirely.
301
+ 4. Unsloth's claim of "fast_inference" being 10-20% faster than vLLM is a single-turn benchmark. For 8-generation × multi-turn × 4K+ prompts, vLLM's continuous batching wins decisively.
302
+
303
+ **Caveat:** keep Unsloth for the model itself (`FastLanguageModel.from_pretrained(..., load_in_4bit=True, fast_inference=True)` — Unsloth loads the weights with its memory-saving patch, then hands off to vLLM for the rollouts). This is the combo the Unsloth GRPO guide endorses.
304
+
305
+ **Confidence:** MEDIUM-HIGH. If colocate OOMs at 16K+8gen on A100-40GB, the recovery is drop to 8K prompt + 4 generations, not switch to Unsloth inference.
306
+
307
+ ---
308
+
309
+ ## 6. LoRA Save Sequence — Verbatim Snippet
310
+
311
+ This is the runnable snippet that replaces every pattern named in PITFALLS.md R4 (#1791, #2339, #1267, #3882, #1877). Do exactly this; no variations.
312
+
313
+ ```python
314
+ # === AT END OF GRPO TRAINING (after trainer.train() returns) ===
315
+ import os
316
+ from pathlib import Path
317
+
318
+ out_dir = Path("outputs/fathom_grpo_1p5b_run1")
319
+ adapter_dir = out_dir / "adapter_final"
320
+ merged_dir = out_dir / "merged_16bit"
321
+
322
+ # (1) ALWAYS save adapter-only first. Small (~15MB), never hits the broken merge paths.
323
+ # This is our reproducibility artifact and our safety net.
324
+ trainer.model.save_pretrained(str(adapter_dir))
325
+ tokenizer.save_pretrained(str(adapter_dir))
326
+
327
+ # (2) Push adapter to HF Hub immediately — cheap and protects against local-disk loss.
328
+ from huggingface_hub import HfApi
329
+ api = HfApi()
330
+ api.upload_folder(
331
+ folder_path=str(adapter_dir),
332
+ repo_id="your-org/fathom-1p5b-adapter",
333
+ repo_type="model",
334
+ commit_message="adapter after step 400",
335
+ )
336
+
337
+ # (3) Save a merged 16-bit model for fast inference at demo time.
338
+ # save_method="merged_16bit" is the ONE safe choice. Never use:
339
+ # - "merged_4bit" → corrupt under QLoRA (#1267, #2339)
340
+ # - "merged_4bit_forced" → silently emits 16bit (#2339) or size-mismatched (#1791)
341
+ # If the call fails inside Docker with an empty dir (#3882), fall back to
342
+ # step (4) manual merge via peft.
343
+ from unsloth import FastLanguageModel
344
+ # trainer.model is already an Unsloth-patched PeftModel
345
+ trainer.model.save_pretrained_merged(
346
+ str(merged_dir),
347
+ tokenizer,
348
+ save_method="merged_16bit", # the only path we trust
349
+ )
350
+
351
+ # (4) Sanity check BEFORE declaring training done: load the merged model,
352
+ # run 3 eval questions, assert non-empty output.
353
+ from transformers import AutoModelForCausalLM, AutoTokenizer
354
+ m = AutoModelForCausalLM.from_pretrained(str(merged_dir), torch_dtype="bfloat16", device_map="auto")
355
+ t = AutoTokenizer.from_pretrained(str(merged_dir))
356
+ for q in SMOKE_QUESTIONS: # 3 short eval prompts
357
+ inputs = t.apply_chat_template([{"role": "user", "content": q}], tokenize=True,
358
+ add_generation_prompt=True, return_tensors="pt").to(m.device)
359
+ out = m.generate(inputs, max_new_tokens=64, do_sample=False)
360
+ decoded = t.decode(out[0][inputs.shape[1]:], skip_special_tokens=True)
361
+ assert len(decoded.strip()) > 0, f"Merged model produced empty output for: {q!r}"
362
+ del m, t
363
+ import torch; torch.cuda.empty_cache()
364
+ ```
365
+
366
+ ### Loading back for eval
367
+
368
+ **Adapter-only (recommended for eval — small, flexible, no merge bugs):**
369
+
370
+ ```python
371
+ from unsloth import FastLanguageModel
372
+ model, tokenizer = FastLanguageModel.from_pretrained(
373
+ model_name="unsloth/Qwen2.5-Coder-1.5B-Instruct-bnb-4bit",
374
+ max_seq_length=200_000, # eval-time context (YaRN scaling applied)
375
+ load_in_4bit=True,
376
+ fast_inference=True,
377
+ )
378
+ model = FastLanguageModel.get_peft_model_from_hub(model, "your-org/fathom-1p5b-adapter")
379
+ FastLanguageModel.for_inference(model)
380
+ ```
381
+
382
+ **Merged-16bit (recommended for Space demo — faster, single directory):**
383
+
384
+ ```python
385
+ from unsloth import FastLanguageModel
386
+ model, tokenizer = FastLanguageModel.from_pretrained(
387
+ model_name=str(merged_dir), # local path or HF hub ID of merged model
388
+ max_seq_length=200_000,
389
+ load_in_4bit=False, # merged-16bit already 16-bit; re-quantize at load if VRAM-tight
390
+ fast_inference=True,
391
+ )
392
+ FastLanguageModel.for_inference(model)
393
+ ```
394
+
395
+ ### RoPE scaling for 200K eval
396
+
397
+ Append to `config.json` of the loaded dir (or pass via `from_pretrained(..., rope_scaling=...)`):
398
+
399
+ ```json
400
+ {"rope_scaling": {"type": "yarn", "factor": 4.0, "original_max_position_embeddings": 32768}}
401
+ ```
402
+
403
+ Verified in HF Qwen2.5 model card long-context section. Per PITFALLS.md M8, this is tested but not guaranteed on all Unsloth long-context paths — have a 100K fallback ready.
404
+
405
+ **Confidence on save sequence: HIGH.** These save semantics are stable across Unsloth 0.1.2x through 0.1.36 per the release notes. The five issue numbers in PITFALLS.md R4 are all about the `merged_4bit_forced` / `merge_and_unload` paths we avoid.
406
+
407
+ ---
408
+
409
+ ## 7. Python REPL Sandbox — Choice + Safety Checklist
410
+
411
+ **Choice:** RestrictedPython (AST-rewriting) **+** subprocess isolation with ulimit. Bolt-and-belt.
412
+
413
+ **Rejected alternatives:**
414
+ - **Plain subprocess with timeout only** — no AST filter means attribute-access escape paths (`__subclasses__`, `catch_warnings`, traceback walk) still work inside the subprocess. Model can escape to subprocess.Popen within our own subprocess.
415
+ - **Pyodide-via-subprocess** — spawns a browser-grade WASM runtime per call; 500ms+ startup per REPL action, kills rollout throughput at 8 generations × multiple turns.
416
+ - **OpenEnv built-in** — OpenEnv ships tool primitives but no Python REPL sandbox; the scaffold expects us to write `env/server/repl.py` (confirmed in ARCHITECTURE.md §1).
417
+
418
+ **Why RestrictedPython + subprocess:**
419
+ - RestrictedPython AST-rewrites at compile time: blocks `__class__`, `__subclasses__`, `__globals__`, attribute access into dunders, star imports. Not a full sandbox but stops 90% of trivial Python-sandbox-escape CTF payloads.
420
+ - Subprocess isolation handles the remaining 10%: if a tricky escape produces an import or system call, it hits the OS-level wall (no network, read-only cwd, ulimit'd CPU/memory, seccomp deny-list). Subprocess crashes don't kill the env server.
421
+ - `RestrictedPython>=7.4` is actively maintained by zopefoundation and mature.
422
+
423
+ ### Safety checklist (implement as `tests/test_repl_sandbox.py` before shipping)
424
+
425
+ After RestrictedPython compile, also explicitly delete from the exec globals:
426
+
427
+ - [ ] `open` — no file I/O
428
+ - [ ] `exec` — no dynamic code
429
+ - [ ] `eval` — no dynamic eval
430
+ - [ ] `compile` — no AST manipulation
431
+ - [ ] `__import__` — whitelist via custom import hook (see below)
432
+ - [ ] `__builtins__` — set to a minimal dict; never pass through the raw module
433
+ - [ ] `input` / `breakpoint` / `help` / `exit` / `quit` — exit paths and interactive prompts
434
+
435
+ Whitelist imports (custom `__import__`):
436
+
437
+ - [ ] `re`, `json`, `math`, `statistics`, `collections`, `itertools`, `string`, `functools`, `operator`
438
+ - [ ] **NO** `os`, `sys`, `subprocess`, `socket`, `urllib`, `http`, `requests`, `pathlib`, `io`, `ctypes`, `threading`, `multiprocessing`, `pickle`, `marshal`, `importlib`
439
+
440
+ Subprocess-level defenses (per-exec OS isolation):
441
+
442
+ - [ ] Separate `subprocess.Popen` with `start_new_session=True`
443
+ - [ ] `ulimit -v 524288` (512MB RAM) via `preexec_fn` / `resource.setrlimit`
444
+ - [ ] `ulimit -t 5` (5s CPU), `ulimit -f 0` (no file writes)
445
+ - [ ] `cwd=/tmp/episode-{uuid}` (ephemeral, wiped on `reset()`)
446
+ - [ ] Non-root user (venue A100 containers default to uid 1000 — verify at setup)
447
+ - [ ] Network: `unshare -n` if available, else seccomp deny `socket`, `connect`, `sendto`
448
+ - [ ] Timeout: hard-kill at 30s wall-clock (`subprocess.wait(timeout=30)`, then `Popen.kill()`)
449
+
450
+ Adversarial unit tests (all must fail-to-escape):
451
+
452
+ - [ ] `().__class__.__base__.__subclasses__()` — attribute access blocked by RestrictedPython
453
+ - [ ] `e.__traceback__.tb_frame.f_back.f_builtins` — traceback walk after a raise
454
+ - [ ] `warnings.catch_warnings()` builtins trick — import blocked by whitelist
455
+ - [ ] `__import__('os')` — whitelist rejects
456
+ - [ ] `getattr(__builtins__, 'open')` — `__builtins__` replaced with minimal dict
457
+ - [ ] 30s-infinite-loop — subprocess killed, env continues
458
+ - [ ] 1GB memory bomb — rlimit kills, env continues
459
+
460
+ Implementation reference: ARCHITECTURE.md §1 (`env/server/repl.py`) + PITFALLS.md H3.
461
+
462
+ **Confidence: HIGH.** RestrictedPython + subprocess is the canonical hackathon-grade sandbox (see dida.do and tianpan.co references in PITFALLS.md sources). Gets us past judge sniff-tests without eating 4+ hours on gVisor/Firecracker setup.
463
+
464
+ ---
465
+
466
+ ## 8. `openenv init` Scaffold Workflow
467
+
468
+ ### 8.1 Scaffold
469
+
470
+ ```bash
471
+ # From repo root
472
+ openenv init fathom --template fastapi
473
+ # or with explicit typed-action template (v0.2.3+):
474
+ openenv init fathom --template fastapi-typed
475
+ ```
476
+
477
+ Files created under `fathom/` (per OpenEnv v0.2.3 template + ARCHITECTURE.md §1/§2 alignment):
478
+
479
+ ```
480
+ fathom/
481
+ ├── openenv.yaml # name, version, entrypoint, max_concurrent_envs
482
+ ├── Dockerfile # python:3.11-slim base + uv install
483
+ ├── src/envs/fathom/
484
+ │ ├── __init__.py # re-exports action/observation/env
485
+ │ ├── models.py # Pydantic: FathomAction, FathomObservation, FathomState
486
+ │ ├── environment.py # FathomEnv(Environment[Action,Obs,State]): reset/step/state
487
+ │ ├── server/
488
+ │ │ └── app.py # from openenv.server import create_app; app = create_app(...)
489
+ │ └── client.py # FathomEnvClient(HTTPEnvClient[Action,Obs])
490
+ ├── tests/
491
+ │ └── test_roundtrip.py # reset() + step() contract test
492
+ ├── pyproject.toml
493
+ └── README.md
494
+ ```
495
+
496
+ ### 8.2 Edits we make (post-init)
497
+
498
+ - Add to `src/envs/fathom/server/app.py`:
499
+ ```python
500
+ app = create_app(
501
+ factory=create_fathom_environment,
502
+ action_cls=FathomAction,
503
+ observation_cls=FathomObservation,
504
+ max_concurrent_envs=64, # ≥ our generation_batch_size (§4)
505
+ )
506
+ ```
507
+ - In `environment.py`, declare `SUPPORTS_CONCURRENT_SESSIONS: bool = True` at module scope (required for TRL's 8 parallel generations — confirmed in [TRL OpenEnv guide §Server concurrency](https://huggingface.co/docs/trl/main/en/openenv#server-concurrency)).
508
+ - Implement REPL + `llm()` primitive per ARCHITECTURE.md §1.
509
+
510
+ ### 8.3 Local run
511
+
512
+ ```bash
513
+ cd fathom
514
+ pip install -e .
515
+ python -m uvicorn src.envs.fathom.server.app:app --host 0.0.0.0 --port 8001
516
+ # In another terminal:
517
+ python -c "from envs.fathom.client import FathomEnvClient; c = FathomEnvClient(base_url='http://localhost:8001'); print(c.reset())"
518
+ ```
519
+
520
+ ### 8.4 Deploy to HF Space
521
+
522
+ ```bash
523
+ # Login once (prerequisite):
524
+ hf auth login # paste WRITE token from hf.co/settings/tokens
525
+
526
+ # Push (creates or updates the Space):
527
+ openenv push fathom \
528
+ --space-id your-hf-username/fathom-env \
529
+ --visibility public
530
+ ```
531
+
532
+ What `openenv push` does under the hood:
533
+ 1. Zips the `fathom/` directory
534
+ 2. Creates/updates the HF Space (SDK: Docker)
535
+ 3. Uploads Dockerfile + code
536
+ 4. HF Space auto-builds the image and starts the FastAPI server on port 7860
537
+
538
+ **Fallback if `openenv push` misbehaves** (it's newish — PITFALLS.md M3 warns about this):
539
+
540
+ ```bash
541
+ # Manual push via huggingface_hub:
542
+ hf repo create fathom-env --type space --sdk docker
543
+ git clone https://huggingface.co/spaces/your-hf-username/fathom-env
544
+ cp -r fathom/* fathom-env/
545
+ cd fathom-env
546
+ git add . && git commit -m "initial env" && git push
547
+ ```
548
+
549
+ Test deployed Space (2+ minutes for cold boot per PITFALLS.md R6):
550
+
551
+ ```bash
552
+ curl -X POST https://your-hf-username-fathom-env.hf.space/reset -H "Content-Type: application/json" -d '{"seed": 0}'
553
+ ```
554
+
555
+ Per FEATURES.md TS-ENV-7 and PITFALLS.md M3 / R6: deploy this Thursday, not during training.
556
+
557
+ **Confidence:** HIGH on the directory layout and pip-install-from-Space pattern (verified in TRL OpenEnv guide §Installation). MEDIUM on `openenv push` stability (newish CLI, have the fallback ready).
558
+
559
+ ---
560
+
561
+ ## 9. SFT Library Choice
562
+
563
+ **Pick:** `trl.SFTTrainer` wrapping an Unsloth `FastLanguageModel` (get_peft_model for LoRA).
564
+
565
+ **Rejected:**
566
+ - `unsloth.UnslothTrainer` — custom trainer, fewer public examples, harder to swap to GRPO afterward.
567
+ - Raw training loop — pointless reinvention; SFTTrainer is ~20 LoC of setup.
568
+
569
+ **Why SFTTrainer + Unsloth FastLanguageModel:**
570
+ - Same model object then passes to `GRPOTrainer` with zero re-init cost (PITFALLS.md H6 prevention).
571
+ - TRL 1.2 `SFTTrainer` accepts Unsloth-patched models without special handling.
572
+ - We keep the chat-template formatting in one place (`formatting_func`) — same template used later in GRPO.
573
+
574
+ ### Snippet — adapting Claude-generated conversations to SFT format
575
+
576
+ ```python
577
+ import json
578
+ from datasets import Dataset
579
+ from trl import SFTConfig, SFTTrainer
580
+ from unsloth import FastLanguageModel
581
+
582
+ # (1) Load base model with LoRA adapters attached (Unsloth patch)
583
+ model, tokenizer = FastLanguageModel.from_pretrained(
584
+ model_name="unsloth/Qwen2.5-Coder-1.5B-Instruct-bnb-4bit",
585
+ max_seq_length=16384,
586
+ load_in_4bit=True,
587
+ fast_inference=False, # SFT only; keep False here, flip to True for GRPO
588
+ )
589
+ model = FastLanguageModel.get_peft_model(
590
+ model,
591
+ r=16,
592
+ target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
593
+ "gate_proj", "up_proj", "down_proj"],
594
+ lora_alpha=32,
595
+ lora_dropout=0.0,
596
+ bias="none",
597
+ use_gradient_checkpointing="unsloth",
598
+ random_state=3407,
599
+ use_rslora=False,
600
+ loftq_config=None,
601
+ )
602
+
603
+ # (2) Claude traces on disk look like this (one line of JSONL per trajectory):
604
+ # {"system": "...", "messages": [{"role":"user","content":"..."},
605
+ # {"role":"assistant","content":"..."},
606
+ # {"role":"tool","content":"..."},
607
+ # {"role":"assistant","content":"..."}]}
608
+
609
+ def load_claude_traces(path):
610
+ rows = []
611
+ with open(path) as f:
612
+ for line in f:
613
+ obj = json.loads(line)
614
+ rows.append({
615
+ "messages": [{"role": "system", "content": obj["system"]}] + obj["messages"]
616
+ })
617
+ return Dataset.from_list(rows)
618
+
619
+ dataset = load_claude_traces("data/claude_traces.jsonl")
620
+
621
+ # (3) formatting_func → apply_chat_template (single source of truth, §3.3 rule)
622
+ def formatting_func(examples):
623
+ return [
624
+ tokenizer.apply_chat_template(msgs, tokenize=False, add_generation_prompt=False)
625
+ for msgs in examples["messages"]
626
+ ]
627
+
628
+ # (4) SFT config — light touch so we don't overfit (PITFALLS.md M7)
629
+ sft_config = SFTConfig(
630
+ output_dir="outputs/fathom_sft",
631
+ num_train_epochs=2, # stop early if eval plateaus; ≥3 overfits
632
+ per_device_train_batch_size=2,
633
+ gradient_accumulation_steps=4,
634
+ learning_rate=1e-5, # low; SFT is warm-start, not the training
635
+ lr_scheduler_type="cosine",
636
+ warmup_ratio=0.03,
637
+ logging_steps=10,
638
+ save_steps=100,
639
+ max_seq_length=16384,
640
+ bf16=True,
641
+ gradient_checkpointing=True,
642
+ packing=False, # our sequences are long; packing corrupts boundaries
643
+ report_to=["wandb"],
644
+ run_name="fathom-sft",
645
+ )
646
+
647
+ trainer = SFTTrainer(
648
+ model=model,
649
+ args=sft_config,
650
+ train_dataset=dataset,
651
+ tokenizer=tokenizer,
652
+ formatting_func=formatting_func,
653
+ )
654
+ trainer.train()
655
+
656
+ # Save SFT adapter — GRPO will load from here
657
+ trainer.model.save_pretrained("outputs/fathom_sft/adapter_final")
658
+ tokenizer.save_pretrained("outputs/fathom_sft/adapter_final")
659
+ ```
660
+
661
+ **Confidence: HIGH.** TRL SFTTrainer + Unsloth is the most-reproduced RL-prep pattern in the Unsloth notebook collection.
662
+
663
+ ---
664
+
665
+ ## 10. Anti-Patterns — Stack-Specific
666
+
667
+ These are "I saw someone do this and it broke at hour 40" patterns. Each has a WHY.
668
+
669
+ ### 10.1 `save_pretrained_merged(save_method="merged_4bit")` on a QLoRA model
670
+
671
+ **Why bad:** Unsloth QLoRA stores 4-bit base weights + fp16 LoRA deltas. Merging into 4-bit requires quantizing the delta back down, which produces corrupt output (GitHub #1267, #2339). Some versions silently write 16bit and misnamed it.
672
+ **Instead:** Always `save_method="merged_16bit"`. Quantize separately later with `bnb` if you need 4-bit for inference.
673
+
674
+ ### 10.2 Mismatched chat template between SFT prep and GRPO rollouts
675
+
676
+ **Why bad:** SFT model sees `<|im_start|>assistant\n`, GRPO rollout generates against `<|im_start|>assistant\n\n`. KV cache diverges, reward collapses, diagnoses as "GRPO just doesn't work." Silent — no exception (PITFALLS.md H6).
677
+ **Instead:** One tokenizer, one `apply_chat_template(..., add_generation_prompt=True)` call, period. Diff-test token IDs in the smoke test.
678
+
679
+ ### 10.3 `max_completion_length > 2048` before the smoke test has run
680
+
681
+ **Why bad:** `max_completion_length` in TRL's OpenEnv path is the TOTAL across multi-turn (verified in [TRL OpenEnv guide §max_completion_length in multi-turn episodes](https://huggingface.co/docs/trl/main/en/openenv)). Setting 8192 with 4 turns generates 32K tokens per rollout × 8 generations = OOM at step 1. No graceful failure.
682
+ **Instead:** Start at 2048, run one smoke-test step at 4K context / 4 turns, then widen. PITFALLS.md H5 has the math.
683
+
684
+ ### 10.4 `vllm_mode="server"` for multi-turn OpenEnv training
685
+
686
+ **Why bad:** TRL #4543 — server mode duplicates the original prompt N times and mis-attributes importance-sampling weights across turns. Training looks smooth but the IS ratio is wrong; model converges to something weird.
687
+ **Instead:** Always `vllm_mode="colocate"` until TRL > 1.2 explicitly fixes #4543 with a patch note naming multi-turn. Pin TRL version.
688
+
689
+ ### 10.5 `pip install -U unsloth` at the venue
690
+
691
+ **Why bad:** Unsloth ships rapid beta releases (5 releases in March 2026 alone per the release log). An "upgrade" at hour 20 can change the merge path, the 4-bit dynamic quant API, or the chat template loader. Debugging the delta eats 4 hours.
692
+ **Instead:** Pin to the exact version in §1 that passed the Thursday smoke test. No upgrades until post-submission retrospective.
693
+
694
+ ### 10.6 Running REPL subprocess in the env server's cwd
695
+
696
+ **Why bad:** Sandbox escape writes a file to cwd → env server's state directory → poisons all subsequent episodes.
697
+ **Instead:** `cwd=/tmp/episode-{uuid}` per exec call, wiped on `reset()`. PITFALLS.md H3.
698
+
699
+ ### 10.7 Logging only the composite reward to W&B
700
+
701
+ **Why bad:** Composite climbs from 0.1 → 0.6. Is the model getting *right* or getting *formatted*? Can't tell. If format-gate only is climbing and correctness is flat, we've been training nothing for 200 steps (PITFALLS.md H4, M5).
702
+ **Instead:** Log format / correctness / tokens / depth as four separate W&B series. Any flat component is a bug.
703
+
704
+ ### 10.8 Passing raw `<|im_start|>user\n...` strings to GRPOTrainer
705
+
706
+ **Why bad:** Bypasses `apply_chat_template`, defeats TRL's tool-call parser, emits double BOS or drops the system prompt. Silent format-reward collapse.
707
+ **Instead:** `dataset` contains `{"prompt": [{"role": "user", "content": "..."}]}` as a list of messages. TRL applies the template internally.
708
+
709
+ ### 10.9 Saving merged model without sanity-checking the output
710
+
711
+ **Why bad:** Merge succeeds, output directory has safetensors, you call `training complete`. At demo time the model produces `!!!!!!!!` for every prompt — merge corrupted weights (PITFALLS.md R4).
712
+ **Instead:** The §6 snippet's step (4) — load the merged dir and generate 3 short outputs, assert non-empty. Two minutes of CPU time prevents a 48-hour-late ruin.
713
+
714
+ ### 10.10 Using `Qwen2.5-Coder-1.5B` (base) instead of `-Instruct`
715
+
716
+ **Why bad:** Base model has no chat template, no instruction-following prior. Format gate stays at 0% for 100 GRPO steps (PITFALLS.md H7).
717
+ **Instead:** Always the `-Instruct` variant for SFT and GRPO. Prefer `unsloth/Qwen2.5-Coder-1.5B-Instruct-bnb-4bit` for faster load.
718
+
719
+ ### 10.11 Trusting `trl vllm-serve` in server mode as a separate process
720
+
721
+ **Why bad:** Two-GPU-minimum setup (PITFALLS.md H2 / TRL #4543 context). At a venue where compute allocation is unknown, single-GPU colocate is the safer default.
722
+ **Instead:** Colocate mode on one GPU, even if two are available; reserves the second GPU for eval sweeps and pareto generation.
723
+
724
+ ### 10.12 `gradient_checkpointing=False` "to go fast"
725
+
726
+ **Why bad:** 1.5B × 16K context × 8 generations needs ~42GB activation memory without checkpointing. OOMs on A100-40GB.
727
+ **Instead:** Always `gradient_checkpointing=True` for 1.5B @ 16K. Use `"unsloth"` string value (not `True`) when on Unsloth-patched models for the 30% speedup.
728
+
729
+ ---
730
+
731
+ ## 11. Prerequisite Setup — One-Time Commands at Venue
732
+
733
+ Run these in order on the A100 machine before any training. Each is a named gate.
734
+
735
+ ```bash
736
+ # ── G1: GPU alive, CUDA visible
737
+ nvidia-smi
738
+ # expect: CUDA Version 12.4+, A100-SXM4-40GB or A100-80GB, 0 MiB used
739
+
740
+ # ── G2: CUDA toolkit version compatible with torch cu124 wheel
741
+ nvcc --version # expect 12.1 or 12.4
742
+
743
+ # ── G3: Python + uv
744
+ python --version # expect 3.11.x
745
+ which uv || pip install uv
746
+
747
+ # ── G4: Install FATHOM env (single pip block from §2 above)
748
+ # run the §2 commands here
749
+
750
+ # ── G5: Verify torch sees the A100
751
+ python -c "import torch; print(torch.cuda.is_available(), torch.cuda.get_device_name(0))"
752
+ # expect: True NVIDIA A100-...
753
+
754
+ # ── G6: HF login (write token from hf.co/settings/tokens)
755
+ hf auth login
756
+ # paste token; verify:
757
+ hf whoami
758
+
759
+ # ── G7: W&B login (API key from wandb.ai/authorize)
760
+ wandb login
761
+ # paste key; verify:
762
+ wandb online
763
+ python -c "import wandb; wandb.init(project='fathom-smoke', mode='online'); wandb.finish()"
764
+
765
+ # ── G8: OpenEnv CLI alive
766
+ openenv --version # expect 0.2.3+
767
+
768
+ # ── G9: Pre-pull the model into HF cache (avoid mid-training download stalls)
769
+ python -c "from huggingface_hub import snapshot_download; \
770
+ snapshot_download('unsloth/Qwen2.5-Coder-1.5B-Instruct-bnb-4bit')"
771
+
772
+ # ── G10: Smoke-test GRPOTrainer imports (catches version-skew issues)
773
+ python -c "from trl import GRPOTrainer, GRPOConfig; \
774
+ from unsloth import FastLanguageModel; \
775
+ from openenv.core import Environment; \
776
+ print('all imports OK')"
777
+
778
+ # ── G11: Sandbox unit tests (these MUST pass before env server goes public)
779
+ pytest tests/test_repl_sandbox.py -v
780
+
781
+ # ── G12: End-to-end smoke — 1 GRPO step
782
+ python scripts/smoke_test.py # from ARCHITECTURE.md §4 — 0.5B, 1 step, 1 generation
783
+ ```
784
+
785
+ If any gate fails, STOP and fix before proceeding. Do not start the 5-hour GRPO run with a yellow gate.
786
+
787
+ ---
788
+
789
+ ## 12. Confidence & Verification Matrix
790
+
791
+ | Recommendation | Confidence | Basis | If LOW → 10-min venue verification |
792
+ |---------------|------------|-------|------------------------------------|
793
+ | TRL == 1.2.0 | HIGH | PyPI dated 2026-04-17 | — |
794
+ | Unsloth == 2026.4.8 (v0.1.36-beta) | MEDIUM | GitHub releases page; rapid iteration | `python -c "import unsloth; print(unsloth.__version__)"` then run G12 smoke test |
795
+ | OpenEnv-core >= 0.2.3 | HIGH | GitHub tag + TRL guide references 0.2.1+ | — |
796
+ | torch 2.6 cu124 | HIGH | PyTorch wheel index stable | `torch.version.cuda` == '12.4' |
797
+ | vllm 0.7.3 | HIGH | TRL 1.2 OpenEnv examples target it | `vllm --version` |
798
+ | `vllm_mode="colocate"` for multi-turn | HIGH | TRL OpenEnv guide Wordle example + PITFALLS #4543 | Run G12 smoke with 1 turn, then with 2 turns — advantage should look normal |
799
+ | `beta=0.04` KL floor | MEDIUM | EDGE-GRPO paper + PITFALLS H1; TRL default is 0.0 | Train 50 steps with β=0.0; if group_std drops <0.05 → switch to 0.04 |
800
+ | `num_generations=8` | HIGH | TRL default; all OpenEnv examples | — |
801
+ | `learning_rate=5e-6` with 4-bit | MEDIUM | PITFALLS M1 "4-bit safe band 5e-6 to 1e-5" | First 20 steps — if loss NaN, drop to 3e-6; if flat, raise to 1e-5 |
802
+ | `max_grad_norm=0.5` | MEDIUM | PITFALLS M1 conservative | Widen to 1.0 only if training too conservative and loss plateaus |
803
+ | Unsloth Qwen2.5-Coder-1.5B-Instruct-bnb-4bit | HIGH | HF Unsloth collection page | — |
804
+ | YaRN factor=4.0 for 200K eval | MEDIUM | HF model card long-context section; PITFALLS M8 warns | At eval time, test 50K → 100K → 150K → 200K; note first OOM point, cap claim there |
805
+ | Merged-16bit save as the only trusted path | HIGH | PITFALLS R4 (5 issue numbers); §6 snippet follows Unsloth docs | — |
806
+ | RestrictedPython + subprocess sandbox | HIGH | Canonical pattern; dida.do / tianpan.co verified | Run the 7 adversarial unit tests in §7 — if any pass, patch before shipping |
807
+ | Streamlit (not Gradio) for demo Space | MEDIUM | Our override on FEATURES.md TS-DEM-2 rationale | Build both skeletons Thu; pick the one that renders the D3 tree cleaner in 30 min |
808
+ | `SFTTrainer` + Unsloth model | HIGH | Unsloth notebook collection; TRL 1.2 supports Unsloth models | — |
809
+ | `openenv push` for HF Space | MEDIUM | OpenEnv CLI 0.2.3 docs; new CLI | Try `push` Thu; if it fails, use the manual `git push` fallback in §8.4 |
810
+ | Chat template one-source-of-truth | HIGH | PITFALLS H6 + HF Qwen card | Diff-test token IDs in smoke — zero drift is the gate |
811
+
812
+ **Low-confidence areas to recheck at venue hour 0 (pre-training):**
813
+ - Unsloth exact version pin (re-run G10 smoke after pinning)
814
+ - vLLM colocate memory fraction — `vllm_gpu_memory_utilization=0.45` may need tuning on A100-40GB vs 80GB; watch `nvidia-smi` during the first 10 GRPO steps
815
+ - β (KL coefficient) — if group_std stays healthy at 0.0, save the 1% throughput; if collapse appears, bump to 0.04. Cheap to toggle mid-run via config.
816
+
817
+ ---
818
+
819
+ ## 13. Sources
820
+
821
+ **Pinned version sources (verified 2026-04-22):**
822
+ - [TRL 1.2.0 on PyPI (2026-04-17)](https://pypi.org/project/trl/) — HIGH
823
+ - [TRL OpenEnv Integration guide](https://huggingface.co/docs/trl/main/en/openenv) — HIGH
824
+ - [TRL GRPOConfig on GitHub main](https://github.com/huggingface/trl/blob/main/trl/trainer/grpo_config.py) — HIGH
825
+ - [Unsloth releases on GitHub](https://github.com/unslothai/unsloth/releases) — HIGH
826
+ - [Unsloth RL Guide](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide) — HIGH
827
+ - [Unsloth Qwen 2.5 Coder collection](https://huggingface.co/collections/unsloth/qwen-25-coder) — HIGH
828
+ - [Qwen2.5-Coder-1.5B-Instruct HF card](https://huggingface.co/Qwen/Qwen2.5-Coder-1.5B-Instruct) — HIGH
829
+ - [Unsloth/Qwen2.5-Coder-1.5B-Instruct HF card](https://huggingface.co/unsloth/Qwen2.5-Coder-1.5B-Instruct) — HIGH
830
+ - [meta-pytorch/OpenEnv GitHub v0.2.3](https://github.com/meta-pytorch/OpenEnv) — HIGH
831
+ - [vLLM training/trl docs](https://docs.vllm.ai/en/latest/training/trl/) — HIGH
832
+ - [RestrictedPython docs](https://restrictedpython.readthedocs.io/) — HIGH
833
+
834
+ **Cross-referenced from sibling research (already vetted):**
835
+ - Unsloth LoRA save bug issues: #1791, #2339, #1267, #3882, #1877 (PITFALLS.md R4) — HIGH
836
+ - TRL multi-turn bug: #4543 (PITFALLS.md H2) — HIGH
837
+ - Chat template gotcha: transformers #34172 (PITFALLS.md N2) — HIGH
838
+ - Qwen2.5 + RoPE scaling for 200K: HF card + Unsloth #3083 (PITFALLS.md M8) — HIGH
839
+ - OpenEnv env server design: ARCHITECTURE.md §1, §8 — HIGH
840
+ - Reward decomposition pattern: ARCHITECTURE.md §6 Pattern 1 — HIGH
841
+ - Sandbox pattern choice: dida.do + tianpan.co via PITFALLS.md H3 — HIGH
842
+
843
+ ---
844
+
845
+ **Working-verified-as-of: 2026-04-22.** Re-run §11 gates G1–G12 on the A100 before any long-running training. If a version in §1 is no longer installable, check the release notes for the closest patch version; do NOT jump a minor version at the venue.
.planning/research/SUMMARY.md ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FATHOM — Research Synthesis (SUMMARY)
2
+
3
+ **Project:** FATHOM — RL-trained Recursive Language Model (Meta OpenEnv Hackathon Grand Finale, Theme 2: Long-Horizon Planning)
4
+ **Domain:** Open-source 1.5B LM + GRPO on an OpenEnv-compliant environment that teaches the model to use a Python REPL + recursive `llm()` primitive to answer 200K-token questions its 4K context cannot hold.
5
+ **Researched:** 2026-04-22 / 2026-04-23 (STACK, FEATURES, ARCHITECTURE, PITFALLS — four-way research complete)
6
+ **Overall confidence:** MEDIUM-HIGH. Every load-bearing decision is verified against primary sources; residual uncertainty is venue-specific (A100 flavor, Mercor sub-prize exact rubric, `openenv push` stability, 3B stretch feasibility).
7
+
8
+ ---
9
+
10
+ ## Top 5 Findings
11
+
12
+ 1. **The Mercor hook is the pitch, not a side quest.** DF-REW-1 (α-parameterized token-budget shaping, same checkpoint evaluated at α ∈ {0.05, 0.2, 0.5, 1.0}) + DF-REW-2 (Pareto chart) + DF-PIT-1 (sub-prize slide) together are the *only* differentiator combination where FATHOM moves from "competent hackathon RLM" to "podium + sub-prize". Every table-stake item exists to get us onto the Pareto chart; the chart is the product. Roadmap must gate "Pareto-chart-ready" as a Phase-3 exit criterion.
13
+
14
+ 2. **The critical path is architecturally thin but temporally long.** One sequence — env contract → REPL+`llm()` → env.server → rollout loop → GRPO run → eval sweep → viz — blocks everything downstream. The GRPO run itself (~5h wall-clock on A100-40GB for 400 steps at 1.5B / 16K / 8-gen) is the single longest unparallelizable block. This collapses "40 usable hours" into ~20h of serial path + ~20h of parallel work around it.
15
+
16
+ 3. **The deterministic-verifier-only stance is our credibility moat.** PITFALLS R3 + H4 + H8 + FEATURES anti-feature "LLM-as-judge in the reward loop" converge: an exact-match / numeric-span verifier, audited pre-training (REWARD-03) and re-audited every 50 steps (REWARD-04), is the single highest-leverage anti-hacking artifact a 1.5B-model team can ship. DF-MET-1 (holdout accuracy plotted over reward curve) is the kill-shot chart. Skipping the audit is a RUIN mode.
17
+
18
+ 4. **Failure isolation beats optimism.** ARCHITECTURE §10 + PITFALLS R4/R5/R6 force a non-obvious truth: *adapter-only saves pushed to HF Hub every 50 steps are more valuable than the merged model*, because adapters never hit the broken merge path. The whole plan is built around "always have a recoverable artifact at every boundary."
19
+
20
+ 5. **Stack opinions are HIGH-confidence and prescriptive — do not deviate at venue.** TRL 1.2.0 + Unsloth 2026.4.8 + OpenEnv 0.2.3 + Qwen2.5-Coder-1.5B-**Instruct** + vLLM 0.7.3 in **colocate** mode + RestrictedPython + subprocess. All verified 2026-04-22. `pip install -U` at venue is a named anti-pattern.
21
+
22
+ ---
23
+
24
+ ## Conflict Resolutions (sibling-doc disagreements — resolved, not deferred)
25
+
26
+ | Conflict | FEATURES.md says | STACK.md says | **SUMMARY decision** | Why |
27
+ |----------|------------------|---------------|----------------------|-----|
28
+ | Demo framework | TS-DEM-2: **Gradio** | §1.1: **Streamlit** (better D3 embed + multi-column layout) | **Streamlit for `fathom-demo` Space** (override TS-DEM-2) | Recursion-tree viz (DF-DEM-1) is load-bearing; Streamlit's `st.components.v1.html` + `st.columns` delivers left-input / middle-tree / right-Pareto cleanly. Thu evening: time-box 30 min to prototype both; pick cleaner renderer. |
29
+ | Sandbox depth | Anti-feature: no VM sandbox | §7 + PITFALLS H3: RestrictedPython + subprocess + 7 adversarial tests | **RestrictedPython + subprocess + STACK §7 checklist** | No real conflict; STACK specifies the floor. Gate env-goes-public on all 7 tests failing-to-escape. |
30
+ | 3B stretch | DF-TRN-5: LOW confidence, skip | §3.2: fits A100-80GB, tight on 40GB | **Config-flip-only stretch, hour-10 GO/NO-GO** | Upside not dependency; Hydra `model=qwen_3b` override, no code branch. |
31
+ | Reward weight composition | TS-REW-2: `Sequential(Gate, WeightedSum)` | §4: TRL `reward_weights` cannot express gate | **Compose in our own fn; `reward_weights=None`** | TRL can't express format=0 → composite=0 gate. Single fn returns composite; W&B callback logs components separately. |
32
+
33
+ ---
34
+
35
+ ## Stack at a Glance (exact pins — April 2026, verified 2026-04-22)
36
+
37
+ | Library | Version | Role | Confidence |
38
+ |---------|---------|------|------------|
39
+ | `torch` | `==2.6.0` (cu124) | Base framework | HIGH |
40
+ | `transformers` | `==4.49.0` | Model, tokenizer, chat template | HIGH |
41
+ | `accelerate` | `==1.5.2` | Distributed + mixed precision | HIGH |
42
+ | `peft` | `==0.14.0` | LoRA adapters | HIGH |
43
+ | `bitsandbytes` | `==0.45.1` | 4-bit + 8-bit Adam | HIGH |
44
+ | `trl` | `==1.2.0` | GRPOTrainer, SFTTrainer, OpenEnv integration | HIGH |
45
+ | `unsloth` | `==2026.4.8` (v0.1.36-beta) | Efficiency, FastLanguageModel, merged-16bit save | MEDIUM (beta — pin after Thu smoke) |
46
+ | `unsloth-zoo` | latest-compatible | Unsloth helpers | MEDIUM |
47
+ | `vllm` | `==0.7.3` | Rollout generation (colocate) | HIGH |
48
+ | `openenv-core[core]` | `>=0.2.3,<0.3` | Env base + `openenv init`/`push` | HIGH |
49
+ | `fastapi` | `>=0.115,<0.120` | Env server | HIGH |
50
+ | `uvicorn[standard]` | `>=0.32,<1.0` | ASGI | HIGH |
51
+ | `pydantic` | `>=2.9,<3.0` | Typed Action/Observation/State | HIGH |
52
+ | `RestrictedPython` | `>=7.4,<8.0` | REPL AST filter | HIGH |
53
+ | `datasets` | `==3.3.2` | JSONL → HF Dataset | HIGH |
54
+ | `huggingface_hub` | `>=0.28,<1.0` | Model + Space push | HIGH |
55
+ | `wandb` | `>=0.18,<1.0` | Metrics logging | HIGH |
56
+ | `streamlit` | `>=1.39,<2.0` | Demo Space UI (overrides Gradio) | MEDIUM |
57
+ | `plotly` | `>=5.24,<6.0` | Pareto chart | HIGH |
58
+ | `matplotlib` | `>=3.9,<4.0` | Quick plots | HIGH |
59
+ | `anthropic` | `>=0.40,<1.0` | Claude API for 500 SFT traces | HIGH |
60
+ | `flash-attn` | `==2.7.4.post1` | Generation speedup | HIGH |
61
+ | **Base model** | `unsloth/Qwen2.5-Coder-1.5B-Instruct-bnb-4bit` | Primary; 3B-Instruct-bnb-4bit stretch | HIGH |
62
+ | Python | `3.11.x` (NOT 3.12) | Unsloth parity | HIGH |
63
+ | CUDA | `12.4+` | Torch cu124 wheel | HIGH |
64
+
65
+ **Critical GRPOConfig values** (STACK §4): `num_generations=8`, `beta=0.04` (KL floor, not TRL default 0.0), `lr=5e-6`, `max_grad_norm=0.5`, `bf16=True` (NEVER fp16), `vllm_mode="colocate"` (NEVER server), `max_prompt_length=4096`, `max_completion_length=2048`, `gradient_checkpointing="unsloth"`, `optim="adamw_8bit"`, `max_steps=400`.
66
+
67
+ ---
68
+
69
+ ## Must-Haves for Passing the Judging Floor
70
+
71
+ **Environment:** TS-ENV-1 (`openenv init` scaffold), TS-ENV-2 (Pydantic dataclasses), TS-ENV-3 (`reset(seed, difficulty)`), TS-ENV-4 (episode metadata in state), TS-ENV-6 (REPL + `llm()` with root-only code), TS-ENV-7 (HF Space deployed *before* training).
72
+
73
+ **Rewards:** TS-REW-1 (deterministic exact-match verifier — NO LLM judge), TS-REW-2 (`Sequential(Gate, WeightedSum([Corr×0.75, Tok×0.20, Rec×0.05]))`), TS-REW-3 (per-component logging), TS-REW-4 (adversarial audit in `REWARD_AUDIT.md`), TS-REW-5/6/7 (masked-context, format-only baseline, length-exploit tests pass).
74
+
75
+ **Training:** TS-TRN-1 (SFT warmup on ~500 Claude traces), TS-TRN-2 (GRPO via TRL + Unsloth + LoRA), TS-TRN-3 (untrained-baseline 25–50% on easy tier), TS-TRN-4 (≥400 steps + reward curve), TS-TRN-5 (adapter saved to HF Hub via STACK §6 snippet), TS-TRN-6 (public W&B run link in README).
76
+
77
+ **Demo:** TS-DEM-1 (public GitHub repo + README), TS-DEM-2 (HF Space interactive UI — Streamlit per override), TS-DEM-3 (clean reward curve chart), TS-DEM-4 (before/after trace on 200K question), TS-DEM-5 (`run_sft.sh` / `run_grpo.sh` / `run_eval.sh`).
78
+
79
+ **Pitch:** TS-PIT-1 (3-min rehearsed, problem-first), TS-PIT-2 (pre-baked Q&A ≤20s for RAG/bigger-model/prompting/reward-hacking), TS-PIT-3 (tagline: *"A 1.5B model answers questions about documents 50× its context window — because we trained it to use Python to read."*).
80
+
81
+ **Reproducibility & safety:** TS-REP-1 (pinned `pyproject.toml`), TS-REP-2 (Dockerfile — OpenEnv scaffold), TS-REP-3 (<5 min README quickstart), TS-REP-4 (RestrictedPython + subprocess + 30s timeout + 512MB RLIMIT + 7 adversarial tests).
82
+
83
+ ---
84
+
85
+ ## Differentiator Wins (cross-checked against Mercor hook)
86
+
87
+ **Mercor sub-prize trio (ship all three or lose the narrative):** DF-REW-1 (α parameterization), DF-REW-2 (Pareto chart), DF-PIT-1 (dedicated Mercor slide with "Same checkpoint, different α. User picks the cost-accuracy point.").
88
+
89
+ **Anti-hacking credibility multipliers:** DF-REW-4 (`pytest -m reward_audit`), DF-MET-1 (holdout accuracy *over* reward curve — the kill-shot chart), DF-REW-5 (20-trajectory human spot-check).
90
+
91
+ **Storytelling amplifiers:** DF-DEM-1 (recursion-tree viz, fork from `alexzhang13/rlm`), DF-DEM-2 (three-panel before/after), DF-DEM-3 (interactive HF Space with live tree), DF-DEM-4 (accuracy-vs-context-length at 4K/16K/64K/200K).
92
+
93
+ **Training rigor:** DF-TRN-1 (eval during training), DF-TRN-2 (trajectory sampling to `trajectories/step_{N}.md`), DF-TRN-3 (Thu laptop 0.5B smoke test), DF-TRN-4 (KL + clip-ratio curves alongside reward).
94
+
95
+ **Environment polish:** DF-ENV-1 (curriculum `reset(difficulty)`), DF-ENV-2 (4 task types with deterministic verifiers).
96
+
97
+ **Reproducibility edge:** DF-REP-1 (one-command `bash reproduce.sh`), DF-REP-3 (Hydra-composable YAML configs).
98
+
99
+ ---
100
+
101
+ ## Avoid at All Costs (daily venue checklist)
102
+
103
+ **RUIN modes (prevention is the only defense):**
104
+
105
+ 1. **Untrained 1.5B has 0% on all tiers (R1)** → pre-event HF Inference API spot-check; design 2–3 trivial tasks so success-floor exists; SFT warmup non-negotiable.
106
+ 2. **A100 unavailable >8h (R2)** → adapter pushed to Hub every 50 steps; $50 RunPod contingency; 0.5B laptop fallback.
107
+ 3. **Reward loophole → model cheats (R3)** → REWARD-03 manual pre-training audit + REWARD-04 scheduled audits + DF-MET-1 kill-shot chart.
108
+ 4. **LoRA save bug at hour 47 (R4)** → STACK §6 verbatim snippet: adapter-only FIRST, push Hub, then merged-16bit, then 3-question sanity check. **NEVER `merged_4bit` or `merged_4bit_forced`.**
109
+ 5. **Claude Code compaction wipes session (R5)** → `.planning/VENUE_STATE.md` updated before every `/compact`; commit every 30 min; `git log --oneline` survives compaction.
110
+ 6. **Live demo fails at pitch (R6)** → 60s recorded video in repo; HF Space kept warm 5 min before slot; HDMI+USB-C+MiniDP adapters; phone hotspot.
111
+
112
+ **HIGH-severity guards:**
113
+
114
+ - Entropy collapse (H1) → log `completions/mean_group_std`; β=0.04; temp 0.7–1.0.
115
+ - TRL #4543 multi-turn bug (H2) → ALWAYS `vllm_mode="colocate"`; pin TRL==1.2.0.
116
+ - Sandbox escape (H3) → RestrictedPython + subprocess + 7 adversarial tests must fail-to-escape.
117
+ - Chat-template drift SFT↔GRPO (H6) → one `apply_chat_template(add_generation_prompt=True)` call; diff-test token IDs.
118
+ - Format gate stuck at 0% (H7) → `-Instruct` variant; additive format reward (+0.3/+0.3/+0.4); SFT first.
119
+ - Time-budget inversion (H9) → TRAIN-03 smoke test gates everything; 4 task types ship > 10 half-built.
120
+ - Over-claiming 200K (H10) → pre-validated 200K question 20/20; 50K/100K fallbacks ready.
121
+
122
+ **Anti-features (don't build even with time):** LLM-as-judge in reward loop; learned reward models; PPO/DPO; training at depth >2; sub-LMs writing Python; RL from scratch (no SFT); training context >16K; real-world training corpora; other hackathon themes; comparing head-to-head with GPT-4/Claude; multiple base models; custom React frontend; gVisor/Firecracker; live training during pitch; technique-first pitch open; `pip install -U unsloth` at venue.
123
+
124
+ ---
125
+
126
+ ## Build Order (ARCHITECTURE §4 — one line per phase)
127
+
128
+ 1. **Phase 0 — Foundation (Thu evening, ~6h, SEQUENTIAL solo):** `openenv init` → Pydantic models → RestrictedPython+subprocess REPL → `llm()` stub → env server → client → roundtrip smoke. **Exit:** env server answers `reset()`+`step(dummy)` locally.
129
+ 2. **Phase 1 — Parallel scaffolding (Thu night → Fri, ~20h):** A (Hardik): training stack (SFT + GRPO + rollout + 0.5B laptop smoke). B (Pratham): 4 reward components + compose + audit + 1000/200 synthetic dataset + 500 Claude SFT traces + HF Space deploy. **Exit:** 0.5B GRPO smoke test runs one full step end-to-end.
130
+ 3. **Phase 2 — Venue Day 1 (Sat, ~10h, MERGED CRITICAL PATH):** 1.5B SFT (~1h) → 1.5B GRPO 400 steps (~5h A100) → during GRPO: build `viz/tree.py`, `viz/pareto.py`, `eval/run_eval.py`. Hour-10 GO/NO-GO on 3B stretch (TRAIN-04).
131
+ 4. **Phase 3 — Venue Day 2 (Sun, ~10h, DEMO):** eval sweep α ∈ {0.05, 0.2, 0.5} → `pareto.json` → Streamlit demo Space with live recursion-tree → 60s fallback demo video → pitch rehearsal with pre-baked Q&A → push final repo. **Last 4h: NO code changes.**
132
+
133
+ **Phase-gate artifacts:** Phase 0 exit = env roundtrip + `test_client_server.py` green. Phase 1 exit = 0.5B smoke step + env Space live + `REWARD_AUDIT.md`. Phase 2 exit = 1.5B adapter on Hub + reward curve + untrained/trained eval captured. Phase 3 exit = Pareto chart + demo Space + pitch rehearsed ≥3× with 20/20 demo reliability.
134
+
135
+ ---
136
+
137
+ ## Critical Path (single named sequence that blocks everything)
138
+
139
+ **env contract (Pydantic models) → REPL + `llm()` primitive (RestrictedPython + subprocess + HTTP to inference) → env.server (FastAPI `reset/step/state`) → rollout loop (multi-turn, retry, timeout) → GRPO run (1.5B @ 16K, 400 steps, ~5h A100 wall-clock) → eval sweep (α × eval.jsonl) → viz (recursion tree + Pareto).**
140
+
141
+ If any single link breaks unrecoverably, the Mercor sub-prize is gone and the judging-floor demo is at risk. Everything else (dataset generation, SFT trace gen, HF Space deploy, Streamlit UI, pitch assets) parallelizes off this spine, but *nothing downstream of link 5 can start until GRPO produces a checkpoint*. The single greatest bottleneck is the GRPO run itself — ~5h of A100 wall-clock, unparallelizable, can't be started until SFT adapter exists, can't be accelerated below `num_generations=8 × 16K context × 400 steps`. This is why TRAIN-03 (laptop 0.5B smoke) and TRAIN-06 (untrained-baseline measurement) are non-negotiable.
142
+
143
+ ---
144
+
145
+ ## Top 3 Open Questions (60-second human decisions — escalate to user)
146
+
147
+ 1. **3B stretch pre-provisioning: commit to A100-80GB or explicitly cut 3B from roadmap?** FEATURES DF-TRN-5 says LOW confidence; STACK §3.2 says 3B + LoRA 32 + 16K context + 8 gen ≈ 38–45 GB → fits 80GB, tight/no-go on 40GB. Venue flavor unknown. Decide before Thursday. Wrong-call cost: ~6h at venue chasing a 3B run that OOMs.
148
+
149
+ 2. **SFT trace budget: approve ~$30–80 Claude API spend for 500 traces Thu evening?** PITFALLS R1 + H7 + M2 + M7 converge — SFT warmup is the biggest lever against "GRPO learns nothing." Alternative: 200 traces via cheaper model. Recommendation: approve the 500-trace spend. Wrong-call cost: ~3h venue rework if SFT under-primes the base model.
150
+
151
+ 3. **Demo UI: commit to Streamlit now, or time-box 30 min Thu to prototype both Streamlit and Gradio and pick the cleaner renderer?** STACK §12 recommends the 30-min prototype; FEATURES TS-DEM-2 defaulted to Gradio. Recommendation: time-box 30 min (option a). Wrong-call cost: ~2h lost switching late if the first bet doesn't render.
152
+
153
+ ---
154
+
155
+ ## Confidence Assessment
156
+
157
+ | Area | Confidence | Notes |
158
+ |------|------------|-------|
159
+ | Stack | HIGH | Every pin verified against PyPI/GitHub/HF on 2026-04-22. Only MEDIUM item: Unsloth beta (rapid-release risk — mitigated by Thu smoke test + §10.5 "no -U at venue"). |
160
+ | Features | MEDIUM-HIGH | OpenEnv/GRPO/RLM sources verified; Mercor rubric inferred (no public rubric). Weights 0.75/0.20/0.05 defensible but not "winning formula." |
161
+ | Architecture | HIGH | OpenEnv scaffold + TRL GRPO patterns verified against official docs; two reference impls exist. `openenv push` MEDIUM — manual `git push` fallback documented. |
162
+ | Pitfalls | HIGH | Every RUIN + HIGH-severity pitfall ties to a GitHub issue number or primary source (TRL #4543, Unsloth #1267/#1791/#1877/#2339/#3882/#3506/#3083, transformers #34172). |
163
+ | **Overall** | **MEDIUM-HIGH** | Enough confidence to commit without further research. Residual uncertainty is venue logistics + Mercor rubric — neither blocks planning. |
164
+
165
+ ### Gaps to Address During Planning / Execution
166
+
167
+ - **Venue A100 flavor (40GB vs 80GB)** — confirm first 10 min at venue; drives 3B go/no-go.
168
+ - **Mercor sub-prize exact rubric** — not published; α-sweep approach is our interpretation; adjust DF-PIT-1 copy if rubric handed out Day 1.
169
+ - **HF Space CPU vs GPU tier for 200K inference** — decide from Sunday dress rehearsal latency; ngrok-to-laptop demo fallback.
170
+ - **`openenv push` stability** — try Thursday; manual `git push` fallback in STACK §8.4.
171
+ - **YaRN factor=4.0 on Unsloth long-context path (issue #3083)** — eval sweep 50K→100K→150K→200K; cap claim at first OOM; 100K fallback story ready.
172
+ - **TRL `GRPOTrainer` closure-over-α reward fn** — discover in Phase 1 smoke; fallback is class-based callable (~20 LoC).
173
+
174
+ ---
175
+
176
+ ### Executive Summary (one-paragraph)
177
+
178
+ FATHOM is a 1.5B Qwen2.5-Coder-Instruct + GRPO submission to the Meta OpenEnv Grand Finale where the unique win is the Mercor Pareto chart: one trained checkpoint evaluated at multiple α values, plotted as accuracy-vs-tokens. The stack (TRL 1.2.0 + Unsloth 2026.4.8 + OpenEnv 0.2.3 + vLLM 0.7.3 colocate) is HIGH-confidence and must not be deviated from at venue. The critical path is a single sequence culminating in a ~5h GRPO run; everything before it must be green by Saturday morning, and adapter-only saves to HF Hub every 50 steps are the insurance policy for the whole plan.
.python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.11
CLAUDE.md ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!-- GSD:project-start source:PROJECT.md -->
2
+ ## Project
3
+
4
+ **FATHOM — First RL-Trained Recursive Language Model**
5
+
6
+ FATHOM is an OpenEnv environment + GRPO training pipeline that teaches a small open-source language model (Qwen2.5-Coder-1.5B-Instruct, 4-bit + LoRA) to *use* a Recursive Language Model (RLM) scaffold well: slice long contexts with Python, grep for relevant regions, delegate to sub-LM calls only when needed, and answer questions about documents that are 50× larger than its own context window. It is our submission to the Meta × PyTorch × Hugging Face OpenEnv Hackathon Grand Finale (Scaler School of Technology, Bangalore, April 25–26, 2026).
7
+
8
+ **Core Value:** A 1.5B model trained on our environment solves 200K-token QA tasks its own 4K context cannot hold — demonstrated with a clean reward curve, a selective recursion-tree visualization, and a Pareto frontier of accuracy-vs-tokens. If only one thing ships, it is this single demo.
9
+
10
+ ### Constraints
11
+
12
+ - **Timeline**: ~40 usable hours of building across 4 days — every phase must produce a committed, demonstrable artifact before moving on, because there is no slack for context-lost rebuilds.
13
+ - **Tech stack**: TRL (trainer) + Unsloth (efficiency + inference) + OpenEnv (environment standard) + Hugging Face Spaces (deployment). Dictated by hackathon rubric — deviating costs "judge goodwill" points.
14
+ - **Algorithm**: GRPO specifically (not PPO/DPO) — hackathon rubric names GRPO as canonical, public reproductions exist to crib from, TRL `GRPOTrainer` is the first-class path.
15
+ - **Base model**: Qwen2.5-Coder-1.5B-Instruct (4-bit via `unsloth/Qwen2.5-Coder-1.5B-Instruct-bnb-4bit`). 3B cut post-research-synthesis — venue A100 flavor unknown, 3B would eat the 40-hour budget for uncertain upside. 1.5B with clean curve beats 3B with partial curve on judging.
16
+ - **Pre-event hardware**: Laptop RTX 4060, 8 GB VRAM. Can only run CPU/0.5B smoke tests locally — 1.5B+ runs are venue-only.
17
+ - **Reward verifier must be deterministic**: No LLM-as-judge in the training loop. Every task has an exact-match or short-span gold answer.
18
+ - **Context window**: Train at 16 K, claim generalization to 200 K at eval. Rollout latency at 200 K is prohibitive for GRPO.
19
+ - **Recursion depth in training**: ≤ 2. Deeper recursion is demo-only at inference time.
20
+ - **Judging criteria (hard)**: Submissions are scored on clear env design + objective rewards + demonstrable improvement + anti-reward-hacking + reproducible deployment + sharp demo. Every phase must produce an artifact mapping to at least one criterion.
21
+ - **Pitch**: 3 minutes, 30% of judging weight goes to storytelling. The Pareto chart and recursion-tree viz must exist before the last 4 hours, not after.
22
+ <!-- GSD:project-end -->
23
+
24
+ <!-- GSD:stack-start source:research/STACK.md -->
25
+ ## Technology Stack
26
+
27
+ ## 1. Versions by Role (April 2026 pins)
28
+ | Role | Package | Version pin | Confidence | Source |
29
+ |------|---------|-------------|------------|--------|
30
+ | **OpenEnv core/CLI** | `openenv-core[core]` | `>=0.2.3,<0.3` | HIGH | [meta-pytorch/OpenEnv v0.2.3, Mar 28 2026](https://github.com/meta-pytorch/OpenEnv) |
31
+ | **TRL (GRPOTrainer + OpenEnv integration)** | `trl` | `==1.2.0` | HIGH | [PyPI trl 1.2.0, Apr 17 2026](https://pypi.org/project/trl/); [TRL OpenEnv guide](https://huggingface.co/docs/trl/main/en/openenv) |
32
+ | **Unsloth (efficiency + save)** | `unsloth` | `==2026.4.8` (v0.1.36-beta tag, whatever passes Thu smoke test) | MEDIUM | [unslothai/unsloth releases, v0.1.36-beta Apr 8 2026](https://github.com/unslothai/unsloth/releases) |
33
+ | **Unsloth Zoo** | `unsloth-zoo` | latest-compatible (Unsloth resolves) | MEDIUM | — |
34
+ | **Torch (CUDA 12.4 for A100)** | `torch` | `==2.6.0` | HIGH | PyTorch 2.6+ is Unsloth-supported; 2.6 ships cu124 wheel |
35
+ | **Transformers** | `transformers` | `==4.49.0` | HIGH | Pinned by TRL 1.2 requirements |
36
+ | **Accelerate** | `accelerate` | `==1.5.2` | HIGH | Pinned by TRL 1.2 |
37
+ | **PEFT** | `peft` | `==0.14.0` | HIGH | Pinned by TRL 1.2 |
38
+ | **bitsandbytes** | `bitsandbytes` | `==0.45.1` | HIGH | 0.45+ required for cu124 + Ampere+Hopper paths |
39
+ | **vLLM (for colocate inference)** | `vllm` | `==0.7.3` | HIGH | Matches TRL 1.2 server + colocate contract; Unsloth fast_inference path compatible |
40
+ | **Datasets** | `datasets` | `==3.3.2` | HIGH | Transformers 4.49 pair |
41
+ | **HF Hub** | `huggingface_hub` | `>=0.28,<1.0` | HIGH | Space push + snapshot_download |
42
+ | **FastAPI (env server)** | `fastapi` | `>=0.115,<0.120` | HIGH | OpenEnv scaffold generates FastAPI app |
43
+ | **Uvicorn** | `uvicorn[standard]` | `>=0.32,<1.0` | HIGH | — |
44
+ | **Pydantic** | `pydantic` | `>=2.9,<3.0` | HIGH | OpenEnv + TRL both on Pydantic 2 |
45
+ | **W&B** | `wandb` | `>=0.18,<1.0` | HIGH | Default TRL callback |
46
+ | **Streamlit (viz + tree)** | `streamlit` | `>=1.39,<2.0` | HIGH | Richer than Gradio for recursion-tree D3 embed (see §1.1) |
47
+ | **Plotly (Pareto chart)** | `plotly` | `>=5.24,<6.0` | HIGH | — |
48
+ | **Matplotlib (quick plots, W&B)** | `matplotlib` | `>=3.9,<4.0` | HIGH | — |
49
+ | **RestrictedPython (REPL AST filter)** | `RestrictedPython` | `>=7.4,<8.0` | HIGH | [zopefoundation/RestrictedPython docs](https://restrictedpython.readthedocs.io/) |
50
+ | **Claude API (SFT data gen)** | `anthropic` | `>=0.40,<1.0` | HIGH | SFT trace generator only; not a runtime dep |
51
+ ### 1.1 Streamlit vs Gradio — decided
52
+ ## 2. Canonical Install — Single Block
53
+ # Step 0: CUDA 12.4 sanity check
54
+ # Step 1: create env (uv preferred; pip fallback below)
55
+ # Step 2: torch first (CUDA 12.4 wheel), own line to avoid resolver confusion
56
+ # Step 3: core training stack
57
+ # Step 4: Unsloth (pin to Thu-smoke-test version; DO NOT -U at venue)
58
+ # Step 5: env server + tooling
59
+ # Step 6: observability + viz + data gen
60
+ # Step 7: flash-attention (optional but big speedup for generation)
61
+ ## 3. Qwen2.5-Coder Specifics
62
+ ### 3.1 Primary: Qwen2.5-Coder-1.5B-Instruct
63
+ | Attribute | Value | Source |
64
+ |-----------|-------|--------|
65
+ | HF ID (base) | `Qwen/Qwen2.5-Coder-1.5B-Instruct` | [HF model card](https://huggingface.co/Qwen/Qwen2.5-Coder-1.5B-Instruct) |
66
+ | HF ID (Unsloth-preferred for faster load) | `unsloth/Qwen2.5-Coder-1.5B-Instruct` | [unsloth/Qwen2.5-Coder-1.5B-Instruct](https://huggingface.co/unsloth/Qwen2.5-Coder-1.5B-Instruct) |
67
+ | HF ID (Unsloth 4-bit bnb pre-quant) | `unsloth/Qwen2.5-Coder-1.5B-Instruct-bnb-4bit` | [Unsloth Qwen 2.5 Coder collection](https://huggingface.co/collections/unsloth/qwen-25-coder) |
68
+ | Parameters | 1.54B total / 1.31B non-embedding | HF card |
69
+ | BF16 size on disk | ~3.1 GB | HF card |
70
+ | 4-bit bnb size on disk | ~1.1 GB | Unsloth card |
71
+ | Context length (native) | 32,768 tokens | HF card config.json |
72
+ | Context length (with YaRN scaling) | 131,072 tokens (factor 4.0) | HF card long-context section |
73
+ | Architecture | 28 layers, 12 Q-heads, 2 KV-heads (GQA), SwiGLU, RMSNorm, RoPE | HF card |
74
+ | Chat template | ChatML-derived with `<|im_start|>` / `<|im_end|>` + system/user/assistant | HF card |
75
+ ### 3.2 Stretch: Qwen2.5-Coder-3B-Instruct
76
+ | Attribute | Value |
77
+ |-----------|-------|
78
+ | HF ID | `Qwen/Qwen2.5-Coder-3B-Instruct` |
79
+ | HF ID (Unsloth 4-bit) | `unsloth/Qwen2.5-Coder-3B-Instruct-bnb-4bit` |
80
+ | BF16 size on disk | ~6.2 GB |
81
+ | 4-bit + LoRA (rank 32) VRAM @ 16K context + 8 generations | ~38-45 GB (**fits A100-80GB with headroom; tight/no-go on A100-40GB**) |
82
+ | Context length (native) | 32,768 tokens |
83
+ ### 3.3 Chat template — one source of truth (prevents H6)
84
+ # For training (SFT with assistant response to learn):
85
+ # For rollout/eval (model to generate):
86
+ - Use `Qwen2.5-Coder-1.5B-Instruct` (has chat template baked in), never the non-Instruct base.
87
+ - Do NOT try `return_assistant_tokens_mask=True` — broken on Qwen2.5 per transformers #34172. Full-sequence SFT loss is fine for our scale.
88
+ - TRL 1.2's OpenEnv integration applies the tokenizer's chat template internally for multi-turn tool calls. Do not hand-concatenate `<|im_start|>` strings anywhere in our code.
89
+ - Diff-test the smoke test: tokenize one SFT example with our prep code; tokenize the same example through `GRPOTrainer._prepare_inputs` (set a breakpoint); confirm zero token-ID drift.
90
+ ## 4. GRPOTrainer Config — Minimum Viable
91
+ ### Passing per-sample reward functions
92
+ ### `reward_weights` vs compose-in-fn
93
+ ## 5. Unsloth vs vLLM for Rollouts — Resolved
94
+ ## 6. LoRA Save Sequence — Verbatim Snippet
95
+ # === AT END OF GRPO TRAINING (after trainer.train() returns) ===
96
+ # (1) ALWAYS save adapter-only first. Small (~15MB), never hits the broken merge paths.
97
+ # This is our reproducibility artifact and our safety net.
98
+ # (2) Push adapter to HF Hub immediately — cheap and protects against local-disk loss.
99
+ # (3) Save a merged 16-bit model for fast inference at demo time.
100
+ # save_method="merged_16bit" is the ONE safe choice. Never use:
101
+ # - "merged_4bit" → corrupt under QLoRA (#1267, #2339)
102
+ # - "merged_4bit_forced" → silently emits 16bit (#2339) or size-mismatched (#1791)
103
+ # If the call fails inside Docker with an empty dir (#3882), fall back to
104
+ # step (4) manual merge via peft.
105
+ # trainer.model is already an Unsloth-patched PeftModel
106
+ # (4) Sanity check BEFORE declaring training done: load the merged model,
107
+ # run 3 eval questions, assert non-empty output.
108
+ ### Loading back for eval
109
+ ### RoPE scaling for 200K eval
110
+ ## 7. Python REPL Sandbox — Choice + Safety Checklist
111
+ - **Plain subprocess with timeout only** — no AST filter means attribute-access escape paths (`__subclasses__`, `catch_warnings`, traceback walk) still work inside the subprocess. Model can escape to subprocess.Popen within our own subprocess.
112
+ - **Pyodide-via-subprocess** — spawns a browser-grade WASM runtime per call; 500ms+ startup per REPL action, kills rollout throughput at 8 generations × multiple turns.
113
+ - **OpenEnv built-in** — OpenEnv ships tool primitives but no Python REPL sandbox; the scaffold expects us to write `env/server/repl.py` (confirmed in ARCHITECTURE.md §1).
114
+ - RestrictedPython AST-rewrites at compile time: blocks `__class__`, `__subclasses__`, `__globals__`, attribute access into dunders, star imports. Not a full sandbox but stops 90% of trivial Python-sandbox-escape CTF payloads.
115
+ - Subprocess isolation handles the remaining 10%: if a tricky escape produces an import or system call, it hits the OS-level wall (no network, read-only cwd, ulimit'd CPU/memory, seccomp deny-list). Subprocess crashes don't kill the env server.
116
+ - `RestrictedPython>=7.4` is actively maintained by zopefoundation and mature.
117
+ ### Safety checklist (implement as `tests/test_repl_sandbox.py` before shipping)
118
+ - [ ] `open` — no file I/O
119
+ - [ ] `exec` — no dynamic code
120
+ - [ ] `eval` — no dynamic eval
121
+ - [ ] `compile` — no AST manipulation
122
+ - [ ] `__import__` — whitelist via custom import hook (see below)
123
+ - [ ] `__builtins__` — set to a minimal dict; never pass through the raw module
124
+ - [ ] `input` / `breakpoint` / `help` / `exit` / `quit` — exit paths and interactive prompts
125
+ - [ ] `re`, `json`, `math`, `statistics`, `collections`, `itertools`, `string`, `functools`, `operator`
126
+ - [ ] **NO** `os`, `sys`, `subprocess`, `socket`, `urllib`, `http`, `requests`, `pathlib`, `io`, `ctypes`, `threading`, `multiprocessing`, `pickle`, `marshal`, `importlib`
127
+ - [ ] Separate `subprocess.Popen` with `start_new_session=True`
128
+ - [ ] `ulimit -v 524288` (512MB RAM) via `preexec_fn` / `resource.setrlimit`
129
+ - [ ] `ulimit -t 5` (5s CPU), `ulimit -f 0` (no file writes)
130
+ - [ ] `cwd=/tmp/episode-{uuid}` (ephemeral, wiped on `reset()`)
131
+ - [ ] Non-root user (venue A100 containers default to uid 1000 — verify at setup)
132
+ - [ ] Network: `unshare -n` if available, else seccomp deny `socket`, `connect`, `sendto`
133
+ - [ ] Timeout: hard-kill at 30s wall-clock (`subprocess.wait(timeout=30)`, then `Popen.kill()`)
134
+ - [ ] `().__class__.__base__.__subclasses__()` — attribute access blocked by RestrictedPython
135
+ - [ ] `e.__traceback__.tb_frame.f_back.f_builtins` — traceback walk after a raise
136
+ - [ ] `warnings.catch_warnings()` builtins trick — import blocked by whitelist
137
+ - [ ] `__import__('os')` — whitelist rejects
138
+ - [ ] `getattr(__builtins__, 'open')` — `__builtins__` replaced with minimal dict
139
+ - [ ] 30s-infinite-loop — subprocess killed, env continues
140
+ - [ ] 1GB memory bomb — rlimit kills, env continues
141
+ ## 8. `openenv init` Scaffold Workflow
142
+ ### 8.1 Scaffold
143
+ # From repo root
144
+ # or with explicit typed-action template (v0.2.3+):
145
+ ### 8.2 Edits we make (post-init)
146
+ - Add to `src/envs/fathom/server/app.py`:
147
+ - In `environment.py`, declare `SUPPORTS_CONCURRENT_SESSIONS: bool = True` at module scope (required for TRL's 8 parallel generations — confirmed in [TRL OpenEnv guide §Server concurrency](https://huggingface.co/docs/trl/main/en/openenv#server-concurrency)).
148
+ - Implement REPL + `llm()` primitive per ARCHITECTURE.md §1.
149
+ ### 8.3 Local run
150
+ # In another terminal:
151
+ ### 8.4 Deploy to HF Space
152
+ # Login once (prerequisite):
153
+ # Push (creates or updates the Space):
154
+ # Manual push via huggingface_hub:
155
+ ## 9. SFT Library Choice
156
+ - `unsloth.UnslothTrainer` — custom trainer, fewer public examples, harder to swap to GRPO afterward.
157
+ - Raw training loop — pointless reinvention; SFTTrainer is ~20 LoC of setup.
158
+ - Same model object then passes to `GRPOTrainer` with zero re-init cost (PITFALLS.md H6 prevention).
159
+ - TRL 1.2 `SFTTrainer` accepts Unsloth-patched models without special handling.
160
+ - We keep the chat-template formatting in one place (`formatting_func`) — same template used later in GRPO.
161
+ ### Snippet — adapting Claude-generated conversations to SFT format
162
+ # (1) Load base model with LoRA adapters attached (Unsloth patch)
163
+ # (2) Claude traces on disk look like this (one line of JSONL per trajectory):
164
+ # {"system": "...", "messages": [{"role":"user","content":"..."},
165
+ # {"role":"assistant","content":"..."},
166
+ # {"role":"tool","content":"..."},
167
+ # {"role":"assistant","content":"..."}]}
168
+ # (3) formatting_func → apply_chat_template (single source of truth, §3.3 rule)
169
+ # (4) SFT config — light touch so we don't overfit (PITFALLS.md M7)
170
+ # Save SFT adapter — GRPO will load from here
171
+ ## 10. Anti-Patterns — Stack-Specific
172
+ ### 10.1 `save_pretrained_merged(save_method="merged_4bit")` on a QLoRA model
173
+ ### 10.2 Mismatched chat template between SFT prep and GRPO rollouts
174
+ ### 10.3 `max_completion_length > 2048` before the smoke test has run
175
+ ### 10.4 `vllm_mode="server"` for multi-turn OpenEnv training
176
+ ### 10.5 `pip install -U unsloth` at the venue
177
+ ### 10.6 Running REPL subprocess in the env server's cwd
178
+ ### 10.7 Logging only the composite reward to W&B
179
+ ### 10.8 Passing raw `<|im_start|>user\n...` strings to GRPOTrainer
180
+ ### 10.9 Saving merged model without sanity-checking the output
181
+ ### 10.10 Using `Qwen2.5-Coder-1.5B` (base) instead of `-Instruct`
182
+ ### 10.11 Trusting `trl vllm-serve` in server mode as a separate process
183
+ ### 10.12 `gradient_checkpointing=False` "to go fast"
184
+ ## 11. Prerequisite Setup — One-Time Commands at Venue
185
+ # ── G1: GPU alive, CUDA visible
186
+ # expect: CUDA Version 12.4+, A100-SXM4-40GB or A100-80GB, 0 MiB used
187
+ # ── G2: CUDA toolkit version compatible with torch cu124 wheel
188
+ # ── G3: Python + uv
189
+ # ── G4: Install FATHOM env (single pip block from §2 above)
190
+ # run the §2 commands here
191
+ # ── G5: Verify torch sees the A100
192
+ # expect: True NVIDIA A100-...
193
+ # ── G6: HF login (write token from hf.co/settings/tokens)
194
+ # paste token; verify:
195
+ # ── G7: W&B login (API key from wandb.ai/authorize)
196
+ # paste key; verify:
197
+ # ── G8: OpenEnv CLI alive
198
+ # ── G9: Pre-pull the model into HF cache (avoid mid-training download stalls)
199
+ # ── G10: Smoke-test GRPOTrainer imports (catches version-skew issues)
200
+ # ── G11: Sandbox unit tests (these MUST pass before env server goes public)
201
+ # ── G12: End-to-end smoke — 1 GRPO step
202
+ ## 12. Confidence & Verification Matrix
203
+ | Recommendation | Confidence | Basis | If LOW → 10-min venue verification |
204
+ |---------------|------------|-------|------------------------------------|
205
+ | TRL == 1.2.0 | HIGH | PyPI dated 2026-04-17 | — |
206
+ | Unsloth == 2026.4.8 (v0.1.36-beta) | MEDIUM | GitHub releases page; rapid iteration | `python -c "import unsloth; print(unsloth.__version__)"` then run G12 smoke test |
207
+ | OpenEnv-core >= 0.2.3 | HIGH | GitHub tag + TRL guide references 0.2.1+ | — |
208
+ | torch 2.6 cu124 | HIGH | PyTorch wheel index stable | `torch.version.cuda` == '12.4' |
209
+ | vllm 0.7.3 | HIGH | TRL 1.2 OpenEnv examples target it | `vllm --version` |
210
+ | `vllm_mode="colocate"` for multi-turn | HIGH | TRL OpenEnv guide Wordle example + PITFALLS #4543 | Run G12 smoke with 1 turn, then with 2 turns — advantage should look normal |
211
+ | `beta=0.04` KL floor | MEDIUM | EDGE-GRPO paper + PITFALLS H1; TRL default is 0.0 | Train 50 steps with β=0.0; if group_std drops <0.05 → switch to 0.04 |
212
+ | `num_generations=8` | HIGH | TRL default; all OpenEnv examples | — |
213
+ | `learning_rate=5e-6` with 4-bit | MEDIUM | PITFALLS M1 "4-bit safe band 5e-6 to 1e-5" | First 20 steps — if loss NaN, drop to 3e-6; if flat, raise to 1e-5 |
214
+ | `max_grad_norm=0.5` | MEDIUM | PITFALLS M1 conservative | Widen to 1.0 only if training too conservative and loss plateaus |
215
+ | Unsloth Qwen2.5-Coder-1.5B-Instruct-bnb-4bit | HIGH | HF Unsloth collection page | — |
216
+ | YaRN factor=4.0 for 200K eval | MEDIUM | HF model card long-context section; PITFALLS M8 warns | At eval time, test 50K → 100K → 150K → 200K; note first OOM point, cap claim there |
217
+ | Merged-16bit save as the only trusted path | HIGH | PITFALLS R4 (5 issue numbers); §6 snippet follows Unsloth docs | — |
218
+ | RestrictedPython + subprocess sandbox | HIGH | Canonical pattern; dida.do / tianpan.co verified | Run the 7 adversarial unit tests in §7 — if any pass, patch before shipping |
219
+ | Streamlit (not Gradio) for demo Space | MEDIUM | Our override on FEATURES.md TS-DEM-2 rationale | Build both skeletons Thu; pick the one that renders the D3 tree cleaner in 30 min |
220
+ | `SFTTrainer` + Unsloth model | HIGH | Unsloth notebook collection; TRL 1.2 supports Unsloth models | — |
221
+ | `openenv push` for HF Space | MEDIUM | OpenEnv CLI 0.2.3 docs; new CLI | Try `push` Thu; if it fails, use the manual `git push` fallback in §8.4 |
222
+ | Chat template one-source-of-truth | HIGH | PITFALLS H6 + HF Qwen card | Diff-test token IDs in smoke — zero drift is the gate |
223
+ - Unsloth exact version pin (re-run G10 smoke after pinning)
224
+ - vLLM colocate memory fraction — `vllm_gpu_memory_utilization=0.45` may need tuning on A100-40GB vs 80GB; watch `nvidia-smi` during the first 10 GRPO steps
225
+ - β (KL coefficient) — if group_std stays healthy at 0.0, save the 1% throughput; if collapse appears, bump to 0.04. Cheap to toggle mid-run via config.
226
+ ## 13. Sources
227
+ - [TRL 1.2.0 on PyPI (2026-04-17)](https://pypi.org/project/trl/) — HIGH
228
+ - [TRL OpenEnv Integration guide](https://huggingface.co/docs/trl/main/en/openenv) — HIGH
229
+ - [TRL GRPOConfig on GitHub main](https://github.com/huggingface/trl/blob/main/trl/trainer/grpo_config.py) — HIGH
230
+ - [Unsloth releases on GitHub](https://github.com/unslothai/unsloth/releases) — HIGH
231
+ - [Unsloth RL Guide](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide) — HIGH
232
+ - [Unsloth Qwen 2.5 Coder collection](https://huggingface.co/collections/unsloth/qwen-25-coder) — HIGH
233
+ - [Qwen2.5-Coder-1.5B-Instruct HF card](https://huggingface.co/Qwen/Qwen2.5-Coder-1.5B-Instruct) — HIGH
234
+ - [Unsloth/Qwen2.5-Coder-1.5B-Instruct HF card](https://huggingface.co/unsloth/Qwen2.5-Coder-1.5B-Instruct) — HIGH
235
+ - [meta-pytorch/OpenEnv GitHub v0.2.3](https://github.com/meta-pytorch/OpenEnv) — HIGH
236
+ - [vLLM training/trl docs](https://docs.vllm.ai/en/latest/training/trl/) — HIGH
237
+ - [RestrictedPython docs](https://restrictedpython.readthedocs.io/) — HIGH
238
+ - Unsloth LoRA save bug issues: #1791, #2339, #1267, #3882, #1877 (PITFALLS.md R4) — HIGH
239
+ - TRL multi-turn bug: #4543 (PITFALLS.md H2) — HIGH
240
+ - Chat template gotcha: transformers #34172 (PITFALLS.md N2) — HIGH
241
+ - Qwen2.5 + RoPE scaling for 200K: HF card + Unsloth #3083 (PITFALLS.md M8) — HIGH
242
+ - OpenEnv env server design: ARCHITECTURE.md §1, §8 — HIGH
243
+ - Reward decomposition pattern: ARCHITECTURE.md §6 Pattern 1 — HIGH
244
+ - Sandbox pattern choice: dida.do + tianpan.co via PITFALLS.md H3 — HIGH
245
+ <!-- GSD:stack-end -->
246
+
247
+ <!-- GSD:conventions-start source:CONVENTIONS.md -->
248
+ ## Conventions
249
+
250
+ Conventions not yet established. Will populate as patterns emerge during development.
251
+ <!-- GSD:conventions-end -->
252
+
253
+ <!-- GSD:architecture-start source:ARCHITECTURE.md -->
254
+ ## Architecture
255
+
256
+ Architecture not yet mapped. Follow existing patterns found in the codebase.
257
+ <!-- GSD:architecture-end -->
258
+
259
+ <!-- GSD:skills-start source:skills/ -->
260
+ ## Project Skills
261
+
262
+ No project skills found. Add skills to any of: `.claude/skills/`, `.agents/skills/`, `.cursor/skills/`, or `.github/skills/` with a `SKILL.md` index file.
263
+ <!-- GSD:skills-end -->
264
+
265
+ <!-- GSD:workflow-start source:GSD defaults -->
266
+ ## GSD Workflow Enforcement
267
+
268
+ Before using Edit, Write, or other file-changing tools, start work through a GSD command so planning artifacts and execution context stay in sync.
269
+
270
+ Use these entry points:
271
+ - `/gsd-quick` for small fixes, doc updates, and ad-hoc tasks
272
+ - `/gsd-debug` for investigation and bug fixing
273
+ - `/gsd-execute-phase` for planned phase work
274
+
275
+ Do not make direct repo edits outside a GSD workflow unless the user explicitly asks to bypass it.
276
+ <!-- GSD:workflow-end -->
277
+
278
+
279
+
280
+ <!-- GSD:profile-start -->
281
+ ## Developer Profile
282
+
283
+ > Profile not yet configured. Run `/gsd-profile-user` to generate your developer profile.
284
+ > This section is managed by `generate-claude-profile` -- do not edit manually.
285
+ <!-- GSD:profile-end -->
Dockerfile ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # syntax=docker/dockerfile:1.7
2
+ FROM python:3.11-slim AS base
3
+
4
+ # System deps: gcc for any wheel builds, procps for ps (useful for REPL debug), tini for PID 1
5
+ RUN apt-get update && apt-get install -y --no-install-recommends \
6
+ gcc \
7
+ g++ \
8
+ procps \
9
+ tini \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
+ # Install uv (fast Python package installer)
13
+ RUN pip install --no-cache-dir uv==0.5.11
14
+
15
+ # Create non-root user (uid 1000) per STACK §7 — switched to AFTER the
16
+ # root-owned site-packages install completes.
17
+ RUN useradd --create-home --shell /bin/bash --uid 1000 fathom
18
+
19
+ WORKDIR /home/fathom/app
20
+
21
+ # Copy pyproject first for layer-cache efficiency
22
+ COPY --chown=fathom:fathom pyproject.toml ./
23
+ COPY --chown=fathom:fathom README.md ./
24
+
25
+ # Copy the package source before install so hatchling can build the wheel
26
+ COPY --chown=fathom:fathom env/ ./env/
27
+ COPY --chown=fathom:fathom openenv.yaml ./
28
+
29
+ # Install base deps as root into /usr/local (NO [train]/[viz] extras for
30
+ # env-server image — those are venue-side). Switching to non-root BEFORE
31
+ # this install would hit EACCES on /usr/local/lib/python3.11/site-packages.
32
+ RUN uv pip install --system --no-cache .
33
+
34
+ # Drop to non-root user (uid 1000) for runtime per STACK §7 threat model
35
+ USER fathom
36
+ ENV PATH="/home/fathom/.local/bin:${PATH}"
37
+
38
+ # HF Space default port
39
+ EXPOSE 7860
40
+
41
+ ENTRYPOINT ["/usr/bin/tini", "--"]
42
+ CMD ["python", "-m", "uvicorn", "env.server.app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FATHOM — First RL-Trained Recursive Language Model
2
+
3
+ OpenEnv environment + GRPO training pipeline that teaches Qwen2.5-Coder-1.5B to use a Python REPL + sub-LM calls to answer questions about documents 50x its context window.
4
+
5
+ ## Phase 0 Local Run
6
+
7
+ ```bash
8
+ uv venv fathom --python 3.11
9
+ source fathom/bin/activate # On Windows: fathom\Scripts\activate
10
+ uv pip install -e .
11
+ python -m uvicorn env.server.app:app --host 0.0.0.0 --port 8001
12
+ ```
13
+
14
+ Check health: `curl http://localhost:8001/healthz` → `{"status":"ok"}`
15
+
16
+ See `.planning/ROADMAP.md` for the four-phase plan.
REWARD_AUDIT.md ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # REWARD AUDIT — REW-05
2
+
3
+ **Date:** 2026-04-25
4
+ **Auditor:** Pratham (Plan 01-05 executor)
5
+ **Scope:** `rewards/` package — 4 components + composition layer
6
+
7
+ ---
8
+
9
+ ## A-01: Format-only shortcut exploit
10
+
11
+ **Vector:** Model learns to always output `<answer></answer>` (empty or random) because
12
+ format_gate=1.0 still gives partial credit via token_budget + recursion_efficiency.
13
+
14
+ **Test:**
15
+ ```python
16
+ score = compose_reward_single("<answer></answer>", "Rome", 100, 0, cfg_reward)
17
+ ```
18
+
19
+ **Expected:** ≤ 0.05 (A-02 fix: correctness=0 caps composite)
20
+
21
+ **Result:** 0.05 (matches cap) ✅
22
+
23
+ **Status:** MITIGATED — A-02 correctness==0 cap blocks this. Empty answers have correctness=0.
24
+
25
+ ---
26
+
27
+ ## A-02: Exact case/punctuation gaming
28
+
29
+ **Vector:** Model outputs `<answer>ROME.</answer>` — exact text with trailing period —
30
+ to exploit correctness normalizer edge case.
31
+
32
+ **Test:**
33
+ ```python
34
+ score = correctness("<answer>ROME.</answer>", "Rome")
35
+ ```
36
+
37
+ **Expected:** 1.0 (normalizer strips trailing punctuation and lowercases)
38
+
39
+ **Result:** 1.0 ✅
40
+
41
+ **Status:** ACCEPTABLE — This is correct behavior; normalizer intentionally handles casing/punctuation.
42
+
43
+ ---
44
+
45
+ ## A-03: Gold answer verbatim copy from context
46
+
47
+ **Vector:** Model copies the gold answer from the context before the document is masked,
48
+ or finds a copy of the gold answer in the context itself (DATA-04 post-check).
49
+
50
+ **Test:** `test_no_gold_answer_verbatim_leak` in `tests/test_dataset.py`
51
+
52
+ **Expected:** Zero examples where `gold_answer.lower() in context.lower()`
53
+
54
+ **Result:** 0 violations across 1200 examples (1000 train + 200 eval) ✅
55
+
56
+ **Status:** MITIGATED — Generator `_assert_no_leak()` blocks this at generation time.
57
+
58
+ ---
59
+
60
+ ## A-04: Length reward gaming (write more = score more)
61
+
62
+ **Vector:** Model writes extremely long responses to maximize token_budget reward component.
63
+
64
+ **Test:** `test_no_monotonic_length_exploit` (REW-06 audit, `tests/test_rewards.py`)
65
+
66
+ **Expected:** Longer completions score lower (capped_linear is strictly decreasing in length)
67
+
68
+ **Result:** Confirmed — longest completion (20000 words) scores < shortest (10 words) ✅
69
+
70
+ **Status:** MITIGATED — `token_budget(variant="capped_linear")` with α=0.2 ensures
71
+ `score = max(0, 1 - 0.2 * ratio)` — ratio rises with length, score falls.
72
+
73
+ ---
74
+
75
+ ## A-05: Recursion depth gaming (avoid all tool calls)
76
+
77
+ **Vector:** Model uses 0 llm() calls (skips recursion entirely) to maximize
78
+ recursion_efficiency=1.0, giving up correctness to focus on efficiency bonus.
79
+
80
+ **Analysis:**
81
+ - recursion_efficiency weight = 0.05 (smallest component)
82
+ - correctness weight = 0.75
83
+ - Even with recursion_efficiency=1.0 and correctness=0.0: composite = min(0.05 * 1.0, 0.05) = 0.05
84
+ (A-02 cap applies)
85
+ - For correctness=1.0: composite ≈ 0.75 + 0.2*tb + 0.05*1.0 ≈ 1.0 — dominates
86
+
87
+ **Status:** LOW RISK — Efficiency weight is 5% of composite; correctness dominates at 75%.
88
+ The model cannot exploit efficiency to score high without being correct.
89
+
90
+ ---
91
+
92
+ ## A-06 (Stretch): Partial answer overlap gaming
93
+
94
+ **Vector:** Model outputs `<answer>azure blue</answer>` when gold is `azure` to try to
95
+ include the gold answer as a substring.
96
+
97
+ **Test:**
98
+ ```python
99
+ score = correctness("<answer>azure blue</answer>", "azure")
100
+ ```
101
+
102
+ **Expected:** 0.0 (exact-match after normalization, "azure blue" != "azure")
103
+
104
+ **Result:** 0.0 ✅
105
+
106
+ **Status:** MITIGATED — Normalizer uses exact-match after case/whitespace/punctuation
107
+ normalization. Substring matches are rejected.
108
+
109
+ ---
110
+
111
+ ## Summary
112
+
113
+ | Attempt | Vector | Status | Mitigation |
114
+ |---------|--------|--------|------------|
115
+ | A-01 | Format-only (empty answer) | ✅ MITIGATED | A-02 correctness==0 cap (≤0.05) |
116
+ | A-02 | Case/punctuation gaming | ✅ ACCEPTABLE | Intentional normalizer behavior |
117
+ | A-03 | Context verbatim copy | ✅ MITIGATED | Generator `_assert_no_leak()` |
118
+ | A-04 | Length reward exploit | ✅ MITIGATED | capped_linear token_budget |
119
+ | A-05 | Recursion avoidance | ✅ LOW RISK | 5% weight; correctness dominates |
120
+ | A-06 | Partial answer substring | ✅ MITIGATED | Exact-match normalizer |
121
+
122
+ **VERDICT: REWARD SYSTEM APPROVED FOR PHASE 2 TRAINING**
configs/config.yaml ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TRN-10: Hydra-composable FATHOM config tree.
2
+ # Invoke: python -m train.grpo model=qwen_1_5b train=grpo data=v1 reward=v1
3
+
4
+ defaults:
5
+ - model: qwen_1_5b # OVERRIDE: model=qwen_0_5b_smoke for laptop
6
+ - train: grpo # OVERRIDE: train=sft
7
+ - data: v1
8
+ - reward: v1
9
+ - _self_
10
+
11
+ seed: 42
12
+ output_dir: outputs
configs/data/v1.yaml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ train_path: "data/train.jsonl"
2
+ eval_path: "data/eval.jsonl"
3
+ sft_traces_path: "data/sft_traces.jsonl"
4
+ mix:
5
+ niah: 0.4
6
+ multi_needle: 0.3
7
+ extractive: 0.2
8
+ counting: 0.1
configs/model/qwen_0_5b_smoke.yaml ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # TRN-04: laptop smoke test variant. Invoke via: model=qwen_0_5b_smoke train=grpo
2
+ name: "unsloth/Qwen2.5-Coder-0.5B-Instruct-bnb-4bit"
3
+ lora_rank: 8
4
+ lora_alpha: 16
5
+ target_modules: "all-linear"
6
+ max_seq_length: 4096
7
+ load_in_4bit: true
configs/model/qwen_1_5b.yaml ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # TRN-01: base model + LoRA spec. HF ID verified via STACK §3.1 table.
2
+ name: "unsloth/Qwen2.5-Coder-1.5B-Instruct-bnb-4bit"
3
+ lora_rank: 16
4
+ lora_alpha: 32
5
+ target_modules: "all-linear"
6
+ max_seq_length: 16384
7
+ load_in_4bit: true
configs/reward/v1.yaml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ alpha: 0.2
2
+ weights:
3
+ correctness: 0.75
4
+ token_budget: 0.2
5
+ recursion_efficiency: 0.05
6
+ token_budget_variant: "capped_linear"
7
+ answer_regex: "<answer>(.*?)</answer>"
8
+ max_calls: 2
configs/train/grpo.yaml ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ num_generations: 8
2
+ beta: 0.04 # STACK §4 + PITFALLS H1 KL floor
3
+ learning_rate: 5.0e-6 # STACK §4 + PITFALLS M1 4-bit safe band
4
+ max_grad_norm: 0.5 # STACK §4 conservative
5
+ bf16: true
6
+ max_prompt_length: 4096
7
+ max_completion_length: 2048
8
+ optim: "adamw_8bit"
9
+ max_steps: 400
10
+ save_steps: 50
11
+ vllm_mode: "colocate" # STACK §10.4: 'server' mode breaks multi-turn (TRL #4543)
12
+ vllm_gpu_memory_utilization: 0.45
configs/train/sft.yaml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ learning_rate: 2.0e-4
2
+ num_train_epochs: 1
3
+ max_seq_length: 8192
4
+ per_device_train_batch_size: 2
5
+ gradient_accumulation_steps: 4
6
+ optim: "adamw_8bit"
7
+ bf16: true
8
+ save_strategy: "epoch"
data/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """FATHOM dataset generation — DATA-01..06. Deterministic. See data/generate.py."""
data/generate.py ADDED
@@ -0,0 +1,466 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FATHOM deterministic dataset generator — DATA-01..06.
2
+
3
+ Produces 1000 train + 200 eval examples across 4 task types plus >=450 SFT
4
+ warm-start traces for TRL SFTTrainer (chat format).
5
+
6
+ All randomness is routed through random.Random(seed) instances keyed by
7
+ data/seeds.json — re-running this script with the same seeds file MUST
8
+ produce byte-identical JSONL outputs. Enforced by tests/test_dataset.py.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ import os
15
+ import random
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ log = logging.getLogger("fathom.data")
20
+
21
+ TASK_TYPES = ("niah", "multi_needle", "extractive", "counting")
22
+ DEFAULT_MIX = {"niah": 0.4, "multi_needle": 0.3, "extractive": 0.2, "counting": 0.1}
23
+ CONTEXT_LENGTHS = (4096, 16384, 65536, 204800)
24
+ NEEDLE_POSITIONS = ("start", "middle", "end")
25
+
26
+ # Filler corpus: 30 neutral sentences, no proper nouns that could collide with answers
27
+ _FILLER = [
28
+ "The committee reviewed the proposed amendments to the existing policy framework.",
29
+ "Several participants noted that further clarification would be necessary.",
30
+ "The quarterly report indicated a steady increase in operational efficiency.",
31
+ "Researchers observed significant variability across the sample population.",
32
+ "The maintenance schedule was updated to reflect recent infrastructure changes.",
33
+ "All participants were required to complete the mandatory orientation session.",
34
+ "The distribution of resources followed a predetermined allocation protocol.",
35
+ "Field observations confirmed the accuracy of the theoretical predictions.",
36
+ "The project timeline was adjusted to accommodate unexpected technical delays.",
37
+ "Compliance with the updated regulations required comprehensive staff training.",
38
+ "The evaluation criteria were established prior to the commencement of testing.",
39
+ "Multiple iterations of the process were necessary to achieve the desired outcome.",
40
+ "The inventory management system was integrated with the existing database.",
41
+ "Periodic assessments were conducted to monitor progress toward stated objectives.",
42
+ "The documentation requirements were clarified during the preliminary review phase.",
43
+ "Stakeholder feedback was incorporated into the revised implementation strategy.",
44
+ "The assessment framework distinguished between formative and summative measures.",
45
+ "Resource allocation decisions were guided by priority rankings established earlier.",
46
+ "The calibration procedure ensured consistency across all measurement instruments.",
47
+ "Preliminary findings suggested that the intervention produced measurable effects.",
48
+ "The oversight committee convened on a monthly basis to review operational metrics.",
49
+ "Participants were divided into cohorts based on predetermined selection criteria.",
50
+ "The verification process involved cross-referencing multiple independent sources.",
51
+ "An audit of the existing procedures identified several areas for improvement.",
52
+ "The configuration parameters were adjusted to optimize system performance.",
53
+ "Baseline measurements were recorded prior to the introduction of any changes.",
54
+ "The scheduling algorithm prioritized tasks based on urgency and available capacity.",
55
+ "A comparative analysis revealed differences between the two methodological approaches.",
56
+ "The deployment process followed a staged rollout to minimize disruption.",
57
+ "All submitted materials were reviewed according to established evaluation rubrics.",
58
+ ]
59
+
60
+ _ADJECTIVES = [
61
+ "azure", "crimson", "emerald", "golden", "ivory", "jade", "lavender",
62
+ "magenta", "onyx", "pearl", "ruby", "sapphire", "scarlet", "silver", "teal",
63
+ "violet", "amber", "cobalt", "coral", "indigo",
64
+ ]
65
+ _NOUNS = [
66
+ "vase", "lamp", "clock", "mirror", "chair", "table", "shelf", "frame",
67
+ "carpet", "curtain", "statue", "pillar", "cabinet", "drawer", "bench",
68
+ "chest", "vessel", "column", "panel", "gate",
69
+ ]
70
+ _ITEMS = ["apple", "banana", "cherry", "mango", "peach", "plum", "grape", "lemon"]
71
+
72
+
73
+ def _build_filler(rng: random.Random, target_chars: int) -> str:
74
+ """Tile filler sentences until >= target_chars characters."""
75
+ sentences = list(_FILLER)
76
+ rng.shuffle(sentences)
77
+ result = []
78
+ total = 0
79
+ while total < target_chars:
80
+ for s in sentences:
81
+ result.append(s)
82
+ total += len(s) + 1
83
+ if total >= target_chars:
84
+ break
85
+ return " ".join(result)
86
+
87
+
88
+ def _assert_no_leak(gold_answer: str, context_without_needle: str) -> None:
89
+ """DATA-04 post-check: gold_answer must not appear in filler (without the needle)."""
90
+ if gold_answer.lower().strip() in context_without_needle.lower():
91
+ raise ValueError(
92
+ f"DATA-04 post-check: gold_answer '{gold_answer}' appears verbatim in filler context"
93
+ )
94
+
95
+
96
+ def _gen_niah(rng: random.Random, context_length: int, needle_position: str) -> dict:
97
+ """Needle-in-haystack: single fact extraction."""
98
+ adj = rng.choice(_ADJECTIVES)
99
+ noun = rng.choice(_NOUNS)
100
+ gold_answer = adj
101
+ fact = f"The {noun} is {adj}."
102
+ prompt = f"Question: What color is the {noun} mentioned in the document?"
103
+
104
+ target_chars = context_length * 4 # ~4 chars per token estimate
105
+ filler = _build_filler(rng, target_chars)
106
+ words = filler.split()
107
+ total = len(words)
108
+
109
+ if needle_position == "start":
110
+ insert_idx = 0
111
+ elif needle_position == "end":
112
+ insert_idx = max(0, total - 20)
113
+ else: # middle
114
+ insert_idx = total // 2
115
+
116
+ fact_words = fact.split()
117
+ words = words[:insert_idx] + fact_words + words[insert_idx:]
118
+ context = " ".join(words)
119
+
120
+ _assert_no_leak(gold_answer, context.replace(fact, ""))
121
+ return {"prompt": prompt, "context": context, "gold_answer": gold_answer}
122
+
123
+
124
+ def _gen_multi_needle(rng: random.Random, context_length: int, needle_position: str) -> dict:
125
+ """Multi-needle: sum of 3 integer facts."""
126
+ items = rng.sample(_ITEMS, 3)
127
+ values = [rng.randint(10, 99) for _ in range(3)]
128
+ gold_answer = str(sum(values))
129
+ prompt = f"Question: What is the total cost of {items[0]}, {items[1]}, and {items[2]}?"
130
+
131
+ target_chars = context_length * 4
132
+ filler = _build_filler(rng, target_chars)
133
+ words = filler.split()
134
+ total = len(words)
135
+
136
+ # Insert 3 facts at distributed positions
137
+ facts = [f"The {items[i]} costs {values[i]}." for i in range(3)]
138
+ positions = [total // 4, total // 2, 3 * total // 4]
139
+
140
+ offset = 0
141
+ for i, (fact, pos) in enumerate(zip(facts, positions)):
142
+ insert_at = pos + offset
143
+ fw = fact.split()
144
+ words = words[:insert_at] + fw + words[insert_at:]
145
+ offset += len(fw)
146
+
147
+ context = " ".join(words)
148
+ _assert_no_leak(gold_answer, context)
149
+ return {"prompt": prompt, "context": context, "gold_answer": gold_answer}
150
+
151
+
152
+ def _gen_extractive(rng: random.Random, context_length: int, needle_position: str) -> dict:
153
+ """Extractive QA: short-span exact match."""
154
+ years = [str(y) for y in range(1950, 2010)]
155
+ cities = ["Rome", "Vienna", "Geneva", "Brussels", "Lisbon", "Madrid", "Athens",
156
+ "Helsinki", "Stockholm", "Warsaw", "Prague", "Budapest", "Zurich"]
157
+ year = rng.choice(years)
158
+ city = rng.choice(cities)
159
+ gold_answer = city
160
+ fact = f"The {year} agreement was signed in {city}."
161
+ prompt = f"Question: In which city was the {year} agreement signed?"
162
+
163
+ target_chars = context_length * 4
164
+ filler = _build_filler(rng, target_chars)
165
+ words = filler.split()
166
+ total = len(words)
167
+
168
+ if needle_position == "start":
169
+ insert_idx = 0
170
+ elif needle_position == "end":
171
+ insert_idx = max(0, total - 20)
172
+ else:
173
+ insert_idx = total // 2
174
+
175
+ fact_words = fact.split()
176
+ words = words[:insert_idx] + fact_words + words[insert_idx:]
177
+ context = " ".join(words)
178
+
179
+ _assert_no_leak(gold_answer, context.replace(fact, ""))
180
+ return {"prompt": prompt, "context": context, "gold_answer": gold_answer}
181
+
182
+
183
+ def _gen_counting(rng: random.Random, context_length: int, needle_position: str) -> dict:
184
+ """Counting: count occurrences of a target word."""
185
+ target_word = rng.choice(_ITEMS)
186
+ count = rng.randint(5, 20)
187
+ gold_answer = str(count)
188
+ prompt = f"Question: How many times does '{target_word}' appear in the document?"
189
+
190
+ target_chars = context_length * 4
191
+ filler_words = _build_filler(rng, target_chars).split()
192
+ # Filter out any accidental occurrences of target_word in filler
193
+ filler_words = [w for w in filler_words if w.lower().strip(".,") != target_word]
194
+
195
+ # Insert target_word at evenly-spaced positions
196
+ step = max(1, len(filler_words) // (count + 1))
197
+ words = list(filler_words)
198
+ for i in range(count):
199
+ insert_at = min((i + 1) * step, len(words))
200
+ words.insert(insert_at, target_word)
201
+
202
+ context = " ".join(words)
203
+ _assert_no_leak(gold_answer, context)
204
+ return {"prompt": prompt, "context": context, "gold_answer": gold_answer}
205
+
206
+
207
+ _GEN_FN = {
208
+ "niah": _gen_niah,
209
+ "multi_needle": _gen_multi_needle,
210
+ "extractive": _gen_extractive,
211
+ "counting": _gen_counting,
212
+ }
213
+
214
+
215
+ def _compute_difficulty(context_length: int, needle_position: str, task_type: str) -> str:
216
+ """Deterministic difficulty tier from example attributes."""
217
+ if context_length == 4096 and needle_position == "start" and task_type in ("niah", "extractive"):
218
+ return "trivial"
219
+ elif context_length in (4096, 16384) and needle_position in ("start", "middle"):
220
+ return "easy"
221
+ elif context_length == 65536 or task_type == "multi_needle":
222
+ return "medium"
223
+ else:
224
+ return "hard"
225
+
226
+
227
+ def _write_jsonl(path: Path, rows: list[dict]) -> None:
228
+ """Write JSONL with sorted keys and compact separators for byte-determinism."""
229
+ path.parent.mkdir(parents=True, exist_ok=True)
230
+ with open(path, "w", encoding="utf-8") as f:
231
+ for row in rows:
232
+ f.write(json.dumps(row, sort_keys=True, separators=(",", ":")) + "\n")
233
+
234
+
235
+ def _pick_task_type(mix: dict, counts: dict) -> str:
236
+ """Pick the task type with the largest gap from target proportions."""
237
+ total = sum(counts.values()) + 1
238
+ best = max(
239
+ mix.keys(),
240
+ key=lambda t: mix[t] - counts.get(t, 0) / total,
241
+ )
242
+ return best
243
+
244
+
245
+ # RLM system prompt for SFT traces (DATA-06)
246
+ _RLM_SYSTEM_PROMPT = (
247
+ "You are FATHOM, a recursive language model with a Python REPL sandbox. "
248
+ "You can read a long document via the variable `ctx` and call `llm(prompt, chunk)` "
249
+ "for sub-queries. Think step by step. Emit your final answer inside <answer>...</answer>."
250
+ )
251
+
252
+
253
+ def _template_sft_trace(rng: random.Random, example: dict) -> dict:
254
+ """Build a template grep-then-answer SFT trace (deterministic)."""
255
+ task_type = example.get("task_type", "niah")
256
+ gold = example["gold_answer"]
257
+ prompt = example["prompt"]
258
+ ctx_preview = example["context"][:2000]
259
+
260
+ if task_type == "counting":
261
+ target = gold # the count
262
+ # Infer target word from prompt
263
+ import re
264
+ m = re.search(r"'([^']+)'", prompt)
265
+ target_word = m.group(1) if m else "item"
266
+ code = f'count = ctx.count("{target_word}")\nprint(count)'
267
+ tool_output = str(gold)
268
+ elif task_type == "multi_needle":
269
+ code = (
270
+ "import re\n"
271
+ "matches = re.findall(r'costs (\\d+)', ctx)\n"
272
+ "print(sum(int(x) for x in matches))"
273
+ )
274
+ tool_output = str(gold)
275
+ else:
276
+ code = (
277
+ "import re\n"
278
+ "matches = re.findall(r'(?:is|was signed in) ([\\w]+)', ctx[:8192])\n"
279
+ "print(matches[0] if matches else 'not found')"
280
+ )
281
+ tool_output = str(gold)
282
+
283
+ messages = [
284
+ {"content": _RLM_SYSTEM_PROMPT, "role": "system"},
285
+ {"content": f"{prompt}\n\n[Document excerpt]:\n{ctx_preview}", "role": "user"},
286
+ {
287
+ "content": f"I'll search the document programmatically.\n```python\n{code}\n```",
288
+ "role": "assistant",
289
+ },
290
+ {"content": tool_output, "role": "tool"},
291
+ {"content": f"Based on the search results, the answer is <answer>{gold}</answer>", "role": "assistant"},
292
+ ]
293
+ return {"messages": messages, "task_id": f"template-sft-{example['task_id']}"}
294
+
295
+
296
+ def _haiku_sft_trace(client: Any, example: dict) -> dict | None:
297
+ """Call Claude Haiku to generate an SFT trace. Returns None on error."""
298
+ try:
299
+ ctx_preview = example["context"][:3000]
300
+ user_msg = f"{example['prompt']}\n\n[Document excerpt]:\n{ctx_preview}"
301
+ resp = client.messages.create(
302
+ model="claude-haiku-4-5",
303
+ max_tokens=1024,
304
+ system=_RLM_SYSTEM_PROMPT,
305
+ messages=[{"role": "user", "content": user_msg}],
306
+ )
307
+ assistant_text = resp.content[0].text
308
+ # Ensure answer tag present
309
+ if "<answer>" not in assistant_text:
310
+ assistant_text += f"\n<answer>{example['gold_answer']}</answer>"
311
+ messages = [
312
+ {"content": _RLM_SYSTEM_PROMPT, "role": "system"},
313
+ {"content": user_msg, "role": "user"},
314
+ {"content": assistant_text, "role": "assistant"},
315
+ ]
316
+ return {"messages": messages, "task_id": f"haiku-sft-{example['task_id']}"}
317
+ except Exception as e:
318
+ log.warning("Haiku API error on seed %s: %s; falling back to template", example.get("seed"), e)
319
+ return None
320
+
321
+
322
+ def generate_sft_traces(
323
+ seed_list: list,
324
+ train_rows: list,
325
+ target_count: int = 500,
326
+ api_key: str | None = None,
327
+ ) -> list[dict]:
328
+ """Generate SFT traces — Claude Haiku where possible, template fallback. DATA-06."""
329
+ budget = int(os.environ.get("FATHOM_HAIKU_BUDGET", "200")) if api_key else 0
330
+ client = None
331
+ if api_key:
332
+ try:
333
+ import anthropic # type: ignore
334
+ client = anthropic.Anthropic(api_key=api_key)
335
+ except Exception as e:
336
+ log.warning("anthropic SDK import failed: %s; template-only", e)
337
+ client = None
338
+
339
+ # Use trivial/easy rows as basis for traces
340
+ source_rows = [r for r in train_rows if r.get("difficulty") in ("trivial", "easy")]
341
+ if not source_rows:
342
+ source_rows = train_rows
343
+
344
+ traces = []
345
+ for i, seed in enumerate(seed_list[:target_count]):
346
+ rng = random.Random(seed)
347
+ example = source_rows[i % len(source_rows)]
348
+ trace = None
349
+ if client is not None and i < budget:
350
+ trace = _haiku_sft_trace(client, example)
351
+ if trace is None:
352
+ trace = _template_sft_trace(rng, example)
353
+ traces.append(trace)
354
+
355
+ assert len(traces) >= 450, f"DATA-06 floor: got {len(traces)} traces, need >=450"
356
+ return traces
357
+
358
+
359
+ def generate_all(
360
+ out_dir: str | Path = "data",
361
+ seeds_path: str | Path = "data/seeds.json",
362
+ train_count: int = 1000,
363
+ eval_count: int = 200,
364
+ sft_target_count: int = 500,
365
+ mix: dict | None = None,
366
+ ) -> dict:
367
+ """Deterministic end-to-end generator. Writes train.jsonl + eval.jsonl + sft_traces.jsonl.
368
+
369
+ DATA-01..06 — all randomness routed through seeded RNGs.
370
+ """
371
+ out_dir = Path(out_dir)
372
+ seeds_path = Path(seeds_path)
373
+ mix = mix or DEFAULT_MIX
374
+
375
+ with open(seeds_path, "r", encoding="utf-8") as f:
376
+ seeds = json.load(f)
377
+
378
+ # Force trivial floor: first 6% of train are trivial (DATA-04)
379
+ trivial_floor = max(60, int(0.06 * train_count))
380
+
381
+ def _build_split(seed_list: list, count: int, split: str) -> list[dict]:
382
+ rows = []
383
+ task_counts: dict[str, int] = {t: 0 for t in TASK_TYPES}
384
+
385
+ for idx, seed in enumerate(seed_list[:count]):
386
+ rng = random.Random(seed)
387
+
388
+ # Force trivial tier for first N examples (DATA-04)
389
+ if split == "train" and idx < trivial_floor:
390
+ task_type = "niah" if idx % 2 == 0 else "extractive"
391
+ context_length = 4096
392
+ needle_position = "start"
393
+ else:
394
+ task_type = _pick_task_type(mix, task_counts)
395
+ context_length = rng.choice(CONTEXT_LENGTHS)
396
+ needle_position = rng.choice(NEEDLE_POSITIONS)
397
+
398
+ task_counts[task_type] = task_counts.get(task_type, 0) + 1
399
+
400
+ gen_fn = _GEN_FN[task_type]
401
+ try:
402
+ ex = gen_fn(rng, context_length, needle_position)
403
+ except Exception as e:
404
+ log.warning("Skipping example %d due to generation error: %s", idx, e)
405
+ # Retry with a simpler config
406
+ ex = _gen_niah(rng, 4096, "start")
407
+ task_type = "niah"
408
+ context_length = 4096
409
+ needle_position = "start"
410
+
411
+ difficulty = _compute_difficulty(context_length, needle_position, task_type)
412
+ row = {
413
+ "context": ex["context"],
414
+ "context_length": context_length,
415
+ "difficulty": difficulty,
416
+ "gold_answer": ex["gold_answer"],
417
+ "needle_position": needle_position,
418
+ "prompt": ex["prompt"],
419
+ "seed": seed,
420
+ "task_id": f"{task_type}-{split}-{idx:04d}",
421
+ "task_type": task_type,
422
+ }
423
+ rows.append(row)
424
+ return rows
425
+
426
+ log.info("DATA generating train split (%d examples)...", train_count)
427
+ train_rows = _build_split(seeds["train"], train_count, "train")
428
+ log.info("DATA generating eval split (%d examples)...", eval_count)
429
+ eval_rows = _build_split(seeds["eval"], eval_count, "eval")
430
+
431
+ # Self-checks (DATA-02, DATA-04, DATA-05)
432
+ assert len(train_rows) == train_count, f"Expected {train_count} train rows, got {len(train_rows)}"
433
+ assert len(eval_rows) == eval_count, f"Expected {eval_count} eval rows, got {len(eval_rows)}"
434
+
435
+ train_ids = {r["task_id"] for r in train_rows}
436
+ eval_ids = {r["task_id"] for r in eval_rows}
437
+ assert not (train_ids & eval_ids), "Train/eval task_id overlap detected (DATA-02)"
438
+
439
+ trivial_share = sum(1 for r in train_rows if r["difficulty"] == "trivial") / len(train_rows)
440
+ assert trivial_share >= 0.05, f"Trivial share {trivial_share:.3f} < 0.05 (DATA-04)"
441
+
442
+ _write_jsonl(out_dir / "train.jsonl", train_rows)
443
+ _write_jsonl(out_dir / "eval.jsonl", eval_rows)
444
+ log.info(
445
+ "DATA train=%d eval=%d trivial_share=%.3f written",
446
+ len(train_rows), len(eval_rows), trivial_share,
447
+ )
448
+
449
+ # SFT traces (DATA-06)
450
+ log.info("DATA generating SFT traces (template-only unless ANTHROPIC_API_KEY set)...")
451
+ sft_traces = generate_sft_traces(
452
+ seeds["sft"],
453
+ train_rows,
454
+ target_count=sft_target_count,
455
+ api_key=os.environ.get("ANTHROPIC_API_KEY"),
456
+ )
457
+ _write_jsonl(out_dir / "sft_traces.jsonl", sft_traces)
458
+ log.info("DATA sft_traces=%d written", len(sft_traces))
459
+
460
+ return {"train": len(train_rows), "eval": len(eval_rows), "sft": len(sft_traces)}
461
+
462
+
463
+ if __name__ == "__main__":
464
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
465
+ result = generate_all()
466
+ print(f"Generated: train={result['train']} eval={result['eval']} sft={result['sft']}")
data/seeds.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "train": [1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009, 1010, 1011, 1012, 1013, 1014, 1015, 1016, 1017, 1018, 1019, 1020, 1021, 1022, 1023, 1024, 1025, 1026, 1027, 1028, 1029, 1030, 1031, 1032, 1033, 1034, 1035, 1036, 1037, 1038, 1039, 1040, 1041, 1042, 1043, 1044, 1045, 1046, 1047, 1048, 1049, 1050, 1051, 1052, 1053, 1054, 1055, 1056, 1057, 1058, 1059, 1060, 1061, 1062, 1063, 1064, 1065, 1066, 1067, 1068, 1069, 1070, 1071, 1072, 1073, 1074, 1075, 1076, 1077, 1078, 1079, 1080, 1081, 1082, 1083, 1084, 1085, 1086, 1087, 1088, 1089, 1090, 1091, 1092, 1093, 1094, 1095, 1096, 1097, 1098, 1099, 1100, 1101, 1102, 1103, 1104, 1105, 1106, 1107, 1108, 1109, 1110, 1111, 1112, 1113, 1114, 1115, 1116, 1117, 1118, 1119, 1120, 1121, 1122, 1123, 1124, 1125, 1126, 1127, 1128, 1129, 1130, 1131, 1132, 1133, 1134, 1135, 1136, 1137, 1138, 1139, 1140, 1141, 1142, 1143, 1144, 1145, 1146, 1147, 1148, 1149, 1150, 1151, 1152, 1153, 1154, 1155, 1156, 1157, 1158, 1159, 1160, 1161, 1162, 1163, 1164, 1165, 1166, 1167, 1168, 1169, 1170, 1171, 1172, 1173, 1174, 1175, 1176, 1177, 1178, 1179, 1180, 1181, 1182, 1183, 1184, 1185, 1186, 1187, 1188, 1189, 1190, 1191, 1192, 1193, 1194, 1195, 1196, 1197, 1198, 1199, 1200, 1201, 1202, 1203, 1204, 1205, 1206, 1207, 1208, 1209, 1210, 1211, 1212, 1213, 1214, 1215, 1216, 1217, 1218, 1219, 1220, 1221, 1222, 1223, 1224, 1225, 1226, 1227, 1228, 1229, 1230, 1231, 1232, 1233, 1234, 1235, 1236, 1237, 1238, 1239, 1240, 1241, 1242, 1243, 1244, 1245, 1246, 1247, 1248, 1249, 1250, 1251, 1252, 1253, 1254, 1255, 1256, 1257, 1258, 1259, 1260, 1261, 1262, 1263, 1264, 1265, 1266, 1267, 1268, 1269, 1270, 1271, 1272, 1273, 1274, 1275, 1276, 1277, 1278, 1279, 1280, 1281, 1282, 1283, 1284, 1285, 1286, 1287, 1288, 1289, 1290, 1291, 1292, 1293, 1294, 1295, 1296, 1297, 1298, 1299, 1300, 1301, 1302, 1303, 1304, 1305, 1306, 1307, 1308, 1309, 1310, 1311, 1312, 1313, 1314, 1315, 1316, 1317, 1318, 1319, 1320, 1321, 1322, 1323, 1324, 1325, 1326, 1327, 1328, 1329, 1330, 1331, 1332, 1333, 1334, 1335, 1336, 1337, 1338, 1339, 1340, 1341, 1342, 1343, 1344, 1345, 1346, 1347, 1348, 1349, 1350, 1351, 1352, 1353, 1354, 1355, 1356, 1357, 1358, 1359, 1360, 1361, 1362, 1363, 1364, 1365, 1366, 1367, 1368, 1369, 1370, 1371, 1372, 1373, 1374, 1375, 1376, 1377, 1378, 1379, 1380, 1381, 1382, 1383, 1384, 1385, 1386, 1387, 1388, 1389, 1390, 1391, 1392, 1393, 1394, 1395, 1396, 1397, 1398, 1399, 1400, 1401, 1402, 1403, 1404, 1405, 1406, 1407, 1408, 1409, 1410, 1411, 1412, 1413, 1414, 1415, 1416, 1417, 1418, 1419, 1420, 1421, 1422, 1423, 1424, 1425, 1426, 1427, 1428, 1429, 1430, 1431, 1432, 1433, 1434, 1435, 1436, 1437, 1438, 1439, 1440, 1441, 1442, 1443, 1444, 1445, 1446, 1447, 1448, 1449, 1450, 1451, 1452, 1453, 1454, 1455, 1456, 1457, 1458, 1459, 1460, 1461, 1462, 1463, 1464, 1465, 1466, 1467, 1468, 1469, 1470, 1471, 1472, 1473, 1474, 1475, 1476, 1477, 1478, 1479, 1480, 1481, 1482, 1483, 1484, 1485, 1486, 1487, 1488, 1489, 1490, 1491, 1492, 1493, 1494, 1495, 1496, 1497, 1498, 1499, 1500, 1501, 1502, 1503, 1504, 1505, 1506, 1507, 1508, 1509, 1510, 1511, 1512, 1513, 1514, 1515, 1516, 1517, 1518, 1519, 1520, 1521, 1522, 1523, 1524, 1525, 1526, 1527, 1528, 1529, 1530, 1531, 1532, 1533, 1534, 1535, 1536, 1537, 1538, 1539, 1540, 1541, 1542, 1543, 1544, 1545, 1546, 1547, 1548, 1549, 1550, 1551, 1552, 1553, 1554, 1555, 1556, 1557, 1558, 1559, 1560, 1561, 1562, 1563, 1564, 1565, 1566, 1567, 1568, 1569, 1570, 1571, 1572, 1573, 1574, 1575, 1576, 1577, 1578, 1579, 1580, 1581, 1582, 1583, 1584, 1585, 1586, 1587, 1588, 1589, 1590, 1591, 1592, 1593, 1594, 1595, 1596, 1597, 1598, 1599, 1600, 1601, 1602, 1603, 1604, 1605, 1606, 1607, 1608, 1609, 1610, 1611, 1612, 1613, 1614, 1615, 1616, 1617, 1618, 1619, 1620, 1621, 1622, 1623, 1624, 1625, 1626, 1627, 1628, 1629, 1630, 1631, 1632, 1633, 1634, 1635, 1636, 1637, 1638, 1639, 1640, 1641, 1642, 1643, 1644, 1645, 1646, 1647, 1648, 1649, 1650, 1651, 1652, 1653, 1654, 1655, 1656, 1657, 1658, 1659, 1660, 1661, 1662, 1663, 1664, 1665, 1666, 1667, 1668, 1669, 1670, 1671, 1672, 1673, 1674, 1675, 1676, 1677, 1678, 1679, 1680, 1681, 1682, 1683, 1684, 1685, 1686, 1687, 1688, 1689, 1690, 1691, 1692, 1693, 1694, 1695, 1696, 1697, 1698, 1699, 1700, 1701, 1702, 1703, 1704, 1705, 1706, 1707, 1708, 1709, 1710, 1711, 1712, 1713, 1714, 1715, 1716, 1717, 1718, 1719, 1720, 1721, 1722, 1723, 1724, 1725, 1726, 1727, 1728, 1729, 1730, 1731, 1732, 1733, 1734, 1735, 1736, 1737, 1738, 1739, 1740, 1741, 1742, 1743, 1744, 1745, 1746, 1747, 1748, 1749, 1750, 1751, 1752, 1753, 1754, 1755, 1756, 1757, 1758, 1759, 1760, 1761, 1762, 1763, 1764, 1765, 1766, 1767, 1768, 1769, 1770, 1771, 1772, 1773, 1774, 1775, 1776, 1777, 1778, 1779, 1780, 1781, 1782, 1783, 1784, 1785, 1786, 1787, 1788, 1789, 1790, 1791, 1792, 1793, 1794, 1795, 1796, 1797, 1798, 1799, 1800, 1801, 1802, 1803, 1804, 1805, 1806, 1807, 1808, 1809, 1810, 1811, 1812, 1813, 1814, 1815, 1816, 1817, 1818, 1819, 1820, 1821, 1822, 1823, 1824, 1825, 1826, 1827, 1828, 1829, 1830, 1831, 1832, 1833, 1834, 1835, 1836, 1837, 1838, 1839, 1840, 1841, 1842, 1843, 1844, 1845, 1846, 1847, 1848, 1849, 1850, 1851, 1852, 1853, 1854, 1855, 1856, 1857, 1858, 1859, 1860, 1861, 1862, 1863, 1864, 1865, 1866, 1867, 1868, 1869, 1870, 1871, 1872, 1873, 1874, 1875, 1876, 1877, 1878, 1879, 1880, 1881, 1882, 1883, 1884, 1885, 1886, 1887, 1888, 1889, 1890, 1891, 1892, 1893, 1894, 1895, 1896, 1897, 1898, 1899, 1900, 1901, 1902, 1903, 1904, 1905, 1906, 1907, 1908, 1909, 1910, 1911, 1912, 1913, 1914, 1915, 1916, 1917, 1918, 1919, 1920, 1921, 1922, 1923, 1924, 1925, 1926, 1927, 1928, 1929, 1930, 1931, 1932, 1933, 1934, 1935, 1936, 1937, 1938, 1939, 1940, 1941, 1942, 1943, 1944, 1945, 1946, 1947, 1948, 1949, 1950, 1951, 1952, 1953, 1954, 1955, 1956, 1957, 1958, 1959, 1960, 1961, 1962, 1963, 1964, 1965, 1966, 1967, 1968, 1969, 1970, 1971, 1972, 1973, 1974, 1975, 1976, 1977, 1978, 1979, 1980, 1981, 1982, 1983, 1984, 1985, 1986, 1987, 1988, 1989, 1990, 1991, 1992, 1993, 1994, 1995, 1996, 1997, 1998, 1999],
3
+ "eval": [5000, 5001, 5002, 5003, 5004, 5005, 5006, 5007, 5008, 5009, 5010, 5011, 5012, 5013, 5014, 5015, 5016, 5017, 5018, 5019, 5020, 5021, 5022, 5023, 5024, 5025, 5026, 5027, 5028, 5029, 5030, 5031, 5032, 5033, 5034, 5035, 5036, 5037, 5038, 5039, 5040, 5041, 5042, 5043, 5044, 5045, 5046, 5047, 5048, 5049, 5050, 5051, 5052, 5053, 5054, 5055, 5056, 5057, 5058, 5059, 5060, 5061, 5062, 5063, 5064, 5065, 5066, 5067, 5068, 5069, 5070, 5071, 5072, 5073, 5074, 5075, 5076, 5077, 5078, 5079, 5080, 5081, 5082, 5083, 5084, 5085, 5086, 5087, 5088, 5089, 5090, 5091, 5092, 5093, 5094, 5095, 5096, 5097, 5098, 5099, 5100, 5101, 5102, 5103, 5104, 5105, 5106, 5107, 5108, 5109, 5110, 5111, 5112, 5113, 5114, 5115, 5116, 5117, 5118, 5119, 5120, 5121, 5122, 5123, 5124, 5125, 5126, 5127, 5128, 5129, 5130, 5131, 5132, 5133, 5134, 5135, 5136, 5137, 5138, 5139, 5140, 5141, 5142, 5143, 5144, 5145, 5146, 5147, 5148, 5149, 5150, 5151, 5152, 5153, 5154, 5155, 5156, 5157, 5158, 5159, 5160, 5161, 5162, 5163, 5164, 5165, 5166, 5167, 5168, 5169, 5170, 5171, 5172, 5173, 5174, 5175, 5176, 5177, 5178, 5179, 5180, 5181, 5182, 5183, 5184, 5185, 5186, 5187, 5188, 5189, 5190, 5191, 5192, 5193, 5194, 5195, 5196, 5197, 5198, 5199],
4
+ "sft": [9000, 9001, 9002, 9003, 9004, 9005, 9006, 9007, 9008, 9009, 9010, 9011, 9012, 9013, 9014, 9015, 9016, 9017, 9018, 9019, 9020, 9021, 9022, 9023, 9024, 9025, 9026, 9027, 9028, 9029, 9030, 9031, 9032, 9033, 9034, 9035, 9036, 9037, 9038, 9039, 9040, 9041, 9042, 9043, 9044, 9045, 9046, 9047, 9048, 9049, 9050, 9051, 9052, 9053, 9054, 9055, 9056, 9057, 9058, 9059, 9060, 9061, 9062, 9063, 9064, 9065, 9066, 9067, 9068, 9069, 9070, 9071, 9072, 9073, 9074, 9075, 9076, 9077, 9078, 9079, 9080, 9081, 9082, 9083, 9084, 9085, 9086, 9087, 9088, 9089, 9090, 9091, 9092, 9093, 9094, 9095, 9096, 9097, 9098, 9099, 9100, 9101, 9102, 9103, 9104, 9105, 9106, 9107, 9108, 9109, 9110, 9111, 9112, 9113, 9114, 9115, 9116, 9117, 9118, 9119, 9120, 9121, 9122, 9123, 9124, 9125, 9126, 9127, 9128, 9129, 9130, 9131, 9132, 9133, 9134, 9135, 9136, 9137, 9138, 9139, 9140, 9141, 9142, 9143, 9144, 9145, 9146, 9147, 9148, 9149, 9150, 9151, 9152, 9153, 9154, 9155, 9156, 9157, 9158, 9159, 9160, 9161, 9162, 9163, 9164, 9165, 9166, 9167, 9168, 9169, 9170, 9171, 9172, 9173, 9174, 9175, 9176, 9177, 9178, 9179, 9180, 9181, 9182, 9183, 9184, 9185, 9186, 9187, 9188, 9189, 9190, 9191, 9192, 9193, 9194, 9195, 9196, 9197, 9198, 9199, 9200, 9201, 9202, 9203, 9204, 9205, 9206, 9207, 9208, 9209, 9210, 9211, 9212, 9213, 9214, 9215, 9216, 9217, 9218, 9219, 9220, 9221, 9222, 9223, 9224, 9225, 9226, 9227, 9228, 9229, 9230, 9231, 9232, 9233, 9234, 9235, 9236, 9237, 9238, 9239, 9240, 9241, 9242, 9243, 9244, 9245, 9246, 9247, 9248, 9249, 9250, 9251, 9252, 9253, 9254, 9255, 9256, 9257, 9258, 9259, 9260, 9261, 9262, 9263, 9264, 9265, 9266, 9267, 9268, 9269, 9270, 9271, 9272, 9273, 9274, 9275, 9276, 9277, 9278, 9279, 9280, 9281, 9282, 9283, 9284, 9285, 9286, 9287, 9288, 9289, 9290, 9291, 9292, 9293, 9294, 9295, 9296, 9297, 9298, 9299, 9300, 9301, 9302, 9303, 9304, 9305, 9306, 9307, 9308, 9309, 9310, 9311, 9312, 9313, 9314, 9315, 9316, 9317, 9318, 9319, 9320, 9321, 9322, 9323, 9324, 9325, 9326, 9327, 9328, 9329, 9330, 9331, 9332, 9333, 9334, 9335, 9336, 9337, 9338, 9339, 9340, 9341, 9342, 9343, 9344, 9345, 9346, 9347, 9348, 9349, 9350, 9351, 9352, 9353, 9354, 9355, 9356, 9357, 9358, 9359, 9360, 9361, 9362, 9363, 9364, 9365, 9366, 9367, 9368, 9369, 9370, 9371, 9372, 9373, 9374, 9375, 9376, 9377, 9378, 9379, 9380, 9381, 9382, 9383, 9384, 9385, 9386, 9387, 9388, 9389, 9390, 9391, 9392, 9393, 9394, 9395, 9396, 9397, 9398, 9399, 9400, 9401, 9402, 9403, 9404, 9405, 9406, 9407, 9408, 9409, 9410, 9411, 9412, 9413, 9414, 9415, 9416, 9417, 9418, 9419, 9420, 9421, 9422, 9423, 9424, 9425, 9426, 9427, 9428, 9429, 9430, 9431, 9432, 9433, 9434, 9435, 9436, 9437, 9438, 9439, 9440, 9441, 9442, 9443, 9444, 9445, 9446, 9447, 9448, 9449, 9450, 9451, 9452, 9453, 9454, 9455, 9456, 9457, 9458, 9459, 9460, 9461, 9462, 9463, 9464, 9465, 9466, 9467, 9468, 9469, 9470, 9471, 9472, 9473, 9474, 9475, 9476, 9477, 9478, 9479, 9480, 9481, 9482, 9483, 9484, 9485, 9486, 9487, 9488, 9489, 9490, 9491, 9492, 9493, 9494, 9495, 9496, 9497, 9498, 9499]
5
+ }
env/__init__.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FATHOM env package.
2
+
3
+ Re-exports the Pydantic schemas so callers can ``from env import FathomAction``
4
+ rather than reaching into the module layout.
5
+ """
6
+ from env.models import (
7
+ FathomAction,
8
+ FathomObservation,
9
+ FathomState,
10
+ FathomStepResult,
11
+ TerminationReason,
12
+ )
13
+
14
+ __all__ = [
15
+ "FathomAction",
16
+ "FathomObservation",
17
+ "FathomState",
18
+ "FathomStepResult",
19
+ "TerminationReason",
20
+ ]