Kaushalraj Puwar commited on
Commit
e987a94
·
1 Parent(s): 79412de

refactor(core): integrate modular task system for episode management

Browse files

Introduce a new task-based architecture with abstract ThermalPlantTask class and specific implementations (task1-4) to govern episode disturbances, completions, and policies. Update env/core.py to delegate max_steps, resets, and disturbances to tasks, enhancing modularity and deterministic behavior. Refine transitions.py with improved reward smoothing and initial state clamping for observable gaps. Modify inference.py to embed task descriptions in LLM prompts for better guidance. Add comprehensive unit and integration tests for task functionality.

BREAKING CHANGE: Episode management now requires valid task_id; max_steps are task-specific, altering default episode lengths.

env/core.py CHANGED
@@ -12,14 +12,16 @@ from env.state import ThermalPlantState
12
  from env.transitions import (
13
  build_coherent_initial_state,
14
  integration_step,
 
15
  )
16
  from utils.constants import (
17
  ACTION_F_TARGET,
18
  ACTION_U_TARGET,
19
  DEFAULT_EPISODE_ID,
20
- DEFAULT_MAX_STEPS,
21
  DEFAULT_TASK_ID,
22
  )
 
 
23
 
24
 
25
  class ThermalPlantEnv:
@@ -39,13 +41,23 @@ class ThermalPlantEnv:
39
 
40
  def __init__(
41
  self,
42
- max_steps: int = DEFAULT_MAX_STEPS,
43
  task_id: str = DEFAULT_TASK_ID,
44
  episode_id: int = DEFAULT_EPISODE_ID,
45
  ) -> None:
46
- self.max_steps = max_steps
47
  self.task_id = task_id
48
  self.episode_id = int(episode_id)
 
 
 
 
 
 
 
 
 
 
 
49
  self._state = build_coherent_initial_state(task_id=self.task_id, episode_id=self.episode_id)
50
  self._step_count = 0
51
 
@@ -60,12 +72,18 @@ class ThermalPlantEnv:
60
  # fall back to the default task id and log a warning so validator runs
61
  # continue (robust behaviour for external callers).
62
  try:
 
 
63
  self._state = build_coherent_initial_state(task_id=self.task_id, episode_id=self.episode_id)
64
  except ValueError as exc:
65
  logging.warning("reset(): unknown task_id '%s' - falling back to default '%s' (%s)", self.task_id, DEFAULT_TASK_ID, exc)
66
  self.task_id = DEFAULT_TASK_ID
 
 
67
  self._state = build_coherent_initial_state(task_id=self.task_id, episode_id=self.episode_id)
 
68
  self._step_count = 0
 
69
  return self._state.to_observation()
70
 
71
  def state(self) -> Dict[str, float]:
@@ -94,6 +112,16 @@ class ThermalPlantEnv:
94
  error_msg = "invalid_action_payload"
95
  action = {}
96
 
 
 
 
 
 
 
 
 
 
 
97
  next_state, reward, done_catastrophic, transition_info = integration_step(self._state, action)
98
 
99
  if invalid_action:
@@ -102,14 +130,15 @@ class ThermalPlantEnv:
102
  transition_info["error"] = error_msg
103
 
104
  self._state = next_state
105
- self._step_count += 1
106
 
107
- done = done_catastrophic or self._step_count >= self.max_steps
 
108
 
109
  info: Dict[str, Any] = {
110
  "error": transition_info.get("error"),
111
  "step_count": self._step_count,
112
  "invalid_action": transition_info.get("invalid_action", False),
113
- "invalid_action_penalty": transition_info.get("invalid_action_penalty", 0.0)
 
114
  }
115
  return self._state.to_observation(), float(reward), bool(done), info
 
12
  from env.transitions import (
13
  build_coherent_initial_state,
14
  integration_step,
15
+ clamp_state,
16
  )
17
  from utils.constants import (
18
  ACTION_F_TARGET,
19
  ACTION_U_TARGET,
20
  DEFAULT_EPISODE_ID,
 
21
  DEFAULT_TASK_ID,
22
  )
23
+ from tasks.config import ThermalPlantTask
24
+ from tasks.registry import get_task
25
 
26
 
27
  class ThermalPlantEnv:
 
41
 
42
  def __init__(
43
  self,
44
+ max_steps: Optional[int] = None,
45
  task_id: str = DEFAULT_TASK_ID,
46
  episode_id: int = DEFAULT_EPISODE_ID,
47
  ) -> None:
 
48
  self.task_id = task_id
49
  self.episode_id = int(episode_id)
50
+ # Initialize task
51
+ try:
52
+ self._task: ThermalPlantTask = get_task(self.task_id)
53
+ self.max_steps = max_steps if max_steps is not None else self._task.max_steps
54
+ except ValueError as exc:
55
+ logging.warning("__init__(): unknown task_id '%s' - falling back to default '%s' (%s)", self.task_id, DEFAULT_TASK_ID, exc)
56
+ self.task_id = DEFAULT_TASK_ID
57
+ self._task = get_task(self.task_id)
58
+ self.max_steps = max_steps if max_steps is not None else self._task.max_steps
59
+
60
+ self._task.reset(self.episode_id)
61
  self._state = build_coherent_initial_state(task_id=self.task_id, episode_id=self.episode_id)
62
  self._step_count = 0
63
 
 
72
  # fall back to the default task id and log a warning so validator runs
73
  # continue (robust behaviour for external callers).
74
  try:
75
+ self._task = get_task(self.task_id)
76
+ self.max_steps = getattr(self._task, "max_steps", self.max_steps)
77
  self._state = build_coherent_initial_state(task_id=self.task_id, episode_id=self.episode_id)
78
  except ValueError as exc:
79
  logging.warning("reset(): unknown task_id '%s' - falling back to default '%s' (%s)", self.task_id, DEFAULT_TASK_ID, exc)
80
  self.task_id = DEFAULT_TASK_ID
81
+ self._task = get_task(self.task_id)
82
+ self.max_steps = getattr(self._task, "max_steps", self.max_steps)
83
  self._state = build_coherent_initial_state(task_id=self.task_id, episode_id=self.episode_id)
84
+
85
  self._step_count = 0
86
+ self._task.reset(self.episode_id)
87
  return self._state.to_observation()
88
 
89
  def state(self) -> Dict[str, float]:
 
112
  error_msg = "invalid_action_payload"
113
  action = {}
114
 
115
+ self._step_count += 1
116
+
117
+ # Apply task disturbances before physics
118
+ deltas, task_event = self._task.apply_disturbance(self._state, self._step_count)
119
+ if deltas:
120
+ for k, v in deltas.items():
121
+ if hasattr(self._state, k):
122
+ setattr(self._state, k, getattr(self._state, k) + v)
123
+ self._state = clamp_state(self._state)
124
+
125
  next_state, reward, done_catastrophic, transition_info = integration_step(self._state, action)
