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

Tests:
  1. Scenario registry loads correctly (all 12 scenarios present)
  2. DriftInjector β€” initial vs mutated world
  3. AdaptiveGrader β€” grade_task and grade_belief
  4. DriftDifficultyController β€” escalation logic
  5. Environment reset/step (manual reward signal verification)
  6. Drift detection after drift step
  7. Cross-episode belief persistence
  8. Expert: transient error simulation
  9. Grader fallback: infer_belief_from_actions
"""
import pytest
import sys
import os

# Add project root to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from scenarios.registry import SCENARIO_REGISTRY, ALL_SCENARIOS
from server.drift_injector import DriftInjector, DRIFT_CONFIGS
from server.difficulty_controller import DriftDifficultyController
from graders.grader import AdaptiveGrader
from models import AdaptiveAction, AdaptiveObservation, AdaptiveState


# ── 1. Scenario registry ───────────────────────────────────────────────────────

class TestScenarioRegistry:

    def test_all_difficulty_levels_present(self):
        for level in ["easy", "medium", "hard", "expert"]:
            assert level in SCENARIO_REGISTRY, f"Missing difficulty: {level}"

    def test_each_level_has_three_scenarios(self):
        for level, scenarios in SCENARIO_REGISTRY.items():
            assert len(scenarios) == 3, f"{level} should have 3 scenarios, got {len(scenarios)}"

    def test_total_scenario_count(self):
        assert len(ALL_SCENARIOS) == 12

    def test_all_scenarios_have_required_fields(self):
        required = ["id", "description", "domain", "drift_trigger_step",
                    "drift_type", "task_goal", "max_steps"]
        for s in ALL_SCENARIOS:
            for field in required:
                assert field in s, f"Scenario {s.get('id')} missing field: {field}"

    def test_all_scenario_ids_in_drift_configs(self):
        """Every scenario must have a matching DriftInjector config."""
        for s in ALL_SCENARIOS:
            sid = s["id"]
            assert sid in DRIFT_CONFIGS, (
                f"Scenario '{sid}' has no entry in drift_injector.DRIFT_CONFIGS"
            )

    def test_no_drift_occurred_in_scenarios(self):
        """Drift provenance must be hidden β€” no drift_occurred / drift_type in scenario dict
        that would leak to the agent observation."""
        for s in ALL_SCENARIOS:
            # The key exists internally for grading but must NOT be a field name that
            # gets put into AdaptiveObservation (which no longer has drift_occurred)
            keys = list(s.keys())
            assert "drift_occurred" not in keys, (
                f"Scenario {s['id']} has drift_occurred β€” this should not be in observation"
            )


# ── 2. DriftInjector ──────────────────────────────────────────────────────────

class TestDriftInjector:

    def test_initial_world_loaded(self):
        inj = DriftInjector("easy_field_rename")
        world = inj.get_world()
        assert world["order_field"] == "qty"
        assert world.get("required_extra") is None

    def test_inject_changes_world(self):
        inj = DriftInjector("easy_field_rename")
        inj.inject()
        world = inj.get_world()
        assert world["order_field"] == "quantity"
        assert world["required_extra"] == "customer_id"

    def test_drifted_flag_set(self):
        inj = DriftInjector("easy_endpoint_version")
        assert not inj.drifted
        inj.inject()
        assert inj.drifted

    def test_world_truth_is_mutated_state(self):
        inj = DriftInjector("hard_status_meaning")
        truth = inj.get_world_truth()
        assert truth["order_status"] == "approved"  # mutated value

    def test_expert_transient_error_step(self):
        inj = DriftInjector("expert_transient_vs_real")
        assert inj.get_transient_error_step() == 2

    def test_expert_secondary_drift_step(self):
        inj = DriftInjector("expert_cross_service")
        assert inj.get_secondary_drift_step() == 6

    def test_unknown_scenario_raises(self):
        with pytest.raises(ValueError, match="Unknown scenario"):
            DriftInjector("nonexistent_scenario")


# ── 3. AdaptiveGrader ─────────────────────────────────────────────────────────

class TestAdaptiveGrader:
    grader = AdaptiveGrader()

    # grade_task tests
    def test_task_reward_failed_no_detection(self):
        r = self.grader.grade_task(
            task_completed=False, steps_taken=5, max_steps=8, drift_detected=False
        )
        assert r == 0.001

    def test_task_reward_failed_with_detection(self):
        r = self.grader.grade_task(
            task_completed=False, steps_taken=5, max_steps=8, drift_detected=True
        )
        assert r == 0.150

    def test_task_reward_completed_basic(self):
        r = self.grader.grade_task(
            task_completed=True, steps_taken=4, max_steps=8, drift_detected=False
        )
        assert 0.5 <= r <= 0.999

    def test_task_reward_completed_proactive_bonus(self):
        r_no_bonus = self.grader.grade_task(True, 4, 8, False)
        r_bonus = self.grader.grade_task(True, 4, 8, True)
        assert r_bonus > r_no_bonus

    def test_task_reward_capped_at_1(self):
        r = self.grader.grade_task(True, 1, 8, True)
        assert r <= 0.999

    # grade_belief tests β€” field_rename
    def test_belief_correct_field_rename(self):
        truth = {"order_field": "quantity", "required_extra": "customer_id"}
        belief = {"order_field": "quantity", "required_extra": "customer_id"}
        score = self.grader.grade_belief(belief, truth, "field_rename")
        assert score >= 0.9

    def test_belief_wrong_field_rename(self):
        truth = {"order_field": "quantity", "required_extra": "customer_id"}
        belief = {"order_field": "qty"}   # stale belief
        score = self.grader.grade_belief(belief, truth, "field_rename")
        assert score < 0.5

    # grade_belief tests β€” endpoint_version
    def test_belief_correct_endpoint(self):
        truth = {"rooms_endpoint": "/mock_api/v2/rooms/book"}
        belief = {"endpoint": "/mock_api/v2/rooms/book"}
        score = self.grader.grade_belief(belief, truth, "endpoint_version")
        assert score == 1.0

    def test_belief_wrong_endpoint(self):
        truth = {"rooms_endpoint": "/mock_api/v2/rooms/book"}
        belief = {"endpoint": "/mock_api/rooms/book"}
        score = self.grader.grade_belief(belief, truth, "endpoint_version")
        assert score == 0.0

    # grade_belief tests β€” silent_semantic
    def test_belief_correct_silent_semantic(self):
        truth = {"order_status": "approved"}
        belief = {"order_status_value": "approved"}
        score = self.grader.grade_belief(belief, truth, "silent_semantic")
        assert score == 1.0

    def test_belief_partial_silent_semantic_knows_something_changed(self):
        truth = {"order_status": "approved"}
        belief = {"drift_detected": True}   # knows something changed but not what
        score = self.grader.grade_belief(belief, truth, "silent_semantic")
        assert score == 0.4

    def test_belief_wrong_silent_semantic(self):
        truth = {"order_status": "approved"}
        belief = {"order_status_value": "confirmed"}  # stale
        score = self.grader.grade_belief(belief, truth, "silent_semantic")
        assert score == 0.0

    # grade_belief tests β€” policy_change
    def test_belief_correct_policy(self):
        truth = {"discount_requires_membership": True}
        belief = {"discount_requires_membership": True}
        score = self.grader.grade_belief(belief, truth, "policy_change")
        assert score == 1.0

    def test_belief_none_returns_zero(self):
        score = self.grader.grade_belief(None, {"x": 1}, "field_rename")
        assert score == 0.0

    # infer_belief_from_actions
    def test_infer_belief_probed_and_recovered(self):
        log = [
            {"step": 1, "url": "/mock_api/orders", "status": 200, "response": "{}"},
            {"step": 2, "url": "/mock_api/orders", "status": 422, "response": '{"detail": "..."}'},
            {"step": 3, "url": "/openapi.json", "status": 200, "response": "{}"},
            {"step": 4, "url": "/mock_api/orders", "status": 200, "response": '{"order_id": "x"}'},
        ]
        score = self.grader.infer_belief_from_actions(log, "field_rename")
        assert score == 0.6

    def test_infer_belief_probed_no_recovery(self):
        log = [
            {"step": 1, "url": "/mock_api/orders", "status": 200, "response": "{}"},
            {"step": 2, "url": "/mock_api/orders", "status": 422, "response": "{}"},
            {"step": 3, "url": "/openapi.json", "status": 200, "response": "{}"},
        ]
        score = self.grader.infer_belief_from_actions(log, "field_rename")
        assert score == 0.3

    def test_infer_belief_no_action(self):
        score = self.grader.infer_belief_from_actions([], "field_rename")
        assert score == 0.0


# ── 4. DriftDifficultyController ──────────────────────────────────────────────

class TestDriftDifficultyController:

    def test_initial_level_is_zero(self):
        ctrl = DriftDifficultyController()
        assert ctrl.level == 0

    def test_no_escalation_without_enough_data(self):
        ctrl = DriftDifficultyController()
        for _ in range(4):  # ESCALATION_WINDOW is 5
            ctrl.record("field_rename", 0.95)
            ctrl.record("endpoint_version", 0.95)
            ctrl.record("policy_change", 0.95)
        assert ctrl.level == 0  # not enough data yet

    def test_escalates_when_all_drift_types_above_threshold(self):
        ctrl = DriftDifficultyController()
        for _ in range(5):  # ESCALATION_WINDOW = 5
            ctrl.record("field_rename", 0.90)
            ctrl.record("endpoint_version", 0.90)
            ctrl.record("policy_change", 0.90)
        assert ctrl.level == 1

    def test_no_escalation_when_one_drift_type_below_threshold(self):
        ctrl = DriftDifficultyController()
        for _ in range(5):
            ctrl.record("field_rename", 0.90)
            ctrl.record("endpoint_version", 0.50)  # below threshold
            ctrl.record("policy_change", 0.90)
        assert ctrl.level == 0

    def test_get_scenario_params_level_zero_unchanged(self):
        ctrl = DriftDifficultyController()
        scenario = {"id": "easy_field_rename", "drift_trigger_step": 3, "drift_type": "field_rename"}
        result = ctrl.get_scenario_params(scenario)
        assert result == scenario

    def test_reset_clears_state(self):
        ctrl = DriftDifficultyController()
        ctrl._escalation_level = 2
        ctrl.reset()
        assert ctrl.level == 0
        assert len(ctrl._history) == 0


# ── 5. Reward signal verification (Day 3 Step 1 from the plan) ────────────────

class TestRewardSignal:
    """
    Manual verification that the reward signal works correctly.
    These tests don't hit a live server β€” they test the grader math directly.
    """

    grader = AdaptiveGrader()

    def test_untrained_behavior_produces_low_rewards(self):
        """Simulates agent that never detects drift."""
        task_reward = self.grader.grade_task(
            task_completed=False, steps_taken=8, max_steps=8, drift_detected=False
        )
        # Completely wrong belief (wrong field name; no required_extra knowledge)
        # grade_belief for field_rename scores 3 sub-checks: field_match, extra_match, ep_match
        # field_match: "qty" != "quantity" β†’ 0.0
        # extra_match: None == "customer_id" β†’ 0.0
        # ep_match: no claims_endpoint in truth β†’ 1.0 (vacuous)
        # avg = (0+0+1)/3 = 0.333 β€” we assert it is < 0.5 (stale)
        belief_accuracy = self.grader.grade_belief(
            agent_belief={"order_field": "qty"},   # stale β€” wrong field name
            world_truth={"order_field": "quantity", "required_extra": "customer_id"},
            drift_type="field_rename",
        )
        combined = task_reward * 0.7 + belief_accuracy * 0.3
        assert task_reward <= 0.001
        assert belief_accuracy < 0.5   # stale belief β€” partial at best
        assert combined < 0.2

    def test_informed_behavior_produces_high_rewards(self):
        """Simulates agent that detects drift, probes, and adapts."""
        task_reward = self.grader.grade_task(
            task_completed=True, steps_taken=6, max_steps=8, drift_detected=True
        )
        belief_accuracy = self.grader.grade_belief(
            agent_belief={"order_field": "quantity", "required_extra": "customer_id"},
            world_truth={"order_field": "quantity", "required_extra": "customer_id"},
            drift_type="field_rename",
        )
        combined = task_reward * 0.7 + belief_accuracy * 0.3
        assert task_reward >= 0.70
        assert belief_accuracy >= 0.90
        assert combined >= 0.75

    def test_combined_reward_formula(self):
        """Verify the 0.7/0.3 split is applied correctly."""
        task_reward = 0.80
        belief_accuracy = 0.60
        expected = task_reward * 0.7 + belief_accuracy * 0.3
        # Recreate with grader outputs
        tr = self.grader.grade_task(True, 4, 8, True)
        ba = self.grader.grade_belief(
            {"order_field": "quantity", "required_extra": "customer_id"},
            {"order_field": "quantity", "required_extra": "customer_id"},
            "field_rename"
        )
        combined = tr * 0.7 + ba * 0.3
        assert 0.0 <= combined <= 1.0


# ── 6. AdaptiveState model ────────────────────────────────────────────────────

class TestModels:

    def test_adaptive_action_defaults(self):
        action = AdaptiveAction()
        assert action.action_type == "call_api"
        assert action.method == "GET"
        assert action.belief_state is None
        assert action.history_steps == 3

    def test_adaptive_observation_no_drift_occurred(self):
        """Verify drift_occurred is NOT a field in AdaptiveObservation (v2 compliance)."""
        obs = AdaptiveObservation()
        assert not hasattr(obs, "drift_occurred"), (
            "drift_occurred must be removed from AdaptiveObservation in v2"
        )
        assert not hasattr(obs, "drift_type"), (
            "drift_type must be removed from AdaptiveObservation in v2"
        )

    def test_adaptive_observation_has_v2_fields(self):
        obs = AdaptiveObservation()
        assert hasattr(obs, "prior_world_model")
        assert hasattr(obs, "episode_history")
        assert hasattr(obs, "belief_accuracy")
        assert hasattr(obs, "difficulty_level")

    def test_adaptive_state_defaults(self):
        state = AdaptiveState()
        assert not state.drift_injected
        assert state.agent_belief == {}
        assert state.world_truth == {}
        assert state.step_history == []