File size: 16,685 Bytes
31252db
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4d47803
 
31252db
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b36b193
 
 
31252db
 
 
 
b36b193
 
 
 
 
 
 
 
 
 
31252db
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
"""The server: OpenEnv's app plus the map replay, a train-graph viewer and their read-only JSON on the same port.

OpenEnv provides `/reset`, `/step`, `/state`, `/schema`, `/ws`, `/mcp`, `/health`, the Task API under `/rail_ops/...`
and its web UI at `/web`, where an "Explore" tab (`gradio_ui.py`) frames the pages below next to OpenEnv's Playground.
We add:

    /twin/                                the map replay: trains moving on the line's OpenStreetMap track, any plan
    /viewer/                              a train graph (time against distance) of any plan, live episodes too
    /explorer/                            the eval explorer: the board and every model's plan on every eval task
    /api/tasks                            task summaries, with the lazy baselines' rewards
    /api/runs                             the committed model runs: each episode's reward and outcome, and a board
    /api/tasks/{id}                       a public task (what the agent sees, structured)
    /api/tasks/{id}/reference             optimum, floor, baselines and the real outcome - for people; no tool shows them
    /api/tasks/{id}/timeline?plan=...     paths for the graph: plan = optimal | naive | greedy | greedy_fast
    /api/episodes                         live episodes on this server (newest first)
    /api/episodes/{episode_id}/timeline   the latest plan an episode checked or submitted, as paths
    /api/tasks/{id}/plans                 the plans the replay can show: optimum, baselines, models' submitted plans
                                          (from results/rollouts/*/index.json, or RAIL_RUNS_DIR), live episodes
    /api/tasks/{id}/replay?plan=...       everything the replay animates (rail_core.replay)
    /api/episodes/{episode_id}/replay     the same for the latest plan of a live episode

    uvicorn rail_openenv.server:app --port 8000

`/` is OpenEnv's: it redirects to the web UI (`/web/`), or to `/explorer/` when ENABLE_WEB_INTERFACE is false.
"""

from __future__ import annotations

import json
import os
import random
import statistics
from functools import lru_cache
from pathlib import Path

from fastapi import HTTPException
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from openenv.core.env_server.http_server import create_app
from openenv.core.env_server.mcp_types import CallToolAction, CallToolObservation

from rail_core import TaskPack, evaluate, hhmm, load_pack, parse_plan
from rail_core.baselines import POLICIES
from rail_core.check import network
from rail_core.replay import replay

try:
    from .environment import STORE, RailOpsEnvironment, RailState
    from .gradio_ui import build_ui
except ImportError:
    from environment import STORE, RailOpsEnvironment, RailState
    from gradio_ui import build_ui

ENV_NAME = "rail_ops"
WEB = Path(__file__).resolve().parent / "web"
_UP = Path(__file__).resolve().parents
RUNS_DIR = Path(os.environ.get("RAIL_RUNS_DIR") or (_UP[4] if len(_UP) > 4 else _UP[-1]) / "results" / "rollouts")
BASELINES = {"greedy": "greedy dispatcher", "greedy_fast": "greedy dispatcher (turns early)",
             "naive": "change nothing"}


TIERS = ("real", "blockage", "loop", "compound")
PROVIDERS = {"together": "Together", "baseten": "Baseten", "novita": "Novita", "deepinfra": "DeepInfra",
             "fireworks": "Fireworks"}


def model_name(spec: str) -> str:
    """A person's name for a run's model spec: "hf:Qwen/Qwen3.8-2.4T-A95B:together@default" -> "Qwen3.8-2.4T-A95B
    (Together)", "claude-sonnet-5-5@high" -> "Claude Sonnet 5.5 (effort high)"."""
    model, _, effort = spec.partition("@")
    if model.startswith("hf:"):
        repo, _, provider = model[3:].partition(":")
        name = repo.split("/")[-1]
        return f"{name} ({PROVIDERS.get(provider, provider)})" if provider else name
    if model.startswith("claude-"):
        parts = model.split("-")[1:]
        family = " ".join(p.capitalize() for p in parts if not p.isdigit())
        version = ".".join(p for p in parts if p.isdigit())
        name = f"Claude {family} {version}".strip()
        return f"{name} (effort {effort})" if effort and effort != "default" else name
    return model