126
 
127
  if invalid_action:
 
130
  transition_info["error"] = error_msg
131
 
132
  self._state = next_state
 
133
 
134
+ task_done = hasattr(self._task, "is_completed") and self._task.is_completed(self._state, self._step_count)
135
+ done = done_catastrophic or task_done or self._step_count >= self.max_steps
136
 
137
  info: Dict[str, Any] = {
138
  "error": transition_info.get("error"),
139
  "step_count": self._step_count,
140
  "invalid_action": transition_info.get("invalid_action", False),
141
+ "invalid_action_penalty": transition_info.get("invalid_action_penalty", 0.0),
142
+ "task_event": task_event
143
  }
144
  return self._state.to_observation(), float(reward), bool(done), info
env/transitions.py CHANGED
@@ -218,7 +218,24 @@ def build_coherent_initial_state(task_id: str, episode_id: int) -> ThermalPlantS
218
  S_BOUNDS[0],
219
  S_BOUNDS[1],
220
  )
221
- power = clamp(load + regime["G_gap"] - P_STRESS_DRAG * stress, P_BOUNDS[0], P_BOUNDS[1])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  control, cooling = _solve_controls(
223
  load=load,
224
  power=power,
@@ -295,8 +312,8 @@ def integration_step(state: ThermalPlantState, action: Dict[str, float]) -> Tupl
295
  def der(x_vec):
296
  P, T, Pr, S, D = x_vec
297
 
298
- # Power: dP/dt = P_GAIN * U * (1 - P/1.0) - P_LOAD_COEF*(P - L) - P_DEG_COEF*P*D
299
- dP = C.P_GAIN * next_u * (1.0 - P) - C.P_LOAD_COEF * (P - state.L) - C.P_DEG_COEF * P * D
300
 
301
  # Temperature: dT/dt = a*P^1.1 - b*F^1.05 - c*(T - T_env) - e*D*T
302
  # T_env is 0.0 effectively
@@ -372,20 +389,23 @@ def check_catastrophic(state: ThermalPlantState) -> Tuple[bool, str]:
372
 
373
 
374
  def compute_reward(prev_state: ThermalPlantState, state: ThermalPlantState, delta_u: float) -> float:
375
- # R_track
376
- r_track = -abs(state.P - state.L)
377
-
378
- # R_safety
379
- r_safety = C.SAFETY_PENALTY_COEF * max(0.0, state.T - C.SOFT_T) + C.SAFETY_PENALTY_COEF * max(0.0, state.Pr - C.SOFT_PR)
380
 
381
- # R_stability
382
- r_stability = (delta_u ** 2)
 
383
 
384
- # R_eff
385
- r_eff = state.U**2 + state.F**2
386
 
387
- # R_invalid is applied outside this by parsing wrapper, but can be added in env core if needed.
 
388
 
389
- reward = C.W_TRACK * r_track - C.W_SAFETY * r_safety - C.W_STABILITY * r_stability - C.W_EFF * r_eff
 
390
  return clamp(reward, -10.0, 10.0)
391
 
 
218
  S_BOUNDS[0],
219
  S_BOUNDS[1],
220
  )
