Sahil Tailor commited on
Commit
1632038
Β·
1 Parent(s): 2a9e6c4

Reverted and updated Inference.py

Browse files
Files changed (1) hide show
  1. inference.py +243 -394
inference.py CHANGED
@@ -1,5 +1,7 @@
1
  """
2
- inference.py β€” Satellite constellation RL submission entry point.
 
 
3
 
4
  Environment variables:
5
  API_BASE_URL β€” OpenAI-compatible endpoint base URL (required)
@@ -17,52 +19,47 @@ Environment variables:
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
32
  import textwrap
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,9 +100,9 @@ def read_int_env(name: str, default: int) -> int:
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)
@@ -115,334 +112,195 @@ STEP_TIMEOUT = read_float_env("STEP_TIMEOUT", 45.0) # per-step wall-clock li
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,46 +318,43 @@ def extract_response_text(completion: Any) -> str:
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,7 +363,7 @@ def validate_api_base_url(base_url: Optional[str]) -> Optional[str]:
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,28 +373,47 @@ def validate_api_base_url(base_url: Optional[str]) -> Optional[str]:
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(
@@ -562,84 +436,81 @@ async def choose_actions(
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,52 +520,30 @@ async def run_task(task_name: str, client: Optional[OpenAI]) -> TaskRunResult:
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.",
 
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
  API_BASE_URL β€” OpenAI-compatible endpoint base URL (required)
 
19
  Usage:
20
  API_BASE_URL=https://... API_KEY=hf_... MODEL_NAME=mistralai/... python inference.py
21
 
22
+ # Force heuristic baseline:
23
+ BASELINE_POLICY=heuristic python inference.py
24
+
25
+ # Timeouts:
26
  STEP_TIMEOUT=20 TASK_TIMEOUT=300 python inference.py
27
  """
28
  from __future__ import annotations
29
 
30
  import asyncio
 
31
  import json
 
32
  import os
33
  import re
34
  import sys
35
  import textwrap
 
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
 
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)
 
112
  TASK_TIMEOUT = read_float_env("TASK_TIMEOUT", 0.0) # per-task limit; 0 = no limit
113
  DEBUG = os.getenv("DEBUG", "false").lower() == "true"
114
 
115
+ FALLBACK_ACTION = "recharge"
116
  TASK_ORDER = ["easy", "medium", "hard"]
117
  TASK_TYPES = {"easy": EasyTask, "medium": MediumTask, "hard": HardTask}
118
 
119
+ VALID_ACTIONS = {"produce", "assemble", "deliver", "recharge"}
120
+ ACTION_PATTERN = re.compile(r"(produce|assemble|deliver|recharge)", re.IGNORECASE)
121
 
122
+ _SCORE_EPS = 1e-9 # keeps every score strictly inside (0, 1)
 
 
 
 
123
 
 
 
 
 
 
124
 
125
+ def _clamp_score(value: float) -> float:
126
+ """Clamp *value* to the open interval (0, 1) exclusive."""
127
+ return max(_SCORE_EPS, min(1.0 - _SCORE_EPS, float(value)))
128
+
129
+ # ── system prompt ──────────────────────────────────────────────────────────────
130
+ SYSTEM_PROMPT = textwrap.dedent("""
131
+ You are controlling orbital manufacturing platforms.
132
+ Each step, output ONLY a JSON object mapping platform IDs (as strings) to one of:
133
+ "produce", "assemble", "deliver", "recharge"
134
 
135
+ Decision guidance:
136
+ - recharge immediately if energy < 15
137
+ - deliver when product_stock > 0 and a delivery window is open
138
+ - assemble when component_stock >= 10 and product_stock < 5
139
+ - produce when material_stock >= 15 and component_stock < 30
140
+ - recharge when energy < 40 and no urgent action is available
141
+ - avoid invalid actions (e.g. assemble with no components)
142
+ - keep all platforms energy-healthy across the full episode
143
 
144
+ Output format (no explanation, no markdown):
145
+ {"0": "produce", "1": "assemble", "2": "deliver"}
146
+ """).strip()
147
 
148
 
149
+ # ── result dataclass ──────────────────────────────────────────────────────────
150
  @dataclass
151
  class TaskRunResult:
152
  task_name: str
153
+ score: float
154
+ total_reward: float
155
  steps: int
156
  done: bool
157
+ metrics: Dict[str, float]
158
 
159
 
160
  # ── observation helpers ────────────────────────────────────────────────────────
161
 
162
+ def observation_to_dict(obs: ManufacturingObservation) -> Dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  return {
164
+ "platforms": [
165
  {
166
+ "id": p.id,
167
+ "energy": p.energy,
168
+ "material_stock": p.material_stock,
169
+ "component_stock": p.component_stock,
170
+ "product_stock": p.product_stock,
171
+ "last_action": p.last_action,
172
  }
173
+ for p in obs.platforms
174
+ ],
175
+ "time_step": obs.time_step,
176
+ "delivery_windows": [
177
+ {"order_id": w.order_id, "product_type": w.product_type, "deadline": w.deadline}
178
+ for w in obs.delivery_windows
179
+ ],
180
+ "solar_conditions": obs.solar_conditions,
181
+ "pending_orders": [
182
+ {"order_id": o.order_id, "product_type": o.product_type,
183
+ "requires_assembly": o.requires_assembly}
184
+ for o in obs.pending_orders
185
  ],
186
+ "total_reward": obs.total_reward,
187
+ "done": obs.done,
188
+ "reward": obs.reward,
189
+ "metadata": obs.metadata,
 
 
 
 
190
  }
191
 
192
 
193
+ def build_idle_actions(obs_dict: Dict[str, Any]) -> Dict[int, str]:
194
+ return {int(p["id"]): FALLBACK_ACTION for p in obs_dict.get("platforms", [])}
 
 
 
 
 
 
195
 
196
 
197
  # ── heuristic policy ───────────────────────────────────────────────────────────
198
 
199
+ def heuristic_action(obs_dict: Dict[str, Any]) -> Dict[int, str]:
200
  actions: Dict[int, str] = {}
201
+ has_open_window = len(obs_dict.get("delivery_windows", [])) > 0
202
+
203
+ for p in obs_dict.get("platforms", []):
204
+ pid = int(p["id"])
205
+ energy = float(p["energy"])
206
+ mat = float(p["material_stock"])
207
+ comp = float(p["component_stock"])
208
+ prod = int(p["product_stock"])
209
+
210
+ if energy < 15.0:
211
+ action = "recharge"
212
+ elif prod > 0 and has_open_window:
213
+ action = "deliver"
214
+ elif comp >= 10.0 and prod < 5:
215
+ action = "assemble"
216
+ elif mat >= 15.0 and comp < 30.0:
217
+ action = "produce"
218
+ elif energy < 40.0:
219
+ action = "recharge"
220
+ elif mat >= 15.0:
221
+ action = "produce"
222
+ else:
223
+ action = "recharge"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
 
225
+ actions[pid] = action
226
  return actions
227
 
228
 
229
+ def safe_heuristic_action(obs_dict: Dict[str, Any], reason: str) -> Dict[int, str]:
230
  try:
231
+ return heuristic_action(obs_dict)
232
  except Exception as exc: # noqa: BLE001
233
  warn_once(
234
  f"heuristic:{reason}",
235
+ f"Heuristic fallback failed after {reason}: {exc}. Returning all-{FALLBACK_ACTION}.",
236
  )
237
+ return build_idle_actions(obs_dict)
238
 
239
 
240
  # ── prompt formatting ──────────────────────────────────────────────────────────
241
 
242
+ def build_history_lines(history: List[str]) -> str:
243
+ return "\n".join(history[-6:]) if history else "None"
244
+
245
+
246
+ def format_observation(task_name: str, obs_dict: Dict[str, Any]) -> str:
247
+ platforms_lines = []
248
+ for p in obs_dict.get("platforms", []):
249
+ platforms_lines.append(
250
+ f" [{p['id']}] energy={p['energy']:.1f} mat={p['material_stock']:.1f}"
251
+ f" comp={p['component_stock']:.1f} prod={p['product_stock']}"
252
+ f" last={p['last_action']}"
 
253
  )
254
 
255
+ windows_lines = []
256
+ for w in obs_dict.get("delivery_windows", []):
257
+ step_now = obs_dict.get("time_step", 0)
258
+ urgency = w["deadline"] - step_now
259
+ windows_lines.append(
260
+ f" order={w['order_id']} type={w['product_type']}"
261
+ f" deadline={w['deadline']} ({urgency} steps left)"
262
+ )
263
+
264
+ solar_str = ", ".join(
265
+ f"{z}={round(v * 100)}%"
266
+ for z, v in obs_dict.get("solar_conditions", {}).items()
 
267
  )
268
 
269
+ return textwrap.dedent(f"""
 
270
  Task: {task_name}
271
+ Time Step: {obs_dict.get('time_step', 0)}
272
+ Total Reward: {obs_dict.get('total_reward', 0.0):.2f}
273
+ Solar: {solar_str or 'n/a'}
274
+
275
+ Platforms:
276
+ {chr(10).join(platforms_lines) or ' None'}
277
 
278
+ Open Delivery Windows:
279
+ {chr(10).join(windows_lines) if windows_lines else ' None'}
280
 
281
+ Pending orders: {len(obs_dict.get('pending_orders', []))}
282
+ """).strip()
 
 
283
 