def load_runs(runs_dir: Path = RUNS_DIR) -> list[dict]:
    """The committed rollout runs (results/rollouts/*/index.json): [{run, episodes}]."""
    out = []
    for idx in sorted(runs_dir.glob("*/index.json")) if runs_dir.is_dir() else []:
        try:
            body = json.loads(idx.read_text())
        except (OSError, ValueError):
            continue
        out.append({"run": body["run"], "episodes": body.get("episodes", [])})
    return out


def run_plans(runs_dir: Path = RUNS_DIR) -> dict[str, list[dict]]:
    """Submitted plans in the committed rollout runs, by task: [{id, label, model, run, reward, plan}]."""
    out: dict[str, list[dict]] = {}
    for body in load_runs(runs_dir):
        for e in body["episodes"]:
            if e.get("plan") is None:
                continue
            out.setdefault(e["task_id"], []).append(
                {"id": f"run:{body['run']}:{e['model']}", "label": model_name(e["model"]),
                 "model": e["model"], "run": body["run"], "reward": e.get("reward"), "plan": e["plan"]})
    for entries in out.values():                     # two runs of one model on a task: name the run too
        for e in entries:
            if sum(x["model"] == e["model"] for x in entries) > 1:
                e["label"] = f"{e['label']} ({e['run']})"
    return out


def ci(xs, n=2000, seed=0):
    """95 % bootstrap interval of the mean, the same as eval/report.py's (so the numbers match the README)."""
    xs = sorted(xs)
    rng = random.Random(seed)
    means = sorted(statistics.mean(rng.choices(xs, k=len(xs))) for _ in range(n))
    return means[int(0.025 * n)], means[int(0.975 * n)]


def runs_summary(runs: list[dict]) -> dict:
    """What the eval explorer shows: one row per (run, model) with the board's columns, and every episode's outcome."""
    rows, episodes = [], []
    for body in runs:
        by: dict[str, list[dict]] = {}
        for e in body["episodes"]:
            by.setdefault(e["model"], []).append(e)
        for model, es in by.items():
            r = [e["reward"] for e in es]
            lo, hi = ci(r)
            real = [e for e in es if e["difficulty"] == "real" and e.get("real_cost") is not None]
            rows.append({
                "run": body["run"], "model": model, "name": model_name(model), "split": es[0]["split"],
                "n": len(es), "mean": statistics.mean(r), "ci": [lo, hi],
                "tiers": {t: statistics.mean(x["reward"] for x in es if x["difficulty"] == t)
                          for t in TIERS if any(x["difficulty"] == t for x in es)},
                "feasible": sum(bool(e["feasible"]) for e in es),
                "optimal": sum(1 for e in es if e["feasible"] and e["cost"] == e["optimal_cost"]),
                "no_submit": sum(not e["submitted"] for e in es),
                "beat_real": sum(1 for e in real if e["feasible"] and e["cost"] < e["real_cost"]), "real": len(real),
                "median_turns": statistics.median(e["agent_turns"] for e in es)})
            for e in es:
                episodes.append({k: e.get(k) for k in ("model", "task_id", "reward", "feasible", "submitted", "cost",
                                                       "optimal_cost", "end_reason", "agent_turns", "checks",
                                                       "violations")}
                                | {"run": body["run"], "plan_id": f"run:{body['run']}:{model}"
                                   if e.get("plan") is not None else None})
    rows.sort(key=lambda x: (x["split"], -x["mean"]))
    return {"models": rows, "episodes": episodes}


def _summary(t, plans: dict | None = None) -> dict:
    ref = t.reference
    return {"task_id": t.task_id, "split": t.split, "day": t.day, "start": hhmm(t.start), "end": hhmm(t.end),
            "difficulty": t.difficulty, "runs": len(t.runs), "closures": [c.kind for c in t.closures],
            "causes": sorted({c.cause for c in t.closures if c.cause}),
            "model_plans": len((plans or {}).get(t.task_id, [])), "optimal_cost": ref.get("optimal_cost"),
            "rewards": {"greedy": ref.get("greedy_reward"), "greedy_fast": ref.get("greedy_fast_reward"),
                        "naive": ref.get("naive_reward"), "real": (ref.get("real") or {}).get("reward_if_legal")}}


def timeline(task, plan) -> dict:
    """Every served run as a path of (km, minute) points, for a time-distance graph."""
    net = network(task)
    res = evaluate(task, plan)
    paths = []
    for x in res.runs:
        if x.cancelled:
            paths.append({"train": x.train, "set": x.unit, "cancelled": True, "points": []})
            continue
        r = task.run(x.train)
        pts = []
        for i in sorted(set(x.arr) | set(x.dep)):
            km = net.by_code[r.route[i]].km
            if i in x.arr:
                pts.append([km, x.arr[i]])
            if i in x.dep and x.dep[i] != x.arr.get(i):
                pts.append([km, x.dep[i]])
        paths.append({"train": x.train, "set": x.unit, "kind": r.kind, "points": pts, "delay": x.delay,
                      "unserved": x.unserved, "problems": x.problems})
    return {"task_id": task.task_id, "start": task.start, "end": task.end,
            "points": [{"code": p.code, "km": p.km, "kind": p.kind, "tracks": p.tracks} for p in net.points],
            "sections": [{"a": s.a, "b": s.b, "single": s.single} for s in net.sections],
            "closures": [{"kind": c.kind, "where": c.where, "start": c.start, "end": c.end, "cause": c.cause}
                         for c in task.closures],
            "paths": paths, "feasible": res.feasible, "cost": res.cost, "violations": res.violations[:50],
            "optimal_cost": task.reference.get("optimal_cost")}


PAGES = ("/twin", "/explorer", "/viewer")


def mount_api(app, pack: TaskPack) -> None:
    plans_by_task = run_plans()
    runs = runs_summary(load_runs())

    @app.middleware("http")
    async def revalidate_pages(request, call_next):
        """The pages and their scripts change with every deploy. Without a Cache-Control header browsers guess how
        long to reuse a stored copy (often hours), so a new version did not show; `no-cache` makes them ask every time,
        and an unchanged file costs a 304."""
        response = await call_next(request)
        if request.url.path.startswith(PAGES):
            response.headers.setdefault("Cache-Control", "no-cache")
        return response

    @app.get("/api/tasks", include_in_schema=False)
    def tasks():
        return [_summary(t, plans_by_task) for t in pack.tasks]

    @app.get("/api/runs", include_in_schema=False)
    def model_runs():
        return runs

    def _task(task_id):
        try:
            return pack.get(task_id)
        except KeyError:
            raise HTTPException(404, f"no task {task_id}") from None

    @app.get("/api/tasks/{task_id}", include_in_schema=False)
    def task(task_id: str):
        return pack.public(_task(task_id))

    @app.get("/api/tasks/{task_id}/reference", include_in_schema=False)
    def reference(task_id: str):
        return _task(task_id).reference

    @app.get("/api/tasks/{task_id}/timeline", include_in_schema=False)
    def task_timeline(task_id: str, plan: str = "optimal"):
        t = _task(task_id)
        if plan == "optimal":
            p, _ = parse_plan(t, t.reference["optimal_plan"])
        elif plan in POLICIES:
            p = POLICIES[plan](t)
        else:
            raise HTTPException(400, "plan must be optimal, naive, greedy or greedy_fast")
        return timeline(t, p)

    def _plan(t, plan_id: str):
        """(plan, parse problems, label) for a plan id."""
        if plan_id == "optimal":
            p, probs = parse_plan(t, t.reference["optimal_plan"])
            return p, probs, "CP-SAT optimum"
        if plan_id in POLICIES:
            return POLICIES[plan_id](t), [], BASELINES.get(plan_id, plan_id)
        for e in plans_by_task.get(t.task_id, []):
            if e["id"] == plan_id:
                p, probs = parse_plan(t, e["plan"])
                return p, probs, e["label"]
        raise HTTPException(400, f"unknown plan {plan_id!r}: see /api/tasks/{t.task_id}/plans")

    @lru_cache(maxsize=256)
    def _replay(task_id: str, plan_id: str) -> dict:
        t = _task(task_id)
        p, probs, label = _plan(t, plan_id)
        return replay(t, p, label, probs) | {"plan_id": plan_id}

    @app.get("/api/tasks/{task_id}/plans", include_in_schema=False)
    def task_plans(task_id: str):
        t = _task(task_id)
        ref = t.reference
        out = [{"id": "optimal", "label": "CP-SAT optimum", "reward": 1.0, "cost": ref.get("optimal_cost")}]
        out += [{"id": k, "label": v, "reward": ref.get(f"{k}_reward"),
                 "cost": ref.get(f"{k}_cost") if ref.get(f"{k}_feasible") else None} for k, v in BASELINES.items()]
        out += [{k: e[k] for k in ("id", "label", "reward")} for e in plans_by_task.get(task_id, [])]
        out += [{"id": f"episode:{ep.episode_id}", "label": f"live episode {ep.episode_id}", "reward": None}
                for ep in STORE.recent() if ep.task.task_id == task_id]
        return out

    @app.get("/api/tasks/{task_id}/replay", include_in_schema=False)
    def task_replay(task_id: str, plan: str = "optimal"):
        if plan.startswith("episode:"):
            return episode_replay(plan.split(":", 1)[1])
        return _replay(task_id, plan)

    @app.get("/api/episodes/{episode_id}/replay", include_in_schema=False)
    def episode_replay(episode_id: str):
        ep = STORE.get(episode_id)
        if ep is None:
            raise HTTPException(404, f"no episode {episode_id}")
        last = next((s for s in reversed(ep.steps) if s.get("plan") is not None), None)
        p, probs = parse_plan(ep.task, last["plan"]) if last else ({}, [])
        return replay(ep.task, p, f"live episode {episode_id}", probs) | {"plan_id": f"episode:{episode_id}"}

    @app.get("/api/episodes", include_in_schema=False)
    def episodes():
        return [{k: v for k, v in ep.summary().items() if k != "steps"} | {"steps": len(ep.steps)}
                for ep in STORE.recent()]

    @app.get("/api/episodes/{episode_id}/timeline", include_in_schema=False)
    def episode_timeline(episode_id: str):
        ep = STORE.get(episode_id)
        if ep is None:
            raise HTTPException(404, f"no episode {episode_id}")
        last = next((s for s in reversed(ep.steps) if s.get("plan") is not None), None)
        p, _ = parse_plan(ep.task, last["plan"]) if last else ({}, [])
        return timeline(ep.task, p) | {"episode": {k: v for k, v in ep.summary().items() if k != "steps"}}

    if (WEB / "twin").is_dir():
        app.mount("/twin", StaticFiles(directory=WEB / "twin", html=True), name="twin")
    if (WEB / "explorer").is_dir():
        app.mount("/explorer", StaticFiles(directory=WEB / "explorer", html=True), name="explorer")
    if WEB.is_dir():
        app.mount("/viewer", StaticFiles(directory=WEB, html=True), name="viewer")

        @app.get("/", include_in_schema=False)
        def root():
            return RedirectResponse("/explorer/" if (WEB / "explorer" / "index.html").is_file() else "/twin/")


def create_server(pack: TaskPack | None = None):
    pack = pack or load_pack()

    def factory():
        return RailOpsEnvironment(pack)

    os.environ.setdefault("ENABLE_WEB_INTERFACE", "true")
    app = create_app(factory, CallToolAction, CallToolObservation, env_name=ENV_NAME,
                     max_concurrent_envs=int(os.environ.get("MAX_CONCURRENT_ENVS", "64")),
                     gradio_builder=build_ui, custom_tab_name="Explore", custom_tab_primary=True,
                     show_default_tab=True, title_override="RailSimEnv v1 Environment", state_cls=RailState)
    mount_api(app, pack)

    @app.get("/healthz", include_in_schema=False)
    def healthz():
        return {"status": "ok", "tasks": {s: pack.count(s) for s in pack.splits()}}

    return app


_app = None


def __getattr__(name: str):
    global _app
    if name == "app":
        if _app is None:
            _app = create_server()
        return _app
    raise AttributeError(name)


def main(host: str = "0.0.0.0", port: int | None = None) -> None:
    import uvicorn

    port = port or int(os.environ.get("PORT", "8000"))
    uvicorn.run(create_server(), host=host, port=port, ws_ping_interval=60, ws_ping_timeout=300)


if __name__ == "__main__":
    main()