Sahil Tailor commited on
Commit
e234ceb
Β·
1 Parent(s): b96f305

updated inference.py

Browse files
Files changed (1) hide show
  1. inference.py +436 -249
inference.py CHANGED
@@ -1,33 +1,31 @@
1
  """
2
- inference.py β€” Space Manufacturing RL submission entry point.
3
-
4
- Default policy: OpenAI (falls back to heuristic if the client cannot be built).
5
 
6
  Environment variables:
7
- OPENAI_API_KEY β€” API key (required for OpenAI policy); alias: API_KEY
8
- OPENAI_BASE_URL β€” Custom base URL (optional, e.g. Azure / proxy); alias: API_BASE_URL
9
- MODEL_NAME β€” Model to use (default: gpt-4o-mini)
10
- BASELINE_POLICY β€” Force policy: "openai" (default) or "heuristic"
11
- TEMPERATURE β€” Sampling temperature (default: 0.0)
12
- MAX_TOKENS β€” Max tokens per response (default: 300)
13
- REQUEST_DELAY β€” Seconds to sleep between steps (default: 0.0)
14
- REQUEST_TIMEOUT β€” HTTP timeout in seconds (default: 30.0)
15
- DEBUG β€” Print raw model responses when "true"
 
 
16
 
17
  Usage:
18
- # OpenAI (default):
19
- OPENAI_API_KEY=sk-... python inference.py
20
-
21
- # Custom base URL / compatible provider:
22
- OPENAI_API_KEY=hf_... OPENAI_BASE_URL=https://... MODEL_NAME=mistralai/Mixtral-8x7B-Instruct-v0.1 python inference.py
23
 
24
- # Force heuristic baseline:
25
- BASELINE_POLICY=heuristic python inference.py
26
  """
27
  from __future__ import annotations
28
 
29
  import asyncio
 
30
  import json
 
31
  import os
32
  import re
33
  import sys
@@ -35,31 +33,36 @@ import textwrap
35
  import time
36
  from dataclasses import dataclass
37
  from pathlib import Path
38
- from typing import Any, Dict, List, Optional
39
  from urllib.parse import urlparse
40
 
41
- try:
42
- from openai import OpenAI
43
- except ImportError: # pragma: no cover
44
- OpenAI = None # type: ignore[assignment,misc]
45
 
46
  try:
47
  from dotenv import load_dotenv
48
  except Exception: # pragma: no cover
49
  load_dotenv = None # type: ignore[assignment]
50
 
51
- # ── package imports ────────────────────────────────────────────────────────────
52
- # inference.py is a script inside SpaceFactory/. Insert the parent directory so
53
- # the whole folder is importable as the 'SpaceFactory' package, which keeps all
54
- # relative imports inside the package working correctly.
55
- _pkg_parent = str(Path(__file__).resolve().parent.parent)
56
- if _pkg_parent not in sys.path:
57
- sys.path.insert(0, _pkg_parent)
58
-
59
- from SpaceFactory.env import ManufacturingTaskEnv
60
- from SpaceFactory.graders import ManufacturingTaskGrader
61
- from SpaceFactory.models import ManufacturingAction, ManufacturingObservation
62
- from SpaceFactory.tasks import EasyTask, HardTask, MediumTask
 
 
 
 
 
 
 
 
63
 
64
  if load_dotenv is not None:
65
  load_dotenv()
@@ -100,198 +103,346 @@ def read_int_env(name: str, default: int) -> int:
100
 
101
 
102
  # ── configuration ──────────────────────────────────────────────────────────────
103
- API_KEY = os.getenv("OPENAI_API_KEY") or os.getenv("API_KEY") or os.getenv("HF_TOKEN")
104
- API_BASE_URL = os.getenv("OPENAI_BASE_URL") or os.getenv("API_BASE_URL")
105
- MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4o-mini")
106
  BASELINE_POLICY = os.getenv("BASELINE_POLICY", "openai").lower()
107
  TEMPERATURE = read_float_env("TEMPERATURE", 0.0)
108
  MAX_TOKENS = read_int_env("MAX_TOKENS", 300)
109
  REQUEST_DELAY = read_float_env("REQUEST_DELAY", 0.0)
110
  REQUEST_TIMEOUT = read_float_env("REQUEST_TIMEOUT", 30.0)
 
 
111
  DEBUG = os.getenv("DEBUG", "false").lower() == "true"
112
 
113
- FALLBACK_ACTION = "recharge"
114
  TASK_ORDER = ["easy", "medium", "hard"]
115
  TASK_TYPES = {"easy": EasyTask, "medium": MediumTask, "hard": HardTask}
116
 
117
- VALID_ACTIONS = {"produce", "assemble", "deliver", "recharge"}
118
- ACTION_PATTERN = re.compile(r"(produce|assemble|deliver|recharge)", re.IGNORECASE)
119
 
120
  # ── system prompt ──────────────────────────────────────────────────────────────
121
- SYSTEM_PROMPT = textwrap.dedent("""
122
- You are controlling orbital manufacturing platforms.
123
- Each step, output ONLY a JSON object mapping platform IDs (as strings) to one of:
124
- "produce", "assemble", "deliver", "recharge"
 
 
 
 
 
 
125
 
126
  Decision guidance:
127
- - recharge immediately if energy < 15
128
- - deliver when product_stock > 0 and a delivery window is open
129
- - assemble when component_stock >= 10 and product_stock < 5
130
- - produce when material_stock >= 15 and component_stock < 30
131
- - recharge when energy < 40 and no urgent action is available
132
- - avoid invalid actions (e.g. assemble with no components)
133
- - keep all platforms energy-healthy across the full episode
134
 
135
- Output format (no explanation, no markdown):
136
- {"0": "produce", "1": "assemble", "2": "deliver"}
137
- """).strip()
138
 
 
 
 
139
 
140
- # ── result dataclass ──────────────────────────────────────────────────────────
 
141
  @dataclass
142
  class TaskRunResult:
143
  task_name: str
