File size: 9,645 Bytes
071ba6b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
"""Phase 0 env verification suite β€” ENV-01..03, ENV-07, ENV-08.

ROADMAP Success Criteria literals:
  - `pytest tests/test_env.py::test_gold_answer_never_in_observation` passes (ENV-08)
  - All 4 termination reasons appear as named tests covering ENV-07 (a/b/c/d)
  - End-to-end HTTP roundtrip via FathomEnvClient proves ENV-01..03
"""
from __future__ import annotations

import time

import pytest
from fastapi.testclient import TestClient

from env.client import FathomEnvClient
from env.models import FathomAction, FathomObservation, FathomStepResult, TerminationReason


# ─── ENV-01: scaffold + HTTP roundtrip ────────────────────────────────────────

def test_healthz_returns_ok(fresh_test_client: TestClient) -> None:
    r = fresh_test_client.get("/healthz")
    assert r.status_code == 200
    assert r.json() == {"status": "ok"}


def test_reset_returns_observation_no_gold_field(fresh_test_client: TestClient) -> None:
    r = fresh_test_client.post("/reset", json={"seed": 0, "difficulty": "easy"})
    assert r.status_code == 200, r.text
    obs_json = r.json()
    assert "gold_answer" not in obs_json, f"ENV-08 violation: gold_answer key in observation JSON: {obs_json}"
    assert "task_type" not in obs_json
    obs = FathomObservation.model_validate(obs_json)
    assert obs.tokens_remaining == 100_000
    assert obs.depth_max == 2
    assert obs.turns_left == 20


def test_step_roundtrip_print_1_plus_1(fresh_test_client: TestClient) -> None:
    fresh_test_client.post("/reset", json={"seed": 0, "difficulty": "easy"})
    r = fresh_test_client.post("/step", json={"tool_name": "repl", "code": "print(1 + 1)"})
    assert r.status_code == 200
    body = r.json()
    assert "gold_answer" not in body.get("observation", {})
    sr = FathomStepResult.model_validate(body)
    assert "2" in sr.observation.stdout
    assert sr.done is False


# ─── ENV-08: gold-answer sealing (ROADMAP Success Criterion #3 literal) ───────

def test_gold_answer_never_in_observation(fresh_test_client: TestClient, fixture_rows: list[dict]) -> None:
    """For every fixture row, after reset() + several steps, no observation dict contains
    gold_answer or task_type as a key.

    This is the LITERAL test that ROADMAP Phase 0 Exit Gate Success Criterion #3 names.
    Key-presence is what's tested β€” the gold VALUE appearing inside context_preview is
    NOT a violation (the context legitimately contains it).
    """
    difficulties = ["easy", "medium", "hard"]
    for diff in difficulties:
        r = fresh_test_client.post("/reset", json={"seed": 0, "difficulty": diff})
        assert r.status_code == 200
        obs = r.json()
        assert "gold_answer" not in obs, f"diff={diff}: gold_answer leaked on reset: keys={list(obs.keys())}"
        assert "task_type" not in obs, f"diff={diff}: task_type leaked on reset: keys={list(obs.keys())}"

        for code in ("x = 1", "y = x + 2", "z = y * 3"):
            r2 = fresh_test_client.post("/step", json={"tool_name": "repl", "code": code})
            assert r2.status_code == 200
            body = r2.json()
            assert "gold_answer" not in body.get("observation", {}), f"step leak on diff={diff}, code={code}"
            assert "task_type" not in body.get("observation", {})


def test_state_endpoint_sanitized(fresh_test_client: TestClient) -> None:
    """ENV-08: /state returns a whitelist dict β€” no gold_answer, task_type, or raw context."""
    fresh_test_client.post("/reset", json={"seed": 0, "difficulty": "easy"})
    r = fresh_test_client.get("/state")
    assert r.status_code == 200
    s = r.json()
    assert "gold_answer" not in s
    assert "task_type" not in s
    assert "context" not in s
    assert "episode_id" in s
    assert "step_count" in s
    assert "tokens_used_total" in s


# ─── ENV-07: 4 termination reasons ────────────────────────────────────────────

def test_termination_answer(fresh_test_client: TestClient) -> None:
    """ENV-07a β€” <answer>...</answer> emits done=True with reason=answer."""
    fresh_test_client.post("/reset", json={"seed": 0, "difficulty": "easy"})
    r = fresh_test_client.post("/step", json={"tool_name": "repl", "code": "<answer>42</answer>"})
    sr = FathomStepResult.model_validate(r.json())
    assert sr.done is True
    assert sr.info.get("termination_reason") == TerminationReason.ANSWER.value
    assert sr.observation.answer_emitted is True
    assert sr.observation.return_val == "42"


