PROTHAM
Fix environment bug: adjust drift triggers and prevent premature auto-termination to avoid reward hacking; update README
0983a18 | """ | |
| validate.py β AdaptiveWorld Submission Validator | |
| ================================================= | |
| Runs a comprehensive offline validation of the adaptive-world-env submission. | |
| Checks: | |
| 1. Scenario registry β all 12 scenarios, all difficulty levels | |
| 2. Drift configs β every scenario has a matching DriftInjector config | |
| 3. DriftInjector β inject() mutates world correctly | |
| 4. AdaptiveGrader β grade_task / grade_belief / infer_belief_from_actions | |
| 5. DriftDifficultyController β escalation logic | |
| 6. Episode lifecycle β simulated reset β step β done for each difficulty | |
| 7. inference.py β import check (no LLM call needed) | |
| Run from the adaptive-world-env directory: | |
| python validate.py | |
| """ | |
| import sys | |
| import os | |
| import copy | |
| import traceback | |
| # ββ Bootstrap path βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| ROOT = os.path.dirname(os.path.abspath(__file__)) | |
| sys.path.insert(0, ROOT) | |
| # ββ Terminal colours (Windows-safe via ANSI or plain) ββββββββββββββββββββββββββ | |
| try: | |
| import ctypes | |
| ctypes.windll.kernel32.SetConsoleMode(ctypes.windll.kernel32.GetStdHandle(-11), 7) | |
| GREEN = "\033[92m" | |
| RED = "\033[91m" | |
| YELLOW = "\033[93m" | |
| BOLD = "\033[1m" | |
| RESET = "\033[0m" | |
| except Exception: | |
| GREEN = RED = YELLOW = BOLD = RESET = "" | |
| PASS = 0 | |
| FAIL = 0 | |
| ERRORS = [] | |
| def ok(msg): | |
| global PASS | |
| PASS += 1 | |
| print(f" {GREEN}β{RESET} {msg}") | |
| def fail(msg, exc=None): | |
| global FAIL | |
| FAIL += 1 | |
| ERRORS.append(msg) | |
| print(f" {RED}β{RESET} {msg}") | |
| if exc: | |
| print(f" {YELLOW}β {exc}{RESET}") | |
| def section(title): | |
| print(f"\n{BOLD}{'β'*60}{RESET}") | |
| print(f"{BOLD} {title}{RESET}") | |
| print(f"{BOLD}{'β'*60}{RESET}") | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 1. Scenario Registry | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| section("1 / 7 Β· Scenario Registry") | |
| try: | |
| from scenarios.registry import SCENARIO_REGISTRY, ALL_SCENARIOS | |
| DIFFICULTIES = ["easy", "medium", "hard", "expert"] | |
| REQUIRED_FIELDS = ["id", "description", "domain", "drift_trigger_step", | |
| "drift_type", "task_goal", "max_steps"] | |
| for level in DIFFICULTIES: | |
| if level in SCENARIO_REGISTRY: | |
| ok(f"Difficulty '{level}' present") | |
| else: | |
| fail(f"Difficulty '{level}' MISSING from registry") | |
| if len(ALL_SCENARIOS) == 12: | |
| ok(f"Total scenario count: {len(ALL_SCENARIOS)} (expected 12)") | |
| else: | |
| fail(f"Expected 12 scenarios, found {len(ALL_SCENARIOS)}") | |
| for level, scenarios in SCENARIO_REGISTRY.items(): | |
| if len(scenarios) == 3: | |
| ok(f" '{level}' has 3 scenarios") | |
| else: | |
| fail(f" '{level}' has {len(scenarios)} scenarios (expected 3)") | |
| bad = [] | |
| for s in ALL_SCENARIOS: | |
| missing = [f for f in REQUIRED_FIELDS if f not in s] | |
| if missing: | |
| bad.append(f"{s.get('id', '?')} missing: {missing}") | |
| if "drift_occurred" in s: | |
| bad.append(f"{s['id']} has forbidden key 'drift_occurred'") | |
| if bad: | |
| for b in bad: | |
| fail(b) | |
| else: | |
| ok(f"All {len(ALL_SCENARIOS)} scenarios have required fields (no forbidden keys)") | |
| except Exception as e: | |
| fail("Scenario registry import/validation failed", e) | |
| traceback.print_exc() | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 2. Drift Configs | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| section("2 / 7 Β· Drift Injector Configs") | |
| try: | |
| from server.drift_injector import DriftInjector, DRIFT_CONFIGS | |
| missing_configs = [s["id"] for s in ALL_SCENARIOS if s["id"] not in DRIFT_CONFIGS] | |
| if missing_configs: | |
| for sid in missing_configs: | |
| fail(f"No DRIFT_CONFIG entry for scenario '{sid}'") | |
| else: | |
| ok(f"All {len(ALL_SCENARIOS)} scenarios have DRIFT_CONFIG entries") | |
| # Quick smoke-test inject on a few known scenarios | |
| for sid in ["easy_field_rename", "easy_endpoint_version", "hard_status_meaning"]: | |
| try: | |
| inj = DriftInjector(sid) | |
| before = copy.deepcopy(inj.get_world()) | |
| inj.inject() | |
| after = inj.get_world() | |
| if before != after: | |
| ok(f" DriftInjector({sid!r}).inject() mutates world β") | |
| else: | |
| fail(f" DriftInjector({sid!r}).inject() did NOT change world") | |
| except Exception as ex: | |
| fail(f" DriftInjector({sid!r}) error", ex) | |
| # Expert-specific helpers | |
| try: | |
| inj = DriftInjector("expert_transient_vs_real") | |
| step = inj.get_transient_error_step() | |
| if step == 2: | |
| ok(f" expert_transient_vs_real: transient_error_step == 2") | |
| else: | |
| fail(f" expected transient_error_step 2, got {step}") | |
| except Exception as ex: | |
| fail(" expert_transient_vs_real transient error step check", ex) | |
| try: | |
| inj = DriftInjector("expert_cross_service") | |
| step = inj.get_secondary_drift_step() | |
| if step == 6: | |
| ok(f" expert_cross_service: secondary_drift_step == 6") | |
| else: | |
| fail(f" expected secondary_drift_step 6, got {step}") | |
| except Exception as ex: | |
| fail(" expert_cross_service secondary drift step check", ex) | |
| try: | |
| DriftInjector("this_scenario_does_not_exist") | |
| fail(" Unknown scenario should raise ValueError but did not") | |
| except ValueError: | |
| ok(" Unknown scenario raises ValueError β") | |
| except Exception as ex: | |
| fail(" Unknown scenario check raised unexpected exception", ex) | |
| except Exception as e: | |
| fail("Drift injector section failed", e) | |
| traceback.print_exc() | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 3. AdaptiveGrader | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| section("3 / 7 Β· AdaptiveGrader") | |
| try: | |
| from graders.grader import AdaptiveGrader | |
| g = AdaptiveGrader() | |
| # grade_task | |
| r = g.grade_task(task_completed=False, steps_taken=5, max_steps=8, drift_detected=False) | |
| if r == 0.001: | |
| ok("grade_task(failed, no detection) == 0.001") | |
| else: | |
| fail(f"grade_task(failed, no detection) expected 0.001 got {r}") | |
| r = g.grade_task(task_completed=False, steps_taken=5, max_steps=8, drift_detected=True) | |
| if r == 0.150: | |
| ok("grade_task(failed, detected) == 0.150") | |
| else: | |
| fail(f"grade_task(failed, detected) expected 0.150 got {r}") | |
| r_no = g.grade_task(True, 4, 8, False) | |
| r_yes = g.grade_task(True, 4, 8, True) | |
| if r_yes > r_no: | |
| ok("Proactive drift detection gives bonus reward β") | |
| else: | |
| fail(f"Bonus expected: r_with_detect={r_yes:.4f} vs r_without={r_no:.4f}") | |
| r = g.grade_task(True, 1, 8, True) | |
| if r <= 0.999: | |
| ok(f"Task reward capped at 0.999 (got {r})") | |
| else: | |
| fail(f"Task reward not capped: {r}") | |
| # grade_belief β field_rename | |
| score = g.grade_belief( | |
| {"order_field": "quantity", "required_extra": "customer_id"}, | |
| {"order_field": "quantity", "required_extra": "customer_id"}, | |
| "field_rename" | |
| ) | |
| if score >= 0.9: | |
| ok(f"grade_belief: correct field_rename β {score:.3f} (β₯0.9) β") | |
| else: | |
| fail(f"grade_belief: correct field_rename expected β₯0.9, got {score:.3f}") | |
| score = g.grade_belief({"order_field": "qty"}, {"order_field": "quantity", "required_extra": "customer_id"}, "field_rename") | |
| if score < 0.5: | |
| ok(f"grade_belief: stale field_rename β {score:.3f} (<0.5) β") | |
| else: | |
| fail(f"grade_belief: stale belief expected <0.5, got {score:.3f}") | |
| # grade_belief β endpoint_version | |
| score = g.grade_belief({"endpoint": "/mock_api/v2/rooms/book"}, {"rooms_endpoint": "/mock_api/v2/rooms/book"}, "endpoint_version") | |
| if score == 1.0: | |
| ok(f"grade_belief: correct endpoint_version β 1.0 β") | |
| else: | |
| fail(f"grade_belief: correct endpoint expected 1.0, got {score}") | |
| # grade_belief β None | |
| score = g.grade_belief(None, {"x": 1}, "field_rename") | |
| if score == 0.0: | |
| ok("grade_belief(None, ...) == 0.0 β") | |
| else: | |
| fail(f"grade_belief(None) expected 0.0, got {score}") | |
| # infer_belief_from_actions | |
| 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 = g.infer_belief_from_actions(log, "field_rename") | |
| if score == 0.6: | |
| ok(f"infer_belief_from_actions: probed+recovered β 0.6 β") | |
| else: | |
| fail(f"infer_belief_from_actions expected 0.6, got {score}") | |
| score = g.infer_belief_from_actions([], "field_rename") | |
| if score == 0.0: | |
| ok("infer_belief_from_actions([]) == 0.0 β") | |
| else: | |
| fail(f"infer_belief_from_actions([]) expected 0.0, got {score}") | |
| except Exception as e: | |
| fail("AdaptiveGrader section failed", e) | |
| traceback.print_exc() | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 4. DriftDifficultyController | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| section("4 / 7 Β· DriftDifficultyController") | |
| try: | |
| from server.difficulty_controller import DriftDifficultyController | |
| ctrl = DriftDifficultyController() | |
| if ctrl.level == 0: | |
| ok("Initial level == 0 β") | |
| else: | |
| fail(f"Expected initial level 0, got {ctrl.level}") | |
| # not enough data | |
| ctrl2 = DriftDifficultyController() | |
| for _ in range(4): | |
| ctrl2.record("field_rename", 0.95) | |
| ctrl2.record("endpoint_version", 0.95) | |
| ctrl2.record("policy_change", 0.95) | |
| if ctrl2.level == 0: | |
| ok("No escalation with < 5 window data β") | |
| else: | |
| fail(f"Expected no escalation, level={ctrl2.level}") | |
| # escalation happens at window=5 | |
| ctrl3 = DriftDifficultyController() | |
| for _ in range(5): | |
| ctrl3.record("field_rename", 0.90) | |
| ctrl3.record("endpoint_version", 0.90) | |
| ctrl3.record("policy_change", 0.90) | |
| if ctrl3.level == 1: | |
| ok("Escalates to level 1 after 5 high-accuracy episodes β") | |
| else: | |
| fail(f"Expected level 1 after escalation, got {ctrl3.level}") | |
| # no escalation if one type below threshold | |
| ctrl4 = DriftDifficultyController() | |
| for _ in range(5): | |
| ctrl4.record("field_rename", 0.90) | |
| ctrl4.record("endpoint_version", 0.50) # below threshold | |
| ctrl4.record("policy_change", 0.90) | |
| if ctrl4.level == 0: | |
| ok("No escalation when one drift type below threshold β") | |
| else: | |
| fail(f"Expected no escalation, got level {ctrl4.level}") | |
| # reset | |
| ctrl5 = DriftDifficultyController() | |
| ctrl5._escalation_level = 2 | |
| ctrl5.reset() | |
| if ctrl5.level == 0 and len(ctrl5._history) == 0: | |
| ok("reset() clears level and history β") | |
| else: | |
| fail(f"reset() failed: level={ctrl5.level}, history_len={len(ctrl5._history)}") | |
| # get_scenario_params at level 0 is identity | |
| sc = {"id": "easy_field_rename", "drift_trigger_step": 3, "drift_type": "field_rename"} | |
| result = ctrl.get_scenario_params(sc) | |
| if result == sc: | |
| ok("get_scenario_params at level 0 returns unchanged scenario β") | |
| else: | |
| fail(f"get_scenario_params unexpected change: {result}") | |
| except Exception as e: | |
| fail("DriftDifficultyController section failed", e) | |
| traceback.print_exc() | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 5. Models | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| section("5 / 7 Β· Models (AdaptiveAction / AdaptiveObservation / AdaptiveState)") | |
| try: | |
| from models import AdaptiveAction, AdaptiveObservation, AdaptiveState | |
| a = AdaptiveAction() | |
| if a.action_type == "call_api" and a.method == "GET": | |
| ok("AdaptiveAction defaults: action_type='call_api', method='GET' β") | |
| else: | |
| fail(f"AdaptiveAction defaults wrong: {a.action_type}, {a.method}") | |
| obs = AdaptiveObservation() | |
| if not hasattr(obs, "drift_occurred") and not hasattr(obs, "drift_type"): | |
| ok("AdaptiveObservation has NO drift_occurred / drift_type (v2 compliant) β") | |
| else: | |
| fail("AdaptiveObservation still has forbidden field drift_occurred / drift_type") | |
| for field in ["prior_world_model", "episode_history", "belief_accuracy", "difficulty_level"]: | |
| if hasattr(obs, field): | |
| ok(f" AdaptiveObservation has v2 field '{field}' β") | |
| else: | |
| fail(f" AdaptiveObservation missing v2 field '{field}'") | |
| state = AdaptiveState() | |
| if not state.drift_injected and state.agent_belief == {} and state.world_truth == {}: | |
| ok("AdaptiveState defaults correct β") | |
| else: | |
| fail(f"AdaptiveState defaults wrong: {state}") | |
| except Exception as e: | |
| fail("Models section failed", e) | |
| traceback.print_exc() | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 6. Simulated Episode Lifecycle (no live server) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| section("6 / 7 Β· Simulated Episode Lifecycle (offline, all difficulties)") | |
| try: | |
| from unittest.mock import patch, MagicMock | |
| from models import AdaptiveAction, AdaptiveObservation | |
| def make_mock_http(status=200, body="{}"): | |
| mock_resp = MagicMock() | |
| mock_resp.status_code = status | |
| mock_resp.text = body | |
| mock_resp.headers = {"content-type": "application/json"} | |
| return mock_resp | |
| for difficulty in ["easy", "medium", "hard", "expert"]: | |
| try: | |
| with patch("httpx.Client") as MockClient: | |
| cm = MockClient.return_value.__enter__.return_value | |
| # Mock the admin mutate call | |
| cm.post.return_value = make_mock_http(200, "{}") | |
| # Mock API calls β return a successful order | |
| cm.request.return_value = make_mock_http( | |
| 200, '{"order_id": "abc123", "status": "confirmed"}' | |
| ) | |
| cm.get.return_value = make_mock_http(200, '{"endpoints": []}') | |
| import importlib | |
| import server.adaptive_world_environment as awe_module | |
| importlib.reload(awe_module) | |
| env = awe_module.AdaptiveWorldEnvironment() | |
| # reset | |
| obs = env.reset(scenario_id="auto", difficulty=difficulty) | |
| assert isinstance(obs, AdaptiveObservation), "reset() must return AdaptiveObservation" | |
| assert not obs.done, "done should be False after reset" | |
| assert obs.task_description, "task_description should not be empty" | |
| # step β probe schema | |
| action = AdaptiveAction(action_type="probe_schema") | |
| obs2 = env.step(action) | |
| assert isinstance(obs2, AdaptiveObservation) | |
| # step β call_api | |
| action2 = AdaptiveAction( | |
| action_type="call_api", | |
| method="POST", | |
| url="/mock_api/orders", | |
| body={"qty": 1, "product_id": 5}, | |
| ) | |
| obs3 = env.step(action2) | |
| assert isinstance(obs3, AdaptiveObservation) | |
| assert env.state.step_count >= 1 | |
| # submit_result | |
| submit = AdaptiveAction( | |
| action_type="submit_result", | |
| belief_state={"order_field": "qty", "drift_detected": False}, | |
| ) | |
| obs_final = env.step(submit) | |
| assert obs_final.done, "done must be True after submit_result" | |
| assert 0.0 <= obs_final.reward <= 1.0, f"reward out of range: {obs_final.reward}" | |
| ok(f" Difficulty '{difficulty}': resetβstepβsubmit OK " | |
| f"(reward={obs_final.reward:.4f})") | |
| except Exception as ex: | |
| fail(f" Difficulty '{difficulty}' lifecycle failed", ex) | |
| traceback.print_exc() | |
| except Exception as e: | |
| fail("Episode lifecycle section failed", e) | |
| traceback.print_exc() | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 7. Inference.py Import Check | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| section("7 / 7 Β· inference.py Import / Parse Check") | |
| try: | |
| import ast | |
| inf_path = os.path.join(ROOT, "inference.py") | |
| if os.path.exists(inf_path): | |
| with open(inf_path, "r", encoding="utf-8") as f: | |
| src = f.read() | |
| try: | |
| tree = ast.parse(src) | |
| ok("inference.py parses without syntax errors β") | |
| # Check key functions exist | |
| funcs = {n.name for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)} | |
| for fn in ["run_episode", "run_evaluation", "build_user_message", "parse_action"]: | |
| if fn in funcs: | |
| ok(f" Function '{fn}' found in inference.py β") | |
| else: | |
| fail(f" Function '{fn}' MISSING from inference.py") | |
| # Check all difficulty choices are listed | |
| src_lower = src.lower() | |
| for d in ["easy", "medium", "hard", "expert"]: | |
| if d in src_lower: | |
| ok(f" Difficulty '{d}' referenced in inference.py β") | |
| else: | |
| fail(f" Difficulty '{d}' NOT referenced in inference.py") | |
| except SyntaxError as se: | |
| fail(f"inference.py has syntax error: {se}") | |
| else: | |
| fail(f"inference.py not found at {inf_path}") | |
| except Exception as e: | |
| fail("inference.py check failed", e) | |
| traceback.print_exc() | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Summary | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| total = PASS + FAIL | |
| print(f"\n{BOLD}{'β'*60}{RESET}") | |
| if FAIL == 0: | |
| print(f"{GREEN}{BOLD} ALL {PASS}/{total} CHECKS PASSED β{RESET}") | |
| print(f"{GREEN}{BOLD} Submission looks valid!{RESET}") | |
| else: | |
| print(f"{RED}{BOLD} {FAIL} / {total} CHECKS FAILED β{RESET}") | |
| print(f"\n{YELLOW}Failed checks:{RESET}") | |
| for err in ERRORS: | |
| print(f" β’ {err}") | |
| print(f"{BOLD}{'β'*60}{RESET}\n") | |
| sys.exit(0 if FAIL == 0 else 1) | |