144
- score: float
145
- total_reward: float
146
  steps: int
147
  done: bool
148
- metrics: Dict[str, float]
149
 
150
 
151
  # ── observation helpers ────────────────────────────────────────────────────────
152
 
153
- def observation_to_dict(obs: ManufacturingObservation) -> Dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  return {
155
- "platforms": [
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  {
157
- "id": p.id,
158
- "energy": p.energy,
159
- "material_stock": p.material_stock,
160
- "component_stock": p.component_stock,
161
- "product_stock": p.product_stock,
162
- "last_action": p.last_action,
163
  }
164
- for p in obs.platforms
165
- ],
166
- "time_step": obs.time_step,
167
- "delivery_windows": [
168
- {"order_id": w.order_id, "product_type": w.product_type, "deadline": w.deadline}
169
- for w in obs.delivery_windows
170
  ],
171
- "solar_conditions": obs.solar_conditions,
172
- "pending_orders": [
173
- {"order_id": o.order_id, "product_type": o.product_type,
174
- "requires_assembly": o.requires_assembly}
175
- for o in obs.pending_orders
176
- ],
177
- "total_reward": obs.total_reward,
178
- "done": obs.done,
179
- "reward": obs.reward,
180
- "metadata": obs.metadata,
181
  }
182
 
183
 
184
- def build_idle_actions(obs_dict: Dict[str, Any]) -> Dict[int, str]:
185
- return {int(p["id"]): FALLBACK_ACTION for p in obs_dict.get("platforms", [])}
 
 
 
 
 
 
186
 
187
 
188
  # ── heuristic policy ───────────────────────────────────────────────────────────
189
 
190
- def heuristic_action(obs_dict: Dict[str, Any]) -> Dict[int, str]:
191
  actions: Dict[int, str] = {}
192
- has_open_window = len(obs_dict.get("delivery_windows", [])) > 0
193
-
194
- for p in obs_dict.get("platforms", []):
195
- pid = int(p["id"])
196
- energy = float(p["energy"])
197
- mat = float(p["material_stock"])
198
- comp = float(p["component_stock"])
199
- prod = int(p["product_stock"])
200
-
201
- if energy < 15.0:
202
- action = "recharge"
203
- elif prod > 0 and has_open_window:
204
- action = "deliver"
205
- elif comp >= 10.0 and prod < 5:
206
- action = "assemble"
207
- elif mat >= 15.0 and comp < 30.0:
208
- action = "produce"
209
- elif energy < 40.0:
210
- action = "recharge"
211
- elif mat >= 15.0:
212
- action = "produce"
213
- else:
214
- action = "recharge"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
 
216
- actions[pid] = action
217
  return actions
218
 
219
 
220
- def safe_heuristic_action(obs_dict: Dict[str, Any], reason: str) -> Dict[int, str]:
221
  try:
222
- return heuristic_action(obs_dict)
223
  except Exception as exc: # noqa: BLE001
224
  warn_once(
225
  f"heuristic:{reason}",
226
- f"Heuristic fallback failed after {reason}: {exc}. Returning all-{FALLBACK_ACTION}.",
227
  )
228
- return build_idle_actions(obs_dict)
229
 
230
 
231
  # ── prompt formatting ──────────────────────────────────────────────────────────
232
 
233
- def build_history_lines(history: List[str]) -> str:
234
- return "\n".join(history[-6:]) if history else "None"
235
-
236
-
237
- def format_observation(task_name: str, obs_dict: Dict[str, Any]) -> str:
238
- platforms_lines = []
239
- for p in obs_dict.get("platforms", []):
240
- platforms_lines.append(
241
- f" [{p['id']}] energy={p['energy']:.1f} mat={p['material_stock']:.1f}"
242
- f" comp={p['component_stock']:.1f} prod={p['product_stock']}"
243
- f" last={p['last_action']}"
244
- )
245
-
246
- windows_lines = []
247
- for w in obs_dict.get("delivery_windows", []):
248
- step_now = obs_dict.get("time_step", 0)
249
- urgency = w["deadline"] - step_now
250
- windows_lines.append(
251
- f" order={w['order_id']} type={w['product_type']}"
252
- f" deadline={w['deadline']} ({urgency} steps left)"
253
  )
254
 
255
- solar_str = ", ".join(
256
- f"{z}={round(v * 100)}%"
257
- for z, v in obs_dict.get("solar_conditions", {}).items()
 
 
 
 
 
 
 
 
 
 
258
  )
259
 
260
- return textwrap.dedent(f"""
 
261
  Task: {task_name}
262
- Time Step: {obs_dict.get('time_step', 0)}
263
- Total Reward: {obs_dict.get('total_reward', 0.0):.2f}
264
- Solar: {solar_str or 'n/a'}
265
-
266
- Platforms:
267
- {chr(10).join(platforms_lines) or ' None'}
268
 
269
- Open Delivery Windows:
270
- {chr(10).join(windows_lines) if windows_lines else ' None'}
271
 
272
- Pending orders: {len(obs_dict.get('pending_orders', []))}
273
- """).strip()
 
 
274
 
275
 
276
  def build_user_prompt(
277
  task_name: str,
278
  step: int,
279
- obs_dict: Dict[str, Any],
280
  history: List[str],
281
  total_reward: float,
282
  ) -> str:
283
- return textwrap.dedent(f"""
 
284
  Step: {step}
285
  Aggregate reward so far: {total_reward:+.2f}
286
 
287
  Current state:
288
- {format_observation(task_name, obs_dict)}
289
 
290
  Previous steps:
291
  {build_history_lines(history)}
292
 
293
  Reply with exactly one JSON object.
294
- """).strip()
 
295
 
296
 
297
  # ── model response parsing ────────────────────────────────────────────────────
@@ -309,43 +460,46 @@ def extract_response_text(completion: Any) -> str:
309
  if isinstance(content, list):
310
  parts: List[str] = []
311
  for item in content:
312
- text = item.get("text") if isinstance(item, dict) else getattr(item, "text", None)
 
 
 
313
  if text:
314
  parts.append(str(text))
315
  return "\n".join(parts)
316
  return str(content or "")
317
 
318
 
319
- def parse_model_action(
320
- response_text: str, obs_dict: Dict[str, Any]
321
- ) -> Dict[int, str]:
322
  if not response_text:
323
- return safe_heuristic_action(obs_dict, "empty model response")
 
 
324
 
325
  try:
326
- json_match = re.search(r"\{.*\}", response_text.strip(), re.DOTALL)
327
  if json_match:
328
  parsed = json.loads(json_match.group(0))
329
- valid_ids = {int(p["id"]) for p in obs_dict.get("platforms", [])}
330
  actions: Dict[int, str] = {}
 
331
  for key, value in parsed.items():
332
- pid = int(key)
333
- if pid not in valid_ids:
334
  continue
335
  action = str(value).strip().lower()
336
- if action not in VALID_ACTIONS:
337
  action = FALLBACK_ACTION
338
- actions[pid] = action
339
  if actions:
340
- fallback = heuristic_action(obs_dict)
341
- for pid in valid_ids:
342
- actions.setdefault(pid, fallback.get(pid, FALLBACK_ACTION))
343
  return actions
344
  except (json.JSONDecodeError, TypeError, ValueError):
345
  pass
346
 
347
- warn_once("parse:model-response", "Model response was not valid JSON; using heuristic.")
348
- return safe_heuristic_action(obs_dict, "invalid model response")
349
 
350
 
351
  # ── client construction ────────────────────────────────────────────────────────
@@ -354,7 +508,7 @@ def validate_api_base_url(base_url: Optional[str]) -> Optional[str]:
354
  if not base_url:
355
  return None
356
  cleaned = base_url.strip().rstrip("/")
357
- parsed = urlparse(cleaned)
358
  if parsed.scheme not in {"http", "https"} or not parsed.netloc:
359
  warn_once(
360
  "config:api-base-url",
@@ -364,48 +518,30 @@ def validate_api_base_url(base_url: Optional[str]) -> Optional[str]:
364
  return cleaned
365
 
366
 
367
- def build_client() -> Optional[Any]:
368
- if BASELINE_POLICY == "heuristic":
369
- return None
370
-
371
- if OpenAI is None:
372
- warn_once("client:import", "openai package not installed; falling back to heuristic policy.")
373
- return None
374
-
375
- if not API_KEY:
376
- warn_once(
377
- "config:missing",
378
- "OPENAI_API_KEY is not set. Falling back to heuristic policy.",
379
- )
380
- return None
381
-
382
- validated_base = validate_api_base_url(API_BASE_URL) # None = use OpenAI default endpoint
383
-
384
- try:
385
- kwargs: Dict[str, Any] = {"api_key": API_KEY, "timeout": REQUEST_TIMEOUT}
386
- if validated_base:
387
- kwargs["base_url"] = validated_base
388
- return OpenAI(**kwargs)
389
- except Exception as exc: # noqa: BLE001
390
- warn_once("client:init", f"Failed to build OpenAI client: {exc}. Using heuristic.")
391
- return None
392
 
393
 
394
  # ── action chooser ─────────────────────────────────────────────────────────────
395
 
396
- def choose_actions(
397
- client: Optional[Any],
398
  task_name: str,
399
  step: int,
400
- obs_dict: Dict[str, Any],
401
  history: List[str],
402
  total_reward: float,
403
  ) -> Dict[int, str]:
404
  if client is None:
405
- return safe_heuristic_action(obs_dict, "heuristic mode")
406
 
407
- user_prompt = build_user_prompt(task_name, step, obs_dict, history, total_reward)
408
- try:
 
409
  completion = client.chat.completions.create(
410
  model=MODEL_NAME,
411
  messages=[
@@ -415,79 +551,95 @@ def choose_actions(
415
  temperature=TEMPERATURE,
416
  max_tokens=MAX_TOKENS,
417
  )
418
- response_text = extract_response_text(completion)
 
 
 
 
 
 
 
 
 
 
 
 
 
419
  except Exception as exc: # noqa: BLE001
420
  warn_once(
421
  f"model:{task_name}",
422
- f"[{task_name}] Model request failed at step {step}: {exc}. Using heuristic.",
423
  )
424
- return safe_heuristic_action(obs_dict, f"model failure on {task_name} step {step}")
425
 
426
  if DEBUG:
427
- print(f"[DEBUG] [{task_name}] step={step} model_response={response_text[:300]!r}")
428
 
429
  try:
430
- return parse_model_action(response_text, obs_dict)
431
  except Exception as exc: # noqa: BLE001
432
  warn_once(
433
  f"parse:{task_name}",
434
- f"[{task_name}] Parse failed at step {step}: {exc}. Using heuristic.",
 
435
  )
436
- return safe_heuristic_action(obs_dict, f"parse failure on {task_name} step {step}")
437
 
438
 
439
  # ── episode runner ─────────────────────────────────────────────────────────────
440
 
441
- async def run_task(task_name: str, client: Optional[Any]) -> TaskRunResult:
442
- env = ManufacturingTaskEnv(task_name=task_name)
443
- grader = ManufacturingTaskGrader(task_name=task_name)
 
444
  history: List[str] = []
445
 
446
- obs = env.reset()
447
- state = env.state()
448
  step_limit = state.max_steps
449
-
450
- print(f"[START] task={task_name} max_steps={step_limit}", flush=True)
451
 
452
  for step in range(1, step_limit + 1):
453
- obs_dict = observation_to_dict(obs)
454
- actions = choose_actions(client, task_name, step, obs_dict, history, obs.total_reward)
455
-
456
- # Wrap dict back into ManufacturingAction
457
- action_obj = ManufacturingAction(platform_actions=actions)
458
- obs, reward, done, info = env.step(action_obj)
 
 
 
459
 
 
 
 
460
  reward_value = float(reward.value)
461
  history.append(f"step {step}: {actions} -> reward {reward_value:+.2f}")
462
 
463
  print(
464
- f"[STEP] task={task_name} step={step}/{step_limit}"
465
- f" reward={reward_value:.4f} total={obs.total_reward:.4f}"
466
- f" done={done}",
467
  flush=True,
468
  )
469
 
470
- if REQUEST_DELAY > 0 and not done:
471
  time.sleep(REQUEST_DELAY)
472
 
473
  if done:
474
  break
475
 
476
  final_state = env.state()
477
- metrics = {k: float(v) for k, v in final_state.metrics.items()}
478
- score = grader.grade(metrics, final_state.step_count, final_state.platforms)
479
-
480
  print(
481
- f"[END] task={task_name} score={score:.4f}"
482
- f" steps={final_state.step_count} total_reward={final_state.total_reward:.4f}"
483
- f" done={final_state.done}",
484
  flush=True,
485
  )
486
-
487
  return TaskRunResult(
488
  task_name=task_name,
489
- score=score,
490
- total_reward=final_state.total_reward,
491
  steps=final_state.step_count,
492
  done=final_state.done,
493
  metrics=metrics,
@@ -497,29 +649,64 @@ async def run_task(task_name: str, client: Optional[Any]) -> TaskRunResult:
497
  # ── summary printer ────────────────────────────────────────────────────────────
498
 
499
  def print_summary(results: List[TaskRunResult]) -> None:
500
- aggregate = sum(r.score for r in results) / len(results)
501
- print("\nInference Summary")
502
- print("=" * 60)
503
  for r in results:
504
- print(
505
- f"{r.task_name:<8} score={r.score:.4f}"
506
- f" reward={r.total_reward:.2f}"
507
- f" steps={r.steps}"
508
- f" done={r.done}"
509
- )
510
- print("-" * 60)
511
- print(f"aggregate_score={aggregate:.4f}")
512
- print("=" * 60)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
513
 
514
 
515
  # ── async main ─────────────────────────────────────────────────────────────────
516
 
517
  async def async_main() -> None:
518
- client = build_client()
519
  results = []
520
  for task_name in TASK_ORDER:
521
- results.append(await run_task(task_name, client))
522
- print_summary(results)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
523
 
524
 
525
  def main() -> None:
 
1
  """
2
+ inference.py β€” Satellite constellation RL submission entry point.
 
 
3
 
4
  Environment variables:
5
+ API_BASE_URL β€” OpenAI-compatible endpoint base URL (required)
6
+ API_KEY β€” API key (required)
7
+ MODEL_NAME β€” Model to use (required)
8
+ BASELINE_POLICY β€” Force policy: "openai" (default) or "heuristic"
9
+ TEMPERATURE β€” Sampling temperature (default: 0.0)
10
+ MAX_TOKENS β€” Max tokens per response (default: 300)
11
+ REQUEST_DELAY β€” Seconds to sleep between steps (default: 0.0)
12
+ REQUEST_TIMEOUT β€” HTTP timeout in seconds (default: 30.0)
13
+ STEP_TIMEOUT β€” Per-step inference wall-clock timeout in seconds (default: 45.0)
14
+ TASK_TIMEOUT β€” Per-task wall-clock timeout in seconds, 0 = no limit (default: 0.0)
15
+ DEBUG β€” Print raw model responses when "true"
16
 
17
  Usage:
18
+ API_BASE_URL=https://... API_KEY=hf_... MODEL_NAME=mistralai/... python inference.py
 
 
 
 
19
 
20
+ # Per-step and per-task timeouts:
21
+ STEP_TIMEOUT=20 TASK_TIMEOUT=300 python inference.py
22
  """
23
  from __future__ import annotations
24
 
25
  import asyncio
26
+ import importlib.util
27
  import json
28
+ import math
29
  import os
30
  import re
31
  import sys
 
33
  import time
34
  from dataclasses import dataclass
35
  from pathlib import Path
36
+ from typing import Any, Dict, List, Optional, Tuple
37
  from urllib.parse import urlparse
38
 
39
+ from openai import OpenAI
 
 
 
40
 
41
  try:
42
  from dotenv import load_dotenv
43
  except Exception: # pragma: no cover
44
  load_dotenv = None # type: ignore[assignment]
45
 
46
+ try:
47
+ from satellite import EasyTask, HardTask, MediumTask, SatelliteAction, SatelliteTaskEnv, TaskGrader
48
+ except ImportError: # pragma: no cover
49
+ package_root = Path(__file__).resolve().parent
50
+ spec = importlib.util.spec_from_file_location(
51
+ "satellite",
52
+ package_root / "__init__.py",
53
+ submodule_search_locations=[str(package_root)],
54
+ )
55
+ if spec is None or spec.loader is None:
56
+ raise
57
+ satellite = importlib.util.module_from_spec(spec)
58
+ sys.modules["satellite"] = satellite
59
+ spec.loader.exec_module(satellite)
60
+ EasyTask = satellite.EasyTask
61
+ HardTask = satellite.HardTask
62
+ MediumTask = satellite.MediumTask
63
+ SatelliteAction = satellite.SatelliteAction
64
+ SatelliteTaskEnv = satellite.SatelliteTaskEnv
65
+ TaskGrader = satellite.TaskGrader
66
 
67
  if load_dotenv is not None:
68
  load_dotenv()
 
103
 
104
 
105
  # ── configuration ──────────────────────────────────────────────────────────────
106
+ API_BASE_URL = os.environ["API_BASE_URL"]
107
+ API_KEY = os.environ["API_KEY"]
108
+ MODEL_NAME = os.getenv("MODEL_NAME","meta-llama/Llama-3.1-8B-Instruct:novita")
109
  BASELINE_POLICY = os.getenv("BASELINE_POLICY", "openai").lower()
110
  TEMPERATURE = read_float_env("TEMPERATURE", 0.0)
111
  MAX_TOKENS = read_int_env("MAX_TOKENS", 300)
112
  REQUEST_DELAY = read_float_env("REQUEST_DELAY", 0.0)
113
  REQUEST_TIMEOUT = read_float_env("REQUEST_TIMEOUT", 30.0)
114
+ STEP_TIMEOUT = read_float_env("STEP_TIMEOUT", 45.0) # per-step wall-clock limit
115
+ TASK_TIMEOUT = read_float_env("TASK_TIMEOUT", 0.0) # per-task limit; 0 = no limit
116
  DEBUG = os.getenv("DEBUG", "false").lower() == "true"
117
 
118
+ FALLBACK_ACTION = "idle"
119
  TASK_ORDER = ["easy", "medium", "hard"]
120
  TASK_TYPES = {"easy": EasyTask, "medium": MediumTask, "hard": HardTask}
121
 
122
+ ACTION_PATTERN = re.compile(r"(capture|downlink|maintain|idle)", re.IGNORECASE)
123
+ ACTION_PREFIX_RE = re.compile(r"^(action|next action)\s*[:\-]\s*", re.IGNORECASE)
124
 
125
  # ── system prompt ──────────────────────────────────────────────────────────────
126
+ SYSTEM_PROMPT = textwrap.dedent(
127
+ """
128
+ You are managing a real-world satellite constellation.
129
+ Reply with exactly one JSON object mapping satellite ids to actions.
130
+
131
+ Valid actions:
132
+ - capture
133
+ - downlink
134
+ - maintain
135
+ - idle
136
 
137
  Decision guidance:
138
+ - prefer capture only when a visible image task exists and the satellite has battery/storage margin
139
+ - prefer downlink when a visible ground station task exists, especially if storage is high
140
+ - use maintain to recover low-battery satellites before they become risky
141
+ - avoid invalid, repeated, or wasteful actions
142
+ - keep the fleet healthy across the full episode, not just the current step
 
 
143
 
144
+ Output format:
145
+ {"0": "capture", "1": "idle"}
 
146
 
147
+ Do not include explanations or any extra text outside the JSON object.
148
+ """
149
+ ).strip()
150
 
151
+
152
+ # ── result dataclass ───────────────────────────────────────────────────────────
153
  @dataclass
154
  class TaskRunResult:
155
  task_name: str
156
+ grade: float # 0.0 – 1.0 (primary output)
157
+ grade_components: Dict[str, float] # per-criterion, each 0.0 – 1.0
158
  steps: int
159
  done: bool
160
+ metrics: Dict[str, float] # raw env metrics (informational)
161
 
162
 
163
  # ── observation helpers ────────────────────────────────────────────────────────
164
 
165
+ def build_history_lines(history: List[str]) -> str:
166
+ if not history:
167
+ return "None"
168
+ return "\n".join(history[-6:])
169
+
170
+
171
+ def satellite_geo(position: Tuple[float, float, float]) -> Tuple[float, float, float]:
172
+ x, y, z = position
173
+ radius = math.sqrt((x * x) + (y * y) + (z * z))
174
+ if radius <= 0:
175
+ return 0.0, 0.0, 0.0
176
+ lat = math.degrees(math.asin(z / radius))
177
+ lon = math.degrees(math.atan2(y, x))
178
+ altitude = max(0.0, radius - 6371.0)
179
+ return float(lat), float(lon), float(altitude)
180
+
181
+
182
+ def visibility_radius_rad(altitude_km: float) -> float:
183
+ earth_radius_km = 6371.0
184
+ alt = max(0.0, altitude_km)
185
+ horizon = math.acos(min(1.0, earth_radius_km / (earth_radius_km + alt)))
186
+ return max(math.radians(35.0), min(math.radians(120.0), horizon + math.radians(50.0)))
187
+
188
+
189
+ def great_circle_distance_rad(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
190
+ lat1_rad = math.radians(lat1)
191
+ lon1_rad = math.radians(lon1)
192
+ lat2_rad = math.radians(lat2)
193
+ lon2_rad = math.radians(lon2)
194
+ d_lat = lat2_rad - lat1_rad
195
+ d_lon = lon2_rad - lon1_rad
196
+ a = (
197
+ math.sin(d_lat / 2.0) ** 2
198
+ + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(d_lon / 2.0) ** 2
199
+ )
200
+ return 2.0 * math.asin(min(1.0, math.sqrt(a)))
201
+
202
+
203
+ def normalize_pending_tasks(tasks: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
204
+ normalized = []
205
+ for task in tasks:
206
+ normalized.append({key: value for key, value in task.items()})
207
+ return normalized
208
+
209
+
210
+ def extract_capture_regions(observation: Dict[str, Any]) -> Dict[str, Tuple[float, float]]:
211
+ regions = observation.get("capture_regions")
212
+ if isinstance(regions, dict) and regions:
213
+ return {
214
+ str(name): (float(coords[0]), float(coords[1]))
215
+ for name, coords in regions.items()
216
+ if isinstance(coords, (list, tuple)) and len(coords) == 2
217
+ }
218
  return {
219
+ "region1": (18.5, 73.9),
220
+ "region2": (34.0, -117.0),
221
+ "region3": (-22.8, -43.2),
222
+ }
223
+
224
+
225
+ def find_visible_capture_task(
226
+ sat: Dict[str, Any],
227
+ observation: Dict[str, Any],
228
+ ) -> Optional[Dict[str, Any]]:
229
+ capture_regions = extract_capture_regions(observation)
230
+ sat_lat, sat_lon, sat_alt = satellite_geo(tuple(sat["position"]))
231
+ max_distance = visibility_radius_rad(sat_alt)
232
+ candidates: List[Tuple[float, str, Dict[str, Any]]] = []
233
+ weather = observation.get("weather_conditions", {})
234
+
235
+ for task in normalize_pending_tasks(observation.get("pending_tasks", [])):
236
+ if task.get("type") != "image_capture":
237
+ continue
238
+ region = str(task.get("region", ""))
239
+ if region not in capture_regions:
240
+ continue
241
+ reg_lat, reg_lon = capture_regions[region]
242
+ distance = great_circle_distance_rad(sat_lat, sat_lon, reg_lat, reg_lon)
243
+ if distance > max_distance:
244
+ continue
245
+ priority = float(task.get("priority", 1))
246
+ cloud = float(weather.get(region, 0.5))
247
+ score = (priority * 3.0) + ((1.0 - cloud) * 2.0) - distance
248
+ candidates.append((score, str(task.get("id", "")), task))
249
+
250
+ if not candidates:
251
+ return None
252
+ candidates.sort(key=lambda item: (-item[0], item[1]))
253
+ return candidates[0][2]
254
+
255
+
256
+ def find_visible_downlink_task(
257
+ sat: Dict[str, Any],
258
+ observation: Dict[str, Any],
259
+ ) -> Optional[Dict[str, Any]]:
260
+ stations = observation.get("ground_stations", [])
261
+ sat_lat, sat_lon, sat_alt = satellite_geo(tuple(sat["position"]))
262
+ max_distance = visibility_radius_rad(sat_alt)
263
+ candidates: List[Tuple[float, str, Dict[str, Any]]] = []
264
+
265
+ for task in normalize_pending_tasks(observation.get("pending_tasks", [])):
266
+ if task.get("type") != "data_downlink":
267
+ continue
268
+ station_id = int(task.get("station", 0))
269
+ if station_id < 0 or station_id >= len(stations):
270
+ continue
271
+ gs_lat, gs_lon = stations[station_id]
272
+ distance = great_circle_distance_rad(sat_lat, sat_lon, float(gs_lat), float(gs_lon))
273
+ if distance > max_distance:
274
+ continue
275
+ priority = float(task.get("priority", 1))
276
+ units_remaining = float(task.get("units_remaining", 20.0))
277
+ completion_bias = 0.75 if float(sat["storage"]) >= units_remaining else 0.0
278
+ score = (priority * 3.0) + completion_bias - distance
279
+ candidates.append((score, str(task.get("id", "")), task))
280
+
281
+ if not candidates:
282
+ return None
283
+ candidates.sort(key=lambda item: (-item[0], item[1]))
284
+ return candidates[0][2]
285
+
286
+
287
+ def observation_to_dict(observation: Any) -> Dict[str, Any]:
288
+ return {
289
+ "satellites": [
290
  {
291
+ "id": sat.id,
292
+ "position": sat.position,
293
+ "battery": sat.battery,
294
+ "storage": sat.storage,
295
+ "last_action": sat.last_action,
 
296
  }
297
+ for sat in observation.satellites
 
 
 
 
 
298
  ],
299
+ "time_step": observation.time_step,
300
+ "ground_stations": observation.ground_stations,
301
+ "weather_conditions": observation.weather_conditions,
302
+ "pending_tasks": observation.pending_tasks,
303
+ "total_reward": observation.total_reward,
304
+ "done": observation.done,
305
+ "reward": observation.reward,
306
+ "metadata": observation.metadata,
 
 
307
  }
308
 
309
 
310
+ def build_idle_actions(observation: Dict[str, Any]) -> Dict[int, str]:
311
+ actions: Dict[int, str] = {}
312
+ for sat in observation.get("satellites", []):
313
+ try:
314
+ actions[int(sat["id"])] = FALLBACK_ACTION
315
+ except (KeyError, TypeError, ValueError):
316
+ continue
317
+ return actions
318
 
319
 
320
  # ── heuristic policy ───────────────────────────────────────────────────────────
321
 
322
+ def heuristic_action(observation: Dict[str, Any]) -> Dict[int, str]:
323
  actions: Dict[int, str] = {}
324
+ pending_tasks = observation.get("pending_tasks", [])
325
+ has_capture_task = any(task.get("type") == "image_capture" for task in pending_tasks)
326
+ has_downlink_task = any(task.get("type") == "data_downlink" for task in pending_tasks)
327
+
328
+ for sat in observation.get("satellites", []):
329
+ sat_id = int(sat["id"])
330
+ battery = float(sat["battery"])
331
+ storage = float(sat["storage"])
332
+ visible_capture = find_visible_capture_task(sat, observation)
333
+ visible_downlink = find_visible_downlink_task(sat, observation)
334
+
335
+ if battery <= 12:
336
+ actions[sat_id] = "maintain"
337
+ continue
338
+ if battery < 28 and not visible_downlink:
339
+ actions[sat_id] = "maintain"
340
+ continue
341
+ if storage >= 85 and visible_downlink:
342
+ actions[sat_id] = "downlink"
343
+ continue
344
+ if visible_capture and battery >= 25 and storage <= 80:
345
+ actions[sat_id] = "capture"
346
+ continue
347
+ if visible_downlink and storage > 0:
348
+ actions[sat_id] = "downlink"
349
+ continue
350
+ if battery < 45 and not has_capture_task:
351
+ actions[sat_id] = "maintain"
352
+ continue
353
+ if battery < 35 and storage <= 5:
354
+ actions[sat_id] = "maintain"
355
+ continue
356
+ if has_downlink_task and storage >= 50:
357
+ actions[sat_id] = "idle"
358
+ continue
359
+ if has_capture_task and battery >= 30 and storage < 70:
360
+ actions[sat_id] = "idle"
361
+ continue
362
+ actions[sat_id] = "idle"
363
 
 
364
  return actions
365
 
366
 
367
+ def safe_heuristic_action(observation: Dict[str, Any], reason: str) -> Dict[int, str]:
368
  try:
369
+ return heuristic_action(observation)
370
  except Exception as exc: # noqa: BLE001
371
  warn_once(
372
  f"heuristic:{reason}",
373
+ f"Heuristic fallback failed after {reason}: {exc}. Returning all-idle actions.",
374
  )
375
+ return build_idle_actions(observation)
376
 
377
 
378
  # ── prompt formatting ──────────────────────────────────────────────────────────
379
 
380
+ def format_observation(task_name: str, observation: Dict[str, Any]) -> str:
381
+ satellites_info = []
382
+ for sat in observation.get("satellites", []):
383
+ sat_lat, sat_lon, sat_alt = satellite_geo(tuple(sat["position"]))
384
+ capture_task = find_visible_capture_task(sat, observation)
385
+ downlink_task = find_visible_downlink_task(sat, observation)
386
+ satellites_info.append(
387
+ f" Satellite {sat['id']}: battery={sat['battery']:.1f}, "
388
+ f"storage={sat['storage']:.1f}, last={sat['last_action']}, "
389
+ f"lat={sat_lat:.1f}, lon={sat_lon:.1f}, alt={sat_alt:.1f}km, "
390
+ f"capture_visible={capture_task is not None}, "
391
+ f"downlink_visible={downlink_task is not None}"
 
 
 
 
 
 
 
 
392
  )
393
 
394
+ tasks_info = []
395
+ for task in observation.get("pending_tasks", [])[:12]:
396
+ descriptor = task["type"]
397
+ if task["type"] == "image_capture":
398
+ descriptor += f" region={task.get('region')}"
399
+ if task["type"] == "data_downlink":
400
+ descriptor += f" station={task.get('station')}"
401
+ descriptor += f" units={task.get('units_remaining', 0)}"
402
+ tasks_info.append(f" - {descriptor} priority={task.get('priority', 1)}")
403
+
404
+ weather_info = ", ".join(
405
+ f"{region}={cover:.0%}"
406
+ for region, cover in observation.get("weather_conditions", {}).items()
407
  )
408
 
409
+ return textwrap.dedent(
410
+ f"""
411
  Task: {task_name}
412
+ Time Step: {observation.get('time_step', 0)}
413
+ Total Reward: {observation.get('total_reward', 0.0):.2f}
414
+ Weather: {weather_info or 'n/a'}
 
 
 
415
 
416
+ Satellites:
417
+ {chr(10).join(satellites_info) or ' None'}
418
 
419
+ Pending Tasks:
420
+ {chr(10).join(tasks_info) or ' - None'}
421
+ """
422
+ ).strip()
423
 
424
 
425
  def build_user_prompt(
426
  task_name: str,
427
  step: int,
428
+ observation: Dict[str, Any],
429
  history: List[str],
430
  total_reward: float,
431
  ) -> str:
432
+ return textwrap.dedent(
433
+ f"""
434
  Step: {step}
435
  Aggregate reward so far: {total_reward:+.2f}
436
 
437
  Current state:
438
+ {format_observation(task_name, observation)}
439
 
440
  Previous steps:
441
  {build_history_lines(history)}
442
 
443
  Reply with exactly one JSON object.
444
+ """
445
+ ).strip()
446
 
447
 
448
  # ── model response parsing ────────────────────────────────────────────────────
 
460
  if isinstance(content, list):
461
  parts: List[str] = []
462
  for item in content:
463
+ if isinstance(item, dict):
464
+ text = item.get("text")
465
+ else:
466
+ text = getattr(item, "text", None)
467
  if text:
468
  parts.append(str(text))
469
  return "\n".join(parts)
470
  return str(content or "")
471
 
472
 
473
+ def parse_model_action(response_text: str, observation: Dict[str, Any]) -> Dict[int, str]:
 
 
474
  if not response_text:
475
+ return safe_heuristic_action(observation, "empty model response")
476
+
477
+ cleaned = ACTION_PREFIX_RE.sub("", response_text.strip())
478
 
479
  try:
480
+ json_match = re.search(r"\{.*\}", cleaned, re.DOTALL)
481
  if json_match:
482
  parsed = json.loads(json_match.group(0))
 
483
  actions: Dict[int, str] = {}
484
+ valid_ids = {int(sat["id"]) for sat in observation.get("satellites", [])}
485
  for key, value in parsed.items():
486
+ sat_id = int(key)
487
+ if sat_id not in valid_ids:
488
  continue
489
  action = str(value).strip().lower()
490
+ if not ACTION_PATTERN.fullmatch(action):
491
  action = FALLBACK_ACTION
492
+ actions[sat_id] = action
493
  if actions:
494
+ fallback = heuristic_action(observation)
495
+ for sat_id in valid_ids:
496
+ actions.setdefault(sat_id, fallback.get(sat_id, FALLBACK_ACTION))
497
  return actions
498
  except (json.JSONDecodeError, TypeError, ValueError):
499
  pass
500
 
501
+ warn_once("parse:model-response", "Model response was not valid JSON; using heuristic actions.")
502
+ return safe_heuristic_action(observation, "invalid model response")
503
 
504
 
505
  # ── client construction ────────────────────────────────────────────────────────
 
508
  if not base_url:
509
  return None
510
  cleaned = base_url.strip().rstrip("/")
511
+ parsed = urlparse(cleaned)
512
  if parsed.scheme not in {"http", "https"} or not parsed.netloc:
513
  warn_once(
514
  "config:api-base-url",
 
518
  return cleaned
519
 
520
 
521
+ def build_client() -> OpenAI:
522
+ return OpenAI(
523
+ base_url=API_BASE_URL,
524
+ api_key=API_KEY,
525
+ timeout=REQUEST_TIMEOUT,
526
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
527
 
528
 
529
  # ── action chooser ─────────────────────────────────────────────────────────────
530
 
531
+ async def choose_actions(
532
+ client: Optional[OpenAI],
533
  task_name: str,
534
  step: int,
535
+ observation: Dict[str, Any],
536
  history: List[str],
537
  total_reward: float,
538
  ) -> Dict[int, str]:
539
  if client is None:
540
+ raise RuntimeError("OpenAI client not initialized")
541
 
542
+ user_prompt = build_user_prompt(task_name, step, observation, history, total_reward)
543
+
544
+ def _call() -> str:
545
  completion = client.chat.completions.create(
546
  model=MODEL_NAME,
547
  messages=[
 
551
  temperature=TEMPERATURE,
552
  max_tokens=MAX_TOKENS,
553
  )
554
+ return extract_response_text(completion)
555
+
556
+ try:
557
+ loop = asyncio.get_event_loop()
558
+ response_text = await asyncio.wait_for(
559
+ loop.run_in_executor(None, _call),
560
+ timeout=STEP_TIMEOUT,
561
+ )
562
+ except asyncio.TimeoutError:
563
+ warn_once(
564
+ f"timeout:{task_name}",
565
+ f"[{task_name}] Step {step} timed out after {STEP_TIMEOUT}s. Using heuristic actions.",
566
+ )
567
+ return safe_heuristic_action(observation, f"step timeout on {task_name} step {step}")
568
  except Exception as exc: # noqa: BLE001
569
  warn_once(
570
  f"model:{task_name}",
571
+ f"[{task_name}] Model request failed at step {step}: {exc}. Using heuristic actions.",
572
  )
573
+ return safe_heuristic_action(observation, f"model request failure on {task_name} step {step}")
574
 
575
  if DEBUG:
576
+ print(f"[{task_name}] model response: {response_text[:300]}")
577
 
578
  try:
579
+ return parse_model_action(response_text, observation)
580
  except Exception as exc: # noqa: BLE001
581
  warn_once(
582
  f"parse:{task_name}",
583
+ f"[{task_name}] Failed to parse model response at step {step}: {exc}. "
584
+ "Using heuristic actions.",
585
  )
586
+ return safe_heuristic_action(observation, f"parse failure on {task_name} step {step}")
587
 
588
 
589
  # ── episode runner ─────────────────────────────────────────────────────────────
590
 
591
+ async def run_task(task_name: str, client: Optional[OpenAI]) -> TaskRunResult:
592
+ env = SatelliteTaskEnv(task_name=task_name)
593
+ task = TASK_TYPES[task_name]()
594
+ grader = TaskGrader(task)
595
  history: List[str] = []
596
 
597
+ observation = env.reset()
598
+ state = env.state()
599
  step_limit = state.max_steps
600
+ print(f"[START] task={task_name}", flush=True)
 
601
 
602
  for step in range(1, step_limit + 1):
603
+ obs_dict = observation_to_dict(observation)
604
+ actions = await choose_actions(
605
+ client,
606
+ task_name,
607
+ step,
608
+ obs_dict,
609
+ history,
610
+ observation.total_reward,
611
+ )
612
 
613
+ observation, reward, done, info = env.step(
614
+ SatelliteAction(satellite_actions=actions)
615
+ )
616
  reward_value = float(reward.value)
617
  history.append(f"step {step}: {actions} -> reward {reward_value:+.2f}")
618
 
619
  print(
620
+ f"[STEP] step={step} reward={reward_value:.4f} total={observation.total_reward:.4f}",
 
 
621
  flush=True,
622
  )
623
 
624
+ if REQUEST_DELAY > 0 and step < step_limit and not done:
625
  time.sleep(REQUEST_DELAY)
626
 
627
  if done:
628
  break
629
 
630
  final_state = env.state()
631
+ metrics = {key: float(value) for key, value in final_state.metrics.items()}
632
+ grade = grader.grade_episode(env)
633
+ grade_components = grader.grade_components(env)
634
  print(
635
+ f"[END] task={task_name} grade={grade:.4f} "
636
+ f"steps={final_state.step_count} done={final_state.done}",
 
637
  flush=True,
638
  )
 
639
  return TaskRunResult(
640
  task_name=task_name,
641
+ grade=grade,
642
+ grade_components=grade_components,
643
  steps=final_state.step_count,
644
  done=final_state.done,
645
  metrics=metrics,
 
649
  # ── summary printer ────────────────────────────────────────────────────────────
650
 
651
  def print_summary(results: List[TaskRunResult]) -> None:
652
+ aggregate = sum(r.grade for r in results) / len(results)
653
+ all_keys: list = []
 
654
  for r in results:
655
+ for k in r.grade_components:
656
+ if k not in all_keys:
657
+ all_keys.append(k)
658
+
659
+ label_map = getattr(
660
+ __import__("graders", fromlist=["TaskGrader"]).TaskGrader, "CRITERION_LABELS", {}
661
+ )
662
+
663
+ col_w = 10
664
+ header_parts = [f"{'task':<8}", f"{'grade':>7}"]
665
+ for k in all_keys:
666
+ label = label_map.get(k, k)[:col_w]
667
+ header_parts.append(f"{label:>{col_w}}")
668
+ header_parts.append(f"{'steps':>6}")
669
+
670
+ sep_width = 8 + 7 + col_w * len(all_keys) + 6 + len(all_keys) * 2 + 10
671
+ print("\nInference Grade Summary (all values 0.0 – 1.0)")
672
+ print("=" * sep_width)
673
+ print(" ".join(header_parts))
674
+ print("-" * sep_width)
675
+ for r in results:
676
+ row = [f"{r.task_name:<8}", f"{r.grade:>7.4f}"]
677
+ for k in all_keys:
678
+ v = r.grade_components.get(k, float("nan"))
679
+ row.append(f"{v:>{col_w}.4f}")
680
+ row.append(f"{r.steps:>6}")
681
+ print(" ".join(row))
682
+ print("-" * sep_width)
683
+ print(f" {'aggregate':<8} {aggregate:>7.4f}")
684
+ print("=" * sep_width)
685
 
686
 
687
  # ── async main ─────────────────────────────────────────────────────────────────
688
 
689
  async def async_main() -> None:
690
+ client = build_client()
691
  results = []
692
  for task_name in TASK_ORDER:
693
+ if TASK_TIMEOUT > 0:
694
+ try:
695
+ result = await asyncio.wait_for(
696
+ run_task(task_name, client), timeout=TASK_TIMEOUT
697
+ )
698
+ except asyncio.TimeoutError:
699
+ print(
700
+ f"[TIMEOUT] task={task_name} exceeded {TASK_TIMEOUT}s; skipping.",
701
+ file=sys.stderr,
702
+ flush=True,
703
+ )
704
+ continue
705
+ else:
706
+ result = await run_task(task_name, client)
707
+ results.append(result)
708
+ if results:
709
+ print_summary(results)
710
 
711
 
712
  def main() -> None: