CrisisWorldCortex / inference.py
Angshuman28's picture
Upload folder using huggingface_hub
c1c4162 verified
Raw
History Blame Contribute Delete
19 kB
# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the BSD-style license found in the
# LICENSE file in the root directory of this source tree.
"""
Inference harness for CrisisWorldCortex (Session 7b).
Runs B1 (single-LLM-call-per-tick) against the env over the HTTP client,
emits the byte-for-byte stdout protocol the hackathon validator expects:
[START] task=<task> env=<env> model=<model>
[STEP] step=<N> action=<str> reward=<r:.2f> done=<true|false> error=<error|null>
[END] success=<true|false> steps=<N> score=<s:.3f> rewards=<r1:.2f,r2:.2f,...>
Required env vars:
HF_TOKEN - HF Router / OpenAI API key. No default.
LOCAL_IMAGE_NAME - Docker image (Docker mode), OR
ENV_URL - HF Spaces URL (Spaces mode).
One of LOCAL_IMAGE_NAME / ENV_URL must be set.
Optional env vars:
API_BASE_URL - default https://router.huggingface.co/v1
MODEL_NAME - default Qwen/Qwen2.5-72B-Instruct
Task ladder (3 tasks, restored in Session 7c).
- outbreak_easy seed=0 max_ticks=12
- outbreak_medium seed=1 max_ticks=12
- outbreak_hard seed=2 max_ticks=12
CrisisworldcortexEnvironment.reset() now accepts task_name/seed/
max_ticks kwargs; the framework's ResetRequest already supports
arbitrary kwargs via extra="allow", so the wire path needs no
schema changes.
Score formula (Session 7a §7 + 7b §9.4 revision): see compute_score.
"""
from __future__ import annotations
import argparse
import os
import sys
from dataclasses import dataclass
from typing import Any, Dict, List, Literal, Optional
from baselines.flat_agent import B1FlatAgent, B1StepEvent
from cortex.llm_client import LLMClient
from CrisisWorldCortex.models import OuterActionPayload
# Terminal bonus constants (inlined from server/graders to avoid import-graph
# violation — inference.py must not import server.simulator or server.graders).
_TERMINAL_BONUS_SUCCESS = 0.20
_TERMINAL_BONUS_FAILURE = -0.20
AgentKind = Literal["b1", "b2", "b3", "b6"]
_AGENT_CHOICES: tuple = ("b1", "b2", "b3", "b6")
# ============================================================================
# Constants
# ============================================================================
BENCHMARK = "CrisisWorldCortex"
SUCCESS_THRESHOLD = 0.5
DEFAULT_API_BASE_URL = "https://router.huggingface.co/v1"
DEFAULT_MODEL = "Qwen/Qwen2.5-72B-Instruct"
# Three-task ladder restored in Session 7c (env.reset(task_name=...) is now
# wired through). Difficulty progression: easy -> medium -> hard, with
# distinct seeds per task for cross-episode reproducibility.
TASK_CONFIGS: List[dict] = [
{"task_name": "outbreak_easy", "seed": 0, "max_ticks": 12},
{"task_name": "outbreak_medium", "seed": 1, "max_ticks": 12},
{"task_name": "outbreak_hard", "seed": 2, "max_ticks": 12},
]
# Score-clamp bounds keep .3f formatting strictly inside (0, 1) so the
# validator's distribution check never sees a "0.000"/"1.000" round-down.
SCORE_LOWER_CLAMP = 1e-3
SCORE_UPPER_CLAMP = 1.0 - 1e-3
# ============================================================================
# Step record + line formatters
# ============================================================================
@dataclass(frozen=True)
class StepRecord:
"""One per-tick log entry. Frozen so it can't be mutated mid-render."""
step: int
action_str: str
reward: float
done: bool
error: Optional[str]
def _format_start_line(task_name: str, env_name: str, model_name: str) -> str:
return f"[START] task={task_name} env={env_name} model={model_name}"
def _format_step_line(record: StepRecord) -> str:
error_val = record.error if record.error else "null"
done_val = str(record.done).lower()
return (
f"[STEP] step={record.step} action={record.action_str} "
f"reward={record.reward:.2f} done={done_val} error={error_val}"
)
def _format_end_line(
success: bool,
steps: int,
score: float,
rewards: List[float],
) -> str:
rewards_str = ",".join(f"{r:.2f}" for r in rewards)
return (
f"[END] success={str(success).lower()} steps={steps} "
f"score={score:.3f} rewards={rewards_str}"
)
# ============================================================================
# Action -> compact string (for [STEP] line)
# ============================================================================
def action_to_str(payload: OuterActionPayload) -> str:
"""One-token-ish summary keyed by ``kind``. Quantity/amount/honesty
are intentionally dropped to keep the [STEP] line short — the
validator only needs to see WHICH action ran, not its parameters."""
kind = payload.kind
if kind == "deploy_resource":
return f"deploy_resource:{payload.region}:{payload.resource_type}"
if kind == "request_data":
return f"request_data:{payload.region}:{payload.data_type}"
if kind == "restrict_movement":
return f"restrict_movement:{payload.region}:{payload.severity}"
if kind == "escalate":
return f"escalate:{payload.to_authority}"
if kind == "reallocate_budget":
return f"reallocate_budget:{payload.from_resource}:{payload.to_resource}"
# no_op, public_communication: just the kind.
return kind
# ============================================================================
# Score
# ============================================================================
def compute_score(rewards: List[float], terminal_bonus_value: float) -> float:
"""Compute episode score per design §14.3.
Linear rescale of natural [-1.20, 1.20] range to [0, 1] before clamping.
The natural range arises from outer_reward in [-1.0, 1.0] (post-Phase-1)
plus terminal_bonus in [-0.20, +0.20].
Empty-rewards case returns the lower clamp (1e-3) — a coarse failure
signal. Session 14 (eval) will refine "env-failed-to-reset" vs
"agent-did-nothing" distinctions.
"""
if not rewards:
return SCORE_LOWER_CLAMP
raw = sum(rewards) / len(rewards) + terminal_bonus_value
rescaled = (raw + 1.20) / 2.40
return min(max(rescaled, SCORE_LOWER_CLAMP), SCORE_UPPER_CLAMP)
# ============================================================================
# Pure-function formatter (test path)
# ============================================================================
def format_episode_trace(
task_name: str,
model_name: str,
steps: List[StepRecord],
final_state: Any,
) -> str:
"""Render the full ``[START] / [STEP]xN / [END]`` block as a string.
Used by tests to validate format-string shape on synthetic traces.
Production (``main()``) doesn't call this — it streams the line
helpers directly so per-tick output flushes in real time. Both paths
share ``_format_*_line`` so the string format can't drift.
Computes ``terminal_bonus`` from ``final_state.terminal`` inline
(constants inlined from server/graders to avoid import-graph violation).
"""
rewards = [s.reward for s in steps]
terminal = getattr(final_state, "terminal", "none")
if terminal == "success":
bonus = _TERMINAL_BONUS_SUCCESS
elif terminal == "failure":
bonus = _TERMINAL_BONUS_FAILURE
else:
bonus = 0.0
score = compute_score(rewards, terminal_bonus_value=bonus)
success = score >= SUCCESS_THRESHOLD
lines: List[str] = [
_format_start_line(task_name, BENCHMARK, model_name),
]
for record in steps:
lines.append(_format_step_line(record))
lines.append(
_format_end_line(
success=success,
steps=len(steps),
score=score,
rewards=rewards,
)
)
return "\n".join(lines)
# ============================================================================
# Env construction
# ============================================================================
_DOCKER_READY_TIMEOUT_S = 120.0
def _sync_if_available(env: Any) -> Any:
"""OpenEnv 0.2.2+ exposes .sync(); 0.2.1 reset/step are already sync."""
sync = getattr(env, "sync", None)
return sync() if callable(sync) else env
def _make_env_from_docker(image_name: str) -> Any:
"""Spin up Docker container, return a sync wrapper.
Mirrors triagesieve_env's manual ``LocalDockerProvider`` pattern
rather than ``EnvClient.from_docker_image`` because the convenience
constructor's default 30s ``wait_for_ready`` is too tight on Windows
Docker Desktop after a cold image build (Session 7c smoke timed out
at 30s; first-start commonly takes 45–90s here). 120s gives ample
headroom without papering over a real hang.
OpenEnv 0.2.2+ returns an async client with a ``.sync()`` adapter.
OpenEnv 0.2.1 exposes synchronous ``reset()`` / ``step()`` directly.
We still call ``connect()`` because both API shapes expose it.
"""
from openenv.core.containers.runtime.providers import LocalDockerProvider
from CrisisWorldCortex import CrisisworldcortexEnv
provider = LocalDockerProvider()
base_url = provider.start_container(image_name)
provider.wait_for_ready(base_url, timeout_s=_DOCKER_READY_TIMEOUT_S)
async_client = CrisisworldcortexEnv(base_url=base_url, provider=provider)
sync_env = _sync_if_available(async_client)
sync_env.connect()
return sync_env
def _make_env_from_spaces(base_url: str) -> Any:
"""Connect to an already-running env at ``base_url`` (HF Spaces or
any reachable OpenEnv server). Returns a sync wrapper.
OpenEnv version differences are handled by ``_sync_if_available``.
"""
from CrisisWorldCortex import CrisisworldcortexEnv
return _sync_if_available(CrisisworldcortexEnv(base_url=base_url))
# ============================================================================
# Episode loop — delegates to the selected agent's run_episode(step_callback=...)
# ============================================================================
class _SyncEnvAdapter:
"""Bridges the HTTP/sync env client (returns ``StepResult``) to
B1FlatAgent's expected env shape (``reset() -> obs``, ``step(action)
-> obs``).
Pre-binds task-selection kwargs for the wire-level reset call. After
each operation, copies ``result.reward`` and ``result.done`` from the
StepResult wrapper onto the observation, since B1's loop reads them
off ``obs`` directly.
"""
def __init__(self, env: Any, *, reset_kwargs: Dict[str, Any]) -> None:
self._env = env
self._reset_kwargs = dict(reset_kwargs)
def reset(self) -> Any:
result = self._env.reset(**self._reset_kwargs)
return self._normalize(result)
def step(self, action: Any) -> Any:
result = self._env.step(action)
return self._normalize(result)
@staticmethod
def _normalize(result: Any) -> Any:
# Some shapes: StepResult{observation, reward, done} (HTTP client)
# or a bare observation (in-process). Try .observation; fall back
# to result itself.
obs = getattr(result, "observation", result)
wrapper_reward = getattr(result, "reward", None)
if wrapper_reward is not None:
obs.reward = float(wrapper_reward)
wrapper_done = getattr(result, "done", None)
if wrapper_done is not None:
obs.done = bool(wrapper_done)
return obs
def _make_agent(kind: str, env: Any, llm: Any, *, cortex_router: Optional[str] = None) -> Any:
"""Construct the B1/B2/B3/B6 agent for ``kind``.
All agents share the ``(env, llm)`` constructor shape and
expose ``run_episode(task, seed, max_ticks, *, step_callback)`` per
Phase A Decision 54. Lazy imports for B2/B3 keep the cold-start cost
of the default B1 path unchanged; B6 additionally receives the trained
router LoRA repo id.
"""
if kind == "b1":
return B1FlatAgent(env=env, llm=llm)
if kind == "b2":
from baselines.flat_agent_matched_compute import B2MatchedComputeAgent
return B2MatchedComputeAgent(env=env, llm=llm)
if kind == "b3":
from baselines.cortex_fixed_router import B3CortexFixedRouter
return B3CortexFixedRouter(env=env, llm=llm)
if kind == "b6":
if not cortex_router:
raise ValueError("--cortex-router is required when --agent b6")
from baselines.cortex_trained_router import B6CortexTrainedRouter
return B6CortexTrainedRouter(env=env, llm=llm, router_repo=cortex_router)
raise ValueError(f"unknown agent kind: {kind!r}; expected one of {_AGENT_CHOICES}")
def _build_argparser() -> argparse.ArgumentParser:
"""Argparse for inference.py CLI flags. Default --agent=b1 keeps the
pre-Session-13 invocation working for the existing eval suite."""
parser = argparse.ArgumentParser(
prog="inference",
description="CrisisWorldCortex inference harness (B1/B2/B3/B6 dispatch).",
)
parser.add_argument(
"--agent",
choices=_AGENT_CHOICES,
default="b1",
help=(
"Agent to run: b1 (flat), b2 (matched-compute), "
"b3 (cortex+deterministic-router), b6 (cortex+trained-router)."
),
)
parser.add_argument(
"--cortex-router",
default=None,
help="HF model repo containing the trained B6 Cortex router LoRA adapter.",
)
return parser
def _run_episode(
env: Any,
llm: LLMClient,
task_name: str,
seed: int,
model_name: str,
max_ticks: int,
agent_kind: str = "b1",
cortex_router: Optional[str] = None,
) -> dict:
"""Stream one episode end-to-end via ``<Agent>.run_episode``.
The agent owns the per-tick LLM-call + parse + env.step loop; this
harness owns the [START] / [STEP] / [END] stdout protocol via a
callback. Net effect of the Session 8 refactor: ~80 LOC drop here.
"""
print(_format_start_line(task_name, BENCHMARK, model_name), flush=True)
rewards: List[float] = []
parse_failure_count = 0
def step_cb(ev: B1StepEvent) -> None:
nonlocal parse_failure_count
rewards.append(ev.reward)
if ev.parse_failure:
parse_failure_count += 1
print(
_format_step_line(
StepRecord(
step=ev.tick,
action_str=action_to_str(ev.action),
reward=ev.reward,
done=ev.done,
error=ev.error,
)
),
flush=True,
)
adapter = _SyncEnvAdapter(
env,
reset_kwargs={"task_name": task_name, "seed": seed, "max_ticks": max_ticks},
)
agent = _make_agent(agent_kind, adapter, llm, cortex_router=cortex_router)
try:
traj = agent.run_episode(
task=task_name,
seed=seed,
max_ticks=max_ticks,
step_callback=step_cb,
)
except Exception as exc: # pragma: no cover - exercised manually
print(f"[ERROR] episode failed: {exc!r}", file=sys.stderr, flush=True)
# Coarse failure signal: empty rewards -> lower-clamp score.
score = compute_score([], terminal_bonus_value=0.0)
print(_format_end_line(False, 0, score, []), flush=True)
return {
"task": task_name,
"steps_taken": 0,
"score": score,
"success": False,
"rewards": [],
"parse_failure_count": 0,
}
# Harness can't read state.terminal over the wire — pass 0.0. The
# trainer (Session 14, reward_shaping.py) composes the real bonus
# from server-side state, not from this stdout score.
score = compute_score(rewards, terminal_bonus_value=0.0)
success = score >= SUCCESS_THRESHOLD
print(
_format_end_line(
success=success,
steps=traj["steps_taken"],
score=score,
rewards=rewards,
),
flush=True,
)
return {
"task": task_name,
"steps_taken": traj["steps_taken"],
"score": score,
"success": success,
"rewards": rewards,
"parse_failure_count": parse_failure_count,
"tokens": traj.get("tokens_total", 0),
}
# ============================================================================
# Main
# ============================================================================
def main() -> None:
"""Entry point for ``uv run python inference.py`` and the validator."""
args = _build_argparser().parse_args()
api_base_url = os.getenv("API_BASE_URL", DEFAULT_API_BASE_URL)
model_name = os.getenv("MODEL_NAME", DEFAULT_MODEL)
hf_token = os.getenv("HF_TOKEN")
local_image_name = os.getenv("LOCAL_IMAGE_NAME")
env_url = os.getenv("ENV_URL")
if not hf_token:
raise SystemExit("ERROR: HF_TOKEN environment variable is not set.")
if not local_image_name and not env_url:
raise SystemExit(
"ERROR: must set either LOCAL_IMAGE_NAME (Docker) or ENV_URL "
"(HF Spaces). No default URL — set explicitly."
)
if local_image_name and env_url:
print(
"[INFO] both LOCAL_IMAGE_NAME and ENV_URL set; preferring Docker.",
flush=True,
)
llm = LLMClient(
api_base_url=api_base_url,
api_key=hf_token,
model=model_name,
)
results = []
for cfg in TASK_CONFIGS:
if local_image_name:
print(f"[INFO] Using Docker image: {local_image_name}", flush=True)
env = _make_env_from_docker(local_image_name)
else:
print(f"[INFO] Using env URL: {env_url}", flush=True)
env = _make_env_from_spaces(env_url)
try:
result = _run_episode(
env=env,
llm=llm,
task_name=cfg["task_name"],
seed=cfg["seed"],
model_name=model_name,
max_ticks=cfg["max_ticks"],
agent_kind=args.agent,
cortex_router=args.cortex_router,
)
results.append(result)
finally:
close = getattr(env, "close", None)
if callable(close):
try:
close()
except Exception as exc: # pragma: no cover
print(
f"[WARN] env.close() failed: {exc!r}",
file=sys.stderr,
flush=True,
)
print("", flush=True)
n = len(results)
print(
f"=== RESULTS SUMMARY ({n} task{'s' if n != 1 else ''}) ===",
flush=True,
)
for r in results:
status = "PASS" if r["success"] else "FAIL"
print(
f" {r['task']}: score={r['score']:.3f} steps={r['steps_taken']} [{status}]",
flush=True,
)
if __name__ == "__main__":
main()