284
 
285
  def build_user_prompt(
286
  task_name: str,
287
  step: int,
288
+ obs_dict: Dict[str, Any],
289
  history: List[str],
290
  total_reward: float,
291
  ) -> str:
292
+ return textwrap.dedent(f"""
 
293
  Step: {step}
294
  Aggregate reward so far: {total_reward:+.2f}
295
 
296
  Current state:
297
+ {format_observation(task_name, obs_dict)}
298
 
299
  Previous steps:
300
  {build_history_lines(history)}
301
 
302
  Reply with exactly one JSON object.
303
+ """).strip()
 
304
 
305
 
306
  # ── model response parsing ────────────────────────────────────────────────────
 
318
  if isinstance(content, list):
319
  parts: List[str] = []
320
  for item in content:
321
+ text = item.get("text") if isinstance(item, dict) else getattr(item, "text", None)
 
 
 
322
  if text:
323
  parts.append(str(text))
324
  return "\n".join(parts)
325
  return str(content or "")
326
 
327
 
328
+ def parse_model_action(
329
+ response_text: str, obs_dict: Dict[str, Any]
330
+ ) -> Dict[int, str]:
331
  if not response_text:
332
+ return safe_heuristic_action(obs_dict, "empty model response")
 
 
333
 
334
  try:
335
+ json_match = re.search(r"\{.*\}", response_text.strip(), re.DOTALL)
336
  if json_match:
337
  parsed = json.loads(json_match.group(0))
338
+ valid_ids = {int(p["id"]) for p in obs_dict.get("platforms", [])}
339
  actions: Dict[int, str] = {}
 
340
  for key, value in parsed.items():
341
+ pid = int(key)
342
+ if pid not in valid_ids:
343
  continue
344
  action = str(value).strip().lower()
345
+ if action not in VALID_ACTIONS:
346
  action = FALLBACK_ACTION
347
+ actions[pid] = action
348
  if actions:
349
+ fallback = heuristic_action(obs_dict)
350
+ for pid in valid_ids:
351
+ actions.setdefault(pid, fallback.get(pid, FALLBACK_ACTION))
352
  return actions
353
  except (json.JSONDecodeError, TypeError, ValueError):
354
  pass
355
 
356
+ warn_once("parse:model-response", "Model response was not valid JSON; using heuristic.")
357
+ return safe_heuristic_action(obs_dict, "invalid model response")
358
 
359
 
360
  # ── client construction ────────────────────────────────────────────────────────
 
363
  if not base_url:
364
  return None
365
  cleaned = base_url.strip().rstrip("/")
366
+ parsed = urlparse(cleaned)
367
  if parsed.scheme not in {"http", "https"} or not parsed.netloc:
368
  warn_once(
369
  "config:api-base-url",
 
373
  return cleaned
374
 
375
 
376
+ def build_client() -> Optional[Any]:
377
+ if BASELINE_POLICY == "heuristic":
378
+ return None
379
+
380
+ if OpenAI is None:
381
+ warn_once("client:import", "openai package not installed; falling back to heuristic policy.")
382
+ return None
383
+
384
+ if not API_KEY:
385
+ warn_once(
386
+ "config:missing",
387
+ "OPENAI_API_KEY is not set. Falling back to heuristic policy.",
388
+ )
389
+ return None
390
+
391
+ validated_base = validate_api_base_url(API_BASE_URL) # None = use OpenAI default endpoint
392
+
393
+ try:
394
+ kwargs: Dict[str, Any] = {"api_key": API_KEY, "timeout": REQUEST_TIMEOUT}
395
+ if validated_base:
396
+ kwargs["base_url"] = validated_base
397
+ return OpenAI(**kwargs)
398
+ except Exception as exc: # noqa: BLE001
399
+ warn_once("client:init", f"Failed to build OpenAI client: {exc}. Using heuristic.")
400
+ return None
401
 
402
 
403
  # ── action chooser ─────────────────────────────────────────────────────────────
404
 
405
  async def choose_actions(
406
+ client: Optional[Any],
407
  task_name: str,
408
  step: int,
409
+ obs_dict: Dict[str, Any],
410
  history: List[str],
411
  total_reward: float,
412
  ) -> Dict[int, str]:
413
  if client is None:
414
+ return safe_heuristic_action(obs_dict, "heuristic mode")
415
 
416
+ user_prompt = build_user_prompt(task_name, step, obs_dict, history, total_reward)
417
 
418
  def _call() -> str:
419
  completion = client.chat.completions.create(
 
436
  except asyncio.TimeoutError:
437
  warn_once(
438
  f"timeout:{task_name}",
439
+ f"[{task_name}] Step {step} timed out after {STEP_TIMEOUT}s. Using heuristic.",
440
  )
441
+ return safe_heuristic_action(obs_dict, f"step timeout on {task_name} step {step}")
442
  except Exception as exc: # noqa: BLE001
443
  warn_once(
444
  f"model:{task_name}",
445
+ f"[{task_name}] Model request failed at step {step}: {exc}. Using heuristic.",
446
  )
447
+ return safe_heuristic_action(obs_dict, f"model failure on {task_name} step {step}")
448
 
449
  if DEBUG:
450
+ print(f"[DEBUG] [{task_name}] step={step} model_response={response_text[:300]!r}")
451
 
452
  try:
453
+ return parse_model_action(response_text, obs_dict)
454
  except Exception as exc: # noqa: BLE001
455
  warn_once(
456
  f"parse:{task_name}",
457
+ f"[{task_name}] Parse failed at step {step}: {exc}. Using heuristic.",
 
458
  )
459
+ return safe_heuristic_action(obs_dict, f"parse failure on {task_name} step {step}")
460
 
461
 
462
  # ── episode runner ─────────────────────────────────────────────────────────────
463
 
464
+ async def run_task(task_name: str, client: Optional[Any]) -> TaskRunResult:
465
+ env = ManufacturingTaskEnv(task_name=task_name)
466
+ grader = ManufacturingTaskGrader(task_name=task_name)
 
467
  history: List[str] = []
468
 
469
+ obs = env.reset()
470
+ state = env.state()
471
  step_limit = state.max_steps
472
+
473
+ print(f"[START] task={task_name} max_steps={step_limit}", flush=True)
474
 
475
  for step in range(1, step_limit + 1):
476
+ obs_dict = observation_to_dict(obs)
477
+ actions = await choose_actions(client, task_name, step, obs_dict, history, obs.total_reward)
478
+
479
+ # Wrap dict back into ManufacturingAction
480
+ action_obj = ManufacturingAction(platform_actions=actions)
481
+ obs, reward, done, info = env.step(action_obj)
 
 
 
482
 
 
 
 
483
  reward_value = float(reward.value)
484
  history.append(f"step {step}: {actions} -> reward {reward_value:+.2f}")
485
 
486
  print(
487
+ f"[STEP] task={task_name} step={step}/{step_limit}"
488
+ f" reward={reward_value:.4f} total={obs.total_reward:.4f}"
489
+ f" done={done}",
490
  flush=True,
491
  )
492
 
493
+ if REQUEST_DELAY > 0 and not done:
494
+ await asyncio.sleep(REQUEST_DELAY)
495
 
496
  if done:
497
  break
498
 
499
  final_state = env.state()
500
+ metrics = {k: float(v) for k, v in final_state.metrics.items()}
501
+ score = _clamp_score(grader.grade(metrics, final_state.step_count, final_state.platforms))
502
+
503
  print(
504
+ f"[END] task={task_name} score={score:.4f}"
505
+ f" steps={final_state.step_count} total_reward={final_state.total_reward:.4f}"
506
+ f" done={final_state.done}",
507
  flush=True,
508
  )
509
+
510
  return TaskRunResult(
511
  task_name=task_name,
512
+ score=score,
513
+ total_reward=final_state.total_reward,
514
  steps=final_state.step_count,
515
  done=final_state.done,
516
  metrics=metrics,
 
520
  # ── summary printer ────────────────────────────────────────────────────────────
521
 
522
  def print_summary(results: List[TaskRunResult]) -> None:
523
+ aggregate = sum(r.score for r in results) / len(results)
524
+ print("\nInference Summary")
525
+ print("=" * 60)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
526
  for r in results:
527
+ print(
528
+ f"{r.task_name:<8} score={r.score:.4f}"
529
+ f" reward={r.total_reward:.2f}"
530
+ f" steps={r.steps}"
531
+ f" done={r.done}"
532
+ )
533
+ print("-" * 60)
534
+ print(f"aggregate_score={aggregate:.4f}")
535
+ print("=" * 60)
536
 
537
 
538
  # ── async main ─────────────────────────────────────────────────────────────────
539
 
540
  async def async_main() -> None:
541
+ client = build_client()
542
  results = []
543
  for task_name in TASK_ORDER:
544
  if TASK_TIMEOUT > 0:
545
  try:
546
+ result = await asyncio.wait_for(run_task(task_name, client), timeout=TASK_TIMEOUT)
 
 
547
  except asyncio.TimeoutError:
548
  print(
549
  f"[TIMEOUT] task={task_name} exceeded {TASK_TIMEOUT}s; skipping.",