221
+
222
+ # Compute gap, allowing it to vary around load for balanced control challenge
223
+ # The gap can be positive or negative, creating both "reduce" and "increase" scenarios
224
+ g_gap = regime["G_gap"]
225
+
226
+ # Clamp stress slightly if excessive to prevent artificial P reduction
227
+ if stress > 0.3:
228
+ stress = clamp(stress * 0.85, S_BOUNDS[0], S_BOUNDS[1])
229
+
230
+ power = clamp(load + g_gap - P_STRESS_DRAG * stress, P_BOUNDS[0], P_BOUNDS[1])
231
+
232
+ # Ensure observable gap: |P - L| >= 0.04 so LLM sees clear control objective
233
+ gap_magnitude = abs(power - load)
234
+ if gap_magnitude < 0.04:
235
+ # If gap too small, nudge power away from load in direction determined by seed
236
+ direction = 1.0 if (g_gap >= 0) else -1.0
237
+ power = clamp(load + direction * 0.08, P_BOUNDS[0], P_BOUNDS[1])
238
+
239
  control, cooling = _solve_controls(
240
  load=load,
241
  power=power,
 
312
  def der(x_vec):
313
  P, T, Pr, S, D = x_vec
314
 
315
+ # Power: target tracking for U, minus load drag, minus degradation drag
316
+ dP = C.P_GAIN * (next_u - P) - C.P_LOAD_COEF * (P - state.L) - C.P_DEG_COEF * P * D
317
 
318
  # Temperature: dT/dt = a*P^1.1 - b*F^1.05 - c*(T - T_env) - e*D*T
319
  # T_env is 0.0 effectively
 
389
 
390
 
391
  def compute_reward(prev_state: ThermalPlantState, state: ThermalPlantState, delta_u: float) -> float:
392
+ # Tracking reward: smooth linear decay as error increases
393
+ # At error=0: r_track=1.0, at error=0.2: r_track=0.0, at error=0.4: r_track=-1.0
394
+ # This avoids the cliff discontinuity and provides smooth gradient for learning
395
+ tracking_error = abs(state.P - state.L)
396
+ r_track = 1.0 - tracking_error / 0.2 # Linear decay over 0.2 error window
397
 
398
+ # Safety penalty: only penalize when approaching limits
399
+ safety_violation = max(0.0, state.T - C.SOFT_T) + max(0.0, state.Pr - C.SOFT_PR)
400
+ r_safety = C.SAFETY_PENALTY_COEF * safety_violation
401
 
402
+ # Stability penalty: penalize excessive control changes
403
+ r_stability = C.OSCILLATION_PENALTY_COEF * (delta_u ** 2)
404
 
405
+ # Stress penalty: penalize high stress accumulation
406
+ r_stress = C.STRESS_PENALTY_COEF * state.S if state.S > 0.1 else 0.0
407
 
408
+ # Combine: tracking reward + safety/stress penalty - control oscillation
409
+ reward = C.W_TRACK * r_track - C.W_SAFETY * r_safety - C.W_STABILITY * r_stability - 0.2 * r_stress
410
  return clamp(reward, -10.0, 10.0)
411
 
inference.py CHANGED
@@ -12,6 +12,7 @@ from dotenv import load_dotenv
12
  load_dotenv()
13
 
14
  from env.interface import ConcreteOpenEnvInterface
 
15
  from utils import constants as C
16
  from utils.logging_utils import canonical_action_string, log_end, log_start, log_step
17
  from utils.parser import parse_llm_action
@@ -28,18 +29,36 @@ DEFAULT_ACTION = {
28
  }
29
 
30
  # These will be injected by the judge's infrastructure
31
- HF_TOKEN = os.getenv("HF_TOKEN")
32
- MODEL_NAME = os.getenv("MODEL_NAME")
33
- API_BASE_URL = os.getenv("API_BASE_URL")
34
  TASK_NAME = os.getenv("THERMAL_PLANT_TASK", C.DEFAULT_TASK_ID)
35
  EPISODE_ID = int(os.getenv("THERMAL_PLANT_EPISODE_ID", str(C.DEFAULT_EPISODE_ID)))
36
  DEBUG = os.getenv("DEBUG", "false").lower() in ("1", "true", "yes")
37
 
 
38
  SYSTEM_PROMPT = textwrap.dedent(
39
  """
40
- You are controlling a thermal plant benchmark.
41
- Return ONLY a compact numeric pair separated by a space representing your chosen `U_target` and `F_target` (e.g. "0.60 0.50").
42
- Both targets must be strictly in the range [0, 1]. Do not output JSON, explanations, or prose.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  """
44
  ).strip()
45
 
@@ -50,6 +69,7 @@ def _format_observation(observation: Dict[str, float]) -> str:
50
 
51
 
52
  def build_user_prompt(
 
53
  step: int,
54
  observation: Dict[str, float],
55
  last_reward: float,
@@ -59,6 +79,8 @@ def build_user_prompt(
59
  history_block = "\n".join(history[-4:]) if history else "None"
60
  return textwrap.dedent(
61
  f"""
 
 
62
  Step: {step}
63
  Observation: {_format_observation(observation)}
64
  Last reward: {last_reward:.2f}
@@ -70,6 +92,7 @@ def build_user_prompt(
70
 
71
  def get_model_response(
72
  client: OpenAI,
 
73
  step: int,
74
  observation: Dict[str, float],
75
  last_reward: float,
@@ -81,7 +104,7 @@ def get_model_response(
81
  model=MODEL_NAME,
82
  messages=[
83
  {"role": "system", "content": SYSTEM_PROMPT},
84
- {"role": "user", "content": build_user_prompt(step, observation, last_reward, history)},
85
  ],
86
  temperature=TEMPERATURE,
87
  max_tokens=MAX_TOKENS,
@@ -93,21 +116,21 @@ def get_model_response(
93
  return ""
94
 
95
 
96
- def compute_normalized_score(rewards: List[float]) -> float:
97
  """Convert collected step rewards into a normalized score in [0, 1]."""
98
  if not rewards:
99
  return 0.0
100
  positive_reward = sum(max(0.0, reward) for reward in rewards)
101
- return min(max(positive_reward / float(C.DEFAULT_MAX_STEPS), 0.0), 1.0)
102
 
103
 
104
- def determine_termination_reason(loop_error: Optional[str], done: bool, steps_taken: int) -> str:
105
  """Return a stable end-of-episode reason."""
106
  if loop_error:
107
  return "exception"
108
  if done:
109
  return "env_done"
110
- if steps_taken >= C.DEFAULT_MAX_STEPS:
111
  return "max_steps"
112
  return "stopped"
113
 
@@ -115,10 +138,11 @@ def determine_termination_reason(loop_error: Optional[str], done: bool, steps_ta
115
  def main() -> None:
116
  """Run a full deterministic inference episode and always emit end logs."""
117
  model_name_for_logs = MODEL_NAME or "unset"
118
- env = ConcreteOpenEnvInterface(max_steps=C.DEFAULT_MAX_STEPS)
119
  client: Optional[OpenAI] = None
120
  last_valid_action: Optional[Dict[str, float]] = None
121
  observation = env.reset(task_id=TASK_NAME, episode_id=EPISODE_ID)
 
122
  history: List[str] = []
123
  rewards: List[float] = []
124
  score = 0.0
@@ -131,6 +155,12 @@ def main() -> None:
131
 
132
  log_start(task=TASK_NAME, env=BENCHMARK, model=model_name_for_logs)
133
 
 
 
 
 
 
 
134
  try:
135
  if HF_TOKEN and MODEL_NAME and API_BASE_URL:
136
  client = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN)
@@ -141,11 +171,12 @@ def main() -> None:
141
  flush=True,
142
  )
143
 
144
- for step in range(1, C.DEFAULT_MAX_STEPS + 1):
145
  raw_response = ""
146
  if client is not None:
147
  raw_response = get_model_response(
148
  client=client,
 
149
  step=step,
150
  observation=observation,
151
  last_reward=last_reward,
@@ -250,9 +281,9 @@ def main() -> None:
250
  except Exception as exc:
251
  loop_error = str(exc)
252
  finally:
253
- score = compute_normalized_score(rewards)
254
  success = score >= SUCCESS_SCORE_THRESHOLD
255
- termination_reason = determine_termination_reason(loop_error=loop_error, done=done, steps_taken=steps_taken)
256
  trajectory.summary = TrajectorySummary(
257
  task=TASK_NAME,
258
  benchmark=BENCHMARK,
@@ -267,4 +298,6 @@ def main() -> None:
267
 
268
 
269
  if __name__ == "__main__":
270
- main()
 
 
 
12
  load_dotenv()
13
 
14
  from env.interface import ConcreteOpenEnvInterface
15
+ from tasks.registry import get_task
16
  from utils import constants as C
17
  from utils.logging_utils import canonical_action_string, log_end, log_start, log_step
18
  from utils.parser import parse_llm_action
 
29
  }
30
 
31
  # These will be injected by the judge's infrastructure
32
+ HF_TOKEN = os.getenv("HF_TOKEN") or os.getenv("API_KEY")
33
+ MODEL_NAME = os.getenv("MODEL_NAME", "meta-llama/Llama-3.3-70B-Instruct")
34
+ API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
35
  TASK_NAME = os.getenv("THERMAL_PLANT_TASK", C.DEFAULT_TASK_ID)
36
  EPISODE_ID = int(os.getenv("THERMAL_PLANT_EPISODE_ID", str(C.DEFAULT_EPISODE_ID)))
37
  DEBUG = os.getenv("DEBUG", "false").lower() in ("1", "true", "yes")
38
 
39
+
40
  SYSTEM_PROMPT = textwrap.dedent(
41
  """
42
+ You are controlling a thermal plant benchmark.
43
+ Your objective is to track the required load (L) with your power output (P) while keeping temperature (T) and pressure (Pr) safely below 1.0 to avoid stress (S).
44
+
45
+ State Variables (Inputs):
46
+ P : Current Power output (track this to L)
47
+ L : Required Load
48
+ T : Temperature (keep safely below 1.0)
49
+ Pr: Pressure (keep safely below 1.0)
50
+ S : System Stress
51
+ D : Plant Degradation
52
+ U : Current Control Valve position (inertia-delayed power target)
53
+ F : Current Cooling Valve position (inertia-delayed cooling target)
54
+
55
+ Action Space (Your Outputs):
56
+ - U_target: Desired power level [0, 1]. Drives the power valve U.
57
+ - F_target: Desired cooling level [0, 1]. Drives the cooling valve F.
58
+
59
+ Note that your target controls (U_target, F_target) are subject to physical lag. They slowly move the actual valves (U, F) towards your targets. Anticipate delayed responses and system inertia.
60
+
61
+ Return ONLY a compact numeric pair separated by a space representing your chosen `U_target` and `F_target` (e.g. "0.60 0.50"). Do not output JSON, explanations, or prose.
62
  """
63
  ).strip()
64
 
 
69
 
70
 
71
  def build_user_prompt(
72
+ task_description: str,
73
  step: int,
74
  observation: Dict[str, float],
75
  last_reward: float,
 
79
  history_block = "\n".join(history[-4:]) if history else "None"
80
  return textwrap.dedent(
81
  f"""
82
+ Task Objective: {task_description}
83
+
84
  Step: {step}
85
  Observation: {_format_observation(observation)}
86
  Last reward: {last_reward:.2f}
 
92
 
93
  def get_model_response(
94
  client: OpenAI,
95
+ task_description: str,
96
  step: int,
97
  observation: Dict[str, float],
98
  last_reward: float,
 
104
  model=MODEL_NAME,
105
  messages=[
106
  {"role": "system", "content": SYSTEM_PROMPT},
107
+ {"role": "user", "content": build_user_prompt(task_description, step, observation, last_reward, history)},
108
  ],
109
  temperature=TEMPERATURE,
110
  max_tokens=MAX_TOKENS,
 
116
  return ""
117
 
118
 
119
+ def compute_normalized_score(rewards: List[float], max_steps: int) -> float:
120
  """Convert collected step rewards into a normalized score in [0, 1]."""
121
  if not rewards:
122
  return 0.0
123
  positive_reward = sum(max(0.0, reward) for reward in rewards)
124
+ return min(max(positive_reward / float(max_steps), 0.0), 1.0)
125
 
126
 
127
+ def determine_termination_reason(loop_error: Optional[str], done: bool, steps_taken: int, max_steps: int) -> str:
128
  """Return a stable end-of-episode reason."""
129
  if loop_error:
130
  return "exception"
131
  if done:
132
  return "env_done"
133
+ if steps_taken >= max_steps:
134
  return "max_steps"
135
  return "stopped"
136
 
 
138
  def main() -> None:
139
  """Run a full deterministic inference episode and always emit end logs."""
140
  model_name_for_logs = MODEL_NAME or "unset"
141
+ env = ConcreteOpenEnvInterface()
142
  client: Optional[OpenAI] = None
143
  last_valid_action: Optional[Dict[str, float]] = None
144
  observation = env.reset(task_id=TASK_NAME, episode_id=EPISODE_ID)
145
+ max_steps = getattr(env._env, "max_steps", 12) if hasattr(env, "_env") else 12
146
  history: List[str] = []
147
  rewards: List[float] = []
148
  score = 0.0
 
155
 
156
  log_start(task=TASK_NAME, env=BENCHMARK, model=model_name_for_logs)
157
 
158
+ try:
159
+ task_obj = get_task(TASK_NAME)
160
+ task_description = getattr(task_obj, "description", "Optimal Thermal Plant Control")
161
+ except Exception:
162
+ task_description = "Optimal Thermal Plant Control"
163
+
164
  try:
165
  if HF_TOKEN and MODEL_NAME and API_BASE_URL:
166
  client = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN)
 
171
  flush=True,
172
  )
173
 
174
+ for step in range(1, max_steps + 1):
175
  raw_response = ""
176
  if client is not None:
177
  raw_response = get_model_response(
178
  client=client,
179
+ task_description=task_description,
180
  step=step,
181
  observation=observation,
182
  last_reward=last_reward,
 
281
  except Exception as exc:
282
  loop_error = str(exc)
283
  finally:
284
+ score = compute_normalized_score(rewards, max_steps)
285
  success = score >= SUCCESS_SCORE_THRESHOLD
286
+ termination_reason = determine_termination_reason(loop_error=loop_error, done=done, steps_taken=steps_taken, max_steps=max_steps)
287
  trajectory.summary = TrajectorySummary(
288
  task=TASK_NAME,
289
  benchmark=BENCHMARK,
 
298
 
299
 
300
  if __name__ == "__main__":
301
+ if HF_TOKEN is None:
302
+ raise ValueError("HF_TOKEN environment variable is required")
303
+ main()
tasks/config.py CHANGED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Task configuration, interfaces, and shared types."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Dict, Optional, Tuple, Any
5
+
6
+ from env.state import ThermalPlantState
7
+
8
+
9
+ class AgentPolicy(ABC):
10
+ """A deterministic baseline controller for sanity checks and grading."""
11
+
12
+ @abstractmethod
13
+ def get_action(self, observation: Dict[str, float]) -> Dict[str, float]:
14
+ """Return a deterministic control action based on the state observation."""
15
+ pass
16
+
17
+
18
+ class ThermalPlantTask(ABC):
19
+ """Base class for tasks governing the episodes in the standard benchmark.
20
+
21
+ Tasks control deterministic disturbances over time and provide metadata
22
+ for graders and OpenEnv integration.
23
+ """
24
+
25
+ task_id: str
26
+ name: str
27
+ description: str
28
+ max_steps: int
29
+
30
+ @abstractmethod
31
+ def reset(self, episode_id: int) -> None:
32
+ """Reset the task-internal state using the episode ID as a deterministic seed."""
33
+ pass
34
+
35
+ @abstractmethod
36
+ def apply_disturbance(self, state: ThermalPlantState, step: int) -> Tuple[Dict[str, float], Optional[Dict[str, Any]]]:
37
+ """Compute relative disturbances for the current step.
38
+
39
+ Args:
40
+ state: The fully specified current actual state (un-rounded).
41
+ step: The current 1-indexed environment step.
42
+
43
+ Returns:
44
+ deltas: A dictionary mapping state keys (e.g., 'L', 'D', 'T') to absolute Delta values
45
+ to add to the current state fields. Amplitudes should be derived relative
46
+ to the variable domain bounds implicitly.
47
+ event: An optional dictionary describing the event that just occurred for info logging.
48
+ """
49
+ pass
50
+
51
+ def is_completed(self, state: ThermalPlantState, step_count: int) -> bool:
52
+ """Check if the task has successfully reached a terminal stabilizing state."""
53
+ return False
54
+
55
+ @abstractmethod
56
+ def get_baseline_policy(self) -> AgentPolicy:
57
+ """Return a simple heuristic or rule-based agent capable of surviving the task."""
58
+ pass
tasks/registry.py CHANGED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Central task discovery and instantiation."""
2
+
3
+ from typing import Dict, Type, Optional
4
+
5
+ from tasks.config import ThermalPlantTask
6
+ from tasks.task1 import Task1
7
+ from tasks.task2 import Task2
8
+ from tasks.task3 import Task3
9
+ from tasks.task4 import Task4
10
+
11
+
12
+ _TASK_CLASSES: Dict[str, Type[ThermalPlantTask]] = {
13
+ "task1": Task1,
14
+ "task2": Task2,
15
+ "task3": Task3,
16
+ "task4": Task4,
17
+ }
18
+
19
+
20
+ def get_task(task_id: str) -> ThermalPlantTask:
21
+ """Instantiate and return the canonical task."""
22
+ cls = _TASK_CLASSES.get(task_id)
23
+ if not cls:
24
+ raise ValueError(f"Unknown task_id '{task_id}'. Expected one of {list(_TASK_CLASSES.keys())}")
25
+ return cls()
26
+
27
+
28
+ def task_registry() -> Dict[str, dict]:
29
+ """Expose registry metadata required by OpenEnv validation."""
30
+ return {
31
+ task_id: {
32
+ "name": getattr(cls, "name", task_id),
33
+ "description": getattr(cls, "description", ""),
34
+ "max_steps": getattr(cls, "max_steps", None),
35
+ }
36
+ for task_id, cls in _TASK_CLASSES.items()
37
+ }
tasks/task1.py CHANGED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Task 1: Stable Baseline Operation.
2
+
3
+ Maintain P close to L while staying safe and smooth. Constant load pattern L_t = 0.6.
4
+ """
5
+
6
+ from typing import Dict, Optional, Tuple, Any
7
+
8
+ from env.state import ThermalPlantState
9
+ from tasks.config import ThermalPlantTask, AgentPolicy
10
+ from utils.constants import U_BOUNDS, F_BOUNDS, P_BOUNDS, L_BOUNDS, T_BOUNDS, PR_BOUNDS
11
+
12
+ class BaselinePolicy(AgentPolicy):
13
+ """A simple rule-based tracker."""
14
+
15
+ def get_action(self, observation: Dict[str, float]) -> Dict[str, float]:
16
+ # Track power to load
17
+ u_target = 0.6
18
+ if observation["P"] < observation["L"] - 0.05:
19
+ u_target = min(0.9, observation["U"] + 0.1)
20
+ elif observation["P"] > observation["L"] + 0.05:
21
+ u_target = max(0.1, observation["U"] - 0.1)
22
+
23
+ # Heavy cooling if temp/pressure gets hot
24
+ f_target = 0.4
25
+ if observation["T"] > 0.85 or observation["Pr"] > 0.85:
26
+ f_target = 0.8
27
+ u_target = max(0.1, u_target - 0.2)
28
+
29
+ return {"U_target": u_target, "F_target": f_target}
30
+
31
+
32
+ class Task1(ThermalPlantTask):
33
+ task_id = "task1"
34
+ name = "Stable Baseline Operation"
35
+ description = "Maintain power close to a constant load of 0.6 while staying safe and smooth."
36
+ max_steps = 12
37
+
38
+ def reset(self, episode_id: int) -> None:
39
+ self._seed = episode_id
40
+
41
+ def apply_disturbance(self, state: ThermalPlantState, step: int) -> Tuple[Dict[str, float], Optional[Dict[str, Any]]]:
42
+ deltas = {}
43
+ # Force L_t = 0.6
44
+ target_L = 0.6
45
+ if abs(state.L - target_L) > 1e-5:
46
+ deltas["L"] = target_L - state.L
47
+ return deltas, {"type": "constant_load", "target_L": target_L}
48
+
49
+ def is_completed(self, state: ThermalPlantState, step_count: int) -> bool:
50
+ # User requested early stop when P=L.
51
+ tracking_error = abs(state.P - 0.6)
52
+ # End task immediately if error is very small (converged to load target)
53
+ return tracking_error <= 0.02 and step_count >= 3
54
+
55
+ def get_baseline_policy(self) -> AgentPolicy:
56
+ return BaselinePolicy()
tasks/task2.py CHANGED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Task 2: Load Following.
2
+
3
+ Track changing load with minimal lag and overshoot.
4
+ Step changes: t=1..3: L=0.5, t=4..6: L=0.8, t=7..N: L=0.6.
5
+ """
6
+
7
+ from typing import Dict, Optional, Tuple, Any
8
+
9
+ from env.state import ThermalPlantState
10
+ from tasks.config import ThermalPlantTask, AgentPolicy
11
+ from utils.constants import L_BOUNDS, D_BOUNDS, TASK_CODE
12
+
13
+ class PeriodicPolicy(AgentPolicy):
14
+ """Tracker robust to periodic disturbances."""
15
+
16
+ def get_action(self, observation: Dict[str, float]) -> Dict[str, float]:
17
+ # Track power to load aggressively to minimize lag
18
+ u_target = min(max(observation["U"] + 0.4 * (observation["L"] - observation["P"]), 0.1), 0.9)
19
+ f_target = 0.5
20
+ if observation["T"] > 0.8:
21
+ f_target = 0.75
22
+ u_target -= 0.15
23
+ elif observation["T"] > 0.9:
24
+ f_target = 0.95
25
+ u_target = 0.1
26
+
27
+ return {"U_target": max(0.0, u_target), "F_target": f_target}
28
+
29
+
30
+ class Task2(ThermalPlantTask):
31
+ task_id = "task2"
32
+ name = "Load Following"
33
+ description = "Track step changes in required load (0.5 -> 0.8 -> 0.6) with minimal lag and overshoot."
34
+ max_steps = 12
35
+
36
+ def reset(self, episode_id: int) -> None:
37
+ self._seed = episode_id
38
+
39
+ def apply_disturbance(self, state: ThermalPlantState, step: int) -> Tuple[Dict[str, float], Optional[Dict[str, Any]]]:
40
+ deltas = {}
41
+
42
+ if step <= 3:
43
+ target_L = 0.5
44
+ elif step <= 6:
45
+ target_L = 0.8
46
+ else:
47
+ target_L = 0.6
48
+
49
+ if abs(state.L - target_L) > 1e-5:
50
+ deltas["L"] = target_L - state.L
51
+
52
+ event = {"type": "load_step", "target_L": target_L}
53
+ return deltas, event
54
+
55
+ def is_completed(self, state: ThermalPlantState, step_count: int) -> bool:
56
+ # Task 2 has a final load step change at step 7.
57
+ # So we only allow early completion after the final change has stabilized.
58
+ if step_count < 8:
59
+ return False
60
+ return abs(state.P - 0.6) <= 0.02
61
+
62
+ def get_baseline_policy(self) -> AgentPolicy:
63
+ return PeriodicPolicy()
tasks/task3.py CHANGED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Task 3: Preemptive Constraint Management.
2
+
3
+ L_t = moderately high constant (0.7).
4
+ Stress accumulates when T > 0.9. Agent must reduce risk BEFORE visible failure.
5
+ """
6
+
7
+ from typing import Dict, Optional, Tuple, Any
8
+
9
+ from env.state import ThermalPlantState
10
+ from tasks.config import ThermalPlantTask, AgentPolicy
11
+ from utils.constants import L_BOUNDS, TASK_CODE
12
+
13
+ class RampPolicy(AgentPolicy):
14
+ """Proportional tracker."""
15
+
16
+ def get_action(self, observation: Dict[str, float]) -> Dict[str, float]:
17
+ # Track power to load proportionally
18
+ u_target = min(observation["U"] + 0.4 * (observation["L"] - observation["P"]), 1.0)
19
+ u_target = max(u_target, 0.0)
20
+
21
+ f_target = max(0.4, 0.8 * observation["T"])
22
+ # Preemptively keep T < 0.9 by sharply increasing cooling when safely near the threshold
23
+ if observation["T"] > 0.85:
24
+ f_target = 0.95
25
+ u_target = max(0.0, u_target - 0.2)
26
+
27
+ return {"U_target": u_target, "F_target": f_target}
28
+
29
+
30
+ class Task3(ThermalPlantTask):
31
+ task_id = "task3"
32
+ name = "Preemptive Constraint Management"
33
+ description = "Manage moderately high load (0.7) and prevent stress accumulation by keeping Temperature below 0.9 under restricted coolant conditions."
34
+ max_steps = 12
35
+
36
+ def reset(self, episode_id: int) -> None:
37
+ self._seed = int(episode_id)
38
+
39
+ def apply_disturbance(self, state: ThermalPlantState, step: int) -> Tuple[Dict[str, float], Optional[Dict[str, Any]]]:
40
+ deltas: Dict[str, float] = {}
41
+ event: Dict[str, Any] = {"type": "constraint_management"}
42
+
43
+ target_L = 0.7
44
+ if abs(state.L - target_L) > 1e-5:
45
+ deltas["L"] = target_L - state.L
46
+
47
+ # Exogenous heat: acts as a coolant deficiency.
48
+ # Max cooling (F=1.0) is no longer strong enough to overcome both this and P=0.7.
49
+ deltas["T"] = 0.045
50
+
51
+ # Stress accumulates when T > 0.9
52
+ if state.T > 0.9:
53
+ deltas["S"] = 0.05 # Additive stress accumulation per step
54
+ event["stress_warning"] = True
55
+
56
+ return deltas, event
57
+
58
+ def is_completed(self, state: ThermalPlantState, step_count: int) -> bool:
59
+ # End task if stable and constraints are well managed.
60
+ error = abs(state.P - 0.7)
61
+ return error <= 0.02 and state.T < 0.85 and step_count >= 3
62
+
63
+ def get_baseline_policy(self) -> AgentPolicy:
64
+ return RampPolicy()
tasks/task4.py CHANGED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Task 4: Fault Recovery with Degradation.
2
+
3
+ L=0.6 mostly. Disturbance at t=4: T spike (+0.3).
4
+ Degradation reduces cooling effectiveness over time.
5
+ """
6
+
7
+ from typing import Dict, Optional, Tuple, Any
8
+
9
+ from env.state import ThermalPlantState
10
+ from tasks.config import ThermalPlantTask, AgentPolicy
11
+ from utils.constants import T_BOUNDS, PR_BOUNDS, TASK_CODE
12
+
13
+ class ShockPolicy(AgentPolicy):
14
+ """Heavy cooling strategy."""
15
+
16
+ def get_action(self, observation: Dict[str, float]) -> Dict[str, float]:
17
+ # Track power to load cautiously
18
+ u_target = min(observation["U"] + 0.2 * (observation["L"] - observation["P"]), 0.8)
19
+
20
+ # Heavy cooling
21
+ f_target = 0.6
22
+ if observation["T"] > 0.8 or observation["Pr"] > 0.8:
23
+ f_target = 1.0
24
+ u_target = 0.0 # Emergency shutdown
25
+
26
+ return {"U_target": max(0.0, u_target), "F_target": f_target}
27
+
28
+
29
+ class Task4(ThermalPlantTask):
30
+ task_id = "task4"
31
+ name = "Fault Recovery with Degradation"
32
+ description = "Recover from a major thermal spike at step 4 while managing ongoing system degradation."
33
+ max_steps = 12
34
+
35
+ def reset(self, episode_id: int) -> None:
36
+ self._seed = int(episode_id)
37
+
38
+ def apply_disturbance(self, state: ThermalPlantState, step: int) -> Tuple[Dict[str, float], Optional[Dict[str, Any]]]:
39
+ deltas = {}
40
+ event = {"type": "constant_load"}
41
+
42
+ target_L = 0.6
43
+ if abs(state.L - target_L) > 1e-5:
44
+ deltas["L"] = target_L - state.L
45
+
46
+ if step == 4:
47
+ deltas["T"] = 0.5
48
+ event = {"type": "thermal_fault", "T_delta": deltas["T"]}
49
+
50
+ # Ongoing degradation to simulate reduced cooling effectiveness
51
+ deltas["D"] = 0.02
52
+
53
+ return deltas, event
54
+
55
+ def is_completed(self, state: ThermalPlantState, step_count: int) -> bool:
56
+ # Task 4 has a major fault at step 4.
57
+ # Don't end early before the fault has been processed and stabilized.
58
+ if step_count < 6:
59
+ return False
60
+ return abs(state.P - 0.6) <= 0.02 and state.T < 0.8 and state.Pr < 0.8
61
+
62
+ def get_baseline_policy(self) -> AgentPolicy:
63
+ return ShockPolicy()
tests/adversarial/test_malformed_llm_outputs.py CHANGED
@@ -20,7 +20,8 @@ def mock_env_vars():
20
 
21
  def test_inference_main_with_malformed_outputs(capsys, monkeypatch, mock_env_vars):
22
  # Mock settings to create a short run
23
- monkeypatch.setattr(inference.C, "DEFAULT_MAX_STEPS", 4)
 
24
  monkeypatch.setattr(inference.C, "INCLUDE_PARSE_ERROR_IN_STEP", True)
25
 
26
  # We will simulate exactly 4 steps of LLM responses:
 
20
 
21
  def test_inference_main_with_malformed_outputs(capsys, monkeypatch, mock_env_vars):
22
  # Mock settings to create a short run
23
+ import tasks.task2
24
+ monkeypatch.setattr(tasks.task2.Task2, "max_steps", 4)
25
  monkeypatch.setattr(inference.C, "INCLUDE_PARSE_ERROR_IN_STEP", True)
26
 
27
  # We will simulate exactly 4 steps of LLM responses:
tests/integration/test_inference_loop.py CHANGED
@@ -20,6 +20,7 @@ MOCK_RESPONSES = [
20
 
21
  def mock_get_model_response(
22
  client: MagicMock,
 
23
  step: int,
24
  observation: Dict[str, float],
25
  last_reward: float,
@@ -35,7 +36,9 @@ def mock_inference_env(monkeypatch):
35
  monkeypatch.setenv("MODEL_NAME", "fake_model")
36
  monkeypatch.setenv("API_BASE_URL", "http://fake.api")
37
  monkeypatch.setenv("THERMAL_PLANT_EPISODE_ID", "1")
38
- monkeypatch.setattr(inference.C, "DEFAULT_MAX_STEPS", 5) # Short episode
 
 
39
  yield
40
 
41
  def test_inference_main_prints_exact_stdout_format(mock_inference_env, monkeypatch):
 
20
 
21
  def mock_get_model_response(
22
  client: MagicMock,
23
+ task_description: str,
24
  step: int,
25
  observation: Dict[str, float],
26
  last_reward: float,
 
36
  monkeypatch.setenv("MODEL_NAME", "fake_model")
37
  monkeypatch.setenv("API_BASE_URL", "http://fake.api")
38
  monkeypatch.setenv("THERMAL_PLANT_EPISODE_ID", "1")
39
+ # Tell the default task it's a short episode
40
+ import tasks.task2
41
+ monkeypatch.setattr(tasks.task2.Task2, "max_steps", 5)
42
  yield
43
 
44
  def test_inference_main_prints_exact_stdout_format(mock_inference_env, monkeypatch):
tests/integration/test_tasks_integration.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ import io
3
+ import contextlib
4
+ import re
5
+
6
+ import inference
7
+ from env.core import ThermalPlantEnv
8
+ from utils.constants import TASK_CODE
9
+
10
+ @pytest.fixture
11
+ def mock_inference_env_tasks(monkeypatch):
12
+ monkeypatch.setenv("HF_TOKEN", "fake_token")
13
+ monkeypatch.setenv("MODEL_NAME", "fake_model")
14
+ monkeypatch.setenv("API_BASE_URL", "http://fake.api")
15
+ monkeypatch.setenv("THERMAL_PLANT_EPISODE_ID", "42")
16
+ yield monkeypatch
17
+
18
+
19
+ @pytest.mark.parametrize("task_id", ["task1", "task2", "task3", "task4"])
20
+ def test_inference_runs_full_episode_for_each_task(mock_inference_env_tasks, task_id):
21
+ mock_inference_env_tasks.setenv("THERMAL_PLANT_TASK", task_id)
22
+
23
+ # We will hook inference so it just runs the task's baseline policy instead of calling a model
24
+ env = ThermalPlantEnv(task_id=task_id, episode_id=42)
25
+ policy = env._task.get_baseline_policy()
26
+
27
+ def mock_get_model_response(client, task_description, step, observation, last_reward, history):
28
+ action = policy.get_action(observation)
29
+ # convert back to valid string
30
+ return f"{action['U_target']:.2f} {action['F_target']:.2f}"
31
+
32
+ mock_inference_env_tasks.setattr(inference, "get_model_response", mock_get_model_response)
33
+
34
+ stdout_capture = io.StringIO()
35
+ with contextlib.redirect_stdout(stdout_capture):
36
+ inference.main()
37
+
38
+ output = stdout_capture.getvalue()
39
+ lines = output.strip().split("\n")
40
+
41
+ assert lines[0].startswith(f"[START] task={task_id} env=thermal-plant-control")
42
+ assert lines[-1].startswith("[END] success=")
43
+
44
+ step_lines = [line for line in lines if line.startswith("[STEP]")]
45
+ max_steps = env.max_steps
46
+ assert len(step_lines) <= max_steps
tests/unit/test_tasks.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ from env.core import ThermalPlantEnv
4
+ from tasks.registry import get_task
5
+
6
+
7
+ @pytest.mark.parametrize("task_id", ["task1", "task2", "task3", "task4"])
8
+ def test_task_registry_has_tasks(task_id):
9
+ task = get_task(task_id)
10
+ assert task.task_id == task_id
11
+ assert task.max_steps > 0
12
+ assert task.get_baseline_policy() is not None
13
+
14
+
15
+ def test_task1_baseline():
16
+ env = ThermalPlantEnv(task_id="task1", episode_id=42)
17
+ obs = env.reset()
18
+ assert env.max_steps == 12
19
+
20
+ # Task1 applies NO disturbances except constant Load enforcement
21
+ action = env._task.get_baseline_policy().get_action(obs)
22
+ obs, reward, done, info = env.step(action)
23
+
24
+ assert info.get("task_event") is not None
25
+ assert info["task_event"]["type"] == "constant_load"
26
+
27
+
28
+ def test_task2_periodic_pulses():
29
+ env = ThermalPlantEnv(task_id="task2", episode_id=42)
30
+ obs = env.reset()
31
+ assert env.max_steps == 12
32
+
33
+ events_seen = 0
34
+ for step in range(1, 13):
35
+ action = env._task.get_baseline_policy().get_action(obs)
36
+ obs, reward, done, info = env.step(action)
37
+ if info.get("task_event"):
38
+ assert info["task_event"]["type"] == "load_step"
39
+ target = info["task_event"]["target_L"]
40
+ if step <= 3:
41
+ assert target == 0.5
42
+ elif step <= 6:
43
+ assert target == 0.8
44
+ else:
45
+ assert target == 0.6
46
+
47
+ if done:
48
+ break
49
+
50
+
51
+ def test_task3_ramp():
52
+ env = ThermalPlantEnv(task_id="task3", episode_id=42)
53
+ obs = env.reset()
54
+ assert env.max_steps == 12
55
+
56
+ events_seen = 0
57
+ # Artificially spike T to test stress accumulation
58
+ env._state.T = 0.95
59
+ for _ in range(12):
60
+ action = env._task.get_baseline_policy().get_action(obs)
61
+ obs, reward, done, info = env.step(action)
62
+ if info.get("task_event") is not None:
63
+ events_seen += 1
64
+
65
+ if done:
66
+ break
67
+
68
+ assert events_seen > 0, "No events occurred in task3"
69
+
70
+
71
+ def test_task4_thermal_shock():
72
+ env = ThermalPlantEnv(task_id="task4", episode_id=42)
73
+ obs = env.reset()
74
+ assert env.max_steps == 12
75
+
76
+ events_seen = 0
77
+ for step in range(1, 13):
78
+ action = env._task.get_baseline_policy().get_action(obs)
79
+ obs, reward, done, info = env.step(action)
80
+ if info.get("task_event") is not None:
81
+ if info["task_event"].get("type") == "thermal_fault":
82
+ events_seen += 1
83
+ assert step == 4
84
+
85
+ if done:
86
+ break
87
+
88
+ # Should see exactly 1 thermal shock at step 4
89
+ assert events_seen == 1, f"Expected 1 thermal shock, got {events_seen}"
utils/constants.py CHANGED
@@ -47,16 +47,15 @@ DEFAULT_STATE: Dict[str, float] = {
47
  }
48
 
49
  # Episode controls.
50
- # Shorter episode length helps validation runs show signal quickly.
51
- DEFAULT_MAX_STEPS = 12
52
  DEFAULT_TASK_ID = "task1"
53
- DEFAULT_EPISODE_ID = 0
54
  OBSERVATION_DECIMALS = 2
55
 
56
  # External evaluator episode policy
57
  # External (evaluator/public) calls will use this fixed episode id unless
58
  # a valid developer token is supplied via the `X-DEV-TOKEN` header.
59
- DEFAULT_EXTERNAL_EPISODE_ID = 1
60
  EXTERNAL_EPISODE_DISPLAY_WIDTH = 3
61
 
62
  # Name of the environment variable that holds the developer reset token.
@@ -110,75 +109,75 @@ INIT_T_F_GAIN = 0.30
110
  TASK_STARTUP_PROFILES: Dict[str, Dict[str, float]] = {
111
  "task1": {
112
  "aL": 0.60,
113
- "sL": 0.10,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  "aD": 0.04,
115
- "sD": 0.05,
116
- "aT": 0.12,
117
  "sT": 0.10,
118
- "aM": 0.82,
119
  "sM": 0.08,
120
- "gap_scale": 0.05,
121
- "d_max": 0.15,
122
- "s_base": 0.10,
123
- "s_gain": 0.14,
124
- "f_bias": 0.16,
125
- "t_task_bias": -0.06,
126
- "soft_t_cap": 0.78,
127
- "soft_pr_cap": 0.84,
128
- },
129
- "task2": {
130
- "aL": 0.50,
131
- "sL": 0.12,
132
- "aD": 0.05,
133
- "sD": 0.06,
134
- "aT": 0.18,
135
- "sT": 0.14,
136
- "aM": 0.70,
137
- "sM": 0.10,
138
- "gap_scale": 0.08,
139
  "d_max": 0.10,
140
- "s_base": 0.00,
141
- "s_gain": 0.18,
142
- "f_bias": 0.18,
143
- "t_task_bias": -0.02,
144
- "soft_t_cap": 0.82,
145
  "soft_pr_cap": 0.88,
146
  },
147
  "task3": {
148
  "aL": 0.70,
149
- "sL": 0.10,
150
- "aD": 0.12,
151
- "sD": 0.10,
152
- "aT": 0.48,
153
- "sT": 0.18,
154
- "aM": 0.46,
155
- "sM": 0.10,
156
- "gap_scale": 0.07,
157
- "d_max": 0.22,
158
- "s_base": 0.08,
159
- "s_gain": 0.30,
160
- "f_bias": 0.24,
161
- "t_task_bias": 0.05,
162
- "soft_t_cap": 0.92,
163
- "soft_pr_cap": 0.98,
164
  },
165
  "task4": {
166
  "aL": 0.60,
167
- "sL": 0.10,
168
- "aD": 0.34,
169
- "sD": 0.14,
170
- "aT": 0.34,
171
- "sT": 0.16,
172
- "aM": 0.55,
173
- "sM": 0.10,
174
- "gap_scale": 0.06,
175
- "d_max": 0.45,
176
- "s_base": 0.03,
177
- "s_gain": 0.22,
178
- "f_bias": 0.30,
179
- "t_task_bias": 0.04,
180
- "soft_t_cap": 0.88,
181
- "soft_pr_cap": 0.94,
182
  },
183
  }
184
 
 
47
  }
48
 
49
  # Episode controls.
50
+ # Tasks dictate default episode lengths. Max steps are defined in the specific Task subclasses.
 
51
  DEFAULT_TASK_ID = "task1"
52
+ DEFAULT_EPISODE_ID = 143
53
  OBSERVATION_DECIMALS = 2
54
 
55
  # External evaluator episode policy
56
  # External (evaluator/public) calls will use this fixed episode id unless
57
  # a valid developer token is supplied via the `X-DEV-TOKEN` header.
58
+ DEFAULT_EXTERNAL_EPISODE_ID = 143
59
  EXTERNAL_EPISODE_DISPLAY_WIDTH = 3
60
 
61
  # Name of the environment variable that holds the developer reset token.
 
109
  TASK_STARTUP_PROFILES: Dict[str, Dict[str, float]] = {
110
  "task1": {
111
  "aL": 0.60,
112
+ "sL": 0.00, # Fixed load start to match constant task target
113
+ "aD": 0.02,
114
+ "sD": 0.03,
115
+ "aT": 0.08,
116
+ "sT": 0.06,
117
+ "aM": 0.88,
118
+ "sM": 0.06,
119
+ "gap_scale": 0.22,
120
+ "d_max": 0.08,
121
+ "s_base": 0.05,
122
+ "s_gain": 0.08,
123
+ "f_bias": 0.20,
124
+ "t_task_bias": -0.08,
125
+ "soft_t_cap": 0.82,
126
+ "soft_pr_cap": 0.86,
127
+ },
128
+ "task2": {
129
+ "aL": 0.50, # Set to exact step 1 load sequence baseline
130
+ "sL": 0.00,
131
  "aD": 0.04,
132
+ "sD": 0.04,
133
+ "aT": 0.14,
134
  "sT": 0.10,
135
+ "aM": 0.78,
136
  "sM": 0.08,
137
+ "gap_scale": 0.16,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  "d_max": 0.10,
139
+ "s_base": 0.03,
140
+ "s_gain": 0.12,
141
+ "f_bias": 0.22,
142
+ "t_task_bias": -0.04,
143
+ "soft_t_cap": 0.84,
144
  "soft_pr_cap": 0.88,
145
  },
146
  "task3": {
147
  "aL": 0.70,
148
+ "sL": 0.00,
149
+ "aD": 0.08,
150
+ "sD": 0.06,
151
+ "aT": 0.72,
152
+ "sT": 0.12,
153
+ "aM": 0.60,
154
+ "sM": 0.08,
155
+ "gap_scale": 0.05, # Reduce gap further, so power is already high and generating heat
156
+ "d_max": 0.18,
157
+ "s_base": 0.20,
158
+ "s_gain": 0.18,
159
+ "f_bias": 0.12,
160
+ "t_task_bias": 0.35, # Extra direct heat
161
+ "soft_t_cap": 1.05, # Prevent environment from rescuing the initial state
162
+ "soft_pr_cap": 1.10,
163
  },
164
  "task4": {
165
  "aL": 0.60,
166
+ "sL": 0.00,
167
+ "aD": 0.18,
168
+ "sD": 0.08,
169
+ "aT": 0.22,
170
+ "sT": 0.10,
171
+ "aM": 0.68,
172
+ "sM": 0.08,
173
+ "gap_scale": 0.14,
174
+ "d_max": 0.32,
175
+ "s_base": 0.05,
176
+ "s_gain": 0.14,
177
+ "f_bias": 0.32,
178
+ "t_task_bias": 0.02,
179
+ "soft_t_cap": 0.85,
180
+ "soft_pr_cap": 0.89,
181
  },
182
  }
183