def test_termination_max_steps(fresh_test_client: TestClient) -> None:
    """ENV-07b β€” exceeding max_steps terminates with reason=max_steps."""
    fresh_test_client.post("/reset", json={"seed": 0, "difficulty": "easy", "max_steps": 2})
    r1 = fresh_test_client.post("/step", json={"tool_name": "repl", "code": "a = 1"})
    sr1 = FathomStepResult.model_validate(r1.json())
    assert sr1.done is False
    r2 = fresh_test_client.post("/step", json={"tool_name": "repl", "code": "b = 2"})
    sr2 = FathomStepResult.model_validate(r2.json())
    assert sr2.done is True
    assert sr2.info.get("termination_reason") == TerminationReason.MAX_STEPS.value


def test_termination_max_tokens(fresh_test_client: TestClient) -> None:
    """ENV-07c β€” exceeding max_tokens terminates with reason=max_tokens."""
    fresh_test_client.post("/reset", json={"seed": 0, "difficulty": "easy", "max_tokens": 2})
    r = fresh_test_client.post(
        "/step",
        json={"tool_name": "repl", "code": "print('this is a long string designed to exceed two tokens easily')"},
    )
    sr = FathomStepResult.model_validate(r.json())
    assert sr.done is True, f"Expected done=True on max_tokens, got {sr}"
    assert sr.info.get("termination_reason") == TerminationReason.MAX_TOKENS.value


def test_termination_walltime(fresh_test_client: TestClient) -> None:
    """ENV-07d β€” walltime budget exceeded terminates with reason=walltime."""
    fresh_test_client.post("/reset", json={"seed": 0, "difficulty": "easy", "walltime_budget_s": 0.3})
    time.sleep(0.5)
    r = fresh_test_client.post("/step", json={"tool_name": "repl", "code": "x = 1"})
    sr = FathomStepResult.model_validate(r.json())
    assert sr.done is True, f"Expected walltime termination, got {sr}"
    assert sr.info.get("termination_reason") == TerminationReason.WALLTIME.value


# ─── ENV-06: malformed code β†’ structured error, done=False ────────────────────

def test_malformed_code_returns_error_observation_done_false(fresh_test_client: TestClient) -> None:
    fresh_test_client.post("/reset", json={"seed": 0, "difficulty": "easy"})
    r = fresh_test_client.post("/step", json={"tool_name": "repl", "code": "1 / 0"})
    sr = FathomStepResult.model_validate(r.json())
    assert sr.done is False, "ENV-06: division by zero should NOT terminate the episode"
    combined = sr.observation.stderr + (sr.observation.return_val or "")
    assert "ZeroDivisionError" in combined


def test_malformed_syntax_returns_error_observation(fresh_test_client: TestClient) -> None:
    fresh_test_client.post("/reset", json={"seed": 0, "difficulty": "easy"})
    r = fresh_test_client.post("/step", json={"tool_name": "repl", "code": "def (:"})
    sr = FathomStepResult.model_validate(r.json())
    assert sr.done is False
    assert sr.observation.stderr, "syntax error should surface in stderr"


# ─── ENV-03 / D-10: difficulty mapping ────────────────────────────────────────

def test_difficulty_mapping_medium_loads_multi_needle_row(fresh_test_client: TestClient) -> None:
    fresh_test_client.post("/reset", json={"seed": 0, "difficulty": "medium"})
    r = fresh_test_client.get("/state")
    assert r.status_code == 200
    s = r.json()
    assert s["task_id"] == "fixture-0001-multi-needle", f"D-10 violation: got {s['task_id']}"


def test_difficulty_mapping_hard_loads_counting_row(fresh_test_client: TestClient) -> None:
    fresh_test_client.post("/reset", json={"seed": 0, "difficulty": "hard"})
    r = fresh_test_client.get("/state")
    s = r.json()
    assert s["task_id"] == "fixture-0002-counting"


def test_reset_by_task_id(fresh_test_client: TestClient) -> None:
    fresh_test_client.post("/reset", json={"seed": 0, "task_id": "fixture-0002-counting"})
    r = fresh_test_client.get("/state")
    s = r.json()
    assert s["task_id"] == "fixture-0002-counting"


# ─── ENV-01: live HTTP roundtrip via FathomEnvClient ──────────────────────────

def test_live_uvicorn_roundtrip(live_uvicorn_server: str) -> None:
    """End-to-end HTTP roundtrip matching ROADMAP Success Criterion #1.

    Spawns uvicorn in a subprocess, uses FathomEnvClient over the real HTTP wire,
    verifies reset() + step(print(1+1)) + state() all succeed with no gold leak.
    """
    with FathomEnvClient(base_url=live_uvicorn_server) as client:
        assert client.healthz() == {"status": "ok"}
        obs = client.reset(seed=0, difficulty="easy")
        assert isinstance(obs, FathomObservation)
        sr = client.step(FathomAction(tool_name="repl", code="print(1+1)"))
        assert isinstance(sr, FathomStepResult)
        assert "2" in sr.observation.stdout
        assert sr.done is False
        s = client.state()
        assert "gold_answer" not in s
        assert "task_type" not in s
        assert s.get("step_count") == 1