bpHigh commited on
Commit
31252db
·
verified ·
1 Parent(s): 7adefb6

RailSimEnv v1: OpenEnv server, eval explorer, 3D replay and train graph

Browse files
Files changed (50) hide show
  1. README.md +89 -5
  2. app.py +52 -0
  3. core/rail_core/__init__.py +14 -0
  4. core/rail_core/baselines.py +263 -0
  5. core/rail_core/build.py +243 -0
  6. core/rail_core/check.py +364 -0
  7. core/rail_core/data.py +207 -0
  8. core/rail_core/generate.py +226 -0
  9. core/rail_core/hardness.py +59 -0
  10. core/rail_core/model.py +307 -0
  11. core/rail_core/network.py +80 -0
  12. core/rail_core/ops.py +160 -0
  13. core/rail_core/pack.py +100 -0
  14. core/rail_core/prompts.py +192 -0
  15. core/rail_core/reference.py +131 -0
  16. core/rail_core/replay.py +109 -0
  17. core/rail_core/report.py +72 -0
  18. core/rail_core/reward.py +68 -0
  19. core/rail_core/select.py +189 -0
  20. core/rail_core/solve.py +608 -0
  21. openenv.yaml +38 -0
  22. openenv/rail_openenv/__init__.py +0 -0
  23. openenv/rail_openenv/environment.py +266 -0
  24. openenv/rail_openenv/gradio_ui.py +37 -0
  25. openenv/rail_openenv/rubric.py +99 -0
  26. openenv/rail_openenv/server.py +338 -0
  27. openenv/rail_openenv/session.py +115 -0
  28. openenv/rail_openenv/web/explorer/index.html +313 -0
  29. openenv/rail_openenv/web/index.html +592 -0
  30. openenv/rail_openenv/web/twin/SOURCES.md +45 -0
  31. openenv/rail_openenv/web/twin/app.js +286 -0
  32. openenv/rail_openenv/web/twin/geo.js +138 -0
  33. openenv/rail_openenv/web/twin/graph.js +313 -0
  34. openenv/rail_openenv/web/twin/index.html +80 -0
  35. openenv/rail_openenv/web/twin/motion.js +398 -0
  36. openenv/rail_openenv/web/twin/scene.js +765 -0
  37. openenv/rail_openenv/web/twin/style.css +161 -0
  38. openenv/rail_openenv/web/twin/twin.json.gz +3 -0
  39. openenv/rail_openenv/web/twin/world.js +514 -0
  40. requirements.txt +2 -0
  41. runs/2026-10-07-sonnet-5-5-eval45/index.json +0 -0
  42. runs/2026-10-08-deepseek-v4-1-flash-eval45/index.json +0 -0
  43. runs/2026-10-08-glm-5-3-flash-eval45/index.json +4831 -0
  44. runs/2026-10-08-qwen3-8-2-4t-eval45/index.json +0 -0
  45. runs/calib-sonnet-5-5/index.json +1900 -0
  46. tasks/rail-v1-eval/manifest.json +92 -0
  47. tasks/rail-v1-eval/selection.json +116 -0
  48. tasks/rail-v1-eval/tasks.jsonl +0 -0
  49. tasks/rail-v1-train/manifest.json +106 -0
  50. tasks/rail-v1-train/tasks.jsonl +0 -0
README.md CHANGED
@@ -1,13 +1,97 @@
1
  ---
2
- title: RailSimEnv
3
- emoji: ⚡
4
  colorFrom: blue
5
- colorTo: purple
6
  sdk: gradio
7
  sdk_version: 6.29.1
8
- python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: RailSimEnv v1
3
+ emoji: 🚆
4
  colorFrom: blue
5
+ colorTo: gray
6
  sdk: gradio
7
  sdk_version: 6.29.1
8
+ python_version: "3.12"
9
  app_file: app.py
10
  pinned: false
11
+ short_description: Dispatch trains on a real single-track Dutch line
12
+ tags: [openenv, simulation, rl-environment, railway, scheduling, operations-research]
13
  ---
14
 
15
+ # RailSimEnv v1 · OpenEnv
16
+
17
+ Dispatch Arriva's trains on the single-track Groningen – Leeuwarden line (Netherlands) after a real 2025 disruption:
18
+ a broken-down train, a collision at a level crossing, a signal or points failure, on its real day and time, with every
19
+ train where it really was. Hold, short-turn and cancel trains so opposing trains cross safely. One graded submit per
20
+ episode against the proven CP-SAT optimum. Serves the rail-v1 packs: 45 eval tasks chosen for hardness and 159 train
21
+ tasks on other days.
22
+
23
+ | page | what |
24
+ |---|---|
25
+ | `/web` | OpenEnv's web UI. **Explore**: the eval explorer, the 3D replay and the train graph. **Playground**: reset, step and the MCP tools by hand |
26
+ | `/explorer/` | the leaderboard and every model's plan on every eval task; pick one to watch it |
27
+ | `/twin/` | the 3D replay: any plan on the line's real track from OpenStreetMap, signals, level crossings, closures, the train graph and the cost as it accrues |
28
+ | `/viewer/` | the train graph (time across, distance down) of any plan, with the plan's holds, short-turns and rule breaks |
29
+ | `/reset`, `/step`, `/state`, `/ws`, `/mcp` | OpenEnv |
30
+ | `/rail_ops/...` | OpenEnv Task API (`splits`, `num_tasks`, `task`, `task_range`) |
31
+ | `/api/...` | read-only JSON for the pages |
32
+
33
+ ## Tools
34
+
35
+ | tool | returns |
36
+ |---|---|
37
+ | `get_situation()` | the line, the closures, every train's timetable and position, the train sets and their chains of runs |
38
+ | `check_plan(plan)` | simulates a draft: every train's times, delay and lost stops, every rule broken, the cost; 10 per episode |
39
+ | `submit_plan(plan)` | ends the episode with one grade |
40
+
41
+ `plan` lists only the trains you change: `[{"train": "37413", "to": "HDG"}, {"train": "37324", "from": "HDG"},
42
+ {"train": "37419", "hold": {"GK": "08:36"}}, {"train": "37330", "cancel": true}]`. Trains left alone run as early as
43
+ the physics allow, never ahead of the timetable. `reset(task_id=...)`, `reset(split=..., index=...)` or
44
+ `reset(seed=...)`.
45
+
46
+ **Rules:** no two opposing trains in a single-track section at once; 3-minute headways for trains going the same way;
47
+ never more trains at a station than tracks; no train in a closed section; runs start where their train set is, and
48
+ every set ends the window where its next run leaves; no stop more than 2 hours late.
49
+
50
+ **Cost**, in stop-minutes: every minute of delay at every stop served, 30 per stop not served, and lateness handed on
51
+ to the next window.
52
+
53
+ ## Reward
54
+
55
+ Graded once, on `submit_plan`, by `RailPlanRubric` (every other action scores 0.0). A plan that breaks any rule scores
56
+ 0.2 × the share of runs it leaves clean. A legal plan scores 0.2 + 0.8 × exp(−gap / 0.5), with
57
+ gap = (cost − optimum) / (optimum − floor + 100), so the proven CP-SAT optimum scores 1.0. No submission scores 0. The
58
+ children `runs_clean` and `cost_quality` are reported in the final observation's `metadata.rubric`.
59
+
60
+ ## Results on the 45 eval tasks
61
+
62
+ One rollout per model, the same rules for every model (12 turns, 64k output tokens per turn):
63
+
64
+ | model | mean reward (95% CI) | legal plans | at the optimum |
65
+ |---|---|---:|---:|
66
+ | Qwen3.8-2.4T-A95B | **0.556** (0.452-0.665) | 39 | 12 |
67
+ | DeepSeek-V4.1-Flash | **0.534** (0.427-0.643) | 33 | 12 |
68
+ | Claude Sonnet 5.5 | **0.467** (0.384-0.553) | 45 | 4 |
69
+ | GLM-5.3-Flash | **0.360** (0.251-0.477) | 24 | 6 |
70
+ | the real dispatchers (real tier, scored as if legal) | 0.502 | | |
71
+ | greedy dispatcher | 0.180 | | |
72
+ | change nothing | 0.105 | 0 | 0 |
73
+
74
+ Every plan is in the eval explorer, and replays in 3D exactly as it was graded.
75
+
76
+ ## Connect an agent
77
+
78
+ ```python
79
+ from openenv.core.env_server.mcp_types import CallToolAction
80
+ from openenv.core.mcp_client import MCPToolClient
81
+
82
+ env = MCPToolClient("https://bphigh-railsimenv.hf.space").sync()
83
+ obs = env.reset(split="eval", index=0)
84
+ rules = obs.observation.metadata["instructions"] # the system prompt
85
+ situation = env.step(CallToolAction(tool_name="get_situation", arguments={}))
86
+ plan = [{"train": "37413", "to": "HDG"}, {"train": "37324", "from": "HDG"}]
87
+ print(env.step(CallToolAction(tool_name="check_plan", arguments={"plan": plan})).observation)
88
+ ```
89
+
90
+ ## Data and licences
91
+
92
+ | data | source | licence |
93
+ |---|---|---|
94
+ | trains, timetables, delays, cancellations, platforms; disruptions | [Rijden de Treinen](https://www.rijdendetreinen.nl/en/open-data) open data | CC BY 4.0 ("Rijden de Treinen") |
95
+ | track layout; the 3D replay's map (track, platforms, buildings, land cover, water, roads) | © OpenStreetMap contributors | ODbL 1.0 |
96
+
97
+ The rolling-stock circulation, the disruption effects by cause and the cost weights are modelling choices.
app.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """RailSimEnv v1 on a Hugging Face Space: the OpenEnv server (reset, step, state, the MCP tools, the Task API and the
2
+ web UI at /web), the eval explorer (/explorer/), the 3D replay (/twin/) and the train graph (/viewer/), on one port.
3
+
4
+ The Space runs on ZeroGPU hardware but never asks for a GPU: the environment is plain Python and the grader re-simulates
5
+ a plan in milliseconds. ZeroGPU wants one decorated function, so `_noop` exists and is never called.
6
+
7
+ python app.py # http://127.0.0.1:7860/web/ (the Space's page), /explorer/, /twin/, /viewer/
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ ROOT = Path(__file__).resolve().parent
17
+ sys.path[:0] = [str(ROOT / "core"), str(ROOT / "openenv")]
18
+ os.environ.setdefault("RAIL_TASKS_DIR", f"{ROOT / 'tasks' / 'rail-v1-eval'}:{ROOT / 'tasks' / 'rail-v1-train'}")
19
+ os.environ.setdefault("RAIL_RUNS_DIR", str(ROOT / "runs"))
20
+ os.environ.setdefault("ENABLE_WEB_INTERFACE", "true")
21
+
22
+ # The web UI shows a README: this card, without its front matter.
23
+ card = (ROOT / "README.md").read_text(encoding="utf-8")
24
+ if card.startswith("---"):
25
+ card = card.split("---", 2)[2].lstrip()
26
+ readme = Path(os.environ.get("TMPDIR", "/tmp")) / "railsim_readme.md"
27
+ readme.write_text(card, encoding="utf-8")
28
+ os.environ.setdefault("ENV_README_PATH", str(readme))
29
+
30
+ try:
31
+ import spaces
32
+ except ImportError: # running elsewhere
33
+ spaces = None
34
+
35
+ if spaces is not None:
36
+ @spaces.GPU(duration=1)
37
+ def _noop(): # ZeroGPU requires one decorated function; nothing calls it
38
+ pass
39
+
40
+ import uvicorn # noqa: E402
41
+
42
+ from rail_openenv.server import create_server # noqa: E402
43
+
44
+ app = create_server()
45
+
46
+ if __name__ == "__main__":
47
+ if spaces is not None:
48
+ from spaces import zero
49
+
50
+ zero.startup()
51
+ uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "7860")), ws_ping_interval=60,
52
+ ws_ping_timeout=600)
core/rail_core/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rail dispatch on the single-track Groningen - Leeuwarden line: tasks from Arriva's real 2025 days, the physics and
2
+ checker, CP-SAT references, a reward."""
3
+
4
+ from .check import PlanResult, evaluate, simulate
5
+ from .model import (RULES, Closure, Order, Plan, PlanError, Run, Task, Unit, hhmm, parse_plan, parse_time,
6
+ plan_from_list, plan_to_list)
7
+ from .pack import TaskPack, load_pack
8
+ from .prompts import PROMPT_VERSION, brief, rules, situation
9
+ from .report import plan_report
10
+ from .reward import Grade, grade, score
11
+
12
+ __all__ = ["Closure", "Grade", "Order", "PROMPT_VERSION", "Plan", "PlanError", "PlanResult", "RULES", "Run", "Task",
13
+ "TaskPack", "Unit", "brief", "evaluate", "grade", "hhmm", "load_pack", "parse_plan", "parse_time",
14
+ "plan_from_list", "plan_report", "plan_to_list", "rules", "score", "simulate", "situation"]
core/rail_core/baselines.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cheap dispatching policies: the lazy bounds a task must stay hard against, and the solver's starting point.
2
+
3
+ naive change nothing: every train runs as scheduled and as early as it can (usually breaks the rules)
4
+ greedy first come, first served: trains are planned one at a time in the order they are ready to leave; each
5
+ takes the earliest times that clear every train already planned (holding at its scheduled stops), and
6
+ a train whose path is closed for more than `turn_after` minutes is short-turned at the last turn point
7
+ before the closure, its train starting back from there. This is how a dispatcher works by rule of
8
+ thumb; it never cancels and never makes a train wait to let a later one through.
9
+ greedy_fast the same, but fast trains (Sneltrein) are planned before stopping trains that are ready at the
10
+ same time (within 10 minutes): priority to the fast service
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import heapq
16
+ from collections import defaultdict
17
+
18
+ from .check import evaluate, network, run_dir
19
+ from .model import Order, Plan, Run, Task
20
+
21
+ MAXWAIT = 240
22
+ STEP_BUDGET = 60_000 # departure times tried per run before the greedy gives up on it
23
+
24
+
25
+ class _Book:
26
+ """What is already planned: section occupancies and station presences."""
27
+
28
+ def __init__(self, task: Task):
29
+ self.task = task
30
+ self.net = network(task)
31
+ self.sec: dict[int, list] = defaultdict(list) # (dir, enter, exit)
32
+ self.pres: dict[str, list] = defaultdict(list) # (dir, t0, t1)
33
+ self.hf, self.ho = task.rule("h_follow"), task.rule("h_opp")
34
+ self.closed = [c for c in task.closures if c.kind == "closed"]
35
+ self.tracks_out = [c for c in task.closures if c.kind == "track"]
36
+ self.slow = [c for c in task.closures if c.kind == "slow"]
37
+
38
+ def section_ok(self, sec: int, d: int, t0: int, t1: int, history: bool = False) -> bool:
39
+ s = self.net.sections[sec]
40
+ for dd, a, b in self.sec[sec]:
41
+ if dd == d:
42
+ if (t0 >= a and (t0 - a < self.hf or t1 - b < self.hf)) or \
43
+ (t0 < a and (a - t0 < self.hf or b - t1 < self.hf)):
44
+ return False
45
+ elif s.single and not (b + self.ho <= t0 or t1 + self.ho <= a):
46
+ return False
47
+ if not history:
48
+ for c in self.closed:
49
+ lo, hi = sorted((self.net.index[c.where[0]], self.net.index[c.where[-1]]))
50
+ if lo <= sec < hi and t0 < c.end and t1 > c.start:
51
+ return False
52
+ return True
53
+
54
+ def station_ok(self, code: str, d: int, t0: int, t1: int) -> bool:
55
+ p = self.net.by_code[code]
56
+ if p.tracks is None or p.kind == "junction":
57
+ return True
58
+ others = [(dd, a, b) for dd, a, b in self.pres[code] if not (b < t0 or a > t1)]
59
+ if p.directional:
60
+ return not any(dd in (d, 0) for dd, _, _ in others)
61
+ # peak count over [t0, t1] including this train
62
+ times = sorted({t0} | {a for _, a, _ in others if t0 <= a <= t1} |
63
+ {c.start for c in self.tracks_out if c.where[0] == code and t0 <= c.start <= t1})
64
+ for t in times:
65
+ n = 1 + sum(1 for _, a, b in others if a <= t <= b)
66
+ cap = p.tracks - sum(1 for c in self.tracks_out if c.where[0] == code and c.start <= t < c.end)
67
+ if n > cap:
68
+ return False
69
+ return True
70
+
71
+ def slow_extra(self, sec: int, t: int) -> int:
72
+ x = 0
73
+ for c in self.slow:
74
+ lo, hi = sorted((self.net.index[c.where[0]], self.net.index[c.where[-1]]))
75
+ if lo <= sec < hi and c.start <= t < c.end:
76
+ x = max(x, c.extra)
77
+ return x
78
+
79
+
80
+ def _closure_wait(book: _Book, r: Run, i: int, t: int) -> int:
81
+ """Minutes a train entering route[i] -> route[i+1] at t would wait for a closure."""
82
+ sec = book.net.section(r.route[i], r.route[i + 1])
83
+ w = 0
84
+ for c in book.closed:
85
+ lo, hi = sorted((book.net.index[c.where[0]], book.net.index[c.where[-1]]))
86
+ if lo <= sec < hi and c.start <= t + r.seg[i] and t < c.end:
87
+ w = max(w, c.end - t)
88
+ return w
89
+
90
+
91
+ def _path(book: _Book, r: Run, first: int, last: int, a0: int | None, ready: int, wait_from: int | None):
92
+ """Earliest conflict-free times for one run over route[first..last] given what is booked: (arr, dep, stopped)
93
+ or None. a0: the fixed arrival at `first` of a run already moving (else the run starts at `first`, where its
94
+ train has stood since `wait_from` if that is a station with few tracks)."""
95
+ net, task = book.net, book.task
96
+ L = task.rule("stop_loss")
97
+ d = run_dir(net, r)
98
+ fail: set = set()
99
+ steps = [0]
100
+ arr: dict[int, int] = {}
101
+ dep: dict[int, int] = {}
102
+ stopped: dict[int, bool] = {}
103
+
104
+ def go(i: int, a: int) -> bool:
105
+ if i == last:
106
+ arr[i], stopped[i] = a, True
107
+ return True
108
+ if (i, a) in fail:
109
+ return False
110
+ code = r.route[i]
111
+ p = net.by_code[code]
112
+ starting = i == first and a0 is None
113
+ if starting:
114
+ d0 = max(ready, r.dep[i])
115
+ elif r.stops[i]:
116
+ d0 = max(a, r.dep[i])
117
+ else:
118
+ d0 = a
119
+ if i == first and a0 is not None:
120
+ d0 = max(d0, task.start)
121
+ sec = net.section(code, r.route[i + 1])
122
+ for t in range(d0, d0 + MAXWAIT):
123
+ steps[0] += 1
124
+ if steps[0] > STEP_BUDGET:
125
+ return False
126
+ if starting:
127
+ a_here, acc, halt, t0 = None, (0 if r.stops[i] else L), True, wait_from
128
+ elif r.stops[i] or (i == first and a0 is not None and t > a and False):
129
+ a_here, acc, halt, t0 = a, 0, True, a
130
+ elif t == a:
131
+ a_here, acc, halt, t0 = a, 0, False, a
132
+ else:
133
+ break # greedy only waits where the train has a scheduled stop
134
+ if t0 is not None and not book.station_ok(code, d, t0, t):
135
+ if halt:
136
+ break # a longer wait here only makes it worse
137
+ continue
138
+ nxt = t + r.seg[i] + acc + book.slow_extra(sec, t)
139
+ if r.arr[i + 1] is not None:
140
+ nxt = max(nxt, r.arr[i + 1])
141
+ if i + 1 == last and not r.stops[last]:
142
+ nxt += L
143
+ if not book.section_ok(sec, d, t, nxt):
144
+ continue
145
+ if a_here is not None:
146
+ arr[i] = a_here
147
+ dep[i], stopped[i] = t, halt
148
+ if go(i + 1, nxt):
149
+ return True
150
+ fail.add((i, a))
151
+ return False
152
+
153
+ if not go(first, a0 if a0 is not None else 0):
154
+ return None
155
+ return arr, dep, stopped
156
+
157
+
158
+ def greedy(task: Task, fast_first: bool = False, turn_after: int = 20) -> Plan:
159
+ book = _Book(task)
160
+ net = book.net
161
+ runs = task.run_map
162
+ turn = task.rule("turn_min")
163
+ plan: Plan = {}
164
+ for r in task.runs: # trains between points when the task starts
165
+ if r.started and r.fixed_from is not None:
166
+ k = r.fixed_to
167
+ book.sec[net.section(r.route[k - 1], r.route[k])].append((run_dir(net, r), r.fixed_from, r.fixed_arr))
168
+ state = {}
169
+ for u in task.units:
170
+ finite = u.at is not None and net.by_code[u.at].tracks is not None
171
+ state[u.id] = {"k": 0, "ready": u.ready, "wait_from": task.start if finite else None, "start_at": None,
172
+ "park": None}
173
+ if finite:
174
+ stay = (0, task.start, task.end + 1440)
175
+ book.pres[u.at].append(stay)
176
+ state[u.id]["park"] = (u.at, stay)
177
+ heap = []
178
+ units = {u.id: u for u in task.units}
179
+
180
+ def push(u):
181
+ s = state[u.id]
182
+ if s["k"] >= len(u.runs):
183
+ return
184
+ r = runs[u.runs[s["k"]]]
185
+ t = r.fixed_arr if r.started else max(s["ready"] or 0, r.dep[0])
186
+ pri = (t // 10, 0 if r.kind == "fast" else 1, t) if fast_first else (t, 0, 0)
187
+ heapq.heappush(heap, (pri, u.id))
188
+
189
+ for u in task.units:
190
+ push(u)
191
+ while heap:
192
+ _, uid = heapq.heappop(heap)
193
+ u = units[uid]
194
+ s = state[uid]
195
+ r = runs[u.runs[s["k"]]]
196
+ if s["park"]: # its provisional stay is replaced by the real one below
197
+ code, stay = s["park"]
198
+ book.pres[code].remove(stay)
199
+ s["park"] = None
200
+ has_next = s["k"] + 1 < len(u.runs)
201
+ first = r.fixed_to if r.started else (r.idx(s["start_at"]) if s["start_at"] else 0)
202
+ last = len(r.route) - 1
203
+ if has_next: # short-turn before a long closure, if its train has a run to come back on
204
+ nxt = runs[u.runs[s["k"] + 1]]
205
+ t = r.fixed_arr if r.started else max(s["ready"] or 0, r.dep[first])
206
+ for i in range(first, last):
207
+ if i > first and r.stops[i]:
208
+ t = max(t, r.dep[i])
209
+ if _closure_wait(book, r, i, t) > turn_after:
210
+ lo = first if r.started else first + 1
211
+ # only where the next run can start (some runs work part of the line, e.g. Groningen - Zuidhorn)
212
+ cands = [j for j in range(lo, i + 1) if net.by_code[r.route[j]].turn
213
+ and r.route[j] in nxt.route[:-1]]
214
+ if cands:
215
+ last = cands[-1]
216
+ break
217
+ t += r.seg[i]
218
+ a0 = r.fixed_arr if r.started else None
219
+ wait_from = None if r.started else s["wait_from"]
220
+ res = _path(book, r, first, last, a0, s["ready"] or 0, wait_from)
221
+ if res is None and last != len(r.route) - 1:
222
+ last = len(r.route) - 1
223
+ res = _path(book, r, first, last, a0, s["ready"] or 0, wait_from)
224
+ if res is None:
225
+ plan[r.id] = Order(start=r.route[first] if (not r.started and first > 0) else None)
226
+ s["k"] += 1
227
+ s["ready"], s["wait_from"], s["start_at"] = None, None, None
228
+ push(u)
229
+ continue
230
+ arr, dep, stopped = res
231
+ d = run_dir(net, r)
232
+ if wait_from is not None:
233
+ book.pres[r.route[first]].append((d, wait_from, dep[first]))
234
+ for i in range(first, last):
235
+ book.sec[net.section(r.route[i], r.route[i + 1])].append((d, dep[i], arr[i + 1]))
236
+ if (i > first or r.started) and net.by_code[r.route[i]].tracks is not None:
237
+ book.pres[r.route[i]].append((d, arr[i], dep[i]))
238
+ plan[r.id] = Order(start=r.route[first] if (not r.started and first > 0) else None,
239
+ end=r.route[last] if last < len(r.route) - 1 else None,
240
+ hold={r.route[i]: dep[i] for i in range(first, last)
241
+ if net.by_code[r.route[i]].kind != "junction"})
242
+ end_code = r.route[last]
243
+ s["k"] += 1
244
+ s["ready"] = arr[last] + turn
245
+ s["start_at"] = end_code if last < len(r.route) - 1 else None
246
+ p_end = net.by_code[end_code]
247
+ if p_end.tracks is not None and p_end.kind != "junction":
248
+ s["wait_from"] = arr[last]
249
+ stay = (0, arr[last], task.end + 1440)
250
+ book.pres[end_code].append(stay)
251
+ s["park"] = (end_code, stay)
252
+ else:
253
+ s["wait_from"] = None
254
+ push(u)
255
+ from .solve import _tidy
256
+ return _tidy(task, plan)
257
+
258
+
259
+ def naive(task: Task) -> Plan:
260
+ return {}
261
+
262
+
263
+ POLICIES = {"naive": naive, "greedy": greedy, "greedy_fast": lambda t: greedy(t, fast_first=True)}
core/rail_core/build.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build task packs (candidates -> references -> filters), resumable, deterministic for the same data and arguments.
2
+
3
+ python -m rail_core.build --split eval --out ../tasks/pool-eval --n 400 # a candidate pool, every tier
4
+ python -m rail_core.build --split train --out ../tasks/rail-v1-train --n 300
5
+
6
+ Candidates: the record's own disruptions on their own days (tier `real`), then seeded tiers (`ops.TIERS`) on every
7
+ day of the split at peak-hour starts, cycling seeds. A candidate is kept when:
8
+ the window can be rebuilt from the record (generate.build_task) and has at least MIN_RUNS runs
9
+ CP-SAT's plan is within 1 % of its proven bound, measured on the avoidable cost (cost - floor + gap_k)
10
+ the naive plan (change nothing) scores at most MAX_NAIVE_REWARD and the greedy dispatchers at most
11
+ MAX_GREEDY_REWARD
12
+ Every candidate and why it was dropped goes to build_log.jsonl, with the hardness features of every kept task
13
+ (`hardness.features`). Choosing the eval set from a pool is `rail_core.select`.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import gzip
20
+ import hashlib
21
+ import json
22
+ import random
23
+ import time
24
+ from concurrent.futures import ProcessPoolExecutor
25
+ from pathlib import Path
26
+
27
+ from .generate import build_task
28
+ from .hardness import features
29
+ from .model import Closure, hhmm
30
+ from .network import load_network
31
+ from .ops import (LATE_CAUSES, PEAKS, TIERS, candidate_id, closures_for, real_disruptions, seeded_closures,
32
+ split_days)
33
+ from .reference import add_reference
34
+
35
+ MIN_RUNS = 15
36
+ MAX_NAIVE_REWARD = 0.6
37
+ MAX_GREEDY_REWARD = 0.85
38
+ MAX_GAP = 0.01
39
+
40
+
41
+ def _rng(cid: str) -> random.Random:
42
+ return random.Random(int(hashlib.sha256(cid.encode()).hexdigest()[:12], 16))
43
+
44
+
45
+ def make_candidate(day: str, start: int, window: int, tier: str, seed: int, split: str,
46
+ real: dict | None = None):
47
+ """The task for one candidate (no reference yet), or (None, reason)."""
48
+ net = load_network()
49
+ cid = candidate_id(day, start, window, tier, seed)
50
+ rng = _rng(cid)
51
+ notices: list[str] = []
52
+ why: list[str] = []
53
+ task = None
54
+ if tier == "real":
55
+ # The record's start is when the disruption was announced; dispatchers have often cut trains short a few
56
+ # minutes before. Start the window (and the disruption) earlier until no train was cut before it.
57
+ for early in (0, 5, 10, 15, 20, 30, 45):
58
+ s0 = real["start"] - early
59
+ closures = closures_for(net, real["cause"], real["stations"], s0, real["end"], _rng(cid), real=True)
60
+ why = []
61
+ task = build_task(day, s0, s0 + window, closures, task_id=cid, split=split, difficulty=tier,
62
+ notices=notices, why=why)
63
+ if task is not None or "cut short" not in (why[0] if why else ""):
64
+ break
65
+ if task is not None and early:
66
+ task.notices.append(f"The record announces this disruption at {hhmm(real['start'])}; trains were already "
67
+ f"being cut short, so it starts here at {hhmm(task.start)}.")
68
+ else:
69
+ closures, notices = seeded_closures(net, tier, start, window, rng)
70
+ task = build_task(day, start, start + window, closures, task_id=cid, split=split, difficulty=tier,
71
+ notices=notices, why=why)
72
+ if task is None:
73
+ return None, "rebuild: " + (why[0] if why else "?")
74
+ lo, hi = TIERS[tier].get("late", (0, 0)) if isinstance(TIERS[tier].get("late"), tuple) else (0, 0)
75
+ waiting = [u for u in task.units if u.at and not task.run(u.runs[0]).started]
76
+ for u in rng.sample(waiting, min(len(waiting), rng.randint(lo, hi))):
77
+ extra = rng.choice((10, 15, 20, 25, 30))
78
+ first = task.run(u.runs[0])
79
+ u.ready = max(u.ready or start, first.dep[0]) + extra
80
+ task.notices.append(f"Train set {u.id} at {u.at} is held for {rng.choice(LATE_CAUSES)}: it cannot leave "
81
+ f"before {hhmm(u.ready)} (its next run {first.id} is due at {hhmm(first.dep[0])}).")
82
+ if len(task.runs) < MIN_RUNS:
83
+ return None, f"too small: {len(task.runs)} runs"
84
+ maxd = task.rule("max_delay")
85
+ for r in task.runs:
86
+ if r.started and r.arr[r.fixed_to] is not None and r.fixed_arr - r.arr[r.fixed_to] > maxd - 30:
87
+ return None, f"{r.id} is already {r.fixed_arr - r.arr[r.fixed_to]} min late"
88
+ return task, ""
89
+
90
+
91
+ def _one(args):
92
+ day, start, window, tier, seed, split, real, limit, workers = args
93
+ t0 = time.time()
94
+ stats = {"day": day, "start": start, "window": window, "tier": tier, "seed": seed}
95
+ try:
96
+ task, reason = make_candidate(day, start, window, tier, seed, split, real)
97
+ if task is None:
98
+ stats["reason"] = reason
99
+ return None, stats
100
+ ref = add_reference(task, time_limit=limit, workers=workers)
101
+ gap = (ref["optimal_cost"] - ref["lower_bound"]) / (ref["optimal_cost"] - ref["floor_cost"] + task.rule("gap_k"))
102
+ stats.update(task_id=task.task_id, runs=len(task.runs), optimal=ref["optimal_cost"], bound=ref["lower_bound"],
103
+ floor=ref["floor_cost"], proven=ref["proven_optimal"], naive=ref["naive_reward"],
104
+ greedy=ref["greedy_reward"], greedy_fast=ref["greedy_fast_reward"],
105
+ real_cost=ref.get("real", {}).get("cost"))
106
+ if gap > MAX_GAP:
107
+ stats["reason"] = f"not solved within 1% ({gap:.3f})"
108
+ return None, stats
109
+ if ref["naive_reward"] > MAX_NAIVE_REWARD:
110
+ stats["reason"] = f"too easy: naive {ref['naive_reward']}"
111
+ return None, stats
112
+ if max(ref["greedy_reward"], ref["greedy_fast_reward"]) > MAX_GREEDY_REWARD:
113
+ stats["reason"] = f"too easy: greedy {max(ref['greedy_reward'], ref['greedy_fast_reward'])}"
114
+ return None, stats
115
+ ref["hardness"] = features(task)
116
+ stats["hardness"] = ref["hardness"]
117
+ stats["reason"] = "kept"
118
+ return task.to_dict(), stats
119
+ except ValueError as e:
120
+ if "no feasible plan" in str(e): # the record's state at `start` already breaks a rule (e.g. two trains
121
+ stats["reason"] = "infeasible: no legal plan from the rebuilt state" # stuck head-on)
122
+ else:
123
+ stats["reason"] = f"error {type(e).__name__}: {e}"
124
+ return None, stats
125
+ except Exception as e: # a generator or solver bug on one candidate is logged, not fatal
126
+ stats["reason"] = f"error {type(e).__name__}: {e}"
127
+ return None, stats
128
+ finally:
129
+ stats["seconds"] = round(time.time() - t0, 1)
130
+
131
+
132
+ def candidates(split: str, max_seed: int, tiers: list[str]) -> list[tuple]:
133
+ out = []
134
+ if "real" in tiers:
135
+ for d in real_disruptions(split):
136
+ for window in (180,):
137
+ out.append((d["day"], d["start"], window, "real", 0, split, d))
138
+ seeded = [t for t in tiers if t != "real"]
139
+ for seed in range(max_seed):
140
+ batch = []
141
+ for day in split_days(split):
142
+ for a, b in PEAKS:
143
+ for start in range(a, b + 1, 30):
144
+ for tier in seeded:
145
+ lo, hi = TIERS[tier]["window"]
146
+ window = lo + 30 * ((seed + start // 30) % ((hi - lo) // 30 + 1))
147
+ batch.append((day, start, window, tier, seed, split, None))
148
+ key = lambda c: hashlib.sha1(repr(c[:5]).encode()).hexdigest()
149
+ out += sorted(batch, key=key)
150
+ return out
151
+
152
+
153
+ def write_pack(out: Path, tasks: list[dict], log: list[dict], split: str, extra: dict | None = None):
154
+ out.mkdir(parents=True, exist_ok=True)
155
+ body = "".join(json.dumps(t, ensure_ascii=False, separators=(",", ":")) + "\n" for t in tasks).encode()
156
+ name = "tasks.jsonl"
157
+ if len(body) > 8_000_000:
158
+ (out / "tasks.jsonl.gz").write_bytes(gzip.compress(body, mtime=0))
159
+ (out / "tasks.jsonl").unlink(missing_ok=True)
160
+ name = "tasks.jsonl.gz"
161
+ else:
162
+ (out / name).write_bytes(body)
163
+ (out / "tasks.jsonl.gz").unlink(missing_ok=True)
164
+ tiers: dict[str, int] = {}
165
+ for t in tasks:
166
+ tiers[t["difficulty"]] = tiers.get(t["difficulty"], 0) + 1
167
+ manifest = {"name": out.name, "split": split, "tasks": len(tasks), "file": name, "tiers": tiers,
168
+ "sha256": hashlib.sha256(body).hexdigest(), "candidates": len(log),
169
+ "days": sorted({t["day"] for t in tasks}),
170
+ "proven_optimal": sum(bool(t["reference"].get("proven_optimal")) for t in tasks),
171
+ "runs": [min((len(t["runs"]) for t in tasks), default=0), max((len(t["runs"]) for t in tasks), default=0)],
172
+ "source": "Rijden de Treinen train archive and disruptions, 2024-2025 (CC BY 4.0); OpenStreetMap "
173
+ "(ODbL); see data/gnlw/SOURCE.md", **(extra or {})}
174
+ (out / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
175
+ (out / "build_log.jsonl").write_text("".join(json.dumps(s) + "\n" for s in log))
176
+ return manifest
177
+
178
+
179
+ def main():
180
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
181
+ ap.add_argument("--split", required=True, choices=["eval", "train"])
182
+ ap.add_argument("--n", type=int, required=True, help="tasks to keep")
183
+ ap.add_argument("--out", required=True)
184
+ ap.add_argument("--tiers", nargs="*", default=list(TIERS))
185
+ ap.add_argument("--time-limit", type=float, default=120.0)
186
+ ap.add_argument("--procs", type=int, default=2)
187
+ ap.add_argument("--workers", type=int, default=2, help="CP-SAT threads per candidate")
188
+ ap.add_argument("--max-seed", type=int, default=4)
189
+ ap.add_argument("--max-candidates", type=int, default=0, help="stop after trying this many (0: no cap)")
190
+ args = ap.parse_args()
191
+ out = Path(args.out)
192
+ part = out / "partial"
193
+ part.mkdir(parents=True, exist_ok=True)
194
+ log: list[dict] = []
195
+ kept: list[dict] = []
196
+ done = set()
197
+ if (part / "log.jsonl").is_file():
198
+ for line in (part / "log.jsonl").read_text().splitlines():
199
+ st = json.loads(line)
200
+ log.append(st)
201
+ done.add((st["day"], st["start"], st["window"], st["tier"], st["seed"]))
202
+ if (part / "kept.jsonl").is_file():
203
+ kept = [json.loads(line) for line in (part / "kept.jsonl").read_text().splitlines()]
204
+ queue = [c for c in candidates(args.split, args.max_seed, args.tiers) if c[:5] not in done]
205
+ print(f"resuming: {len(log)} tried, {len(kept)} kept, {len(queue)} queued", flush=True)
206
+ from concurrent.futures import FIRST_COMPLETED, wait
207
+
208
+ def more() -> bool:
209
+ return bool(queue) and len(kept) < args.n and (not args.max_candidates or len(log) + len(running)
210
+ < args.max_candidates)
211
+
212
+ with ProcessPoolExecutor(args.procs) as ex:
213
+ running = set()
214
+ while True:
215
+ while more() and len(running) < args.procs: # keep every worker busy
216
+ running.add(ex.submit(_one, queue.pop(0) + (args.time_limit, args.workers)))
217
+ if not running:
218
+ break
219
+ finished, running = wait(running, return_when=FIRST_COMPLETED)
220
+ for f in finished:
221
+ task, stats = f.result()
222
+ log.append(stats)
223
+ with open(part / "log.jsonl", "a") as fh:
224
+ fh.write(json.dumps(stats) + "\n")
225
+ if task and len(kept) < args.n:
226
+ kept.append(task)
227
+ with open(part / "kept.jsonl", "a") as fh:
228
+ fh.write(json.dumps(task, ensure_ascii=False, separators=(",", ":")) + "\n")
229
+ reasons: dict[str, int] = {}
230
+ for s_ in log:
231
+ k = s_["reason"].split(":")[0].split(" (")[0]
232
+ reasons[k] = reasons.get(k, 0) + 1
233
+ print(f"{len(log)} tried, {len(kept)} kept; {reasons}", flush=True)
234
+ if len(kept) >= args.n:
235
+ for f in running:
236
+ f.cancel()
237
+ break
238
+ kept.sort(key=lambda t: (t["difficulty"], t["day"], t["start"], t["task_id"]))
239
+ print(json.dumps(write_pack(out, kept, log, args.split), indent=2))
240
+
241
+
242
+ if __name__ == "__main__":
243
+ main()
core/rail_core/check.py ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The physics and the grader: a plan in, every train's times, every rule it breaks and its cost out. Pure Python and
2
+ deterministic; nothing here needs a solver.
3
+
4
+ Physics (`simulate`). Each train set works its runs in order. A run leaves its first point at the latest of: when its
5
+ train is ready there, its scheduled departure (or passing minute), and the plan's hold. It then runs at its minimum
6
+ running times, but never reaches a point before its timetable minute there (drivers run to the timetable; a late
7
+ train makes up time). At a scheduled stop it leaves at the latest of arriving, its scheduled departure and any hold; at a
8
+ point where it has no stop it passes, unless held past its passing minute: then it stops, losing `stop_loss` minutes
9
+ braking and `stop_loss` accelerating. Junctions cannot hold a train. After a run its train is ready again `turn_min`
10
+ minutes after arriving, at the point where the run ended.
11
+
12
+ Rules (`evaluate`), each broken one is a violation:
13
+ single track two trains going opposite ways may not be in a single-track section at once; the second enters
14
+ at least h_opp minutes after the first has left
15
+ headway trains going the same way keep h_follow minutes apart at both ends of every section, so nothing
16
+ overtakes between points
17
+ platforms no more trains at a station than it has tracks (minus any out of use); at a directional station
18
+ (double track) one per direction
19
+ closed no train in a closed section while it is closed (a train already stuck inside waits there)
20
+ continuity a run starts where its train is; short-turns only at turn points; a running train cannot be
21
+ cancelled or turned behind itself; every train ends the window where its next run leaves from
22
+ max delay no stop reached more than max_delay minutes late
23
+
24
+ Cost, in stop-minutes (lower is better): the arrival delay at every scheduled stop a train serves, plus cancel_cost
25
+ for every scheduled stop it does not serve, plus, for each train set, its lateness for its first run after the
26
+ window times that run's number of stops (delay handed on to the next window).
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from collections import defaultdict
32
+ from dataclasses import dataclass, field
33
+
34
+ from .model import Order, Plan, Run, Task, hhmm
35
+ from .network import Network
36
+
37
+ NEG = -10**9
38
+
39
+
40
+ @dataclass
41
+ class RunResult:
42
+ train: str
43
+ unit: str
44
+ cancelled: bool = False
45
+ first: int | None = None # route index of its first served point
46
+ last: int | None = None # route index of its last served point
47
+ arr: dict[int, int] = field(default_factory=dict)
48
+ dep: dict[int, int] = field(default_factory=dict)
49
+ stopped: dict[int, bool] = field(default_factory=dict)
50
+ delay: int = 0
51
+ unserved: int = 0
52
+ max_late: int = 0
53
+ cost: int = 0
54
+ problems: list[str] = field(default_factory=list)
55
+
56
+
57
+ @dataclass
58
+ class PlanResult:
59
+ feasible: bool
60
+ cost: int | None
61
+ delay_cost: int
62
+ cancel_cost: int
63
+ carry_cost: int
64
+ cancelled: int
65
+ short_turned: int
66
+ runs: list[RunResult]
67
+ violations: list[dict]
68
+
69
+
70
+ @dataclass
71
+ class _Occ: # a train in a section
72
+ sec: int
73
+ dir: int
74
+ enter: int
75
+ exit: int
76
+ train: str
77
+ history: bool = False
78
+
79
+
80
+ @dataclass
81
+ class _Pres: # a train standing at (or passing) a station, minutes inclusive
82
+ point: str
83
+ dir: int
84
+ t0: int
85
+ t1: int
86
+ train: str
87
+
88
+
89
+ def network(task: Task) -> Network:
90
+ n = task.__dict__.get("_net")
91
+ if n is None:
92
+ n = task.__dict__["_net"] = Network.from_dict(task.network)
93
+ return n
94
+
95
+
96
+ def run_dir(net: Network, r: Run) -> int:
97
+ return net.direction(r.origin, r.dest)
98
+
99
+
100
+ def served_part(task: Task, r: Run, o: Order) -> tuple[int, int, list[str]]:
101
+ """(first, last, problems) for the part of the route the plan runs."""
102
+ net = network(task)
103
+ probs = []
104
+ s = r.idx(o.start) if o.start else 0
105
+ e = r.idx(o.end) if o.end else len(r.route) - 1
106
+ if o.start and o.start != r.origin:
107
+ if r.started:
108
+ probs.append(f"{r.id} is already running from {r.origin}: it cannot start at {o.start}")
109
+ s = 0
110
+ elif not net.by_code[o.start].turn:
111
+ probs.append(f"{r.id} cannot start at {o.start}: trains can only be turned at "
112
+ + ", ".join(p.code for p in net.points if p.turn))
113
+ if o.end and o.end != r.dest and not net.by_code[o.end].turn:
114
+ probs.append(f"{r.id} cannot end at {o.end}: trains can only be turned at "
115
+ + ", ".join(p.code for p in net.points if p.turn))
116
+ if e <= s:
117
+ probs.append(f"{r.id}: 'to' {r.route[e]} must come after where it starts ({r.route[s]})")
118
+ e = len(r.route) - 1
119
+ if r.started and e < r.fixed_to:
120
+ probs.append(f"{r.id} is already past {r.route[e]}: it can end at {r.route[r.fixed_to]} at the earliest")
121
+ e = len(r.route) - 1
122
+ return s, e, probs
123
+
124
+
125
+ def simulate(task: Task, plan: Plan):
126
+ """Times for every run under the plan. Returns (results by train, structural violations, section occupancies,
127
+ station presences, carry cost per unit)."""
128
+ net = network(task)
129
+ loss, turn = task.rule("stop_loss"), task.rule("turn_min")
130
+ runs = task.run_map
131
+ results: dict[str, RunResult] = {}
132
+ viol: list[dict] = []
133
+ occ: list[_Occ] = []
134
+ pres: list[_Pres] = []
135
+ carry: dict[str, int] = {}
136
+ slow = [c for c in task.closures if c.kind == "slow"]
137
+
138
+ def slow_extra(sec: int, t: int) -> int:
139
+ x = 0
140
+ for c in slow:
141
+ lo, hi = sorted((net.index[c.where[0]], net.index[c.where[-1]]))
142
+ if lo <= sec < hi and c.start <= t < c.end:
143
+ x = max(x, c.extra)
144
+ return x
145
+
146
+ def finite(code: str) -> bool:
147
+ p = net.by_code[code]
148
+ return p.kind != "junction" and p.tracks is not None
149
+
150
+ for u in task.units:
151
+ pos, ready = u.at, u.ready
152
+ wait_from: int | None = task.start if (u.at and finite(u.at)) else None # standing at a station at start
153
+ for rid in u.runs:
154
+ r = runs[rid]
155
+ o = plan.get(rid) or Order()
156
+ res = RunResult(train=rid, unit=u.id)
157
+ results[rid] = res
158
+ d = run_dir(net, r)
159
+ if o.cancel:
160
+ if r.started:
161
+ res.problems.append(f"{rid} is already running: it cannot be cancelled (short-turn it instead)")
162
+ o = Order(end=o.end, hold=o.hold)
163
+ else:
164
+ res.cancelled = True
165
+ res.unserved = r.n_stops
166
+ continue
167
+ s, e, probs = served_part(task, r, o)
168
+ res.problems += probs
169
+ k = r.fixed_to if r.started else s
170
+ if not r.started and r.route[s] != pos:
171
+ res.problems.append(f"{rid} would start at {r.route[s]}, but its train ({u.id}) is at {pos}")
172
+ for code in o.hold:
173
+ i = r.idx(code)
174
+ if i < k or i >= e:
175
+ res.problems.append(f"{rid}: hold at {code} is outside the part it runs "
176
+ f"({r.route[k]} to {r.route[e]}, holds only where it leaves from)")
177
+ elif net.by_code[code].kind == "junction":
178
+ res.problems.append(f"{rid}: trains cannot be held at the junction {code}")
179
+ res.first, res.last = s, e
180
+ arr, dep, stopped = res.arr, res.dep, res.stopped
181
+ last_occ = None
182
+ if r.started:
183
+ arr[k] = r.fixed_arr
184
+ if r.fixed_from is not None:
185
+ last_occ = _Occ(net.section(r.route[k - 1], r.route[k]), d, r.fixed_from, r.fixed_arr, rid, True)
186
+ occ.append(last_occ)
187
+ i = k
188
+ while True:
189
+ code = r.route[i]
190
+ pt = net.by_code[code]
191
+ h = o.hold.get(code) if s <= i < e else None
192
+ accel = 0
193
+ if i == e:
194
+ stopped[i] = True
195
+ break
196
+ if i == k and not r.started: # where the run starts
197
+ t = max(ready if ready is not None else NEG, r.dep[i], h if h is not None else NEG)
198
+ stopped[i] = True
199
+ accel = 0 if r.stops[i] else loss
200
+ if wait_from is not None and finite(code):
201
+ pres.append(_Pres(code, d, wait_from, t, rid))
202
+ elif r.stops[i]:
203
+ t = max(arr[i], r.dep[i], h if h is not None else NEG)
204
+ stopped[i] = True
205
+ elif pt.kind != "junction" and h is not None and h > arr[i]:
206
+ arr[i] += loss # an unscheduled stop: it brakes ...
207
+ if last_occ is not None:
208
+ last_occ.exit = arr[i] # ... still in the section it is leaving
209
+ t = max(arr[i], h)
210
+ stopped[i] = True
211
+ accel = loss # ... and accelerates again
212
+ else:
213
+ t = arr[i]
214
+ stopped[i] = False
215
+ if r.started and i == k:
216
+ t = max(t, task.start) # standing there when the task starts
217
+ dep[i] = t
218
+ if (i != k or r.started) and finite(code):
219
+ pres.append(_Pres(code, d, arr[i], t, rid))
220
+ sec = net.section(code, r.route[i + 1])
221
+ nxt = t + r.seg[i] + accel + slow_extra(sec, t)
222
+ if r.arr[i + 1] is not None:
223
+ nxt = max(nxt, r.arr[i + 1]) # never ahead of the timetable
224
+ if i + 1 == e and not r.stops[e]:
225
+ nxt += loss # brakes to end where it has no scheduled stop
226
+ arr[i + 1] = nxt
227
+ last_occ = _Occ(sec, d, t, nxt, rid)
228
+ occ.append(last_occ)
229
+ i += 1
230
+ # cost
231
+ for j in range(k if r.started else s + 1, e + 1):
232
+ if r.stops[j] and r.arr[j] is not None and arr[j] >= task.start:
233
+ late = arr[j] - r.arr[j]
234
+ res.delay += max(0, late)
235
+ res.max_late = max(res.max_late, late)
236
+ res.unserved = sum(1 for j, st in enumerate(r.stops) if st and not (s <= j <= e))
237
+ pos, ready = r.route[e], arr[e] + turn
238
+ wait_from = arr[e] if finite(r.route[e]) else None
239
+ if wait_from is not None and pos is not None:
240
+ pres.append(_Pres(pos, 0, wait_from, task.end + 24 * 60, u.id))
241
+ if u.after:
242
+ if pos != u.after["station"]:
243
+ viol.append({"rule": "continuity", "trains": [u.after["run"]], "where": pos,
244
+ "detail": f"train set {u.id} ends the window at {pos}, but its next run "
245
+ f"{u.after['run']} leaves {u.after['station']} at {hhmm(u.after['dep'])}"})
246
+ elif ready is not None:
247
+ carry[u.id] = max(0, ready - u.after["dep"]) * int(u.after["stops"])
248
+ return results, viol, occ, pres, carry
249
+
250
+
251
+ def evaluate(task: Task, plan: Plan) -> PlanResult:
252
+ net = network(task)
253
+ results, viol, occ, pres, carry = simulate(task, plan)
254
+ hf, ho, maxd = task.rule("h_follow"), task.rule("h_opp"), task.rule("max_delay")
255
+ bad: dict[str, set] = defaultdict(set)
256
+
257
+ def add(rule: str, trains: list[str], where: str, time: int | None, detail: str):
258
+ viol.append({"rule": rule, "trains": trains, "where": where, "time": hhmm(time), "detail": detail})
259
+ for t in trains:
260
+ if t in results:
261
+ bad[t].add(rule)
262
+
263
+ for rid, res in results.items():
264
+ for p in res.problems:
265
+ add("continuity", [rid], "", None, p)
266
+ if res.max_late > maxd:
267
+ add("max_delay", [rid], "", None, f"{rid} reaches a stop {res.max_late} minutes late (limit {maxd}): "
268
+ f"short-turn or cancel it")
269
+
270
+ # sections: headways and single track
271
+ by_sec: dict[int, list[_Occ]] = defaultdict(list)
272
+ for o in occ:
273
+ by_sec[o.sec].append(o)
274
+ for sec, lst in by_sec.items():
275
+ s = net.sections[sec]
276
+ name = f"{s.a}-{s.b}"
277
+ lst.sort(key=lambda o: (o.enter, o.exit))
278
+ for i, a in enumerate(lst):
279
+ for b in lst[i + 1:]:
280
+ if b.enter > a.exit + max(hf, ho) and b.enter > a.enter + hf:
281
+ break
282
+ if a.train == b.train:
283
+ continue
284
+ if a.dir == b.dir:
285
+ if b.enter - a.enter < hf or b.exit - a.exit < hf:
286
+ what = "overtakes" if b.exit < a.exit else "follows too closely"
287
+ add("headway", [a.train, b.train], name, b.enter,
288
+ f"{b.train} {what} {a.train} in {name}: enter {hhmm(a.enter)}/{hhmm(b.enter)}, "
289
+ f"leave {hhmm(a.exit)}/{hhmm(b.exit)} (same direction: {hf} min apart at both ends, "
290
+ f"no overtaking between points)")
291
+ elif s.single and not (a.exit + ho <= b.enter or b.exit + ho <= a.enter):
292
+ add("single_track", [a.train, b.train], name, b.enter,
293
+ f"{a.train} ({hhmm(a.enter)}-{hhmm(a.exit)}) and {b.train} ({hhmm(b.enter)}-{hhmm(b.exit)}) "
294
+ f"meet head-on in single-track {name}")
295
+ # closures
296
+ for c in task.closures:
297
+ if c.kind != "closed":
298
+ continue
299
+ lo, hi = sorted((net.index[c.where[0]], net.index[c.where[-1]]))
300
+ for o in occ:
301
+ if lo <= o.sec < hi and not o.history and o.enter < c.end and o.exit > c.start:
302
+ s = net.sections[o.sec]
303
+ add("closed", [o.train], f"{s.a}-{s.b}", o.enter,
304
+ f"{o.train} is in {s.a}-{s.b} ({hhmm(o.enter)}-{hhmm(o.exit)}) while it is closed "
305
+ f"{hhmm(c.start)}-{hhmm(c.end)} ({c.cause})")
306
+ # platforms
307
+ out_of_use: dict[str, list] = defaultdict(list)
308
+ for c in task.closures:
309
+ if c.kind == "track":
310
+ out_of_use[c.where[0]].append((c.start, c.end))
311
+ by_pt: dict[str, list[_Pres]] = defaultdict(list)
312
+ for p in pres:
313
+ by_pt[p.point].append(p)
314
+ for code, lst in by_pt.items():
315
+ pt = net.by_code[code]
316
+ groups = [[p for p in lst if p.dir == d] for d in (1, -1)] if pt.directional else [lst]
317
+ for g in groups:
318
+ cap = 1 if pt.directional else pt.tracks
319
+ events = sorted({p.t0 for p in g} | {a for a, _ in out_of_use.get(code, [])})
320
+ reported = set()
321
+ for t in events:
322
+ here = [p for p in g if p.t0 <= t <= p.t1]
323
+ c_t = cap - sum(1 for a, b in out_of_use.get(code, []) if a <= t < b)
324
+ if len(here) > c_t:
325
+ key = tuple(sorted(p.train for p in here))
326
+ if key in reported:
327
+ continue
328
+ reported.add(key)
329
+ tracks = ("one track each way" if pt.directional else f"{c_t} track" + ("s" if c_t != 1 else ""))
330
+ add("platforms", list(key), code, t,
331
+ f"{len(here)} trains at {code} at {hhmm(t)} but it has {tracks}: "
332
+ + ", ".join(f"{p.train} {hhmm(p.t0)}-{hhmm(p.t1) if p.t1 < task.end + 1440 else 'end'}"
333
+ for p in here))
334
+
335
+ delay = sum(r.delay for r in results.values())
336
+ cancel = task.rule("cancel_cost") * sum(r.unserved for r in results.values())
337
+ carry_cost = sum(carry.values())
338
+ for rid, res in results.items():
339
+ res.cost = res.delay + task.rule("cancel_cost") * res.unserved
340
+ res.problems = sorted(bad.get(rid, set()))
341
+ feasible = not viol
342
+ n_cxl = sum(1 for r in results.values() if r.cancelled)
343
+ n_st = sum(1 for rid, r in results.items()
344
+ if not r.cancelled and (r.first != 0 or r.last != len(task.run(rid).route) - 1))
345
+ return PlanResult(feasible, delay + cancel + carry_cost if feasible else None, delay, cancel, carry_cost, n_cxl,
346
+ n_st, list(results.values()), viol)
347
+
348
+
349
+ def timetable(task: Task, res: RunResult) -> list[dict]:
350
+ """The served points of one run with arrival and departure (passing) minutes, for display."""
351
+ r = task.run(res.train)
352
+ net = network(task)
353
+ out = []
354
+ if res.cancelled:
355
+ return out
356
+ for i in sorted(set(res.arr) | set(res.dep)):
357
+ code = r.route[i]
358
+ if net.by_code[code].kind == "junction":
359
+ continue
360
+ out.append({"at": code, "arr": hhmm(res.arr.get(i)), "dep": hhmm(res.dep.get(i)),
361
+ "stop": bool(res.stopped.get(i)), "sched": hhmm(r.arr[i] if r.stops[i] and r.arr[i] is not None
362
+ else r.dep[i] if r.stops[i] else None),
363
+ "late": (res.arr[i] - r.arr[i]) if (r.stops[i] and r.arr[i] is not None and i in res.arr) else None})
364
+ return out
core/rail_core/data.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The curated record (data/gnlw): every Arriva train between Leeuwarden and Groningen, January-June 2025, with its
2
+ schedule, delays and cancellations; the derived rolling-stock links; the real disruptions; the minimum running times.
3
+
4
+ `day_runs(day)` turns one service day into model runs: the route through every point (junctions included), the
5
+ scheduled stops, the timetable's passing minutes at the points in between (interpolated by distance) and the
6
+ minimum running time of every section (the calibrated stop-to-stop minimum, split by distance).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import csv
12
+ import gzip
13
+ import json
14
+ from dataclasses import dataclass, field
15
+ from functools import lru_cache
16
+ from pathlib import Path
17
+
18
+ from .model import Run
19
+ from .network import DATA, Network, load_network
20
+
21
+ LINE_DIR = DATA / "gnlw"
22
+ KIND = {"Stoptrein": "stop", "Sneltrein": "fast"}
23
+
24
+
25
+ @dataclass
26
+ class Stop:
27
+ code: str
28
+ sa: int | None # scheduled arrival
29
+ sd: int | None # scheduled departure
30
+ ad: int | None # arrival delay
31
+ dd: int | None # departure delay
32
+ arr_cxl: bool
33
+ dep_cxl: bool
34
+
35
+ @property
36
+ def aa(self) -> int | None: # actual arrival
37
+ return None if self.sa is None or self.ad is None else self.sa + self.ad
38
+
39
+ @property
40
+ def adep(self) -> int | None: # actual departure
41
+ return None if self.sd is None or self.dd is None else self.sd + self.dd
42
+
43
+
44
+ @dataclass
45
+ class Service:
46
+ train: str
47
+ type: str
48
+ stops: list[Stop]
49
+ full_cxl: bool
50
+ part_cxl: bool
51
+
52
+ @property
53
+ def kind(self) -> str:
54
+ return KIND.get(self.type, "stop")
55
+
56
+
57
+ @dataclass
58
+ class Day:
59
+ date: str
60
+ services: dict[str, Service]
61
+ links: dict[str, str] # arriving train -> the train it forms next
62
+ runs: dict[str, Run] = field(default_factory=dict)
63
+ chains: list[list[str]] = field(default_factory=list)
64
+
65
+
66
+ def _int(x: str) -> int | None:
67
+ return int(x) if x not in ("", None) else None
68
+
69
+
70
+ @lru_cache(maxsize=1)
71
+ def _all_services() -> dict[str, dict[str, Service]]:
72
+ by_day: dict[str, dict[str, Service]] = {}
73
+ rows: dict[tuple, list[dict]] = {}
74
+ with gzip.open(LINE_DIR / "services_2025h1.csv.gz", "rt", encoding="utf-8") as f:
75
+ for r in csv.DictReader(f):
76
+ rows.setdefault((r["date"], r["svc"]), []).append(r)
77
+ for (day, _), rs in rows.items():
78
+ rs.sort(key=lambda r: int(r["seq"]))
79
+ s = Service(rs[0]["train"], rs[0]["type"],
80
+ [Stop(r["code"], _int(r["sched_arr"]), _int(r["sched_dep"]), _int(r["arr_delay"]),
81
+ _int(r["dep_delay"]), r["arr_cxl"] == "1", r["dep_cxl"] == "1") for r in rs],
82
+ rs[0]["full_cxl"] == "1", rs[0]["part_cxl"] == "1")
83
+ by_day.setdefault(day, {})
84
+ if s.train in by_day[day]: # a number used twice in a day: keep the first, number the second
85
+ s.train = f"{s.train}b"
86
+ by_day[day][s.train] = s
87
+ return by_day
88
+
89
+
90
+ @lru_cache(maxsize=1)
91
+ def _all_links() -> dict[str, dict[str, str]]:
92
+ out: dict[str, dict[str, str]] = {}
93
+ with open(LINE_DIR / "links_2025h1.csv", encoding="utf-8") as f:
94
+ for r in csv.DictReader(f):
95
+ out.setdefault(r["date"], {})[r["arriving"]] = r["forms"]
96
+ return out
97
+
98
+
99
+ @lru_cache(maxsize=1)
100
+ def runtimes() -> dict:
101
+ return json.loads((LINE_DIR / "runtimes.json").read_text())
102
+
103
+
104
+ def disruptions() -> list[dict]:
105
+ with open(LINE_DIR / "disruptions_2025h1.csv", encoding="utf-8") as f:
106
+ return list(csv.DictReader(f))
107
+
108
+
109
+ def days() -> list[str]:
110
+ return sorted(_all_services())
111
+
112
+
113
+ def span_minutes(net: Network, kind: str, a: str, b: str) -> int:
114
+ """Minimum running minutes between consecutive scheduled stops a and b."""
115
+ rt = runtimes()
116
+ hit = rt.get(kind, {}).get(f"{a}>{b}")
117
+ if hit:
118
+ return int(hit["min"])
119
+ # fallback: the stopping train's legs, less a minute per stop skipped
120
+ route = [c for c in net.route(a, b) if net.by_code[c].kind != "junction"]
121
+ total = 0
122
+ for x, y in zip(route, route[1:]):
123
+ leg = rt["stop"].get(f"{x}>{y}")
124
+ if leg is None:
125
+ raise KeyError(f"no running time for {kind} {a}>{b}")
126
+ total += int(leg["min"])
127
+ return max(1, total - (len(route) - 2))
128
+
129
+
130
+ def clean_stops(net: Network, s: Service) -> list[Stop]:
131
+ """The service's stops in route order with both times where it calls on the way. On disrupted days the record
132
+ holds services changed on the day: an extra stop with only one time, or stops listed out of order."""
133
+ timed = sorted(s.stops, key=lambda st: st.sd if st.sd is not None else (st.sa if st.sa is not None else 10**9))
134
+ first, last = timed[0].code, timed[-1].code
135
+ d = net.direction(first, last)
136
+ seen: dict[str, Stop] = {}
137
+ for st in s.stops:
138
+ seen.setdefault(st.code, st)
139
+ out = sorted(seen.values(), key=lambda st: d * net.index[st.code])
140
+ for i, st in enumerate(out):
141
+ if 0 < i < len(out) - 1:
142
+ if st.sa is None:
143
+ st.sa = st.sd
144
+ if st.sd is None:
145
+ st.sd = st.sa
146
+ if out[0].sd is None:
147
+ out[0].sd = out[0].sa
148
+ if out[-1].sa is None:
149
+ out[-1].sa = out[-1].sd
150
+ times = [t for st in out for t in (st.sa, st.sd) if t is not None]
151
+ if any(b < a for a, b in zip(times, times[1:])):
152
+ raise ValueError(f"service {s.train}: times not in route order")
153
+ return out
154
+
155
+
156
+ def make_run(net: Network, s: Service, unit: str = "") -> Run:
157
+ stops_rec = clean_stops(net, s)
158
+ codes = [st.code for st in stops_rec]
159
+ route = net.route(codes[0], codes[-1])
160
+ stop_at = {st.code: st for st in stops_rec}
161
+ n = len(route)
162
+ stops = [c in stop_at for c in route]
163
+ arr: list[int | None] = [None] * n
164
+ dep: list[int | None] = [None] * n
165
+ seg = [0] * (n - 1)
166
+ idx = [i for i in range(n) if stops[i]]
167
+ for i in idx:
168
+ st = stop_at[route[i]]
169
+ arr[i] = st.sa if i > 0 else None
170
+ dep[i] = st.sd if i < n - 1 else None
171
+ for ia, ib in zip(idx, idx[1:]):
172
+ a, b = stop_at[route[ia]], stop_at[route[ib]]
173
+ t_min = span_minutes(net, s.kind, route[ia], route[ib])
174
+ t_sched = (b.sa if b.sa is not None else b.sd) - (a.sd if a.sd is not None else a.sa)
175
+ ka, kb = net.by_code[route[ia]].km, net.by_code[route[ib]].km
176
+ cum = [round(t_min * abs(net.by_code[route[i]].km - ka) / abs(kb - ka)) for i in range(ia, ib + 1)]
177
+ for j in range(1, len(cum)): # every section takes at least a minute
178
+ cum[j] = max(cum[j], cum[j - 1] + 1)
179
+ for j in range(ia, ib):
180
+ seg[j] = cum[j - ia + 1] - cum[j - ia]
181
+ t0 = a.sd if a.sd is not None else a.sa
182
+ for i in range(ia + 1, ib):
183
+ arr[i] = dep[i] = t0 + round(t_sched * abs(net.by_code[route[i]].km - ka) / abs(kb - ka))
184
+ return Run(id=s.train, kind=s.kind, route=route, stops=stops, arr=arr, dep=dep, seg=seg, unit=unit)
185
+
186
+
187
+ @lru_cache(maxsize=64)
188
+ def load_day(date: str) -> Day:
189
+ net = load_network()
190
+ services = dict(_all_services()[date])
191
+ for t in list(services): # a handful of services changed on the day are unreadable: drop them
192
+ try:
193
+ clean_stops(net, services[t])
194
+ except ValueError:
195
+ del services[t]
196
+ links = {a: f for a, f in _all_links().get(date, {}).items() if a in services and f in services}
197
+ day = Day(date, services, links)
198
+ formed = set(links.values())
199
+ starts = sorted((t for t in services if t not in formed), key=lambda t: services[t].stops[0].sd or 0)
200
+ for k, t in enumerate(starts):
201
+ chain = [t]
202
+ while chain[-1] in links:
203
+ chain.append(links[chain[-1]])
204
+ day.chains.append(chain)
205
+ for x in chain:
206
+ day.runs[x] = make_run(net, services[x], unit=f"T{k + 1:02d}")
207
+ return day
core/rail_core/generate.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A task from a real day: the line as it really stood at `start`, every run that leaves before `end`, the closures.
2
+
3
+ The state at `start` comes from the 2025 record:
4
+ a train running then is placed where its actual departure from its last stop puts it (passing points by the
5
+ minimum running times); it keeps its real delay
6
+ a train set standing at a terminal is ready `turn_min` after its last run's actual arrival
7
+ Everything after `start` is decided by the plan; the record's later times are kept only to score what the real
8
+ dispatchers did (`reference.real`).
9
+
10
+ A window is refused (`None`) when it cannot be rebuilt honestly: a run before `start` was cancelled or cut short in
11
+ the record (its train is then not where the schedule says), or the plain timetable already conflicts inside it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from .check import evaluate, network
17
+ from .data import Day, Service, load_day
18
+ from .model import RULES, Closure, Order, Run, Task, Unit, hhmm
19
+ from .network import Network, load_network
20
+
21
+ # causes after which the train involved is itself out of service (it blocks the section until the line reopens)
22
+ FAILURES = ("broken down train", "collision", "defective train", "a collision", "an accident")
23
+
24
+
25
+ def _actual(svc: Service, code: str) -> tuple[int | None, int | None, bool]:
26
+ """(actual arrival, actual departure, cancelled) at a stop of the record."""
27
+ for st in svc.stops:
28
+ if st.code == code:
29
+ return st.aa, st.adep, (st.arr_cxl or st.sa is None) and (st.dep_cxl or st.sd is None)
30
+ return None, None, True
31
+
32
+
33
+ def _is_failure(net: Network, r: Run, served: list[str], closed: list[Closure]) -> bool:
34
+ """The run that broke down (or collided): cut short by a failure closure, last served next to the closed span."""
35
+ for c in closed:
36
+ if not any(f in c.cause.lower() for f in FAILURES):
37
+ continue
38
+ lo, hi = sorted((net.index[c.where[0]], net.index[c.where[-1]]))
39
+ last = served[-1] if served else r.origin
40
+ if lo - 1 <= net.index[last] <= hi + 1:
41
+ return True
42
+ return False
43
+
44
+
45
+ def _served_in_record(svc: Service) -> list[str]:
46
+ return [st.code for st in svc.stops if not ((st.arr_cxl or st.sa is None) and (st.dep_cxl or st.sd is None))]
47
+
48
+
49
+ def _place(net: Network, r: Run, svc: Service, start: int, closed: list[Closure]):
50
+ """Where a run is at `start`, from the record: None (it was cut short before `start`: the window cannot be
51
+ rebuilt), ("done", actual arrival), ("future", None), or (fixed_to, fixed_arr, fixed_from, stuck)."""
52
+ last_t = last_i = None
53
+ stop_at = len(r.route) - 1 # the first stop it had not reached by `start`
54
+ for i, code in enumerate(r.route):
55
+ if not r.stops[i]:
56
+ continue
57
+ aa, ad, cxl = _actual(svc, code)
58
+ t_sched = r.arr[i] if r.arr[i] is not None else r.dep[i]
59
+ if cxl:
60
+ if t_sched < start:
61
+ return None
62
+ stop_at = i
63
+ break
64
+ if i > 0 and (aa is None or aa > start):
65
+ stop_at = i
66
+ break
67
+ if i == len(r.route) - 1:
68
+ return ("done", aa)
69
+ if ad is None or ad > start:
70
+ return ("future", None) if i == 0 else (i, aa, None, False)
71
+ last_t, last_i = ad, i
72
+ if last_i is None:
73
+ return ("future", None)
74
+ t = last_t
75
+ for i in range(last_i, stop_at):
76
+ nxt = t + r.seg[i]
77
+ if r.arr[i + 1] is not None:
78
+ nxt = max(nxt, r.arr[i + 1])
79
+ sec = net.section(r.route[i], r.route[i + 1])
80
+ for c in closed:
81
+ lo, hi = sorted((net.index[c.where[0]], net.index[c.where[-1]]))
82
+ if lo <= sec < hi and c.start <= start < c.end and t < c.start:
83
+ return (i + 1, max(start, c.end + max(0, nxt - c.start)), t, True) # caught in the closed section
84
+ if nxt >= start or i + 1 == stop_at:
85
+ return (i + 1, max(start, nxt), t, False)
86
+ t = nxt
87
+ return ("done", t)
88
+
89
+
90
+ def build_task(date: str, start: int, end: int, closures: list[Closure] | None = None, *, task_id: str = "",
91
+ split: str = "train", difficulty: str = "standard", notices: list[str] | None = None,
92
+ disruptions: list[dict] | None = None, rules: dict | None = None,
93
+ lookback: int = 150, why: list | None = None) -> Task | None:
94
+ why = why if why is not None else []
95
+ net = load_network()
96
+ day = load_day(date)
97
+ rules = {**RULES, **(rules or {})}
98
+ turn = rules["turn_min"]
99
+ closures = list(closures or [])
100
+ closed = [c for c in closures if c.kind == "closed"]
101
+ runs: list[Run] = []
102
+ units: list[Unit] = []
103
+ notes = list(notices or [])
104
+ real_runs: dict[str, dict] = {}
105
+ failed = None # (unit, run, last stop): the train that broke down
106
+ for chain in day.chains:
107
+ u_runs: list[Run] = []
108
+ at, ready, after = None, None, None
109
+ last_hist: Service | None = None
110
+ for rid in chain:
111
+ r0 = day.runs[rid]
112
+ svc = day.services[rid]
113
+ sched_dep = r0.dep[0]
114
+ if sched_dep < start - lookback: # long before the window: history
115
+ last_hist = svc
116
+ at, ready = r0.dest, None
117
+ aa, _, cxl = _actual(svc, r0.dest)
118
+ if aa is not None and not cxl:
119
+ ready = aa + turn
120
+ continue
121
+ if last_hist is not None:
122
+ if last_hist.full_cxl or last_hist.part_cxl:
123
+ why.append(f"{last_hist.train} (before the window) was cut short: its train is not where the "
124
+ f"schedule says")
125
+ return None
126
+ last_hist = None
127
+ if sched_dep >= end:
128
+ after = {"run": rid, "station": r0.origin, "dep": sched_dep, "stops": r0.n_stops}
129
+ break
130
+ if svc.full_cxl or svc.part_cxl:
131
+ served = _served_in_record(svc)
132
+ cut_before = [c for c in r0.route if r0.stops[r0.route.index(c)] and c not in served]
133
+ early = [c for c in cut_before if (r0.arr[r0.idx(c)] or r0.dep[r0.idx(c)]) < start]
134
+ if early:
135
+ if failed is None and _is_failure(net, r0, served, closed):
136
+ failed = (r0.unit, rid, served[-1] if served else r0.origin)
137
+ u_runs = []
138
+ break
139
+ why.append(f"{rid} was cut short before the window ({', '.join(early)})")
140
+ return None
141
+ r = Run(**{**r0.__dict__})
142
+ r.route, r.stops, r.arr, r.dep, r.seg = list(r0.route), list(r0.stops), list(r0.arr), list(r0.dep), list(r0.seg)
143
+ placed = _place(net, r, svc, start, closed)
144
+ if placed is None:
145
+ why.append(f"{rid}: a stop before the window was cancelled")
146
+ return None
147
+ if isinstance(placed, tuple) and placed[0] == "done":
148
+ at, ready = r.dest, max(start, placed[1] + turn)
149
+ continue
150
+ if isinstance(placed, tuple) and placed[0] == "future":
151
+ u_runs.append(r)
152
+ elif isinstance(placed, tuple):
153
+ fixed_to, fixed_arr, fixed_from, stuck = placed
154
+ if u_runs: # a later run of the same set cannot be running already
155
+ why.append(f"{rid} is running but an earlier run of its set {r.unit} has not")
156
+ return None
157
+ r.fixed_to, r.fixed_arr, r.fixed_from, r.stuck = fixed_to, fixed_arr, fixed_from, stuck
158
+ u_runs.append(r)
159
+ at, ready = None, None
160
+ real_runs[rid] = _real(r, svc, start)
161
+ if not u_runs or (failed and u_runs[0].unit == failed[0]):
162
+ continue
163
+ if u_runs[0].started:
164
+ at, ready = None, None
165
+ else:
166
+ if at is None:
167
+ at = u_runs[0].origin
168
+ ready = start if ready is None else max(start, ready)
169
+ units.append(Unit(id=u_runs[0].unit, runs=[r.id for r in u_runs], at=at, ready=ready, after=after))
170
+ runs += u_runs
171
+ if not runs:
172
+ why.append("no runs")
173
+ return None
174
+ if failed:
175
+ c = closed[0]
176
+ notes.insert(0, f"Train {failed[1]} broke down after {failed[2]} and is stuck in {c.where[0]}-{c.where[-1]}; "
177
+ f"its train set is out of service for the rest of the window, so its later runs are already "
178
+ f"cancelled and are not yours to plan.")
179
+ task = Task(task_id=task_id or f"{date}-{hhmm(start).replace(':', '')}", split=split, line="GN-LW", day=date,
180
+ start=start, end=end, network=net.to_dict(), runs=runs, units=units, closures=closures, notices=notes,
181
+ disruptions=list(disruptions or []), difficulty=difficulty, reference={}, rules=rules)
182
+ # the plain timetable (no delays, no closures) must be conflict-free in the window
183
+ calm = Task.from_dict({**task.to_dict(), "closures": []})
184
+ for r in calm.runs:
185
+ r.fixed_to, r.fixed_arr, r.fixed_from, r.stuck = 0, None, None, False
186
+ for u in calm.units:
187
+ first = calm.run(u.runs[0])
188
+ u.at, u.ready = first.origin, None
189
+ calm_res = evaluate(calm, {})
190
+ if not calm_res.feasible:
191
+ why.append("the plain timetable conflicts in the window: " + calm_res.violations[0]["detail"][:160])
192
+ return None
193
+ task.reference["real"] = _real_cost(task, real_runs)
194
+ return task
195
+
196
+
197
+ def _real(r: Run, svc: Service, start: int) -> dict:
198
+ """What really happened to a run: actual arrivals at the stops it served, and the stops it did not."""
199
+ arr, unserved = {}, 0
200
+ for i, code in enumerate(r.route):
201
+ if not r.stops[i]:
202
+ continue
203
+ aa, ad, cxl = _actual(svc, code)
204
+ if cxl:
205
+ unserved += 1
206
+ elif i > 0 and aa is not None:
207
+ arr[code] = aa
208
+ return {"arr": arr, "unserved": unserved, "cancelled": svc.full_cxl, "short": svc.part_cxl and not svc.full_cxl}
209
+
210
+
211
+ def _real_cost(task: Task, real_runs: dict[str, dict]) -> dict:
212
+ """The record's outcome scored with the task's cost (delays at served stops reached after `start`, cancel cost
213
+ for unserved stops). It is what Arriva and ProRail did with their information; it need not obey this model."""
214
+ delay = unserved = cxl = short = 0
215
+ for r in task.runs:
216
+ x = real_runs.get(r.id)
217
+ if x is None:
218
+ continue
219
+ for i, code in enumerate(r.route):
220
+ if r.stops[i] and code in x["arr"] and x["arr"][code] >= task.start and r.arr[i] is not None:
221
+ delay += max(0, x["arr"][code] - r.arr[i])
222
+ unserved += x["unserved"]
223
+ cxl += bool(x["cancelled"])
224
+ short += bool(x["short"])
225
+ return {"cost": delay + task.rule("cancel_cost") * unserved, "delay": delay, "unserved_stops": unserved,
226
+ "cancelled": cxl, "short_turned": short}
core/rail_core/hardness.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hardness features of a task: model-independent properties that predict how badly an agent will do on it.
2
+
3
+ They come from the task and its reference only (no language model), so an eval set can be chosen by them without
4
+ ever looking at a model's results on the eval tasks themselves (see `select`).
5
+
6
+ runs, sets problem size
7
+ conflicts rule breaks if nothing is changed (single track, headway, platforms, closures)
8
+ decisions holds + short-turns + cancellations in the proven optimum: how much must change
9
+ turn_points distinct points where the optimum short-turns trains
10
+ avoidable optimum - floor: what good sequencing saves over the train sets planned alone
11
+ greedy_gap how far the best greedy dispatcher is from the optimum, in reward
12
+ closed_min, slow_min, track_min minutes of each kind of disruption inside the window
13
+ single_closed a closure on single track (no way round it)
14
+ mid_line a closure with turn points on both sides (trains must be turned at both ends of it)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections import Counter
20
+
21
+ from .check import evaluate, network
22
+ from .model import Task, plan_from_list
23
+
24
+
25
+ def features(task: Task) -> dict:
26
+ net = network(task)
27
+ ref = task.reference
28
+ naive = evaluate(task, {})
29
+ rules = Counter(v["rule"] for v in naive.violations)
30
+ opt = ref.get("optimal_plan") or []
31
+ holds = sum(len(e.get("hold") or {}) for e in opt)
32
+ shorts = sum(1 for e in opt if ("to" in e or "from" in e) and not e.get("cancel"))
33
+ cancels = sum(1 for e in opt if e.get("cancel"))
34
+ turn_pts = {e.get("to") for e in opt if e.get("to")} | {e.get("from") for e in opt if e.get("from")}
35
+ clip = lambda c: max(0, min(c.end, task.end) - max(c.start, task.start))
36
+ single_closed = mid_line = False
37
+ for c in task.closures:
38
+ if c.kind != "closed":
39
+ continue
40
+ lo, hi = sorted((net.index[c.where[0]], net.index[c.where[-1]]))
41
+ if any(net.sections[k].single for k in range(lo, hi)):
42
+ single_closed = True
43
+ west = any(p.turn for p in net.points[:lo + 1])
44
+ east = any(p.turn for p in net.points[hi:])
45
+ if lo > 0 and hi < len(net.points) - 1 and west and east:
46
+ mid_line = True
47
+ greedy = max(ref.get("greedy_reward", 0.0), ref.get("greedy_fast_reward", 0.0))
48
+ return {
49
+ "runs": len(task.runs), "sets": len(task.units), "stops": sum(r.n_stops for r in task.runs),
50
+ "conflicts": len(naive.violations), "single_track": rules.get("single_track", 0),
51
+ "headway": rules.get("headway", 0), "platforms": rules.get("platforms", 0), "closed": rules.get("closed", 0),
52
+ "decisions": holds + shorts + cancels, "holds": holds, "short_turns": shorts, "cancels": cancels,
53
+ "turn_points": len(turn_pts), "avoidable": ref.get("optimal_cost", 0) - ref.get("floor_cost", 0),
54
+ "optimal": ref.get("optimal_cost", 0), "greedy_gap": round(1.0 - greedy, 4),
55
+ "closed_min": sum(clip(c) for c in task.closures if c.kind == "closed"),
56
+ "slow_min": sum(clip(c) for c in task.closures if c.kind == "slow"),
57
+ "track_min": sum(clip(c) for c in task.closures if c.kind == "track"),
58
+ "single_closed": single_closed, "mid_line": mid_line, "late_sets": sum("is held for" in n for n in task.notices),
59
+ }
core/rail_core/model.py ADDED
@@ -0,0 +1,307 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tasks and plans.
2
+
3
+ A task is a window of one real 2025 day on the Groningen - Leeuwarden line after something went wrong. The agent is
4
+ the dispatcher: every train (a *run* from one end of the line to the other) runs by default exactly as scheduled and
5
+ as early as the physics allow (never ahead of its timetable). The plan says only what to change:
6
+
7
+ hold keep a train at a station until a minute (to let an opposing train through a single-track section,
8
+ or to wait for a section to reopen)
9
+ to short-turn: end the run early at a turn point, so the same train can start back from there
10
+ from start a run late at a turn point (where its train was short-turned)
11
+ cancel do not run it at all (its train stays where it is)
12
+
13
+ The physics, given a plan, are deterministic (`check.simulate`): a train leaves each point at the latest of when it
14
+ is ready, its scheduled departure (never early at a scheduled stop) and its hold; it runs at its minimum running
15
+ time. A train held where it has no scheduled stop loses `stop_loss` minutes braking and `stop_loss` accelerating.
16
+ Whether the result is legal (single-track and headway conflicts, platform tracks, closed sections, where each train
17
+ ends up) is `check.evaluate`.
18
+
19
+ Times are whole minutes after 00:00 of the service day (00:30 the next night is 24:30, minute 1470); plans may give
20
+ them as integers or "HH:MM".
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import re
27
+ from dataclasses import asdict, dataclass, field
28
+ from typing import Any
29
+
30
+ RULES = {
31
+ "h_follow": 3, # minutes between two trains going the same way, at both ends of a section
32
+ "h_opp": 0, # minutes after a train leaves a single-track section before an opposing train may enter it
33
+ # (0: in the same minute, as the timetable's crossings are planned)
34
+ "turn_min": 4, # minutes for a train to turn back (driver changes ends)
35
+ "stop_loss": 1, # minutes lost braking, and again accelerating, at a stop that is not in the timetable
36
+ "cancel_cost": 30, # per scheduled stop a train does not serve (the next train of its kind is 30 minutes later)
37
+ "max_delay": 120, # no train may reach a stop more than this many minutes late
38
+ "gap_k": 100, # reward: added to the avoidable cost (about three unserved stops)
39
+ }
40
+
41
+
42
+ @dataclass
43
+ class Run:
44
+ """One scheduled train from one end of its route to the other."""
45
+ id: str # train number
46
+ kind: str # stop (Stoptrein: every station) | fast (Sneltrein)
47
+ route: list[str] # every point from origin to destination in travel order, junctions included
48
+ stops: list[bool] # scheduled stop at route[i]
49
+ arr: list[int | None] # scheduled arrival at a stop, the timetable's passing minute elsewhere; None at origin
50
+ dep: list[int | None] # scheduled departure at a stop, the passing minute elsewhere; None at destination
51
+ seg: list[int] # minimum running minutes route[i] -> route[i + 1] for this train's stopping pattern
52
+ unit: str # the train (rolling-stock unit) that runs it
53
+ fixed_to: int = 0 # in progress: route index of the point it is at or approaching when the task starts
54
+ fixed_arr: int | None = None # in progress: its arrival there (cannot change)
55
+ fixed_from: int | None = None # in progress and between points: the minute it left route[fixed_to - 1]
56
+ stuck: bool = False # in progress inside a section that closed: it waits there for the reopening
57
+
58
+ @property
59
+ def origin(self) -> str:
60
+ return self.route[0]
61
+
62
+ @property
63
+ def dest(self) -> str:
64
+ return self.route[-1]
65
+
66
+ @property
67
+ def started(self) -> bool:
68
+ return self.fixed_arr is not None
69
+
70
+ @property
71
+ def n_stops(self) -> int:
72
+ return sum(self.stops)
73
+
74
+ def idx(self, code: str) -> int:
75
+ return self.route.index(code)
76
+
77
+
78
+ @dataclass
79
+ class Unit:
80
+ """A train set and the runs it works through the window, in order."""
81
+ id: str
82
+ runs: list[str]
83
+ at: str | None # where it stands before its first run (None: its first run is already moving)
84
+ ready: int | None # when it may leave `at`
85
+ after: dict | None = None # its first run after the window: {"run", "station", "dep", "stops"}
86
+
87
+
88
+ @dataclass
89
+ class Closure:
90
+ kind: str # closed (no train may be in the sections) | slow (+extra minutes) | track (one
91
+ # platform track out of use at a station)
92
+ where: list[str] # closed/slow: [a, b], every section between points a and b; track: [station]
93
+ start: int
94
+ end: int # exclusive
95
+ extra: int = 0
96
+ cause: str = ""
97
+ real: bool = False # from the 2025 disruption record (else generated)
98
+
99
+
100
+ @dataclass
101
+ class Task:
102
+ task_id: str
103
+ split: str
104
+ line: str
105
+ day: str
106
+ start: int # "now": the minute the agent takes over
107
+ end: int # runs leaving their origin before this minute are in the task
108
+ network: dict
109
+ runs: list[Run]
110
+ units: list[Unit]
111
+ closures: list[Closure]
112
+ notices: list[str]
113
+ disruptions: list[dict]
114
+ difficulty: str
115
+ reference: dict = field(default_factory=dict) # optimum, floor, baselines, the real outcome: never shown
116
+ rules: dict = field(default_factory=lambda: dict(RULES))
117
+
118
+ def to_dict(self, public: bool = False) -> dict:
119
+ d = asdict(self)
120
+ if public:
121
+ d.pop("reference")
122
+ return d
123
+
124
+ @classmethod
125
+ def from_dict(cls, d: dict) -> "Task":
126
+ d = dict(d)
127
+ d["runs"] = [Run(**r) for r in d["runs"]]
128
+ d["units"] = [Unit(**u) for u in d["units"]]
129
+ d["closures"] = [Closure(**c) for c in d["closures"]]
130
+ d.setdefault("reference", {})
131
+ d["rules"] = {**RULES, **(d.get("rules") or {})}
132
+ return cls(**d)
133
+
134
+ def run(self, rid: str) -> Run:
135
+ return self.run_map[rid]
136
+
137
+ @property
138
+ def run_map(self) -> dict[str, Run]:
139
+ m = self.__dict__.get("_run_map")
140
+ if m is None:
141
+ m = self.__dict__["_run_map"] = {r.id: r for r in self.runs}
142
+ return m
143
+
144
+ def rule(self, k: str) -> int:
145
+ return int(self.rules.get(k, RULES[k]))
146
+
147
+
148
+ # ---------------------------------------------------------------- plans
149
+
150
+ @dataclass
151
+ class Order:
152
+ """What the plan changes for one run (absent from the plan: runs in full, as early as possible)."""
153
+ cancel: bool = False
154
+ start: str | None = None # "from": start at this turn point instead of the origin
155
+ end: str | None = None # "to": end at this turn point instead of the destination
156
+ hold: dict[str, int] = field(default_factory=dict) # point -> do not leave before this minute
157
+
158
+ def to_json(self, rid: str) -> dict:
159
+ d: dict[str, Any] = {"train": rid}
160
+ if self.cancel:
161
+ d["cancel"] = True
162
+ return d
163
+ if self.start:
164
+ d["from"] = self.start
165
+ if self.end:
166
+ d["to"] = self.end
167
+ if self.hold:
168
+ d["hold"] = {k: hhmm(v) for k, v in self.hold.items()}
169
+ return d
170
+
171
+
172
+ Plan = dict[str, Order]
173
+
174
+
175
+ class PlanError(ValueError):
176
+ """The plan cannot be read at all."""
177
+
178
+
179
+ def hhmm(m: int | None) -> str | None:
180
+ if m is None:
181
+ return None
182
+ return f"{m // 60:02d}:{m % 60:02d}"
183
+
184
+
185
+ _TIME = re.compile(r"^\s*(\d{1,2}):(\d{2})\s*$")
186
+
187
+
188
+ def parse_time(v: Any) -> int:
189
+ if isinstance(v, bool):
190
+ raise ValueError(f"not a time: {v!r}")
191
+ if isinstance(v, int):
192
+ m = v
193
+ elif isinstance(v, float):
194
+ if v != v or v in (float("inf"), float("-inf")) or v != int(v):
195
+ raise ValueError(f"not a whole minute: {v!r}")
196
+ m = int(v)
197
+ elif isinstance(v, str):
198
+ s = v.strip()
199
+ if re.fullmatch(r"-?\d+", s):
200
+ m = int(s)
201
+ else:
202
+ g = _TIME.match(s)
203
+ if not g:
204
+ raise ValueError(f'not a time: {v!r} (use "HH:MM" or minutes after midnight)')
205
+ h, mi = int(g.group(1)), int(g.group(2))
206
+ if mi > 59:
207
+ raise ValueError(f"not a time: {v!r}")
208
+ m = h * 60 + mi
209
+ else:
210
+ raise ValueError(f"not a time: {v!r}")
211
+ if not 0 <= m <= 48 * 60:
212
+ raise ValueError(f"time out of range: {v!r}")
213
+ return m
214
+
215
+
216
+ def _load(raw: Any) -> Any:
217
+ if isinstance(raw, str):
218
+ s = raw.strip()
219
+ fence = re.search(r"```(?:json)?\s*(.*?)```", s, re.S)
220
+ if fence:
221
+ s = fence.group(1).strip()
222
+ if not s.startswith(("[", "{")):
223
+ i = min([k for k in (s.find("["), s.find("{")) if k >= 0], default=-1)
224
+ if i < 0:
225
+ raise PlanError("the plan is not JSON: give a list like "
226
+ '[{"train": "37413", "hold": {"HDG": "07:31"}}, {"train": "37324", "cancel": true}]')
227
+ s = s[i:]
228
+ try:
229
+ raw, _ = json.JSONDecoder().raw_decode(s)
230
+ except json.JSONDecodeError as e:
231
+ raise PlanError(f"the plan is not valid JSON: {e}") from None
232
+ if isinstance(raw, dict):
233
+ if "plan" in raw and isinstance(raw["plan"], (list, dict)):
234
+ return _load(raw["plan"])
235
+ # {"37413": {...}, ...}
236
+ raw = [{"train": k, **(v if isinstance(v, dict) else {"cancel": v} if isinstance(v, bool) else {"bad": v})}
237
+ for k, v in raw.items()]
238
+ if not isinstance(raw, list):
239
+ raise PlanError("the plan must be a list of entries, one per train you change")
240
+ return raw
241
+
242
+
243
+ def parse_plan(task: Task, raw: Any) -> tuple[Plan, list[str]]:
244
+ """(plan, entry problems). Raises PlanError only when nothing can be read. An empty list is a valid plan:
245
+ every train runs in full, as early as it can."""
246
+ entries = _load(raw)
247
+ runs = task.run_map
248
+ plan: Plan = {}
249
+ problems: list[str] = []
250
+ for n, e in enumerate(entries):
251
+ if not isinstance(e, dict):
252
+ problems.append(f"entry {n}: not an object")
253
+ continue
254
+ rid = e.get("train", e.get("run", e.get("id")))
255
+ if isinstance(rid, bool) or rid is None:
256
+ problems.append(f"entry {n}: no train number")
257
+ continue
258
+ rid = str(rid).strip()
259
+ if rid not in runs:
260
+ problems.append(f"entry {n}: unknown train {rid!r}")
261
+ continue
262
+ if rid in plan:
263
+ problems.append(f"entry {n}: train {rid} appears twice")
264
+ continue
265
+ r = runs[rid]
266
+ o = Order()
267
+ unknown = set(e) - {"train", "run", "id", "cancel", "from", "start", "to", "end", "hold", "note", "reason"}
268
+ if unknown:
269
+ problems.append(f"train {rid}: unknown field(s) {sorted(unknown)}")
270
+ c = e.get("cancel", False)
271
+ if c not in (True, False, None):
272
+ problems.append(f"train {rid}: cancel must be true or false")
273
+ o.cancel = c is True
274
+ for key, alt, attr in (("from", "start", "start"), ("to", "end", "end")):
275
+ v = e.get(key, e.get(alt))
276
+ if v is None:
277
+ continue
278
+ if not isinstance(v, str) or v.strip().upper() not in r.route:
279
+ problems.append(f"train {rid}: {key} {v!r} is not on its route {'-'.join(r.route)}")
280
+ continue
281
+ setattr(o, attr, v.strip().upper())
282
+ h = e.get("hold") or {}
283
+ if not isinstance(h, dict):
284
+ problems.append(f'train {rid}: hold must be an object like {{"HDG": "07:31"}}')
285
+ h = {}
286
+ for k, v in h.items():
287
+ code = str(k).strip().upper()
288
+ if code not in r.route:
289
+ problems.append(f"train {rid}: hold at {k!r}, which is not on its route")
290
+ continue
291
+ try:
292
+ o.hold[code] = parse_time(v)
293
+ except ValueError as ex:
294
+ problems.append(f"train {rid}: hold at {code}: {ex}")
295
+ plan[rid] = o
296
+ return plan, problems
297
+
298
+
299
+ def plan_to_list(plan: Plan) -> list[dict]:
300
+ return [o.to_json(rid) for rid, o in plan.items()]
301
+
302
+
303
+ def plan_from_list(task: Task, entries: list[dict]) -> Plan:
304
+ plan, problems = parse_plan(task, entries)
305
+ if problems:
306
+ raise PlanError("; ".join(problems))
307
+ return plan
core/rail_core/network.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The line: points west to east (terminals, stations, halts, junctions) and the sections between them.
2
+
3
+ A section joins two neighbouring points and is single or double track. A point has a number of tracks (None: as many
4
+ as needed, the terminals), may be *directional* (a station on double track: one track per direction, so two trains
5
+ going the same way cannot both stand there and nothing overtakes), and may be a *turn* point where a train can be
6
+ short-turned. Junctions are where double track becomes single between two stations; trains pass them but never stop.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from dataclasses import asdict, dataclass
13
+ from pathlib import Path
14
+
15
+ DATA = Path(__file__).resolve().parents[4] / "data"
16
+
17
+
18
+ @dataclass
19
+ class Point:
20
+ code: str
21
+ name: str
22
+ km: float
23
+ kind: str # terminal | station | halt | junction
24
+ tracks: int | None # None: unlimited (terminal)
25
+ directional: bool # one track per direction (station on double track)
26
+ turn: bool # trains may be short-turned here
27
+
28
+ @property
29
+ def stoppable(self) -> bool:
30
+ return self.kind != "junction"
31
+
32
+
33
+ @dataclass
34
+ class Section:
35
+ a: str # west end
36
+ b: str # east end
37
+ single: bool
38
+ prorail: str = ""
39
+
40
+
41
+ class Network:
42
+ def __init__(self, points: list[Point], sections: list[Section], line: str = "", name: str = ""):
43
+ self.line, self.name = line, name
44
+ self.points = points
45
+ self.sections = sections
46
+ self.index = {p.code: i for i, p in enumerate(points)}
47
+ self.by_code = {p.code: p for p in points}
48
+ for i, s in enumerate(sections):
49
+ if (self.index[s.a], self.index[s.b]) != (i, i + 1):
50
+ raise ValueError(f"section {s.a}-{s.b} is not between neighbouring points {i} and {i + 1}")
51
+
52
+ @classmethod
53
+ def from_dict(cls, d: dict) -> "Network":
54
+ return cls([Point(**p) for p in d["points"]], [Section(**s) for s in d["sections"]], d.get("line", ""),
55
+ d.get("name", ""))
56
+
57
+ def to_dict(self) -> dict:
58
+ return {"line": self.line, "name": self.name, "points": [asdict(p) for p in self.points],
59
+ "sections": [asdict(s) for s in self.sections]}
60
+
61
+ def section(self, x: str, y: str) -> int:
62
+ """Index of the section between neighbouring points x and y (either order)."""
63
+ i, j = self.index[x], self.index[y]
64
+ if abs(i - j) != 1:
65
+ raise ValueError(f"{x} and {y} are not neighbours")
66
+ return min(i, j)
67
+
68
+ def route(self, a: str, b: str) -> list[str]:
69
+ """Every point from a to b inclusive, in travel order."""
70
+ i, j = self.index[a], self.index[b]
71
+ step = 1 if j >= i else -1
72
+ return [self.points[k].code for k in range(i, j + step, step)]
73
+
74
+ def direction(self, a: str, b: str) -> int:
75
+ """+1 eastbound (towards the last point), -1 westbound."""
76
+ return 1 if self.index[b] > self.index[a] else -1
77
+
78
+
79
+ def load_network(line: str = "gnlw") -> Network:
80
+ return Network.from_dict(json.loads((DATA / line / "network.json").read_text()))
core/rail_core/ops.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Disruptions and tiers: what can go wrong on the line, from the 2025 record, and how tasks are drawn.
2
+
3
+ A disruption in the Rijden de Treinen record has a cause, a start, an end and the stations it was linked to. Its
4
+ effect in the model depends on the cause (an assumption, stated in SOURCE.md):
5
+
6
+ broken down train, collision, emergency call, defective track, person or animal on the track, power failure
7
+ -> the sections between the outermost stations named are closed for the whole disruption
8
+ signal failure, level crossing failure
9
+ -> trains run on sight there: every section in the span takes `SLOW_EXTRA` minutes longer
10
+ points failure
11
+ -> one track out of use at the passing station(s) in the span (no crossing there if it had two)
12
+
13
+ A disruption linked to every station of the line has no usable location; such causes are replayed on a section
14
+ drawn with the task's seed.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import random
20
+
21
+ from .model import Closure
22
+ from .network import Network
23
+
24
+ SLOW_EXTRA = 4
25
+ CLOSE = ("broken down", "collision", "emergency", "defective railway track", "person", "animal", "power failure",
26
+ "damaged", "fire", "accident", "vandalism", "copper theft")
27
+ SLOW = ("signal", "level crossing")
28
+ POINTS = ("points",)
29
+ LOOPS = ("HDG", "FWD", "BP", "ZH")
30
+
31
+
32
+ def effect(cause: str) -> str:
33
+ c = cause.lower()
34
+ if any(k in c for k in POINTS) and not any(k in c for k in SLOW):
35
+ return "track"
36
+ if any(k in c for k in SLOW):
37
+ return "slow"
38
+ return "closed"
39
+
40
+
41
+ def span(net: Network, codes: list[str]) -> list[str] | None:
42
+ """[west, east] of the stations named, or None when the record names (nearly) the whole line."""
43
+ idx = sorted(net.index[c] for c in codes if c in net.index)
44
+ if len(idx) < 2 or (idx[0] == 0 and idx[-1] == len(net.points) - 1 and len(idx) >= 8):
45
+ return None
46
+ return [net.points[idx[0]].code, net.points[idx[-1]].code]
47
+
48
+
49
+ def closures_for(net: Network, cause: str, codes: list[str], start: int, end: int, rng: random.Random,
50
+ real: bool = True) -> list[Closure]:
51
+ kind = effect(cause)
52
+ where = span(net, codes)
53
+ if where is None: # no location in the record: draw one
54
+ where = _draw_span(net, kind, rng)
55
+ if kind == "track":
56
+ lo, hi = net.index[where[0]], net.index[where[-1]]
57
+ loops = [p.code for p in net.points[lo:hi + 1] if p.code in LOOPS] or [rng.choice(LOOPS)]
58
+ return [Closure("track", [c], start, end, cause=cause, real=real) for c in loops[:1]]
59
+ if kind == "slow":
60
+ return [Closure("slow", where, start, end, extra=SLOW_EXTRA, cause=cause, real=real)]
61
+ return [Closure("closed", where, start, end, cause=cause, real=real)]
62
+
63
+
64
+ def _draw_span(net: Network, kind: str, rng: random.Random) -> list[str]:
65
+ stations = [p.code for p in net.points if p.kind != "junction"]
66
+ if kind == "track":
67
+ c = rng.choice(LOOPS)
68
+ return [c, c]
69
+ i = rng.randrange(len(stations) - 1)
70
+ j = min(len(stations) - 1, i + rng.choice((1, 1, 2)))
71
+ return [stations[i], stations[j]]
72
+
73
+
74
+ # ---------------------------------------------------------------- tiers and candidates
75
+
76
+ import csv
77
+ from datetime import date as _date
78
+ from functools import lru_cache
79
+
80
+ from .data import LINE_DIR, days as _days, disruptions as _real_disruptions
81
+ from .model import hhmm
82
+
83
+ # Eval days lie in these ISO weeks of 2025 (42 days); train days are every other day of January-June.
84
+ HELD_OUT = {4, 7, 11, 17, 22, 25}
85
+ PEAKS = [(6 * 60 + 30, 9 * 60), (15 * 60 + 30, 18 * 60 + 30)]
86
+ LATE_CAUSES = ("a technical inspection", "a late driver", "a door fault being fixed", "a cleaning crew overrun",
87
+ "coupling problems")
88
+
89
+ TIERS = {
90
+ # window minutes; what is seeded (each disruption's cause and duration drawn from the 2024-2025 record)
91
+ "real": dict(window=(150, 210)), # the record's own disruption, on its own day
92
+ "blockage": dict(window=(150, 210), closed=1, late=(0, 1)),
93
+ "loop": dict(window=(150, 210), track=1, slow=(0, 1), late=(0, 1)),
94
+ "compound": dict(window=(180, 240), closed=1, track=(0, 1), slow=(0, 1), late=(1, 3)),
95
+ }
96
+
97
+
98
+ def split_of(day: str) -> str:
99
+ return "eval" if _date.fromisoformat(day).isocalendar()[1] in HELD_OUT else "train"
100
+
101
+
102
+ def split_days(split: str) -> list[str]:
103
+ return [d for d in _days() if split_of(d) == split]
104
+
105
+
106
+ @lru_cache(maxsize=1)
107
+ def profiles() -> list[dict]:
108
+ with open(LINE_DIR / "disruption_profiles.csv", encoding="utf-8") as f:
109
+ rows = [r for r in csv.DictReader(f) if r["duration_minutes"]]
110
+ for r in rows:
111
+ r["effect"] = effect(r["cause_en"])
112
+ r["minutes"] = max(10, min(240, round(float(r["duration_minutes"]))))
113
+ return rows
114
+
115
+
116
+ def real_disruptions(split: str | None = None) -> list[dict]:
117
+ """The record's own disruptions on the line, as (day, start, end, cause, stations)."""
118
+ out = []
119
+ for d in _real_disruptions():
120
+ day = d["start_time"][:10]
121
+ if split and split_of(day) != split:
122
+ continue
123
+ hh, mm = int(d["start_time"][11:13]), int(d["start_time"][14:16])
124
+ start = hh * 60 + mm
125
+ dur = round(float(d["duration_minutes"] or 0))
126
+ if dur < 15 or not 5 * 60 <= start <= 22 * 60:
127
+ continue
128
+ out.append({"day": day, "start": start, "end": start + dur, "cause": d["cause_en"],
129
+ "stations": [c.strip() for c in d["rdt_station_codes"].split(",")]})
130
+ return out
131
+
132
+
133
+ def draw(rng: random.Random, kind: str) -> dict:
134
+ pool = [p for p in profiles() if p["effect"] == kind] or profiles()
135
+ return rng.choice(pool)
136
+
137
+
138
+ def seeded_closures(net: Network, tier: str, start: int, window: int, rng: random.Random) -> tuple[list, list[str]]:
139
+ """Closures for a seeded tier, starting within the first half hour of the window."""
140
+ r = TIERS[tier]
141
+ out: list[Closure] = []
142
+
143
+ def count(key):
144
+ v = r.get(key, 0)
145
+ return rng.randint(*v) if isinstance(v, tuple) else v
146
+
147
+ for kind, key in (("closed", "closed"), ("track", "track"), ("slow", "slow")):
148
+ for _ in range(count(key)):
149
+ p = draw(rng, kind)
150
+ t0 = start + rng.choice((0, 0, 5, 10, 20, 30))
151
+ minutes = min(p["minutes"], window - 20) if kind == "closed" else p["minutes"]
152
+ codes = [c.strip() for c in p["rdt_station_codes"].split(",")]
153
+ where = span(net, codes) or _draw_span(net, kind, rng)
154
+ out += closures_for(net, p["cause_en"], where, t0, t0 + minutes, rng, real=False)
155
+ notes = []
156
+ return out, notes
157
+
158
+
159
+ def candidate_id(day: str, start: int, window: int, tier: str, seed: int) -> str:
160
+ return f"gnlw-{day}-{hhmm(start).replace(':', '')}-{window}m-{tier}-{seed}"
core/rail_core/pack.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Task packs: directories of tasks (tasks.jsonl or tasks.jsonl.gz, with their reference plans) plus a manifest.
2
+
3
+ The server serves several packs at once; splits come from each task's `split` field. By default those are the
4
+ rail-v1 eval and train packs.
5
+
6
+ RAIL_TASKS_DIR=/path/a:/path/b colon-separated pack directories
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import gzip
12
+ import hashlib
13
+ import json
14
+ import os
15
+ from pathlib import Path
16
+
17
+ from .model import Task
18
+
19
+ TASKS_ROOT = Path(__file__).resolve().parents[2] / "tasks"
20
+ DEFAULT_PACKS = ["rail-v1-eval", "rail-v1-train"]
21
+
22
+
23
+ def default_roots() -> list[Path]:
24
+ env = os.environ.get("RAIL_TASKS_DIR", "").strip()
25
+ if env:
26
+ return [Path(p) for p in env.split(":") if p]
27
+ return [TASKS_ROOT / p for p in DEFAULT_PACKS if _tasks_file(TASKS_ROOT / p)]
28
+
29
+
30
+ def _tasks_file(root: Path) -> Path | None:
31
+ for name in ("tasks.jsonl", "tasks.jsonl.gz"):
32
+ if (root / name).is_file():
33
+ return root / name
34
+ return None
35
+
36
+
37
+ def _read(root: Path) -> tuple[list[Task], dict]:
38
+ path = _tasks_file(root)
39
+ if path is None:
40
+ raise FileNotFoundError(f"no tasks.jsonl(.gz) in {root}")
41
+ raw = path.read_bytes()
42
+ body = gzip.decompress(raw) if path.suffix == ".gz" else raw
43
+ manifest = json.loads((root / "manifest.json").read_text()) if (root / "manifest.json").is_file() else {}
44
+ digest = hashlib.sha256(body).hexdigest()
45
+ if manifest.get("sha256") and manifest["sha256"] != digest:
46
+ raise ValueError(f"{path} does not match its manifest.json (sha256 {digest[:12]} != {manifest['sha256'][:12]})")
47
+ return [Task.from_dict(json.loads(line)) for line in body.decode().splitlines() if line.strip()], manifest
48
+
49
+
50
+ class TaskPack:
51
+ def __init__(self, roots: Path | str | list[Path | str] | None = None):
52
+ if roots is None:
53
+ roots = default_roots()
54
+ if isinstance(roots, (str, Path)):
55
+ roots = [roots]
56
+ self.roots = [Path(r) for r in roots]
57
+ if not self.roots:
58
+ raise FileNotFoundError(f"no task packs found under {TASKS_ROOT}; set RAIL_TASKS_DIR")
59
+ self.tasks: list[Task] = []
60
+ self.manifests: dict[str, dict] = {}
61
+ for r in self.roots:
62
+ tasks, manifest = _read(r)
63
+ self.tasks += tasks
64
+ self.manifests[r.name] = manifest
65
+ self._by_id: dict[str, Task] = {}
66
+ for t in self.tasks:
67
+ if t.task_id in self._by_id:
68
+ raise ValueError(f"task id {t.task_id} appears in more than one pack")
69
+ self._by_id[t.task_id] = t
70
+ self._by_split: dict[str, list[Task]] = {}
71
+ for t in self.tasks:
72
+ self._by_split.setdefault(t.split, []).append(t)
73
+
74
+ def splits(self) -> list[str]:
75
+ return sorted(self._by_split)
76
+
77
+ def count(self, split: str) -> int:
78
+ return len(self._by_split.get(split, []))
79
+
80
+ def at(self, split: str, index: int) -> Task:
81
+ return self._by_split[split][index]
82
+
83
+ def get(self, task_id: str) -> Task:
84
+ return self._by_id[task_id]
85
+
86
+ def public(self, task: Task) -> dict:
87
+ """What anyone may see: everything but the reference plans and costs."""
88
+ return task.to_dict(public=True)
89
+
90
+
91
+ _PACK: TaskPack | None = None
92
+
93
+
94
+ def load_pack(root: Path | str | list | None = None) -> TaskPack:
95
+ global _PACK
96
+ if root is not None:
97
+ return TaskPack(root)
98
+ if _PACK is None:
99
+ _PACK = TaskPack()
100
+ return _PACK
core/rail_core/prompts.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """What the agent reads: `rules(task)` is the system prompt, `situation(task)` what get_situation returns.
2
+
3
+ Both are rendered at runtime from the task, so changing them never needs a pack rebuild.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from .check import network
9
+ from .model import Run, Task, hhmm
10
+
11
+ PROMPT_VERSION = "rail-v1.0"
12
+ KIND_NAME = {"stop": "Stoptrein (stopping)", "fast": "Sneltrein (fast)"}
13
+
14
+
15
+ def rules(task: Task, max_checks: int = 10) -> str:
16
+ R = task.rules
17
+ net = network(task)
18
+ turns = ", ".join(p.code for p in net.points if p.turn)
19
+ return f"""You are the train dispatcher (treindienstleider) for Arriva's line between Leeuwarden and Groningen in the \
20
+ Netherlands, on a real day in 2025. Long stretches of the line are single track: trains going opposite ways can only \
21
+ pass each other at stations with two tracks or on the double-track parts. Something has gone wrong and you must re-plan \
22
+ the trains for the rest of the window. Everything you need is in the situation; it is the whole truth (no surprises \
23
+ later).
24
+
25
+ How trains move (the physics; deterministic):
26
+ - Every train set works its runs in the order listed. A run starts where its train set is, no earlier than when the set \
27
+ is ready there and never before its scheduled departure.
28
+ - Unless your plan says otherwise, a train runs at its minimum running times and leaves as soon as it can, but it never \
29
+ leaves a scheduled stop before its scheduled departure and never reaches any point before its timetable minute there \
30
+ (the passing minutes marked * included): arrival at the next point = max(departure + running time, timetable minute). \
31
+ A late train makes up time this way.
32
+ - A train held where it has no scheduled stop (a fast train at a station it normally passes) loses \
33
+ {R['stop_loss']} minute braking and {R['stop_loss']} minute accelerating. Trains cannot be held at junctions (J1, J2).
34
+ - After a run ends, its train set is ready {R['turn_min']} minutes after arriving, at the point where the run ended.
35
+
36
+ Rules (a plan that breaks any of them is infeasible):
37
+ 1. Single track: two trains going opposite ways may never be in a single-track section at the same time: the second \
38
+ may enter only when the first has left it (arrived at the far end){' - in that same minute at the earliest' if not R['h_opp'] else f" - {R['h_opp']} minute(s) later at the earliest"}. \
39
+ A train is in a section from its departure at one end to its arrival at the other.
40
+ 2. Headway: trains going the same way stay at least {R['h_follow']} minutes apart at both ends of every section, so a \
41
+ train can only overtake another at a station where both fit.
42
+ 3. Platforms: never more trains standing at (or passing through) a station than it has tracks. A directional station \
43
+ (on double track) has one track each way. A train set waiting at a station between runs keeps a track there. \
44
+ Terminals (LW, GN) have room for everyone.
45
+ 4. Closures: no train may be in a closed section while it is closed. A train stuck inside when it closed waits there.
46
+ 5. Continuity: a run can only start where its train set is. You may short-turn trains only at the turn points \
47
+ ({turns}). A train already running cannot be cancelled (short-turn it instead). At the end of the window every train \
48
+ set must be where its next run (listed) leaves from.
49
+ 6. No train may reach a stop more than {R['max_delay']} minutes late.
50
+
51
+ What you decide, per train (run), in the plan:
52
+ - "hold": {{"HDG": "07:31"}} keeps it at that point until 07:31 (it leaves then, or later if it is not ready). Use \
53
+ holds to make trains wait for each other or for a closure to end.
54
+ - "to": "HDG" ends the run early at a turn point (short-turn); its train set is then at HDG, so its next run must \
55
+ start there.
56
+ - "from": "HDG" starts a run at a turn point instead of its origin (where its set was short-turned).
57
+ - "cancel": true does not run it at all; its train set stays where it is (so usually its next run is cancelled too, \
58
+ or starts from where the set is).
59
+ Trains you leave out of the plan run in full, as early as they can. An empty plan [] changes nothing.
60
+
61
+ Cost, in stop-minutes (lower is better): every minute a train reaches one of its scheduled stops late (arrival at each \
62
+ stop it serves after where it starts), plus {R['cancel_cost']} for every scheduled stop a train does not serve \
63
+ (cancelled or cut off by a short-turn: passengers wait for the next train), plus, for each train set, the minutes it is \
64
+ late for its next run after the window times that run's number of stops.
65
+
66
+ Tools:
67
+ - get_situation(): the line, the closures, the trains and the train sets.
68
+ - check_plan(plan): simulates your plan and returns every train's times, delay, the rule breaks and the cost. You \
69
+ have {max_checks} checks. It never grades the plan.
70
+ - submit_plan(plan): your final plan. It ends the episode and is graded once against the best possible plan (proved \
71
+ by a solver). A plan that breaks a rule scores below 0.2; a plan that breaks none scores 0.2-1.0 by how close its cost \
72
+ is to the best.
73
+
74
+ Plan format, a JSON list with one entry per train you change, for example:
75
+ [{{"train": "37413", "to": "HDG"}}, {{"train": "37424", "from": "HDG", "hold": {{"HDG": "07:31"}}}}, \
76
+ {{"train": "37326", "hold": {{"FWD": "07:55", "BP": "08:05"}}}}, {{"train": "37419", "cancel": true}}]
77
+ Times are "HH:MM" (after midnight: 24:10)."""
78
+
79
+
80
+ def _line_diagram(task: Task) -> list[str]:
81
+ net = network(task)
82
+ parts = []
83
+ for i, p in enumerate(net.points):
84
+ if p.kind == "terminal":
85
+ tag = f"{p.code}[terminal]"
86
+ elif p.kind == "junction":
87
+ tag = f"{p.code}"
88
+ elif p.directional:
89
+ tag = f"{p.code}[1 each way]"
90
+ else:
91
+ tag = f"{p.code}[{p.tracks}{' turn' if p.turn else ''}]"
92
+ if p.turn and p.kind != "terminal" and "turn" not in tag:
93
+ tag = tag[:-1] + " turn]"
94
+ parts.append(tag)
95
+ if i < len(net.sections):
96
+ parts.append(" ---single--- " if net.sections[i].single else " ===double=== ")
97
+ lines = ["West to east (km from Leeuwarden in brackets):"]
98
+ lines.append(" " + ", ".join(f"{p.code} {p.name} ({p.km:g})" for p in net.points))
99
+ lines.append(" " + "".join(parts))
100
+ lines.append(" [n] = tracks at a station; [1 each way] = one track per direction (no overtaking); turn = trains "
101
+ "can be short-turned there; J1, J2 = junctions where double track becomes single (no stopping).")
102
+ return lines
103
+
104
+
105
+ def _pattern(r: Run) -> tuple:
106
+ return (r.kind, tuple(r.route), tuple(r.stops), tuple(r.seg))
107
+
108
+
109
+ def situation(task: Task) -> str:
110
+ net = network(task)
111
+ out = [f"Groningen - Leeuwarden, {task.day}. It is now {hhmm(task.start)}. You plan every run that leaves "
112
+ f"before {hhmm(task.end)} (and those already running)."]
113
+ out.append("")
114
+ out.append("THE LINE")
115
+ out += _line_diagram(task)
116
+ out.append("")
117
+ out.append("WHAT HAS HAPPENED")
118
+ for n in task.notices:
119
+ out.append(f"- {n}")
120
+ for c in task.closures:
121
+ where = "-".join(c.where) if c.kind != "track" else c.where[0]
122
+ if c.kind == "closed":
123
+ out.append(f"- CLOSED: every section between {c.where[0]} and {c.where[-1]}, {hhmm(c.start)} to "
124
+ f"{hhmm(c.end)} ({c.cause}).")
125
+ elif c.kind == "slow":
126
+ out.append(f"- SLOW: trains entering a section between {c.where[0]} and {c.where[-1]} from "
127
+ f"{hhmm(c.start)} to {hhmm(c.end)} take {c.extra} minutes longer ({c.cause}).")
128
+ else:
129
+ out.append(f"- TRACK OUT OF USE: one track at {where} cannot be used {hhmm(c.start)} to {hhmm(c.end)} "
130
+ f"({c.cause}).")
131
+ if not task.notices and not task.closures:
132
+ out.append("- Nothing beyond the delays below.")
133
+ out.append("")
134
+ out.append("MINIMUM RUNNING TIMES (minutes from each point to the next, for each stopping pattern)")
135
+ patterns: dict[tuple, list[str]] = {}
136
+ for r in task.runs:
137
+ patterns.setdefault(_pattern(r), []).append(r.id)
138
+ for k, (pat, ids) in enumerate(patterns.items()):
139
+ kind, route, stops, seg = pat
140
+ legs = " ".join(f"{route[i]}{'' if stops[i] else '*'} {seg[i]}" for i in range(len(seg))) + f" {route[-1]}"
141
+ out.append(f"P{k + 1} {KIND_NAME[kind]} {route[0]}->{route[-1]}: {legs}")
142
+ out.append(" (* = no scheduled stop there: the train passes; held there it loses "
143
+ f"{task.rule('stop_loss')}+{task.rule('stop_loss')} min)")
144
+ pid = {rid: f"P{k + 1}" for k, ids in enumerate(patterns.values()) for rid in ids}
145
+ out.append("")
146
+ out.append("TRAINS (timetable: arrival/departure at stops, passing minute at points marked *; the pattern gives the "
147
+ "running times)")
148
+ for r in sorted(task.runs, key=lambda r: (r.fixed_arr if r.started else r.dep[0])):
149
+ stops = []
150
+ for i, code in enumerate(r.route):
151
+ if not r.stops[i]:
152
+ stops.append(f"{code}* {hhmm(r.arr[i])}")
153
+ continue
154
+ a, d = r.arr[i], r.dep[i]
155
+ if a is None:
156
+ stops.append(f"{code} {hhmm(d)}")
157
+ elif d is None:
158
+ stops.append(f"{code} {hhmm(a)}")
159
+ elif a == d:
160
+ stops.append(f"{code} {hhmm(a)}")
161
+ else:
162
+ stops.append(f"{code} {hhmm(a)}/{hhmm(d)}")
163
+ head = f"{r.id} {r.kind} {r.origin}->{r.dest} [{pid[r.id]}, set {r.unit}]"
164
+ if r.started:
165
+ k = r.fixed_to
166
+ code = r.route[k]
167
+ sched = r.arr[k] if r.arr[k] is not None else r.dep[k]
168
+ late = r.fixed_arr - sched if sched is not None else 0
169
+ if r.fixed_arr <= task.start and r.stops[k]:
170
+ pos = f"standing at {code} since {hhmm(r.fixed_arr)}"
171
+ elif r.stuck:
172
+ pos = f"STUCK in the closed section before {code}; reaches {code} at {hhmm(r.fixed_arr)} at the earliest"
173
+ else:
174
+ pos = f"running, reaches {code} at {hhmm(r.fixed_arr)}"
175
+ head += f" RUNNING ({pos}; {late:+d} min on its timetable there)"
176
+ out.append(f"{head}: " + ", ".join(stops))
177
+ out.append("")
178
+ out.append("TRAIN SETS (each works its runs in this order)")
179
+ for u in task.units:
180
+ first = task.run(u.runs[0])
181
+ if first.started:
182
+ where = f"running {first.id}"
183
+ else:
184
+ where = f"at {u.at}, ready {hhmm(u.ready)}"
185
+ nxt = (f"; must then be at {u.after['station']} for {u.after['run']} at {hhmm(u.after['dep'])} "
186
+ f"({u.after['stops']} stops)") if u.after else "; no further runs today"
187
+ out.append(f"{u.id}: {where}; runs " + " -> ".join(u.runs) + nxt)
188
+ return "\n".join(out)
189
+
190
+
191
+ def brief(task: Task) -> str:
192
+ return f"{task.task_id}: {len(task.runs)} runs, {len(task.units)} train sets, {len(task.closures)} closures"
core/rail_core/reference.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A task's reference: the proven optimum, the floor, the lazy policies' costs and rewards, and the real outcome.
2
+
3
+ Nothing here is shown to the agent.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+
10
+ from .baselines import POLICIES
11
+ from .check import evaluate
12
+ from .model import Task, plan_to_list
13
+ from .reward import score
14
+ from .solve import floor_parts, optimal_plan
15
+
16
+
17
+ def add_reference(task: Task, time_limit: float = 90.0, workers: int = 4, extend: int = 2) -> dict:
18
+ hints = {}
19
+ for name in ("greedy", "greedy_fast"):
20
+ try:
21
+ hints[name] = POLICIES[name](task)
22
+ except Exception as e: # a baseline that crashes is a bug, but must not stop a build
23
+ hints[name] = None
24
+ task.reference.setdefault("errors", []).append(f"{name}: {type(e).__name__}: {e}")
25
+ best_hint = None
26
+ for name, p in hints.items():
27
+ if p is None:
28
+ continue
29
+ r = evaluate(task, p)
30
+ if r.feasible and (best_hint is None or r.cost < best_hint[1]):
31
+ best_hint = (p, r.cost)
32
+ floor, parts = floor_parts(task)
33
+ sol = optimal_plan(task, time_limit=time_limit, workers=workers, hint=best_hint[0] if best_hint else None,
34
+ unit_floor=parts)
35
+ if sol.plan is None:
36
+ raise ValueError(f"{task.task_id}: no feasible plan found ({sol.status})")
37
+ floor = min(floor, sol.bound)
38
+ # large windows: warm-start from the best plan and give the proof more time, while the gap is closable
39
+ limit, spent = time_limit, sol.seconds
40
+ for _ in range(extend):
41
+ gap = (sol.cost - sol.bound) / (sol.cost - floor + task.rule("gap_k"))
42
+ if sol.optimal or gap <= 0.01 or gap > 0.3: # close enough to finish, or hopeless
43
+ break
44
+ limit *= 2
45
+ nxt = optimal_plan(task, time_limit=limit, workers=workers, hint=sol.plan, unit_floor=parts)
46
+ spent += nxt.seconds
47
+ if nxt.plan is not None and (nxt.cost, -nxt.bound) <= (sol.cost, -sol.bound):
48
+ sol = nxt
49
+ elif nxt.plan is not None:
50
+ sol = type(sol)(sol.plan, sol.cost, max(sol.bound, nxt.bound), sol.optimal, sol.status, sol.seconds)
51
+ floor = min(floor, sol.bound)
52
+ ref = task.reference
53
+ ref.update(optimal_cost=sol.cost, optimal_plan=plan_to_list(sol.plan), proven_optimal=sol.optimal,
54
+ lower_bound=sol.bound, floor_cost=floor, solver_seconds=round(spent, 1), solver=sol.status)
55
+ score_baselines(task, hints)
56
+ if "real" in ref:
57
+ rr = ref["real"]
58
+ rew, _ = score(rr["cost"], True, 1.0, sol.cost, floor, gap_k)
59
+ rr["reward_if_legal"] = round(rew, 4)
60
+ return ref
61
+
62
+
63
+ def score_baselines(task: Task, hints: dict | None = None) -> None:
64
+ """The lazy policies' feasibility, cost, reward and rule breaks, against the stored optimum and floor."""
65
+ ref = task.reference
66
+ if hints is None:
67
+ hints = {}
68
+ for name in ("greedy", "greedy_fast"):
69
+ try:
70
+ hints[name] = POLICIES[name](task)
71
+ except Exception as e:
72
+ hints[name] = None
73
+ ref.setdefault("errors", []).append(f"{name}: {type(e).__name__}: {e}")
74
+ gap_k = task.rule("gap_k")
75
+ for name, p in [("naive", POLICIES["naive"](task))] + list(hints.items()):
76
+ if p is None:
77
+ ref[f"{name}_feasible"] = False
78
+ ref[f"{name}_reward"] = 0.0
79
+ continue
80
+ r = evaluate(task, p)
81
+ clean = sum(1 for x in r.runs if not x.problems) / max(1, len(r.runs))
82
+ rew, _ = score(r.cost, r.feasible, clean, ref["optimal_cost"], ref["floor_cost"], gap_k)
83
+ ref[f"{name}_feasible"] = r.feasible
84
+ ref[f"{name}_cost"] = r.cost
85
+ ref[f"{name}_reward"] = round(rew, 4)
86
+ ref[f"{name}_violations"] = len(r.violations)
87
+
88
+
89
+ BASELINE_KEYS = [f"{n}_{k}" for n in ("naive", "greedy", "greedy_fast") for k in ("feasible", "cost", "reward",
90
+ "violations")]
91
+
92
+
93
+ def refresh_baselines(pack_dir: Path) -> list[str]:
94
+ """Re-score the lazy policies of every task in a pack (after a baseline fix), without re-solving. Rewrites the
95
+ pack only if something changed; returns the ids of the tasks that changed."""
96
+ import json
97
+
98
+ from .build import write_pack
99
+ from .hardness import features
100
+ from .pack import _read
101
+
102
+ tasks, manifest = _read(pack_dir)
103
+ raw = [t.to_dict() for t in tasks]
104
+ changed = []
105
+ for t, d in zip(tasks, raw):
106
+ before = {k: t.reference.get(k) for k in BASELINE_KEYS}
107
+ t.reference.pop("errors", None)
108
+ score_baselines(t)
109
+ if {k: t.reference.get(k) for k in BASELINE_KEYS} != before or "errors" in d["reference"]:
110
+ t.reference["hardness"] = features(t)
111
+ d["reference"] = t.reference
112
+ changed.append(t.task_id)
113
+ if changed:
114
+ log = [json.loads(x) for x in (pack_dir / "build_log.jsonl").read_text().splitlines()]
115
+ for st in log:
116
+ for t in tasks:
117
+ if st.get("task_id") == t.task_id and "hardness" in st:
118
+ st["hardness"] = t.reference["hardness"]
119
+ std = {"name", "split", "tasks", "file", "tiers", "sha256", "candidates", "days", "proven_optimal", "runs",
120
+ "source"}
121
+ write_pack(pack_dir, raw, log, manifest["split"], {k: v for k, v in manifest.items() if k not in std})
122
+ return changed
123
+
124
+
125
+ if __name__ == "__main__":
126
+ import argparse
127
+
128
+ ap = argparse.ArgumentParser(description="re-score the lazy policies stored in task packs")
129
+ ap.add_argument("packs", nargs="+")
130
+ for pack in ap.parse_args().packs:
131
+ print(pack, refresh_baselines(Path(pack)))
core/rail_core/replay.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """What the map replay animates: every run's simulated times under a plan, the plan's orders, the cost as it accrues,
2
+ the rule breaks and the moments worth marking on the timeline. Built from the same `simulate` and `evaluate` the
3
+ grader uses, so the picture is the grade. For people (the `/twin/` viewer); no MCP tool returns it.
4
+
5
+ Cost events add up to the grader's cost: a stop's delay when the train arrives there, 30 for a stop not served at
6
+ the minute it was due, and each train set's lateness handed on to the next window at the window's end.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .check import evaluate, network, simulate
12
+ from .model import Order, Plan, Task, hhmm
13
+ from .reward import score
14
+
15
+
16
+ def _due(r, j: int) -> int | None:
17
+ return r.arr[j] if r.arr[j] is not None else r.dep[j]
18
+
19
+
20
+ def replay(task: Task, plan: Plan, name: str = "", parse_problems: list[str] | None = None) -> dict:
21
+ net = network(task)
22
+ res = evaluate(task, plan)
23
+ _, _, _, _, carry = simulate(task, plan)
24
+ problems = list(parse_problems or [])
25
+ feasible = res.feasible and not problems
26
+ clean = sum(1 for x in res.runs if not x.problems) / max(1, len(res.runs) + len(problems))
27
+ optimum, floor = int(task.reference.get("optimal_cost", 0)), int(task.reference.get("floor_cost", 0))
28
+ reward, _ = score(res.cost if feasible else None, feasible, clean, optimum, floor, task.rule("gap_k"))
29
+ cancel_cost = task.rule("cancel_cost")
30
+
31
+ runs, costs, events = [], [], []
32
+ for x in res.runs:
33
+ r = task.run(x.train)
34
+ o = plan.get(x.train) or Order()
35
+ n = len(r.route)
36
+ runs.append({
37
+ "train": x.train, "unit": x.unit, "kind": r.kind, "dir": "E" if net.direction(r.origin, r.dest) > 0
38
+ else "W", "route": r.route, "stops": r.stops, "sched_arr": r.arr, "sched_dep": r.dep,
39
+ "cancelled": x.cancelled, "first": x.first, "last": x.last,
40
+ "arr": [x.arr.get(i) for i in range(n)], "dep": [x.dep.get(i) for i in range(n)],
41
+ "stopped": [bool(x.stopped.get(i)) for i in range(n)],
42
+ "started": r.started, "fixed_to": r.fixed_to, "fixed_from": r.fixed_from, "stuck": r.stuck,
43
+ "delay": x.delay, "unserved": x.unserved, "problems": x.problems,
44
+ "order": o.to_json(x.train) if (o.cancel or o.start or o.end or o.hold) else None})
45
+ # cost as it accrues (the same terms as check.evaluate)
46
+ if x.cancelled:
47
+ for j in range(n):
48
+ if r.stops[j] and _due(r, j) is not None:
49
+ costs.append([_due(r, j), cancel_cost, "unserved", x.train, r.route[j]])
50
+ events.append({"t": r.dep[0] if r.dep[0] is not None else task.start, "kind": "cancel",
51
+ "train": x.train, "text": f"{x.train} cancelled ({r.origin} {hhmm(r.dep[0])} to {r.dest})"})
52
+ continue
53
+ s, e = x.first, x.last
54
+ k = r.fixed_to if r.started else s
55
+ for j in range(k if r.started else s + 1, e + 1):
56
+ if r.stops[j] and r.arr[j] is not None and x.arr.get(j) is not None and x.arr[j] >= task.start:
57
+ late = max(0, x.arr[j] - r.arr[j])
58
+ if late:
59
+ costs.append([x.arr[j], late, "delay", x.train, r.route[j]])
60
+ for j in range(n):
61
+ if r.stops[j] and not (s <= j <= e) and _due(r, j) is not None:
62
+ costs.append([_due(r, j), cancel_cost, "unserved", x.train, r.route[j]])
63
+ if e < n - 1:
64
+ events.append({"t": x.arr.get(e), "kind": "turn", "train": x.train,
65
+ "text": f"{x.train} turns back at {r.route[e]} (not to {r.dest})"})
66
+ if s > 0 and not r.started:
67
+ events.append({"t": x.dep.get(s), "kind": "turn", "train": x.train,
68
+ "text": f"{x.train} starts at {r.route[s]} (not {r.origin})"})
69
+ for code, minute in sorted(o.hold.items(), key=lambda kv: kv[1]):
70
+ i = r.route.index(code) if code in r.route else None
71
+ if i is not None and x.dep.get(i) is not None:
72
+ events.append({"t": x.arr.get(i, minute), "kind": "hold", "train": x.train,
73
+ "text": f"{x.train} held at {code} until {hhmm(minute)}"})
74
+ for unit, c in carry.items():
75
+ if c:
76
+ costs.append([task.end, c, "handed_on", unit, ""])
77
+ for c in task.closures:
78
+ span = "-".join(dict.fromkeys([c.where[0], c.where[-1]]))
79
+ what = {"closed": "closed", "slow": f"slow (+{c.extra} min)", "track": "one platform track out of use"}
80
+ events.append({"t": c.start, "kind": "closure", "text": f"{span} {what.get(c.kind, c.kind)}: {c.cause} "
81
+ f"({hhmm(c.start)}-{hhmm(c.end)})"})
82
+ events.append({"t": c.end, "kind": "reopen", "text": f"{span} back to normal"})
83
+ for v in res.violations:
84
+ if v.get("time"):
85
+ h, m = v["time"].split(":")
86
+ events.append({"t": int(h) * 60 + int(m), "kind": "violation", "rule": v["rule"],
87
+ "trains": v["trains"], "where": v["where"], "text": v["detail"]})
88
+ events.sort(key=lambda ev: (ev["t"] if ev["t"] is not None else task.start, ev["kind"]))
89
+ costs.sort(key=lambda c: c[0])
90
+ total = sum(c[1] for c in costs)
91
+ assert total == res.delay_cost + res.cancel_cost + res.carry_cost, "cost events must add up to the grader's cost"
92
+ return {
93
+ "task_id": task.task_id, "day": task.day, "start": task.start, "end": task.end,
94
+ "difficulty": task.difficulty, "plan": name, "feasible": feasible, "reward": round(reward, 4),
95
+ "cost": res.cost if feasible else None, "cost_if_legal": total,
96
+ "cost_parts": {"delay": res.delay_cost, "unserved": res.cancel_cost, "handed_on": res.carry_cost},
97
+ "optimal_cost": optimum, "floor_cost": floor,
98
+ "cancelled": res.cancelled, "short_turned": res.short_turned,
99
+ "violations": res.violations[:80], "parse_problems": problems,
100
+ "points": [{"code": p.code, "name": p.name, "km": p.km, "kind": p.kind, "tracks": p.tracks,
101
+ "directional": p.directional, "turn": p.turn} for p in net.points],
102
+ "sections": [{"a": s.a, "b": s.b, "single": s.single} for s in net.sections],
103
+ "closures": [{"kind": c.kind, "where": c.where, "start": c.start, "end": c.end, "extra": c.extra,
104
+ "cause": c.cause, "real": c.real} for c in task.closures],
105
+ "units": [{"id": u.id, "at": u.at, "ready": u.ready, "runs": u.runs, "after": u.after} for u in task.units],
106
+ "runs": runs, "costs": costs, "events": events, "notices": task.notices,
107
+ "rules": {k: task.rule(k) for k in ("h_follow", "h_opp", "turn_min", "stop_loss", "cancel_cost",
108
+ "max_delay")},
109
+ }
core/rail_core/report.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """What check_plan shows the agent: every train's simulated times, its delay and lost stops, every rule broken, the
2
+ cost. Never a score and never anything from the reference."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from .check import evaluate, network
7
+ from .model import Plan, Task, hhmm
8
+
9
+ MAX_VIOLATIONS = 60
10
+
11
+
12
+ def _times(task: Task, res) -> str:
13
+ r = task.run(res.train)
14
+ net = network(task)
15
+ parts = []
16
+ first = r.fixed_to if r.started else res.first
17
+ for i in range(first, res.last + 1):
18
+ code = r.route[i]
19
+ if net.by_code[code].kind == "junction":
20
+ continue
21
+ a, d = res.arr.get(i), res.dep.get(i)
22
+ if a is None and d is None:
23
+ continue
24
+ if a is None: # where it starts
25
+ s = f"{code} dep {hhmm(d)}"
26
+ elif d is None or d == a:
27
+ s = f"{code} {hhmm(a)}"
28
+ else:
29
+ s = f"{code} {hhmm(a)}-{hhmm(d)}"
30
+ if r.stops[i] and r.arr[i] is not None and a is not None and i > res.first:
31
+ late = a - r.arr[i]
32
+ if late > 0:
33
+ s += f" (+{late})"
34
+ elif not r.stops[i] and res.stopped.get(i) and i not in (res.first, res.last):
35
+ s += " (unscheduled stop)"
36
+ parts.append(s)
37
+ return " | ".join(parts)
38
+
39
+
40
+ def plan_report(task: Task, plan: Plan, problems: list[str] | None = None) -> dict:
41
+ res = evaluate(task, plan)
42
+ problems = list(problems or [])
43
+ feasible = res.feasible and not problems
44
+ trains = []
45
+ for x in sorted(res.runs, key=lambda x: (task.run(x.train).fixed_arr if task.run(x.train).started
46
+ else task.run(x.train).dep[0])):
47
+ r = task.run(x.train)
48
+ if x.cancelled:
49
+ what = "cancelled"
50
+ else:
51
+ a, b = r.route[x.first], r.route[x.last]
52
+ what = f"{a}->{b}" + ("" if (x.first == 0 and x.last == len(r.route) - 1) else " (short)")
53
+ row = {"train": x.train, "set": x.unit, "runs": what, "delay": x.delay, "unserved_stops": x.unserved}
54
+ if not x.cancelled:
55
+ row["times"] = _times(task, x)
56
+ if x.problems:
57
+ row["breaks"] = x.problems
58
+ trains.append(row)
59
+ out = {
60
+ "feasible": feasible,
61
+ "cost": res.delay_cost + res.cancel_cost + res.carry_cost if feasible else None,
62
+ "cost_parts": {"delay": res.delay_cost, "unserved_stops": res.cancel_cost, "handed_on": res.carry_cost},
63
+ "violations_total": len(res.violations),
64
+ "violations": res.violations[:MAX_VIOLATIONS],
65
+ "trains": trains,
66
+ }
67
+ if not feasible:
68
+ out["note"] = ("infeasible: fix every violation (cost_parts are what the plan would cost if it were legal)"
69
+ if res.violations or problems else "")
70
+ if problems:
71
+ out["entry_problems"] = problems
72
+ return out
core/rail_core/reward.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The terminal reward, from a submitted plan, the task's proven optimum and its floor.
2
+
3
+ unreadable, malformed or infeasible 0.2 x clean runs / (runs + entry problems) in [0, 0.2)
4
+ feasible 0.2 + 0.8 x exp(-g / 0.5) in (0.2, 1]
5
+ g = (cost - optimum) / (optimum - floor + gap_k)
6
+
7
+ `floor` is each train set's best cost alone with the closures (`solve.floor_cost`): what the disruption forces no
8
+ matter how well the trains are sequenced. The gap is measured against the part a dispatcher controls, so a plan that
9
+ wastes as much as the optimum spends above the floor (plus gap_k) scores 0.31. gap_k is 100 stop-minutes (about three
10
+ unserved stops), which keeps the gap finite when the optimum sits on the floor.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ from dataclasses import asdict, dataclass
17
+
18
+ from .check import evaluate
19
+ from .model import Plan, Task
20
+
21
+ INFEASIBLE_CAP = 0.2
22
+ GAP_TAU = 0.5
23
+
24
+
25
+ @dataclass
26
+ class Grade:
27
+ reward: float
28
+ feasible: bool
29
+ clean_fraction: float
30
+ quality: float
31
+ cost: int | None
32
+ delay_cost: int | None
33
+ cancel_cost: int | None
34
+ carry_cost: int | None
35
+ cancelled: int | None
36
+ short_turned: int | None
37
+ optimal_cost: int
38
+ floor_cost: int
39
+ violations: list[dict]
40
+ parse_problems: list[str]
41
+
42
+ def as_dict(self) -> dict:
43
+ return asdict(self)
44
+
45
+
46
+ def score(cost: int | None, feasible: bool, clean_fraction: float, optimum: int, floor: int,
47
+ gap_k: int) -> tuple[float, float]:
48
+ """(reward, quality)."""
49
+ if not feasible or cost is None:
50
+ return INFEASIBLE_CAP * clean_fraction, 0.0
51
+ g = max(0, cost - optimum) / (max(0, optimum - floor) + gap_k)
52
+ quality = max(math.exp(-g / GAP_TAU), 1e-6)
53
+ return INFEASIBLE_CAP + (1.0 - INFEASIBLE_CAP) * quality, quality
54
+
55
+
56
+ def grade(task: Task, plan: Plan, parse_problems: list[str] | None = None) -> Grade:
57
+ optimum = int(task.reference["optimal_cost"])
58
+ floor = int(task.reference["floor_cost"])
59
+ res = evaluate(task, plan)
60
+ problems = list(parse_problems or [])
61
+ feasible = res.feasible and not problems
62
+ clean = sum(1 for r in res.runs if not r.problems) / max(1, len(res.runs) + len(problems))
63
+ reward, quality = score(res.cost, feasible, clean, optimum, floor, task.rule("gap_k"))
64
+ ok = feasible
65
+ return Grade(round(reward, 6), feasible, round(clean, 4), round(quality, 6),
66
+ res.cost if ok else None, res.delay_cost if ok else None, res.cancel_cost if ok else None,
67
+ res.carry_cost if ok else None, res.cancelled if ok else None, res.short_turned if ok else None,
68
+ optimum, floor, res.violations, problems)
core/rail_core/select.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Choose a hard eval set from a candidate pool, without looking at any model's results on the eval tasks.
2
+
3
+ python -m rail_core.select --pool ../tasks/pool-eval --calib ../../results/rollouts/<run>/index.json \
4
+ --n 45 --out ../tasks/rail-v1-eval
5
+
6
+ How:
7
+ 1. Calibrate on the train split. A model is run on a few train tasks (eval/run_eval.py --split train); its rewards
8
+ and the tasks' hardness features (`hardness.features`, model-free) are the calibration set.
9
+ 2. Fit a ridge regression from standardized features to reward on that set (pure Python, leave-one-out error
10
+ reported). Features whose coefficient sign is unstable under leave-one-out are dropped.
11
+ 3. Predict every pool task's reward (the pool is built on held-out days only) and keep the `n` with the lowest
12
+ prediction, with at most `max_per_day` per day and every tier in the pool represented (`min_per_tier`).
13
+ The eval tasks themselves are never scored by a model before selection: the choice depends only on train-split
14
+ calibration and on features any solver-built task has, so the eval stays a fair test.
15
+
16
+ Writes the pack (tasks.jsonl, manifest.json with the selection) and selection.json: the fit, its leave-one-out
17
+ error, the weights, and every pool task's prediction.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import json
24
+ import math
25
+ from pathlib import Path
26
+
27
+ from .build import write_pack
28
+ from .pack import TaskPack
29
+
30
+ FEATURES = ["runs", "conflicts", "decisions", "holds", "short_turns", "cancels", "turn_points", "avoidable",
31
+ "greedy_gap", "closed_min", "slow_min", "track_min", "single_closed", "mid_line", "late_sets"]
32
+
33
+
34
+ def _vec(h: dict, feats: list[str]) -> list[float]:
35
+ out = []
36
+ for f in feats:
37
+ v = h.get(f, 0)
38
+ v = float(v) if not isinstance(v, bool) else float(int(v))
39
+ if f in ("avoidable",):
40
+ v = math.log1p(max(0.0, v))
41
+ out.append(v)
42
+ return out
43
+
44
+
45
+ def _solve(A: list[list[float]], b: list[float]) -> list[float]:
46
+ """Gaussian elimination with partial pivoting."""
47
+ n = len(A)
48
+ M = [row[:] + [b[i]] for i, row in enumerate(A)]
49
+ for c in range(n):
50
+ p = max(range(c, n), key=lambda r: abs(M[r][c]))
51
+ M[c], M[p] = M[p], M[c]
52
+ if abs(M[c][c]) < 1e-12:
53
+ continue
54
+ for r in range(n):
55
+ if r != c:
56
+ f = M[r][c] / M[c][c]
57
+ for k in range(c, n + 1):
58
+ M[r][k] -= f * M[c][k]
59
+ return [M[i][n] / M[i][i] if abs(M[i][i]) > 1e-12 else 0.0 for i in range(n)]
60
+
61
+
62
+ def ridge(X: list[list[float]], y: list[float], lam: float) -> list[float]:
63
+ """w (intercept first) minimizing |y - [1 X] w|^2 + lam |w[1:]|^2."""
64
+ Z = [[1.0] + row for row in X]
65
+ d = len(Z[0])
66
+ A = [[sum(z[i] * z[j] for z in Z) + (lam if i == j and i > 0 else 0.0) for j in range(d)] for i in range(d)]
67
+ b = [sum(z[i] * yy for z, yy in zip(Z, y)) for i in range(d)]
68
+ return _solve(A, b)
69
+
70
+
71
+ def predict(w: list[float], x: list[float]) -> float:
72
+ return w[0] + sum(a * b for a, b in zip(w[1:], x))
73
+
74
+
75
+ class Scaler:
76
+ def __init__(self, rows: list[list[float]]):
77
+ n = len(rows)
78
+ self.mu = [sum(r[j] for r in rows) / n for j in range(len(rows[0]))]
79
+ self.sd = [math.sqrt(sum((r[j] - self.mu[j]) ** 2 for r in rows) / n) or 1.0 for j in range(len(rows[0]))]
80
+
81
+ def __call__(self, r: list[float]) -> list[float]:
82
+ return [(v - m) / s for v, m, s in zip(r, self.mu, self.sd)]
83
+
84
+
85
+ def fit(calib: list[tuple[dict, float]], pool_feats: list[dict], feats: list[str], lam: float):
86
+ X_raw = [_vec(h, feats) for h, _ in calib]
87
+ scale = Scaler(X_raw + [_vec(h, feats) for h in pool_feats])
88
+ X = [scale(r) for r in X_raw]
89
+ y = [r for _, r in calib]
90
+ w = ridge(X, y, lam)
91
+ loo, signs = [], []
92
+ for i in range(len(X)):
93
+ wi = ridge(X[:i] + X[i + 1:], y[:i] + y[i + 1:], lam)
94
+ loo.append(predict(wi, X[i]) - y[i])
95
+ signs.append([math.copysign(1, v) if abs(v) > 1e-9 else 0 for v in wi[1:]])
96
+ stable = [all(s[j] == signs[0][j] for s in signs) for j in range(len(feats))]
97
+ return w, scale, loo, stable
98
+
99
+
100
+ def _spearman(a: list[float], b: list[float]) -> float:
101
+ def ranks(v):
102
+ o = sorted(range(len(v)), key=lambda i: v[i])
103
+ r = [0.0] * len(v)
104
+ for k, i in enumerate(o):
105
+ r[i] = k
106
+ return r
107
+ ra, rb = ranks(a), ranks(b)
108
+ n = len(a)
109
+ ma, mb = sum(ra) / n, sum(rb) / n
110
+ num = sum((x - ma) * (y - mb) for x, y in zip(ra, rb))
111
+ den = math.sqrt(sum((x - ma) ** 2 for x in ra) * sum((y - mb) ** 2 for y in rb)) or 1.0
112
+ return num / den
113
+
114
+
115
+ def main():
116
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
117
+ ap.add_argument("--pool", required=True)
118
+ ap.add_argument("--calib", nargs="+", required=True, help="index.json of runs on train tasks (with hardness)")
119
+ ap.add_argument("--model", default=None, help="only this model's calibration episodes")
120
+ ap.add_argument("--n", type=int, default=45)
121
+ ap.add_argument("--out", required=True)
122
+ ap.add_argument("--max-per-day", type=int, default=3)
123
+ ap.add_argument("--min-per-tier", type=int, default=4)
124
+ ap.add_argument("--lam", type=float, default=3.0)
125
+ args = ap.parse_args()
126
+ calib = []
127
+ for path in args.calib:
128
+ for e in json.loads(Path(path).read_text())["episodes"]:
129
+ if e.get("split") == "train" and e.get("hardness") and (not args.model or e["model"] == args.model):
130
+ calib.append((e["hardness"], float(e["reward"])))
131
+ pool = TaskPack(args.pool)
132
+ tasks = [t for t in pool.tasks if t.split == "eval"]
133
+ feats = list(FEATURES)
134
+ w, scale, loo, stable = fit(calib, [t.reference["hardness"] for t in tasks], feats, args.lam)
135
+ keep = [f for f, s in zip(feats, stable) if s]
136
+ if keep and len(keep) < len(feats):
137
+ feats = keep
138
+ w, scale, loo, stable = fit(calib, [t.reference["hardness"] for t in tasks], feats, args.lam)
139
+ preds = {t.task_id: predict(w, scale(_vec(t.reference["hardness"], feats))) for t in tasks}
140
+ y = [r for _, r in calib]
141
+ loo_pred = [e + yy for e, yy in zip(loo, y)]
142
+ # pick: lowest predicted reward, every tier represented, at most max_per_day per day
143
+ order = sorted(tasks, key=lambda t: preds[t.task_id])
144
+ chosen, per_day, per_tier = [], {}, {}
145
+ tiers = sorted({t.difficulty for t in tasks})
146
+ for tier in tiers: # the hardest few of each tier first
147
+ for t in [x for x in order if x.difficulty == tier]:
148
+ if per_tier.get(tier, 0) >= args.min_per_tier or len(chosen) >= args.n:
149
+ break
150
+ if per_day.get(t.day, 0) < args.max_per_day:
151
+ chosen.append(t)
152
+ per_day[t.day] = per_day.get(t.day, 0) + 1
153
+ per_tier[tier] = per_tier.get(tier, 0) + 1
154
+ for t in order:
155
+ if len(chosen) >= args.n:
156
+ break
157
+ if t in chosen or per_day.get(t.day, 0) >= args.max_per_day:
158
+ continue
159
+ chosen.append(t)
160
+ per_day[t.day] = per_day.get(t.day, 0) + 1
161
+ per_tier[t.difficulty] = per_tier.get(t.difficulty, 0) + 1
162
+ report = {
163
+ "calibration_episodes": len(calib), "features": feats, "lambda": args.lam,
164
+ "weights": dict(zip(["intercept"] + feats, [round(v, 4) for v in w])),
165
+ "loo_mae": round(sum(abs(e) for e in loo) / max(1, len(loo)), 4),
166
+ "loo_spearman": round(_spearman(loo_pred, y), 4) if len(y) > 2 else None,
167
+ "calibration_mean_reward": round(sum(y) / max(1, len(y)), 4),
168
+ "pool": len(tasks), "chosen": len(chosen), "per_tier": per_tier,
169
+ "predicted_mean_chosen": round(sum(preds[t.task_id] for t in chosen) / max(1, len(chosen)), 4),
170
+ "predicted_mean_pool": round(sum(preds.values()) / max(1, len(preds)), 4),
171
+ "predictions": {k: round(v, 4) for k, v in sorted(preds.items(), key=lambda kv: kv[1])},
172
+ }
173
+ out = Path(args.out)
174
+ out.mkdir(parents=True, exist_ok=True)
175
+ for t in chosen:
176
+ t.reference["predicted_reward"] = round(preds[t.task_id], 4)
177
+ picked = sorted((t.to_dict() for t in chosen), key=lambda d: (d["difficulty"], d["day"], d["start"]))
178
+ log = [json.loads(line) for line in (Path(args.pool) / "build_log.jsonl").read_text().splitlines()] \
179
+ if (Path(args.pool) / "build_log.jsonl").is_file() else []
180
+ manifest = write_pack(out, picked, log, "eval", extra={"selected_from": Path(args.pool).name,
181
+ "selection": {k: report[k] for k in report
182
+ if k != "predictions"}})
183
+ (out / "selection.json").write_text(json.dumps(report, indent=1) + "\n")
184
+ print(json.dumps({k: v for k, v in report.items() if k != "predictions"}, indent=1))
185
+ print(json.dumps({k: manifest[k] for k in ("tasks", "tiers", "runs")}))
186
+
187
+
188
+ if __name__ == "__main__":
189
+ main()
core/rail_core/solve.py ADDED
@@ -0,0 +1,608 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The reference plan: CP-SAT over the same physics and rules as `check`, proven optimal (or within a stated gap).
2
+
3
+ The model mirrors `check.simulate` exactly:
4
+ one option per run (run in full, short-turned / started at a turn point, or cancelled)
5
+ arrival and departure minutes at every point it may serve, with the same recurrences: minimum running times,
6
+ never ahead of the timetable, no early departures at scheduled stops, `stop_loss` for an unscheduled stop (a Boolean per passing point)
7
+ each train set's position and readiness carried run to run (a cancelled run leaves both unchanged)
8
+ single-track and headway rules as pairwise disjunctions (pruned where the time windows cannot meet), platform
9
+ tracks as cumulatives, closures as disjunctions, max delay as a domain bound
10
+ The solution is turned into a plan of holds, short-turns and cancellations and re-checked with `check.evaluate`: its
11
+ cost must equal the solver's objective.
12
+
13
+ `floor`: the same model without any interaction between train sets (no single-track, headway or platform rules).
14
+ Every real plan is feasible there, so its optimum is a lower bound: the cost the closures force on each train set
15
+ alone. The reward measures the gap against optimum - floor.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import time
21
+ from dataclasses import dataclass
22
+
23
+ from ortools.sat.python import cp_model
24
+
25
+ from .check import evaluate, network, run_dir
26
+ from .model import Order, Plan, Run, Task
27
+
28
+
29
+ @dataclass
30
+ class Solution:
31
+ plan: Plan | None
32
+ cost: int | None
33
+ bound: int
34
+ optimal: bool
35
+ status: str
36
+ seconds: float
37
+
38
+
39
+ def _options(task: Task, r: Run) -> list[tuple[int, int] | None]:
40
+ net = network(task)
41
+ n = len(r.route)
42
+ turn = [i for i in range(1, n - 1) if net.by_code[r.route[i]].turn]
43
+ if r.started:
44
+ ends = [n - 1] + [i for i in turn if i >= r.fixed_to]
45
+ return [(0, e) for e in ends]
46
+ starts = [0] + turn
47
+ opts: list[tuple[int, int] | None] = [None]
48
+ for s in starts:
49
+ for e in [n - 1] + turn:
50
+ if e > s:
51
+ opts.append((s, e))
52
+ return opts
53
+
54
+
55
+ class _Model:
56
+ def __init__(self, task: Task, interactions: bool = True, horizon_pad: int = 300,
57
+ unit_floor: dict[str, int] | None = None):
58
+ self.task = task
59
+ self.net = network(task)
60
+ self.m = cp_model.CpModel()
61
+ self.H = task.end + horizon_pad
62
+ self.interactions = interactions
63
+ self.L = task.rule("stop_loss")
64
+ self.turn = task.rule("turn_min")
65
+ self.cost_terms: list = []
66
+ self.const_cost = 0
67
+ self.unit_terms: dict[str, list] = {u.id: [] for u in task.units}
68
+ self.unit_const: dict[str, int] = {u.id: 0 for u in task.units}
69
+ self.unit_floor = unit_floor or {}
70
+ self.rv: dict[str, dict] = {}
71
+ self._build()
72
+
73
+ # ---------------------------------------------------------------- per run
74
+ def _bounds(self, r: Run):
75
+ """(lo, hi_arr, hi_dep): earliest minute at each route point, and the latest arrival and departure there in
76
+ any plan that obeys max_delay (a run that may end before its next stop is bounded only by the horizon)."""
77
+ n = len(r.route)
78
+ lo = [0] * n
79
+ k = r.fixed_to if r.started else 0
80
+ t = max(r.fixed_arr, self.task.start) if r.started else r.dep[0]
81
+ lo[k] = r.fixed_arr if r.started else t
82
+ for i in range(k, n - 1):
83
+ if i > k and r.stops[i]:
84
+ t = max(t, r.dep[i])
85
+ t += r.seg[i]
86
+ if r.arr[i + 1] is not None:
87
+ t = max(t, r.arr[i + 1])
88
+ lo[i + 1] = t
89
+ maxd = self.task.rule("max_delay")
90
+ H = self.H
91
+ hi_arr, hi_dep = [H] * n, [H] * n
92
+ cap, travel, open_end = None, 0, False # the next stop's latest arrival, minutes to it, could end before it
93
+ for i in range(n - 1, k - 1, -1):
94
+ p = self.net.by_code[r.route[i]]
95
+ if i < n - 1:
96
+ travel += r.seg[i]
97
+ if cap is not None and not open_end:
98
+ hi_dep[i] = cap - travel
99
+ if r.stops[i] and r.arr[i] is not None and i > 0:
100
+ hi_arr[i] = r.arr[i] + maxd
101
+ cap, travel, open_end = r.arr[i] + maxd, 0, False
102
+ elif i < n - 1:
103
+ hi_arr[i] = hi_dep[i] if not p.turn else H
104
+ if p.turn:
105
+ open_end = True
106
+ for i in range(n):
107
+ hi_dep[i] = max(hi_dep[i], lo[i])
108
+ hi_arr[i] = max(hi_arr[i], lo[i])
109
+ return lo, hi_arr, hi_dep
110
+
111
+ def _run_vars(self, r: Run):
112
+ m, net = self.m, self.net
113
+ n = len(r.route)
114
+ opts = _options(self.task, r)
115
+ o = [m.NewBoolVar(f"o_{r.id}_{a}") for a in range(len(opts))]
116
+ m.AddExactlyOne(o)
117
+ lo, hi_arr, hi_dep = self._bounds(r)
118
+ f0 = r.fixed_to if r.started else 0
119
+ arr, dep = {}, {}
120
+ for i in range(f0, n):
121
+ arr[i] = m.NewIntVar(lo[i], hi_arr[i] + (self.L if not r.stops[i] else 0), f"a_{r.id}_{i}")
122
+ if i < n - 1:
123
+ dep[i] = m.NewIntVar(lo[i], hi_dep[i] + self.L, f"d_{r.id}_{i}")
124
+
125
+ def bsum(pred, name):
126
+ terms = [o[a] for a, op in enumerate(opts) if op is not None and pred(op)]
127
+ if not terms:
128
+ return 0
129
+ if len(terms) == 1:
130
+ return terms[0]
131
+ b = m.NewBoolVar(name)
132
+ m.Add(b == sum(terms))
133
+ return b
134
+
135
+ v = {i: bsum(lambda op, i=i: op[0] <= i <= op[1], f"v_{r.id}_{i}") for i in range(f0, n)}
136
+ u = {i: bsum(lambda op, i=i: op[0] <= i and op[1] >= i + 1, f"u_{r.id}_{i}") for i in range(f0, n - 1)}
137
+ st = {} if r.started else {i: bsum(lambda op, i=i: op[0] == i, f"s_{r.id}_{i}") for i in range(n)}
138
+ en = {i: bsum(lambda op, i=i: op[1] == i, f"e_{r.id}_{i}") for i in range(f0, n)}
139
+ cancel = o[0] if opts[0] is None else 0
140
+ w = {}
141
+ inner = {}
142
+ for i in range(f0, n):
143
+ code = r.route[i]
144
+ vi, si, ei = v[i], st.get(i, 0), en[i]
145
+ if isinstance(vi, int) and vi == 0:
146
+ inner[i] = 0
147
+ continue
148
+ if r.started and i == f0:
149
+ inner[i] = m.NewBoolVar(f"in_{r.id}_{i}")
150
+ m.Add(inner[i] == 1 - ei)
151
+ else:
152
+ inner[i] = m.NewBoolVar(f"in_{r.id}_{i}")
153
+ m.Add(inner[i] == vi - si - ei)
154
+ if not r.stops[i] and net.by_code[code].kind != "junction" and i < n - 1:
155
+ w[i] = m.NewBoolVar(f"w_{r.id}_{i}")
156
+ m.AddImplication(w[i], inner[i])
157
+ # departures and dwell
158
+ for i in range(f0, n - 1):
159
+ code = r.route[i]
160
+ pt = net.by_code[code]
161
+ if not isinstance(inner[i], int):
162
+ lit = inner[i]
163
+ if r.started and i == f0:
164
+ if r.stops[i]:
165
+ m.Add(dep[i] >= max(r.dep[i], self.task.start)).OnlyEnforceIf(lit)
166
+ m.Add(dep[i] >= self.task.start).OnlyEnforceIf(lit)
167
+ if r.stops[i]:
168
+ m.Add(dep[i] >= arr[i]).OnlyEnforceIf(lit)
169
+ m.Add(dep[i] >= r.dep[i]).OnlyEnforceIf(lit)
170
+ elif pt.kind == "junction":
171
+ m.Add(dep[i] == arr[i]).OnlyEnforceIf(lit)
172
+ else:
173
+ m.Add(dep[i] == arr[i]).OnlyEnforceIf([lit, w[i].Not()])
174
+ m.Add(dep[i] >= arr[i]).OnlyEnforceIf(w[i])
175
+ if not r.started and not isinstance(st[i], int):
176
+ m.Add(dep[i] >= r.dep[i]).OnlyEnforceIf(st[i])
177
+ if r.started:
178
+ k = f0
179
+ if k in w:
180
+ m.Add(arr[k] == r.fixed_arr + self.L * w[k])
181
+ else:
182
+ m.Add(arr[k] == r.fixed_arr)
183
+ # running
184
+ slow = [c for c in self.task.closures if c.kind == "slow"]
185
+ for i in range(f0, n - 1):
186
+ if isinstance(u[i], int):
187
+ continue
188
+ acc_terms = []
189
+ if i in w:
190
+ acc_terms.append(w[i])
191
+ if not r.started and not r.stops[i] and not isinstance(st.get(i, 0), int):
192
+ acc_terms.append(st[i])
193
+ brk_terms = []
194
+ if (i + 1) in w:
195
+ brk_terms.append(w[i + 1])
196
+ if not r.stops[i + 1] and not isinstance(en[i + 1], int):
197
+ brk_terms.append(en[i + 1])
198
+ extra = 0
199
+ sec = self.net.section(r.route[i], r.route[i + 1])
200
+ for c in slow:
201
+ lo_i, hi_i = sorted((self.net.index[c.where[0]], self.net.index[c.where[-1]]))
202
+ if not lo_i <= sec < hi_i:
203
+ continue
204
+ y = m.NewBoolVar(f"slow_{r.id}_{i}_{c.start}")
205
+ b1 = m.NewBoolVar("")
206
+ b2 = m.NewBoolVar("")
207
+ m.Add(dep[i] >= c.start).OnlyEnforceIf(b1)
208
+ m.Add(dep[i] < c.start).OnlyEnforceIf(b1.Not())
209
+ m.Add(dep[i] < c.end).OnlyEnforceIf(b2)
210
+ m.Add(dep[i] >= c.end).OnlyEnforceIf(b2.Not())
211
+ m.AddBoolAnd([b1, b2]).OnlyEnforceIf(y)
212
+ m.AddBoolOr([b1.Not(), b2.Not()]).OnlyEnforceIf(y.Not())
213
+ extra = extra + c.extra * y
214
+ run_t = m.NewIntVar(0, 2 * self.H, f"rt_{r.id}_{i}")
215
+ m.Add(run_t == dep[i] + r.seg[i] + self.L * sum(acc_terms) + extra)
216
+ if r.arr[i + 1] is not None: # never ahead of the timetable
217
+ reach = m.NewIntVar(0, 2 * self.H, f"rc_{r.id}_{i}")
218
+ m.AddMaxEquality(reach, [run_t, r.arr[i + 1]])
219
+ else:
220
+ reach = run_t
221
+ m.Add(arr[i + 1] == reach + self.L * sum(brk_terms)).OnlyEnforceIf(u[i])
222
+ # cost: delays at served stops (not where it starts), cancelled stops
223
+ C = self.task.rule("cancel_cost")
224
+ for i in range(f0, n):
225
+ if not r.stops[i] or r.arr[i] is None or i == 0:
226
+ continue
227
+ if r.started and i == f0:
228
+ if r.fixed_arr >= self.task.start:
229
+ self.const_cost += max(0, r.fixed_arr - r.arr[i])
230
+ self.unit_const[r.unit] += max(0, r.fixed_arr - r.arr[i])
231
+ continue
232
+ lit = m.NewBoolVar(f"late_{r.id}_{i}")
233
+ m.Add(lit == v[i] - st.get(i, 0)) if not isinstance(v[i], int) else m.Add(lit == 0)
234
+ dl = m.NewIntVar(0, self.H, f"dl_{r.id}_{i}")
235
+ m.Add(dl >= arr[i] - r.arr[i]).OnlyEnforceIf(lit)
236
+ self.cost_terms.append(dl)
237
+ self.unit_terms[r.unit].append(dl)
238
+ for a, op in enumerate(opts):
239
+ if op is None:
240
+ self.cost_terms.append(C * r.n_stops * o[a])
241
+ self.unit_terms[r.unit].append(C * r.n_stops * o[a])
242
+ else:
243
+ s, e = op
244
+ lost = sum(1 for j, x in enumerate(r.stops) if x and not (s <= j <= e))
245
+ if lost:
246
+ self.cost_terms.append(C * lost * o[a])
247
+ self.unit_terms[r.unit].append(C * lost * o[a])
248
+ self.rv[r.id] = dict(run=r, opts=opts, o=o, arr=arr, dep=dep, v=v, u=u, st=st, en=en, w=w, inner=inner,
249
+ lo=lo, hi_arr=hi_arr, hi_dep=hi_dep, cancel=cancel, f0=f0)
250
+
251
+ # ---------------------------------------------------------------- train sets
252
+ def _units(self):
253
+ m, net = self.m, self.net
254
+ self.waits = [] # (station, start expr/var, end var, literal)
255
+ for u in self.task.units:
256
+ pos = net.index[u.at] if u.at else None
257
+ rdy = u.ready
258
+ arrived = self.task.start if u.at else None
259
+ for k, rid in enumerate(u.runs):
260
+ R = self.rv[rid]
261
+ r = R["run"]
262
+ npos = m.NewIntVar(0, len(net.points) - 1, f"pos_{rid}")
263
+ nrdy = m.NewIntVar(0, self.H + self.turn, f"rdy_{rid}")
264
+ narr = m.NewIntVar(0, self.H, f"arrd_{rid}")
265
+ c = R["cancel"]
266
+ if not isinstance(c, int):
267
+ if pos is None:
268
+ m.Add(c == 0)
269
+ else:
270
+ m.Add(npos == pos).OnlyEnforceIf(c)
271
+ m.Add(nrdy == (rdy if rdy is not None else 0)).OnlyEnforceIf(c)
272
+ m.Add(narr == (arrived if arrived is not None else 0)).OnlyEnforceIf(c)
273
+ if not r.started:
274
+ for i, sv in R["st"].items():
275
+ if isinstance(sv, int):
276
+ continue
277
+ want = net.index[r.route[i]]
278
+ if pos is None or (isinstance(pos, int) and pos != want):
279
+ m.Add(sv == 0)
280
+ elif not isinstance(pos, int):
281
+ m.Add(pos == want).OnlyEnforceIf(sv)
282
+ if rdy is not None:
283
+ m.Add(R["dep"][i] >= rdy).OnlyEnforceIf(sv)
284
+ code = r.route[i]
285
+ p = net.by_code[code]
286
+ if p.tracks is not None and p.kind != "junction" and self.interactions:
287
+ self.waits.append((code, arrived, R["dep"][i], sv))
288
+ for i, ev in R["en"].items():
289
+ if isinstance(ev, int):
290
+ continue
291
+ m.Add(npos == net.index[r.route[i]]).OnlyEnforceIf(ev)
292
+ m.Add(nrdy == R["arr"][i] + self.turn).OnlyEnforceIf(ev)
293
+ m.Add(narr == R["arr"][i]).OnlyEnforceIf(ev)
294
+ pos, rdy, arrived = npos, nrdy, narr
295
+ if u.after:
296
+ m.Add(pos == net.index[u.after["station"]])
297
+ cr = m.NewIntVar(0, (self.H + self.turn) * int(u.after["stops"]), f"carry_{u.id}")
298
+ m.Add(cr >= int(u.after["stops"]) * (rdy - u.after["dep"]))
299
+ self.cost_terms.append(cr)
300
+ self.unit_terms[u.id].append(cr)
301
+ # a set that ends the window at a station with few tracks keeps one there
302
+ if self.interactions and pos is not None and not isinstance(pos, int):
303
+ for p in net.points:
304
+ if p.tracks is not None and p.kind != "junction" and p.turn:
305
+ b = m.NewBoolVar(f"park_{u.id}_{p.code}")
306
+ m.Add(pos == net.index[p.code]).OnlyEnforceIf(b)
307
+ m.Add(pos != net.index[p.code]).OnlyEnforceIf(b.Not())
308
+ self.waits.append((p.code, arrived, self.H, b))
309
+
310
+ # ---------------------------------------------------------------- interactions
311
+ def _sections(self):
312
+ m, net = self.m, self.net
313
+ hf, ho = self.task.rule("h_follow"), self.task.rule("h_opp")
314
+ occ: dict[int, list] = {}
315
+ for rid, R in self.rv.items():
316
+ r = R["run"]
317
+ d = run_dir(net, r)
318
+ for i, ui in R["u"].items():
319
+ if isinstance(ui, int) and ui == 0:
320
+ continue
321
+ sec = net.section(r.route[i], r.route[i + 1])
322
+ occ.setdefault(sec, []).append((rid, d, R["dep"][i], R["arr"][i + 1], ui,
323
+ R["lo"][i], R["hi_dep"][i] + self.L, R["lo"][i + 1],
324
+ R["hi_arr"][i + 1] + self.L))
325
+ if r.started and r.fixed_from is not None:
326
+ k = r.fixed_to
327
+ sec = net.section(r.route[k - 1], r.route[k])
328
+ occ.setdefault(sec, []).append((rid, d, r.fixed_from, R["arr"][k], 1, r.fixed_from, r.fixed_from,
329
+ r.fixed_arr, r.fixed_arr + self.L))
330
+ self.n_pairs = 0
331
+ order: dict[tuple, object] = {} # (section, first run id, second run id) -> "first goes first"
332
+ for sec, lst in occ.items():
333
+ single = net.sections[sec].single
334
+ for x in range(len(lst)):
335
+ for y in range(x + 1, len(lst)):
336
+ a, b = lst[x], lst[y]
337
+ if a[0] == b[0]:
338
+ continue
339
+ same = a[1] == b[1]
340
+ if not same and not single:
341
+ continue
342
+ lits = [l for l in (a[4], b[4]) if not isinstance(l, int)]
343
+ if same:
344
+ a_first = b[5] >= a[6] + hf and b[7] >= a[8] + hf
345
+ b_first = a[5] >= b[6] + hf and a[7] >= b[8] + hf
346
+ else:
347
+ a_first = b[5] >= a[8] + ho
348
+ b_first = a[5] >= b[8] + ho
349
+ if a_first or b_first:
350
+ order[(sec, a[0], b[0])] = bool(a_first)
351
+ continue
352
+ self.n_pairs += 1
353
+ z = m.NewBoolVar("")
354
+ order[(sec, a[0], b[0])] = z
355
+ if same:
356
+ m.Add(b[2] >= a[2] + hf).OnlyEnforceIf([z] + lits)
357
+ m.Add(b[3] >= a[3] + hf).OnlyEnforceIf([z] + lits)
358
+ m.Add(a[2] >= b[2] + hf).OnlyEnforceIf([z.Not()] + lits)
359
+ m.Add(a[3] >= b[3] + hf).OnlyEnforceIf([z.Not()] + lits)
360
+ else:
361
+ m.Add(b[2] >= a[3] + ho).OnlyEnforceIf([z] + lits)
362
+ m.Add(a[2] >= b[3] + ho).OnlyEnforceIf([z.Not()] + lits)
363
+ self._tie_orders(order, occ)
364
+
365
+ def _tie_orders(self, order: dict, occ: dict):
366
+ """Redundant but exact: where two trains cannot pass each other at the point between two sections (a junction,
367
+ a one-track halt, a station with one track each way), their order is the same in both sections. Tying the
368
+ order variables removes symmetric search the solver would otherwise do."""
369
+ m, net = self.m, self.net
370
+ self.n_ties = 0
371
+ dirs = {e[0]: e[1] for lst in occ.values() for e in lst}
372
+
373
+ def lit(sec, a, b):
374
+ if (sec, a, b) in order:
375
+ return order[(sec, a, b)]
376
+ if (sec, b, a) in order:
377
+ v = order[(sec, b, a)]
378
+ return (not v) if isinstance(v, bool) else v.Not()
379
+ return None
380
+
381
+ for sec in range(len(net.sections) - 1):
382
+ p = net.points[sec + 1]
383
+ no_pass = p.kind == "junction" or p.directional or p.tracks == 1
384
+ one_track = p.tracks == 1 and net.sections[sec].single and net.sections[sec + 1].single
385
+ if not (no_pass or one_track):
386
+ continue
387
+ for (s, a, b) in list(order):
388
+ if s != sec:
389
+ continue
390
+ same = dirs.get(a) == dirs.get(b)
391
+ if (same and not no_pass) or (not same and not one_track):
392
+ continue
393
+ x, y = lit(sec, a, b), lit(sec + 1, a, b)
394
+ if x is None or y is None or (isinstance(x, bool) and isinstance(y, bool)):
395
+ continue
396
+ if isinstance(x, bool):
397
+ m.AddBoolOr([y if x else y.Not()])
398
+ elif isinstance(y, bool):
399
+ m.AddBoolOr([x if y else x.Not()])
400
+ else:
401
+ m.AddImplication(x, y)
402
+ m.AddImplication(y, x)
403
+ self.n_ties += 1
404
+
405
+ def _closures(self):
406
+ m, net = self.m, self.net
407
+ for c in self.task.closures:
408
+ if c.kind != "closed":
409
+ continue
410
+ lo_s, hi_s = sorted((net.index[c.where[0]], net.index[c.where[-1]]))
411
+ for rid, R in self.rv.items():
412
+ r = R["run"]
413
+ for i, ui in R["u"].items():
414
+ if isinstance(ui, int) and ui == 0:
415
+ continue
416
+ sec = net.section(r.route[i], r.route[i + 1])
417
+ if not lo_s <= sec < hi_s:
418
+ continue
419
+ if R["lo"][i] >= c.end or R["hi_arr"][i + 1] + self.L <= c.start:
420
+ continue
421
+ lits = [ui] if not isinstance(ui, int) else []
422
+ z = m.NewBoolVar("")
423
+ m.Add(R["arr"][i + 1] <= c.start).OnlyEnforceIf([z] + lits)
424
+ m.Add(R["dep"][i] >= c.end).OnlyEnforceIf([z.Not()] + lits)
425
+
426
+ def _stations(self):
427
+ m, net = self.m, self.net
428
+ by_pt: dict[str, list] = {}
429
+ for rid, R in self.rv.items():
430
+ r = R["run"]
431
+ d = run_dir(net, r)
432
+ for i, lit in R["inner"].items():
433
+ if isinstance(lit, int) and lit == 0:
434
+ continue
435
+ code = r.route[i]
436
+ p = net.by_code[code]
437
+ if p.tracks is None or p.kind == "junction" or i == len(r.route) - 1:
438
+ continue
439
+ end = m.NewIntVar(0, self.H + 1, "")
440
+ m.Add(end == R["dep"][i] + 1)
441
+ size = m.NewIntVar(1, self.H + 1, "")
442
+ iv = m.NewOptionalIntervalVar(R["arr"][i], size, end, lit, f"p_{rid}_{i}")
443
+ by_pt.setdefault(code, []).append((iv, d))
444
+ for code, start, depv, lit in self.waits:
445
+ s0 = start if start is not None else 0
446
+ if isinstance(s0, int):
447
+ sv = m.NewConstant(s0)
448
+ else:
449
+ sv = s0
450
+ end = m.NewIntVar(0, self.H + 2, "")
451
+ m.Add(end == depv + 1)
452
+ size = m.NewIntVar(0, self.H + 2, "")
453
+ iv = m.NewOptionalIntervalVar(sv, size, end, lit, f"wait_{code}")
454
+ by_pt.setdefault(code, []).append((iv, 0))
455
+ for code, lst in by_pt.items():
456
+ p = net.by_code[code]
457
+ outs = [c for c in self.task.closures if c.kind == "track" and c.where[0] == code]
458
+ fixed = [m.NewFixedSizeIntervalVar(c.start, c.end - c.start, "") for c in outs]
459
+ if p.directional:
460
+ for d in (1, -1):
461
+ ivs = [iv for iv, dd in lst if dd in (d, 0)]
462
+ if len(ivs) + len(fixed) > 1:
463
+ m.AddNoOverlap(ivs + fixed)
464
+ else:
465
+ ivs = [iv for iv, _ in lst] + fixed
466
+ if len(ivs) > p.tracks:
467
+ m.AddCumulative(ivs, [1] * len(ivs), p.tracks)
468
+
469
+ def _build(self):
470
+ for r in self.task.runs:
471
+ self._run_vars(r)
472
+ self._units()
473
+ self._closures()
474
+ self.n_pairs = 0
475
+ if self.interactions:
476
+ self._sections()
477
+ self._stations()
478
+ self.obj = sum(self.cost_terms) + self.const_cost
479
+ # each train set costs at least what it costs alone with the closures (the floor's part): exact, and it
480
+ # lifts the solver's bound to the floor at once, so only the interactions are left to prove
481
+ for uid, f in self.unit_floor.items():
482
+ if uid in self.unit_terms:
483
+ self.m.Add(sum(self.unit_terms[uid]) + self.unit_const[uid] >= f)
484
+ if self.unit_floor: # CP-SAT's bound does not add the parts up by itself
485
+ self.m.Add(self.obj >= sum(self.unit_floor.values()))
486
+ self.m.Minimize(self.obj)
487
+
488
+ def unit_costs(self, solver) -> dict[str, int]:
489
+ return {uid: int(sum(solver.Value(t) for t in terms)) + self.unit_const[uid]
490
+ for uid, terms in self.unit_terms.items()}
491
+
492
+ # ---------------------------------------------------------------- solution
493
+ def plan(self, solver) -> Plan:
494
+ plan: Plan = {}
495
+ for rid, R in self.rv.items():
496
+ r = R["run"]
497
+ a = next(a for a, ov in enumerate(R["o"]) if solver.Value(ov))
498
+ op = R["opts"][a]
499
+ if op is None:
500
+ plan[rid] = Order(cancel=True)
501
+ continue
502
+ s, e = op
503
+ o = Order(start=r.route[s] if s > 0 else None, end=r.route[e] if e < len(r.route) - 1 else None)
504
+ first = R["f0"] if r.started else s
505
+ for i in range(first, e):
506
+ if self.net.by_code[r.route[i]].kind == "junction":
507
+ continue
508
+ o.hold[r.route[i]] = solver.Value(R["dep"][i])
509
+ plan[rid] = o
510
+ return _tidy(self.task, plan)
511
+
512
+ def hint(self, plan: Plan):
513
+ """Hint the solver with a plan's times (from `check.simulate`)."""
514
+ from .check import simulate
515
+ res, *_ = simulate(self.task, plan)
516
+ for rid, R in self.rv.items():
517
+ x = res.get(rid)
518
+ o = plan.get(rid) or Order()
519
+ r = R["run"]
520
+ if x is None:
521
+ continue
522
+ if x.cancelled:
523
+ key = None
524
+ else:
525
+ key = (0 if r.started else x.first, x.last)
526
+ for a, op in enumerate(R["opts"]):
527
+ self.m.AddHint(R["o"][a], int(op == key))
528
+ if x.cancelled:
529
+ continue
530
+ for i, t in x.arr.items():
531
+ if i in R["arr"]:
532
+ self.m.AddHint(R["arr"][i], t)
533
+ for i, t in x.dep.items():
534
+ if i in R["dep"]:
535
+ self.m.AddHint(R["dep"][i], t)
536
+ for i, wv in R["w"].items():
537
+ self.m.AddHint(wv, int(bool(x.stopped.get(i)) and not r.stops[i] and i != x.first and i != x.last))
538
+
539
+
540
+ def _tidy(task: Task, plan: Plan) -> Plan:
541
+ """Drop holds that change nothing (the train would leave then anyway), so reference plans read cleanly."""
542
+ from .check import simulate
543
+ out: Plan = {}
544
+ for rid, o in plan.items():
545
+ if o.cancel:
546
+ out[rid] = o
547
+ continue
548
+ out[rid] = Order(start=o.start, end=o.end, hold=dict(o.hold))
549
+ base, *_ = simulate(task, out)
550
+ for rid, o in list(out.items()):
551
+ if o.cancel:
552
+ continue
553
+ for code in list(o.hold):
554
+ trial = Order(start=o.start, end=o.end, hold={k: v for k, v in o.hold.items() if k != code})
555
+ res, *_ = simulate(task, {**out, rid: trial})
556
+ if res[rid].arr == base[rid].arr and res[rid].dep == base[rid].dep:
557
+ out[rid] = trial
558
+ o = trial
559
+ return {rid: o for rid, o in out.items() if o.cancel or o.start or o.end or o.hold}
560
+
561
+
562
+ def solve(task: Task, time_limit: float = 60.0, workers: int = 4, hint: Plan | None = None,
563
+ interactions: bool = True, log: bool = False, unit_floor: dict[str, int] | None = None) -> Solution:
564
+ t0 = time.time()
565
+ M = _Model(task, interactions=interactions, unit_floor=unit_floor)
566
+ if hint is not None:
567
+ M.hint(hint)
568
+ solver = cp_model.CpSolver()
569
+ solver.parameters.max_time_in_seconds = time_limit
570
+ solver.parameters.num_workers = workers
571
+ solver.parameters.log_search_progress = log
572
+ st = solver.Solve(M.m)
573
+ name = solver.StatusName(st)
574
+ if st not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
575
+ return Solution(None, None, int(solver.BestObjectiveBound()) if st != cp_model.INFEASIBLE else 0, False, name,
576
+ time.time() - t0)
577
+ plan = M.plan(solver) if interactions else None
578
+ return Solution(plan, int(round(solver.ObjectiveValue())), int(round(solver.BestObjectiveBound())),
579
+ st == cp_model.OPTIMAL, name, time.time() - t0)
580
+
581
+
582
+ def floor_cost(task: Task, time_limit: float = 30.0) -> int:
583
+ """Lower bound: each train set alone with the closures (no interactions)."""
584
+ return floor_parts(task, time_limit)[0]
585
+
586
+
587
+ def floor_parts(task: Task, time_limit: float = 30.0) -> tuple[int, dict[str, int]]:
588
+ """(floor, each train set's own part). Without interactions the sets are independent, so a proven optimum is
589
+ the sum of each set's optimum alone; the parts are only returned when that is proven."""
590
+ M = _Model(task, interactions=False)
591
+ solver = cp_model.CpSolver()
592
+ solver.parameters.max_time_in_seconds = time_limit
593
+ solver.parameters.num_workers = 4
594
+ st = solver.Solve(M.m)
595
+ if st == cp_model.OPTIMAL:
596
+ return int(round(solver.ObjectiveValue())), M.unit_costs(solver)
597
+ return int(solver.BestObjectiveBound()) if st == cp_model.FEASIBLE else 0, {}
598
+
599
+
600
+ def optimal_plan(task: Task, time_limit: float = 60.0, workers: int = 4, hint: Plan | None = None,
601
+ unit_floor: dict[str, int] | None = None) -> Solution:
602
+ sol = solve(task, time_limit=time_limit, workers=workers, hint=hint, unit_floor=unit_floor)
603
+ if sol.plan is not None:
604
+ res = evaluate(task, sol.plan)
605
+ if not res.feasible or res.cost != sol.cost:
606
+ raise AssertionError(f"{task.task_id}: solver plan re-checks to feasible={res.feasible} cost={res.cost}, "
607
+ f"solver said {sol.cost}: {res.violations[:3]}")
608
+ return sol
openenv.yaml ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ spec_version: 1
2
+ name: rail_ops
3
+ type: space
4
+ runtime: fastapi
5
+ app: rail_openenv.server:app
6
+ port: 7860
7
+ description: Rail dispatch on Arriva's single-track Groningen - Leeuwarden line (Netherlands) on real January-June 2025 days - hold, short-turn and cancel trains so opposing trains cross safely after real breakdowns, collisions, signal and points failures (and seeded ones drawn from the 2024-2025 disruption record); graded once on submit against the CP-SAT optimum. 45 eval tasks on held-out weeks, chosen for hardness, and a train pack on the other days.
8
+ version: "0.1.0"
9
+ variables:
10
+ RAIL_TASKS_DIR: "" # colon-separated pack directories; defaults to tasks/rail-v1-eval:tasks/rail-v1-train
11
+ RAIL_MAX_CHECKS: "10"
12
+ RAIL_MAX_TOOL_CALLS: "24"
13
+ MAX_CONCURRENT_ENVS: "64"
14
+ validation:
15
+ reward:
16
+ range: [0.0, 1.0]
17
+ # oracle/solve.py submits the CP-SAT optimum and scores exactly 1.0; no plan scores 0, any rule broken < 0.2
18
+ oracle_tolerance: 0.001
19
+ floor_margin: 0.5
20
+ resources:
21
+ # pure Python: the grader re-simulates a plan in milliseconds, CP-SAT never runs on the server
22
+ cpu: 2.0
23
+ memory_mb: 2048
24
+ disk_mb: 1024
25
+ episode_timeout_s: 3600.0
26
+ network:
27
+ mode: no-network
28
+ capabilities:
29
+ verifier:
30
+ kind: reward_channel
31
+ oracle:
32
+ form: script
33
+ location: oracle/solve.py
34
+ rubric_tree: true
35
+ task_api: true
36
+ declared_tools: [get_situation, check_plan, submit_plan]
37
+ types:
38
+ tags: [simulation, railway, dispatching, scheduling, operations-research, mcp, real-world-data]
openenv/rail_openenv/__init__.py ADDED
File without changes
openenv/rail_openenv/environment.py ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The OpenEnv environment: one dispatching episode per session, three MCP tools.
2
+
3
+ get_situation() the line, closures, trains (timetables, positions, delays) and train sets
4
+ check_plan(plan) simulates a draft: every train's times, the rules it breaks and the cost (budgeted; never a
5
+ score or a reference)
6
+ submit_plan(plan) the final answer: ends the episode; the rubric grades it once
7
+
8
+ `reset(task_id=...)` or `reset(split=, index=)` (or `seed=` for a random task) picks a task. Tasks are served through
9
+ OpenEnv's Task API (`list_splits`, `num_tasks`, `get_task`, `get_task_range`). The reward is `RailPlanRubric` applied
10
+ to the `submit_plan` action; every other step has reward 0.0. An episode that runs out of tool calls ends with 0.0.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import random
18
+ import threading
19
+ import time
20
+ import uuid
21
+ from collections import OrderedDict
22
+ from typing import Annotated, Any
23
+
24
+ from fastmcp import FastMCP
25
+ from openenv.core.env_server.mcp_environment import MCPEnvironment
26
+ from openenv.core.env_server.mcp_types import CallToolAction
27
+ from openenv.core.env_server.types import Action, EnvironmentMetadata, Observation, State
28
+ from pydantic import Field
29
+
30
+ from rail_core import (PROMPT_VERSION, PlanError, Task, TaskPack, load_pack, parse_plan, plan_report, plan_to_list,
31
+ rules, situation)
32
+
33
+ try:
34
+ from .rubric import SUBMIT_TOOL, RailPlanRubric
35
+ except ImportError: # flat layout
36
+ from rubric import SUBMIT_TOOL, RailPlanRubric
37
+
38
+ MAX_CHECKS = int(os.environ.get("RAIL_MAX_CHECKS", "10"))
39
+ MAX_TOOL_CALLS = int(os.environ.get("RAIL_MAX_TOOL_CALLS", "24"))
40
+
41
+ PlanArg = Annotated[list[dict[str, Any]] | str, Field(
42
+ description='one entry per train you change, e.g. [{"train": "37413", "to": "HDG"}, '
43
+ '{"train": "37424", "from": "HDG", "hold": {"HDG": "07:31"}}, {"train": "37419", "cancel": true}]; '
44
+ 'trains not listed run in full, as early as they can')]
45
+
46
+
47
+ class RailState(State):
48
+ task_id: str | None = None
49
+ split: str | None = None
50
+ checks_used: int = 0
51
+ tool_calls: int = 0
52
+ done: bool = False
53
+ reward: float | None = None
54
+ grade: dict | None = None
55
+
56
+
57
+ class Episode:
58
+ """Everything one episode did (bounded store, newest first), for /api/episodes."""
59
+
60
+ def __init__(self, task: Task, episode_id: str):
61
+ self.task = task
62
+ self.episode_id = episode_id
63
+ self.started = time.time()
64
+ self.steps: list[dict] = []
65
+ self.done = False
66
+ self.grade: dict | None = None
67
+ self.end_reason: str | None = None
68
+
69
+ def summary(self) -> dict:
70
+ return {"episode_id": self.episode_id, "task_id": self.task.task_id, "started": self.started,
71
+ "done": self.done, "end_reason": self.end_reason, "steps": self.steps, "grade": self.grade}
72
+
73
+
74
+ class _Store:
75
+ def __init__(self, cap: int = 200):
76
+ self.cap = cap
77
+ self._d: OrderedDict[str, Episode] = OrderedDict()
78
+ self._lock = threading.Lock()
79
+
80
+ def add(self, ep: Episode):
81
+ with self._lock:
82
+ self._d[ep.episode_id] = ep
83
+ while len(self._d) > self.cap:
84
+ self._d.popitem(last=False)
85
+
86
+ def get(self, eid: str) -> Episode | None:
87
+ return self._d.get(eid)
88
+
89
+ def recent(self, n: int = 50) -> list[Episode]:
90
+ return list(reversed(self._d.values()))[:n]
91
+
92
+
93
+ STORE = _Store()
94
+
95
+
96
+ def _json(obj: Any) -> str:
97
+ return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
98
+
99
+
100
+ class RailOpsEnvironment(MCPEnvironment):
101
+ """Re-plan Arriva's trains on the single-track Groningen - Leeuwarden line on a real 2025 day."""
102
+
103
+ SUPPORTS_CONCURRENT_SESSIONS = True
104
+
105
+ def get_metadata(self) -> EnvironmentMetadata:
106
+ return EnvironmentMetadata(
107
+ name="RailSimEnv v1",
108
+ description="Dispatch Arriva's trains on the single-track Groningen - Leeuwarden line (Netherlands) on a "
109
+ "real 2025 day: real timetables, delays and disruptions from the Rijden de Treinen record. "
110
+ "Hold, short-turn or cancel trains so they cross safely. Tools: get_situation, check_plan "
111
+ "(10 per episode), submit_plan (graded once, deterministically, against the CP-SAT optimum).",
112
+ version="1.0.0",
113
+ author="RLSimEnv",
114
+ )
115
+
116
+ def __init__(self, pack: TaskPack | None = None):
117
+ self.pack = pack or load_pack()
118
+ self._episode: Episode | None = None
119
+ self._state = RailState()
120
+ mcp = FastMCP("rail_ops")
121
+
122
+ @mcp.tool
123
+ def get_situation() -> str:
124
+ """The line, what has happened (closures), every train to plan and every train set."""
125
+ ep = self._require()
126
+ return situation(ep.task)
127
+
128
+ @mcp.tool
129
+ def check_plan(plan: PlanArg) -> str:
130
+ """Simulate your plan: every train's times, its delay and the stops it loses, every rule it breaks, and
131
+ the cost. Uses one of your checks. It does not grade the plan."""
132
+ ep = self._require(open_only=True)
133
+ if self._state.checks_used >= MAX_CHECKS:
134
+ return _json({"error": f"no checks left (you had {MAX_CHECKS}); submit_plan when ready"})
135
+ self._state.checks_used += 1
136
+ try:
137
+ parsed, problems = parse_plan(ep.task, plan)
138
+ except PlanError as e:
139
+ out = {"error": str(e), "checks_left": MAX_CHECKS - self._state.checks_used}
140
+ ep.steps.append({"turn": self._state.tool_calls, "tool": "check_plan", "plan": None, "result": out})
141
+ return _json(out)
142
+ out = plan_report(ep.task, parsed, problems)
143
+ out["checks_left"] = MAX_CHECKS - self._state.checks_used
144
+ ep.steps.append({"turn": self._state.tool_calls, "tool": "check_plan", "plan": plan_to_list(parsed),
145
+ "result": {k: v for k, v in out.items() if k != "trains"}})
146
+ return _json(out)
147
+
148
+ @mcp.tool
149
+ def submit_plan(plan: PlanArg) -> str:
150
+ """Submit your final plan. Ends the episode; the plan is graded once."""
151
+ ep = self._require(open_only=True)
152
+ try:
153
+ parsed, problems = parse_plan(ep.task, plan)
154
+ except PlanError as e:
155
+ parsed, problems = {}, [str(e)]
156
+ # The rubric grades the same arguments again in step(); this result is what the agent reads.
157
+ g = self.rubric.graded(CallToolAction(tool_name=SUBMIT_TOOL, arguments={"plan": plan}))
158
+ ep.done = True
159
+ ep.end_reason = "submitted"
160
+ ep.grade = g.as_dict() if g else None
161
+ ep.steps.append({"turn": self._state.tool_calls, "tool": "submit_plan", "plan": plan_to_list(parsed),
162
+ "result": {"grade": ep.grade}})
163
+ out = {"submitted": True, "feasible": g.feasible if g else False, "reward": g.reward if g else 0.0}
164
+ if g and g.feasible:
165
+ out.update(cost=g.cost, delay_cost=g.delay_cost, unserved_stops_cost=g.cancel_cost,
166
+ handed_on_cost=g.carry_cost, cancelled=g.cancelled, short_turned=g.short_turned)
167
+ elif g:
168
+ out.update(violations=g.violations[:20], entry_problems=g.parse_problems)
169
+ return _json(out)
170
+
171
+ super().__init__(mcp)
172
+ self.rubric = RailPlanRubric()
173
+
174
+ # ------------------------------------------------------------ Task API
175
+
176
+ def list_splits(self):
177
+ return [{"name": s, "type": s, "num_tasks": self.pack.count(s)} for s in self.pack.splits()]
178
+
179
+ def num_tasks(self, split):
180
+ return self.pack.count(split)
181
+
182
+ def get_task(self, split, index):
183
+ return self.pack.public(self.pack.at(split, int(index)))
184
+
185
+ def get_task_range(self, split, start=None, stop=None):
186
+ n = self.pack.count(split)
187
+ start, stop = int(start or 0), int(n if stop is None else stop)
188
+ return [self.pack.public(self.pack.at(split, i)) for i in range(max(0, start), min(n, stop))]
189
+
190
+ def list_tasks(self, split):
191
+ return self.get_task_range(split)
192
+
193
+ # ------------------------------------------------------------ lifecycle
194
+
195
+ def reset(self, seed: int | None = None, episode_id: str | None = None, split: str | None = None,
196
+ index: int | None = None, task_id: str | None = None, **kwargs: Any) -> Observation:
197
+ if task_id is not None:
198
+ task = self.pack.get(task_id)
199
+ else:
200
+ split = split or ("train" if "train" in self.pack.splits() else self.pack.splits()[0])
201
+ n = self.pack.count(split)
202
+ index = random.Random(seed).randrange(n) if index is None else int(index)
203
+ task = self.pack.at(split, index)
204
+ ep = Episode(task, str(episode_id)[:64] if episode_id else uuid.uuid4().hex[:12])
205
+ STORE.add(ep)
206
+ self._episode = ep
207
+ self._state = RailState(episode_id=ep.episode_id, step_count=0, task_id=task.task_id, split=task.split)
208
+ self._reset_rubric()
209
+ self.rubric.set_task(task)
210
+ return Observation(done=False, reward=None, metadata={
211
+ "episode_id": ep.episode_id,
212
+ "task_id": task.task_id,
213
+ "split": task.split,
214
+ "difficulty": task.difficulty,
215
+ "instructions": rules(task, MAX_CHECKS),
216
+ "situation": situation(task),
217
+ "max_checks": MAX_CHECKS,
218
+ "max_tool_calls": MAX_TOOL_CALLS,
219
+ "tools": ["get_situation", "check_plan", "submit_plan"],
220
+ "prompt_version": PROMPT_VERSION,
221
+ })
222
+
223
+ def _require(self, open_only: bool = False) -> Episode:
224
+ if self._episode is None:
225
+ raise ValueError("no episode: call reset first")
226
+ if open_only and self._episode.done:
227
+ raise ValueError("the episode is over: the plan was already submitted")
228
+ return self._episode
229
+
230
+ @property
231
+ def state(self) -> RailState:
232
+ return self._state
233
+
234
+ # ------------------------------------------------------------ stepping
235
+
236
+ def _after_step(self, action: Action, obs: Observation) -> Observation:
237
+ ep = self._episode
238
+ if ep is None or not isinstance(action, CallToolAction):
239
+ return obs
240
+ self._state.tool_calls += 1
241
+ self._state.step_count += 1
242
+ obs.reward = self._apply_rubric(action, obs) # 0.0 unless this was submit_plan
243
+ if ep.done:
244
+ g = self.rubric.last_grade
245
+ obs.done = True
246
+ obs.metadata = {**(obs.metadata or {}), "grade": g.as_dict() if g else ep.grade,
247
+ "rubric": {name: r.last_score for name, r in self.rubric.named_rubrics()},
248
+ "episode_id": ep.episode_id}
249
+ self._state.done, self._state.reward = True, obs.reward
250
+ self._state.grade = obs.metadata["grade"]
251
+ elif self._state.tool_calls >= MAX_TOOL_CALLS:
252
+ ep.done, ep.end_reason = True, "tool_call_limit"
253
+ obs.done, obs.reward = True, 0.0
254
+ obs.metadata = {**(obs.metadata or {}), "end_reason": "tool_call_limit", "episode_id": ep.episode_id}
255
+ self._state.done, self._state.reward = True, 0.0
256
+ return obs
257
+
258
+ def step(self, action: Action, timeout_s: float | None = None, **kwargs: Any) -> Observation:
259
+ return self._after_step(action, super().step(action, timeout_s=timeout_s, **kwargs))
260
+
261
+ async def step_async(self, action: Action, timeout_s: float | None = None, **kwargs: Any) -> Observation:
262
+ return self._after_step(action, await super().step_async(action, timeout_s=timeout_s, **kwargs))
263
+
264
+ def _step_impl(self, action: Action, timeout_s: float | None = None, **kwargs) -> Observation:
265
+ return Observation(done=False, reward=0.0,
266
+ metadata={"error": "use the MCP tools: get_situation, check_plan, submit_plan"})
openenv/rail_openenv/gradio_ui.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The "Explore" tab of OpenEnv's web UI (/web): the eval explorer, the 3D replay and the train graph, each the
2
+ server's own page in a frame, one tab each. OpenEnv's Playground tab (reset, step and the MCP tools) stays next to it.
3
+
4
+ A frame is only created when its tab is first opened, so the 3D replay does not start WebGL in a hidden tab.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import html
10
+
11
+ import gradio as gr
12
+
13
+ PAGES = [("Eval explorer", "/explorer/?embed=1"), ("3D replay", "/twin/"), ("Train graph", "/viewer/?embed=1")]
14
+ # OpenEnv's page caps the container width; the pages want the full window.
15
+ STYLE = ("<style>.gradio-container .main.fillable{max-width:none!important;padding-left:12px!important;"
16
+ "padding-right:12px!important}.gradio-container .html-container:has(iframe){padding:0!important}</style>")
17
+
18
+
19
+ def frame(src: str, title: str) -> str:
20
+ return (f'{STYLE}<iframe src="{html.escape(src)}" title="RailSimEnv {html.escape(title)}" allow="fullscreen" '
21
+ 'style="width:100%;height:calc(100vh - 190px);min-height:720px;border:0;display:block"></iframe>')
22
+
23
+
24
+ def build_ui(*_args, **_kwargs) -> gr.Blocks:
25
+ """OpenEnv's `gradio_builder` hook; its arguments (web manager, action fields, metadata, ...) are not needed."""
26
+ with gr.Blocks(title="RailSimEnv v1") as ui:
27
+ with gr.Tabs():
28
+ for i, (name, src) in enumerate(PAGES):
29
+ with gr.Tab(name) as tab:
30
+ out = gr.HTML(frame(src, name) if i == 0 else "")
31
+ loaded = gr.State(i == 0)
32
+
33
+ def show(done: bool, src: str = src, name: str = name):
34
+ return (gr.update(), True) if done else (frame(src, name), True)
35
+
36
+ tab.select(show, inputs=loaded, outputs=[out, loaded])
37
+ return ui
openenv/rail_openenv/rubric.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The reward as OpenEnv rubrics (RFC 004).
2
+
3
+ `RailPlanRubric` scores the `submit_plan` call and returns 0.0 for every other action, so the grader runs once per
4
+ episode. Its two children are the inputs of the score and show up in `named_rubrics()` with their `last_score`:
5
+
6
+ runs_clean fraction of runs with no rule broken (entry problems count as unclean)
7
+ cost_quality exp(-g / 0.5), g the gap to the proven optimum over the avoidable cost; 0 when infeasible
8
+
9
+ The combination is `rail_core.reward.score` (infeasible: 0.2 x runs_clean; feasible: 0.2 + 0.8 x cost_quality),
10
+ shared with the offline grader so the two can never disagree.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from openenv.core.env_server.mcp_types import CallToolAction
18
+ from openenv.core.rubrics import Rubric
19
+
20
+ from rail_core import Grade, PlanError, Task, grade, parse_plan
21
+
22
+ SUBMIT_TOOL = "submit_plan"
23
+
24
+
25
+ def submitted_plan(action: Any):
26
+ if isinstance(action, CallToolAction) and action.tool_name == SUBMIT_TOOL:
27
+ return (action.arguments or {}).get("plan")
28
+ return None
29
+
30
+
31
+ class _Graded(Rubric):
32
+ """Grades the submitted plan once per action and shares the result with sibling rubrics."""
33
+
34
+ def __init__(self):
35
+ super().__init__()
36
+ self.task: Task | None = None
37
+ self._cache: tuple[int, Grade | None] | None = None
38
+
39
+ def graded(self, action: Any) -> Grade | None:
40
+ if self._cache is not None and self._cache[0] == id(action):
41
+ return self._cache[1]
42
+ raw = submitted_plan(action)
43
+ g = None
44
+ if raw is not None and self.task is not None:
45
+ try:
46
+ plan, problems = parse_plan(self.task, raw)
47
+ except PlanError as e:
48
+ plan, problems = {}, [str(e)]
49
+ g = grade(self.task, plan, problems)
50
+ self._cache = (id(action), g)
51
+ return g
52
+
53
+
54
+ class RunsClean(Rubric):
55
+ def __init__(self, source: _Graded):
56
+ super().__init__()
57
+ object.__setattr__(self, "_source", source) # not a child: avoid a cycle in named_rubrics()
58
+
59
+ def forward(self, action, observation) -> float:
60
+ g = self._source.graded(action)
61
+ return 0.0 if g is None else g.clean_fraction
62
+
63
+
64
+ class CostQuality(Rubric):
65
+ def __init__(self, source: _Graded):
66
+ super().__init__()
67
+ object.__setattr__(self, "_source", source)
68
+
69
+ def forward(self, action, observation) -> float:
70
+ g = self._source.graded(action)
71
+ return 0.0 if g is None else g.quality
72
+
73
+
74
+ class RailPlanRubric(_Graded):
75
+ """Terminal reward for a dispatch plan: 0.0 until `submit_plan`, then the score in [0, 1]."""
76
+
77
+ def __init__(self):
78
+ super().__init__()
79
+ self.runs_clean = RunsClean(self)
80
+ self.cost_quality = CostQuality(self)
81
+ self.last_grade: Grade | None = None
82
+
83
+ def set_task(self, task: Task) -> None:
84
+ self.task = task
85
+ self._cache = None
86
+ self.last_grade = None
87
+
88
+ def forward(self, action, observation) -> float:
89
+ g = self.graded(action)
90
+ self.runs_clean(action, observation)
91
+ self.cost_quality(action, observation)
92
+ if g is None:
93
+ return 0.0
94
+ self.last_grade = g
95
+ return g.reward
96
+
97
+ def reset(self) -> None:
98
+ self._cache = None
99
+ self.last_grade = None
openenv/rail_openenv/server.py ADDED
@@ -0,0 +1,338 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The server: OpenEnv's app plus the map replay, a train-graph viewer and their read-only JSON on the same port.
2
+
3
+ OpenEnv provides `/reset`, `/step`, `/state`, `/schema`, `/ws`, `/mcp`, `/health`, the Task API under `/rail_ops/...`
4
+ and its web UI at `/web`, where an "Explore" tab (`gradio_ui.py`) frames the pages below next to OpenEnv's Playground.
5
+ We add:
6
+
7
+ /twin/ the map replay: trains moving on the line's OpenStreetMap track, any plan
8
+ /viewer/ a train graph (time against distance) of any plan, live episodes too
9
+ /explorer/ the eval explorer: the board and every model's plan on every eval task
10
+ /api/tasks task summaries, with the lazy baselines' rewards
11
+ /api/runs the committed model runs: each episode's reward and outcome, and a board
12
+ /api/tasks/{id} a public task (what the agent sees, structured)
13
+ /api/tasks/{id}/reference optimum, floor, baselines and the real outcome - for people; no tool shows them
14
+ /api/tasks/{id}/timeline?plan=... paths for the graph: plan = optimal | naive | greedy | greedy_fast
15
+ /api/episodes live episodes on this server (newest first)
16
+ /api/episodes/{episode_id}/timeline the latest plan an episode checked or submitted, as paths
17
+ /api/tasks/{id}/plans the plans the replay can show: optimum, baselines, models' submitted plans
18
+ (from results/rollouts/*/index.json, or RAIL_RUNS_DIR), live episodes
19
+ /api/tasks/{id}/replay?plan=... everything the replay animates (rail_core.replay)
20
+ /api/episodes/{episode_id}/replay the same for the latest plan of a live episode
21
+
22
+ uvicorn rail_openenv.server:app --port 8000
23
+
24
+ `/` is OpenEnv's: it redirects to the web UI (`/web/`), or to `/explorer/` when ENABLE_WEB_INTERFACE is false.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import os
31
+ import random
32
+ import statistics
33
+ from functools import lru_cache
34
+ from pathlib import Path
35
+
36
+ from fastapi import HTTPException
37
+ from fastapi.responses import RedirectResponse
38
+ from fastapi.staticfiles import StaticFiles
39
+ from openenv.core.env_server.http_server import create_app
40
+ from openenv.core.env_server.mcp_types import CallToolAction, CallToolObservation
41
+
42
+ from rail_core import TaskPack, evaluate, hhmm, load_pack, parse_plan
43
+ from rail_core.baselines import POLICIES
44
+ from rail_core.check import network
45
+ from rail_core.replay import replay
46
+
47
+ try:
48
+ from .environment import STORE, RailOpsEnvironment, RailState
49
+ from .gradio_ui import build_ui
50
+ except ImportError:
51
+ from environment import STORE, RailOpsEnvironment, RailState
52
+ from gradio_ui import build_ui
53
+
54
+ ENV_NAME = "rail_ops"
55
+ WEB = Path(__file__).resolve().parent / "web"
56
+ RUNS_DIR = Path(os.environ.get("RAIL_RUNS_DIR", Path(__file__).resolve().parents[4] / "results" / "rollouts"))
57
+ BASELINES = {"greedy": "greedy dispatcher", "greedy_fast": "greedy dispatcher (turns early)",
58
+ "naive": "change nothing"}
59
+
60
+
61
+ TIERS = ("real", "blockage", "loop", "compound")
62
+ PROVIDERS = {"together": "Together", "baseten": "Baseten", "novita": "Novita", "deepinfra": "DeepInfra",
63
+ "fireworks": "Fireworks"}
64
+
65
+
66
+ def model_name(spec: str) -> str:
67
+ """A person's name for a run's model spec: "hf:Qwen/Qwen3.8-2.4T-A95B:together@default" -> "Qwen3.8-2.4T-A95B
68
+ (Together)", "claude-sonnet-5-5@high" -> "Claude Sonnet 5.5 (effort high)"."""
69
+ model, _, effort = spec.partition("@")
70
+ if model.startswith("hf:"):
71
+ repo, _, provider = model[3:].partition(":")
72
+ name = repo.split("/")[-1]
73
+ return f"{name} ({PROVIDERS.get(provider, provider)})" if provider else name
74
+ if model.startswith("claude-"):
75
+ parts = model.split("-")[1:]
76
+ family = " ".join(p.capitalize() for p in parts if not p.isdigit())
77
+ version = ".".join(p for p in parts if p.isdigit())
78
+ name = f"Claude {family} {version}".strip()
79
+ return f"{name} (effort {effort})" if effort and effort != "default" else name
80
+ return model
81
+
82
+
83
+ def load_runs(runs_dir: Path = RUNS_DIR) -> list[dict]:
84
+ """The committed rollout runs (results/rollouts/*/index.json): [{run, episodes}]."""
85
+ out = []
86
+ for idx in sorted(runs_dir.glob("*/index.json")) if runs_dir.is_dir() else []:
87
+ try:
88
+ body = json.loads(idx.read_text())
89
+ except (OSError, ValueError):
90
+ continue
91
+ out.append({"run": body["run"], "episodes": body.get("episodes", [])})
92
+ return out
93
+
94
+
95
+ def run_plans(runs_dir: Path = RUNS_DIR) -> dict[str, list[dict]]:
96
+ """Submitted plans in the committed rollout runs, by task: [{id, label, model, run, reward, plan}]."""
97
+ out: dict[str, list[dict]] = {}
98
+ for body in load_runs(runs_dir):
99
+ for e in body["episodes"]:
100
+ if e.get("plan") is None:
101
+ continue
102
+ out.setdefault(e["task_id"], []).append(
103
+ {"id": f"run:{body['run']}:{e['model']}", "label": model_name(e["model"]),
104
+ "model": e["model"], "run": body["run"], "reward": e.get("reward"), "plan": e["plan"]})
105
+ for entries in out.values(): # two runs of one model on a task: name the run too
106
+ for e in entries:
107
+ if sum(x["model"] == e["model"] for x in entries) > 1:
108
+ e["label"] = f"{e['label']} ({e['run']})"
109
+ return out
110
+
111
+
112
+ def ci(xs, n=2000, seed=0):
113
+ """95 % bootstrap interval of the mean, the same as eval/report.py's (so the numbers match the README)."""
114
+ xs = sorted(xs)
115
+ rng = random.Random(seed)
116
+ means = sorted(statistics.mean(rng.choices(xs, k=len(xs))) for _ in range(n))
117
+ return means[int(0.025 * n)], means[int(0.975 * n)]
118
+
119
+
120
+ def runs_summary(runs: list[dict]) -> dict:
121
+ """What the eval explorer shows: one row per (run, model) with the board's columns, and every episode's outcome."""
122
+ rows, episodes = [], []
123
+ for body in runs:
124
+ by: dict[str, list[dict]] = {}
125
+ for e in body["episodes"]:
126
+ by.setdefault(e["model"], []).append(e)
127
+ for model, es in by.items():
128
+ r = [e["reward"] for e in es]
129
+ lo, hi = ci(r)
130
+ real = [e for e in es if e["difficulty"] == "real" and e.get("real_cost") is not None]
131
+ rows.append({
132
+ "run": body["run"], "model": model, "name": model_name(model), "split": es[0]["split"],
133
+ "n": len(es), "mean": statistics.mean(r), "ci": [lo, hi],
134
+ "tiers": {t: statistics.mean(x["reward"] for x in es if x["difficulty"] == t)
135
+ for t in TIERS if any(x["difficulty"] == t for x in es)},
136
+ "feasible": sum(bool(e["feasible"]) for e in es),
137
+ "optimal": sum(1 for e in es if e["feasible"] and e["cost"] == e["optimal_cost"]),
138
+ "no_submit": sum(not e["submitted"] for e in es),
139
+ "beat_real": sum(1 for e in real if e["feasible"] and e["cost"] < e["real_cost"]), "real": len(real),
140
+ "median_turns": statistics.median(e["agent_turns"] for e in es)})
141
+ for e in es:
142
+ episodes.append({k: e.get(k) for k in ("model", "task_id", "reward", "feasible", "submitted", "cost",
143
+ "optimal_cost", "end_reason", "agent_turns", "checks",
144
+ "violations")}
145
+ | {"run": body["run"], "plan_id": f"run:{body['run']}:{model}"
146
+ if e.get("plan") is not None else None})
147
+ rows.sort(key=lambda x: (x["split"], -x["mean"]))
148
+ return {"models": rows, "episodes": episodes}
149
+
150
+
151
+ def _summary(t, plans: dict | None = None) -> dict:
152
+ ref = t.reference
153
+ return {"task_id": t.task_id, "split": t.split, "day": t.day, "start": hhmm(t.start), "end": hhmm(t.end),
154
+ "difficulty": t.difficulty, "runs": len(t.runs), "closures": [c.kind for c in t.closures],
155
+ "causes": sorted({c.cause for c in t.closures if c.cause}),
156
+ "model_plans": len((plans or {}).get(t.task_id, [])), "optimal_cost": ref.get("optimal_cost"),
157
+ "rewards": {"greedy": ref.get("greedy_reward"), "greedy_fast": ref.get("greedy_fast_reward"),
158
+ "naive": ref.get("naive_reward"), "real": (ref.get("real") or {}).get("reward_if_legal")}}
159
+
160
+
161
+ def timeline(task, plan) -> dict:
162
+ """Every served run as a path of (km, minute) points, for a time-distance graph."""
163
+ net = network(task)
164
+ res = evaluate(task, plan)
165
+ paths = []
166
+ for x in res.runs:
167
+ if x.cancelled:
168
+ paths.append({"train": x.train, "set": x.unit, "cancelled": True, "points": []})
169
+ continue
170
+ r = task.run(x.train)
171
+ pts = []
172
+ for i in sorted(set(x.arr) | set(x.dep)):
173
+ km = net.by_code[r.route[i]].km
174
+ if i in x.arr:
175
+ pts.append([km, x.arr[i]])
176
+ if i in x.dep and x.dep[i] != x.arr.get(i):
177
+ pts.append([km, x.dep[i]])
178
+ paths.append({"train": x.train, "set": x.unit, "kind": r.kind, "points": pts, "delay": x.delay,
179
+ "unserved": x.unserved, "problems": x.problems})
180
+ return {"task_id": task.task_id, "start": task.start, "end": task.end,
181
+ "points": [{"code": p.code, "km": p.km, "kind": p.kind, "tracks": p.tracks} for p in net.points],
182
+ "sections": [{"a": s.a, "b": s.b, "single": s.single} for s in net.sections],
183
+ "closures": [{"kind": c.kind, "where": c.where, "start": c.start, "end": c.end, "cause": c.cause}
184
+ for c in task.closures],
185
+ "paths": paths, "feasible": res.feasible, "cost": res.cost, "violations": res.violations[:50],
186
+ "optimal_cost": task.reference.get("optimal_cost")}
187
+
188
+
189
+ def mount_api(app, pack: TaskPack) -> None:
190
+ plans_by_task = run_plans()
191
+ runs = runs_summary(load_runs())
192
+
193
+ @app.get("/api/tasks", include_in_schema=False)
194
+ def tasks():
195
+ return [_summary(t, plans_by_task) for t in pack.tasks]
196
+
197
+ @app.get("/api/runs", include_in_schema=False)
198
+ def model_runs():
199
+ return runs
200
+
201
+ def _task(task_id):
202
+ try:
203
+ return pack.get(task_id)
204
+ except KeyError:
205
+ raise HTTPException(404, f"no task {task_id}") from None
206
+
207
+ @app.get("/api/tasks/{task_id}", include_in_schema=False)
208
+ def task(task_id: str):
209
+ return pack.public(_task(task_id))
210
+
211
+ @app.get("/api/tasks/{task_id}/reference", include_in_schema=False)
212
+ def reference(task_id: str):
213
+ return _task(task_id).reference
214
+
215
+ @app.get("/api/tasks/{task_id}/timeline", include_in_schema=False)
216
+ def task_timeline(task_id: str, plan: str = "optimal"):
217
+ t = _task(task_id)
218
+ if plan == "optimal":
219
+ p, _ = parse_plan(t, t.reference["optimal_plan"])
220
+ elif plan in POLICIES:
221
+ p = POLICIES[plan](t)
222
+ else:
223
+ raise HTTPException(400, "plan must be optimal, naive, greedy or greedy_fast")
224
+ return timeline(t, p)
225
+
226
+ def _plan(t, plan_id: str):
227
+ """(plan, parse problems, label) for a plan id."""
228
+ if plan_id == "optimal":
229
+ p, probs = parse_plan(t, t.reference["optimal_plan"])
230
+ return p, probs, "CP-SAT optimum"
231
+ if plan_id in POLICIES:
232
+ return POLICIES[plan_id](t), [], BASELINES.get(plan_id, plan_id)
233
+ for e in plans_by_task.get(t.task_id, []):
234
+ if e["id"] == plan_id:
235
+ p, probs = parse_plan(t, e["plan"])
236
+ return p, probs, e["label"]
237
+ raise HTTPException(400, f"unknown plan {plan_id!r}: see /api/tasks/{t.task_id}/plans")
238
+
239
+ @lru_cache(maxsize=256)
240
+ def _replay(task_id: str, plan_id: str) -> dict:
241
+ t = _task(task_id)
242
+ p, probs, label = _plan(t, plan_id)
243
+ return replay(t, p, label, probs) | {"plan_id": plan_id}
244
+
245
+ @app.get("/api/tasks/{task_id}/plans", include_in_schema=False)
246
+ def task_plans(task_id: str):
247
+ t = _task(task_id)
248
+ ref = t.reference
249
+ out = [{"id": "optimal", "label": "CP-SAT optimum", "reward": 1.0, "cost": ref.get("optimal_cost")}]
250
+ out += [{"id": k, "label": v, "reward": ref.get(f"{k}_reward"),
251
+ "cost": ref.get(f"{k}_cost") if ref.get(f"{k}_feasible") else None} for k, v in BASELINES.items()]
252
+ out += [{k: e[k] for k in ("id", "label", "reward")} for e in plans_by_task.get(task_id, [])]
253
+ out += [{"id": f"episode:{ep.episode_id}", "label": f"live episode {ep.episode_id}", "reward": None}
254
+ for ep in STORE.recent() if ep.task.task_id == task_id]
255
+ return out
256
+
257
+ @app.get("/api/tasks/{task_id}/replay", include_in_schema=False)
258
+ def task_replay(task_id: str, plan: str = "optimal"):
259
+ if plan.startswith("episode:"):
260
+ return episode_replay(plan.split(":", 1)[1])
261
+ return _replay(task_id, plan)
262
+
263
+ @app.get("/api/episodes/{episode_id}/replay", include_in_schema=False)
264
+ def episode_replay(episode_id: str):
265
+ ep = STORE.get(episode_id)
266
+ if ep is None:
267
+ raise HTTPException(404, f"no episode {episode_id}")
268
+ last = next((s for s in reversed(ep.steps) if s.get("plan") is not None), None)
269
+ p, probs = parse_plan(ep.task, last["plan"]) if last else ({}, [])
270
+ return replay(ep.task, p, f"live episode {episode_id}", probs) | {"plan_id": f"episode:{episode_id}"}
271
+
272
+ @app.get("/api/episodes", include_in_schema=False)
273
+ def episodes():
274
+ return [{k: v for k, v in ep.summary().items() if k != "steps"} | {"steps": len(ep.steps)}
275
+ for ep in STORE.recent()]
276
+
277
+ @app.get("/api/episodes/{episode_id}/timeline", include_in_schema=False)
278
+ def episode_timeline(episode_id: str):
279
+ ep = STORE.get(episode_id)
280
+ if ep is None:
281
+ raise HTTPException(404, f"no episode {episode_id}")
282
+ last = next((s for s in reversed(ep.steps) if s.get("plan") is not None), None)
283
+ p, _ = parse_plan(ep.task, last["plan"]) if last else ({}, [])
284
+ return timeline(ep.task, p) | {"episode": {k: v for k, v in ep.summary().items() if k != "steps"}}
285
+
286
+ if (WEB / "twin").is_dir():
287
+ app.mount("/twin", StaticFiles(directory=WEB / "twin", html=True), name="twin")
288
+ if (WEB / "explorer").is_dir():
289
+ app.mount("/explorer", StaticFiles(directory=WEB / "explorer", html=True), name="explorer")
290
+ if WEB.is_dir():
291
+ app.mount("/viewer", StaticFiles(directory=WEB, html=True), name="viewer")
292
+
293
+ @app.get("/", include_in_schema=False)
294
+ def root():
295
+ return RedirectResponse("/explorer/" if (WEB / "explorer" / "index.html").is_file() else "/twin/")
296
+
297
+
298
+ def create_server(pack: TaskPack | None = None):
299
+ pack = pack or load_pack()
300
+
301
+ def factory():
302
+ return RailOpsEnvironment(pack)
303
+
304
+ os.environ.setdefault("ENABLE_WEB_INTERFACE", "true")
305
+ app = create_app(factory, CallToolAction, CallToolObservation, env_name=ENV_NAME,
306
+ max_concurrent_envs=int(os.environ.get("MAX_CONCURRENT_ENVS", "64")),
307
+ gradio_builder=build_ui, custom_tab_name="Explore", custom_tab_primary=True,
308
+ show_default_tab=True, title_override="RailSimEnv v1 Environment", state_cls=RailState)
309
+ mount_api(app, pack)
310
+
311
+ @app.get("/healthz", include_in_schema=False)
312
+ def healthz():
313
+ return {"status": "ok", "tasks": {s: pack.count(s) for s in pack.splits()}}
314
+
315
+ return app
316
+
317
+
318
+ _app = None
319
+
320
+
321
+ def __getattr__(name: str):
322
+ global _app
323
+ if name == "app":
324
+ if _app is None:
325
+ _app = create_server()
326
+ return _app
327
+ raise AttributeError(name)
328
+
329
+
330
+ def main(host: str = "0.0.0.0", port: int | None = None) -> None:
331
+ import uvicorn
332
+
333
+ port = port or int(os.environ.get("PORT", "8000"))
334
+ uvicorn.run(create_server(), host=host, port=port, ws_ping_interval=60, ws_ping_timeout=300)
335
+
336
+
337
+ if __name__ == "__main__":
338
+ main()
openenv/rail_openenv/session.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A client session on a RailSimEnv server (OpenEnv's MCP client over its WebSocket), for scripts and agents.
2
+
3
+ env = EnvSession("http://127.0.0.1:8000") # or LocalSession(pack): the same tools, in this process
4
+ meta = env.reset("gnlw-2025-03-13-0703-real-0") # metadata["instructions"] is the system prompt
5
+ text, done, reward, meta = env.call("submit_plan", {"plan": [...]})
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+
12
+ from openenv.core.env_server.mcp_types import CallToolAction
13
+ from openenv.core.mcp_client import MCPToolClient
14
+
15
+
16
+ class EnvSession:
17
+ """One WebSocket session (one episode at a time)."""
18
+
19
+ def __init__(self, base_url: str, timeout_s: float = 300.0):
20
+ self.client = MCPToolClient(base_url.rstrip("/"), message_timeout_s=timeout_s).sync()
21
+ self._tools = None
22
+
23
+ def reset(self, task_id: str | None = None, **kw) -> dict:
24
+ if task_id is not None:
25
+ kw["task_id"] = task_id
26
+ return dict(self.client.reset(**kw).observation.metadata or {})
27
+
28
+ @property
29
+ def tools(self):
30
+ if self._tools is None:
31
+ self._tools = self.client.list_tools()
32
+ return self._tools
33
+
34
+ def call(self, name: str, arguments: dict) -> tuple[str, bool, float | None, dict]:
35
+ """(tool result text, done, reward, observation metadata)."""
36
+ step = self.client.step(CallToolAction(tool_name=name, arguments=arguments))
37
+ obs = step.observation
38
+ err = getattr(obs, "error", None)
39
+ res = getattr(obs, "result", None)
40
+ text = None
41
+ if isinstance(res, dict):
42
+ blocks = res.get("content") or []
43
+ text = "\n".join(b.get("text", "") for b in blocks if b.get("type") == "text") or None
44
+ if text is None and res.get("data") is not None:
45
+ text = res["data"] if isinstance(res["data"], str) else json.dumps(res["data"])
46
+ elif res is not None:
47
+ data = getattr(res, "data", None)
48
+ text = data if isinstance(data, str) else json.dumps(data) if data is not None else str(res)
49
+ if err:
50
+ msg = err.get("message") if isinstance(err, dict) else getattr(err, "message", str(err))
51
+ text = json.dumps({"error": str(msg)})
52
+ reward = step.reward if step.reward is not None else obs.reward
53
+ return text or "", bool(step.done or obs.done), reward, dict(obs.metadata or {})
54
+
55
+ def close(self):
56
+ try:
57
+ self.client.close()
58
+ except Exception:
59
+ pass
60
+
61
+
62
+ def _result_text(res) -> str | None:
63
+ if res is None:
64
+ return None
65
+ if isinstance(res, dict):
66
+ blocks = res.get("content") or []
67
+ text = "\n".join(b.get("text", "") for b in blocks if b.get("type") == "text")
68
+ if text:
69
+ return text
70
+ data = res.get("data")
71
+ return data if isinstance(data, str) else (json.dumps(data) if data is not None else None)
72
+ content = getattr(res, "content", None)
73
+ if content:
74
+ text = "\n".join(getattr(b, "text", "") for b in content)
75
+ if text:
76
+ return text
77
+ data = getattr(res, "data", None)
78
+ return data if isinstance(data, str) else (json.dumps(data) if data is not None else str(res))
79
+
80
+
81
+ class LocalSession:
82
+ """The environment in this process (no server, no WebSocket): the same tools, results and rubric. For tests,
83
+ pilots and quick experiments."""
84
+
85
+ def __init__(self, pack=None):
86
+ from openenv.core.env_server.mcp_types import ListToolsAction
87
+
88
+ from .environment import RailOpsEnvironment
89
+
90
+ self.env = RailOpsEnvironment(pack)
91
+ self._list = ListToolsAction
92
+ self._tools = None
93
+
94
+ def reset(self, task_id: str | None = None, **kw) -> dict:
95
+ if task_id is not None:
96
+ kw["task_id"] = task_id
97
+ return dict(self.env.reset(**kw).metadata or {})
98
+
99
+ @property
100
+ def tools(self):
101
+ if self._tools is None:
102
+ self._tools = self.env.step(self._list()).tools
103
+ return self._tools
104
+
105
+ def call(self, name: str, arguments: dict) -> tuple[str, bool, float | None, dict]:
106
+ obs = self.env.step(CallToolAction(tool_name=name, arguments=arguments))
107
+ err = getattr(obs, "error", None)
108
+ text = _result_text(getattr(obs, "result", None))
109
+ if err:
110
+ msg = err.get("message") if isinstance(err, dict) else getattr(err, "message", str(err))
111
+ text = json.dumps({"error": str(msg)})
112
+ return text or "", bool(obs.done), obs.reward, dict(obs.metadata or {})
113
+
114
+ def close(self):
115
+ pass
openenv/rail_openenv/web/explorer/index.html ADDED
@@ -0,0 +1,313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>RailSimEnv eval explorer</title>
7
+ <link rel="icon" href="data:,">
8
+ <style>
9
+ :root {
10
+ color-scheme: light;
11
+ --page: #f9f9f7; --surface: #fcfcfb; --ink: #0b0b0b; --ink-2: #52514e; --muted: #898781;
12
+ --grid: #e1e0d9; --axis: #c3c2b7; --ring: rgba(11, 11, 11, 0.10); --wash: #f0efec;
13
+ --accent: #2a78d6; --critical: #d03b3b; --good: #0ca30c; --bar: #2a78d6; --ref: #898781;
14
+ }
15
+ @media (prefers-color-scheme: dark) {
16
+ :root:where(:not([data-theme="light"])) {
17
+ color-scheme: dark;
18
+ --page: #0d0d0d; --surface: #1a1a19; --ink: #ffffff; --ink-2: #c3c2b7; --muted: #898781;
19
+ --grid: #2c2c2a; --axis: #383835; --ring: rgba(255, 255, 255, 0.10); --wash: #232321;
20
+ --accent: #3987e5; --bar: #3987e5;
21
+ }
22
+ }
23
+ :root[data-theme="dark"] {
24
+ color-scheme: dark;
25
+ --page: #0d0d0d; --surface: #1a1a19; --ink: #ffffff; --ink-2: #c3c2b7; --muted: #898781;
26
+ --grid: #2c2c2a; --axis: #383835; --ring: rgba(255, 255, 255, 0.10); --wash: #232321;
27
+ --accent: #3987e5; --bar: #3987e5;
28
+ }
29
+ * { box-sizing: border-box; }
30
+ [hidden] { display: none !important; }
31
+ body { margin: 0; background: var(--page); color: var(--ink); font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
32
+ a { color: var(--accent); }
33
+ nav.site { display: flex; gap: 4px 18px; flex-wrap: wrap; align-items: baseline; padding: 12px 16px 0; }
34
+ nav.site b { font-size: 15px; margin-right: 6px; }
35
+ nav.site a { color: var(--ink-2); text-decoration: none; padding: 2px 0; border-bottom: 2px solid transparent; }
36
+ nav.site a:hover { color: var(--ink); }
37
+ nav.site a[aria-current] { color: var(--ink); border-bottom-color: var(--accent); }
38
+ main { padding: 16px 16px 32px; max-width: 1320px; margin: 0 auto; }
39
+ h1 { font-size: 24px; line-height: 1.2; margin: 0 0 6px; font-weight: 650; }
40
+ h2 { font-size: 17px; margin: 0 0 4px; font-weight: 650; }
41
+ .lede { color: var(--ink-2); max-width: 88ch; margin: 0 0 16px; }
42
+ .card { background: var(--surface); border: 1px solid var(--ring); border-radius: 10px; padding: 16px; margin-bottom: 16px; }
43
+ .note { color: var(--ink-2); font-size: 13px; margin: 0 0 10px; }
44
+ .seg { display: inline-flex; border: 1px solid var(--axis); border-radius: 8px; overflow: hidden; margin-bottom: 16px; }
45
+ .seg button { font: inherit; border: 0; background: var(--surface); color: var(--ink-2); padding: 6px 14px; cursor: pointer; }
46
+ .seg button + button { border-left: 1px solid var(--axis); }
47
+ .seg button[aria-pressed="true"] { background: var(--accent); color: #fff; }
48
+ .tablewrap { overflow-x: auto; }
49
+ table { border-collapse: collapse; width: 100%; font-size: 13px; }
50
+ th, td { padding: 7px 8px; border-bottom: 1px solid var(--grid); text-align: left; vertical-align: middle; white-space: nowrap; }
51
+ th { color: var(--ink-2); font-weight: 600; font-size: 12px; }
52
+ td.n, th.n { text-align: right; font-variant-numeric: tabular-nums; }
53
+ tr.ref td { color: var(--ink-2); }
54
+ tr.ref:first-of-type td { border-top: 1px solid var(--axis); }
55
+ .meanbar { display: inline-flex; align-items: center; gap: 10px; }
56
+ .meanbar b { font-variant-numeric: tabular-nums; min-width: 40px; display: inline-block; }
57
+ .meanbar small { color: var(--ink-2); font-variant-numeric: tabular-nums; }
58
+ .bar { width: 160px; height: 14px; position: relative; flex: none; }
59
+ .bar .track { position: absolute; inset: 6px 0; background: var(--wash); border-radius: 2px; }
60
+ .bar .fill { position: absolute; left: 0; top: 3px; height: 8px; background: var(--bar); border-radius: 0 4px 4px 0; }
61
+ .bar .ci { position: absolute; top: 6px; height: 2px; background: var(--ink); opacity: 0.65; }
62
+ .bar .ci::before, .bar .ci::after { content: ""; position: absolute; top: -4px; width: 1.5px; height: 10px; background: var(--ink); }
63
+ .bar .ci::before { left: 0; } .bar .ci::after { right: 0; }
64
+ tr.ref .bar .fill { background: var(--ref); }
65
+ .filters { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; margin: 4px 0 12px; }
66
+ .chips { display: inline-flex; flex-wrap: wrap; gap: 6px; }
67
+ .chip { font: inherit; font-size: 13px; border: 1px solid var(--axis); background: var(--surface); color: var(--ink-2); border-radius: 999px; padding: 3px 11px; cursor: pointer; }
68
+ .chip[aria-pressed="true"] { border-color: var(--accent); color: var(--ink); box-shadow: inset 0 0 0 1px var(--accent); }
69
+ .ramp { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--ink-2); }
70
+ .ramp i { display: inline-block; width: 22px; height: 12px; }
71
+ .ramp i:first-of-type { border-radius: 3px 0 0 3px; } .ramp i:last-of-type { border-radius: 0 3px 3px 0; }
72
+ .key { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--ink-2); }
73
+ #grid th.sort { cursor: pointer; user-select: none; }
74
+ #grid th.sort:hover { color: var(--ink); }
75
+ #grid th.sorted::after { content: " ↓"; }
76
+ #grid th.sorted.asc::after { content: " ↑"; }
77
+ #grid td.cell { padding: 3px; }
78
+ #grid td.cell button { width: 100%; min-width: 64px; font: inherit; font-size: 13px; font-variant-numeric: tabular-nums; border: 0; border-radius: 5px;
79
+ padding: 5px 6px; cursor: pointer; text-align: right; outline-offset: 1px; }
80
+ #grid td.cell button:hover, #grid td.cell button:focus-visible { outline: 2px solid var(--ink); }
81
+ #grid td.cell button.sel { outline: 2px solid var(--accent); }
82
+ #grid td.cell button.illegal { background: var(--wash); color: var(--ink); }
83
+ #grid td.cell button.illegal::before { content: "✕ "; color: var(--critical); font-weight: 700; }
84
+ #grid td.cell button.none { background: transparent; color: var(--muted); border: 1px dashed var(--axis); }
85
+ .tier { display: inline-block; font-size: 11px; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--ring); color: var(--ink-2); }
86
+ .task small { color: var(--ink-2); }
87
+ #detail { position: sticky; bottom: 12px; z-index: 3; margin-top: 12px; box-shadow: 0 6px 24px rgba(0, 0, 0, 0.14); }
88
+ #detail .row { display: flex; flex-wrap: wrap; gap: 8px 22px; align-items: baseline; }
89
+ #detail .k { font-size: 12px; color: var(--ink-2); display: block; }
90
+ #detail .v { font-size: 17px; font-weight: 600; font-variant-numeric: tabular-nums; }
91
+ #detail .links { margin-left: auto; display: flex; gap: 8px; }
92
+ #detail .links a { padding: 6px 12px; border: 1px solid var(--axis); border-radius: 6px; text-decoration: none; color: var(--ink); background: var(--surface); }
93
+ #detail .links a.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
94
+ #detail button.close { font: inherit; background: none; border: 0; color: var(--ink-2); cursor: pointer; font-size: 18px; }
95
+ footer { color: var(--muted); font-size: 12px; margin-top: 18px; }
96
+ .err { color: var(--critical); }
97
+ </style>
98
+ </head>
99
+ <body>
100
+ <nav class="site" id="nav">
101
+ <b>RailSimEnv</b>
102
+ <a href="/explorer/" aria-current="page">Eval explorer</a>
103
+ <a href="/twin/">3D replay</a>
104
+ <a href="/viewer/">Train graph</a>
105
+ <a href="/web">Environment</a>
106
+ </nav>
107
+ <main>
108
+ <h1>Eval explorer</h1>
109
+ <p class="lede">Each task is a real 2025 disruption on the single-track Groningen – Leeuwarden line: hold, short-turn and
110
+ cancel trains so opposing trains cross safely. Every plan is graded against a proven CP-SAT optimum (reward 1.0); a plan
111
+ that breaks a rule scores under 0.2. Pick any cell to watch that plan run in 3D or read it on the train graph.</p>
112
+ <div class="seg" role="group" aria-label="Task split" id="splits"></div>
113
+
114
+ <section class="card">
115
+ <h2>Leaderboard</h2>
116
+ <p class="note" id="boardnote"></p>
117
+ <div class="tablewrap"><table id="board"></table></div>
118
+ </section>
119
+
120
+ <section class="card">
121
+ <h2>Every task, every model</h2>
122
+ <div class="filters">
123
+ <div class="chips" id="tiers" role="group" aria-label="Tier"></div>
124
+ <span class="ramp" id="ramp"></span>
125
+ <span class="key"><b style="color:var(--critical)">✕</b> breaks a rule</span>
126
+ <span class="key"><i style="display:inline-block;width:22px;height:12px;border:1px dashed var(--axis);border-radius:3px"></i> no plan submitted</span>
127
+ </div>
128
+ <div class="tablewrap"><table id="grid"></table></div>
129
+ <div class="card" id="detail" hidden></div>
130
+ </section>
131
+ <footer>Data: Rijden de Treinen open data (CC BY 4.0, "Rijden de Treinen"); track and map © OpenStreetMap contributors (ODbL 1.0).
132
+ Rewards are one rollout per model per task.</footer>
133
+ </main>
134
+ <script>
135
+ "use strict";
136
+ const $ = (s) => document.querySelector(s);
137
+ const params = new URLSearchParams(location.search);
138
+ const TIERS = ["real", "blockage", "loop", "compound"];
139
+ const TIER = { real: "real disruption", blockage: "blockage", loop: "lost passing track", compound: "compound" };
140
+ // sequential blue ramp (light to dark) for rewards from 0.2 (legal) to 1.0 (the optimum)
141
+ const RAMP = [["#cde2fb", 0.2], ["#9ec5f4", 0.3], ["#6da7ec", 0.4], ["#3987e5", 0.55], ["#256abf", 0.7], ["#184f95", 0.85], ["#0d366b", 0.999]];
142
+ const S = { runs: null, tasks: [], byTask: {}, split: params.get("split") || "eval", tier: "all", sort: { key: "mean", asc: true }, sel: null };
143
+
144
+ const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
145
+ const f3 = (x) => (x === null || x === undefined ? "–" : Number(x).toFixed(3));
146
+ const mean = (xs) => xs.reduce((a, b) => a + b, 0) / Math.max(1, xs.length);
147
+ const shortName = (n) => n.replace(/ \(.*\)$/, "");
148
+ const day = (d) => new Date(`${d}T12:00:00Z`).toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", timeZone: "UTC" });
149
+ async function json(url) {
150
+ const r = await fetch(url);
151
+ if (!r.ok) throw new Error(`${url}: HTTP ${r.status}`);
152
+ return r.json();
153
+ }
154
+ function rampColor(r) {
155
+ let c = RAMP[0][0];
156
+ for (const [hex, lo] of RAMP) if (r >= lo) c = hex;
157
+ return c;
158
+ }
159
+ function textOn(hex) {
160
+ const n = parseInt(hex.slice(1), 16), rgb = [n >> 16, (n >> 8) & 255, n & 255].map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; });
161
+ return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2] > 0.3 ? "#0b0b0b" : "#ffffff";
162
+ }
163
+ const bar = (m, ci) => `<span class="bar"><span class="track"></span><span class="fill" style="width:${(m * 100).toFixed(1)}%"></span>`
164
+ + (ci ? `<span class="ci" style="left:${(ci[0] * 100).toFixed(1)}%;width:${((ci[1] - ci[0]) * 100).toFixed(1)}%"></span>` : "") + `</span>`;
165
+
166
+ function models() {
167
+ return S.runs.models.filter((m) => m.split === S.split);
168
+ }
169
+
170
+ function renderSplits() {
171
+ const splits = [...new Set(S.runs.models.map((m) => m.split))].sort((a, b) => (a === "eval" ? -1 : b === "eval" ? 1 : 0));
172
+ if (!splits.includes(S.split)) S.split = splits[0];
173
+ $("#splits").innerHTML = splits.map((sp) => {
174
+ const n = new Set(S.runs.episodes.filter((e) => S.runs.models.some((m) => m.run === e.run && m.split === sp)).map((e) => e.task_id)).size;
175
+ return `<button type="button" data-split="${esc(sp)}" aria-pressed="${sp === S.split}">${sp === "eval" ? "Eval" : "Train (calibration)"} · ${n} tasks</button>`;
176
+ }).join("");
177
+ for (const b of $("#splits").querySelectorAll("button")) b.onclick = () => { S.split = b.dataset.split; S.sel = null; render(); };
178
+ }
179
+
180
+ function taskIds() {
181
+ const runs = new Set(models().map((m) => m.run));
182
+ return [...new Set(S.runs.episodes.filter((e) => runs.has(e.run)).map((e) => e.task_id))];
183
+ }
184
+
185
+ function renderBoard() {
186
+ const ms = models();
187
+ const tiers = TIERS.filter((t) => ms.some((m) => t in m.tiers));
188
+ const ids = taskIds(), tasks = ids.map((id) => S.byTask[id]).filter(Boolean);
189
+ $("#boardnote").textContent = `${tasks.length} tasks, one rollout per model, the same rules for every model: 12 turns, 64k output tokens per turn, `
190
+ + `10 checks of a draft, one graded submit. 95% bootstrap intervals.`;
191
+ const head = `<thead><tr><th>#</th><th>Model</th><th>Mean reward (95% CI)</th>${tiers.map((t) => `<th class="n">${esc(t)}</th>`).join("")}`
192
+ + `<th class="n">Legal plans</th><th class="n">Optimal</th><th class="n">No plan</th><th class="n">Median turns</th></tr></thead>`;
193
+ const rows = ms.map((m, i) => `<tr><td class="n">${i + 1}</td><td><b>${esc(m.name)}</b></td>`
194
+ + `<td><span class="meanbar"><b>${f3(m.mean)}</b>${bar(m.mean, m.ci)}<small>${f3(m.ci[0])}–${f3(m.ci[1])}</small></span></td>`
195
+ + tiers.map((t) => `<td class="n">${f3(m.tiers[t])}</td>`).join("")
196
+ + `<td class="n">${m.feasible} / ${m.n}</td><td class="n">${m.optimal}</td><td class="n">${m.no_submit}</td><td class="n">${m.median_turns}</td></tr>`);
197
+ const refRow = (name, f, legal) => {
198
+ const xs = tasks.map(f).filter((x) => x !== null && x !== undefined);
199
+ if (!xs.length) return "";
200
+ const per = tiers.map((t) => { const ys = tasks.filter((x) => x.difficulty === t).map(f).filter((x) => x !== null && x !== undefined); return `<td class="n">${ys.length ? f3(mean(ys)) : "–"}</td>`; });
201
+ return `<tr class="ref"><td></td><td>${esc(name)}</td><td><span class="meanbar"><b>${f3(mean(xs))}</b>${bar(mean(xs))}<small>${xs.length < tasks.length ? `${xs.length} tasks` : ""}</small></span></td>`
202
+ + per.join("") + `<td class="n">${legal}</td><td class="n"></td><td class="n"></td><td class="n"></td></tr>`;
203
+ };
204
+ const refs = [refRow("CP-SAT optimum (the reference)", () => 1.0, `${tasks.length} / ${tasks.length}`),
205
+ refRow("the real dispatchers (the record, real tier only, scored as if legal)", (t) => (t.difficulty === "real" ? t.rewards.real : null), ""),
206
+ refRow("greedy dispatcher", (t) => t.rewards.greedy, ""),
207
+ refRow("change nothing", (t) => t.rewards.naive, "")];
208
+ $("#board").innerHTML = head + `<tbody>${rows.join("")}${refs.join("")}</tbody>`;
209
+ }
210
+
211
+ function renderGrid() {
212
+ const ms = models();
213
+ const ids = taskIds();
214
+ const ep = {};
215
+ for (const e of S.runs.episodes) ep[`${e.run}|${e.model}|${e.task_id}`] = e;
216
+ const tiers = TIERS.filter((t) => ids.some((id) => S.byTask[id] && S.byTask[id].difficulty === t));
217
+ $("#tiers").innerHTML = ["all", ...tiers].map((t) => {
218
+ const n = t === "all" ? ids.length : ids.filter((id) => S.byTask[id] && S.byTask[id].difficulty === t).length;
219
+ return `<button type="button" class="chip" data-tier="${t}" aria-pressed="${S.tier === t}">${t === "all" ? "All" : esc(TIER[t])} · ${n}</button>`;
220
+ }).join("");
221
+ for (const b of $("#tiers").querySelectorAll("button")) b.onclick = () => { S.tier = b.dataset.tier; renderGrid(); };
222
+ $("#ramp").innerHTML = `0.2 ${RAMP.map(([c]) => `<i style="background:${c}"></i>`).join("")} 1.0 (the optimum)`;
223
+
224
+ let rows = ids.map((id) => {
225
+ const t = S.byTask[id];
226
+ const cells = ms.map((m) => ep[`${m.run}|${m.model}|${id}`] || null);
227
+ const rs = cells.filter(Boolean).map((e) => e.reward);
228
+ return { id, t, cells, mean: rs.length ? mean(rs) : null };
229
+ }).filter((r) => r.t && (S.tier === "all" || r.t.difficulty === S.tier));
230
+ const k = S.sort.key, dir = S.sort.asc ? 1 : -1;
231
+ const val = (r) => (k === "mean" ? r.mean : k === "task" ? `${r.t.day} ${r.t.start}` : k === "greedy" ? r.t.rewards.greedy
232
+ : k === "runs" ? r.t.runs : r.cells[+k] ? r.cells[+k].reward : -1);
233
+ rows.sort((a, b) => { const x = val(a), y = val(b); return (x < y ? -1 : x > y ? 1 : 0) * dir || a.id.localeCompare(b.id); });
234
+
235
+ const th = (key, label, cls = "") => `<th class="sort ${cls}${S.sort.key === key ? ` sorted${S.sort.asc ? " asc" : ""}` : ""}" data-key="${key}" scope="col">${label}</th>`;
236
+ const head = `<thead><tr>${th("task", "Task")}${th("runs", "Trains", "n")}${ms.map((m, i) => th(String(i), esc(shortName(m.name)), "n")).join("")}`
237
+ + `${th("mean", "Mean of models", "n")}${th("greedy", "Greedy", "n")}<th>Watch</th></tr></thead>`;
238
+ const body = rows.map((r) => {
239
+ const cause = (r.t.causes || []).join(", ");
240
+ const cells = r.cells.map((e, i) => {
241
+ if (!e) return `<td class="cell"><button type="button" class="none" disabled>–</button></td>`;
242
+ const sel = S.sel && S.sel.task === r.id && S.sel.i === i ? " sel" : "";
243
+ const title = `${shortName(ms[i].name)}: reward ${f3(e.reward)}, ${e.submitted ? (e.feasible ? "legal plan" : `${e.violations} rule breaks`) : "no plan submitted"}`;
244
+ if (!e.submitted || !e.plan_id) return `<td class="cell"><button type="button" class="none${sel}" data-task="${esc(r.id)}" data-i="${i}" title="${esc(title)}">no plan</button></td>`;
245
+ if (!e.feasible) return `<td class="cell"><button type="button" class="illegal${sel}" data-task="${esc(r.id)}" data-i="${i}" title="${esc(title)}">${f3(e.reward)}</button></td>`;
246
+ const c = rampColor(e.reward);
247
+ return `<td class="cell"><button type="button" class="${sel.trim()}" style="background:${c};color:${textOn(c)}" data-task="${esc(r.id)}" data-i="${i}" title="${esc(title)}">${e.reward >= 0.999 ? "★ " : ""}${f3(e.reward)}</button></td>`;
248
+ }).join("");
249
+ return `<tr><td class="task"><b>${esc(day(r.t.day))}</b> ${esc(r.t.start)}–${esc(r.t.end)} <span class="tier">${esc(TIER[r.t.difficulty] || r.t.difficulty)}</span><br><small>${esc(cause)}</small></td>`
250
+ + `<td class="n">${r.t.runs}</td>${cells}<td class="n">${f3(r.mean)}</td><td class="n">${f3(r.t.rewards.greedy)}</td>`
251
+ + `<td><a href="/twin/?task=${encodeURIComponent(r.id)}&plan=optimal">3D</a> · <a href="/viewer/?task=${encodeURIComponent(r.id)}&plan=optimal">graph</a></td></tr>`;
252
+ }).join("");
253
+ $("#grid").innerHTML = head + `<tbody>${body}</tbody>`;
254
+ for (const h of $("#grid").querySelectorAll("th.sort")) h.onclick = () => {
255
+ const key = h.dataset.key;
256
+ S.sort = { key, asc: S.sort.key === key ? !S.sort.asc : key === "task" || key === "runs" ? true : key === "mean" };
257
+ renderGrid();
258
+ };
259
+ for (const b of $("#grid").querySelectorAll("td.cell button[data-task]")) b.onclick = () => { S.sel = { task: b.dataset.task, i: +b.dataset.i }; renderGrid(); renderDetail(); };
260
+ }
261
+
262
+ function renderDetail() {
263
+ const box = $("#detail");
264
+ if (!S.sel) { box.hidden = true; return; }
265
+ const m = models()[S.sel.i], t = S.byTask[S.sel.task];
266
+ const e = S.runs.episodes.find((x) => x.run === m.run && x.model === m.model && x.task_id === S.sel.task);
267
+ if (!e || !t) { box.hidden = true; return; }
268
+ const over = e.feasible && e.cost !== null && e.optimal_cost ? Math.round((e.cost / e.optimal_cost - 1) * 100) : null;
269
+ const outcome = !e.submitted ? "no plan submitted" : e.feasible ? (over === 0 ? "the proven optimum" : `legal, ${over}% over the optimum`) : `${e.violations} rule break${e.violations === 1 ? "" : "s"}`;
270
+ const plan = e.plan_id ? encodeURIComponent(e.plan_id) : "optimal";
271
+ const task = encodeURIComponent(t.task_id);
272
+ box.innerHTML = `<div class="row">
273
+ <div><span class="k">${esc(day(t.day))} ${esc(t.start)}–${esc(t.end)} · ${esc(TIER[t.difficulty] || t.difficulty)}</span><span class="v">${esc(m.name)}</span></div>
274
+ <div><span class="k">Reward</span><span class="v">${f3(e.reward)}</span></div>
275
+ <div><span class="k">Outcome</span><span class="v">${esc(outcome)}</span></div>
276
+ <div><span class="k">Cost / optimum</span><span class="v">${e.cost ?? "–"} / ${e.optimal_cost}</span></div>
277
+ <div><span class="k">Turns · checks</span><span class="v">${e.agent_turns} · ${e.checks}</span></div>
278
+ <div class="links">${e.plan_id ? `<a class="primary" href="/twin/?task=${task}&plan=${plan}">Watch in 3D</a><a href="/viewer/?task=${task}&plan=${plan}">Train graph</a>` : ""}
279
+ <a href="/viewer/?task=${task}&plan=optimal">The optimum</a><button class="close" type="button" aria-label="Close">×</button></div>
280
+ </div>`;
281
+ box.hidden = false;
282
+ box.querySelector(".close").onclick = () => { S.sel = null; renderGrid(); renderDetail(); };
283
+ }
284
+
285
+ function render() {
286
+ renderSplits();
287
+ renderBoard();
288
+ renderGrid();
289
+ renderDetail();
290
+ }
291
+
292
+ (async () => {
293
+ if (params.has("embed")) $("#nav").hidden = true;
294
+ if (params.get("theme")) document.documentElement.dataset.theme = params.get("theme");
295
+ try {
296
+ const [runs, tasks] = await Promise.all([json("/api/runs"), json("/api/tasks")]);
297
+ S.runs = runs;
298
+ S.tasks = tasks;
299
+ S.byTask = Object.fromEntries(tasks.map((t) => [t.task_id, t]));
300
+ } catch (err) {
301
+ $("main").insertAdjacentHTML("afterbegin", `<p class="err">${esc(err.message)}</p>`);
302
+ return;
303
+ }
304
+ if (!S.runs.models.length) {
305
+ $("main").insertAdjacentHTML("afterbegin", `<p class="note">No model runs on this server (results/rollouts/*/index.json, or RAIL_RUNS_DIR).</p>`);
306
+ return;
307
+ }
308
+ render();
309
+ document.body.dataset.ready = "1";
310
+ })();
311
+ </script>
312
+ </body>
313
+ </html>
openenv/rail_openenv/web/index.html ADDED
@@ -0,0 +1,592 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>RailSimEnv train graph</title>
7
+ <link rel="icon" href="data:,">
8
+ <style>
9
+ :root {
10
+ color-scheme: light;
11
+ --page: #f9f9f7; --surface: #fcfcfb; --ink: #0b0b0b; --ink-2: #52514e; --muted: #898781;
12
+ --grid: #e1e0d9; --axis: #c3c2b7; --ring: rgba(11, 11, 11, 0.10);
13
+ --stop: #2a78d6; --fast: #eb6834; --critical: #d03b3b; --warning: #fab219; --serious: #ec835a; --good: #0ca30c;
14
+ --good-text: #006300; --single: #f1efe9; --after: rgba(11, 11, 11, 0.035); --accent: #2a78d6;
15
+ }
16
+ @media (prefers-color-scheme: dark) {
17
+ :root:where(:not([data-theme="light"])) {
18
+ color-scheme: dark;
19
+ --page: #0d0d0d; --surface: #1a1a19; --ink: #ffffff; --ink-2: #c3c2b7; --muted: #898781;
20
+ --grid: #2c2c2a; --axis: #383835; --ring: rgba(255, 255, 255, 0.10);
21
+ --stop: #3987e5; --fast: #d95926; --good-text: #0ca30c; --single: #232321; --after: rgba(255, 255, 255, 0.03);
22
+ --accent: #3987e5;
23
+ }
24
+ }
25
+ :root[data-theme="dark"] {
26
+ color-scheme: dark;
27
+ --page: #0d0d0d; --surface: #1a1a19; --ink: #ffffff; --ink-2: #c3c2b7; --muted: #898781;
28
+ --grid: #2c2c2a; --axis: #383835; --ring: rgba(255, 255, 255, 0.10);
29
+ --stop: #3987e5; --fast: #d95926; --good-text: #0ca30c; --single: #232321; --after: rgba(255, 255, 255, 0.03);
30
+ --accent: #3987e5;
31
+ }
32
+ * { box-sizing: border-box; }
33
+ [hidden] { display: none !important; }
34
+ body { margin: 0; background: var(--page); color: var(--ink); font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
35
+ a { color: var(--accent); }
36
+ nav.site { display: flex; gap: 4px 18px; flex-wrap: wrap; align-items: baseline; padding: 12px 16px 0; }
37
+ nav.site b { font-size: 15px; margin-right: 6px; }
38
+ nav.site a { color: var(--ink-2); text-decoration: none; padding: 2px 0; border-bottom: 2px solid transparent; }
39
+ nav.site a:hover { color: var(--ink); }
40
+ nav.site a[aria-current] { color: var(--ink); border-bottom-color: var(--accent); }
41
+ main { padding: 12px 16px 24px; max-width: 1440px; margin: 0 auto; }
42
+ .controls { display: flex; gap: 8px 12px; flex-wrap: wrap; align-items: end; margin-bottom: 14px; }
43
+ .controls label { display: grid; gap: 2px; font-size: 12px; color: var(--ink-2); min-width: 0; }
44
+ .controls select { font: inherit; font-size: 14px; color: var(--ink); background: var(--surface); border: 1px solid var(--axis);
45
+ border-radius: 6px; padding: 6px 8px; max-width: min(92vw, 460px); }
46
+ .controls .open3d { margin-left: auto; padding: 7px 12px; border-radius: 6px; border: 1px solid var(--axis); text-decoration: none;
47
+ color: var(--ink); background: var(--surface); white-space: nowrap; }
48
+ .card { background: var(--surface); border: 1px solid var(--ring); border-radius: 10px; padding: 16px; }
49
+ .head { display: flex; gap: 12px 24px; flex-wrap: wrap; justify-content: space-between; align-items: start; }
50
+ .head h1 { font-size: 20px; line-height: 1.25; margin: 0 0 4px; font-weight: 650; }
51
+ .head .sub { color: var(--ink-2); margin: 0; max-width: 78ch; }
52
+ .stats { display: flex; flex-wrap: wrap; gap: 8px 28px; margin: 14px 0 6px; }
53
+ .stat { display: grid; gap: 0; }
54
+ .stat .k { font-size: 12px; color: var(--ink-2); }
55
+ .stat .v { font-size: 20px; font-weight: 600; }
56
+ .stat .v small { font-size: 13px; font-weight: 400; color: var(--ink-2); margin-left: 4px; }
57
+ .badge { display: inline-flex; gap: 6px; align-items: center; font-size: 13px; font-weight: 600; padding: 3px 10px;
58
+ border-radius: 999px; border: 1px solid var(--ring); white-space: nowrap; }
59
+ .badge.ok i { color: var(--good); font-style: normal; }
60
+ .badge.bad i { color: var(--critical); font-style: normal; }
61
+ .legend { display: flex; flex-wrap: wrap; gap: 6px 18px; margin: 12px 0 4px; font-size: 13px; color: var(--ink-2); }
62
+ .legend span { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
63
+ .legend svg { flex: none; }
64
+ .chartwrap { position: relative; overflow-x: auto; margin-top: 4px; }
65
+ #chart { display: block; }
66
+ #chart text { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; }
67
+ .halo { paint-order: stroke; stroke: var(--surface); stroke-width: 4px; stroke-linejoin: round; }
68
+ .num { font-variant-numeric: tabular-nums; }
69
+ .paths.focus .run:not(.on) { opacity: 0.18; }
70
+ .paths.focus .run.on path.line { stroke-width: 3; }
71
+ .tip { position: absolute; pointer-events: none; background: var(--surface); color: var(--ink); border: 1px solid var(--ring);
72
+ border-radius: 8px; padding: 8px 10px; font-size: 13px; line-height: 1.4; box-shadow: 0 4px 16px rgba(0,0,0,0.12);
73
+ max-width: 300px; z-index: 2; }
74
+ .tip b { font-size: 14px; }
75
+ .tip .key { display: inline-block; width: 14px; height: 0; border-top: 2px solid; vertical-align: middle; margin-right: 6px; }
76
+ .tip .m { color: var(--ink-2); }
77
+ details { margin-top: 14px; }
78
+ summary { cursor: pointer; color: var(--ink-2); }
79
+ table { border-collapse: collapse; width: 100%; font-size: 13px; margin-top: 8px; }
80
+ th, td { text-align: left; padding: 5px 8px; border-bottom: 1px solid var(--grid); vertical-align: top; }
81
+ th { color: var(--ink-2); font-weight: 600; }
82
+ td.n, th.n { text-align: right; font-variant-numeric: tabular-nums; }
83
+ .tablewrap { overflow-x: auto; }
84
+ .breaks { margin: 10px 0 0; padding-left: 18px; color: var(--ink-2); font-size: 13px; }
85
+ .err { color: var(--critical); }
86
+ body.figure main { padding: 0; max-width: none; }
87
+ body.figure .card { border: 0; border-radius: 0; padding: 22px 26px 18px; }
88
+ body.figure .head h1 { font-size: 22px; }
89
+ body.figure .sub { font-size: 15px; }
90
+ body.figure .legend { font-size: 14px; }
91
+ </style>
92
+ </head>
93
+ <body>
94
+ <nav class="site" id="nav">
95
+ <b>RailSimEnv</b>
96
+ <a href="/explorer/">Eval explorer</a>
97
+ <a href="/twin/">3D replay</a>
98
+ <a href="/viewer/" aria-current="page">Train graph</a>
99
+ <a href="/web">Environment</a>
100
+ </nav>
101
+ <main>
102
+ <div class="controls" id="controls">
103
+ <label>Task<select id="task" aria-label="Task"></select></label>
104
+ <label>Plan<select id="plan" aria-label="Plan"></select></label>
105
+ <a class="open3d" id="open3d" href="/twin/">Watch it in 3D ↗</a>
106
+ </div>
107
+ <section class="card" aria-live="polite">
108
+ <div class="head">
109
+ <div>
110
+ <h1 id="title">Loading…</h1>
111
+ <p class="sub" id="sub"></p>
112
+ </div>
113
+ <div id="badge"></div>
114
+ </div>
115
+ <div class="stats" id="stats"></div>
116
+ <ol class="breaks" id="breaks" hidden></ol>
117
+ <div class="legend" id="legend"></div>
118
+ <div class="chartwrap" id="chartwrap">
119
+ <svg id="chart" role="img" aria-labelledby="title"></svg>
120
+ <div class="tip" id="tip" hidden></div>
121
+ </div>
122
+ <details id="tablebox">
123
+ <summary>Every train as a table</summary>
124
+ <div class="tablewrap"><table id="table"></table></div>
125
+ </details>
126
+ </section>
127
+ </main>
128
+ <script>
129
+ "use strict";
130
+ const $ = (s) => document.querySelector(s);
131
+ const params = new URLSearchParams(location.search);
132
+ const FIGURE = params.has("figure");
133
+ const EMBED = FIGURE || params.has("embed");
134
+ const FEATURED = "gnlw-2025-05-01-0800-180m-blockage-0";
135
+ const TIER = { real: "real disruption", blockage: "blockage", loop: "lost passing track", compound: "compound" };
136
+ const WHAT = { closed: "closed", slow: "slow running", track: "a platform track out of use" };
137
+ const S = { tasks: [], R: null, paths: [], geo: null };
138
+
139
+ const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
140
+ const hhmm = (m) => { m = Math.round(m); const d = ((m % 1440) + 1440) % 1440; return `${String(Math.floor(d / 60)).padStart(2, "0")}:${String(d % 60).padStart(2, "0")}`; };
141
+ const fmt = (n) => (n === null || n === undefined ? "–" : Number(n).toLocaleString("en-GB"));
142
+ async function json(url) {
143
+ const r = await fetch(url);
144
+ if (!r.ok) throw new Error(`${url}: HTTP ${r.status}`);
145
+ return r.json();
146
+ }
147
+ function longDay(day) {
148
+ return new Date(`${day}T12:00:00Z`).toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long", year: "numeric", timeZone: "UTC" });
149
+ }
150
+ function shortDay(day) {
151
+ return new Date(`${day}T12:00:00Z`).toLocaleDateString("en-GB", { day: "numeric", month: "short", timeZone: "UTC" });
152
+ }
153
+ const shortName = (p) => (p.kind === "junction" ? p.name.replace(/ (east|west) junction$/, " jn.") : p.name);
154
+ function orderText(o) {
155
+ if (!o) return "";
156
+ const out = [];
157
+ if (o.cancel) out.push("cancelled");
158
+ if (o.from) out.push(`starts at ${o.from}`);
159
+ if (o.to) out.push(`turns back at ${o.to}`);
160
+ for (const [c, t] of Object.entries(o.hold || {})) out.push(`held at ${c} until ${t}`);
161
+ return out.join("; ");
162
+ }
163
+
164
+ // ---- geometry ------------------------------------------------------------------------------------------------
165
+
166
+ const measure = (() => {
167
+ const g = document.createElement("canvas").getContext("2d");
168
+ return (text, px, weight = 400) => { g.font = `${weight} ${px}px system-ui, -apple-system, "Segoe UI", sans-serif`; return g.measureText(text).width; };
169
+ })();
170
+
171
+ function pathOf(r, km) {
172
+ const pts = [];
173
+ if (r.started && r.fixed_from !== null && r.fixed_from !== undefined) pts.push([r.fixed_from, km[r.route[r.fixed_to - 1]]]);
174
+ const from = r.started ? r.fixed_to : r.first;
175
+ for (let i = from; i <= r.last; i++) {
176
+ const k = km[r.route[i]];
177
+ if (r.arr[i] !== null && r.arr[i] !== undefined && !(i === from && !r.started)) pts.push([r.arr[i], k]);
178
+ if (r.dep[i] !== null && r.dep[i] !== undefined && i < r.last) pts.push([r.dep[i], k]);
179
+ }
180
+ return pts;
181
+ }
182
+
183
+ function timetablePath(r, km) {
184
+ const pts = [];
185
+ for (let i = 0; i < r.route.length; i++) {
186
+ if (r.sched_arr[i] !== null && i > 0) pts.push([r.sched_arr[i], km[r.route[i]]]);
187
+ if (r.sched_dep[i] !== null && i < r.route.length - 1) pts.push([r.sched_dep[i], km[r.route[i]]]);
188
+ }
189
+ return pts;
190
+ }
191
+
192
+ function holdsOf(r, km) {
193
+ const out = [];
194
+ for (const code of Object.keys((r.order && r.order.hold) || {})) {
195
+ for (let i = r.first; i <= r.last; i++) {
196
+ if (r.route[i] !== code) continue;
197
+ const a = r.arr[i] ?? r.sched_dep[i], d = r.dep[i];
198
+ if (a !== null && d !== null && d > a) out.push([a, d, km[code]]);
199
+ }
200
+ }
201
+ return out;
202
+ }
203
+
204
+ function segHitsBox(ax, ay, bx, by, b) { // Liang-Barsky: does segment a-b cross box b?
205
+ let t0 = 0, t1 = 1;
206
+ const dx = bx - ax, dy = by - ay;
207
+ for (const [p, q] of [[-dx, ax - b.x0], [dx, b.x1 - ax], [-dy, ay - b.y0], [dy, b.y1 - ay]]) {
208
+ if (p === 0) { if (q < 0) return false; continue; }
209
+ const r = q / p;
210
+ if (p < 0) { if (r > t1) return false; if (r > t0) t0 = r; } else { if (r < t0) return false; if (r < t1) t1 = r; }
211
+ }
212
+ return true;
213
+ }
214
+ const overlap = (a, b) => a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
215
+
216
+ // ---- the chart -----------------------------------------------------------------------------------------------
217
+
218
+ function draw() {
219
+ const R = S.R;
220
+ if (!R) return;
221
+ const wrap = $("#chartwrap");
222
+ const W = FIGURE ? Math.floor(wrap.clientWidth) : Math.max(760, Math.floor(wrap.clientWidth));
223
+ const fs = FIGURE ? 14 : 13, fsSmall = fs - 2, fsTrain = FIGURE ? 12.5 : 12;
224
+ const km = Object.fromEntries(R.points.map((p) => [p.code, p.km]));
225
+ const kmMax = Math.max(...R.points.map((p) => p.km));
226
+ const compact = W < 900 && !FIGURE;
227
+ const label = (p) => (compact ? p.code : shortName(p));
228
+ const nameW = Math.max(...R.points.map((p) => measure(label(p), p.kind === "junction" ? fsSmall : fs, p.kind === "terminal" ? 600 : 400)
229
+ + (compact || p.kind === "junction" ? 0 : measure(` ${p.code}`, fsSmall))));
230
+ const L = Math.ceil(nameW) + 44, Rm = 18, T = 38, B = 34;
231
+ const H = FIGURE ? 600 : Math.max(520, Math.min(680, Math.round(W * 0.5)));
232
+ const paths = R.runs.filter((r) => !r.cancelled).map((r) => ({ r, pts: pathOf(r, km) })).filter((p) => p.pts.length > 1);
233
+ const allT = paths.flatMap((p) => p.pts.map((q) => q[0]));
234
+ const t0 = Math.min(R.start - 10, ...allT.filter((t) => t >= R.start - 30)) - 2;
235
+ const t1 = Math.min(R.end + 75, Math.max(R.end + 15, ...allT)) + 3;
236
+ const x = (t) => L + (t - t0) / (t1 - t0) * (W - L - Rm);
237
+ const y = (k) => T + k / kmMax * (H - T - B);
238
+ const s = [];
239
+
240
+ s.push(`<defs>
241
+ <clipPath id="plot"><rect x="${L}" y="${T - 6}" width="${W - L - Rm}" height="${H - T - B + 12}"/></clipPath>
242
+ <pattern id="hatch-closed" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
243
+ <rect width="7" height="7" fill="var(--critical)" fill-opacity="0.10"/><line x1="0" y1="0" x2="0" y2="7" stroke="var(--critical)" stroke-opacity="0.55" stroke-width="2"/></pattern>
244
+ <pattern id="hatch-slow" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
245
+ <rect width="7" height="7" fill="var(--warning)" fill-opacity="0.14"/><line x1="0" y1="0" x2="0" y2="7" stroke="var(--warning)" stroke-opacity="0.75" stroke-width="2"/></pattern>
246
+ <pattern id="hatch-track" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(135)">
247
+ <rect width="7" height="7" fill="var(--serious)" fill-opacity="0.12"/><line x1="0" y1="0" x2="0" y2="7" stroke="var(--serious)" stroke-opacity="0.7" stroke-width="2"/></pattern>
248
+ </defs>`);
249
+
250
+ // single-track rows, the time grid, the window
251
+ R.sections.forEach((sec) => {
252
+ if (!sec.single) return;
253
+ s.push(`<rect x="${L}" y="${y(km[sec.a])}" width="${W - L - Rm}" height="${y(km[sec.b]) - y(km[sec.a])}" fill="var(--single)"/>`);
254
+ });
255
+ if (R.end < t1) s.push(`<rect x="${x(R.end)}" y="${T}" width="${x(t1) - x(R.end)}" height="${H - T - B}" fill="var(--after)"/>`);
256
+ for (const p of R.points) s.push(`<line x1="${L}" x2="${W - Rm}" y1="${y(p.km)}" y2="${y(p.km)}" stroke="var(--grid)"/>`);
257
+ const step = (W - L - Rm) / (t1 - t0) * 30 < 46 ? 60 : 30;
258
+ for (let t = Math.ceil(t0 / step) * step; t <= t1; t += step) {
259
+ const hour = t % 60 === 0;
260
+ s.push(`<line x1="${x(t)}" x2="${x(t)}" y1="${T}" y2="${H - B}" stroke="${hour ? "var(--axis)" : "var(--grid)"}"/>`);
261
+ if (x(t) + measure(hhmm(t), fs, 600) / 2 > W) continue;
262
+ for (const yy of [T - 12, H - B + 21]) {
263
+ s.push(`<text x="${x(t)}" y="${yy}" text-anchor="middle" font-size="${hour ? fs : fsSmall}" font-weight="${hour ? 600 : 400}" fill="${hour ? "var(--ink-2)" : "var(--muted)"}" class="num">${hhmm(t)}</text>`);
264
+ }
265
+ }
266
+
267
+ // stations and the track: one line for single track, two for double
268
+ const tx = L - 18;
269
+ R.sections.forEach((sec) => {
270
+ const ya = y(km[sec.a]), yb = y(km[sec.b]);
271
+ if (sec.single) s.push(`<line x1="${tx}" x2="${tx}" y1="${ya}" y2="${yb}" stroke="var(--ink-2)" stroke-width="2.5"/>`);
272
+ else for (const d of [-2.6, 2.6]) s.push(`<line x1="${tx + d}" x2="${tx + d}" y1="${ya}" y2="${yb}" stroke="var(--ink-2)" stroke-width="1.3"/>`);
273
+ });
274
+ for (const p of R.points) {
275
+ const yy = y(p.km);
276
+ if (p.kind === "terminal") s.push(`<rect x="${tx - 6}" y="${yy - 2.5}" width="12" height="5" rx="1.5" fill="var(--ink)"/>`);
277
+ else if (p.kind === "station") s.push(`<circle cx="${tx}" cy="${yy}" r="4.6" fill="var(--surface)" stroke="var(--ink)" stroke-width="1.6"/>`);
278
+ else if (p.kind === "halt") s.push(`<circle cx="${tx}" cy="${yy}" r="3" fill="var(--ink)"/>`);
279
+ const junction = p.kind === "junction";
280
+ const code = compact || junction ? "" : ` <tspan font-size="${fsSmall}" fill="var(--muted)">${esc(p.code)}</tspan>`;
281
+ s.push(`<text x="${tx - 14}" y="${yy + fs * 0.36}" text-anchor="end" font-size="${junction ? fsSmall : fs}" font-weight="${p.kind === "terminal" ? 600 : 400}" fill="${junction ? "var(--muted)" : "var(--ink)"}"><title>${esc(p.name)} (${esc(p.code)}${p.tracks ? `, ${p.tracks} platform track${p.tracks > 1 ? "s" : ""}` : ""})</title>${esc(label(p))}${code}</text>`);
282
+ }
283
+
284
+ // disruptions
285
+ const boxes = [];
286
+ const closures = [];
287
+ for (const c of R.closures) {
288
+ const a = km[c.where[0]], b = km[c.where[c.where.length - 1]];
289
+ const k0 = c.kind === "track" ? a - 0.55 : Math.min(a, b), k1 = c.kind === "track" ? a + 0.55 : Math.max(a, b);
290
+ const bx = x(Math.max(c.start, t0)), bw = x(Math.min(c.end, t1)) - bx, by = y(k0), bh = Math.max(8, y(k1) - y(k0));
291
+ s.push(`<rect x="${bx}" y="${by}" width="${bw}" height="${bh}" fill="url(#hatch-${c.kind})" stroke="${c.kind === "closed" ? "var(--critical)" : c.kind === "slow" ? "var(--warning)" : "var(--serious)"}" stroke-width="1.2" clip-path="url(#plot)"><title>${esc(c.cause)}: ${WHAT[c.kind] || c.kind} ${hhmm(c.start)}–${hhmm(c.end)}</title></rect>`);
292
+ closures.push({ c, bx, bw, by, bh });
293
+ }
294
+
295
+ // trains
296
+ const geo = [];
297
+ const series = (r) => (r.problems && r.problems.length ? "var(--critical)" : r.kind === "fast" ? "var(--fast)" : "var(--stop)");
298
+ s.push(`<g class="paths" id="paths" clip-path="url(#plot)">`);
299
+ for (const r of R.runs.filter((r) => r.cancelled)) {
300
+ const pts = timetablePath(r, km);
301
+ if (pts.length < 2) continue;
302
+ const d = pts.map((q, i) => `${i ? "L" : "M"}${x(q[0]).toFixed(1)},${y(q[1]).toFixed(1)}`).join("");
303
+ s.push(`<g class="run" data-unit="${esc(r.unit)}"><path d="${d}" fill="none" stroke="var(--muted)" stroke-width="1.3" stroke-dasharray="2 4" stroke-linecap="round"/></g>`);
304
+ geo.push({ r, xy: pts.map((q) => [x(q[0]), y(q[1])]), cancelled: true });
305
+ }
306
+ for (const { r, pts } of paths) {
307
+ const xy = pts.map((q) => [x(q[0]), y(q[1])]);
308
+ const d = xy.map((q, i) => `${i ? "L" : "M"}${q[0].toFixed(1)},${q[1].toFixed(1)}`).join("");
309
+ const col = series(r), bad = r.problems && r.problems.length;
310
+ const parts = [`<path class="line" d="${d}" fill="none" stroke="${col}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"${bad ? ' stroke-dasharray="7 4"' : ""}/>`];
311
+ for (const [a, b, k] of holdsOf(r, km)) parts.push(`<line x1="${x(a)}" x2="${x(b)}" y1="${y(k)}" y2="${y(k)}" stroke="${col}" stroke-width="6" stroke-linecap="round"/>`);
312
+ const o = r.order || {};
313
+ if (o.to) { const q = xy[xy.length - 1]; parts.push(`<circle cx="${q[0]}" cy="${q[1]}" r="5" fill="var(--surface)" stroke="${col}" stroke-width="2.4"/>`); }
314
+ if (o.from) { const q = xy[0]; parts.push(`<circle cx="${q[0]}" cy="${q[1]}" r="5" fill="var(--surface)" stroke="${col}" stroke-width="2.4"/>`); }
315
+ s.push(`<g class="run" data-unit="${esc(r.unit)}">${parts.join("")}</g>`);
316
+ geo.push({ r, xy });
317
+ }
318
+ s.push(`</g>`);
319
+
320
+ // labels: closures first, then each train once, where it collides least
321
+ const plot = { x0: L + 2, x1: W - Rm - 2, y0: T + 1, y1: H - B - 1 };
322
+ const labels = [];
323
+ for (const { c, bx, bw, by, bh } of closures) {
324
+ // a tag with the closure's kind, time and cause: inside the hatched area when it fits (wrapped), else beside it
325
+ const head = c.kind === "closed" ? "Closed" : c.kind === "slow" ? `Slow, +${c.extra} min` : "Track out of use";
326
+ const lh = fsSmall + 4;
327
+ const wrap = (words, maxW) => words.reduce((ls, wd) => {
328
+ const last = ls[ls.length - 1];
329
+ if (last && measure(`${last} ${wd}`, fsSmall) <= maxW) ls[ls.length - 1] = `${last} ${wd}`; else ls.push(wd);
330
+ return ls;
331
+ }, []);
332
+ let lines = [[head, 600], [`${hhmm(c.start)}–${hhmm(c.end)}`, 400], ...wrap((c.cause || "").split(/\s+/).filter(Boolean), bw - 22).map((t) => [t, 400])];
333
+ let w = Math.max(...lines.map(([t, wt]) => measure(t, fsSmall, wt))) + 14, h = lines.length * lh + 8;
334
+ let lx = bx + 6, ly = by + 6;
335
+ const stroke = c.kind === "closed" ? "var(--critical)" : c.kind === "slow" ? "var(--warning)" : "var(--serious)";
336
+ const tag = (ls, cx, cy, rot) => { // a tag of lines centred on (cx, cy), upright when rot
337
+ const tw = Math.max(...ls.map(([t, wt]) => measure(t, fsSmall, wt))) + 14, th = ls.length * lh + 8;
338
+ const [bw2, bh2] = rot ? [th, tw] : [tw, th];
339
+ boxes.push({ x0: cx - bw2 / 2, x1: cx + bw2 / 2, y0: cy - bh2 / 2, y1: cy + bh2 / 2 });
340
+ labels.push(`<g transform="translate(${cx} ${cy})${rot ? " rotate(-90)" : ""}"><rect x="${-tw / 2}" y="${-th / 2}" width="${tw}" height="${th}" rx="5" fill="var(--surface)" stroke="${stroke}" stroke-width="1"/>`
341
+ + ls.map(([t, wt], i) => `<text x="${-tw / 2 + 7}" y="${-th / 2 + 4 + i * lh + lh / 2 + fsSmall * 0.36}" font-size="${fsSmall}" font-weight="${wt}" fill="${i ? "var(--ink-2)" : "var(--ink)"}">${esc(t)}</text>`).join("") + "</g>");
342
+ };
343
+ if (w <= bw - 10 && h <= bh - 10) { tag(lines, bx + 6 + w / 2, by + 6 + h / 2, false); continue; }
344
+ const two = [[`${head} ${hhmm(c.start)}–${hhmm(c.end)}`, 600], ...(c.cause ? [[c.cause, 400]] : [])];
345
+ const tw = Math.max(...two.map(([t, wt]) => measure(t, fsSmall, wt))) + 14, th = two.length * lh + 8;
346
+ if (tw <= bw - 10 && th <= bh - 2) { tag(two, bx + 6 + tw / 2, by + bh / 2, false); continue; }
347
+ if (bw >= th + 8 && bh >= tw + 12) { tag(two, bx + bw / 2, by + bh / 2, true); continue; }
348
+ lines = [[`${head} · ${hhmm(c.start)}–${hhmm(c.end)}${c.cause ? ` · ${c.cause}` : ""}`, 600]];
349
+ w = measure(lines[0][0], fsSmall, 600) + 14; h = lh + 8;
350
+ lx = bx + bw + 6; ly = by + bh / 2 - h / 2;
351
+ if (lx + w > plot.x1) lx = Math.max(plot.x0, bx - w - 6);
352
+ boxes.push({ x0: lx, x1: lx + w, y0: ly, y1: ly + h });
353
+ labels.push(`<rect x="${lx}" y="${ly}" width="${w}" height="${h}" rx="5" fill="var(--surface)" stroke="${stroke}" stroke-width="1"/>`
354
+ + lines.map(([t, wt], i) => `<text x="${lx + 7}" y="${ly + 4 + i * lh + lh / 2 + fsSmall * 0.36}" font-size="${fsSmall}" font-weight="${wt}" fill="${i ? "var(--ink-2)" : "var(--ink)"}">${esc(t)}</text>`).join(""));
355
+ }
356
+ for (const yy of [x(R.start), x(R.end)]) boxes.push({ x0: yy - 1, x1: yy + 1, y0: T - 30, y1: T });
357
+ const segs = geo.filter((g) => !g.cancelled).flatMap((g, gi) => g.xy.slice(1).map((q, i) => [g.xy[i][0], g.xy[i][1], q[0], q[1], gi]));
358
+ const trainBox = [];
359
+ geo.forEach((g, gi) => {
360
+ if (g.cancelled) return;
361
+ const text = g.r.train, w = measure(text, fsTrain, 500) + 4, h = fsTrain + 2;
362
+ const cands = [];
363
+ let acc = 0;
364
+ const total = g.xy.slice(1).reduce((a, q, i) => a + Math.hypot(q[0] - g.xy[i][0], q[1] - g.xy[i][1]), 0);
365
+ for (let i = 1; i < g.xy.length; i++) {
366
+ const [ax, ay] = g.xy[i - 1], [bx, by] = g.xy[i];
367
+ const len = Math.hypot(bx - ax, by - ay);
368
+ if (len < 1) continue;
369
+ const ux = (bx - ax) / len, uy = (by - ay) / len, nx = uy, ny = -ux; // the normal on the upper side
370
+ for (let d = 0; d <= len; d += 12) {
371
+ const px = ax + ux * d, py = ay + uy * d;
372
+ const off = Math.abs(nx) * w / 2 + Math.abs(ny) * h / 2 + 3;
373
+ const cx = px + nx * off, cy = py + ny * off;
374
+ cands.push({ box: { x0: cx - w / 2, x1: cx + w / 2, y0: cy - h / 2, y1: cy + h / 2 }, s: (acc + d) / Math.max(1, total) });
375
+ }
376
+ acc += len;
377
+ }
378
+ let best = null;
379
+ for (const c of cands) {
380
+ const b = c.box;
381
+ if (b.x0 < plot.x0 || b.x1 > plot.x1 || b.y0 < plot.y0 || b.y1 > plot.y1) continue;
382
+ if (boxes.some((o) => overlap(o, b)) || trainBox.some((o) => overlap(o, b))) continue;
383
+ let pen = c.s * 4;
384
+ for (const [ax, ay, bx, by, gj] of segs) {
385
+ if (Math.max(ax, bx) < b.x0 || Math.min(ax, bx) > b.x1 || Math.max(ay, by) < b.y0 || Math.min(ay, by) > b.y1) continue;
386
+ if (segHitsBox(ax, ay, bx, by, b)) pen += gj === gi ? 6 : 3;
387
+ }
388
+ if (!best || pen < best.pen) best = { ...c, pen };
389
+ }
390
+ if (!best) return;
391
+ trainBox.push({ x0: best.box.x0 - 2, x1: best.box.x1 + 2, y0: best.box.y0 - 1, y1: best.box.y1 + 1 });
392
+ labels.push(`<text x="${(best.box.x0 + best.box.x1) / 2}" y="${(best.box.y0 + best.box.y1) / 2 + fsTrain * 0.36}" text-anchor="middle" font-size="${fsTrain}" font-weight="500" fill="var(--ink-2)" class="halo num">${esc(text)}</text>`);
393
+ });
394
+
395
+ // the window's edges
396
+ s.push(`<line x1="${x(R.start)}" x2="${x(R.start)}" y1="${T - 4}" y2="${H - B}" stroke="var(--ink)" stroke-width="1.4"/>`);
397
+ if (R.end < t1) s.push(`<line x1="${x(R.end)}" x2="${x(R.end)}" y1="${T - 4}" y2="${H - B}" stroke="var(--ink)" stroke-width="1.4"/>`);
398
+ s.push(`<text x="${x(R.start) + 6}" y="${T - 27}" font-size="${fsSmall}" fill="var(--ink-2)">window opens</text>`);
399
+ if (R.end < t1) {
400
+ const msg = "window closes · lateness after it is handed on";
401
+ const right = x(R.end) + 6 + measure(msg, fsSmall) < W - Rm;
402
+ s.push(`<text x="${right ? x(R.end) + 6 : x(R.end) - 6}" y="${T - 27}" text-anchor="${right ? "start" : "end"}" font-size="${fsSmall}" fill="var(--ink-2)">${right ? msg : "window closes"}</text>`);
403
+ }
404
+ s.push(labels.join(""));
405
+
406
+ const svg = $("#chart");
407
+ svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
408
+ svg.setAttribute("width", W);
409
+ svg.setAttribute("height", H);
410
+ svg.innerHTML = s.join("");
411
+ S.geo = geo;
412
+ S.frame = { x, y, t0, t1, L, T, W, H, B, Rm, km };
413
+ }
414
+
415
+ // ---- hover: the nearest train, and its train set ---------------------------------------------------------------
416
+
417
+ function nearest(mx, my) {
418
+ let best = null, bd = 12;
419
+ for (const g of S.geo || []) {
420
+ for (let i = 1; i < g.xy.length; i++) {
421
+ const [ax, ay] = g.xy[i - 1], [bx, by] = g.xy[i];
422
+ const dx = bx - ax, dy = by - ay, L2 = dx * dx + dy * dy || 1;
423
+ const u = Math.max(0, Math.min(1, ((mx - ax) * dx + (my - ay) * dy) / L2));
424
+ const d = Math.hypot(mx - ax - u * dx, my - ay - u * dy);
425
+ if (d < bd) { bd = d; best = g; }
426
+ }
427
+ }
428
+ return best;
429
+ }
430
+
431
+ function tipHTML(r) {
432
+ const col = r.problems && r.problems.length ? "var(--critical)" : r.kind === "fast" ? "var(--fast)" : "var(--stop)";
433
+ const origin = r.route[0], dest = r.route[r.route.length - 1];
434
+ const dep = r.sched_dep[0] !== null ? hhmm(r.sched_dep[0]) : "";
435
+ const lines = [`<div><span class="key" style="border-color:${col}"></span><b class="num">${esc(r.train)}</b> <span class="m">${r.kind === "fast" ? "fast" : "stopping"} train · set ${esc(r.unit)}</span></div>`,
436
+ `<div class="m">${esc(origin)} ${dep} → ${esc(dest)}</div>`];
437
+ if (r.cancelled) lines.push(`<div><b>cancelled</b> · ${r.unserved} stops not served</div>`);
438
+ else lines.push(`<div><b class="num">${fmt(r.delay)}</b> <span class="m">stop-minutes late</span> · <b class="num">${fmt(r.unserved)}</b> <span class="m">stops not served</span></div>`);
439
+ const o = orderText(r.order);
440
+ if (o) lines.push(`<div><span class="m">plan:</span> ${esc(o)}</div>`);
441
+ if (r.problems && r.problems.length) lines.push(`<div style="color:var(--critical)">breaks: ${esc(r.problems.join(", "))}</div>`);
442
+ return lines.join("");
443
+ }
444
+
445
+ function onMove(e) {
446
+ const svg = $("#chart"), tip = $("#tip"), wrap = $("#chartwrap");
447
+ const rect = svg.getBoundingClientRect();
448
+ const sx = S.frame ? S.frame.W / rect.width : 1;
449
+ const mx = (e.clientX - rect.left) * sx, my = (e.clientY - rect.top) * sx;
450
+ const g = nearest(mx, my);
451
+ const group = $("#paths");
452
+ if (!g) { tip.hidden = true; group && group.classList.remove("focus"); return; }
453
+ group.classList.add("focus");
454
+ for (const el of group.querySelectorAll(".run")) el.classList.toggle("on", el.dataset.unit === g.r.unit);
455
+ tip.innerHTML = tipHTML(g.r);
456
+ tip.hidden = false;
457
+ const wr = wrap.getBoundingClientRect();
458
+ const left = e.clientX - wr.left + wrap.scrollLeft + 14, top = e.clientY - wr.top + 14;
459
+ tip.style.left = `${Math.min(left, wrap.scrollLeft + wr.width - tip.offsetWidth - 4)}px`;
460
+ tip.style.top = `${top}px`;
461
+ }
462
+
463
+ // ---- the panels around the chart -----------------------------------------------------------------------------
464
+
465
+ function legendKey(kind) {
466
+ const line = (stroke, dash = "", w = 2) => `<svg width="22" height="10" aria-hidden="true"><line x1="1" y1="5" x2="21" y2="5" stroke="${stroke}" stroke-width="${w}" stroke-linecap="round" ${dash ? `stroke-dasharray="${dash}"` : ""}/></svg>`;
467
+ const rect = (fill, stroke) => `<svg width="20" height="12" aria-hidden="true"><rect x="1" y="1" width="18" height="10" rx="2" fill="${fill}" stroke="${stroke}"/></svg>`;
468
+ return {
469
+ stop: line("var(--stop)"), fast: line("var(--fast)"), bad: line("var(--critical)", "7 4"), cancel: line("var(--muted)", "2 4", 1.4),
470
+ hold: line("var(--ink-2)", "", 6), turn: `<svg width="14" height="14" aria-hidden="true"><circle cx="7" cy="7" r="5" fill="var(--surface)" stroke="var(--ink-2)" stroke-width="2.2"/></svg>`,
471
+ single: rect("var(--single)", "var(--grid)"), closed: rect("url(#lg-closed)", "var(--critical)"),
472
+ slow: rect("var(--warning)", "var(--warning)"), track: rect("var(--serious)", "var(--serious)"),
473
+ }[kind];
474
+ }
475
+
476
+ function renderPanels(R) {
477
+ const where = (c) => {
478
+ const names = Object.fromEntries(R.points.map((p) => [p.code, p.name]));
479
+ const a = c.where[0], b = c.where[c.where.length - 1];
480
+ return a === b ? names[a] : `${names[a]} – ${names[b]}`;
481
+ };
482
+ const dis = R.closures.map((c) => `${where(c)} ${WHAT[c.kind] || c.kind}${c.kind === "slow" ? ` (+${c.extra} min)` : ""} ${hhmm(c.start)}–${hhmm(c.end)}${c.cause ? ` (${c.cause})` : ""}`);
483
+ $("#title").textContent = `${longDay(R.day)}, ${hhmm(R.start)}–${hhmm(R.end)}`;
484
+ const turned = R.short_turned, cx = R.cancelled;
485
+ const decisions = [turned ? `${turned} short-turn${turned === 1 ? "" : "s"}` : "", cx ? `${cx} cancellation${cx === 1 ? "" : "s"}` : ""].filter(Boolean).join(", ");
486
+ $("#sub").textContent = `${dis.join("; ") || "No closures"} · ${TIER[R.difficulty] || R.difficulty} · ${R.runs.length} trains. `
487
+ + (FIGURE ? `${R.plan_id === "optimal" ? "The proven-optimal plan (CP-SAT)" : R.plan}: cost ${fmt(R.cost)} stop-minutes${decisions ? `, ${decisions}` : ""}.` : `Plan: ${R.plan}.`);
488
+ const nb = R.violations.length;
489
+ $("#badge").innerHTML = R.feasible ? `<span class="badge ok"><i>✓</i>legal plan</span>`
490
+ : `<span class="badge bad"><i>✕</i>${nb} rule break${nb === 1 ? "" : "s"}: scores under 0.2</span>`;
491
+ const over = R.cost !== null && R.optimal_cost ? R.cost - R.optimal_cost : null;
492
+ const stat = (k, v, small = "") => `<div class="stat"><span class="k">${k}</span><span class="v num">${v}${small ? `<small>${small}</small>` : ""}</span></div>`;
493
+ $("#stats").innerHTML = FIGURE ? "" : [
494
+ stat("Reward", R.reward.toFixed(3)),
495
+ stat("Cost, stop-minutes", R.cost !== null ? fmt(R.cost) : `${fmt(R.cost_if_legal)}`, R.cost === null ? "if it were legal" : over > 0 ? `+${fmt(over)} over the optimum` : "the optimum"),
496
+ stat("Optimum", fmt(R.optimal_cost), `floor ${fmt(R.floor_cost)}`),
497
+ stat("Delay", fmt(R.cost_parts.delay), "stop-min"),
498
+ stat("Stops not served", fmt(R.cost_parts.unserved / (R.rules.cancel_cost || 30))),
499
+ stat("Short-turns", fmt(R.short_turned)), stat("Cancelled", fmt(R.cancelled)),
500
+ ].join("");
501
+ const br = $("#breaks");
502
+ br.hidden = !nb;
503
+ br.innerHTML = R.violations.slice(0, 12).map((v) => `<li>${esc(v.time || "")} ${esc((v.rule || "").replace(/_/g, " "))}: ${esc(v.detail || "")}</li>`).join("")
504
+ + (nb > 12 ? `<li>… and ${nb - 12} more</li>` : "");
505
+
506
+ const kinds = new Set(R.closures.map((c) => c.kind));
507
+ const anyHold = R.runs.some((r) => r.order && r.order.hold && Object.keys(r.order.hold).length);
508
+ const anyTurn = R.runs.some((r) => r.order && (r.order.to || r.order.from));
509
+ const items = [["stop", "stopping train"], ["fast", "fast train"]];
510
+ if (R.runs.some((r) => r.problems && r.problems.length)) items.push(["bad", "breaks a rule"]);
511
+ if (anyHold) items.push(["hold", "held by the plan"]);
512
+ if (anyTurn) items.push(["turn", "short-turn (run ends or starts early)"]);
513
+ if (R.cancelled) items.push(["cancel", "cancelled (its timetable path)"]);
514
+ items.push(["single", "single track"]);
515
+ for (const k of ["closed", "slow", "track"]) if (kinds.has(k)) items.push([k, WHAT[k]]);
516
+ $("#legend").innerHTML = `<svg width="0" height="0" style="position:absolute"><defs><pattern id="lg-closed" width="5" height="5" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="5" height="5" fill="var(--critical)" fill-opacity="0.12"/><line x1="0" y1="0" x2="0" y2="5" stroke="var(--critical)" stroke-opacity="0.6" stroke-width="1.6"/></pattern></defs></svg>`
517
+ + items.map(([k, t]) => `<span>${legendKey(k)}${esc(t)}</span>`).join("");
518
+
519
+ const rows = [...R.runs].sort((a, b) => (a.sched_dep[0] ?? 0) - (b.sched_dep[0] ?? 0));
520
+ $("#table").innerHTML = `<thead><tr><th>Train</th><th>Set</th><th>Type</th><th>Timetable</th><th>Plan</th><th class="n">Delay (stop-min)</th><th class="n">Stops not served</th><th>Rule breaks</th></tr></thead><tbody>`
521
+ + rows.map((r) => `<tr><td class="num">${esc(r.train)}</td><td>${esc(r.unit)}</td><td>${r.kind === "fast" ? "fast" : "stopping"}</td>`
522
+ + `<td>${esc(r.route[0])} ${r.sched_dep[0] !== null ? hhmm(r.sched_dep[0]) : ""} → ${esc(r.route[r.route.length - 1])}</td><td>${esc(orderText(r.order))}</td>`
523
+ + `<td class="n">${fmt(r.delay)}</td><td class="n">${fmt(r.unserved)}</td><td>${esc((r.problems || []).join(", "))}</td></tr>`).join("") + "</tbody>";
524
+ }
525
+
526
+ // ---- loading ---------------------------------------------------------------------------------------------------
527
+
528
+ function setURL() {
529
+ if (EMBED && !params.has("embed")) return;
530
+ const p = new URLSearchParams();
531
+ if (params.has("embed")) p.set("embed", "1");
532
+ p.set("task", $("#task").value);
533
+ p.set("plan", $("#plan").value);
534
+ history.replaceState(null, "", `?${p}`);
535
+ }
536
+
537
+ async function loadPlans(taskId, want) {
538
+ const plans = await json(`/api/tasks/${encodeURIComponent(taskId)}/plans`);
539
+ const groups = { ref: [], model: [], live: [] };
540
+ for (const p of plans) {
541
+ const g = p.id.startsWith("run:") ? "model" : p.id.startsWith("episode:") ? "live" : "ref";
542
+ const r = p.reward === null || p.reward === undefined ? "" : ` · ${Number(p.reward).toFixed(3)}`;
543
+ groups[g].push(`<option value="${esc(p.id)}">${esc(p.label)}${r}</option>`);
544
+ }
545
+ $("#plan").innerHTML = [["ref", "References"], ["model", "Models' submitted plans"], ["live", "Live episodes"]]
546
+ .filter(([k]) => groups[k].length).map(([k, n]) => `<optgroup label="${n}">${groups[k].join("")}</optgroup>`).join("");
547
+ $("#plan").value = plans.some((p) => p.id === want) ? want : "optimal";
548
+ }
549
+
550
+ async function refresh() {
551
+ const task = $("#task").value, plan = $("#plan").value;
552
+ $("#open3d").href = `/twin/?task=${encodeURIComponent(task)}&plan=${encodeURIComponent(plan)}`;
553
+ setURL();
554
+ try {
555
+ S.R = await json(`/api/tasks/${encodeURIComponent(task)}/replay?plan=${encodeURIComponent(plan)}`);
556
+ renderPanels(S.R);
557
+ draw();
558
+ } catch (err) {
559
+ $("#title").innerHTML = `<span class="err">${esc(err.message)}</span>`;
560
+ }
561
+ }
562
+
563
+ (async () => {
564
+ if (EMBED) $("#nav").hidden = true;
565
+ if (FIGURE) { document.body.classList.add("figure"); $("#controls").hidden = true; $("#tablebox").hidden = true; }
566
+ if (params.get("theme")) document.documentElement.dataset.theme = params.get("theme");
567
+ try {
568
+ S.tasks = await json("/api/tasks");
569
+ } catch (err) {
570
+ $("#title").innerHTML = `<span class="err">${esc(err.message)}</span>`;
571
+ return;
572
+ }
573
+ const group = (split) => S.tasks.filter((t) => t.split === split).map((t) =>
574
+ `<option value="${esc(t.task_id)}">${shortDay(t.day)} ${t.start}–${t.end} · ${esc(TIER[t.difficulty] || t.difficulty)} · ${t.runs} trains</option>`).join("");
575
+ const splits = [...new Set(S.tasks.map((t) => t.split))].sort((a, b) => (a === "eval" ? -1 : b === "eval" ? 1 : a.localeCompare(b)));
576
+ $("#task").innerHTML = splits.map((sp) => `<optgroup label="${esc(sp)} split">${group(sp)}</optgroup>`).join("");
577
+ const want = params.get("task") || FEATURED;
578
+ $("#task").value = S.tasks.some((t) => t.task_id === want) ? want : S.tasks[0].task_id;
579
+ await loadPlans($("#task").value, params.get("plan") || "optimal");
580
+ $("#task").onchange = async () => { await loadPlans($("#task").value, $("#plan").value); refresh(); };
581
+ $("#plan").onchange = refresh;
582
+ const svg = $("#chart");
583
+ svg.addEventListener("pointermove", onMove);
584
+ svg.addEventListener("pointerleave", () => { $("#tip").hidden = true; $("#paths")?.classList.remove("focus"); });
585
+ let rt;
586
+ window.addEventListener("resize", () => { clearTimeout(rt); rt = setTimeout(draw, 120); });
587
+ await refresh();
588
+ document.body.dataset.ready = "1";
589
+ })();
590
+ </script>
591
+ </body>
592
+ </html>
openenv/rail_openenv/web/twin/SOURCES.md ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # The line twin: sources
2
+
3
+ `twin.json.gz` is built by `envs/rail_ops/tools/twin/build_twin.py` (`fetch`, then `build`). Frame: metres east and
4
+ north of 53.22 N, 6.19 E (equirectangular), stored as integer decimetres.
5
+
6
+ | What | Source and licence |
7
+ |---|---|
8
+ | track (every rail way near the line: main line, passing loops, yards), platforms, level crossings, station positions | © OpenStreetMap contributors, [ODbL 1.0](https://www.openstreetmap.org/copyright) (Overpass API, data of July-October 2026) |
9
+ | 42,654 buildings (within 700 m of the line), land cover, water, roads and cycle paths, tree rows and trees, wind turbines, windmills, towers, place names | © OpenStreetMap contributors, ODbL 1.0 (same queries) |
10
+ | which platform each direction uses at every station, and the usual terminal tracks | Rijden de Treinen train archive, January-June 2025 (planned platforms; CC BY 4.0, "Rijden de Treinen") |
11
+ | train times | `rail_core.replay`: the grader's own simulation of the plan (`check.simulate`) |
12
+
13
+ ## Real, derived and drawn
14
+
15
+ **Real:**
16
+ - Track geometry, switches and station layout, as mapped in OSM. Trains follow it per car, so they take the curves
17
+ and the loop tracks.
18
+ - Each direction's platform at each station, from the record: Hurdegaryp 1 eastbound / 2 westbound, Feanwâlden 2 / 1,
19
+ De Westereen 2 / 1, Buitenpost 1 / 2, Grijpskerk 2 / 1, Zuidhorn 1 / 2.
20
+ - Leeuwarden bays 6b and 8, the tracks Leeuwarden - Groningen trains used there.
21
+ - The times: every arrival, departure and passing minute is the simulation's. Between them the picture moves
22
+ smoothly: zero speed at stops, passing speed kept through points the train does not stop at.
23
+
24
+ **Derived:**
25
+ - Groningen. OSM maps the station as rebuilt (Spoorzone works, 2026): tracks 5 and 6, which the record's 5a and 6a
26
+ trains used, end at buffer stops on the west side, so no train from Leeuwarden can reach them. The twin stands those
27
+ trains on OSM tracks 2 and 3, the island platform beside them that is reachable from the west.
28
+ - Terminal track per train: the record's usual split by train type (Leeuwarden: westbound fast trains to 8, stopping
29
+ trains to 6b; Groningen: fast 6a, stopping 5a). A second train on the same track stands behind the first.
30
+ - Which of the two tracks a train uses at a passing station: its direction's platform, or the other one when that is
31
+ taken or the train is turning back there.
32
+ - Level crossing barriers close about 35 seconds before a train reaches the crossing and open once it has passed.
33
+ - Signals: one at each entry of every single-track section, red while a train is inside or the section is closed.
34
+ They show the rule the grader enforces; they are not the line's real signals.
35
+
36
+ **Drawn (illustrative):**
37
+ - Building heights: OSM `height` or `building:levels` where tagged, else by building type (a house 7 m, a barn 8 m,
38
+ a warehouse 9 m).
39
+ - Heights of landmarks without a tag: windmills 16 m, wind turbines a 60 m hub, towers 25 m.
40
+ - Trees: the mapped trees and tree rows, plus woods filled at about one tree per 120 m² (capped at 60,000).
41
+ - Train models: every train is drawn as a two-car unit (about 55 m), white, with a blue stripe for stopping trains and
42
+ orange for fast ones. The record does not say which unit ran which train.
43
+ - The sky and the sun: the solar position for the task's date and minute at the line (local time, CET/CEST).
44
+
45
+ The replay never changes a cost or a reward. The viewer loads three.js 0.180 from the jsDelivr CDN.
openenv/rail_openenv/web/twin/app.js ADDED
@@ -0,0 +1,286 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // The line twin: pick a task and a plan, and watch it run on the real line. Data: /api (rail_core.replay, the grader's
2
+ // own simulation) and ./twin.json.gz (OpenStreetMap). URL: ?task=<id>&plan=<id>&t=<HH:MM>&follow=<unit>&cam=map|chase|cab
3
+
4
+ import { Line } from "./geo.js";
5
+ import { buildSim, hhmm } from "./motion.js";
6
+ import { CostChart, SERIES, TrainGraph } from "./graph.js";
7
+ import { KIND_COLOR, TwinScene } from "./scene.js";
8
+
9
+ const $ = (s) => document.querySelector(s);
10
+ const params = new URLSearchParams(location.search);
11
+ const S = { t: 0, playing: false, speed: 60, sim: null, R: null, opt: null, sel: null, cam: "map", lastEvent: -1 };
12
+ const TIER = { real: "real disruption", blockage: "blockage", loop: "lost passing track", compound: "compound" };
13
+ const FEATURED = "gnlw-2025-03-10-0700-210m-blockage-0";
14
+
15
+ async function json(url) {
16
+ const r = await fetch(url);
17
+ if (!r.ok) throw new Error(`${url}: ${r.status} ${await r.text()}`);
18
+ return r.json();
19
+ }
20
+
21
+ async function loadTwin() {
22
+ const r = await fetch("./twin.json.gz");
23
+ if (!r.ok) throw new Error(`twin.json.gz: HTTP ${r.status}`);
24
+ const buf = new Uint8Array(await r.arrayBuffer());
25
+ if (buf[0] === 0x1f && buf[1] === 0x8b) {
26
+ const stream = new Blob([buf]).stream().pipeThrough(new DecompressionStream("gzip"));
27
+ return JSON.parse(await new Response(stream).text());
28
+ }
29
+ return JSON.parse(new TextDecoder().decode(buf)); // a proxy already decoded it
30
+ }
31
+
32
+ function parseClock(v, R) {
33
+ const m = /^(\d{1,2}):(\d{2})$/.exec(v || "");
34
+ if (!m) return null;
35
+ let t = +m[1] * 60 + +m[2];
36
+ if (t < R.start - 120) t += 1440;
37
+ return t;
38
+ }
39
+
40
+ function weekday(day) {
41
+ const d = new Date(`${day}T12:00:00Z`);
42
+ return d.toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long", year: "numeric", timeZone: "UTC" });
43
+ }
44
+
45
+ function setURL() {
46
+ const p = new URLSearchParams();
47
+ p.set("task", $("#task").value);
48
+ p.set("plan", $("#plan").value);
49
+ p.set("t", hhmm(S.t));
50
+ if (S.sel) p.set("follow", S.sel);
51
+ if (S.cam !== "map") p.set("cam", S.cam);
52
+ history.replaceState(null, "", `?${p}`);
53
+ $("#graphlink").href = `/viewer/?task=${encodeURIComponent($("#task").value)}&plan=${encodeURIComponent($("#plan").value)}`;
54
+ }
55
+
56
+ // --- panels ----------------------------------------------------------------------------------------------------
57
+
58
+ function renderScenario(R) {
59
+ const where = (c) => [...new Set([c.where[0], c.where[c.where.length - 1]])].join("–");
60
+ const what = { closed: "closed", slow: "slow running", track: "one platform track out of use" };
61
+ const items = R.closures.map((c) => `<li><b>${where(c)}</b> ${what[c.kind] || c.kind}${c.kind === "slow" ? ` (+${c.extra} min)` : ""}, `
62
+ + `${hhmm(c.start)}–${hhmm(c.end)}<div class="note">${c.cause || ""}${c.real ? " · from the record" : ""}</div></li>`).join("");
63
+ const notes = (R.notices || []).slice(0, 3).map((n) => `<li class="note">${n}</li>`).join("");
64
+ $("#scenario").innerHTML = `<div class="day">${weekday(R.day)}</div>
65
+ <div class="muted">${hhmm(R.start)}–${hhmm(R.end)} · ${R.runs.length} trains<span class="tier">${TIER[R.difficulty] || R.difficulty}</span></div>
66
+ <ul>${items}${notes}</ul>`;
67
+ }
68
+
69
+ function renderResult(R) {
70
+ const rb = R.violations.length;
71
+ const pct = R.cost !== null && R.optimal_cost ? Math.round((R.cost / R.optimal_cost - 1) * 100) : null;
72
+ const viol = R.violations.slice(0, 40).map((v, i) => `<li data-i="${i}">${v.time || ""} ${v.rule.replace("_", " ")}: ${v.detail}</li>`).join("");
73
+ $("#result").innerHTML = `<div class="plan-name">${R.plan}</div>
74
+ <div class="hero">${R.reward.toFixed(3)}<small>reward</small></div>
75
+ <div class="badge ${R.feasible ? "ok" : "bad"}">${R.feasible ? "legal plan" : `${rb} rule break${rb === 1 ? "" : "s"}: scores under 0.2`}</div>
76
+ <dl>
77
+ <dt>Cost</dt><dd>${R.cost !== null ? R.cost.toLocaleString() : `${R.cost_if_legal.toLocaleString()} if it were legal`}${pct !== null && pct > 0 ? ` (+${pct}% over the optimum)` : ""}</dd>
78
+ <dt>Optimum</dt><dd>${R.optimal_cost.toLocaleString()} (floor ${R.floor_cost.toLocaleString()})</dd>
79
+ <dt>Delay</dt><dd>${R.cost_parts.delay.toLocaleString()} stop-min</dd>
80
+ <dt>Unserved</dt><dd>${(R.cost_parts.unserved / (R.rules.cancel_cost || 30)).toLocaleString()} stops (${R.cost_parts.unserved.toLocaleString()})</dd>
81
+ <dt>Handed on</dt><dd>${R.cost_parts.handed_on.toLocaleString()}</dd>
82
+ <dt>Cancelled</dt><dd>${R.cancelled} runs · ${R.short_turned} turned short</dd>
83
+ </dl>
84
+ ${rb ? `<ul class="viol">${viol}</ul>` : ""}`;
85
+ for (const li of document.querySelectorAll("#result .viol li")) {
86
+ li.onclick = () => {
87
+ const v = R.violations[+li.dataset.i];
88
+ const t = parseClock(v.time, R);
89
+ if (t !== null) seek(t - 1);
90
+ if (v.trains && v.trains[0]) selectRun(v.trains[0]);
91
+ };
92
+ }
93
+ }
94
+
95
+ function renderTrains(states) {
96
+ const ol = $("#trains");
97
+ const rows = states.slice().sort((a, b) => a.unit.localeCompare(b.unit));
98
+ $("#ntrains").textContent = `${rows.length} train sets`;
99
+ const html = rows.map((s) => {
100
+ const d = Math.round(s.delay);
101
+ return `<li data-u="${s.unit}" class="${S.sel === s.unit ? "sel" : ""}"><i class="chip" style="background:${KIND_COLOR[s.kind]}"></i>`
102
+ + `<span class="num">${s.train}</span><span class="muted">${s.dir === "E" ? "→ Groningen" : "← Leeuwarden"}</span>`
103
+ + `<span class="dl ${d > 0 ? "late" : ""}">${d > 0 ? `+${d} min` : "on time"}</span><span class="st">${s.status}</span></li>`;
104
+ }).join("");
105
+ if (ol.dataset.html !== html) {
106
+ ol.innerHTML = html;
107
+ ol.dataset.html = html;
108
+ for (const li of ol.children) li.onclick = () => select(li.dataset.u, true);
109
+ }
110
+ const cx = S.sim.cancelled();
111
+ $("#cxl-h").hidden = !cx.length;
112
+ $("#cancelled").innerHTML = cx.map((r) => `<li><i class="chip" style="background:${KIND_COLOR[r.kind]}"></i>`
113
+ + `<span class="num">${r.train}</span><span>${r.route[0]} ${hhmm(r.sched_dep[0])} → ${r.route[r.route.length - 1]}</span>`
114
+ + `<span>${r.stops.filter(Boolean).length} stops</span></li>`).join("");
115
+ }
116
+
117
+ function toast(ev) {
118
+ const box = $("#toasts");
119
+ const el = document.createElement("div");
120
+ el.className = `toast ${ev.kind}`;
121
+ el.innerHTML = `<time>${hhmm(ev.t)}</time>${ev.text}`;
122
+ box.prepend(el);
123
+ while (box.children.length > 4) box.lastChild.remove();
124
+ setTimeout(() => { el.style.opacity = 0; setTimeout(() => el.remove(), 700); }, 4200);
125
+ }
126
+
127
+ // --- selection and cameras -------------------------------------------------------------------------------------
128
+
129
+ let scene, graph, cost;
130
+
131
+ function select(unit, fly) {
132
+ S.sel = unit;
133
+ scene.selected = unit;
134
+ graph.sel = unit;
135
+ graph.draw();
136
+ if (fly && unit) setCam(S.cam === "cab" ? "cab" : "chase");
137
+ setURL();
138
+ }
139
+
140
+ function selectRun(train) {
141
+ const r = S.R.runs.find((x) => x.train === train);
142
+ if (r) select(r.unit, true);
143
+ }
144
+
145
+ function setCam(mode) {
146
+ S.cam = mode;
147
+ for (const [id, m] of [["#cam-map", "map"], ["#cam-follow", "chase"], ["#cam-cab", "cab"]]) $(id).classList.toggle("on", S.cam === m);
148
+ if (mode === "map") scene.viewAll();
149
+ else if (S.sel) scene.follow(S.sel, mode);
150
+ else {
151
+ const first = S.sim.statesAt(S.t).find((s) => s.moving) || S.sim.statesAt(S.t)[0];
152
+ if (first) { select(first.unit, false); scene.follow(first.unit, mode); }
153
+ }
154
+ setURL();
155
+ }
156
+
157
+ function seek(t) {
158
+ S.t = Math.max(S.sim.t0, Math.min(S.sim.t1, t));
159
+ S.lastEvent = S.R.events.findIndex((e) => e.t !== null && e.t > S.t) - 1;
160
+ if (S.lastEvent < -1) S.lastEvent = S.R.events.length - 1;
161
+ }
162
+
163
+ // --- loading a task and a plan ----------------------------------------------------------------------------
164
+
165
+ async function loadPlans(taskId, want) {
166
+ const plans = await json(`/api/tasks/${encodeURIComponent(taskId)}/plans`);
167
+ $("#plan").innerHTML = plans.map((p) => `<option value="${p.id}">${p.label}${p.reward !== null && p.reward !== undefined ? ` · ${(+p.reward).toFixed(3)}` : ""}</option>`).join("");
168
+ const model = plans.find((p) => p.id.startsWith("run:"));
169
+ $("#plan").value = plans.some((p) => p.id === want) ? want : model ? model.id : "optimal";
170
+ }
171
+
172
+ async function loadReplay(keepTime) {
173
+ const taskId = $("#task").value, planId = $("#plan").value;
174
+ const [R, opt] = await Promise.all([
175
+ json(`/api/tasks/${encodeURIComponent(taskId)}/replay?plan=${encodeURIComponent(planId)}`),
176
+ planId === "optimal" ? null : json(`/api/tasks/${encodeURIComponent(taskId)}/replay?plan=optimal`),
177
+ ]);
178
+ const sim = buildSim(R);
179
+ const prevT = S.t;
180
+ S.R = R; S.sim = sim; S.opt = opt || R;
181
+ if (!scene) {
182
+ scene = new TwinScene($("#stage"), TWIN, new Line(TWIN, R.points));
183
+ // a click on a train follows it; the end of a drag (panning the map) is not a click
184
+ let down = null;
185
+ const el = scene.labels.domElement;
186
+ el.addEventListener("pointerdown", (e) => { down = [e.clientX, e.clientY]; });
187
+ el.addEventListener("click", (e) => {
188
+ if (down && Math.hypot(e.clientX - down[0], e.clientY - down[1]) > 5) return;
189
+ const u = scene.pick(e.clientX, e.clientY);
190
+ if (u) select(u, true);
191
+ });
192
+ window.twin = { state: S, scene, seek, select, setCam }; // for the browser console and the viewer checks
193
+ }
194
+ scene.setReplay(R, sim);
195
+ graph.set(R, sim.t0, sim.t1);
196
+ const series = [{ name: planId === "optimal" ? "optimum" : "this plan", costs: R.costs, color: SERIES.plan }];
197
+ if (opt) series.push({ name: "optimum", costs: opt.costs, color: "#898781", dashed: true });
198
+ cost.set(series, sim.t0, sim.t1);
199
+ renderScenario(R);
200
+ renderResult(R);
201
+ const clock = parseClock(params.get("t"), R);
202
+ seek(keepTime ? prevT : clock !== null && !S.booted ? clock : sim.t0);
203
+ S.booted = true;
204
+ if (S.sel && !sim.units.some((u) => u.id === S.sel)) S.sel = null;
205
+ setURL();
206
+ }
207
+
208
+ // --- main loop ---------------------------------------------------------------------------------------------
209
+
210
+ let TWIN;
211
+ let last = performance.now(), lastList = 0;
212
+
213
+ function frame(now) {
214
+ const dt = Math.min(0.5, (now - last) / 1000);
215
+ last = now;
216
+ if (S.sim) {
217
+ if (S.playing) {
218
+ S.t += dt * S.speed / 60;
219
+ if (S.t >= S.sim.t1) { S.t = S.sim.t1; S.playing = false; $("#play").textContent = "▶"; }
220
+ // the plan's moments as they happen
221
+ while (S.lastEvent + 1 < S.R.events.length && S.R.events[S.lastEvent + 1].t !== null && S.R.events[S.lastEvent + 1].t <= S.t) {
222
+ S.lastEvent++;
223
+ if (S.speed <= 120) toast(S.R.events[S.lastEvent]);
224
+ }
225
+ }
226
+ const states = S.sim.statesAt(S.t);
227
+ scene.update(S.t, states, dt);
228
+ graph.setTime(S.t);
229
+ cost.setTime(S.t);
230
+ $("#clock b").textContent = `${hhmm(S.t)}:${String(Math.floor((S.t % 1) * 60)).padStart(2, "0")}`;
231
+ $("#clock span").textContent = `cost so far ${S.sim.costAt(S.t).toLocaleString()}`;
232
+ if (now - lastList > 250) { renderTrains(states); lastList = now; }
233
+ scene.render();
234
+ }
235
+ requestAnimationFrame(frame);
236
+ }
237
+
238
+ async function main() {
239
+ $("#loadmsg").textContent = "OpenStreetMap: track, stations, buildings…";
240
+ const [twin, tasks] = await Promise.all([loadTwin(), json("/api/tasks")]);
241
+ TWIN = twin;
242
+ const label = (t) => `${t.day} ${t.start}–${t.end} · ${TIER[t.difficulty] || t.difficulty} · ${t.runs} trains${t.model_plans ? " · ★" : ""}`;
243
+ const group = (split) => tasks.filter((t) => t.split === split).sort((a, b) => (b.model_plans - a.model_plans) || a.task_id.localeCompare(b.task_id));
244
+ $("#task").innerHTML = ["eval", "train"].map((sp) => `<optgroup label="${sp === "eval" ? "Eval (★ has a model's plan)" : "Train"}">`
245
+ + group(sp).map((t) => `<option value="${t.task_id}">${label(t)}</option>`).join("") + "</optgroup>").join("");
246
+ const want = params.get("task");
247
+ $("#task").value = tasks.some((t) => t.task_id === want) ? want : tasks.some((t) => t.task_id === FEATURED) ? FEATURED : tasks[0].task_id;
248
+ $("#station").innerHTML += twin.stations.map((s) => `<option value="${s.code}">${s.name}</option>`).join("");
249
+ graph = new TrainGraph($("#graph"), $("#gtip"), (t) => seek(t), (u) => select(u, false));
250
+ cost = new CostChart($("#cost"), $("#ctip"), (t) => seek(t));
251
+ $("#loadmsg").textContent = "The plan: every train's simulated times…";
252
+ await loadPlans($("#task").value, params.get("plan"));
253
+ await loadReplay(false);
254
+ if (params.get("follow")) select(params.get("follow"), false);
255
+ if (params.get("cam") && params.get("cam") !== "map") setCam(params.get("cam"));
256
+ $("#loading").classList.add("done");
257
+
258
+ $("#task").onchange = async () => { S.sel = null; scene.selected = null; await loadPlans($("#task").value, $("#plan").value); await loadReplay(false); setCam("map"); };
259
+ $("#plan").onchange = () => loadReplay(true);
260
+ $("#play").onclick = () => { S.playing = !S.playing; if (S.playing && S.t >= S.sim.t1) seek(S.sim.t0); $("#play").textContent = S.playing ? "⏸" : "▶"; };
261
+ $("#restart").onclick = () => seek(S.sim.t0);
262
+ $("#speed").onchange = () => { S.speed = +$("#speed").value; };
263
+ $("#cam-map").onclick = () => setCam("map");
264
+ $("#cam-follow").onclick = () => setCam("chase");
265
+ $("#cam-cab").onclick = () => setCam("cab");
266
+ $("#panels").onclick = () => { document.body.classList.toggle("clean"); $("#panels").classList.toggle("on", document.body.classList.contains("clean")); };
267
+ $("#station").onchange = () => { if ($("#station").value) { S.cam = "map"; scene.viewStation($("#station").value); $("#station").value = ""; } };
268
+ window.addEventListener("keydown", (e) => {
269
+ if (e.target.tagName === "SELECT") return;
270
+ if (e.code === "Space") { e.preventDefault(); $("#play").click(); }
271
+ else if (e.code === "ArrowRight") seek(S.t + (e.shiftKey ? 10 : 1));
272
+ else if (e.code === "ArrowLeft") seek(S.t - (e.shiftKey ? 10 : 1));
273
+ else if (e.code === "Home") seek(S.sim.t0);
274
+ else if (e.key === "m") setCam("map");
275
+ else if (e.key === "f") setCam("chase");
276
+ else if (e.key === "c") setCam("cab");
277
+ else if (e.key === "h") $("#panels").click();
278
+ });
279
+ setInterval(setURL, 2000);
280
+ requestAnimationFrame(frame);
281
+ }
282
+
283
+ main().catch((e) => {
284
+ $("#loadmsg").textContent = `Could not start: ${e.message}`;
285
+ console.error(e);
286
+ });
openenv/rail_openenv/web/twin/geo.js ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // The line on the map: routes (OSM track paths built by tools/twin/build_twin.py) and where a network km lies on them.
2
+ //
3
+ // A route is one direction's lane from a terminal track to the other terminal track, through the platform tracks that
4
+ // direction uses in the record. Every network point has an anchor (its distance along the route), so km -> place is
5
+ // piecewise linear between anchors. A train that stands on the other track at a station (a turned train, or a second
6
+ // train there) blends onto the other direction's route inside that station's zone: fully between the switches that
7
+ // bound a loop, with a ramp where the zone ends on double track (a crossover).
8
+ //
9
+ // Frame: metres east (x) and north (y) of the twin origin. No DOM and no three.js here (node-testable).
10
+
11
+ export const CANONICAL = { E: "E-8-5a", W: "W-5a-8" };
12
+ const RAMP_SWITCH = 0.03; // km: zones bounded by a switch, where the two tracks meet
13
+ const RAMP_CROSS = 0.25; // km: zones that end on double track
14
+
15
+ export function smoothstep(u) {
16
+ const x = Math.min(1, Math.max(0, u));
17
+ return x * x * (3 - 2 * x);
18
+ }
19
+
20
+ export class Route {
21
+ constructor(key, data, kmOf) {
22
+ this.key = key;
23
+ this.dir = data.dir;
24
+ const p = data.p;
25
+ const n = p.length / 2;
26
+ this.x = new Float64Array(n);
27
+ this.y = new Float64Array(n);
28
+ this.s = new Float64Array(n);
29
+ for (let i = 0; i < n; i++) {
30
+ this.x[i] = p[2 * i] / 10;
31
+ this.y[i] = p[2 * i + 1] / 10;
32
+ if (i) this.s[i] = this.s[i - 1] + Math.hypot(this.x[i] - this.x[i - 1], this.y[i] - this.y[i - 1]);
33
+ }
34
+ this.length = this.s[n - 1];
35
+ // anchors sorted by km (the route's s grows with km eastbound, shrinks westbound)
36
+ this.anchors = Object.entries(data.anchors)
37
+ .filter(([c]) => kmOf[c] !== undefined)
38
+ .map(([c, s]) => [kmOf[c], s])
39
+ .sort((a, b) => a[0] - b[0]);
40
+ }
41
+
42
+ sAtKm(km) {
43
+ const a = this.anchors;
44
+ if (km <= a[0][0]) return a[0][1] + (km - a[0][0]) * 1000 * (this.dir === "E" ? 1 : -1);
45
+ for (let i = 1; i < a.length; i++) {
46
+ if (km <= a[i][0]) {
47
+ const [k0, s0] = a[i - 1], [k1, s1] = a[i];
48
+ return s0 + (s1 - s0) * (km - k0) / (k1 - k0);
49
+ }
50
+ }
51
+ const [kl, sl] = a[a.length - 1];
52
+ return sl + (km - kl) * 1000 * (this.dir === "E" ? 1 : -1);
53
+ }
54
+
55
+ kmAtS(s) {
56
+ const a = this.dir === "E" ? this.anchors : [...this.anchors].reverse();
57
+ if (s <= a[0][1]) return a[0][0] + (s - a[0][1]) / 1000 * (this.dir === "E" ? 1 : -1);
58
+ for (let i = 1; i < a.length; i++) {
59
+ if (s <= a[i][1]) {
60
+ const [k0, s0] = a[i - 1], [k1, s1] = a[i];
61
+ return k0 + (k1 - k0) * (s - s0) / (s1 - s0);
62
+ }
63
+ }
64
+ const [kl, sl] = a[a.length - 1];
65
+ return kl + (s - sl) / 1000 * (this.dir === "E" ? 1 : -1);
66
+ }
67
+
68
+ // [x, y] at distance s (clamped to the route)
69
+ pointAtS(s) {
70
+ const S = this.s;
71
+ const n = S.length;
72
+ if (s <= 0) return [this.x[0], this.y[0]];
73
+ if (s >= this.length) return [this.x[n - 1], this.y[n - 1]];
74
+ let lo = 0, hi = n - 1;
75
+ while (hi - lo > 1) {
76
+ const mid = (lo + hi) >> 1;
77
+ if (S[mid] <= s) lo = mid; else hi = mid;
78
+ }
79
+ const L = S[hi] - S[lo];
80
+ const t = L > 0 ? (s - S[lo]) / L : 0;
81
+ return [this.x[lo] + t * (this.x[hi] - this.x[lo]), this.y[lo] + t * (this.y[hi] - this.y[lo])];
82
+ }
83
+
84
+ pointAtKm(km) {
85
+ return this.pointAtS(this.sAtKm(km));
86
+ }
87
+ }
88
+
89
+ export class Line {
90
+ // twin: the decoded twin.json; points: [{code, km}] from the replay (the network)
91
+ constructor(twin, points) {
92
+ this.kmOf = Object.fromEntries(points.map((p) => [p.code, p.km]));
93
+ this.routes = {};
94
+ for (const [k, d] of Object.entries(twin.routes)) this.routes[k] = new Route(k, d, this.kmOf);
95
+ this.zones = twin.zones || {};
96
+ }
97
+
98
+ routeKey(dir, lw, gn) {
99
+ return dir === "E" ? `E-${lw}-${gn}` : `W-${gn}-${lw}`;
100
+ }
101
+
102
+ route(dir, lw, gn) {
103
+ return this.routes[this.routeKey(dir, lw, gn)] || this.routes[CANONICAL[dir]];
104
+ }
105
+
106
+ // weight in [0, 1] of the other track at km, for a train standing on track `lane` at station `code`
107
+ blendWeight(code, km) {
108
+ const z = this.zones[code];
109
+ if (!z) return 0;
110
+ const [k0, k1, sw0, sw1] = z;
111
+ if (km < k0 || km > k1) return 0;
112
+ const r0 = sw0 ? RAMP_SWITCH : RAMP_CROSS, r1 = sw1 ? RAMP_SWITCH : RAMP_CROSS;
113
+ return Math.min(smoothstep((km - k0) / r0), smoothstep((k1 - km) / r1));
114
+ }
115
+
116
+ // [x, y] of a point km along a train's route; lanes: {station: "E" | "W"} where it uses that track
117
+ place(dir, lw, gn, km, lanes) {
118
+ const own = this.route(dir, lw, gn);
119
+ const p = own.pointAtKm(km);
120
+ if (!lanes) return p;
121
+ for (const [code, lane] of Object.entries(lanes)) {
122
+ if (lane === dir) continue;
123
+ const w = this.blendWeight(code, km);
124
+ if (w <= 0) continue;
125
+ const q = this.routes[CANONICAL[lane]].pointAtKm(km);
126
+ return [p[0] + w * (q[0] - p[0]), p[1] + w * (q[1] - p[1])];
127
+ }
128
+ return p;
129
+ }
130
+
131
+ // points along a train: [[x, y], ...] at the given distances (metres) ahead (+) or behind (-) its centre, which
132
+ // is where the simulation puts it (a stopped train's centre is at the platform's stop point)
133
+ body(dir, lw, gn, kmCentre, offsets, lanes) {
134
+ const r = this.route(dir, lw, gn);
135
+ const s0 = r.sAtKm(kmCentre);
136
+ return offsets.map((d) => this.place(dir, lw, gn, r.kmAtS(s0 + d), lanes));
137
+ }
138
+ }
openenv/rail_openenv/web/twin/graph.js ADDED
@@ -0,0 +1,313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // The two charts under the map, on 2D canvases: the train graph (time across, distance down: every train's path,
2
+ // disruptions shaded, the moment being shown) with the plan's events on a strip below it, and the cost so far
3
+ // against the optimum's. Both are cursors on the same clock: click or drag to move it, hover to read a value.
4
+
5
+ import { hhmm } from "./motion.js";
6
+
7
+ const INK = { primary: "#ffffff", secondary: "#c3c2b7", muted: "#898781", grid: "#2c2c2a", axis: "#383835",
8
+ surface: "#1a1a19" };
9
+ export const SERIES = { stop: "#3987e5", fast: "#d95926", plan: "#199e70" };
10
+ const STATUS = { critical: "#d03b3b", warning: "#fab219", serious: "#ec835a", good: "#0ca30c" };
11
+ const GLYPH = { hold: "‖", turn: "↩", cancel: "✕", violation: "!", closure: "▼", reopen: "▲" };
12
+
13
+ function fit(canvas) {
14
+ const r = canvas.getBoundingClientRect();
15
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
16
+ if (canvas.width !== Math.round(r.width * dpr) || canvas.height !== Math.round(r.height * dpr)) {
17
+ canvas.width = Math.round(r.width * dpr);
18
+ canvas.height = Math.round(r.height * dpr);
19
+ }
20
+ const g = canvas.getContext("2d");
21
+ g.setTransform(dpr, 0, 0, dpr, 0, 0);
22
+ return [g, r.width, r.height];
23
+ }
24
+
25
+ export class TrainGraph {
26
+ constructor(canvas, tip, onSeek, onPick) {
27
+ this.c = canvas;
28
+ this.tip = tip;
29
+ this.onSeek = onSeek;
30
+ this.onPick = onPick;
31
+ this.R = null;
32
+ this.t = 0;
33
+ this.sel = null;
34
+ this.hover = null;
35
+ let drag = false;
36
+ canvas.addEventListener("pointerdown", (e) => { drag = true; canvas.setPointerCapture(e.pointerId); this.seekAt(e, true); });
37
+ canvas.addEventListener("pointermove", (e) => { if (drag) this.seekAt(e, false); this.hoverAt(e); });
38
+ canvas.addEventListener("pointerup", () => { drag = false; });
39
+ canvas.addEventListener("pointerleave", () => { this.hover = null; this.tip.hidden = true; this.draw(); });
40
+ }
41
+
42
+ set(R, t0, t1) {
43
+ this.R = R;
44
+ this.t0 = t0;
45
+ this.t1 = t1;
46
+ this.km = Object.fromEntries(R.points.map((p) => [p.code, p.km]));
47
+ this.kmMax = Math.max(...R.points.map((p) => p.km));
48
+ // each served run as a polyline of (minute, km)
49
+ this.paths = R.runs.filter((r) => !r.cancelled).map((r) => {
50
+ const pts = [];
51
+ if (r.started && r.fixed_from !== null && r.fixed_from !== undefined) pts.push([r.fixed_from, this.km[r.route[r.fixed_to - 1]]]);
52
+ const from = r.started ? r.fixed_to : r.first;
53
+ for (let i = from; i <= r.last; i++) {
54
+ const k = this.km[r.route[i]];
55
+ if (r.arr[i] !== null && r.arr[i] !== undefined && !(i === from && !r.started)) pts.push([r.arr[i], k]);
56
+ if (r.dep[i] !== null && r.dep[i] !== undefined && i < r.last) pts.push([r.dep[i], k]);
57
+ }
58
+ return { train: r.train, unit: r.unit, kind: r.kind, pts, bad: r.problems.length > 0 };
59
+ });
60
+ this.draw();
61
+ }
62
+
63
+ frame() {
64
+ const [g, w, h] = fit(this.c);
65
+ const L = 44, Rp = 10, T = 8, B = 30, strip = 16;
66
+ return { g, w, h, L, Rp, T, B, strip, gh: h - T - B - strip,
67
+ x: (t) => L + (t - this.t0) / (this.t1 - this.t0) * (w - L - Rp),
68
+ tx: (x) => this.t0 + (x - L) / (w - L - Rp) * (this.t1 - this.t0),
69
+ y: (km) => T + km / this.kmMax * (h - T - B - strip) };
70
+ }
71
+
72
+ seekAt(e, pick) {
73
+ if (!this.R) return;
74
+ const f = this.frame();
75
+ const r = this.c.getBoundingClientRect();
76
+ const t = Math.max(this.t0, Math.min(this.t1, f.tx(e.clientX - r.left)));
77
+ this.onSeek(t);
78
+ if (pick && this.hover) this.onPick(this.hover.unit);
79
+ }
80
+
81
+ hoverAt(e) {
82
+ if (!this.R) return;
83
+ const f = this.frame();
84
+ const r = this.c.getBoundingClientRect();
85
+ const mx = e.clientX - r.left, my = e.clientY - r.top;
86
+ let best = null, bd = 10;
87
+ for (const p of this.paths) {
88
+ for (let i = 1; i < p.pts.length; i++) {
89
+ const [ta, ka] = p.pts[i - 1], [tb, kb] = p.pts[i];
90
+ const ax = f.x(ta), ay = f.y(ka), bx = f.x(tb), by = f.y(kb);
91
+ const dx = bx - ax, dy = by - ay, L2 = dx * dx + dy * dy || 1;
92
+ const u = Math.max(0, Math.min(1, ((mx - ax) * dx + (my - ay) * dy) / L2));
93
+ const d = Math.hypot(mx - ax - u * dx, my - ay - u * dy);
94
+ if (d < bd) { bd = d; best = { ...p, t: ta + u * (tb - ta), km: ka + u * (kb - ka) }; }
95
+ }
96
+ }
97
+ this.hover = best;
98
+ if (best) {
99
+ const code = Object.entries(this.km).reduce((a, [c, k]) => (Math.abs(k - best.km) < Math.abs(a[1] - best.km) ? [c, k] : a), ["", 1e9]);
100
+ const near = Math.abs(code[1] - best.km) < 0.3 ? `at ${code[0]}` : `km ${best.km.toFixed(1)}`;
101
+ this.tip.innerHTML = `<b>${best.train}</b> ${best.kind === "fast" ? "fast" : "stopping"} · ${hhmm(best.t)} ${near}`;
102
+ this.tip.style.left = `${Math.min(r.width - 180, mx + 12)}px`;
103
+ this.tip.style.top = `${my - 28}px`;
104
+ this.tip.hidden = false;
105
+ } else {
106
+ this.tip.hidden = true;
107
+ }
108
+ this.draw();
109
+ }
110
+
111
+ draw() {
112
+ if (!this.R) return;
113
+ const f = this.frame();
114
+ const { g, w, h, L, Rp, T, B, strip } = f;
115
+ g.clearRect(0, 0, w, h);
116
+ g.font = "11px system-ui, -apple-system, 'Segoe UI', sans-serif";
117
+ // disruptions
118
+ for (const c of this.R.closures) {
119
+ const a = this.km[c.where[0]], b = this.km[c.where[c.where.length - 1]];
120
+ const k0 = c.kind === "track" ? a - 0.4 : Math.min(a, b), k1 = c.kind === "track" ? a + 0.4 : Math.max(a, b);
121
+ g.fillStyle = c.kind === "closed" ? STATUS.critical : c.kind === "slow" ? STATUS.warning : STATUS.serious;
122
+ g.globalAlpha = 0.22;
123
+ g.fillRect(f.x(c.start), f.y(k0), f.x(c.end) - f.x(c.start), Math.max(3, f.y(k1) - f.y(k0)));
124
+ g.globalAlpha = 1;
125
+ }
126
+ // stations: hairlines and names
127
+ g.textAlign = "right";
128
+ g.textBaseline = "middle";
129
+ let lastY = -Infinity;
130
+ for (const p of this.R.points) {
131
+ const y = f.y(p.km);
132
+ g.strokeStyle = INK.grid;
133
+ g.lineWidth = 1;
134
+ g.beginPath(); g.moveTo(L, y + 0.5); g.lineTo(w - Rp, y + 0.5); g.stroke();
135
+ const major = p.kind === "terminal" || p.turn; // terminals and turn points first
136
+ if (p.kind !== "junction" && (y - lastY >= 11 || major)) {
137
+ g.fillStyle = major ? INK.secondary : INK.muted;
138
+ g.fillText(p.code, L - 6, y);
139
+ lastY = y;
140
+ }
141
+ }
142
+ // hours
143
+ g.textAlign = "center";
144
+ g.textBaseline = "top";
145
+ const step = this.t1 - this.t0 > 300 ? 60 : 30;
146
+ for (let t = Math.ceil(this.t0 / step) * step; t <= this.t1; t += step) {
147
+ const x = f.x(t);
148
+ g.strokeStyle = INK.grid;
149
+ g.beginPath(); g.moveTo(x + 0.5, T); g.lineTo(x + 0.5, T + f.gh); g.stroke();
150
+ g.fillStyle = INK.muted;
151
+ g.fillText(hhmm(t), x, h - B + strip + 6);
152
+ }
153
+ // the window's start and end
154
+ g.strokeStyle = INK.axis;
155
+ for (const t of [this.R.start, this.R.end]) {
156
+ const x = f.x(t);
157
+ g.beginPath(); g.moveTo(x + 0.5, T); g.lineTo(x + 0.5, T + f.gh); g.stroke();
158
+ }
159
+ // train paths
160
+ const focus = this.sel ?? (this.hover && this.hover.unit);
161
+ for (const p of this.paths) {
162
+ const on = !focus || p.unit === focus;
163
+ g.strokeStyle = SERIES[p.kind] || SERIES.stop;
164
+ g.globalAlpha = on ? 1 : 0.28;
165
+ g.lineWidth = focus && p.unit === focus ? 3 : 1.5;
166
+ g.lineJoin = "round";
167
+ g.beginPath();
168
+ p.pts.forEach(([t, k], i) => (i ? g.lineTo(f.x(t), f.y(k)) : g.moveTo(f.x(t), f.y(k))));
169
+ g.stroke();
170
+ }
171
+ g.globalAlpha = 1;
172
+ // rule breaks: a cross where and when each happens
173
+ g.strokeStyle = STATUS.critical;
174
+ g.lineWidth = 2;
175
+ for (const v of this.R.violations) {
176
+ if (!v.time || !v.where) continue;
177
+ const [hh, mm] = v.time.split(":").map(Number);
178
+ let t = hh * 60 + mm;
179
+ if (t < this.R.start - 120) t += 1440;
180
+ const ends = v.where.split("-").filter((c) => this.km[c] !== undefined);
181
+ if (!ends.length) continue;
182
+ const k = ends.reduce((a, c) => a + this.km[c], 0) / ends.length;
183
+ const x = f.x(t), y = f.y(k);
184
+ g.beginPath(); g.moveTo(x - 4, y - 4); g.lineTo(x + 4, y + 4); g.moveTo(x + 4, y - 4); g.lineTo(x - 4, y + 4); g.stroke();
185
+ }
186
+ // events strip
187
+ const sy = T + f.gh + 4;
188
+ g.textAlign = "center";
189
+ g.textBaseline = "top";
190
+ g.font = "bold 11px system-ui, -apple-system, 'Segoe UI', sans-serif";
191
+ for (const ev of this.R.events) {
192
+ if (ev.t === null || ev.t === undefined) continue;
193
+ const x = f.x(ev.t);
194
+ g.fillStyle = ev.kind === "violation" || ev.kind === "closure" ? STATUS.critical : ev.kind === "reopen" ? STATUS.good : INK.secondary;
195
+ g.fillText(GLYPH[ev.kind] || "·", x, sy);
196
+ }
197
+ // the cursor
198
+ const x = f.x(this.t);
199
+ g.strokeStyle = INK.primary;
200
+ g.lineWidth = 1.5;
201
+ g.beginPath(); g.moveTo(x, T); g.lineTo(x, T + f.gh + strip); g.stroke();
202
+ }
203
+
204
+ setTime(t) {
205
+ this.t = t;
206
+ this.draw();
207
+ }
208
+ }
209
+
210
+ export class CostChart {
211
+ constructor(canvas, tip, onSeek) {
212
+ this.c = canvas;
213
+ this.tip = tip;
214
+ this.onSeek = onSeek;
215
+ this.t = 0;
216
+ canvas.addEventListener("pointermove", (e) => this.hoverAt(e));
217
+ canvas.addEventListener("pointerleave", () => { this.tip.hidden = true; });
218
+ canvas.addEventListener("pointerdown", (e) => {
219
+ const f = this.frame(), r = canvas.getBoundingClientRect();
220
+ this.onSeek(Math.max(this.t0, Math.min(this.t1, f.tx(e.clientX - r.left))));
221
+ });
222
+ }
223
+
224
+ // series: [{name, costs: [[t, amount], ...], color, dashed}]
225
+ set(series, t0, t1) {
226
+ this.series = series.map((s) => {
227
+ let sum = 0;
228
+ const steps = [[t0, 0]];
229
+ for (const [t, a] of s.costs) { sum += a; steps.push([t, sum]); }
230
+ return { ...s, steps, total: sum };
231
+ });
232
+ this.t0 = t0;
233
+ this.t1 = t1;
234
+ this.max = Math.max(10, ...this.series.map((s) => s.total)) * 1.08;
235
+ this.draw();
236
+ }
237
+
238
+ frame() {
239
+ const [g, w, h] = fit(this.c);
240
+ const L = 44, Rp = 92, T = 22, B = 22;
241
+ return { g, w, h, L, Rp, T, B,
242
+ x: (t) => L + (t - this.t0) / (this.t1 - this.t0) * (w - L - Rp),
243
+ tx: (x) => this.t0 + (x - L) / (w - L - Rp) * (this.t1 - this.t0),
244
+ y: (v) => h - B - v / this.max * (h - T - B) };
245
+ }
246
+
247
+ valueAt(s, t) {
248
+ let v = 0;
249
+ for (const [tt, sum] of s.steps) { if (tt > t) break; v = sum; }
250
+ return v;
251
+ }
252
+
253
+ hoverAt(e) {
254
+ if (!this.series) return;
255
+ const f = this.frame(), r = this.c.getBoundingClientRect();
256
+ const t = Math.max(this.t0, Math.min(this.t1, f.tx(e.clientX - r.left)));
257
+ this.tip.innerHTML = `${hhmm(t)}<br>` + this.series.map((s) => `${s.name}: <b>${this.valueAt(s, t).toLocaleString()}</b>`).join("<br>");
258
+ this.tip.style.left = `${Math.min(r.width - 150, e.clientX - r.left + 12)}px`;
259
+ this.tip.style.top = "6px";
260
+ this.tip.hidden = false;
261
+ }
262
+
263
+ draw() {
264
+ if (!this.series) return;
265
+ const f = this.frame();
266
+ const { g, w, h, L, Rp, T, B } = f;
267
+ g.clearRect(0, 0, w, h);
268
+ g.font = "11px system-ui, -apple-system, 'Segoe UI', sans-serif";
269
+ g.fillStyle = INK.secondary;
270
+ g.textAlign = "left";
271
+ g.textBaseline = "top";
272
+ g.fillText("Cost so far (stop-minutes)", L, 4);
273
+ // y grid: three hairlines
274
+ g.textAlign = "right";
275
+ g.textBaseline = "middle";
276
+ for (let i = 0; i <= 3; i++) {
277
+ const v = (this.max / 1.08) * i / 3;
278
+ const y = Math.round(f.y(v)) + 0.5;
279
+ g.strokeStyle = i ? INK.grid : INK.axis;
280
+ g.beginPath(); g.moveTo(L, y); g.lineTo(w - Rp, y); g.stroke();
281
+ g.fillStyle = INK.muted;
282
+ g.fillText(Math.round(v).toLocaleString(), L - 6, y);
283
+ }
284
+ // the series as step lines, with the total labelled at the end
285
+ for (const s of this.series) {
286
+ g.strokeStyle = s.color;
287
+ g.lineWidth = 2;
288
+ g.setLineDash(s.dashed ? [5, 4] : []);
289
+ g.beginPath();
290
+ let prev = null;
291
+ for (const [t, v] of s.steps) {
292
+ const x = f.x(Math.min(this.t1, t)), y = f.y(v);
293
+ if (prev === null) g.moveTo(x, y); else { g.lineTo(x, prev); g.lineTo(x, y); }
294
+ prev = y;
295
+ }
296
+ g.lineTo(f.x(this.t1), prev);
297
+ g.stroke();
298
+ g.setLineDash([]);
299
+ g.fillStyle = INK.primary;
300
+ g.textAlign = "left";
301
+ g.fillText(`${s.name} ${s.total.toLocaleString()}`, w - Rp + 6, Math.max(T, Math.min(h - B, f.y(s.total))));
302
+ }
303
+ const x = f.x(this.t);
304
+ g.strokeStyle = INK.primary;
305
+ g.lineWidth = 1.5;
306
+ g.beginPath(); g.moveTo(x, T); g.lineTo(x, h - B); g.stroke();
307
+ }
308
+
309
+ setTime(t) {
310
+ this.t = t;
311
+ this.draw();
312
+ }
313
+ }
openenv/rail_openenv/web/twin/index.html ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>RailSimEnv line twin</title>
7
+ <link rel="icon" href="data:,">
8
+ <link rel="stylesheet" href="style.css">
9
+ <script type="importmap">
10
+ {
11
+ "imports": {
12
+ "three": "https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js",
13
+ "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/"
14
+ }
15
+ }
16
+ </script>
17
+ </head>
18
+ <body>
19
+ <div id="stage"></div>
20
+
21
+ <header id="bar">
22
+ <div class="brand"><b>RailSimEnv</b><span>Leeuwarden – Groningen</span></div>
23
+ <label class="pick"><span>Task</span><select id="task" aria-label="Task"></select></label>
24
+ <label class="pick"><span>Plan</span><select id="plan" aria-label="Plan"></select></label>
25
+ <div class="transport">
26
+ <button id="restart" title="Back to the start (Home)" aria-label="Back to the start">⏮</button>
27
+ <button id="play" title="Play / pause (Space)" aria-label="Play">▶</button>
28
+ <select id="speed" title="Playback speed" aria-label="Playback speed">
29
+ <option value="1">1×</option><option value="10">10×</option><option value="30">30×</option>
30
+ <option value="60" selected>60×</option><option value="120">120×</option><option value="300">300×</option>
31
+ </select>
32
+ <div id="clock" aria-live="off"><b>--:--</b><span></span></div>
33
+ </div>
34
+ <div class="cams" role="group" aria-label="Camera">
35
+ <button id="cam-map" class="on" title="The whole line (M)">Line</button>
36
+ <button id="cam-follow" title="Follow the selected train (F)">Follow</button>
37
+ <button id="cam-cab" title="Driver's view of the selected train (C)">Cab</button>
38
+ <select id="station" aria-label="Go to a station"><option value="">Station…</option></select>
39
+ </div>
40
+ <button id="panels" title="Hide or show the panels (H)">Panels</button>
41
+ <a class="graphlink" id="graphlink" href="/viewer/" title="This plan on the train graph">Train graph ↗</a>
42
+ <a class="graphlink" href="/explorer/" title="Every model's plan on every eval task">Eval explorer ↗</a>
43
+ </header>
44
+
45
+ <aside id="left" class="panel">
46
+ <section id="scenario"></section>
47
+ <section id="result"></section>
48
+ <section class="legend">
49
+ <div><i class="chip" style="background:#3987e5"></i>Stopping train <i class="chip" style="background:#d95926"></i>Fast train</div>
50
+ <div><i class="bar closed"></i>Closed <i class="bar slow"></i>Slow <i class="bar track"></i>Track out of use</div>
51
+ <div><i class="bar single"></i>Single track <i class="bar double"></i>Double track (from far away)</div>
52
+ <div class="muted">Signals guard each single-track section: red while a train is in it or it is closed.</div>
53
+ </section>
54
+ </aside>
55
+
56
+ <aside id="right" class="panel">
57
+ <h2>Trains <span id="ntrains" class="muted"></span></h2>
58
+ <ol id="trains"></ol>
59
+ <h3 id="cxl-h" hidden>Cancelled</h3>
60
+ <ol id="cancelled"></ol>
61
+ </aside>
62
+
63
+ <footer id="bottom" class="panel">
64
+ <div class="graphwrap">
65
+ <div class="chart-h"><b>Train graph</b><span class="muted">time across, distance down · click to jump, hover to read</span></div>
66
+ <canvas id="graph"></canvas>
67
+ <div id="gtip" class="tip" hidden></div>
68
+ </div>
69
+ <div class="costwrap">
70
+ <canvas id="cost"></canvas>
71
+ <div id="ctip" class="tip" hidden></div>
72
+ </div>
73
+ </footer>
74
+
75
+ <div id="toasts" aria-live="polite"></div>
76
+ <div id="loading"><div><b>Building the line</b><p id="loadmsg">OpenStreetMap: track, stations, buildings…</p></div></div>
77
+ <noscript>The line twin needs JavaScript and WebGL.</noscript>
78
+ <script type="module" src="app.js"></script>
79
+ </body>
80
+ </html>
openenv/rail_openenv/web/twin/motion.js ADDED
@@ -0,0 +1,398 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Trains in time: where every train set is at any moment under a plan, from the replay's simulated times
2
+ // (rail_core.replay, which uses the grader's own simulate). Nothing here changes a time: positions between the
3
+ // simulated minutes are a smooth monotone curve (stopped trains have zero speed, passing trains keep theirs).
4
+ //
5
+ // A train set's window is a chain of segments: it stands (at a station, a turn point, a terminal), moves (one run, or
6
+ // the part of it the plan keeps), stands again. Track allocation is for the picture only:
7
+ // - terminals: the record's usual tracks (Leeuwarden 6b/8, Groningen 5a/6a by train type), a second train on the
8
+ // same track stands behind the first;
9
+ // - two-track stations: each direction's own platform (as in the record); a train that finds it taken, or a turned
10
+ // train, uses the other track.
11
+ //
12
+ // No DOM and no three.js here (node-testable). Times are minutes after midnight of the service day; km as the network.
13
+
14
+ export const SLOT_KM = 0.065; // a second train on a terminal track stands this far behind the first
15
+ export const TRAIN_M = 55; // a two-car unit, metres
16
+ const PAD = 0.5; // minutes of margin when sharing a station track
17
+
18
+ export function lwTrack(run, arriving) {
19
+ // record (Jan-Jun 2025): westbound arrivals fast -> 8, stopping -> 6b; eastbound departures fast -> 6b, stop -> 8
20
+ if (arriving) return run.kind === "fast" ? "8" : "6b";
21
+ return run.kind === "fast" ? "6b" : "8";
22
+ }
23
+
24
+ export function gnTrack(run) {
25
+ return run.kind === "fast" ? "6a" : "5a"; // record: fast trains mostly 6a, stopping trains 5a
26
+ }
27
+
28
+ export function hhmm(m) {
29
+ if (m === null || m === undefined || !Number.isFinite(m)) return "--:--";
30
+ const t = Math.floor(m + 1e-9);
31
+ return `${String(Math.floor(t / 60) % 24).padStart(2, "0")}:${String(t % 60).padStart(2, "0")}`;
32
+ }
33
+
34
+ // --- monotone cubic Hermite through (t, x) knots; stop knots have zero slope ---------------------------------
35
+
36
+ export function hermite(knots) {
37
+ const n = knots.length;
38
+ const t = knots.map((k) => k.t), x = knots.map((k) => k.x), m = new Array(n).fill(0);
39
+ for (let i = 0; i < n; i++) {
40
+ if (knots[i].stop) continue;
41
+ const d0 = i > 0 ? (x[i] - x[i - 1]) / (t[i] - t[i - 1]) : null;
42
+ const d1 = i < n - 1 ? (x[i + 1] - x[i]) / (t[i + 1] - t[i]) : null;
43
+ if (d0 === null) m[i] = d1;
44
+ else if (d1 === null) m[i] = d0;
45
+ else if (d0 * d1 <= 0) m[i] = 0;
46
+ else {
47
+ const h0 = t[i] - t[i - 1], h1 = t[i + 1] - t[i];
48
+ const w1 = 2 * h1 + h0, w2 = h1 + 2 * h0;
49
+ m[i] = (w1 + w2) / (w1 / d0 + w2 / d1);
50
+ }
51
+ }
52
+ return { t, x, m };
53
+ }
54
+
55
+ export function evalHermite(H, tq) {
56
+ const { t, x, m } = H;
57
+ const n = t.length;
58
+ if (n === 1 || tq <= t[0]) return [x[0], 0];
59
+ if (tq >= t[n - 1]) return [x[n - 1], 0];
60
+ let lo = 0, hi = n - 1;
61
+ while (hi - lo > 1) {
62
+ const mid = (lo + hi) >> 1;
63
+ if (t[mid] <= tq) lo = mid; else hi = mid;
64
+ }
65
+ const h = t[hi] - t[lo];
66
+ const u = (tq - t[lo]) / h, u2 = u * u, u3 = u2 * u;
67
+ const pos = (2 * u3 - 3 * u2 + 1) * x[lo] + (u3 - 2 * u2 + u) * h * m[lo] + (-2 * u3 + 3 * u2) * x[hi]
68
+ + (u3 - u2) * h * m[hi];
69
+ const vel = ((6 * u2 - 6 * u) * x[lo] + (3 * u2 - 4 * u + 1) * h * m[lo] + (-6 * u2 + 6 * u) * x[hi]
70
+ + (3 * u2 - 2 * u) * h * m[hi]) / h;
71
+ return [pos, vel];
72
+ }
73
+
74
+ // --- the simulation picture ------------------------------------------------------------------------------------
75
+
76
+ export function buildSim(R) {
77
+ const kmOf = Object.fromEntries(R.points.map((p) => [p.code, p.km]));
78
+ const point = Object.fromEntries(R.points.map((p) => [p.code, p]));
79
+ const codes = R.points.map((p) => p.code);
80
+ const west = codes[0], east = codes[codes.length - 1];
81
+ const runOf = new Map(R.runs.map((r) => [r.train, r]));
82
+ const units = [];
83
+
84
+ for (const u of R.units) {
85
+ const runs = u.runs.map((id) => runOf.get(id)).filter(Boolean);
86
+ const segs = [];
87
+ let at = u.at, since = R.start, prev = null;
88
+ for (const r of runs) {
89
+ if (r.cancelled) continue;
90
+ const s = r.first, e = r.last;
91
+ const k = r.started ? r.fixed_to : s;
92
+ let t0;
93
+ if (r.started && r.fixed_from !== null && r.fixed_from !== undefined) {
94
+ t0 = r.fixed_from; // already running when the window starts
95
+ } else {
96
+ const where = r.route[k];
97
+ const dep = r.dep[k];
98
+ // an illegal plan can start a run where its train set is not (a continuity break): the set waits where it
99
+ // is and jumps when the run leaves, as the grader's simulation has it
100
+ const standAt = prev && at && at !== where ? at : where;
101
+ segs.push({ type: "stand", code: standAt, t0: Math.min(since, dep), t1: dep, prev, next: r });
102
+ t0 = dep;
103
+ }
104
+ segs.push({ type: "move", run: r, dir: r.dir, t0, t1: r.arr[e], k, s, e, lanes: {} });
105
+ at = r.route[e];
106
+ since = r.arr[e];
107
+ prev = r;
108
+ }
109
+ if (at) segs.push({ type: "stand", code: at, t0: since, t1: Infinity, prev, next: null });
110
+ units.push({ id: u.id, runs, segs });
111
+ }
112
+
113
+ // terminals: which track, and which slot on it
114
+ const slotUse = {};
115
+ const standsAtTerminals = [];
116
+ for (const un of units) {
117
+ un.segs.forEach((sg, i) => {
118
+ if (sg.type !== "stand" || (sg.code !== west && sg.code !== east)) return;
119
+ let track;
120
+ if (sg.code === west) track = sg.prev && sg.prev.dir === "W" ? lwTrack(sg.prev, true) : sg.next ? lwTrack(sg.next, false) : "8";
121
+ else track = sg.prev && sg.prev.dir === "E" ? gnTrack(sg.prev) : sg.next ? gnTrack(sg.next) : "5a";
122
+ sg.track = track;
123
+ standsAtTerminals.push([sg, i, un]);
124
+ });
125
+ }
126
+ standsAtTerminals.sort((a, b) => a[0].t0 - b[0].t0);
127
+ for (const [sg] of standsAtTerminals) {
128
+ const key = `${sg.code}:${sg.track}`;
129
+ const used = (slotUse[key] ||= []);
130
+ let slot = 0;
131
+ while (used.some(([k, a, b]) => k === slot && a < sg.t1 && sg.t0 < b)) slot++;
132
+ used.push([slot, sg.t0, sg.t1]);
133
+ sg.slot = slot;
134
+ sg.km = kmOf[sg.code] + (sg.code === west ? 1 : -1) * slot * SLOT_KM;
135
+ }
136
+ // moves inherit the terminal tracks of the stands around them
137
+ for (const un of units) {
138
+ un.segs.forEach((sg, i) => {
139
+ if (sg.type !== "move") return;
140
+ const r = sg.run, before = un.segs[i - 1], after = un.segs[i + 1];
141
+ sg.lw = r.dir === "E" ? lwTrack(r, false) : lwTrack(r, true);
142
+ sg.gn = gnTrack(r);
143
+ sg.startKm = null; sg.endKm = null;
144
+ if (before && before.type === "stand" && before.track) {
145
+ if (before.code === west) sg.lw = before.track; else sg.gn = before.track;
146
+ sg.startKm = before.km;
147
+ }
148
+ if (after && after.type === "stand" && after.track) {
149
+ if (after.code === west) sg.lw = after.track; else sg.gn = after.track;
150
+ sg.endKm = after.km;
151
+ }
152
+ });
153
+ for (const sg of un.segs) {
154
+ if (sg.type === "stand" && sg.km === undefined) sg.km = kmOf[sg.code];
155
+ }
156
+ }
157
+
158
+ // two-track stations: each direction's platform, or the other one when it is taken
159
+ const occ = {}; // code -> [{t0, t1, prefer, apply(lane)}]
160
+ const twoTrack = (c) => point[c] && point[c].tracks === 2 && !point[c].directional;
161
+ for (const un of units) {
162
+ un.segs.forEach((sg, i) => {
163
+ if (sg.type === "stand" && twoTrack(sg.code)) {
164
+ const prefer = sg.prev ? sg.prev.dir : sg.next ? sg.next.dir : "E";
165
+ const before = un.segs[i - 1], after = un.segs[i + 1];
166
+ (occ[sg.code] ||= []).push({
167
+ t0: sg.t0, t1: Math.min(sg.t1, sg.t0 + 24 * 60), prefer,
168
+ apply: (lane) => {
169
+ sg.lane = lane;
170
+ if (before && before.type === "move") before.lanes[sg.code] = lane;
171
+ if (after && after.type === "move") after.lanes[sg.code] = lane;
172
+ },
173
+ });
174
+ } else if (sg.type === "move") {
175
+ const r = sg.run;
176
+ const moving = r.started && r.fixed_from !== null && r.fixed_from !== undefined;
177
+ const from = moving ? sg.k - 1 : r.started ? sg.k : sg.s; // the first point it leaves in the window
178
+ for (let j = from + 1; j < sg.e; j++) {
179
+ const c = r.route[j];
180
+ if (!twoTrack(c) || r.arr[j] === null) continue;
181
+ (occ[c] ||= []).push({ t0: r.arr[j], t1: r.dep[j] ?? r.arr[j], prefer: r.dir,
182
+ apply: (lane) => { sg.lanes[c] = lane; } });
183
+ }
184
+ }
185
+ });
186
+ }
187
+ for (const list of Object.values(occ)) {
188
+ list.sort((a, b) => a.t0 - b.t0);
189
+ const taken = { E: [], W: [] };
190
+ for (const o of list) {
191
+ const free = (lane) => !taken[lane].some(([a, b]) => a - PAD < o.t1 && o.t0 < b + PAD);
192
+ const lane = free(o.prefer) ? o.prefer : free(o.prefer === "E" ? "W" : "E") ? (o.prefer === "E" ? "W" : "E")
193
+ : o.prefer;
194
+ taken[lane].push([o.t0, o.t1]);
195
+ o.apply(lane);
196
+ }
197
+ }
198
+
199
+ // knots for every move
200
+ for (const un of units) {
201
+ for (const sg of un.segs) {
202
+ if (sg.type !== "move") continue;
203
+ const r = sg.run, kn = [];
204
+ const push = (t, x, stop) => {
205
+ if (t === null || t === undefined) return;
206
+ const last = kn[kn.length - 1];
207
+ if (last && t <= last.t + 1e-9) {
208
+ if (Math.abs(x - last.x) < 1e-9) { last.stop = last.stop || stop; return; }
209
+ t = last.t + 0.05; // never two places at one instant
210
+ }
211
+ kn.push({ t, x, stop });
212
+ };
213
+ const kmAt = (j) => {
214
+ if (j === sg.e && sg.endKm !== null) return sg.endKm;
215
+ if (j === (r.started ? sg.k : sg.s) && !r.started && sg.startKm !== null) return sg.startKm;
216
+ return kmOf[r.route[j]];
217
+ };
218
+ if (r.started && r.fixed_from !== null && r.fixed_from !== undefined) {
219
+ push(r.fixed_from, kmOf[r.route[sg.k - 1]], !!r.stops[sg.k - 1]);
220
+ }
221
+ const first = r.started ? sg.k : sg.s;
222
+ for (let j = first; j <= sg.e; j++) {
223
+ const x = kmAt(j);
224
+ if (j === first && !(r.started && r.fixed_from !== null && r.fixed_from !== undefined)) {
225
+ if (r.started) push(r.arr[j], x, true);
226
+ push(r.dep[j], x, true);
227
+ } else if (j === sg.e) {
228
+ push(r.arr[j], x, true);
229
+ } else if (r.stopped[j]) {
230
+ push(r.arr[j], x, true);
231
+ push(r.dep[j], x, true);
232
+ } else {
233
+ push(r.arr[j], x, false);
234
+ }
235
+ }
236
+ sg.H = hermite(kn);
237
+ sg.knots = kn;
238
+ sg.t0 = kn[0].t;
239
+ sg.t1 = kn[kn.length - 1].t;
240
+ }
241
+ // keep stands contiguous with the moves around them
242
+ un.segs.forEach((sg, i) => {
243
+ if (sg.type !== "stand") return;
244
+ const before = un.segs[i - 1], after = un.segs[i + 1];
245
+ if (before && before.type === "move") sg.t0 = before.t1;
246
+ if (after && after.type === "move") sg.t1 = after.t0;
247
+ });
248
+ }
249
+
250
+ const tEnd = Math.max(R.end, ...units.flatMap((u) => u.segs.filter((s) => s.type === "move").map((s) => s.t1)));
251
+ return new Sim(R, units, kmOf, point, tEnd);
252
+ }
253
+
254
+ export class Sim {
255
+ constructor(R, units, kmOf, point, tEnd) {
256
+ this.R = R;
257
+ this.units = units;
258
+ this.kmOf = kmOf;
259
+ this.point = point;
260
+ this.t0 = R.start;
261
+ this.t1 = tEnd;
262
+ this.codes = R.points.map((p) => p.code);
263
+ }
264
+
265
+ segAt(un, t) {
266
+ const segs = un.segs;
267
+ if (!segs.length) return null;
268
+ let i = 0;
269
+ while (i < segs.length - 1 && t >= segs[i].t1) i++;
270
+ return segs[i];
271
+ }
272
+
273
+ // where a train set is and what it is doing at minute t
274
+ stateAt(un, t) {
275
+ const sg = this.segAt(un, t);
276
+ if (!sg) return null;
277
+ if (sg.type === "stand") {
278
+ const facing = sg.prev ? sg.prev.dir : sg.next ? sg.next.dir : "E";
279
+ const ref = sg.prev || sg.next;
280
+ return {
281
+ unit: un.id, km: sg.km, dir: facing, moving: false, speed: 0, code: sg.code,
282
+ lw: sg.code === this.codes[0] && sg.track ? sg.track : ref ? lwTrack(ref, facing === "W") : "8",
283
+ gn: sg.code === this.codes[this.codes.length - 1] && sg.track ? sg.track : ref ? gnTrack(ref) : "5a",
284
+ lanes: sg.lane ? { [sg.code]: sg.lane } : null, train: sg.next ? sg.next.train : sg.prev ? sg.prev.train : "",
285
+ kind: ref ? ref.kind : "stop", status: this.standStatus(sg, t), delay: this.standDelay(sg),
286
+ run: sg.next || sg.prev,
287
+ };
288
+ }
289
+ const [km, v] = evalHermite(sg.H, t);
290
+ return {
291
+ unit: un.id, km, dir: sg.dir, moving: Math.abs(v) > 1e-4, speed: Math.abs(v) * 60, code: this.atPoint(sg, t),
292
+ lw: sg.lw, gn: sg.gn, lanes: Object.keys(sg.lanes).length ? sg.lanes : null, train: sg.run.train,
293
+ kind: sg.run.kind, status: this.moveStatus(sg, t, km), delay: this.moveDelay(sg, t), run: sg.run,
294
+ };
295
+ }
296
+
297
+ atPoint(sg, t) {
298
+ const kn = sg.knots;
299
+ for (let i = 0; i < kn.length - 1; i++) {
300
+ if (kn[i].stop && kn[i + 1].stop && Math.abs(kn[i].x - kn[i + 1].x) < 1e-9 && kn[i].t <= t && t <= kn[i + 1].t) {
301
+ return this.codeAtKm(kn[i].x);
302
+ }
303
+ }
304
+ return null;
305
+ }
306
+
307
+ codeAtKm(km) {
308
+ let best = null, d = Infinity;
309
+ for (const [c, k] of Object.entries(this.kmOf)) {
310
+ if (Math.abs(k - km) < d) { d = Math.abs(k - km); best = c; }
311
+ }
312
+ return d < 0.3 ? best : null;
313
+ }
314
+
315
+ moveDelay(sg, t) {
316
+ const r = sg.run;
317
+ let late = 0;
318
+ for (let j = 0; j < r.route.length; j++) {
319
+ const a = r.arr[j] ?? r.dep[j];
320
+ const sch = r.sched_arr[j] ?? r.sched_dep[j];
321
+ if (a === null || a === undefined || sch === null || sch === undefined) continue;
322
+ if (a <= t) late = a - sch;
323
+ }
324
+ return Math.max(0, late);
325
+ }
326
+
327
+ standDelay(sg) {
328
+ const r = sg.next;
329
+ if (!r) return 0;
330
+ const j = r.started ? r.fixed_to : r.first;
331
+ return Math.max(0, (r.dep[j] ?? 0) - (r.sched_dep[j] ?? r.dep[j] ?? 0));
332
+ }
333
+
334
+ moveStatus(sg, t, km) {
335
+ const r = sg.run;
336
+ const code = this.atPoint(sg, t);
337
+ if (code) {
338
+ const j = r.route.indexOf(code);
339
+ const hold = r.order && r.order.hold && r.order.hold[code];
340
+ const dep = r.dep[j];
341
+ if (hold && dep !== null && dep > (r.sched_dep[j] ?? dep)) return `held at ${code} until ${hold} (plan)`;
342
+ if (r.stops[j]) return `at ${code}, leaves ${hhmm(dep)}`;
343
+ return `waiting at ${code}, leaves ${hhmm(dep)}`;
344
+ }
345
+ let j = 0;
346
+ while (j < r.route.length - 1 && (r.arr[j + 1] === null || r.arr[j + 1] <= t)) j++;
347
+ const nextStop = r.route.slice(j + 1, sg.e + 1).find((c, i) => r.stops[j + 1 + i]) || r.route[sg.e];
348
+ const to = r.route[sg.e];
349
+ return `to ${to}${to !== r.route[r.route.length - 1] ? " (turned short)" : ""}, next ${nextStop}`;
350
+ }
351
+
352
+ standStatus(sg, t) {
353
+ if (!sg.next) return `at ${sg.code}: done for this window`;
354
+ const r = sg.next;
355
+ const j = r.started ? r.fixed_to : r.first;
356
+ const hold = r.order && r.order.hold && r.order.hold[sg.code];
357
+ if (hold && r.dep[j] > (r.sched_dep[j] ?? r.dep[j])) return `held at ${sg.code} until ${hold} (plan), then ${r.train}`;
358
+ if (sg.prev && sg.prev.last < sg.prev.route.length - 1 && sg.prev.route[sg.prev.last] === sg.code) {
359
+ return `turned back at ${sg.code}; runs ${r.train} at ${hhmm(r.dep[j])}`;
360
+ }
361
+ return `at ${sg.code}; runs ${r.train} at ${hhmm(r.dep[j])}`;
362
+ }
363
+
364
+ // every train set's state at t (those with a window)
365
+ statesAt(t) {
366
+ return this.units.map((u) => this.stateAt(u, t)).filter(Boolean);
367
+ }
368
+
369
+ // cancelled runs (shown in the list, never on the map)
370
+ cancelled() {
371
+ return this.R.runs.filter((r) => r.cancelled);
372
+ }
373
+
374
+ // which sections hold a train between their end points at t: index -> [unit ids]
375
+ occupancy(states) {
376
+ const occ = {};
377
+ const pts = this.R.points;
378
+ for (const s of states) {
379
+ for (let i = 0; i < pts.length - 1; i++) {
380
+ if (s.km > pts[i].km + 0.03 && s.km < pts[i + 1].km - 0.03) {
381
+ (occ[i] ||= []).push(s.unit);
382
+ break;
383
+ }
384
+ }
385
+ }
386
+ return occ;
387
+ }
388
+
389
+ // the cost accrued by minute t (the replay's cost events, which add up to the grader's cost)
390
+ costAt(t) {
391
+ let sum = 0;
392
+ for (const c of this.R.costs) {
393
+ if (c[0] > t) break;
394
+ sum += c[1];
395
+ }
396
+ return sum;
397
+ }
398
+ }
openenv/rail_openenv/web/twin/scene.js ADDED
@@ -0,0 +1,765 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // The live part of the line twin: sky and sun for the task's date and time, the trains, the signals that guard each
2
+ // single-track section, the level crossings, the disruptions, labels, and the cameras. The scenery is world.js.
3
+ //
4
+ // It only draws: positions come from motion.js (the simulated times), places from geo.js (the OSM track).
5
+
6
+ import * as THREE from "three";
7
+ import { MapControls } from "three/addons/controls/MapControls.js";
8
+ import { Line2 } from "three/addons/lines/Line2.js";
9
+ import { LineGeometry } from "three/addons/lines/LineGeometry.js";
10
+ import { LineMaterial } from "three/addons/lines/LineMaterial.js";
11
+ import { CSS2DObject, CSS2DRenderer } from "three/addons/renderers/CSS2DRenderer.js";
12
+
13
+ import { CANONICAL } from "./geo.js";
14
+ import { animateWorld, buildWorld } from "./world.js";
15
+
16
+ // series colours, validated for the dark interface (dataviz reference palette, dark slots 1 and 2)
17
+ export const KIND_COLOR = { stop: "#3987e5", fast: "#d95926" };
18
+ export const STATUS = { critical: "#d03b3b", warning: "#fab219", serious: "#ec835a", good: "#0ca30c" };
19
+ const CAR = 26.5, GAP = 1.0, BOGIE = 9.5;
20
+ const LAT = 53.22, LON = 6.19;
21
+
22
+ // --- sun ---------------------------------------------------------------------------------------------------------
23
+
24
+ function amsterdamOffset(day) {
25
+ // CET (UTC+1), CEST (UTC+2) from the last Sunday of March to the last Sunday of October
26
+ const [y, m, d] = day.split("-").map(Number);
27
+ const lastSunday = (month) => { const t = new Date(Date.UTC(y, month, 0)); return t.getUTCDate() - t.getUTCDay(); };
28
+ const date = Date.UTC(y, m - 1, d);
29
+ const start = Date.UTC(y, 2, lastSunday(3)), end = Date.UTC(y, 9, lastSunday(10));
30
+ return date >= start && date < end ? 2 : 1;
31
+ }
32
+
33
+ export function sunPosition(day, minute) {
34
+ // NOAA's simplified solar position: elevation and azimuth (from north, clockwise), degrees
35
+ const [y, m, d] = day.split("-").map(Number);
36
+ const n = Math.round((Date.UTC(y, m - 1, d) - Date.UTC(y, 0, 0)) / 864e5);
37
+ const utcMin = minute - amsterdamOffset(day) * 60;
38
+ const g = (2 * Math.PI / 365) * (n - 1 + (utcMin / 60 - 12) / 24);
39
+ const eq = 229.18 * (0.000075 + 0.001868 * Math.cos(g) - 0.032077 * Math.sin(g) - 0.014615 * Math.cos(2 * g)
40
+ - 0.040849 * Math.sin(2 * g));
41
+ const decl = 0.006918 - 0.399912 * Math.cos(g) + 0.070257 * Math.sin(g) - 0.006758 * Math.cos(2 * g)
42
+ + 0.000907 * Math.sin(2 * g) - 0.002697 * Math.cos(3 * g) + 0.00148 * Math.sin(3 * g);
43
+ const tst = utcMin + eq + 4 * LON;
44
+ const ha = (tst / 4 - 180) * Math.PI / 180;
45
+ const lat = LAT * Math.PI / 180;
46
+ const cosZ = Math.sin(lat) * Math.sin(decl) + Math.cos(lat) * Math.cos(decl) * Math.cos(ha);
47
+ const zen = Math.acos(Math.max(-1, Math.min(1, cosZ)));
48
+ let az = Math.atan2(Math.sin(ha), Math.cos(ha) * Math.sin(lat) - Math.tan(decl) * Math.cos(lat)) + Math.PI;
49
+ az = (az + 2 * Math.PI) % (2 * Math.PI);
50
+ return { elevation: 90 - zen * 180 / Math.PI, azimuth: az * 180 / Math.PI };
51
+ }
52
+
53
+ // --- trains --------------------------------------------------------------------------------------------------
54
+
55
+ function carGeometries() {
56
+ const s = new THREE.Shape();
57
+ s.moveTo(-1.42, 1.15); s.lineTo(1.42, 1.15); s.lineTo(1.42, 3.25);
58
+ s.quadraticCurveTo(1.4, 3.92, 0.85, 3.98); s.lineTo(-0.85, 3.98); s.quadraticCurveTo(-1.4, 3.92, -1.42, 3.25);
59
+ s.closePath();
60
+ const body = new THREE.ExtrudeGeometry(s, { depth: CAR - 2.2, bevelEnabled: false });
61
+ body.rotateY(Math.PI / 2);
62
+ body.translate(-(CAR - 2.2) / 2 - 1.1, 0, 0); // spans -CAR/2 .. CAR/2 - 2.2 (the nose adds 2.2)
63
+ const side = new THREE.Shape();
64
+ side.moveTo(0, 1.15); side.lineTo(0, 3.98); side.lineTo(0.7, 3.92); side.lineTo(1.75, 2.35); side.lineTo(2.2, 1.6);
65
+ side.lineTo(2.2, 1.15); side.closePath();
66
+ const nose = new THREE.ExtrudeGeometry(side, { depth: 2.84, bevelEnabled: false });
67
+ nose.translate(CAR / 2 - 2.2, 0, -1.42);
68
+ const screen = new THREE.PlaneGeometry(1.95, 2.4);
69
+ screen.rotateY(Math.PI / 2);
70
+ screen.rotateZ(-0.98);
71
+ screen.translate(CAR / 2 - 2.2 + 1.24, 3.12, 0);
72
+ const band = new THREE.BoxGeometry(CAR - 4.4, 0.8, 2.88);
73
+ band.translate(-1.1, 2.6, 0);
74
+ const stripe = new THREE.BoxGeometry(CAR - 2.6, 0.26, 2.87);
75
+ stripe.translate(-1.0, 1.62, 0);
76
+ const bogie = new THREE.BoxGeometry(2.7, 0.75, 2.2);
77
+ const lamp = new THREE.CircleGeometry(0.16, 10);
78
+ lamp.rotateY(Math.PI / 2);
79
+ return { body, nose, screen, band, stripe, bogie, lamp };
80
+ }
81
+
82
+ class TrainMesh {
83
+ constructor(shared, kind) {
84
+ const { geo, mats } = shared;
85
+ this.group = new THREE.Group();
86
+ this.cars = [];
87
+ for (let c = 0; c < 2; c++) {
88
+ const car = new THREE.Group();
89
+ const inner = new THREE.Group(); // car 1 is car 0 turned round: both ends are cabs
90
+ if (c === 1) inner.rotation.y = Math.PI;
91
+ for (const [g, m] of [[geo.body, mats.body], [geo.nose, mats.body], [geo.screen, mats.glass],
92
+ [geo.band, mats.glass], [geo.stripe, mats.stripe[kind]]]) {
93
+ const mesh = new THREE.Mesh(g, m);
94
+ mesh.castShadow = true;
95
+ inner.add(mesh);
96
+ }
97
+ for (const x of [-BOGIE, BOGIE]) {
98
+ const b = new THREE.Mesh(geo.bogie, mats.bogie);
99
+ b.position.set(x, 0.62, 0);
100
+ inner.add(b);
101
+ }
102
+ const lamps = [];
103
+ for (const z of [-0.95, 0.95]) {
104
+ const l = new THREE.Mesh(geo.lamp, mats.head);
105
+ l.position.set(CAR / 2 + 0.01, 1.55, z);
106
+ inner.add(l);
107
+ lamps.push(l);
108
+ }
109
+ car.add(inner);
110
+ car.userData.lamps = lamps;
111
+ this.group.add(car);
112
+ this.cars.push(car);
113
+ }
114
+ this.halo = new THREE.Mesh(new THREE.RingGeometry(31, 32.5, 64), mats.halo);
115
+ this.halo.rotation.x = -Math.PI / 2;
116
+ this.halo.position.y = 0.4;
117
+ this.halo.visible = false;
118
+ this.group.add(this.halo);
119
+ this.glow = new THREE.Sprite(mats.glow);
120
+ this.glow.scale.set(24, 24, 1);
121
+ this.group.add(this.glow);
122
+ const el = document.createElement("div");
123
+ el.className = "tlabel";
124
+ this.label = new CSS2DObject(el);
125
+ this.label.position.set(0, 9, 0);
126
+ this.group.add(this.label);
127
+ this.kind = kind;
128
+ }
129
+
130
+ setKind(shared, kind) {
131
+ if (kind === this.kind) return;
132
+ this.kind = kind;
133
+ for (const car of this.cars) car.children[0].children[4].material = shared.mats.stripe[kind];
134
+ }
135
+ }
136
+
137
+ // --- the scene -----------------------------------------------------------------------------------------------
138
+
139
+ export class TwinScene {
140
+ constructor(el, twin, line) {
141
+ this.el = el;
142
+ this.twin = twin;
143
+ this.line = line;
144
+ this.renderer = new THREE.WebGLRenderer({ antialias: true, logarithmicDepthBuffer: true,
145
+ preserveDrawingBuffer: true });
146
+ this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
147
+ this.renderer.shadowMap.enabled = true;
148
+ this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
149
+ this.renderer.toneMapping = THREE.NeutralToneMapping;
150
+ this.renderer.toneMappingExposure = 1.0;
151
+ el.appendChild(this.renderer.domElement);
152
+ this.labels = new CSS2DRenderer();
153
+ this.labels.domElement.className = "labels";
154
+ el.appendChild(this.labels.domElement);
155
+
156
+ this.scene = new THREE.Scene();
157
+ this.camera = new THREE.PerspectiveCamera(45, 1, 0.5, 300000);
158
+ this.controls = new MapControls(this.camera, this.labels.domElement);
159
+ this.controls.enableDamping = true;
160
+ this.controls.dampingFactor = 0.12;
161
+ this.controls.maxPolarAngle = Math.PI * 0.47;
162
+ this.controls.minDistance = 12;
163
+ this.controls.maxDistance = 140000;
164
+ this.controls.zoomToCursor = true;
165
+
166
+ this.hemi = new THREE.HemisphereLight(0xeef3ff, 0x7c7a62, 2.0);
167
+ this.ambient = new THREE.AmbientLight(0xffffff, 0.25);
168
+ this.sun = new THREE.DirectionalLight(0xffffff, 3.0);
169
+ this.sun.castShadow = true;
170
+ this.sun.shadow.mapSize.set(2048, 2048);
171
+ const sc = this.sun.shadow.camera;
172
+ sc.left = sc.bottom = -350; sc.right = sc.top = 350; sc.near = 10; sc.far = 3000;
173
+ this.sun.shadow.bias = -0.0004;
174
+ this.scene.add(this.hemi, this.ambient, this.sun, this.sun.target);
175
+ this.scene.fog = new THREE.Fog(0xbcd3e6, 4000, 60000);
176
+ this.scene.background = new THREE.Color(0xbcd3e6);
177
+
178
+ this.world = buildWorld(twin, this.scene);
179
+ this.shared = this.sharedAssets();
180
+ this.trains = new Map();
181
+ this.signals = [];
182
+ this.crossings = this.buildCrossings();
183
+ this.closures = [];
184
+ this.stationLabels = this.buildStationLabels();
185
+ this.placeLabels = this.buildPlaceLabels();
186
+ this.followId = null;
187
+ this.mode = "map";
188
+ this.lastFollow = null;
189
+ this.selected = null;
190
+ this.night = 0;
191
+ this.resize();
192
+ window.addEventListener("resize", () => this.resize());
193
+ this.viewAll();
194
+ }
195
+
196
+ sharedAssets() {
197
+ const geo = carGeometries();
198
+ const glowTex = (() => {
199
+ const cv = document.createElement("canvas");
200
+ cv.width = cv.height = 64;
201
+ const g = cv.getContext("2d");
202
+ const grd = g.createRadialGradient(32, 32, 0, 32, 32, 32);
203
+ grd.addColorStop(0, "rgba(255,244,214,1)"); grd.addColorStop(0.25, "rgba(255,230,170,0.5)");
204
+ grd.addColorStop(1, "rgba(255,220,150,0)");
205
+ g.fillStyle = grd; g.fillRect(0, 0, 64, 64);
206
+ return new THREE.CanvasTexture(cv);
207
+ })();
208
+ const mats = {
209
+ body: new THREE.MeshStandardMaterial({ color: 0xeef0f2, roughness: 0.45, metalness: 0.15 }),
210
+ glass: new THREE.MeshStandardMaterial({ color: 0x18222c, roughness: 0.15, metalness: 0.6 }),
211
+ bogie: new THREE.MeshLambertMaterial({ color: 0x2a2a2a }),
212
+ stripe: { stop: new THREE.MeshStandardMaterial({ color: KIND_COLOR.stop, roughness: 0.5 }),
213
+ fast: new THREE.MeshStandardMaterial({ color: KIND_COLOR.fast, roughness: 0.5 }) },
214
+ head: new THREE.MeshBasicMaterial({ color: 0xfff6dc }),
215
+ tail: new THREE.MeshBasicMaterial({ color: 0xff2a1a }),
216
+ halo: new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.85, depthWrite: false }),
217
+ glow: new THREE.SpriteMaterial({ map: glowTex, color: 0xffffff, transparent: true, opacity: 0,
218
+ depthWrite: false, blending: THREE.AdditiveBlending }),
219
+ };
220
+ return { geo, mats };
221
+ }
222
+
223
+ resize() {
224
+ const w = this.el.clientWidth || window.innerWidth, h = this.el.clientHeight || window.innerHeight;
225
+ this.renderer.setSize(w, h);
226
+ this.labels.setSize(w, h);
227
+ for (const m of [...(this.lineMats || []), ...(this.closureMats || [])]) m.resolution.set(w, h);
228
+ this.camera.aspect = w / h;
229
+ this.camera.updateProjectionMatrix();
230
+ }
231
+
232
+ // --- static labels -----------------------------------------------------------------------------------------
233
+
234
+ buildStationLabels() {
235
+ const out = [];
236
+ for (const st of this.twin.stations) {
237
+ const el = document.createElement("div");
238
+ el.className = "slabel";
239
+ el.textContent = st.name;
240
+ const o = new CSS2DObject(el);
241
+ o.position.set(st.x, 14, -st.y);
242
+ this.scene.add(o);
243
+ out.push({ code: st.code, obj: o, x: st.x, y: st.y });
244
+ }
245
+ return out;
246
+ }
247
+
248
+ buildPlaceLabels() {
249
+ const out = [];
250
+ const stations = new Set(this.twin.stations.map((s) => s.name.toLowerCase()));
251
+ for (const p of this.twin.places) {
252
+ if (p.r > 3) continue;
253
+ const el = document.createElement("div");
254
+ el.className = `plabel r${p.r}`;
255
+ el.textContent = p.n;
256
+ const o = new CSS2DObject(el);
257
+ o.position.set(p.x, 30, -p.y);
258
+ this.scene.add(o);
259
+ out.push({ obj: o, r: p.r, x: p.x, y: p.y, dup: stations.has(p.n.toLowerCase()) });
260
+ }
261
+ return out;
262
+ }
263
+
264
+ // --- crossings, signals, closures ------------------------------------------------------------------------
265
+
266
+ buildCrossings() {
267
+ const out = [];
268
+ const cv = document.createElement("canvas");
269
+ cv.width = 64; cv.height = 8;
270
+ const g = cv.getContext("2d");
271
+ for (let i = 0; i < 8; i++) { g.fillStyle = i % 2 ? "#ffffff" : "#d42a2a"; g.fillRect(i * 8, 0, 8, 8); }
272
+ const stripes = new THREE.CanvasTexture(cv);
273
+ const armMat = new THREE.MeshLambertMaterial({ map: stripes });
274
+ const postMat = new THREE.MeshLambertMaterial({ color: 0xdddddd });
275
+ const plateMat = new THREE.MeshLambertMaterial({ color: 0x3e3f42, depthTest: false, depthWrite: false });
276
+ const offMat = new THREE.MeshBasicMaterial({ color: 0x3a1010 });
277
+ this.lampOn = new THREE.MeshBasicMaterial({ color: 0xff3020 });
278
+ const armGeo = new THREE.BoxGeometry(5.6, 0.14, 0.14);
279
+ armGeo.translate(2.8, 0, 0);
280
+ const postGeo = new THREE.CylinderGeometry(0.08, 0.1, 1.3, 6);
281
+ const lampGeo = new THREE.CircleGeometry(0.13, 10);
282
+ for (const c of this.twin.crossings) {
283
+ const grp = new THREE.Group();
284
+ grp.position.set(c.x, 0, -c.y);
285
+ const half = c.n * 2.4 + 2.2;
286
+ const plate = new THREE.Mesh(new THREE.BoxGeometry(half * 2, 0.05, 5.5), plateMat);
287
+ plate.rotation.y = c.r;
288
+ plate.position.y = 0.2;
289
+ plate.renderOrder = -4; // painted with the flat layers (see world.js)
290
+ grp.add(plate);
291
+ const arms = [], lamps = [];
292
+ for (const side of [1, -1]) {
293
+ const ux = Math.cos(c.r) * side, uy = Math.sin(c.r) * side; // along the road, away from the track
294
+ const vx = -uy, vy = ux; // across the road
295
+ const bx = ux * (half + 1.2) + vx * 3.1, by = uy * (half + 1.2) + vy * 3.1;
296
+ const post = new THREE.Mesh(postGeo, postMat);
297
+ post.position.set(bx, 0.65, -by);
298
+ const pivot = new THREE.Group();
299
+ pivot.position.set(bx, 1.05, -by);
300
+ pivot.rotation.y = Math.atan2(-vy, -vx); // arm points back across the road
301
+ const tilt = new THREE.Group();
302
+ tilt.rotation.z = Math.PI / 2; // open: arm up
303
+ const arm = new THREE.Mesh(armGeo, armMat);
304
+ tilt.add(arm);
305
+ pivot.add(tilt);
306
+ for (const dz of [-0.2, 0.2]) {
307
+ const l = new THREE.Mesh(lampGeo, offMat);
308
+ l.position.set(bx + ux * 0.01, 1.25, -by + dz);
309
+ l.rotation.y = Math.atan2(-uy, ux) + Math.PI / 2;
310
+ grp.add(l);
311
+ lamps.push(l);
312
+ }
313
+ grp.add(post, pivot);
314
+ arms.push(tilt);
315
+ }
316
+ this.scene.add(grp);
317
+ out.push({ ...c, arms, lamps, offMat, angle: Math.PI / 2 });
318
+ }
319
+ return out;
320
+ }
321
+
322
+ clearReplayObjects() {
323
+ for (const s of this.signals) this.scene.remove(s.group);
324
+ for (const c of this.closures) { this.scene.remove(c.mesh); this.scene.remove(c.label); if (c.line) this.scene.remove(c.line); }
325
+ this.closureMats = [];
326
+ for (const v of this.breaks || []) { this.scene.remove(v.mesh); this.scene.remove(v.label); }
327
+ this.signals = [];
328
+ this.closures = [];
329
+ this.breaks = [];
330
+ }
331
+
332
+ buildBreaks(R) {
333
+ // where and when the plan breaks a rule: a red ring on the spot for a few minutes around the moment
334
+ const km = Object.fromEntries(R.points.map((p) => [p.code, p.km]));
335
+ const ringGeo = new THREE.RingGeometry(40, 48, 48);
336
+ ringGeo.rotateX(-Math.PI / 2);
337
+ const mat = new THREE.MeshBasicMaterial({ color: STATUS.critical, transparent: true, opacity: 0.9, depthTest: false,
338
+ depthWrite: false });
339
+ for (const v of R.violations) {
340
+ if (!v.time || !v.where) continue;
341
+ const [h, m] = v.time.split(":").map(Number);
342
+ let t = h * 60 + m;
343
+ if (t < R.start - 120) t += 1440;
344
+ const ends = v.where.split("-").filter((c) => km[c] !== undefined);
345
+ if (!ends.length) continue;
346
+ const k = ends.reduce((a, c) => a + km[c], 0) / ends.length;
347
+ const p = this.pose("E", k);
348
+ const mesh = new THREE.Mesh(ringGeo, mat);
349
+ mesh.position.set(p.x, 1.5, -p.y);
350
+ mesh.renderOrder = 6;
351
+ const el = document.createElement("div");
352
+ el.className = "vlabel";
353
+ el.innerHTML = `<b>${v.rule.replace("_", " ")}</b> ${v.trains.join(" · ")} at ${v.where}`;
354
+ const label = new CSS2DObject(el);
355
+ label.position.set(p.x, 30, -p.y);
356
+ this.scene.add(mesh, label);
357
+ this.breaks.push({ t, mesh, label });
358
+ }
359
+ }
360
+
361
+ pose(dir, km, lw = "8", gn = "5a", lanes = null) {
362
+ const p = this.line.place(dir, lw, gn, km, lanes);
363
+ const q = this.line.place(dir, lw, gn, km + (dir === "E" ? 0.01 : -0.01), lanes);
364
+ return { x: p[0], y: p[1], h: Math.atan2(q[1] - p[1], q[0] - p[0]) };
365
+ }
366
+
367
+ buildSignals(R) {
368
+ // one signal at each entry of every single-track section, on the right of the track; red while a train is
369
+ // inside or the section is closed
370
+ const km = Object.fromEntries(R.points.map((p) => [p.code, p.km]));
371
+ const kind = Object.fromEntries(R.points.map((p) => [p.code, p.kind]));
372
+ const mastGeo = new THREE.CylinderGeometry(0.09, 0.11, 4.2, 6);
373
+ const headGeo = new THREE.BoxGeometry(0.35, 0.95, 0.5);
374
+ const lampGeo = new THREE.CircleGeometry(0.12, 12);
375
+ const grey = new THREE.MeshLambertMaterial({ color: 0x2b2b2b });
376
+ this.sigRed = new THREE.MeshBasicMaterial({ color: 0xff2a1a });
377
+ this.sigGreen = new THREE.MeshBasicMaterial({ color: 0x28e070 });
378
+ this.sigOff = new THREE.MeshBasicMaterial({ color: 0x1a1a1a });
379
+ R.sections.forEach((sec, i) => {
380
+ if (!sec.single) return;
381
+ for (const dir of ["E", "W"]) {
382
+ const at = dir === "E" ? sec.a : sec.b;
383
+ const sign = dir === "E" ? 1 : -1;
384
+ const k = kind[at] === "junction" ? km[at] - sign * 0.18 : km[at] + sign * 0.12;
385
+ const p = this.pose(dir, k);
386
+ const rx = Math.sin(p.h), ry = -Math.cos(p.h); // right of the direction of travel
387
+ const group = new THREE.Group();
388
+ group.position.set(p.x + rx * 2.6, 0, -(p.y + ry * 2.6));
389
+ group.rotation.y = p.h + Math.PI; // the lamps face oncoming trains
390
+ const mast = new THREE.Mesh(mastGeo, grey);
391
+ mast.position.y = 2.1;
392
+ const head = new THREE.Mesh(headGeo, grey);
393
+ head.position.y = 4.3;
394
+ const red = new THREE.Mesh(lampGeo, this.sigOff), green = new THREE.Mesh(lampGeo, this.sigOff);
395
+ red.rotation.y = green.rotation.y = Math.PI / 2;
396
+ red.position.set(0.18, 4.52, 0); green.position.set(0.18, 4.12, 0);
397
+ group.add(mast, head, red, green);
398
+ this.scene.add(group);
399
+ this.signals.push({ section: i, dir, group, red, green });
400
+ }
401
+ });
402
+ }
403
+
404
+ buildClosures(R) {
405
+ const km = Object.fromEntries(R.points.map((p) => [p.code, p.km]));
406
+ const single = R.sections.map((s) => s.single);
407
+ const idx = Object.fromEntries(R.points.map((p, i) => [p.code, i]));
408
+ for (const c of R.closures) {
409
+ let k0, k1;
410
+ if (c.kind === "track") { k0 = km[c.where[0]] - 0.2; k1 = km[c.where[0]] + 0.2; }
411
+ else { k0 = Math.min(km[c.where[0]], km[c.where[c.where.length - 1]]); k1 = Math.max(km[c.where[0]], km[c.where[c.where.length - 1]]); }
412
+ const pos = [], index = [];
413
+ const lanes = ["E"];
414
+ const i0 = Math.min(idx[c.where[0]], idx[c.where[c.where.length - 1]]);
415
+ const i1 = Math.max(idx[c.where[0]], idx[c.where[c.where.length - 1]]);
416
+ if (c.kind === "track" || single.slice(i0, Math.max(i1, i0 + 1)).some((s) => !s)) lanes.push("W");
417
+ for (const lane of lanes) {
418
+ const base = pos.length / 3;
419
+ let n = 0;
420
+ for (let k = k0; k <= k1 + 1e-9; k += Math.max(0.005, (k1 - k0) / 400)) {
421
+ const p = this.pose(lane, k, "8", "5a");
422
+ const nx = -Math.sin(p.h) * 2.6, ny = Math.cos(p.h) * 2.6;
423
+ pos.push(p.x + nx, 0.45, -(p.y + ny), p.x - nx, 0.45, -(p.y - ny));
424
+ n++;
425
+ }
426
+ for (let j = 0; j < n - 1; j++) {
427
+ const v = base + 2 * j;
428
+ index.push(v, v + 2, v + 1, v + 1, v + 2, v + 3);
429
+ }
430
+ }
431
+ const g = new THREE.BufferGeometry();
432
+ g.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3));
433
+ g.setIndex(index);
434
+ const color = c.kind === "closed" ? STATUS.critical : c.kind === "slow" ? STATUS.warning : STATUS.serious;
435
+ const mat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.7, depthWrite: false,
436
+ side: THREE.DoubleSide });
437
+ const mesh = new THREE.Mesh(g, mat);
438
+ mesh.renderOrder = 5;
439
+ // from high up: a thick line over the overview line while it lasts
440
+ const lpts = [];
441
+ for (let k = k0; k <= k1 + 1e-9; k += Math.max(0.02, (k1 - k0) / 100)) {
442
+ const q = this.pose("E", Math.min(k, k1));
443
+ lpts.push(q.x, 1.2, -q.y);
444
+ }
445
+ const lmat = new LineMaterial({ color, linewidth: 7, depthTest: false, depthWrite: false, transparent: true });
446
+ lmat.resolution.set(this.el.clientWidth || 1, this.el.clientHeight || 1);
447
+ (this.closureMats ||= []).push(lmat);
448
+ const lg = new LineGeometry();
449
+ lg.setPositions(lpts);
450
+ const line = new Line2(lg, lmat);
451
+ line.renderOrder = -1;
452
+ const el = document.createElement("div");
453
+ el.className = `clabel ${c.kind}`;
454
+ const what = c.kind === "closed" ? "Closed" : c.kind === "slow" ? `Slow +${c.extra} min` : "1 track out of use";
455
+ el.innerHTML = `<b>${what}</b> ${c.cause || ""}`;
456
+ const label = new CSS2DObject(el);
457
+ const mid = this.pose("E", (k0 + k1) / 2);
458
+ label.position.set(mid.x, 22, -mid.y);
459
+ this.scene.add(mesh, label, line);
460
+ this.closures.push({ ...c, mesh, label, mat, line });
461
+ }
462
+ }
463
+
464
+ buildOverviewLine(R) {
465
+ // the line as seen from high up, where 3 m of track is under a pixel: white where it is single track (one train
466
+ // at a time between stations), grey where it is double; a dark casing under both
467
+ const km = Object.fromEntries(R.points.map((p) => [p.code, p.km]));
468
+ this.lineMats = [];
469
+ this.overview = new THREE.Group();
470
+ const mk = (color, width, order) => {
471
+ const m = new LineMaterial({ color, linewidth: width, depthTest: false, depthWrite: false, transparent: true });
472
+ m.resolution.set(this.el.clientWidth || 1, this.el.clientHeight || 1);
473
+ this.lineMats.push(m);
474
+ m.userData.order = order;
475
+ return m;
476
+ };
477
+ const casing = mk(0x1a1a19, 7, -3), single = mk(0xffffff, 3, -2), double = mk(0x9a9a96, 3, -2);
478
+ for (const sec of R.sections) {
479
+ const pts = [];
480
+ for (let k = km[sec.a]; k <= km[sec.b] + 1e-9; k += Math.max(0.02, (km[sec.b] - km[sec.a]) / 120)) {
481
+ const p = this.line.place("E", "8", "5a", Math.min(k, km[sec.b]));
482
+ pts.push(p[0], 1, -p[1]);
483
+ }
484
+ for (const mat of [casing, sec.single ? single : double]) {
485
+ const g = new LineGeometry();
486
+ g.setPositions(pts);
487
+ const l = new Line2(g, mat);
488
+ l.computeLineDistances();
489
+ l.renderOrder = mat.userData.order;
490
+ this.overview.add(l);
491
+ }
492
+ }
493
+ this.scene.add(this.overview);
494
+ }
495
+
496
+ setReplay(R, sim) {
497
+ this.R = R;
498
+ this.sim = sim;
499
+ if (!this.overview) this.buildOverviewLine(R);
500
+ this.clearReplayObjects();
501
+ this.buildSignals(R);
502
+ this.buildClosures(R);
503
+ this.buildBreaks(R);
504
+ const keep = new Set(sim.units.map((u) => u.id));
505
+ for (const [id, tm] of this.trains) {
506
+ if (!keep.has(id)) { this.scene.remove(tm.group); this.trains.delete(id); }
507
+ }
508
+ for (const u of sim.units) {
509
+ if (!this.trains.has(u.id)) {
510
+ const tm = new TrainMesh(this.shared, (u.runs[0] && u.runs[0].kind) || "stop");
511
+ tm.unitId = u.id;
512
+ tm.group.traverse((o) => { o.userData.unit = u.id; });
513
+ this.scene.add(tm.group);
514
+ this.trains.set(u.id, tm);
515
+ }
516
+ }
517
+ this.sunT = null;
518
+ }
519
+
520
+ setSun({ elevation, azimuth }) {
521
+ const el = THREE.MathUtils.degToRad(Math.max(elevation, -8));
522
+ const az = THREE.MathUtils.degToRad(azimuth);
523
+ this.sunDir = new THREE.Vector3(Math.sin(az) * Math.cos(el), Math.sin(Math.max(el, 0.03)), -Math.cos(az) * Math.cos(el));
524
+ const day = THREE.MathUtils.smoothstep(elevation, -6, 10); // 0 night .. 1 day
525
+ const dusk = Math.max(0, 1 - Math.abs(elevation - 1) / 9) * (elevation > -8 ? 1 : 0);
526
+ this.night = 1 - day;
527
+ const sky = new THREE.Color(0x0d1830).lerp(new THREE.Color(0xa9c8e6), day);
528
+ sky.lerp(new THREE.Color(0xe39a62), dusk * 0.45);
529
+ this.scene.background = sky;
530
+ this.scene.fog.color.copy(sky);
531
+ this.hemi.intensity = 0.5 + 2.6 * day;
532
+ this.hemi.color.set(0xeef3ff).lerp(new THREE.Color(0x5a6a9a), 1 - day);
533
+ this.sun.intensity = 3.6 * day + 0.8 * dusk;
534
+ this.sun.color.set(0xffffff).lerp(new THREE.Color(0xffa060), dusk);
535
+ this.renderer.toneMappingExposure = 0.8 + 0.35 * day;
536
+ this.shared.mats.glow.opacity = 0.9 * this.night;
537
+ }
538
+
539
+ // --- cameras ---------------------------------------------------------------------------------------------
540
+
541
+ viewAll() {
542
+ const [x0, y0, x1, y1] = this.twin.bounds;
543
+ const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
544
+ this.mode = "map";
545
+ this.followId = null;
546
+ this.controls.enabled = true;
547
+ // high enough that the whole line fits the width of the view, whatever its shape
548
+ const half = (x1 - x0) / 2 - 2000;
549
+ const tanH = Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2)) * this.camera.aspect;
550
+ const h = Math.max(20000, (half / tanH) * 0.95);
551
+ this.controls.target.set(cx, 0, -cy);
552
+ this.camera.position.set(cx, h, -cy + h * 0.44);
553
+ this.controls.update();
554
+ }
555
+
556
+ viewAt(x, y, dist = 700, pitch = 0.9) {
557
+ this.mode = "map";
558
+ this.followId = null;
559
+ this.controls.enabled = true;
560
+ this.controls.target.set(x, 0, -y);
561
+ this.camera.position.set(x - dist * 0.35, dist * Math.sin(pitch), -y + dist * Math.cos(pitch));
562
+ this.controls.update();
563
+ }
564
+
565
+ viewStation(code) {
566
+ const st = this.twin.stations.find((s) => s.code === code);
567
+ if (st) this.viewAt(st.x, st.y, 650, 0.75);
568
+ }
569
+
570
+ follow(unitId, mode = "chase") {
571
+ this.followId = unitId;
572
+ this.mode = mode;
573
+ this.lastFollow = null;
574
+ this.controls.enabled = mode !== "cab";
575
+ if (mode === "chase") {
576
+ const tm = this.trains.get(unitId);
577
+ if (tm) {
578
+ const p = tm.group.position.clone();
579
+ this.controls.target.copy(p);
580
+ this.camera.position.set(p.x - 140, 95, p.z + 160);
581
+ this.controls.update();
582
+ }
583
+ }
584
+ }
585
+
586
+ // --- per frame ---------------------------------------------------------------------------------------------
587
+
588
+ update(t, states, dtReal) {
589
+ const sim = this.sim;
590
+ if (!sim) return;
591
+ if (this.sunT === null || Math.abs(t - this.sunT) > 0.5) { // the sun moves with the clock
592
+ this.setSun(sunPosition(this.R.day, t));
593
+ this.sunT = t;
594
+ }
595
+ // trains
596
+ const seen = new Set();
597
+ for (const s of states) {
598
+ const tm = this.trains.get(s.unit);
599
+ if (!tm) continue;
600
+ seen.add(s.unit);
601
+ tm.group.visible = true;
602
+ tm.setKind(this.shared, s.kind);
603
+ const offs = [CAR / 2 + GAP / 2 + BOGIE, CAR / 2 + GAP / 2 - BOGIE, -(CAR / 2 + GAP / 2) + BOGIE,
604
+ -(CAR / 2 + GAP / 2) - BOGIE];
605
+ const pts = this.line.body(s.dir, s.lw, s.gn, s.km, offs, s.lanes);
606
+ const mid = this.line.place(s.dir, s.lw, s.gn, s.km, s.lanes);
607
+ tm.group.position.set(mid[0], 0, -mid[1]);
608
+ for (let c = 0; c < 2; c++) {
609
+ const [a, b] = [pts[2 * c], pts[2 * c + 1]]; // its front and rear bogie, so cars follow curves
610
+ const car = tm.cars[c];
611
+ car.position.set((a[0] + b[0]) / 2 - mid[0], 0, -((a[1] + b[1]) / 2 - mid[1]));
612
+ car.rotation.y = Math.atan2(a[1] - b[1], a[0] - b[0]);
613
+ // head lamps on the leading cab, tail lamps behind
614
+ for (const l of car.userData.lamps) l.material = c === 0 ? this.shared.mats.head : this.shared.mats.tail;
615
+ }
616
+ const head = pts[0];
617
+ tm.glow.position.set(head[0] - mid[0], 2.0, -(head[1] - mid[1]));
618
+ tm.halo.visible = this.selected === s.unit;
619
+ tm.state = s;
620
+ const delay = Math.round(s.delay);
621
+ tm.label.element.innerHTML = `<i style="background:${KIND_COLOR[s.kind]}"></i>${s.train}`
622
+ + (delay > 0 ? `<span class="late">+${delay}</span>` : "");
623
+ tm.label.element.classList.toggle("sel", this.selected === s.unit);
624
+ }
625
+ for (const [id, tm] of this.trains) if (!seen.has(id)) tm.group.visible = false;
626
+
627
+ // signals
628
+ const occ = sim.occupancy(states);
629
+ for (const sg of this.signals) {
630
+ const sec = this.R.sections[sg.section];
631
+ const closed = this.closures.some((c) => c.kind === "closed" && t >= c.start && t < c.end
632
+ && this.covers(c, sec));
633
+ const red = closed || (occ[sg.section] && occ[sg.section].length > 0);
634
+ sg.red.material = red ? this.sigRed : this.sigOff;
635
+ sg.green.material = red ? this.sigOff : this.sigGreen;
636
+ }
637
+ // level crossings: down while a train is within ~35 s of reaching it, until it has passed
638
+ const soon = states.map((s) => {
639
+ const un = sim.units.find((u) => u.id === s.unit);
640
+ const f = un ? sim.stateAt(un, t + 0.6) : s;
641
+ return [Math.min(s.km, f.km) - 0.035, Math.max(s.km, f.km) + 0.035];
642
+ });
643
+ const blink = Math.floor(performance.now() / 450) % 2;
644
+ for (const c of this.crossings) {
645
+ const down = soon.some(([a, b]) => c.km >= a && c.km <= b);
646
+ const target = down ? 0 : Math.PI / 2;
647
+ c.angle += (target - c.angle) * Math.min(1, dtReal * 2.5);
648
+ for (const a of c.arms) a.rotation.z = c.angle;
649
+ c.lamps.forEach((l, i) => { l.material = down && (i + blink) % 2 ? this.lampOn : c.offMat; });
650
+ }
651
+ // disruptions: visible while they last, pulsing
652
+ const pulse = 0.45 + 0.25 * Math.sin(performance.now() / 300);
653
+ for (const c of this.closures) {
654
+ const on = t >= c.start && t < c.end;
655
+ c.mesh.visible = on;
656
+ c.label.visible = on;
657
+ c.mat.opacity = pulse;
658
+ c.line.visible = on && this.overview && this.overview.visible;
659
+ }
660
+ for (const v of this.breaks) {
661
+ const on = t >= v.t - 1 && t <= v.t + 3;
662
+ v.mesh.visible = v.label.visible = on;
663
+ if (on) v.mesh.scale.setScalar(1 + 0.15 * Math.sin(performance.now() / 160));
664
+ }
665
+ animateWorld(this.world, dtReal);
666
+ this.updateCamera();
667
+ this.updateLabels();
668
+ }
669
+
670
+ covers(c, sec) {
671
+ const idx = Object.fromEntries(this.R.points.map((p, i) => [p.code, i]));
672
+ const lo = Math.min(idx[c.where[0]], idx[c.where[c.where.length - 1]]);
673
+ const hi = Math.max(idx[c.where[0]], idx[c.where[c.where.length - 1]]);
674
+ const i = idx[sec.a];
675
+ return i >= lo && i < hi;
676
+ }
677
+
678
+ updateCamera() {
679
+ const tm = this.followId ? this.trains.get(this.followId) : null;
680
+ if (tm && tm.group.visible && tm.state) {
681
+ const s = tm.state;
682
+ if (this.mode === "cab") {
683
+ const pts = this.line.body(s.dir, s.lw, s.gn, s.km, [CAR + 0.5, CAR + 160], s.lanes);
684
+ this.camera.position.set(pts[0][0], 3.4, -pts[0][1]);
685
+ this.camera.lookAt(pts[1][0], 2.2, -pts[1][1]);
686
+ } else {
687
+ const p = tm.group.position;
688
+ if (this.lastFollow) {
689
+ const d = p.clone().sub(this.lastFollow);
690
+ this.camera.position.add(d);
691
+ this.controls.target.add(d);
692
+ }
693
+ this.lastFollow = p.clone();
694
+ }
695
+ }
696
+ if (this.mode !== "cab") this.controls.update();
697
+ // shadows follow the view
698
+ const tgt = this.mode === "cab" ? this.camera.position : this.controls.target;
699
+ const h = this.camera.position.y;
700
+ this.sun.castShadow = h < 2500;
701
+ this.sun.target.position.copy(tgt);
702
+ this.sun.position.copy(tgt).addScaledVector(this.sunDir || new THREE.Vector3(0.3, 0.8, 0.3), 1500);
703
+ this.scene.fog.near = Math.max(1500, h * 2);
704
+ this.scene.fog.far = Math.max(20000, h * 7);
705
+ if (this.overview) { // the overview line fades in as the track gets too thin to see
706
+ const a = THREE.MathUtils.smoothstep(h, 1200, 4000);
707
+ this.overview.visible = a > 0.01;
708
+ for (const m of this.lineMats) m.opacity = a;
709
+ for (const m of this.closureMats || []) m.opacity = a;
710
+ }
711
+ const trees = this.world.getObjectByName("trees");
712
+ if (trees) { // trees only near the view: there are tens of thousands
713
+ trees.visible = h < 2500;
714
+ if (trees.visible) for (const im of trees.children) im.visible = im.userData.centre.distanceTo(tgt) < 3500 + h;
715
+ }
716
+ }
717
+
718
+ updateLabels() {
719
+ const cam = this.camera.position;
720
+ const h = cam.y;
721
+ const narrow = window.innerWidth < 760 && h > 20000; // a phone's overview: terminals and turn points only
722
+ for (const l of this.placeLabels) {
723
+ const show = !l.dup && !narrow && ((l.r <= 1 && h > 2500) || (l.r === 2 && h > 900 && h < 20000) || (l.r === 3 && h < 6000 && h > 300));
724
+ l.obj.visible = show;
725
+ }
726
+ for (const l of this.stationLabels) { // far stations only from high up
727
+ l.obj.visible = (h > 2000 || Math.hypot(l.x - cam.x, -l.y - cam.z) < 12000)
728
+ && (!narrow || ["LW", "HDG", "BP", "ZH", "GN"].includes(l.code));
729
+ }
730
+ for (const tm of this.trains.values()) {
731
+ const d = tm.group.position.distanceTo(cam);
732
+ tm.label.visible = tm.group.visible;
733
+ tm.label.element.classList.toggle("far", d > 9000);
734
+ }
735
+ for (const c of this.closures) if (c.label.visible) c.label.element.classList.toggle("far", h > 15000);
736
+ }
737
+
738
+ render() {
739
+ this.renderer.render(this.scene, this.camera);
740
+ this.labels.render(this.scene, this.camera);
741
+ }
742
+
743
+ pick(clientX, clientY) {
744
+ const r = this.renderer.domElement.getBoundingClientRect();
745
+ const v = new THREE.Vector2(((clientX - r.left) / r.width) * 2 - 1, -((clientY - r.top) / r.height) * 2 + 1);
746
+ const ray = new THREE.Raycaster();
747
+ ray.setFromCamera(v, this.camera);
748
+ const objs = [];
749
+ for (const tm of this.trains.values()) if (tm.group.visible) objs.push(tm.group);
750
+ const hit = ray.intersectObjects(objs, true).find((h) => h.object.userData.unit);
751
+ if (hit) return hit.object.userData.unit;
752
+ // generous fallback: the train nearest the click on screen
753
+ let best = null, bd = 28;
754
+ for (const tm of this.trains.values()) {
755
+ if (!tm.group.visible) continue;
756
+ const p = tm.group.position.clone().project(this.camera);
757
+ const sx = (p.x + 1) / 2 * r.width + r.left, sy = (1 - p.y) / 2 * r.height + r.top;
758
+ const d = Math.hypot(sx - clientX, sy - clientY);
759
+ if (d < bd && p.z < 1) { bd = d; best = tm.unitId; }
760
+ }
761
+ return best;
762
+ }
763
+ }
764
+
765
+ export { CANONICAL };
openenv/rail_openenv/web/twin/style.css ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* The line twin's interface: dark glass panels over the 3D view (one theme: the map is the light source). */
2
+ :root {
3
+ color-scheme: dark;
4
+ --surface-1: rgba(26, 26, 25, 0.86);
5
+ --surface-2: rgba(44, 44, 42, 0.92);
6
+ --page: #0d0d0d;
7
+ --text-primary: #ffffff;
8
+ --text-secondary: #c3c2b7;
9
+ --muted: #898781;
10
+ --grid: #2c2c2a;
11
+ --border: rgba(255, 255, 255, 0.10);
12
+ --stop: #3987e5;
13
+ --fast: #d95926;
14
+ --critical: #d03b3b;
15
+ --warning: #fab219;
16
+ --serious: #ec835a;
17
+ --good: #0ca30c;
18
+ --accent: #199e70;
19
+ --bottom-h: 196px;
20
+ --side-w: 300px;
21
+ --bar-h: 52px;
22
+ }
23
+ * { box-sizing: border-box; }
24
+ html, body { margin: 0; height: 100%; overflow: hidden; background: var(--page); color: var(--text-primary);
25
+ font: 13px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
26
+ #stage { position: fixed; inset: 0; }
27
+ #stage canvas { display: block; }
28
+ .labels { position: absolute; inset: 0; pointer-events: auto; }
29
+ .muted { color: var(--muted); }
30
+ button, select { font: inherit; color: var(--text-primary); background: var(--surface-2); border: 1px solid var(--border);
31
+ border-radius: 6px; padding: 4px 9px; cursor: pointer; }
32
+ button:hover, select:hover { border-color: rgba(255, 255, 255, 0.28); }
33
+ button.on { background: #f2f1ec; color: #0b0b0b; }
34
+ select { max-width: 100%; }
35
+
36
+ #bar { position: fixed; top: 0; left: 0; right: 0; height: var(--bar-h); display: flex; align-items: center; gap: 14px;
37
+ padding: 0 14px; background: var(--surface-1); border-bottom: 1px solid var(--border); backdrop-filter: blur(8px);
38
+ z-index: 5; overflow-x: auto; }
39
+ .brand { display: flex; flex-direction: column; line-height: 1.15; white-space: nowrap; }
40
+ .brand span { color: var(--muted); font-size: 11px; }
41
+ .pick { display: flex; align-items: center; gap: 6px; min-width: 0; }
42
+ .pick span { color: var(--muted); font-size: 11px; }
43
+ #task { width: 250px; }
44
+ #plan { width: 230px; }
45
+ #station { width: 120px; }
46
+ .transport { display: flex; align-items: center; gap: 6px; }
47
+ #play { width: 38px; }
48
+ #clock { display: flex; flex-direction: column; line-height: 1.1; min-width: 118px; font-variant-numeric: tabular-nums; }
49
+ #clock b { font-size: 17px; }
50
+ #clock span { color: var(--muted); font-size: 11px; }
51
+ .cams { display: flex; gap: 4px; }
52
+ .graphlink { color: var(--text-secondary); text-decoration: none; white-space: nowrap; }
53
+ #panels { margin-left: auto; }
54
+
55
+ .panel { position: fixed; background: var(--surface-1); border: 1px solid var(--border); border-radius: 10px;
56
+ backdrop-filter: blur(8px); z-index: 4; }
57
+ #left { top: calc(var(--bar-h) + 10px); left: 10px; width: var(--side-w); max-height: calc(100% - var(--bar-h) - var(--bottom-h) - 30px);
58
+ overflow-y: auto; padding: 12px 14px; }
59
+ #right { top: calc(var(--bar-h) + 10px); right: 10px; width: var(--side-w); max-height: calc(100% - var(--bar-h) - var(--bottom-h) - 30px);
60
+ overflow-y: auto; padding: 10px 12px; }
61
+ #bottom { left: 10px; right: 10px; bottom: 10px; height: var(--bottom-h); display: flex; gap: 10px; padding: 8px 10px; }
62
+ h2, h3 { margin: 4px 0 8px; font-size: 13px; font-weight: 600; }
63
+ h3 { color: var(--text-secondary); margin-top: 12px; }
64
+
65
+ #scenario .day { font-size: 15px; font-weight: 600; }
66
+ #scenario .tier { display: inline-block; padding: 1px 7px; border-radius: 9px; background: var(--surface-2);
67
+ color: var(--text-secondary); font-size: 11px; margin-left: 6px; }
68
+ #scenario ul { margin: 6px 0 0; padding-left: 16px; }
69
+ #scenario li { margin: 2px 0; }
70
+ #scenario .note { color: var(--text-secondary); font-size: 12px; }
71
+ #result { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); }
72
+ #result .plan-name { color: var(--text-secondary); }
73
+ #result .hero { font-size: 30px; font-weight: 600; line-height: 1.1; margin: 4px 0; }
74
+ #result .hero small { font-size: 12px; font-weight: 400; color: var(--muted); margin-left: 4px; }
75
+ #result .badge { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; }
76
+ #result .badge.ok::before { content: "✓"; color: var(--good); font-weight: 700; }
77
+ #result .badge.bad::before { content: "✕"; color: var(--critical); font-weight: 700; }
78
+ #result dl { display: grid; grid-template-columns: auto 1fr; gap: 2px 12px; margin: 8px 0 0; }
79
+ #result dt { color: var(--muted); }
80
+ #result dd { margin: 0; font-variant-numeric: tabular-nums; }
81
+ #result .viol { margin: 8px 0 0; padding: 0; list-style: none; max-height: 140px; overflow-y: auto; }
82
+ #result .viol li { cursor: pointer; padding: 3px 0; border-top: 1px solid var(--grid); font-size: 12px; color: var(--text-secondary); }
83
+ #result .viol li::before { content: "! "; color: var(--critical); font-weight: 700; }
84
+ .legend { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); font-size: 12px; color: var(--text-secondary); }
85
+ .legend div { margin: 3px 0; }
86
+ .chip { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin: 0 5px 0 0; vertical-align: -1px; }
87
+ .legend .chip + .chip, .legend i + i { margin-left: 10px; }
88
+ .bar { display: inline-block; width: 16px; height: 6px; border-radius: 2px; margin-right: 5px; vertical-align: 1px; }
89
+ .bar.closed { background: var(--critical); } .bar.slow { background: var(--warning); } .bar.track { background: var(--serious); }
90
+ .bar.single { background: #ffffff; outline: 2px solid #1a1a19; } .bar.double { background: #9a9a96; outline: 2px solid #1a1a19; }
91
+ body.clean #left, body.clean #right { display: none; }
92
+
93
+ #trains, #cancelled { list-style: none; margin: 0; padding: 0; }
94
+ #trains li, #cancelled li { display: grid; grid-template-columns: 12px auto 1fr auto; align-items: center; gap: 2px 8px;
95
+ padding: 6px 4px; border-top: 1px solid var(--grid); cursor: pointer; border-radius: 4px; }
96
+ #trains li:hover { background: rgba(255, 255, 255, 0.05); }
97
+ #trains li.sel { background: rgba(255, 255, 255, 0.10); }
98
+ #trains .num { font-weight: 600; font-variant-numeric: tabular-nums; }
99
+ #trains .st { grid-column: 2 / 5; color: var(--text-secondary); font-size: 12px; }
100
+ #trains .dl { font-variant-numeric: tabular-nums; color: var(--text-secondary); }
101
+ #trains .dl.late { color: var(--warning); }
102
+ #cancelled li { cursor: default; color: var(--muted); }
103
+
104
+ .graphwrap { position: relative; flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
105
+ .costwrap { position: relative; flex: 0 0 340px; }
106
+ .chart-h { display: flex; gap: 10px; align-items: baseline; font-size: 12px; padding-left: 44px; }
107
+ #graph { flex: 1; width: 100%; min-height: 0; cursor: crosshair; touch-action: none; }
108
+ #cost { width: 100%; height: 100%; cursor: crosshair; }
109
+ .tip { position: absolute; pointer-events: none; background: #f2f1ec; color: #0b0b0b; padding: 4px 8px; border-radius: 6px;
110
+ font-size: 12px; white-space: nowrap; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); z-index: 3; }
111
+
112
+ /* labels in the 3D view */
113
+ .tlabel { font-size: 11px; font-weight: 600; padding: 1px 6px 1px 4px; border-radius: 9px; background: rgba(13, 13, 13, 0.78);
114
+ color: #fff; white-space: nowrap; display: flex; align-items: center; gap: 4px; pointer-events: none; }
115
+ .tlabel i { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
116
+ .tlabel .late { color: var(--warning); font-weight: 600; }
117
+ .tlabel.far { font-size: 10px; padding: 0 5px 0 3px; }
118
+ .tlabel.far .late { display: none; }
119
+ .tlabel.sel { outline: 2px solid #fff; }
120
+ .slabel { font-size: 12px; font-weight: 600; color: #fff; background: #23408f; padding: 1px 7px; border-radius: 3px;
121
+ white-space: nowrap; pointer-events: none; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); }
122
+ .plabel { color: rgba(255, 255, 255, 0.82); text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); pointer-events: none; white-space: nowrap; }
123
+ .plabel.r0, .plabel.r1 { font-size: 15px; font-weight: 600; letter-spacing: 0.04em; }
124
+ .plabel.r2 { font-size: 12px; }
125
+ .plabel.r3 { font-size: 11px; font-style: italic; }
126
+ .clabel { font-size: 11px; color: #fff; padding: 2px 7px; border-radius: 4px; white-space: nowrap; pointer-events: none;
127
+ border-left: 3px solid var(--critical); background: rgba(13, 13, 13, 0.82); }
128
+ .clabel.slow { border-color: var(--warning); } .clabel.track { border-color: var(--serious); }
129
+ .clabel.far { font-size: 10px; }
130
+ .vlabel { font-size: 11px; color: #fff; padding: 2px 7px; border-radius: 4px; white-space: nowrap; pointer-events: none;
131
+ background: var(--critical); }
132
+ .vlabel b::before { content: "! "; }
133
+
134
+ #toasts { position: fixed; left: 50%; transform: translateX(-50%); top: calc(var(--bar-h) + 12px); z-index: 6;
135
+ display: flex; flex-direction: column; gap: 6px; align-items: center; pointer-events: none; }
136
+ .toast { background: var(--surface-2); border: 1px solid var(--border); border-left: 3px solid var(--text-secondary);
137
+ padding: 5px 10px; border-radius: 6px; font-size: 12px; max-width: min(560px, 90vw); transition: opacity 0.6s; }
138
+ .toast.closure, .toast.violation { border-left-color: var(--critical); }
139
+ .toast.reopen { border-left-color: var(--good); }
140
+ .toast time { color: var(--muted); margin-right: 6px; font-variant-numeric: tabular-nums; }
141
+
142
+ #loading { position: fixed; inset: 0; display: grid; place-items: center; background: var(--page); z-index: 10;
143
+ transition: opacity 0.5s; text-align: center; }
144
+ #loading p { color: var(--muted); margin: 6px 0 0; }
145
+ #loading.done { opacity: 0; pointer-events: none; }
146
+
147
+ @media (max-width: 1100px) {
148
+ :root { --side-w: 250px; }
149
+ #task { width: 200px; } #plan { width: 180px; }
150
+ .graphlink { display: none; }
151
+ }
152
+ @media (max-width: 760px) {
153
+ :root { --bottom-h: 150px; --bar-h: 96px; }
154
+ #bar { flex-wrap: wrap; height: auto; min-height: var(--bar-h); padding: 6px 16px; gap: 6px 10px; }
155
+ .brand span { display: none; }
156
+ #task, #plan { width: 150px; }
157
+ #left, #right { display: none; }
158
+ #bottom { left: 6px; right: 6px; bottom: 6px; }
159
+ .costwrap { display: none; }
160
+ .chart-h .muted { display: none; }
161
+ }
openenv/rail_openenv/web/twin/twin.json.gz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:11f10c84c37afd98a081fe1ea9494f1f0421a6a09c52ea24589b835cea00db31
3
+ size 3548310
openenv/rail_openenv/web/twin/world.js ADDED
@@ -0,0 +1,514 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // The static scenery of the line twin, built once from twin.json (OpenStreetMap, see SOURCES.md): ground, land cover,
2
+ // water, roads, buildings, trees, landmarks, track and platforms. Everything is merged into a few large meshes,
3
+ // tiled so the renderer can skip what is off screen.
4
+ //
5
+ // World frame: x east, z south (-north), y up, metres; the twin's (x, y) maps to (x, -y).
6
+
7
+ import * as THREE from "three";
8
+ import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
9
+
10
+ export const TILE = 2000; // metres: tile size for culling
11
+ const Y = { landcover: 0.02, farmland: 0.01, water: 0.05, waterway: 0.06, road: 0.09, platform: 0, track: 0.12 };
12
+
13
+ export const COLORS = {
14
+ ground: 0x789a58,
15
+ landcover: {
16
+ 0: 0xbfb6a4, 1: 0x82a85c, 2: 0x7fae5a, 3: 0xb4aebb, 4: 0xc8b9aa, 5: 0xa39d92, 6: 0xb3a789, 7: 0x3e6a35,
17
+ 8: 0x7ea75a, 9: 0x8a9255, 10: 0x6d9a7f, 11: 0x6f9a55, 12: 0x7d9c6a, 13: 0xd2dbd8, 14: 0x7fa257, 15: 0x98a862,
18
+ 16: 0xd8c99a,
19
+ },
20
+ water: 0x4d7da6,
21
+ road: [0x4c4d51, 0x55565a, 0x5b5c60, 0x616265, 0x67686b, 0x6e6e70, 0x9b8b6a, 0xa2584b],
22
+ roadWidth: [22, 13, 10, 8, 5.5, 4, 3, 2.6],
23
+ waterwayWidth: [22, 14, 4, 1.8],
24
+ building: [
25
+ [0x9b5a43, 0x4a3a34], [0xa77d63, 0x585654], [0x7b5d4b, 0x3d3a36], [0x9ea3a8, 0x7c8186], [0xb8a998, 0x6f6a66],
26
+ [0x8a4e3b, 0x3f3936], [0xd5e2de, 0xc3d2ce], [0xb7724f, 0x4b4744],
27
+ ],
28
+ ballast: 0x8b8379, rail: 0xc4c7cb, sleeper: 0x5a4a3d, platform: 0xbab6ae, platformEdge: 0xe0bf38,
29
+ trunk: 0x5b4636, crown: 0x3f6b33, crown2: 0x4f7a3b,
30
+ };
31
+
32
+ const col = new THREE.Color();
33
+
34
+ function decode(flat) {
35
+ const pts = [];
36
+ for (let i = 0; i < flat.length; i += 2) pts.push([flat[i] / 10, flat[i + 1] / 10]);
37
+ return pts;
38
+ }
39
+
40
+ function tileKey(x, y) {
41
+ return `${Math.floor(x / TILE)},${Math.floor(y / TILE)}`;
42
+ }
43
+
44
+ // growable typed buffers for one mesh
45
+ class Buf {
46
+ constructor() { this.pos = []; this.col = []; this.uv = null; this.idx = []; }
47
+ get n() { return this.pos.length / 3; }
48
+ vert(x, y, z, c) { this.pos.push(x, y, z); this.col.push(c.r, c.g, c.b); return this.n - 1; }
49
+ // a triangle that faces up (+y), whichever way its vertices were given
50
+ up(a, b, c) {
51
+ const P = this.pos;
52
+ const ax = P[3 * a], az = P[3 * a + 2], bx = P[3 * b], bz = P[3 * b + 2], cx = P[3 * c], cz = P[3 * c + 2];
53
+ const ny = (bz - az) * (cx - ax) - (bx - ax) * (cz - az);
54
+ if (ny >= 0) this.idx.push(a, b, c); else this.idx.push(a, c, b);
55
+ }
56
+ geometry() {
57
+ const g = new THREE.BufferGeometry();
58
+ g.setAttribute("position", new THREE.Float32BufferAttribute(this.pos, 3));
59
+ g.setAttribute("color", new THREE.Float32BufferAttribute(this.col, 3));
60
+ if (this.uv) g.setAttribute("uv", new THREE.Float32BufferAttribute(this.uv, 2));
61
+ g.setIndex(this.n > 65535 ? new THREE.Uint32BufferAttribute(this.idx, 1) : new THREE.Uint16BufferAttribute(this.idx, 1));
62
+ g.computeVertexNormals();
63
+ g.computeBoundingSphere();
64
+ return g;
65
+ }
66
+ }
67
+
68
+ function tiled(map, key) {
69
+ let b = map.get(key);
70
+ if (!b) { b = new Buf(); map.set(key, b); }
71
+ return b;
72
+ }
73
+
74
+ // Flat layers (ground, land cover, water, roads, track) are painted in a fixed order before anything 3D, without
75
+ // depth: they all lie on the ground, and centimetre offsets cannot win a depth test from kilometres away.
76
+ function flat(material, order) {
77
+ material.side = THREE.DoubleSide;
78
+ material.depthTest = false;
79
+ material.depthWrite = false;
80
+ material.userData.order = order;
81
+ return material;
82
+ }
83
+
84
+ function meshes(map, material, opts = {}) {
85
+ const group = new THREE.Group();
86
+ for (const b of map.values()) {
87
+ if (!b.n) continue;
88
+ const m = new THREE.Mesh(b.geometry(), material);
89
+ m.receiveShadow = !!opts.receive;
90
+ m.castShadow = !!opts.cast;
91
+ if (material.userData.order !== undefined) m.renderOrder = material.userData.order;
92
+ group.add(m);
93
+ }
94
+ return group;
95
+ }
96
+
97
+ // flat polygons (outer ring + holes), triangulated, at height y, into tiled buffers
98
+ function addPolygon(map, outer, holes, y, c) {
99
+ if (outer.length < 3) return;
100
+ const contour = outer.map(([x, yy]) => new THREE.Vector2(x, yy));
101
+ const hs = holes.map((h) => h.map(([x, yy]) => new THREE.Vector2(x, yy)));
102
+ let tris;
103
+ try {
104
+ tris = THREE.ShapeUtils.triangulateShape(contour, hs);
105
+ } catch {
106
+ return;
107
+ }
108
+ const all = [...outer, ...holes.flat()];
109
+ let cx = 0, cy = 0;
110
+ for (const [x, yy] of outer) { cx += x; cy += yy; }
111
+ const b = tiled(map, tileKey(cx / outer.length, cy / outer.length));
112
+ const base = b.n;
113
+ for (const [x, yy] of all) b.vert(x, y, -yy, c);
114
+ for (const [a, bb, cc] of tris) b.up(base + a, base + bb, base + cc);
115
+ }
116
+
117
+ // ribbons along polylines (roads, rivers, ditches) with mitred joins
118
+ function addRibbon(map, pts, width, y, c, uvScale = 0) {
119
+ if (pts.length < 2) return;
120
+ const h = width / 2;
121
+ const n = pts.length;
122
+ const mid = pts[n >> 1];
123
+ const b = tiled(map, tileKey(mid[0], mid[1]));
124
+ if (uvScale && !b.uv) b.uv = [];
125
+ const base = b.n;
126
+ let along = 0;
127
+ for (let i = 0; i < n; i++) {
128
+ const p = pts[i], a = pts[Math.max(0, i - 1)], q = pts[Math.min(n - 1, i + 1)];
129
+ let dx = q[0] - a[0], dy = q[1] - a[1];
130
+ const L = Math.hypot(dx, dy) || 1;
131
+ dx /= L; dy /= L;
132
+ let nx = -dy, ny = dx, k = 1;
133
+ if (i > 0 && i < n - 1) { // mitre: keep the width constant around the corner
134
+ const ex = p[0] - a[0], ey = p[1] - a[1], el = Math.hypot(ex, ey) || 1;
135
+ const sx = -ey / el, sy = ex / el;
136
+ k = 1 / Math.max(0.35, nx * sx + ny * sy);
137
+ }
138
+ if (i > 0) along += Math.hypot(p[0] - pts[i - 1][0], p[1] - pts[i - 1][1]);
139
+ b.vert(p[0] + nx * h * k, y, -(p[1] + ny * h * k), c);
140
+ b.vert(p[0] - nx * h * k, y, -(p[1] - ny * h * k), c);
141
+ if (uvScale) b.uv.push(0, along / uvScale, 1, along / uvScale);
142
+ }
143
+ for (let i = 0; i < n - 1; i++) {
144
+ const v = base + 2 * i;
145
+ b.up(v, v + 1, v + 2);
146
+ b.up(v + 1, v + 3, v + 2);
147
+ }
148
+ }
149
+
150
+ function sleeperTexture() {
151
+ const cv = document.createElement("canvas");
152
+ cv.width = 64; cv.height = 64;
153
+ const g = cv.getContext("2d");
154
+ g.fillStyle = "#8b8379"; g.fillRect(0, 0, 64, 64);
155
+ for (let i = 0; i < 400; i++) { // ballast grain
156
+ const v = 110 + Math.floor(Math.random() * 60);
157
+ g.fillStyle = `rgb(${v},${v - 6},${v - 14})`;
158
+ g.fillRect(Math.random() * 64, Math.random() * 64, 2, 2);
159
+ }
160
+ g.fillStyle = "#5a4a3d";
161
+ g.fillRect(6, 4, 52, 22); // one sleeper per 0.6 m (two per texture repeat of 1.2 m)
162
+ g.fillRect(6, 36, 52, 22);
163
+ const t = new THREE.CanvasTexture(cv);
164
+ t.wrapS = t.wrapT = THREE.RepeatWrapping;
165
+ t.anisotropy = 8;
166
+ t.colorSpace = THREE.SRGBColorSpace;
167
+ return t;
168
+ }
169
+
170
+ export function buildWorld(twin, scene, opts = {}) {
171
+ const root = new THREE.Group();
172
+ root.name = "world";
173
+ const [x0, y0, x1, y1] = twin.bounds;
174
+ const vc = (o = {}) => new THREE.MeshLambertMaterial({ vertexColors: true, ...o });
175
+
176
+ // ground
177
+ const ground = new THREE.Mesh(new THREE.PlaneGeometry(x1 - x0 + 600000, y1 - y0 + 600000),
178
+ flat(new THREE.MeshLambertMaterial({ color: COLORS.ground }), -10));
179
+ ground.renderOrder = -10;
180
+ ground.rotation.x = -Math.PI / 2;
181
+ ground.position.set((x0 + x1) / 2, 0, -(y0 + y1) / 2);
182
+ ground.receiveShadow = true;
183
+ root.add(ground);
184
+
185
+ // land cover: farmland and meadows get a slight per-field tint, so the parcel pattern shows
186
+ const lc = new Map();
187
+ let i = 0;
188
+ for (const p of twin.landcover) {
189
+ const base = COLORS.landcover[p.c] ?? COLORS.ground;
190
+ col.setHex(base);
191
+ if (p.c === 14 || p.c === 15 || p.c === 1) col.offsetHSL(((i * 37) % 11 - 5) * 0.004, 0, ((i * 53) % 9 - 4) * 0.008);
192
+ addPolygon(lc, decode(p.o), p.i.map(decode), p.c >= 14 ? Y.farmland : Y.landcover, col.clone());
193
+ i++;
194
+ }
195
+ root.add(meshes(lc, flat(vc(), -9), { receive: true }));
196
+
197
+ // water: lakes, canals and rivers as areas, ditches and drains as ribbons
198
+ const wat = new Map();
199
+ col.setHex(COLORS.water);
200
+ for (const p of twin.water) addPolygon(wat, decode(p.o), p.i.map(decode), Y.water, col);
201
+ for (const w of twin.waterways) addRibbon(wat, decode(w.p), COLORS.waterwayWidth[w.c] ?? 2, Y.waterway, col);
202
+ const waterMat = flat(new THREE.MeshPhongMaterial({ vertexColors: true, shininess: 80, specular: 0x334455 }), -8);
203
+ root.add(meshes(wat, waterMat));
204
+
205
+ // roads (Dutch cycle paths in their red asphalt)
206
+ const rd = new Map();
207
+ for (const r of [...twin.roads].sort((a, b) => b.c - a.c)) {
208
+ col.setHex(COLORS.road[r.c] ?? COLORS.road[4]);
209
+ addRibbon(rd, decode(r.p), COLORS.roadWidth[r.c] ?? 4, Y.road + (7 - r.c) * 0.004, col);
210
+ }
211
+ root.add(meshes(rd, flat(vc(), -7), { receive: true }));
212
+
213
+ // buildings: walls and flat roofs, coloured by kind
214
+ const bl = new Map();
215
+ const B = twin.buildings;
216
+ const wall = new THREE.Color(), roof = new THREE.Color();
217
+ for (let k = 0; k < B.p.length; k++) {
218
+ const ring = decode(B.p[k]);
219
+ if (ring.length < 3) continue;
220
+ const h = B.h[k];
221
+ const [wc, rc] = COLORS.building[B.k[k]] ?? COLORS.building[0];
222
+ wall.setHex(wc).offsetHSL(0, 0, ((k * 29) % 7 - 3) * 0.012);
223
+ roof.setHex(rc);
224
+ let cx = 0, cy = 0;
225
+ for (const [x, y] of ring) { cx += x; cy += y; }
226
+ const b = tiled(bl, tileKey(cx / ring.length, cy / ring.length));
227
+ // orientation: walls face outwards whichever way the ring runs
228
+ let area = 0;
229
+ for (let j = 0; j < ring.length; j++) {
230
+ const [ax, ay] = ring[j], [bx, by] = ring[(j + 1) % ring.length];
231
+ area += ax * by - bx * ay;
232
+ }
233
+ const ccw = area > 0;
234
+ for (let j = 0; j < ring.length; j++) {
235
+ const [ax, ay] = ring[j], [bx, by] = ring[(j + 1) % ring.length];
236
+ const v = b.n;
237
+ b.vert(ax, 0, -ay, wall); b.vert(bx, 0, -by, wall); b.vert(bx, h, -by, wall); b.vert(ax, h, -ay, wall);
238
+ if (ccw) b.idx.push(v, v + 1, v + 2, v, v + 2, v + 3);
239
+ else b.idx.push(v, v + 2, v + 1, v, v + 3, v + 2);
240
+ }
241
+ let tris;
242
+ try { tris = THREE.ShapeUtils.triangulateShape(ring.map(([x, y]) => new THREE.Vector2(x, y)), []); }
243
+ catch { tris = []; }
244
+ const v0 = b.n;
245
+ for (const [x, y] of ring) b.vert(x, h, -y, roof);
246
+ for (const [a, bb, c] of tris) b.up(v0 + a, v0 + bb, v0 + c);
247
+ }
248
+ const buildings = meshes(bl, vc({ flatShading: true }), { cast: true, receive: true });
249
+ buildings.name = "buildings";
250
+ root.add(buildings);
251
+
252
+ // track: ballast with sleepers, then the two rails (1435 mm gauge)
253
+ const tex = sleeperTexture();
254
+ const bal = new Map(), rails = new Map();
255
+ const white = new THREE.Color(0xffffff), steel = new THREE.Color(COLORS.rail);
256
+ for (const t of twin.tracks) {
257
+ const pts = decode(t.p);
258
+ addRibbon(bal, pts, t.k ? 2.8 : 3.2, Y.track, white, 1.2);
259
+ for (const off of [-0.7175, 0.7175]) {
260
+ const shifted = offsetLine(pts, off);
261
+ addRibbon(rails, shifted, 0.075, Y.track + 0.17, steel);
262
+ }
263
+ }
264
+ root.add(meshes(bal, flat(new THREE.MeshLambertMaterial({ map: tex, vertexColors: true }), -6), { receive: true }));
265
+ root.add(meshes(rails, flat(new THREE.MeshPhongMaterial({ vertexColors: true, shininess: 90, specular: 0x999999 }), -5)));
266
+
267
+ // platforms: 76 cm high, concrete, with the yellow safety line
268
+ const pl = new Map(), edge = new Map();
269
+ for (const p of twin.platforms) {
270
+ const ring = decode(p.p);
271
+ if (ring.length < 3) continue;
272
+ extrude(pl, ring, 0.76, new THREE.Color(COLORS.platform));
273
+ addRibbon(edge, offsetRing(ring, -0.6), 0.1, 0.78, new THREE.Color(COLORS.platformEdge));
274
+ }
275
+ root.add(meshes(pl, vc(), { receive: true, cast: true }));
276
+ root.add(meshes(edge, vc()));
277
+
278
+ // trees: rows (Frisian alder rows and hedgerows), single trees, woods
279
+ root.add(buildTrees(twin, opts.treeCap ?? 60000));
280
+ root.add(buildLandmarks(twin, opts));
281
+ scene.add(root);
282
+ return root;
283
+ }
284
+
285
+ function offsetLine(pts, d) {
286
+ const out = [];
287
+ for (let i = 0; i < pts.length; i++) {
288
+ const a = pts[Math.max(0, i - 1)], b = pts[Math.min(pts.length - 1, i + 1)];
289
+ const dx = b[0] - a[0], dy = b[1] - a[1], L = Math.hypot(dx, dy) || 1;
290
+ out.push([pts[i][0] - dy / L * d, pts[i][1] + dx / L * d]);
291
+ }
292
+ return out;
293
+ }
294
+
295
+ function offsetRing(ring, d) {
296
+ const closed = [...ring, ring[0]];
297
+ return offsetLine(closed, d);
298
+ }
299
+
300
+ function extrude(map, ring, h, c) {
301
+ let cx = 0, cy = 0;
302
+ for (const [x, y] of ring) { cx += x; cy += y; }
303
+ const b = tiled(map, tileKey(cx / ring.length, cy / ring.length));
304
+ let area = 0;
305
+ for (let j = 0; j < ring.length; j++) {
306
+ const [ax, ay] = ring[j], [bx, by] = ring[(j + 1) % ring.length];
307
+ area += ax * by - bx * ay;
308
+ }
309
+ for (let j = 0; j < ring.length; j++) {
310
+ const [ax, ay] = ring[j], [bx, by] = ring[(j + 1) % ring.length];
311
+ const v = b.n;
312
+ b.vert(ax, 0, -ay, c); b.vert(bx, 0, -by, c); b.vert(bx, h, -by, c); b.vert(ax, h, -ay, c);
313
+ if (area > 0) b.idx.push(v, v + 1, v + 2, v, v + 2, v + 3); else b.idx.push(v, v + 2, v + 1, v, v + 3, v + 2);
314
+ }
315
+ let tris;
316
+ try { tris = THREE.ShapeUtils.triangulateShape(ring.map(([x, y]) => new THREE.Vector2(x, y)), []); }
317
+ catch { tris = []; }
318
+ const v0 = b.n;
319
+ for (const [x, y] of ring) b.vert(x, h, -y, c);
320
+ for (const [a, bb, cc] of tris) b.up(v0 + a, v0 + bb, v0 + cc);
321
+ }
322
+
323
+ // deterministic pseudo-random (no Math.random in the scenery: the same twin always looks the same)
324
+ function rng(seed) {
325
+ let s = seed >>> 0;
326
+ return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296);
327
+ }
328
+
329
+ function treeGeometry() {
330
+ const trunk = new THREE.CylinderGeometry(0.18, 0.25, 3, 4);
331
+ trunk.translate(0, 1.5, 0);
332
+ const crown = new THREE.IcosahedronGeometry(2.6, 0);
333
+ crown.scale(1, 1.25, 1);
334
+ crown.translate(0, 5.2, 0);
335
+ const paint = (g, c) => {
336
+ const n = g.attributes.position.count, arr = new Float32Array(n * 3);
337
+ col.setHex(c);
338
+ for (let i = 0; i < n; i++) arr.set([col.r, col.g, col.b], i * 3);
339
+ g.setAttribute("color", new THREE.BufferAttribute(arr, 3));
340
+ return g;
341
+ };
342
+ const flatGeo = (g) => (g.index ? g.toNonIndexed() : g);
343
+ const a = paint(flatGeo(trunk), COLORS.trunk), b = paint(flatGeo(crown), COLORS.crown);
344
+ const g = new THREE.BufferGeometry();
345
+ for (const name of ["position", "normal", "color"]) {
346
+ const x = a.attributes[name], y = b.attributes[name];
347
+ const arr = new Float32Array(x.array.length + y.array.length);
348
+ arr.set(x.array); arr.set(y.array, x.array.length);
349
+ g.setAttribute(name, new THREE.BufferAttribute(arr, x.itemSize));
350
+ }
351
+ g.computeVertexNormals();
352
+ return g;
353
+ }
354
+
355
+ function buildTrees(twin, cap) {
356
+ const spots = [];
357
+ const r = rng(7);
358
+ for (const row of twin.trees.rows) {
359
+ const pts = decode(row);
360
+ for (let i = 1; i < pts.length; i++) {
361
+ const [ax, ay] = pts[i - 1], [bx, by] = pts[i];
362
+ const L = Math.hypot(bx - ax, by - ay);
363
+ for (let d = r() * 7; d < L; d += 6.5 + r() * 2) spots.push([ax + (bx - ax) * d / L, ay + (by - ay) * d / L]);
364
+ }
365
+ }
366
+ const P = twin.trees.points;
367
+ for (let i = 0; i < P.length; i += 2) spots.push([P[i] / 10, P[i + 1] / 10]);
368
+ for (const p of twin.landcover) { // woods: scattered trees, up to one per 120 m2
369
+ if (p.c !== 7) continue;
370
+ const ring = decode(p.o);
371
+ let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
372
+ for (const [x, y] of ring) { minx = Math.min(minx, x); maxx = Math.max(maxx, x); miny = Math.min(miny, y); maxy = Math.max(maxy, y); }
373
+ const step = 11;
374
+ for (let x = minx; x < maxx; x += step) {
375
+ for (let y = miny; y < maxy; y += step) {
376
+ const px = x + r() * step, py = y + r() * step;
377
+ if (inside(px, py, ring)) spots.push([px, py]);
378
+ if (spots.length > cap) break;
379
+ }
380
+ }
381
+ }
382
+ const group = new THREE.Group();
383
+ group.name = "trees";
384
+ const byTile = new Map();
385
+ for (const s of spots.slice(0, cap)) {
386
+ const k = tileKey(s[0], s[1]);
387
+ if (!byTile.has(k)) byTile.set(k, []);
388
+ byTile.get(k).push(s);
389
+ }
390
+ const geo = treeGeometry();
391
+ const mat = new THREE.MeshLambertMaterial({ vertexColors: true, flatShading: true });
392
+ const m4 = new THREE.Matrix4(), q = new THREE.Quaternion(), sc = new THREE.Vector3(), pos = new THREE.Vector3();
393
+ const up = new THREE.Vector3(0, 1, 0);
394
+ for (const list of byTile.values()) {
395
+ const im = new THREE.InstancedMesh(geo, mat, list.length);
396
+ list.forEach(([x, y], j) => {
397
+ const s = 0.7 + r() * 0.6;
398
+ q.setFromAxisAngle(up, r() * Math.PI * 2);
399
+ m4.compose(pos.set(x, 0, -y), q, sc.set(s, s * (0.85 + r() * 0.4), s));
400
+ im.setMatrixAt(j, m4);
401
+ const v = 0.82 + r() * 0.3; // brightness only: keeps green crowns and brown trunks
402
+ im.setColorAt(j, col.setRGB(v, v * (0.98 + r() * 0.06), v));
403
+ });
404
+ im.computeBoundingSphere();
405
+ im.castShadow = true;
406
+ im.userData.centre = new THREE.Vector3(list[0][0], 0, -list[0][1]);
407
+ group.add(im);
408
+ }
409
+ return group;
410
+ }
411
+
412
+ function inside(x, y, ring) {
413
+ let c = false;
414
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
415
+ const [xi, yi] = ring[i], [xj, yj] = ring[j];
416
+ if ((yi > y) !== (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi) c = !c;
417
+ }
418
+ return c;
419
+ }
420
+
421
+ // windmills (the Frisian polder mills), wind turbines, water towers. Static parts are merged into one mesh per
422
+ // material; blades and sails are instanced and turn in animateWorld().
423
+ function buildLandmarks(twin) {
424
+ const group = new THREE.Group();
425
+ group.name = "landmarks";
426
+ const parts = { white: [], body: [], cap: [], brick: [] };
427
+ const rotors = [];
428
+ const m4 = new THREE.Matrix4(), q = new THREE.Quaternion(), up = new THREE.Vector3(0, 1, 0);
429
+ const put = (kind, geo, x, y, z, yaw = 0) => {
430
+ m4.compose(new THREE.Vector3(x, y, z), q.setFromAxisAngle(up, yaw), new THREE.Vector3(1, 1, 1));
431
+ parts[kind].push((geo.index ? geo.toNonIndexed() : geo).applyMatrix4(m4));
432
+ };
433
+ const YAW = 0.6; // facing the prevailing south-westerly
434
+ for (const m of twin.landmarks) {
435
+ const x = m.x, z = -m.y;
436
+ const dx = Math.cos(YAW), dz = -Math.sin(YAW); // the rotor side, in the world frame
437
+ if (m.k === "turbine") {
438
+ const h = Math.max(40, Math.min(m.h, 140));
439
+ put("white", new THREE.CylinderGeometry(1.2, 2.2, h, 10), x, h / 2, z);
440
+ put("white", new THREE.BoxGeometry(6, 3, 3), x, h + 1, z, YAW);
441
+ rotors.push({ kind: "blade", n: 3, len: h * 0.45, hub: new THREE.Vector3(x + dx * 3.2, h + 1, z + dz * 3.2), speed: 1.6,
442
+ phase: (x * 0.013) % 6.28 });
443
+ } else if (m.k === "mill") {
444
+ const h = Math.max(8, Math.min(m.h, 30));
445
+ put("body", new THREE.CylinderGeometry(2.2, 4.2, h * 0.7, 8), x, h * 0.35, z);
446
+ put("cap", new THREE.ConeGeometry(2.8, h * 0.18, 8), x, h * 0.79, z);
447
+ rotors.push({ kind: "sail", n: 4, len: h * 0.55, hub: new THREE.Vector3(x + dx * 2.8, h * 0.72, z + dz * 2.8), speed: 0.5,
448
+ phase: (x * 0.017) % 6.28 });
449
+ } else if (m.k === "water_tower") {
450
+ const h = Math.max(15, Math.min(m.h, 50));
451
+ put("brick", new THREE.CylinderGeometry(3, 3.5, h * 0.75, 12), x, h * 0.375, z);
452
+ put("brick", new THREE.CylinderGeometry(7, 5, h * 0.25, 16), x, h * 0.875, z);
453
+ } else {
454
+ const h = Math.max(10, Math.min(m.h, 120));
455
+ put("white", new THREE.CylinderGeometry(1.5, 2.5, h, 8), x, h / 2, z);
456
+ }
457
+ }
458
+ const mats = {
459
+ white: new THREE.MeshLambertMaterial({ color: 0xf2f2f0 }),
460
+ body: new THREE.MeshLambertMaterial({ color: 0x4a4038, flatShading: true }),
461
+ cap: new THREE.MeshLambertMaterial({ color: 0x2f3a30, flatShading: true }),
462
+ brick: new THREE.MeshLambertMaterial({ color: 0x8c5a45, flatShading: true }),
463
+ };
464
+ for (const [k, list] of Object.entries(parts)) {
465
+ if (!list.length) continue;
466
+ const mesh = new THREE.Mesh(mergeGeometries(list), mats[k]);
467
+ mesh.castShadow = true;
468
+ group.add(mesh);
469
+ }
470
+ // one instanced mesh per rotor part; unit length along +y from the hub
471
+ const bladeGeo = new THREE.BoxGeometry(0.012, 1, 0.035);
472
+ bladeGeo.translate(0, 0.5, 0);
473
+ const sailGeo = mergeGeometries([new THREE.PlaneGeometry(0.004, 1).translate(0, 0.5, 0).toNonIndexed(),
474
+ new THREE.PlaneGeometry(0.075, 0.78).translate(0, 0.56, 0.035).toNonIndexed()]);
475
+ sailGeo.rotateY(Math.PI / 2);
476
+ const inst = {};
477
+ for (const [kind, geo, mat] of [["blade", bladeGeo, mats.white],
478
+ ["sail", sailGeo, new THREE.MeshLambertMaterial({ color: 0xe8e0d0, side: THREE.DoubleSide })]]) {
479
+ const count = rotors.filter((r) => r.kind === kind).reduce((a, r) => a + r.n, 0);
480
+ if (!count) continue;
481
+ const im = new THREE.InstancedMesh(geo, mat, count);
482
+ im.frustumCulled = false;
483
+ group.add(im);
484
+ inst[kind] = im;
485
+ }
486
+ group.userData = { rotors, inst, t: 0 };
487
+ setRotors(group, 0);
488
+ return group;
489
+ }
490
+
491
+ const _m = new THREE.Matrix4(), _r = new THREE.Matrix4(), _y = new THREE.Matrix4(), _s = new THREE.Matrix4();
492
+
493
+ function setRotors(group, t) {
494
+ const { rotors, inst } = group.userData;
495
+ const idx = { blade: 0, sail: 0 };
496
+ _y.makeRotationY(0.6);
497
+ for (const r of rotors) {
498
+ const im = inst[r.kind];
499
+ for (let k = 0; k < r.n; k++) {
500
+ _r.makeRotationX(r.phase + t * r.speed + (k * 2 * Math.PI) / r.n);
501
+ _s.makeScale(r.len, r.len, r.len);
502
+ _m.makeTranslation(r.hub.x, r.hub.y, r.hub.z).multiply(_y).multiply(_r).multiply(_s);
503
+ im.setMatrixAt(idx[r.kind]++, _m);
504
+ }
505
+ }
506
+ for (const im of Object.values(inst)) im.instanceMatrix.needsUpdate = true;
507
+ }
508
+
509
+ export function animateWorld(root, dtReal) {
510
+ const lm = root.getObjectByName("landmarks");
511
+ if (!lm) return;
512
+ lm.userData.t += dtReal;
513
+ setRotors(lm, lm.userData.t);
514
+ }
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ openenv==0.7.0
2
+ fastmcp>=3,<4
runs/2026-10-07-sonnet-5-5-eval45/index.json ADDED
The diff for this file is too large to render. See raw diff
 
runs/2026-10-08-deepseek-v4-1-flash-eval45/index.json ADDED
The diff for this file is too large to render. See raw diff
 
runs/2026-10-08-glm-5-3-flash-eval45/index.json ADDED
@@ -0,0 +1,4831 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "run": "2026-10-08-glm-5-3-flash-eval45",
3
+ "episodes": [
4
+ {
5
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
6
+ "task_id": "gnlw-2025-01-20-1800-180m-compound-0",
7
+ "split": "eval",
8
+ "difficulty": "compound",
9
+ "day": "2025-01-20",
10
+ "runs": 24,
11
+ "reward": 0.066667,
12
+ "submitted": true,
13
+ "feasible": false,
14
+ "cost": null,
15
+ "optimal_cost": 664,
16
+ "floor_cost": 539,
17
+ "greedy_cost": 2501,
18
+ "real_cost": 64,
19
+ "hardness": {
20
+ "runs": 24,
21
+ "sets": 6,
22
+ "stops": 147,
23
+ "conflicts": 11,
24
+ "single_track": 5,
25
+ "headway": 3,
26
+ "platforms": 0,
27
+ "closed": 3,
28
+ "decisions": 43,
29
+ "holds": 36,
30
+ "short_turns": 7,
31
+ "cancels": 0,
32
+ "turn_points": 2,
33
+ "avoidable": 125,
34
+ "optimal": 664,
35
+ "greedy_gap": 0.7999,
36
+ "closed_min": 46,
37
+ "slow_min": 0,
38
+ "track_min": 0,
39
+ "single_closed": true,
40
+ "mid_line": true,
41
+ "late_sets": 1
42
+ },
43
+ "violations": 24,
44
+ "agent_turns": 12,
45
+ "checks": 6,
46
+ "seconds": 3979.1,
47
+ "input_tokens": 3090635,
48
+ "cache_read_tokens": 2529856,
49
+ "cache_write_tokens": 0,
50
+ "output_tokens": 500205,
51
+ "served_by": [
52
+ "zai-org/GLM-5.3-Flash"
53
+ ],
54
+ "end_reason": "submitted",
55
+ "errors": 0,
56
+ "plan": [
57
+ {
58
+ "train": "37466",
59
+ "to": "HDG"
60
+ },
61
+ {
62
+ "train": "37357",
63
+ "to": "HDG",
64
+ "hold": {
65
+ "FWD": "18:51"
66
+ }
67
+ },
68
+ {
69
+ "train": "37468",
70
+ "from": "HDG"
71
+ },
72
+ {
73
+ "train": "37459",
74
+ "hold": {
75
+ "FWD": "19:02"
76
+ }
77
+ },
78
+ {
79
+ "train": "37461",
80
+ "hold": {
81
+ "FWD": "19:05"
82
+ }
83
+ },
84
+ {
85
+ "train": "37359",
86
+ "to": "HDG",
87
+ "hold": {
88
+ "ZH": "18:40",
89
+ "BP": "19:02"
90
+ }
91
+ },
92
+ {
93
+ "train": "37470",
94
+ "from": "HDG"
95
+ },
96
+ {
97
+ "train": "37361",
98
+ "hold": {
99
+ "BP": "19:05",
100
+ "FWD": "19:15"
101
+ }
102
+ },
103
+ {
104
+ "train": "37472",
105
+ "hold": {
106
+ "LW": "19:31"
107
+ }
108
+ },
109
+ {
110
+ "train": "37463",
111
+ "from": "HDG",
112
+ "hold": {
113
+ "HDG": "19:37"
114
+ }
115
+ },
116
+ {
117
+ "train": "37465",
118
+ "hold": {
119
+ "GN": "19:38"
120
+ }
121
+ },
122
+ {
123
+ "train": "37467",
124
+ "hold": {
125
+ "GN": "20:07"
126
+ }
127
+ },
128
+ {
129
+ "train": "37374",
130
+ "hold": {
131
+ "LW": "20:26"
132
+ }
133
+ },
134
+ {
135
+ "train": "37376",
136
+ "hold": {
137
+ "LW": "20:30"
138
+ }
139
+ }
140
+ ]
141
+ },
142
+ {
143
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
144
+ "task_id": "gnlw-2025-01-21-0626-180m-real-0",
145
+ "split": "eval",
146
+ "difficulty": "real",
147
+ "day": "2025-01-21",
148
+ "runs": 29,
149
+ "reward": 0.0,
150
+ "submitted": false,
151
+ "feasible": false,
152
+ "cost": null,
153
+ "optimal_cost": 445,
154
+ "floor_cost": 356,
155
+ "greedy_cost": 1980,
156
+ "real_cost": 1308,
157
+ "hardness": {
158
+ "runs": 29,
159
+ "sets": 7,
160
+ "stops": 199,
161
+ "conflicts": 18,
162
+ "single_track": 14,
163
+ "headway": 4,
164
+ "platforms": 0,
165
+ "closed": 0,
166
+ "decisions": 21,
167
+ "holds": 17,
168
+ "short_turns": 4,
169
+ "cancels": 0,
170
+ "turn_points": 1,
171
+ "avoidable": 89,
172
+ "optimal": 445,
173
+ "greedy_gap": 0.8,
174
+ "closed_min": 0,
175
+ "slow_min": 69,
176
+ "track_min": 0,
177
+ "single_closed": false,
178
+ "mid_line": false,
179
+ "late_sets": 0
180
+ },
181
+ "violations": 0,
182
+ "agent_turns": 4,
183
+ "checks": 2,
184
+ "seconds": 1782.2,
185
+ "input_tokens": 297014,
186
+ "cache_read_tokens": 69888,
187
+ "cache_write_tokens": 0,
188
+ "output_tokens": 192349,
189
+ "served_by": [
190
+ "zai-org/GLM-5.3-Flash"
191
+ ],
192
+ "end_reason": "no_tool_call",
193
+ "errors": 0,
194
+ "plan": null
195
+ },
196
+ {
197
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
198
+ "task_id": "gnlw-2025-01-22-1600-240m-compound-0",
199
+ "split": "eval",
200
+ "difficulty": "compound",
201
+ "day": "2025-01-22",
202
+ "runs": 38,
203
+ "reward": 0.0,
204
+ "submitted": false,
205
+ "feasible": false,
206
+ "cost": null,
207
+ "optimal_cost": 670,
208
+ "floor_cost": 627,
209
+ "greedy_cost": 5661,
210
+ "real_cost": 112,
211
+ "hardness": {
212
+ "runs": 38,
213
+ "sets": 7,
214
+ "stops": 252,
215
+ "conflicts": 28,
216
+ "single_track": 7,
217
+ "headway": 10,
218
+ "platforms": 0,
219
+ "closed": 11,
220
+ "decisions": 24,
221
+ "holds": 18,
222
+ "short_turns": 6,
223
+ "cancels": 0,
224
+ "turn_points": 3,
225
+ "avoidable": 43,
226
+ "optimal": 670,
227
+ "greedy_gap": 0.8,
228
+ "closed_min": 36,
229
+ "slow_min": 33,
230
+ "track_min": 0,
231
+ "single_closed": true,
232
+ "mid_line": false,
233
+ "late_sets": 1
234
+ },
235
+ "violations": 0,
236
+ "agent_turns": 4,
237
+ "checks": 1,
238
+ "seconds": 2084.4,
239
+ "input_tokens": 301096,
240
+ "cache_read_tokens": 144000,
241
+ "cache_write_tokens": 0,
242
+ "output_tokens": 192680,
243
+ "served_by": [
244
+ "zai-org/GLM-5.3-Flash"
245
+ ],
246
+ "end_reason": "no_tool_call",
247
+ "errors": 0,
248
+ "plan": null
249
+ },
250
+ {
251
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
252
+ "task_id": "gnlw-2025-01-22-1800-150m-loop-0",
253
+ "split": "eval",
254
+ "difficulty": "loop",
255
+ "day": "2025-01-22",
256
+ "runs": 25,
257
+ "reward": 0.0,
258
+ "submitted": false,
259
+ "feasible": false,
260
+ "cost": null,
261
+ "optimal_cost": 397,
262
+ "floor_cost": 336,
263
+ "greedy_cost": 562,
264
+ "real_cost": 79,
265
+ "hardness": {
266
+ "runs": 25,
267
+ "sets": 7,
268
+ "stops": 158,
269
+ "conflicts": 18,
270
+ "single_track": 18,
271
+ "headway": 0,
272
+ "platforms": 0,
273
+ "closed": 0,
274
+ "decisions": 19,
275
+ "holds": 19,
276
+ "short_turns": 0,
277
+ "cancels": 0,
278
+ "turn_points": 0,
279
+ "avoidable": 61,
280
+ "optimal": 397,
281
+ "greedy_gap": 0.697,
282
+ "closed_min": 0,
283
+ "slow_min": 138,
284
+ "track_min": 10,
285
+ "single_closed": false,
286
+ "mid_line": false,
287
+ "late_sets": 0
288
+ },
289
+ "violations": 0,
290
+ "agent_turns": 2,
291
+ "checks": 0,
292
+ "seconds": 1286.8,
293
+ "input_tokens": 74757,
294
+ "cache_read_tokens": 1600,
295
+ "cache_write_tokens": 0,
296
+ "output_tokens": 128000,
297
+ "served_by": [
298
+ "zai-org/GLM-5.3-Flash"
299
+ ],
300
+ "end_reason": "no_tool_call",
301
+ "errors": 0,
302
+ "plan": null
303
+ },
304
+ {
305
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
306
+ "task_id": "gnlw-2025-01-23-0800-210m-compound-0",
307
+ "split": "eval",
308
+ "difficulty": "compound",
309
+ "day": "2025-01-23",
310
+ "runs": 34,
311
+ "reward": 0.1,
312
+ "submitted": true,
313
+ "feasible": false,
314
+ "cost": null,
315
+ "optimal_cost": 894,
316
+ "floor_cost": 841,
317
+ "greedy_cost": 4449,
318
+ "real_cost": 83,
319
+ "hardness": {
320
+ "runs": 34,
321
+ "sets": 7,
322
+ "stops": 226,
323
+ "conflicts": 51,
324
+ "single_track": 17,
325
+ "headway": 20,
326
+ "platforms": 0,
327
+ "closed": 14,
328
+ "decisions": 29,
329
+ "holds": 18,
330
+ "short_turns": 11,
331
+ "cancels": 0,
332
+ "turn_points": 3,
333
+ "avoidable": 53,
334
+ "optimal": 894,
335
+ "greedy_gap": 0.8,
336
+ "closed_min": 24,
337
+ "slow_min": 69,
338
+ "track_min": 0,
339
+ "single_closed": true,
340
+ "mid_line": false,
341
+ "late_sets": 1
342
+ },
343
+ "violations": 26,
344
+ "agent_turns": 12,
345
+ "checks": 8,
346
+ "seconds": 2844.5,
347
+ "input_tokens": 2416702,
348
+ "cache_read_tokens": 1687296,
349
+ "cache_write_tokens": 0,
350
+ "output_tokens": 350218,
351
+ "served_by": [
352
+ "zai-org/GLM-5.3-Flash"
353
+ ],
354
+ "end_reason": "submitted",
355
+ "errors": 0,
356
+ "plan": [
357
+ {
358
+ "hold": {
359
+ "HDG": "08:01"
360
+ },
361
+ "train": "37426"
362
+ },
363
+ {
364
+ "hold": {
365
+ "FWD": "08:04"
366
+ },
367
+ "train": "37317"
368
+ },
369
+ {
370
+ "hold": {
371
+ "LWC": "08:44"
372
+ },
373
+ "to": "BP",
374
+ "train": "37328"
375
+ },
376
+ {
377
+ "hold": {
378
+ "LW": "08:44",
379
+ "LWC": "08:48",
380
+ "GK": "09:14"
381
+ },
382
+ "train": "37428"
383
+ },
384
+ {
385
+ "hold": {
386
+ "BP": "08:44",
387
+ "FWD": "08:55"
388
+ },
389
+ "train": "37419"
390
+ },
391
+ {
392
+ "hold": {
393
+ "BP": "08:48",
394
+ "FWD": "08:58"
395
+ },
396
+ "train": "37421"
397
+ },
398
+ {
399
+ "hold": {
400
+ "HDG": "09:25"
401
+ },
402
+ "train": "37319"
403
+ },
404
+ {
405
+ "hold": {
406
+ "GN": "08:45",
407
+ "FWD": "09:18",
408
+ "HDG": "09:22"
409
+ },
410
+ "train": "37321"
411
+ },
412
+ {
413
+ "hold": {
414
+ "FWD": "09:20"
415
+ },
416
+ "to": "ZH",
417
+ "train": "37330"
418
+ },
419
+ {
420
+ "hold": {
421
+ "ZH": "09:50",
422
+ "HDG": "10:15"
423
+ },
424
+ "train": "37425"
425
+ },
426
+ {
427
+ "hold": {
428
+ "LW": "09:35",
429
+ "GK": "10:01"
430
+ },
431
+ "to": "ZH",
432
+ "train": "37430"
433
+ },
434
+ {
435
+ "hold": {
436
+ "LW": "09:58"
437
+ },
438
+ "train": "37334"
439
+ },
440
+ {
441
+ "hold": {
442
+ "LW": "09:59"
443
+ },
444
+ "train": "37434"
445
+ },
446
+ {
447
+ "from": "ZH",
448
+ "hold": {
449
+ "ZH": "10:10"
450
+ },
451
+ "train": "37427"
452
+ },
453
+ {
454
+ "hold": {
455
+ "HDG": "09:44"
456
+ },
457
+ "train": "37423"
458
+ },
459
+ {
460
+ "from": "BP",
461
+ "hold": {
462
+ "BP": "09:30",
463
+ "HDG": "09:41"
464
+ },
465
+ "train": "37323"
466
+ },
467
+ {
468
+ "hold": {
469
+ "HDG": "09:40",
470
+ "GK": "10:04"
471
+ },
472
+ "train": "37432"
473
+ },
474
+ {
475
+ "hold": {
476
+ "GK": "10:52"
477
+ },
478
+ "train": "37336"
479
+ },
480
+ {
481
+ "hold": {
482
+ "HDG": "10:52"
483
+ },
484
+ "train": "37327"
485
+ },
486
+ {
487
+ "hold": {
488
+ "HDG": "11:11"
489
+ },
490
+ "train": "37429"
491
+ },
492
+ {
493
+ "hold": {
494
+ "FWD": "11:05"
495
+ },
496
+ "train": "37329"
497
+ },
498
+ {
499
+ "hold": {
500
+ "HDG": "11:36"
501
+ },
502
+ "train": "37431"
503
+ },
504
+ {
505
+ "hold": {
506
+ "FWD": "11:41"
507
+ },
508
+ "train": "37331"
509
+ },
510
+ {
511
+ "hold": {
512
+ "GN": "11:27"
513
+ },
514
+ "train": "37433"
515
+ },
516
+ {
517
+ "hold": {
518
+ "LW": "11:27",
519
+ "DWE": "11:44"
520
+ },
521
+ "train": "37440"
522
+ }
523
+ ]
524
+ },
525
+ {
526
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
527
+ "task_id": "gnlw-2025-01-23-0900-180m-compound-0",
528
+ "split": "eval",
529
+ "difficulty": "compound",
530
+ "day": "2025-01-23",
531
+ "runs": 30,
532
+ "reward": 0.236904,
533
+ "submitted": true,
534
+ "feasible": true,
535
+ "cost": 1236,
536
+ "optimal_cost": 853,
537
+ "floor_cost": 704,
538
+ "greedy_cost": 3025,
539
+ "real_cost": 61,
540
+ "hardness": {
541
+ "runs": 30,
542
+ "sets": 7,
543
+ "stops": 200,
544
+ "conflicts": 41,
545
+ "single_track": 29,
546
+ "headway": 5,
547
+ "platforms": 0,
548
+ "closed": 7,
549
+ "decisions": 31,
550
+ "holds": 17,
551
+ "short_turns": 14,
552
+ "cancels": 0,
553
+ "turn_points": 2,
554
+ "avoidable": 149,
555
+ "optimal": 853,
556
+ "greedy_gap": 0.8,
557
+ "closed_min": 23,
558
+ "slow_min": 150,
559
+ "track_min": 10,
560
+ "single_closed": true,
561
+ "mid_line": false,
562
+ "late_sets": 1
563
+ },
564
+ "violations": 0,
565
+ "agent_turns": 12,
566
+ "checks": 7,
567
+ "seconds": 3393.6,
568
+ "input_tokens": 2461633,
569
+ "cache_read_tokens": 1489088,
570
+ "cache_write_tokens": 0,
571
+ "output_tokens": 345648,
572
+ "served_by": [
573
+ "zai-org/GLM-5.3-Flash"
574
+ ],
575
+ "end_reason": "submitted",
576
+ "errors": 0,
577
+ "plan": [
578
+ {
579
+ "hold": {
580
+ "LWC": "09:28"
581
+ },
582
+ "train": "37421"
583
+ },
584
+ {
585
+ "to": "HDG",
586
+ "train": "37321"
587
+ },
588
+ {
589
+ "from": "HDG",
590
+ "train": "37432"
591
+ },
592
+ {
593
+ "hold": {
594
+ "FWD": "09:49"
595
+ },
596
+ "to": "HDG",
597
+ "train": "37323"
598
+ },
599
+ {
600
+ "hold": {
601
+ "LW": "09:40"
602
+ },
603
+ "to": "BP",
604
+ "train": "37332"
605
+ },
606
+ {
607
+ "from": "HDG",
608
+ "train": "37434"
609
+ },
610
+ {
611
+ "hold": {
612
+ "GN": "09:27"
613
+ },
614
+ "train": "37425"
615
+ },
616
+ {
617
+ "hold": {
618
+ "ZH": "09:57"
619
+ },
620
+ "train": "37325"
621
+ },
622
+ {
623
+ "hold": {
624
+ "GK": "10:47",
625
+ "LW": "10:24"
626
+ },
627
+ "train": "37336"
628
+ },
629
+ {
630
+ "hold": {
631
+ "FWD": "10:32",
632
+ "ZH": "10:11"
633
+ },
634
+ "train": "37427"
635
+ },
636
+ {
637
+ "hold": {
638
+ "HDG": "10:37"
639
+ },
640
+ "train": "37436"
641
+ },
642
+ {
643
+ "from": "BP",
644
+ "hold": {
645
+ "BP": "10:33"
646
+ },
647
+ "train": "37327"
648
+ },
649
+ {
650
+ "hold": {
651
+ "FWD": "11:05",
652
+ "GN": "10:27"
653
+ },
654
+ "train": "37429"
655
+ },
656
+ {
657
+ "hold": {
658
+ "GN": "10:43",
659
+ "ZH": "11:00"
660
+ },
661
+ "train": "37329"
662
+ },
663
+ {
664
+ "hold": {
665
+ "LW": "11:28"
666
+ },
667
+ "train": "37340"
668
+ },
669
+ {
670
+ "hold": {
671
+ "FWD": "11:31",
672
+ "HDG": "11:38",
673
+ "ZH": "11:11"
674
+ },
675
+ "train": "37431"
676
+ },
677
+ {
678
+ "hold": {
679
+ "GK": "12:00",
680
+ "HDG": "11:38",
681
+ "LW": "11:31"
682
+ },
683
+ "train": "37440"
684
+ },
685
+ {
686
+ "hold": {
687
+ "GK": "11:15",
688
+ "LW": "10:48",
689
+ "ZH": "11:20"
690
+ },
691
+ "train": "37338"
692
+ },
693
+ {
694
+ "hold": {
695
+ "GN": "11:16"
696
+ },
697
+ "train": "37331"
698
+ },
699
+ {
700
+ "hold": {
701
+ "FWD": "12:04"
702
+ },
703
+ "train": "37433"
704
+ },
705
+ {
706
+ "hold": {
707
+ "LW": "11:56"
708
+ },
709
+ "train": "37342"
710
+ },
711
+ {
712
+ "hold": {
713
+ "HDG": "12:07"
714
+ },
715
+ "train": "37442"
716
+ },
717
+ {
718
+ "hold": {
719
+ "ZH": "11:36"
720
+ },
721
+ "train": "37438"
722
+ }
723
+ ]
724
+ },
725
+ {
726
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
727
+ "task_id": "gnlw-2025-01-25-0630-180m-loop-0",
728
+ "split": "eval",
729
+ "difficulty": "loop",
730
+ "day": "2025-01-25",
731
+ "runs": 20,
732
+ "reward": 1.0,
733
+ "submitted": true,
734
+ "feasible": true,
735
+ "cost": 116,
736
+ "optimal_cost": 116,
737
+ "floor_cost": 102,
738
+ "greedy_cost": 518,
739
+ "real_cost": 62,
740
+ "hardness": {
741
+ "runs": 20,
742
+ "sets": 7,
743
+ "stops": 150,
744
+ "conflicts": 3,
745
+ "single_track": 3,
746
+ "headway": 0,
747
+ "platforms": 0,
748
+ "closed": 0,
749
+ "decisions": 23,
750
+ "holds": 23,
751
+ "short_turns": 0,
752
+ "cancels": 0,
753
+ "turn_points": 0,
754
+ "avoidable": 14,
755
+ "optimal": 116,
756
+ "greedy_gap": 0.7993,
757
+ "closed_min": 0,
758
+ "slow_min": 69,
759
+ "track_min": 10,
760
+ "single_closed": false,
761
+ "mid_line": false,
762
+ "late_sets": 0
763
+ },
764
+ "violations": 0,
765
+ "agent_turns": 6,
766
+ "checks": 3,
767
+ "seconds": 1156.3,
768
+ "input_tokens": 426953,
769
+ "cache_read_tokens": 273536,
770
+ "cache_write_tokens": 0,
771
+ "output_tokens": 123383,
772
+ "served_by": [
773
+ "zai-org/GLM-5.3-Flash"
774
+ ],
775
+ "end_reason": "submitted",
776
+ "errors": 0,
777
+ "plan": [
778
+ {
779
+ "hold": {
780
+ "ZH": "08:01"
781
+ },
782
+ "train": "37424"
783
+ },
784
+ {
785
+ "hold": {
786
+ "HDG": "08:03"
787
+ },
788
+ "train": "37426"
789
+ },
790
+ {
791
+ "hold": {
792
+ "HDG": "08:33"
793
+ },
794
+ "train": "37428"
795
+ },
796
+ {
797
+ "hold": {
798
+ "FWD": "08:36"
799
+ },
800
+ "train": "37319"
801
+ }
802
+ ]
803
+ },
804
+ {
805
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
806
+ "task_id": "gnlw-2025-01-25-0730-180m-compound-0",
807
+ "split": "eval",
808
+ "difficulty": "compound",
809
+ "day": "2025-01-25",
810
+ "runs": 26,
811
+ "reward": 0.259548,
812
+ "submitted": true,
813
+ "feasible": true,
814
+ "cost": 1541,
815
+ "optimal_cost": 1302,
816
+ "floor_cost": 1218,
817
+ "greedy_cost": null,
818
+ "real_cost": 129,
819
+ "hardness": {
820
+ "runs": 26,
821
+ "sets": 7,
822
+ "stops": 184,
823
+ "conflicts": 55,
824
+ "single_track": 10,
825
+ "headway": 19,
826
+ "platforms": 2,
827
+ "closed": 24,
828
+ "decisions": 26,
829
+ "holds": 11,
830
+ "short_turns": 15,
831
+ "cancels": 0,
832
+ "turn_points": 2,
833
+ "avoidable": 84,
834
+ "optimal": 1302,
835
+ "greedy_gap": 0.8846,
836
+ "closed_min": 111,
837
+ "slow_min": 100,
838
+ "track_min": 0,
839
+ "single_closed": true,
840
+ "mid_line": false,
841
+ "late_sets": 2
842
+ },
843
+ "violations": 0,
844
+ "agent_turns": 9,
845
+ "checks": 6,
846
+ "seconds": 1971.4,
847
+ "input_tokens": 1338672,
848
+ "cache_read_tokens": 812224,
849
+ "cache_write_tokens": 0,
850
+ "output_tokens": 230040,
851
+ "served_by": [
852
+ "zai-org/GLM-5.3-Flash"
853
+ ],
854
+ "end_reason": "submitted",
855
+ "errors": 0,
856
+ "plan": [
857
+ {
858
+ "train": "37415",
859
+ "to": "HDG"
860
+ },
861
+ {
862
+ "train": "37417",
863
+ "to": "HDG"
864
+ },
865
+ {
866
+ "train": "37419",
867
+ "to": "HDG"
868
+ },
869
+ {
870
+ "train": "37319",
871
+ "to": "HDG",
872
+ "hold": {
873
+ "ZH": "08:44"
874
+ }
875
+ },
876
+ {
877
+ "train": "37421",
878
+ "to": "HDG",
879
+ "hold": {
880
+ "GN": "08:31",
881
+ "FWD": "09:09"
882
+ }
883
+ },
884
+ {
885
+ "train": "37321",
886
+ "to": "HDG",
887
+ "hold": {
888
+ "GN": "08:43"
889
+ }
890
+ },
891
+ {
892
+ "train": "37426",
893
+ "from": "HDG"
894
+ },
895
+ {
896
+ "train": "37328",
897
+ "from": "HDG"
898
+ },
899
+ {
900
+ "train": "37330",
901
+ "from": "HDG"
902
+ },
903
+ {
904
+ "train": "37430",
905
+ "from": "HDG",
906
+ "hold": {
907
+ "GK": "09:28"
908
+ }
909
+ },
910
+ {
911
+ "train": "37332",
912
+ "from": "HDG"
913
+ },
914
+ {
915
+ "train": "37432",
916
+ "from": "HDG"
917
+ },
918
+ {
919
+ "train": "37428",
920
+ "cancel": true
921
+ },
922
+ {
923
+ "train": "37425",
924
+ "cancel": true
925
+ },
926
+ {
927
+ "train": "37423",
928
+ "hold": {
929
+ "GN": "08:59"
930
+ }
931
+ },
932
+ {
933
+ "train": "37323",
934
+ "hold": {
935
+ "HDG": "09:43"
936
+ }
937
+ },
938
+ {
939
+ "train": "37325",
940
+ "hold": {
941
+ "GN": "09:45",
942
+ "ZH": "09:57"
943
+ }
944
+ },
945
+ {
946
+ "train": "37434",
947
+ "hold": {}
948
+ },
949
+ {
950
+ "train": "37336",
951
+ "hold": {
952
+ "LW": "10:23"
953
+ }
954
+ },
955
+ {
956
+ "train": "37427",
957
+ "hold": {
958
+ "HDG": "10:34"
959
+ }
960
+ },
961
+ {
962
+ "train": "37327",
963
+ "hold": {
964
+ "HDG": "10:38"
965
+ }
966
+ },
967
+ {
968
+ "train": "37436",
969
+ "hold": {
970
+ "HDG": "10:37"
971
+ }
972
+ },
973
+ {
974
+ "train": "37429",
975
+ "hold": {}
976
+ }
977
+ ]
978
+ },
979
+ {
980
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
981
+ "task_id": "gnlw-2025-01-25-1700-180m-blockage-0",
982
+ "split": "eval",
983
+ "difficulty": "blockage",
984
+ "day": "2025-01-25",
985
+ "runs": 30,
986
+ "reward": 0.375453,
987
+ "submitted": true,
988
+ "feasible": true,
989
+ "cost": 430,
990
+ "optimal_cost": 320,
991
+ "floor_cost": 275,
992
+ "greedy_cost": 1591,
993
+ "real_cost": 74,
994
+ "hardness": {
995
+ "runs": 30,
996
+ "sets": 7,
997
+ "stops": 200,
998
+ "conflicts": 11,
999
+ "single_track": 0,
1000
+ "headway": 0,
1001
+ "platforms": 0,
1002
+ "closed": 11,
1003
+ "decisions": 51,
1004
+ "holds": 49,
1005
+ "short_turns": 2,
1006
+ "cancels": 0,
1007
+ "turn_points": 1,
1008
+ "avoidable": 45,
1009
+ "optimal": 320,
1010
+ "greedy_gap": 0.8,
1011
+ "closed_min": 15,
1012
+ "slow_min": 0,
1013
+ "track_min": 0,
1014
+ "single_closed": true,
1015
+ "mid_line": false,
1016
+ "late_sets": 0
1017
+ },
1018
+ "violations": 0,
1019
+ "agent_turns": 12,
1020
+ "checks": 8,
1021
+ "seconds": 3447.9,
1022
+ "input_tokens": 2693163,
1023
+ "cache_read_tokens": 2104128,
1024
+ "cache_write_tokens": 0,
1025
+ "output_tokens": 320171,
1026
+ "served_by": [
1027
+ "zai-org/GLM-5.3-Flash"
1028
+ ],
1029
+ "end_reason": "submitted",
1030
+ "errors": 0,
1031
+ "plan": [
1032
+ {
1033
+ "train": "37455",
1034
+ "hold": {
1035
+ "HDG": "17:45"
1036
+ }
1037
+ },
1038
+ {
1039
+ "train": "37364",
1040
+ "hold": {
1041
+ "DWE": "17:45"
1042
+ }
1043
+ },
1044
+ {
1045
+ "train": "37464",
1046
+ "hold": {
1047
+ "HDG": "17:45",
1048
+ "GK": "18:05"
1049
+ }
1050
+ },
1051
+ {
1052
+ "train": "37355",
1053
+ "to": "HDG",
1054
+ "hold": {
1055
+ "BP": "17:45"
1056
+ }
1057
+ },
1058
+ {
1059
+ "train": "37466",
1060
+ "from": "HDG"
1061
+ },
1062
+ {
1063
+ "train": "37457",
1064
+ "hold": {
1065
+ "BP": "17:48",
1066
+ "FWD": "18:03"
1067
+ }
1068
+ },
1069
+ {
1070
+ "train": "37357",
1071
+ "hold": {
1072
+ "DWE": "18:02",
1073
+ "FWD": "18:09"
1074
+ }
1075
+ },
1076
+ {
1077
+ "train": "37366",
1078
+ "hold": {
1079
+ "LW": "18:00",
1080
+ "FWD": "18:16"
1081
+ }
1082
+ },
1083
+ {
1084
+ "train": "37368",
1085
+ "hold": {
1086
+ "LW": "18:20",
1087
+ "HDG": "18:29"
1088
+ }
1089
+ },
1090
+ {
1091
+ "train": "37468",
1092
+ "hold": {
1093
+ "LW": "18:25",
1094
+ "FWD": "18:39"
1095
+ }
1096
+ },
1097
+ {
1098
+ "train": "37459",
1099
+ "hold": {
1100
+ "HDG": "18:33"
1101
+ }
1102
+ },
1103
+ {
1104
+ "train": "37359",
1105
+ "hold": {
1106
+ "DWE": "18:36"
1107
+ }
1108
+ },
1109
+ {
1110
+ "train": "37370",
1111
+ "hold": {
1112
+ "LW": "18:48"
1113
+ }
1114
+ },
1115
+ {
1116
+ "train": "37461",
1117
+ "hold": {
1118
+ "FWD": "18:56"
1119
+ }
1120
+ },
1121
+ {
1122
+ "train": "37470",
1123
+ "hold": {
1124
+ "HDG": "19:00"
1125
+ }
1126
+ },
1127
+ {
1128
+ "train": "37463",
1129
+ "hold": {
1130
+ "GN": "18:56"
1131
+ }
1132
+ },
1133
+ {
1134
+ "train": "37363",
1135
+ "hold": {
1136
+ "FWD": "19:39"
1137
+ }
1138
+ },
1139
+ {
1140
+ "train": "37472",
1141
+ "hold": {
1142
+ "HDG": "19:30"
1143
+ }
1144
+ },
1145
+ {
1146
+ "train": "37465",
1147
+ "hold": {
1148
+ "FWD": "20:00"
1149
+ }
1150
+ },
1151
+ {
1152
+ "train": "37474",
1153
+ "hold": {
1154
+ "HDG": "20:03"
1155
+ }
1156
+ },
1157
+ {
1158
+ "train": "37374",
1159
+ "hold": {
1160
+ "LW": "19:47"
1161
+ }
1162
+ },
1163
+ {
1164
+ "train": "37365",
1165
+ "hold": {
1166
+ "FWD": "20:06"
1167
+ }
1168
+ }
1169
+ ]
1170
+ },
1171
+ {
1172
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
1173
+ "task_id": "gnlw-2025-01-26-1700-210m-compound-0",
1174
+ "split": "eval",
1175
+ "difficulty": "compound",
1176
+ "day": "2025-01-26",
1177
+ "runs": 16,
1178
+ "reward": 0.559463,
1179
+ "submitted": true,
1180
+ "feasible": true,
1181
+ "cost": 670,
1182
+ "optimal_cost": 618,
1183
+ "floor_cost": 588,
1184
+ "greedy_cost": 928,
1185
+ "real_cost": 58,
1186
+ "hardness": {
1187
+ "runs": 16,
1188
+ "sets": 3,
1189
+ "stops": 104,
1190
+ "conflicts": 10,
1191
+ "single_track": 3,
1192
+ "headway": 0,
1193
+ "platforms": 0,
1194
+ "closed": 7,
1195
+ "decisions": 21,
1196
+ "holds": 12,
1197
+ "short_turns": 9,
1198
+ "cancels": 0,
1199
+ "turn_points": 2,
1200
+ "avoidable": 30,
1201
+ "optimal": 618,
1202
+ "greedy_gap": 0.7932,
1203
+ "closed_min": 104,
1204
+ "slow_min": 100,
1205
+ "track_min": 0,
1206
+ "single_closed": true,
1207
+ "mid_line": true,
1208
+ "late_sets": 0
1209
+ },
1210
+ "violations": 0,
1211
+ "agent_turns": 6,
1212
+ "checks": 4,
1213
+ "seconds": 1264.8,
1214
+ "input_tokens": 495339,
1215
+ "cache_read_tokens": 354624,
1216
+ "cache_write_tokens": 0,
1217
+ "output_tokens": 139962,
1218
+ "served_by": [
1219
+ "zai-org/GLM-5.3-Flash"
1220
+ ],
1221
+ "end_reason": "submitted",
1222
+ "errors": 0,
1223
+ "plan": [
1224
+ {
1225
+ "train": "37353",
1226
+ "to": "HDG"
1227
+ },
1228
+ {
1229
+ "train": "37364",
1230
+ "from": "HDG"
1231
+ },
1232
+ {
1233
+ "train": "37455",
1234
+ "to": "HDG"
1235
+ },
1236
+ {
1237
+ "train": "37466",
1238
+ "from": "HDG"
1239
+ },
1240
+ {
1241
+ "train": "37459",
1242
+ "to": "HDG"
1243
+ },
1244
+ {
1245
+ "train": "37470",
1246
+ "from": "HDG"
1247
+ },
1248
+ {
1249
+ "train": "37357",
1250
+ "to": "HDG",
1251
+ "hold": {
1252
+ "GN": "17:46"
1253
+ }
1254
+ },
1255
+ {
1256
+ "train": "37368",
1257
+ "from": "HDG"
1258
+ },
1259
+ {
1260
+ "train": "37361",
1261
+ "hold": {
1262
+ "GN": "18:46"
1263
+ }
1264
+ },
1265
+ {
1266
+ "train": "37463",
1267
+ "hold": {
1268
+ "HDG": "19:36"
1269
+ }
1270
+ }
1271
+ ]
1272
+ },
1273
+ {
1274
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
1275
+ "task_id": "gnlw-2025-02-11-0900-150m-blockage-0",
1276
+ "split": "eval",
1277
+ "difficulty": "blockage",
1278
+ "day": "2025-02-11",
1279
+ "runs": 23,
1280
+ "reward": 1.0,
1281
+ "submitted": true,
1282
+ "feasible": true,
1283
+ "cost": 725,
1284
+ "optimal_cost": 725,
1285
+ "floor_cost": 721,
1286
+ "greedy_cost": null,
1287
+ "real_cost": 318,
1288
+ "hardness": {
1289
+ "runs": 23,
1290
+ "sets": 6,
1291
+ "stops": 152,
1292
+ "conflicts": 32,
1293
+ "single_track": 3,
1294
+ "headway": 0,
1295
+ "platforms": 0,
1296
+ "closed": 29,
1297
+ "decisions": 37,
1298
+ "holds": 23,
1299
+ "short_turns": 14,
1300
+ "cancels": 0,
1301
+ "turn_points": 1,
1302
+ "avoidable": 4,
1303
+ "optimal": 725,
1304
+ "greedy_gap": 0.8174,
1305
+ "closed_min": 120,
1306
+ "slow_min": 0,
1307
+ "track_min": 0,
1308
+ "single_closed": true,
1309
+ "mid_line": false,
1310
+ "late_sets": 0
1311
+ },
1312
+ "violations": 0,
1313
+ "agent_turns": 4,
1314
+ "checks": 2,
1315
+ "seconds": 899.7,
1316
+ "input_tokens": 242522,
1317
+ "cache_read_tokens": 147520,
1318
+ "cache_write_tokens": 0,
1319
+ "output_tokens": 92904,
1320
+ "served_by": [
1321
+ "zai-org/GLM-5.3-Flash"
1322
+ ],
1323
+ "end_reason": "submitted",
1324
+ "errors": 0,
1325
+ "plan": [
1326
+ {
1327
+ "train": "37421",
1328
+ "to": "HDG"
1329
+ },
1330
+ {
1331
+ "train": "37332",
1332
+ "from": "HDG",
1333
+ "hold": {
1334
+ "HDG": "09:21"
1335
+ }
1336
+ },
1337
+ {
1338
+ "train": "37321",
1339
+ "hold": {
1340
+ "FWD": "09:07"
1341
+ },
1342
+ "to": "HDG"
1343
+ },
1344
+ {
1345
+ "train": "37432",
1346
+ "from": "HDG"
1347
+ },
1348
+ {
1349
+ "train": "37428",
1350
+ "hold": {
1351
+ "ZH": "09:05"
1352
+ }
1353
+ },
1354
+ {
1355
+ "train": "37323",
1356
+ "to": "HDG"
1357
+ },
1358
+ {
1359
+ "train": "37434",
1360
+ "from": "HDG"
1361
+ },
1362
+ {
1363
+ "train": "37425",
1364
+ "to": "HDG"
1365
+ },
1366
+ {
1367
+ "train": "37336",
1368
+ "from": "HDG",
1369
+ "hold": {
1370
+ "HDG": "10:21"
1371
+ }
1372
+ },
1373
+ {
1374
+ "train": "37325",
1375
+ "to": "HDG"
1376
+ },
1377
+ {
1378
+ "train": "37436",
1379
+ "from": "HDG"
1380
+ },
1381
+ {
1382
+ "train": "37427",
1383
+ "to": "HDG"
1384
+ },
1385
+ {
1386
+ "train": "37338",
1387
+ "from": "HDG",
1388
+ "hold": {
1389
+ "HDG": "10:51"
1390
+ }
1391
+ },
1392
+ {
1393
+ "train": "37327",
1394
+ "to": "HDG"
1395
+ },
1396
+ {
1397
+ "train": "37438",
1398
+ "from": "HDG"
1399
+ },
1400
+ {
1401
+ "train": "37429",
1402
+ "hold": {
1403
+ "HDG": "11:10"
1404
+ }
1405
+ },
1406
+ {
1407
+ "train": "37340",
1408
+ "hold": {
1409
+ "LW": "11:23"
1410
+ }
1411
+ }
1412
+ ]
1413
+ },
1414
+ {
1415
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
1416
+ "task_id": "gnlw-2025-02-12-0900-180m-real-0",
1417
+ "split": "eval",
1418
+ "difficulty": "real",
1419
+ "day": "2025-02-12",
1420
+ "runs": 31,
1421
+ "reward": 0.19375,
1422
+ "submitted": true,
1423
+ "feasible": false,
1424
+ "cost": null,
1425
+ "optimal_cost": 643,
1426
+ "floor_cost": 529,
1427
+ "greedy_cost": null,
1428
+ "real_cost": 363,
1429
+ "hardness": {
1430
+ "runs": 31,
1431
+ "sets": 7,
1432
+ "stops": 204,
1433
+ "conflicts": 16,
1434
+ "single_track": 8,
1435
+ "headway": 3,
1436
+ "platforms": 0,
1437
+ "closed": 5,
1438
+ "decisions": 38,
1439
+ "holds": 32,
1440
+ "short_turns": 6,
1441
+ "cancels": 0,
1442
+ "turn_points": 3,
1443
+ "avoidable": 114,
1444
+ "optimal": 643,
1445
+ "greedy_gap": 0.8452,
1446
+ "closed_min": 30,
1447
+ "slow_min": 0,
1448
+ "track_min": 0,
1449
+ "single_closed": false,
1450
+ "mid_line": true,
1451
+ "late_sets": 0
1452
+ },
1453
+ "violations": 0,
1454
+ "agent_turns": 12,
1455
+ "checks": 8,
1456
+ "seconds": 2686.2,
1457
+ "input_tokens": 2649912,
1458
+ "cache_read_tokens": 2186880,
1459
+ "cache_write_tokens": 0,
1460
+ "output_tokens": 344321,
1461
+ "served_by": [
1462
+ "zai-org/GLM-5.3-Flash"
1463
+ ],
1464
+ "end_reason": "submitted",
1465
+ "errors": 0,
1466
+ "plan": [
1467
+ {
1468
+ "hold": {
1469
+ "HDG": "09:05"
1470
+ },
1471
+ "train": "37421"
1472
+ },
1473
+ {
1474
+ "hold": {
1475
+ "GK": "09:02"
1476
+ },
1477
+ "train": "37328"
1478
+ },
1479
+ {
1480
+ "hold": {
1481
+ "BP": "09:29",
1482
+ "ZH": "09:07"
1483
+ },
1484
+ "train": "37423"
1485
+ },
1486
+ {
1487
+ "hold": {
1488
+ "HDG": "09:44"
1489
+ },
1490
+ "train": "37323"
1491
+ },
1492
+ {
1493
+ "hold": {
1494
+ "DWE": "09:31"
1495
+ },
1496
+ "to": "HDG",
1497
+ "train": "37321"
1498
+ },
1499
+ {
1500
+ "from": "HDG",
1501
+ "hold": {
1502
+ "GK": "10:05",
1503
+ "HDG": "09:44"
1504
+ },
1505
+ "train": "37432"
1506
+ },
1507
+ {
1508
+ "hold": {
1509
+ "FWD": "09:30"
1510
+ },
1511
+ "to": "ZH",
1512
+ "train": "37330"
1513
+ },
1514
+ {
1515
+ "from": "ZH",
1516
+ "hold": {
1517
+ "FWD": "10:11"
1518
+ },
1519
+ "train": "37325"
1520
+ },
1521
+ {
1522
+ "hold": {
1523
+ "FWD": "09:33",
1524
+ "GK": "09:52"
1525
+ },
1526
+ "to": "ZH",
1527
+ "train": "37430"
1528
+ },
1529
+ {
1530
+ "from": "ZH",
1531
+ "hold": {
1532
+ "FWD": "10:29"
1533
+ },
1534
+ "train": "37427"
1535
+ },
1536
+ {
1537
+ "hold": {
1538
+ "BP": "09:47",
1539
+ "FWD": "09:38",
1540
+ "GK": "09:55",
1541
+ "HDG": "09:29"
1542
+ },
1543
+ "train": "37332"
1544
+ },
1545
+ {
1546
+ "hold": {
1547
+ "LW": "09:52"
1548
+ },
1549
+ "train": "37334"
1550
+ },
1551
+ {
1552
+ "hold": {
1553
+ "GK": "10:21",
1554
+ "LW": "09:55"
1555
+ },
1556
+ "train": "37434"
1557
+ },
1558
+ {
1559
+ "hold": {
1560
+ "FWD": "10:05"
1561
+ },
1562
+ "train": "37425"
1563
+ },
1564
+ {
1565
+ "hold": {
1566
+ "FWD": "10:29"
1567
+ },
1568
+ "from": "ZH",
1569
+ "train": "37427"
1570
+ },
1571
+ {
1572
+ "hold": {
1573
+ "HDG": "10:32",
1574
+ "LW": "10:23"
1575
+ },
1576
+ "train": "37436"
1577
+ },
1578
+ {
1579
+ "hold": {
1580
+ "FWD": "10:35"
1581
+ },
1582
+ "train": "37327"
1583
+ }
1584
+ ]
1585
+ },
1586
+ {
1587
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
1588
+ "task_id": "gnlw-2025-02-13-0954-180m-real-0",
1589
+ "split": "eval",
1590
+ "difficulty": "real",
1591
+ "day": "2025-02-13",
1592
+ "runs": 30,
1593
+ "reward": 0.484494,
1594
+ "submitted": true,
1595
+ "feasible": true,
1596
+ "cost": 233,
1597
+ "optimal_cost": 172,
1598
+ "floor_cost": 154,
1599
+ "greedy_cost": 1960,
1600
+ "real_cost": 215,
1601
+ "hardness": {
1602
+ "runs": 30,
1603
+ "sets": 7,
1604
+ "stops": 195,
1605
+ "conflicts": 13,
1606
+ "single_track": 3,
1607
+ "headway": 7,
1608
+ "platforms": 3,
1609
+ "closed": 0,
1610
+ "decisions": 45,
1611
+ "holds": 45,
1612
+ "short_turns": 0,
1613
+ "cancels": 0,
1614
+ "turn_points": 0,
1615
+ "avoidable": 18,
1616
+ "optimal": 172,
1617
+ "greedy_gap": 0.8,
1618
+ "closed_min": 0,
1619
+ "slow_min": 25,
1620
+ "track_min": 0,
1621
+ "single_closed": false,
1622
+ "mid_line": false,
1623
+ "late_sets": 0
1624
+ },
1625
+ "violations": 0,
1626
+ "agent_turns": 6,
1627
+ "checks": 3,
1628
+ "seconds": 1213.7,
1629
+ "input_tokens": 599410,
1630
+ "cache_read_tokens": 411200,
1631
+ "cache_write_tokens": 0,
1632
+ "output_tokens": 164361,
1633
+ "served_by": [
1634
+ "zai-org/GLM-5.3-Flash"
1635
+ ],
1636
+ "end_reason": "submitted",
1637
+ "errors": 0,
1638
+ "plan": [
1639
+ {
1640
+ "train": "37334",
1641
+ "hold": {
1642
+ "GK": "10:19"
1643
+ }
1644
+ },
1645
+ {
1646
+ "train": "37327",
1647
+ "hold": {
1648
+ "GN": "10:20",
1649
+ "FWD": "10:44"
1650
+ }
1651
+ },
1652
+ {
1653
+ "train": "37434",
1654
+ "hold": {
1655
+ "GK": "10:30"
1656
+ }
1657
+ },
1658
+ {
1659
+ "train": "37429",
1660
+ "hold": {
1661
+ "ZH": "10:35",
1662
+ "HDG": "11:03"
1663
+ }
1664
+ },
1665
+ {
1666
+ "train": "37329",
1667
+ "hold": {
1668
+ "FWD": "11:06"
1669
+ }
1670
+ }
1671
+ ]
1672
+ },
1673
+ {
1674
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
1675
+ "task_id": "gnlw-2025-02-13-1530-180m-blockage-0",
1676
+ "split": "eval",
1677
+ "difficulty": "blockage",
1678
+ "day": "2025-02-13",
1679
+ "runs": 30,
1680
+ "reward": 0.0,
1681
+ "submitted": false,
1682
+ "feasible": false,
1683
+ "cost": null,
1684
+ "optimal_cost": 648,
1685
+ "floor_cost": 588,
1686
+ "greedy_cost": 1690,
1687
+ "real_cost": 151,
1688
+ "hardness": {
1689
+ "runs": 30,
1690
+ "sets": 7,
1691
+ "stops": 200,
1692
+ "conflicts": 25,
1693
+ "single_track": 5,
1694
+ "headway": 5,
1695
+ "platforms": 1,
1696
+ "closed": 14,
1697
+ "decisions": 37,
1698
+ "holds": 29,
1699
+ "short_turns": 8,
1700
+ "cancels": 0,
1701
+ "turn_points": 2,
1702
+ "avoidable": 60,
1703
+ "optimal": 648,
1704
+ "greedy_gap": 0.8,
1705
+ "closed_min": 24,
1706
+ "slow_min": 0,
1707
+ "track_min": 0,
1708
+ "single_closed": true,
1709
+ "mid_line": false,
1710
+ "late_sets": 1
1711
+ },
1712
+ "violations": 0,
1713
+ "agent_turns": 4,
1714
+ "checks": 1,
1715
+ "seconds": 1659.6,
1716
+ "input_tokens": 289662,
1717
+ "cache_read_tokens": 140032,
1718
+ "cache_write_tokens": 0,
1719
+ "output_tokens": 192312,
1720
+ "served_by": [
1721
+ "zai-org/GLM-5.3-Flash"
1722
+ ],
1723
+ "end_reason": "no_tool_call",
1724
+ "errors": 0,
1725
+ "plan": null
1726
+ },
1727
+ {
1728
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
1729
+ "task_id": "gnlw-2025-02-14-1730-210m-blockage-0",
1730
+ "split": "eval",
1731
+ "difficulty": "blockage",
1732
+ "day": "2025-02-14",
1733
+ "runs": 33,
1734
+ "reward": 0.0,
1735
+ "submitted": false,
1736
+ "feasible": false,
1737
+ "cost": null,
1738
+ "optimal_cost": 875,
1739
+ "floor_cost": 776,
1740
+ "greedy_cost": null,
1741
+ "real_cost": 191,
1742
+ "hardness": {
1743
+ "runs": 33,
1744
+ "sets": 7,
1745
+ "stops": 208,
1746
+ "conflicts": 33,
1747
+ "single_track": 7,
1748
+ "headway": 19,
1749
+ "platforms": 3,
1750
+ "closed": 4,
1751
+ "decisions": 35,
1752
+ "holds": 25,
1753
+ "short_turns": 10,
1754
+ "cancels": 0,
1755
+ "turn_points": 3,
1756
+ "avoidable": 99,
1757
+ "optimal": 875,
1758
+ "greedy_gap": 0.8909,
1759
+ "closed_min": 38,
1760
+ "slow_min": 0,
1761
+ "track_min": 0,
1762
+ "single_closed": false,
1763
+ "mid_line": true,
1764
+ "late_sets": 1
1765
+ },
1766
+ "violations": 0,
1767
+ "agent_turns": 2,
1768
+ "checks": 0,
1769
+ "seconds": 1117.8,
1770
+ "input_tokens": 76350,
1771
+ "cache_read_tokens": 7680,
1772
+ "cache_write_tokens": 0,
1773
+ "output_tokens": 128000,
1774
+ "served_by": [
1775
+ "zai-org/GLM-5.3-Flash"
1776
+ ],
1777
+ "end_reason": "no_tool_call",
1778
+ "errors": 0,
1779
+ "plan": null
1780
+ },
1781
+ {
1782
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
1783
+ "task_id": "gnlw-2025-02-15-0630-180m-blockage-0",
1784
+ "split": "eval",
1785
+ "difficulty": "blockage",
1786
+ "day": "2025-02-15",
1787
+ "runs": 20,
1788
+ "reward": 0.542968,
1789
+ "submitted": true,
1790
+ "feasible": true,
1791
+ "cost": 2419,
1792
+ "optimal_cost": 2300,
1793
+ "floor_cost": 2119,
1794
+ "greedy_cost": null,
1795
+ "real_cost": 61,
1796
+ "hardness": {
1797
+ "runs": 20,
1798
+ "sets": 7,
1799
+ "stops": 150,
1800
+ "conflicts": 45,
1801
+ "single_track": 0,
1802
+ "headway": 0,
1803
+ "platforms": 0,
1804
+ "closed": 45,
1805
+ "decisions": 31,
1806
+ "holds": 13,
1807
+ "short_turns": 18,
1808
+ "cancels": 0,
1809
+ "turn_points": 3,
1810
+ "avoidable": 181,
1811
+ "optimal": 2300,
1812
+ "greedy_gap": 0.87,
1813
+ "closed_min": 160,
1814
+ "slow_min": 0,
1815
+ "track_min": 0,
1816
+ "single_closed": true,
1817
+ "mid_line": true,
1818
+ "late_sets": 0
1819
+ },
1820
+ "violations": 0,
1821
+ "agent_turns": 11,
1822
+ "checks": 9,
1823
+ "seconds": 2329.4,
1824
+ "input_tokens": 1812928,
1825
+ "cache_read_tokens": 1286016,
1826
+ "cache_write_tokens": 0,
1827
+ "output_tokens": 231905,
1828
+ "served_by": [
1829
+ "zai-org/GLM-5.3-Flash"
1830
+ ],
1831
+ "end_reason": "submitted",
1832
+ "errors": 0,
1833
+ "plan": [
1834
+ {
1835
+ "train": "37420",
1836
+ "to": "BP"
1837
+ },
1838
+ {
1839
+ "train": "37417",
1840
+ "from": "BP"
1841
+ },
1842
+ {
1843
+ "train": "37422",
1844
+ "to": "BP"
1845
+ },
1846
+ {
1847
+ "train": "37419",
1848
+ "from": "BP"
1849
+ },
1850
+ {
1851
+ "train": "37328",
1852
+ "to": "BP"
1853
+ },
1854
+ {
1855
+ "train": "37323",
1856
+ "from": "BP"
1857
+ },
1858
+ {
1859
+ "train": "37413",
1860
+ "to": "ZH"
1861
+ },
1862
+ {
1863
+ "train": "37424",
1864
+ "from": "ZH"
1865
+ },
1866
+ {
1867
+ "train": "37421",
1868
+ "to": "ZH",
1869
+ "hold": {
1870
+ "GN": "09:19"
1871
+ }
1872
+ },
1873
+ {
1874
+ "train": "37332",
1875
+ "from": "ZH"
1876
+ },
1877
+ {
1878
+ "train": "37415",
1879
+ "to": "ZH"
1880
+ },
1881
+ {
1882
+ "train": "37426",
1883
+ "from": "ZH"
1884
+ },
1885
+ {
1886
+ "train": "37423",
1887
+ "hold": {
1888
+ "ZH": "09:10"
1889
+ }
1890
+ },
1891
+ {
1892
+ "train": "37319",
1893
+ "to": "ZH"
1894
+ },
1895
+ {
1896
+ "train": "37430",
1897
+ "from": "ZH",
1898
+ "hold": {
1899
+ "ZH": "09:30"
1900
+ }
1901
+ },
1902
+ {
1903
+ "train": "37428",
1904
+ "hold": {
1905
+ "BP": "09:10"
1906
+ }
1907
+ },
1908
+ {
1909
+ "train": "37330",
1910
+ "hold": {
1911
+ "FWD": "09:10"
1912
+ }
1913
+ },
1914
+ {
1915
+ "train": "37321",
1916
+ "to": "ZH",
1917
+ "hold": {
1918
+ "GN": "09:40"
1919
+ }
1920
+ },
1921
+ {
1922
+ "train": "37432",
1923
+ "from": "ZH"
1924
+ }
1925
+ ]
1926
+ },
1927
+ {
1928
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
1929
+ "task_id": "gnlw-2025-02-15-0700-240m-compound-0",
1930
+ "split": "eval",
1931
+ "difficulty": "compound",
1932
+ "day": "2025-02-15",
1933
+ "runs": 32,
1934
+ "reward": 0.0,
1935
+ "submitted": false,
1936
+ "feasible": false,
1937
+ "cost": null,
1938
+ "optimal_cost": 858,
1939
+ "floor_cost": 785,
1940
+ "greedy_cost": 2986,
1941
+ "real_cost": 76,
1942
+ "hardness": {
1943
+ "runs": 32,
1944
+ "sets": 7,
1945
+ "stops": 228,
1946
+ "conflicts": 38,
1947
+ "single_track": 4,
1948
+ "headway": 21,
1949
+ "platforms": 3,
1950
+ "closed": 10,
1951
+ "decisions": 37,
1952
+ "holds": 31,
1953
+ "short_turns": 6,
1954
+ "cancels": 0,
1955
+ "turn_points": 2,
1956
+ "avoidable": 73,
1957
+ "optimal": 858,
1958
+ "greedy_gap": 0.8,
1959
+ "closed_min": 34,
1960
+ "slow_min": 0,
1961
+ "track_min": 0,
1962
+ "single_closed": true,
1963
+ "mid_line": false,
1964
+ "late_sets": 2
1965
+ },
1966
+ "violations": 0,
1967
+ "agent_turns": 4,
1968
+ "checks": 1,
1969
+ "seconds": 1677.9,
1970
+ "input_tokens": 294512,
1971
+ "cache_read_tokens": 143360,
1972
+ "cache_write_tokens": 0,
1973
+ "output_tokens": 192263,
1974
+ "served_by": [
1975
+ "zai-org/GLM-5.3-Flash"
1976
+ ],
1977
+ "end_reason": "no_tool_call",
1978
+ "errors": 0,
1979
+ "plan": null
1980
+ },
1981
+ {
1982
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
1983
+ "task_id": "gnlw-2025-02-15-1630-150m-loop-0",
1984
+ "split": "eval",
1985
+ "difficulty": "loop",
1986
+ "day": "2025-02-15",
1987
+ "runs": 26,
1988
+ "reward": 0.0,
1989
+ "submitted": false,
1990
+ "feasible": false,
1991
+ "cost": null,
1992
+ "optimal_cost": 275,
1993
+ "floor_cost": 234,
1994
+ "greedy_cost": 1464,
1995
+ "real_cost": 35,
1996
+ "hardness": {
1997
+ "runs": 26,
1998
+ "sets": 7,
1999
+ "stops": 174,
2000
+ "conflicts": 10,
2001
+ "single_track": 3,
2002
+ "headway": 5,
2003
+ "platforms": 2,
2004
+ "closed": 0,
2005
+ "decisions": 52,
2006
+ "holds": 50,
2007
+ "short_turns": 2,
2008
+ "cancels": 0,
2009
+ "turn_points": 1,
2010
+ "avoidable": 41,
2011
+ "optimal": 275,
2012
+ "greedy_gap": 0.8,
2013
+ "closed_min": 0,
2014
+ "slow_min": 25,
2015
+ "track_min": 140,
2016
+ "single_closed": false,
2017
+ "mid_line": false,
2018
+ "late_sets": 1
2019
+ },
2020
+ "violations": 0,
2021
+ "agent_turns": 2,
2022
+ "checks": 0,
2023
+ "seconds": 926.2,
2024
+ "input_tokens": 75109,
2025
+ "cache_read_tokens": 1600,
2026
+ "cache_write_tokens": 0,
2027
+ "output_tokens": 128000,
2028
+ "served_by": [
2029
+ "zai-org/GLM-5.3-Flash"
2030
+ ],
2031
+ "end_reason": "no_tool_call",
2032
+ "errors": 0,
2033
+ "plan": null
2034
+ },
2035
+ {
2036
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
2037
+ "task_id": "gnlw-2025-02-16-1530-210m-compound-0",
2038
+ "split": "eval",
2039
+ "difficulty": "compound",
2040
+ "day": "2025-02-16",
2041
+ "runs": 18,
2042
+ "reward": 0.200325,
2043
+ "submitted": true,
2044
+ "feasible": true,
2045
+ "cost": 679,
2046
+ "optimal_cost": 273,
2047
+ "floor_cost": 269,
2048
+ "greedy_cost": 2157,
2049
+ "real_cost": 38,
2050
+ "hardness": {
2051
+ "runs": 18,
2052
+ "sets": 3,
2053
+ "stops": 122,
2054
+ "conflicts": 5,
2055
+ "single_track": 2,
2056
+ "headway": 0,
2057
+ "platforms": 0,
2058
+ "closed": 3,
2059
+ "decisions": 9,
2060
+ "holds": 5,
2061
+ "short_turns": 4,
2062
+ "cancels": 0,
2063
+ "turn_points": 1,
2064
+ "avoidable": 4,
2065
+ "optimal": 273,
2066
+ "greedy_gap": 0.8,
2067
+ "closed_min": 21,
2068
+ "slow_min": 80,
2069
+ "track_min": 0,
2070
+ "single_closed": true,
2071
+ "mid_line": false,
2072
+ "late_sets": 0
2073
+ },
2074
+ "violations": 0,
2075
+ "agent_turns": 6,
2076
+ "checks": 3,
2077
+ "seconds": 2070.3,
2078
+ "input_tokens": 735585,
2079
+ "cache_read_tokens": 520704,
2080
+ "cache_write_tokens": 0,
2081
+ "output_tokens": 202536,
2082
+ "served_by": [
2083
+ "zai-org/GLM-5.3-Flash"
2084
+ ],
2085
+ "end_reason": "submitted",
2086
+ "errors": 0,
2087
+ "plan": [
2088
+ {
2089
+ "train": "37458",
2090
+ "cancel": true
2091
+ },
2092
+ {
2093
+ "train": "37353",
2094
+ "cancel": true
2095
+ },
2096
+ {
2097
+ "train": "37360",
2098
+ "to": "BP",
2099
+ "hold": {
2100
+ "LW": "16:48"
2101
+ }
2102
+ },
2103
+ {
2104
+ "train": "37455",
2105
+ "from": "BP",
2106
+ "hold": {
2107
+ "FWD": "17:28"
2108
+ }
2109
+ },
2110
+ {
2111
+ "train": "37349",
2112
+ "hold": {
2113
+ "FWD": "16:09"
2114
+ }
2115
+ }
2116
+ ]
2117
+ },
2118
+ {
2119
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
2120
+ "task_id": "gnlw-2025-02-16-1700-210m-compound-0",
2121
+ "split": "eval",
2122
+ "difficulty": "compound",
2123
+ "day": "2025-02-16",
2124
+ "runs": 16,
2125
+ "reward": 1.0,
2126
+ "submitted": true,
2127
+ "feasible": true,
2128
+ "cost": 796,
2129
+ "optimal_cost": 796,
2130
+ "floor_cost": 787,
2131
+ "greedy_cost": 1089,
2132
+ "real_cost": 90,
2133
+ "hardness": {
2134
+ "runs": 16,
2135
+ "sets": 3,
2136
+ "stops": 104,
2137
+ "conflicts": 31,
2138
+ "single_track": 0,
2139
+ "headway": 0,
2140
+ "platforms": 0,
2141
+ "closed": 31,
2142
+ "decisions": 13,
2143
+ "holds": 6,
2144
+ "short_turns": 6,
2145
+ "cancels": 1,
2146
+ "turn_points": 2,
2147
+ "avoidable": 9,
2148
+ "optimal": 796,
2149
+ "greedy_gap": 0.7963,
2150
+ "closed_min": 92,
2151
+ "slow_min": 0,
2152
+ "track_min": 0,
2153
+ "single_closed": true,
2154
+ "mid_line": false,
2155
+ "late_sets": 0
2156
+ },
2157
+ "violations": 0,
2158
+ "agent_turns": 5,
2159
+ "checks": 3,
2160
+ "seconds": 1182.2,
2161
+ "input_tokens": 347239,
2162
+ "cache_read_tokens": 169152,
2163
+ "cache_write_tokens": 0,
2164
+ "output_tokens": 110263,
2165
+ "served_by": [
2166
+ "zai-org/GLM-5.3-Flash"
2167
+ ],
2168
+ "end_reason": "submitted",
2169
+ "errors": 0,
2170
+ "plan": [
2171
+ {
2172
+ "train": "37455",
2173
+ "to": "ZH"
2174
+ },
2175
+ {
2176
+ "train": "37466",
2177
+ "cancel": true
2178
+ },
2179
+ {
2180
+ "train": "37361",
2181
+ "from": "ZH",
2182
+ "hold": {
2183
+ "ZH": "18:42"
2184
+ }
2185
+ },
2186
+ {
2187
+ "train": "37462",
2188
+ "to": "BP"
2189
+ },
2190
+ {
2191
+ "train": "37357",
2192
+ "from": "BP",
2193
+ "hold": {
2194
+ "BP": "17:56"
2195
+ }
2196
+ },
2197
+ {
2198
+ "train": "37368",
2199
+ "hold": {
2200
+ "BP": "18:43"
2201
+ }
2202
+ },
2203
+ {
2204
+ "train": "37364",
2205
+ "to": "BP"
2206
+ },
2207
+ {
2208
+ "train": "37459",
2209
+ "from": "BP",
2210
+ "hold": {
2211
+ "BP": "18:18"
2212
+ }
2213
+ }
2214
+ ]
2215
+ },
2216
+ {
2217
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
2218
+ "task_id": "gnlw-2025-02-16-1730-240m-compound-0",
2219
+ "split": "eval",
2220
+ "difficulty": "compound",
2221
+ "day": "2025-02-16",
2222
+ "runs": 19,
2223
+ "reward": 0.776911,
2224
+ "submitted": true,
2225
+ "feasible": true,
2226
+ "cost": 673,
2227
+ "optimal_cost": 656,
2228
+ "floor_cost": 652,
2229
+ "greedy_cost": null,
2230
+ "real_cost": 147,
2231
+ "hardness": {
2232
+ "runs": 19,
2233
+ "sets": 3,
2234
+ "stops": 126,
2235
+ "conflicts": 14,
2236
+ "single_track": 8,
2237
+ "headway": 0,
2238
+ "platforms": 0,
2239
+ "closed": 6,
2240
+ "decisions": 21,
2241
+ "holds": 11,
2242
+ "short_turns": 10,
2243
+ "cancels": 0,
2244
+ "turn_points": 3,
2245
+ "avoidable": 4,
2246
+ "optimal": 656,
2247
+ "greedy_gap": 0.9579,
2248
+ "closed_min": 36,
2249
+ "slow_min": 210,
2250
+ "track_min": 235,
2251
+ "single_closed": true,
2252
+ "mid_line": false,
2253
+ "late_sets": 0
2254
+ },
2255
+ "violations": 0,
2256
+ "agent_turns": 7,
2257
+ "checks": 4,
2258
+ "seconds": 1892.2,
2259
+ "input_tokens": 849411,
2260
+ "cache_read_tokens": 632000,
2261
+ "cache_write_tokens": 0,
2262
+ "output_tokens": 199796,
2263
+ "served_by": [
2264
+ "zai-org/GLM-5.3-Flash"
2265
+ ],
2266
+ "end_reason": "submitted",
2267
+ "errors": 0,
2268
+ "plan": [
2269
+ {
2270
+ "train": "37364",
2271
+ "to": "ZH"
2272
+ },
2273
+ {
2274
+ "train": "37459",
2275
+ "from": "ZH"
2276
+ },
2277
+ {
2278
+ "train": "37357",
2279
+ "to": "ZH",
2280
+ "hold": {
2281
+ "GN": "18:16"
2282
+ }
2283
+ },
2284
+ {
2285
+ "train": "37368",
2286
+ "from": "ZH"
2287
+ },
2288
+ {
2289
+ "train": "37361",
2290
+ "to": "HDG"
2291
+ },
2292
+ {
2293
+ "train": "37372",
2294
+ "from": "HDG"
2295
+ },
2296
+ {
2297
+ "train": "37365",
2298
+ "hold": {
2299
+ "FWD": "20:11"
2300
+ },
2301
+ "to": "HDG"
2302
+ },
2303
+ {
2304
+ "train": "37376",
2305
+ "from": "HDG"
2306
+ },
2307
+ {
2308
+ "train": "37369",
2309
+ "hold": {
2310
+ "FWD": "21:11"
2311
+ },
2312
+ "to": "HDG"
2313
+ },
2314
+ {
2315
+ "train": "37380",
2316
+ "from": "HDG"
2317
+ }
2318
+ ]
2319
+ },
2320
+ {
2321
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
2322
+ "task_id": "gnlw-2025-03-10-0700-210m-blockage-0",
2323
+ "split": "eval",
2324
+ "difficulty": "blockage",
2325
+ "day": "2025-03-10",
2326
+ "runs": 34,
2327
+ "reward": 0.0,
2328
+ "submitted": false,
2329
+ "feasible": false,
2330
+ "cost": null,
2331
+ "optimal_cost": 455,
2332
+ "floor_cost": 386,
2333
+ "greedy_cost": null,
2334
+ "real_cost": 157,
2335
+ "hardness": {
2336
+ "runs": 34,
2337
+ "sets": 7,
2338
+ "stops": 226,
2339
+ "conflicts": 17,
2340
+ "single_track": 0,
2341
+ "headway": 0,
2342
+ "platforms": 0,
2343
+ "closed": 17,
2344
+ "decisions": 32,
2345
+ "holds": 26,
2346
+ "short_turns": 6,
2347
+ "cancels": 0,
2348
+ "turn_points": 2,
2349
+ "avoidable": 69,
2350
+ "optimal": 455,
2351
+ "greedy_gap": 0.8294,
2352
+ "closed_min": 23,
2353
+ "slow_min": 0,
2354
+ "track_min": 0,
2355
+ "single_closed": true,
2356
+ "mid_line": false,
2357
+ "late_sets": 0
2358
+ },
2359
+ "violations": 0,
2360
+ "agent_turns": 2,
2361
+ "checks": 0,
2362
+ "seconds": 919.6,
2363
+ "input_tokens": 76613,
2364
+ "cache_read_tokens": 0,
2365
+ "cache_write_tokens": 0,
2366
+ "output_tokens": 128000,
2367
+ "served_by": [
2368
+ "zai-org/GLM-5.3-Flash"
2369
+ ],
2370
+ "end_reason": "no_tool_call",
2371
+ "errors": 0,
2372
+ "plan": null
2373
+ },
2374
+ {
2375
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
2376
+ "task_id": "gnlw-2025-03-11-0730-150m-blockage-0",
2377
+ "split": "eval",
2378
+ "difficulty": "blockage",
2379
+ "day": "2025-03-11",
2380
+ "runs": 26,
2381
+ "reward": 0.0,
2382
+ "submitted": false,
2383
+ "feasible": false,
2384
+ "cost": null,
2385
+ "optimal_cost": 2043,
2386
+ "floor_cost": 1851,
2387
+ "greedy_cost": null,
2388
+ "real_cost": 201,
2389
+ "hardness": {
2390
+ "runs": 26,
2391
+ "sets": 7,
2392
+ "stops": 174,
2393
+ "conflicts": 72,
2394
+ "single_track": 0,
2395
+ "headway": 0,
2396
+ "platforms": 0,
2397
+ "closed": 72,
2398
+ "decisions": 35,
2399
+ "holds": 19,
2400
+ "short_turns": 12,
2401
+ "cancels": 4,
2402
+ "turn_points": 2,
2403
+ "avoidable": 192,
2404
+ "optimal": 2043,
2405
+ "greedy_gap": 0.9,
2406
+ "closed_min": 130,
2407
+ "slow_min": 0,
2408
+ "track_min": 0,
2409
+ "single_closed": true,
2410
+ "mid_line": false,
2411
+ "late_sets": 0
2412
+ },
2413
+ "violations": 0,
2414
+ "agent_turns": 2,
2415
+ "checks": 0,
2416
+ "seconds": 1157.3,
2417
+ "input_tokens": 74967,
2418
+ "cache_read_tokens": 6976,
2419
+ "cache_write_tokens": 0,
2420
+ "output_tokens": 128000,
2421
+ "served_by": [
2422
+ "zai-org/GLM-5.3-Flash"
2423
+ ],
2424
+ "end_reason": "no_tool_call",
2425
+ "errors": 0,
2426
+ "plan": null
2427
+ },
2428
+ {
2429
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
2430
+ "task_id": "gnlw-2025-03-12-1530-180m-blockage-0",
2431
+ "split": "eval",
2432
+ "difficulty": "blockage",
2433
+ "day": "2025-03-12",
2434
+ "runs": 30,
2435
+ "reward": 0.0,
2436
+ "submitted": false,
2437
+ "feasible": false,
2438
+ "cost": null,
2439
+ "optimal_cost": 806,
2440
+ "floor_cost": 730,
2441
+ "greedy_cost": 1632,
2442
+ "real_cost": 98,
2443
+ "hardness": {
2444
+ "runs": 30,
2445
+ "sets": 7,
2446
+ "stops": 200,
2447
+ "conflicts": 31,
2448
+ "single_track": 0,
2449
+ "headway": 0,
2450
+ "platforms": 0,
2451
+ "closed": 31,
2452
+ "decisions": 36,
2453
+ "holds": 28,
2454
+ "short_turns": 8,
2455
+ "cancels": 0,
2456
+ "turn_points": 2,
2457
+ "avoidable": 76,
2458
+ "optimal": 806,
2459
+ "greedy_gap": 0.7999,
2460
+ "closed_min": 25,
2461
+ "slow_min": 0,
2462
+ "track_min": 0,
2463
+ "single_closed": true,
2464
+ "mid_line": false,
2465
+ "late_sets": 0
2466
+ },
2467
+ "violations": 0,
2468
+ "agent_turns": 5,
2469
+ "checks": 2,
2470
+ "seconds": 2063.0,
2471
+ "input_tokens": 422539,
2472
+ "cache_read_tokens": 151744,
2473
+ "cache_write_tokens": 0,
2474
+ "output_tokens": 216391,
2475
+ "served_by": [
2476
+ "zai-org/GLM-5.3-Flash"
2477
+ ],
2478
+ "end_reason": "no_tool_call",
2479
+ "errors": 0,
2480
+ "plan": null
2481
+ },
2482
+ {
2483
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
2484
+ "task_id": "gnlw-2025-03-12-1730-210m-loop-0",
2485
+ "split": "eval",
2486
+ "difficulty": "loop",
2487
+ "day": "2025-03-12",
2488
+ "runs": 33,
2489
+ "reward": 0.0,
2490
+ "submitted": false,
2491
+ "feasible": false,
2492
+ "cost": null,
2493
+ "optimal_cost": 166,
2494
+ "floor_cost": 158,
2495
+ "greedy_cost": 826,
2496
+ "real_cost": 64,
2497
+ "hardness": {
2498
+ "runs": 33,
2499
+ "sets": 7,
2500
+ "stops": 208,
2501
+ "conflicts": 11,
2502
+ "single_track": 2,
2503
+ "headway": 8,
2504
+ "platforms": 1,
2505
+ "closed": 0,
2506
+ "decisions": 35,
2507
+ "holds": 33,
2508
+ "short_turns": 2,
2509
+ "cancels": 0,
2510
+ "turn_points": 1,
2511
+ "avoidable": 8,
2512
+ "optimal": 166,
2513
+ "greedy_gap": 0.8,
2514
+ "closed_min": 0,
2515
+ "slow_min": 0,
2516
+ "track_min": 10,
2517
+ "single_closed": false,
2518
+ "mid_line": false,
2519
+ "late_sets": 1
2520
+ },
2521
+ "violations": 0,
2522
+ "agent_turns": 4,
2523
+ "checks": 1,
2524
+ "seconds": 1521.3,
2525
+ "input_tokens": 289611,
2526
+ "cache_read_tokens": 134400,
2527
+ "cache_write_tokens": 0,
2528
+ "output_tokens": 192468,
2529
+ "served_by": [
2530
+ "zai-org/GLM-5.3-Flash"
2531
+ ],
2532
+ "end_reason": "no_tool_call",
2533
+ "errors": 0,
2534
+ "plan": null
2535
+ },
2536
+ {
2537
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
2538
+ "task_id": "gnlw-2025-03-13-0706-180m-real-0",
2539
+ "split": "eval",
2540
+ "difficulty": "real",
2541
+ "day": "2025-03-13",
2542
+ "runs": 26,
2543
+ "reward": 1.0,
2544
+ "submitted": true,
2545
+ "feasible": true,
2546
+ "cost": 378,
2547
+ "optimal_cost": 378,
2548
+ "floor_cost": 378,
2549
+ "greedy_cost": null,
2550
+ "real_cost": 554,
2551
+ "hardness": {
2552
+ "runs": 26,
2553
+ "sets": 6,
2554
+ "stops": 177,
2555
+ "conflicts": 18,
2556
+ "single_track": 1,
2557
+ "headway": 0,
2558
+ "platforms": 0,
2559
+ "closed": 17,
2560
+ "decisions": 27,
2561
+ "holds": 19,
2562
+ "short_turns": 8,
2563
+ "cancels": 0,
2564
+ "turn_points": 1,
2565
+ "avoidable": 0,
2566
+ "optimal": 378,
2567
+ "greedy_gap": 0.8077,
2568
+ "closed_min": 59,
2569
+ "slow_min": 0,
2570
+ "track_min": 0,
2571
+ "single_closed": true,
2572
+ "mid_line": false,
2573
+ "late_sets": 0
2574
+ },
2575
+ "violations": 0,
2576
+ "agent_turns": 6,
2577
+ "checks": 4,
2578
+ "seconds": 899.1,
2579
+ "input_tokens": 404098,
2580
+ "cache_read_tokens": 217088,
2581
+ "cache_write_tokens": 0,
2582
+ "output_tokens": 89748,
2583
+ "served_by": [
2584
+ "zai-org/GLM-5.3-Flash"
2585
+ ],
2586
+ "end_reason": "submitted",
2587
+ "errors": 0,
2588
+ "plan": [
2589
+ {
2590
+ "train": "37413",
2591
+ "to": "HDG"
2592
+ },
2593
+ {
2594
+ "train": "37324",
2595
+ "from": "HDG",
2596
+ "hold": {
2597
+ "HDG": "07:22"
2598
+ }
2599
+ },
2600
+ {
2601
+ "train": "37313",
2602
+ "to": "HDG"
2603
+ },
2604
+ {
2605
+ "train": "37424",
2606
+ "from": "HDG",
2607
+ "hold": {
2608
+ "HDG": "07:29"
2609
+ }
2610
+ },
2611
+ {
2612
+ "train": "37415",
2613
+ "to": "HDG"
2614
+ },
2615
+ {
2616
+ "train": "37326",
2617
+ "from": "HDG",
2618
+ "hold": {
2619
+ "HDG": "07:52"
2620
+ }
2621
+ },
2622
+ {
2623
+ "train": "37315",
2624
+ "to": "HDG"
2625
+ },
2626
+ {
2627
+ "train": "37426",
2628
+ "from": "HDG",
2629
+ "hold": {
2630
+ "HDG": "07:59"
2631
+ }
2632
+ },
2633
+ {
2634
+ "train": "37417",
2635
+ "hold": {
2636
+ "HDG": "08:00"
2637
+ }
2638
+ }
2639
+ ]
2640
+ },
2641
+ {
2642
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
2643
+ "task_id": "gnlw-2025-03-16-1630-180m-compound-0",
2644
+ "split": "eval",
2645
+ "difficulty": "compound",
2646
+ "day": "2025-03-16",
2647
+ "runs": 15,
2648
+ "reward": 1.0,
2649
+ "submitted": true,
2650
+ "feasible": true,
2651
+ "cost": 1259,
2652
+ "optimal_cost": 1259,
2653
+ "floor_cost": 1256,
2654
+ "greedy_cost": null,
2655
+ "real_cost": 73,
2656
+ "hardness": {
2657
+ "runs": 15,
2658
+ "sets": 3,
2659
+ "stops": 100,
2660
+ "conflicts": 53,
2661
+ "single_track": 3,
2662
+ "headway": 0,
2663
+ "platforms": 0,
2664
+ "closed": 50,
2665
+ "decisions": 15,
2666
+ "holds": 3,
2667
+ "short_turns": 10,
2668
+ "cancels": 2,
2669
+ "turn_points": 2,
2670
+ "avoidable": 3,
2671
+ "optimal": 1259,
2672
+ "greedy_gap": 0.8533,
2673
+ "closed_min": 149,
2674
+ "slow_min": 95,
2675
+ "track_min": 31,
2676
+ "single_closed": true,
2677
+ "mid_line": false,
2678
+ "late_sets": 0
2679
+ },
2680
+ "violations": 0,
2681
+ "agent_turns": 4,
2682
+ "checks": 2,
2683
+ "seconds": 835.2,
2684
+ "input_tokens": 230449,
2685
+ "cache_read_tokens": 139008,
2686
+ "cache_write_tokens": 0,
2687
+ "output_tokens": 91920,
2688
+ "served_by": [
2689
+ "zai-org/GLM-5.3-Flash"
2690
+ ],
2691
+ "end_reason": "submitted",
2692
+ "errors": 0,
2693
+ "plan": [
2694
+ {
2695
+ "train": "37360",
2696
+ "to": "BP"
2697
+ },
2698
+ {
2699
+ "train": "37455",
2700
+ "from": "BP"
2701
+ },
2702
+ {
2703
+ "train": "37466",
2704
+ "to": "BP"
2705
+ },
2706
+ {
2707
+ "train": "37361",
2708
+ "from": "BP"
2709
+ },
2710
+ {
2711
+ "train": "37462",
2712
+ "to": "BP"
2713
+ },
2714
+ {
2715
+ "train": "37357",
2716
+ "from": "BP",
2717
+ "hold": {
2718
+ "FWD": "18:05"
2719
+ }
2720
+ },
2721
+ {
2722
+ "train": "37368",
2723
+ "to": "BP"
2724
+ },
2725
+ {
2726
+ "train": "37463",
2727
+ "from": "BP"
2728
+ },
2729
+ {
2730
+ "train": "37353",
2731
+ "to": "ZH"
2732
+ },
2733
+ {
2734
+ "train": "37364",
2735
+ "cancel": true
2736
+ },
2737
+ {
2738
+ "train": "37459",
2739
+ "cancel": true
2740
+ },
2741
+ {
2742
+ "train": "37470",
2743
+ "from": "ZH"
2744
+ }
2745
+ ]
2746
+ },
2747
+ {
2748
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
2749
+ "task_id": "gnlw-2025-03-16-1700-210m-compound-0",
2750
+ "split": "eval",
2751
+ "difficulty": "compound",
2752
+ "day": "2025-03-16",
2753
+ "runs": 16,
2754
+ "reward": 0.0,
2755
+ "submitted": false,
2756
+ "feasible": false,
2757
+ "cost": null,
2758
+ "optimal_cost": 529,
2759
+ "floor_cost": 529,
2760
+ "greedy_cost": 2995,
2761
+ "real_cost": 86,
2762
+ "hardness": {
2763
+ "runs": 16,
2764
+ "sets": 3,
2765
+ "stops": 104,
2766
+ "conflicts": 13,
2767
+ "single_track": 8,
2768
+ "headway": 0,
2769
+ "platforms": 0,
2770
+ "closed": 5,
2771
+ "decisions": 20,
2772
+ "holds": 12,
2773
+ "short_turns": 8,
2774
+ "cancels": 0,
2775
+ "turn_points": 1,
2776
+ "avoidable": 0,
2777
+ "optimal": 529,
2778
+ "greedy_gap": 0.8,
2779
+ "closed_min": 35,
2780
+ "slow_min": 210,
2781
+ "track_min": 0,
2782
+ "single_closed": true,
2783
+ "mid_line": false,
2784
+ "late_sets": 0
2785
+ },
2786
+ "violations": 0,
2787
+ "agent_turns": 4,
2788
+ "checks": 1,
2789
+ "seconds": 1760.8,
2790
+ "input_tokens": 278022,
2791
+ "cache_read_tokens": 69696,
2792
+ "cache_write_tokens": 0,
2793
+ "output_tokens": 192030,
2794
+ "served_by": [
2795
+ "zai-org/GLM-5.3-Flash"
2796
+ ],
2797
+ "end_reason": "no_tool_call",
2798
+ "errors": 0,
2799
+ "plan": null
2800
+ },
2801
+ {
2802
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
2803
+ "task_id": "gnlw-2025-04-23-1630-150m-blockage-0",
2804
+ "split": "eval",
2805
+ "difficulty": "blockage",
2806
+ "day": "2025-04-23",
2807
+ "runs": 26,
2808
+ "reward": 0.0,
2809
+ "submitted": false,
2810
+ "feasible": false,
2811
+ "cost": null,
2812
+ "optimal_cost": 1926,
2813
+ "floor_cost": 1782,
2814
+ "greedy_cost": null,
2815
+ "real_cost": 40,
2816
+ "hardness": {
2817
+ "runs": 26,
2818
+ "sets": 7,
2819
+ "stops": 174,
2820
+ "conflicts": 18,
2821
+ "single_track": 0,
2822
+ "headway": 0,
2823
+ "platforms": 0,
2824
+ "closed": 18,
2825
+ "decisions": 26,
2826
+ "holds": 12,
2827
+ "short_turns": 14,
2828
+ "cancels": 0,
2829
+ "turn_points": 2,
2830
+ "avoidable": 144,
2831
+ "optimal": 1926,
2832
+ "greedy_gap": 0.8385,
2833
+ "closed_min": 120,
2834
+ "slow_min": 0,
2835
+ "track_min": 0,
2836
+ "single_closed": true,
2837
+ "mid_line": true,
2838
+ "late_sets": 0
2839
+ },
2840
+ "violations": 0,
2841
+ "agent_turns": 2,
2842
+ "checks": 0,
2843
+ "seconds": 1484.8,
2844
+ "input_tokens": 74971,
2845
+ "cache_read_tokens": 0,
2846
+ "cache_write_tokens": 0,
2847
+ "output_tokens": 128000,
2848
+ "served_by": [
2849
+ "zai-org/GLM-5.3-Flash"
2850
+ ],
2851
+ "end_reason": "no_tool_call",
2852
+ "errors": 0,
2853
+ "plan": null
2854
+ },
2855
+ {
2856
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
2857
+ "task_id": "gnlw-2025-04-25-1730-210m-blockage-0",
2858
+ "split": "eval",
2859
+ "difficulty": "blockage",
2860
+ "day": "2025-04-25",
2861
+ "runs": 33,
2862
+ "reward": 0.212973,
2863
+ "submitted": true,
2864
+ "feasible": true,
2865
+ "cost": 1607,
2866
+ "optimal_cost": 1370,
2867
+ "floor_cost": 1355,
2868
+ "greedy_cost": null,
2869
+ "real_cost": 51,
2870
+ "hardness": {
2871
+ "runs": 33,
2872
+ "sets": 7,
2873
+ "stops": 208,
2874
+ "conflicts": 33,
2875
+ "single_track": 5,
2876
+ "headway": 0,
2877
+ "platforms": 0,
2878
+ "closed": 28,
2879
+ "decisions": 25,
2880
+ "holds": 11,
2881
+ "short_turns": 14,
2882
+ "cancels": 0,
2883
+ "turn_points": 2,
2884
+ "avoidable": 15,
2885
+ "optimal": 1370,
2886
+ "greedy_gap": 0.8424,
2887
+ "closed_min": 66,
2888
+ "slow_min": 0,
2889
+ "track_min": 0,
2890
+ "single_closed": true,
2891
+ "mid_line": true,
2892
+ "late_sets": 1
2893
+ },
2894
+ "violations": 0,
2895
+ "agent_turns": 7,
2896
+ "checks": 4,
2897
+ "seconds": 1667.7,
2898
+ "input_tokens": 833434,
2899
+ "cache_read_tokens": 601536,
2900
+ "cache_write_tokens": 0,
2901
+ "output_tokens": 214284,
2902
+ "served_by": [
2903
+ "zai-org/GLM-5.3-Flash"
2904
+ ],
2905
+ "end_reason": "submitted",
2906
+ "errors": 0,
2907
+ "plan": [
2908
+ {
2909
+ "train": "37457",
2910
+ "hold": {
2911
+ "GK": "18:46"
2912
+ },
2913
+ "to": "BP"
2914
+ },
2915
+ {
2916
+ "train": "37368",
2917
+ "cancel": true
2918
+ },
2919
+ {
2920
+ "train": "37364",
2921
+ "to": "BP"
2922
+ },
2923
+ {
2924
+ "train": "37359",
2925
+ "from": "BP"
2926
+ },
2927
+ {
2928
+ "train": "37357",
2929
+ "to": "ZH"
2930
+ },
2931
+ {
2932
+ "train": "37468",
2933
+ "from": "ZH"
2934
+ },
2935
+ {
2936
+ "train": "37459",
2937
+ "to": "ZH"
2938
+ },
2939
+ {
2940
+ "train": "37370",
2941
+ "from": "ZH"
2942
+ },
2943
+ {
2944
+ "train": "37366",
2945
+ "to": "BP",
2946
+ "hold": {
2947
+ "FWD": "18:22"
2948
+ }
2949
+ },
2950
+ {
2951
+ "train": "37361",
2952
+ "from": "BP"
2953
+ },
2954
+ {
2955
+ "train": "37466",
2956
+ "to": "BP",
2957
+ "hold": {
2958
+ "FWD": "18:50"
2959
+ }
2960
+ },
2961
+ {
2962
+ "train": "37463",
2963
+ "from": "BP"
2964
+ },
2965
+ {
2966
+ "train": "37464",
2967
+ "to": "BP"
2968
+ },
2969
+ {
2970
+ "train": "37461",
2971
+ "from": "BP"
2972
+ },
2973
+ {
2974
+ "train": "37363",
2975
+ "from": "BP"
2976
+ },
2977
+ {
2978
+ "train": "37470",
2979
+ "hold": {
2980
+ "DWE": "19:11"
2981
+ }
2982
+ },
2983
+ {
2984
+ "train": "37467",
2985
+ "hold": {
2986
+ "FWD": "20:26"
2987
+ }
2988
+ }
2989
+ ]
2990
+ },
2991
+ {
2992
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
2993
+ "task_id": "gnlw-2025-04-26-0630-180m-blockage-0",
2994
+ "split": "eval",
2995
+ "difficulty": "blockage",
2996
+ "day": "2025-04-26",
2997
+ "runs": 20,
2998
+ "reward": 0.200353,
2999
+ "submitted": true,
3000
+ "feasible": true,
3001
+ "cost": 1692,
3002
+ "optimal_cost": 1047,
3003
+ "floor_cost": 980,
3004
+ "greedy_cost": 3569,
3005
+ "real_cost": 88,
3006
+ "hardness": {
3007
+ "runs": 20,
3008
+ "sets": 7,
3009
+ "stops": 150,
3010
+ "conflicts": 35,
3011
+ "single_track": 9,
3012
+ "headway": 19,
3013
+ "platforms": 1,
3014
+ "closed": 6,
3015
+ "decisions": 38,
3016
+ "holds": 30,
3017
+ "short_turns": 8,
3018
+ "cancels": 0,
3019
+ "turn_points": 3,
3020
+ "avoidable": 67,
3021
+ "optimal": 1047,
3022
+ "greedy_gap": 0.8,
3023
+ "closed_min": 59,
3024
+ "slow_min": 0,
3025
+ "track_min": 0,
3026
+ "single_closed": true,
3027
+ "mid_line": false,
3028
+ "late_sets": 1
3029
+ },
3030
+ "violations": 0,
3031
+ "agent_turns": 10,
3032
+ "checks": 7,
3033
+ "seconds": 2400.5,
3034
+ "input_tokens": 1567517,
3035
+ "cache_read_tokens": 926144,
3036
+ "cache_write_tokens": 0,
3037
+ "output_tokens": 221777,
3038
+ "served_by": [
3039
+ "zai-org/GLM-5.3-Flash"
3040
+ ],
3041
+ "end_reason": "submitted",
3042
+ "errors": 0,
3043
+ "plan": [
3044
+ {
3045
+ "hold": {
3046
+ "HDG": "07:36"
3047
+ },
3048
+ "train": "37413"
3049
+ },
3050
+ {
3051
+ "hold": {
3052
+ "FWD": "07:34",
3053
+ "HDG": "07:39"
3054
+ },
3055
+ "train": "37415"
3056
+ },
3057
+ {
3058
+ "hold": {
3059
+ "LW": "07:29",
3060
+ "HDG": "07:37"
3061
+ },
3062
+ "train": "37422"
3063
+ },
3064
+ {
3065
+ "to": "BP",
3066
+ "train": "37420"
3067
+ },
3068
+ {
3069
+ "from": "BP",
3070
+ "hold": {
3071
+ "FWD": "07:58"
3072
+ },
3073
+ "train": "37417"
3074
+ },
3075
+ {
3076
+ "hold": {
3077
+ "LW": "07:48"
3078
+ },
3079
+ "train": "37424"
3080
+ },
3081
+ {
3082
+ "hold": {
3083
+ "LW": "07:51",
3084
+ "HDG": "08:01",
3085
+ "GK": "08:24"
3086
+ },
3087
+ "train": "37426"
3088
+ },
3089
+ {
3090
+ "hold": {
3091
+ "GN": "08:12"
3092
+ },
3093
+ "train": "37419"
3094
+ },
3095
+ {
3096
+ "hold": {
3097
+ "GN": "08:32"
3098
+ },
3099
+ "train": "37421"
3100
+ },
3101
+ {
3102
+ "hold": {
3103
+ "LW": "08:17",
3104
+ "GK": "08:49"
3105
+ },
3106
+ "train": "37328"
3107
+ },
3108
+ {
3109
+ "hold": {
3110
+ "GK": "08:53"
3111
+ },
3112
+ "train": "37428"
3113
+ },
3114
+ {
3115
+ "hold": {
3116
+ "GN": "08:29"
3117
+ },
3118
+ "train": "37319"
3119
+ },
3120
+ {
3121
+ "hold": {
3122
+ "LW": "09:14"
3123
+ },
3124
+ "train": "37430"
3125
+ },
3126
+ {
3127
+ "hold": {
3128
+ "LW": "09:18",
3129
+ "HDG": "09:25",
3130
+ "FWD": "09:27",
3131
+ "DWE": "09:30",
3132
+ "BP": "09:35",
3133
+ "GK": "09:44",
3134
+ "ZH": "09:47"
3135
+ },
3136
+ "train": "37330"
3137
+ },
3138
+ {
3139
+ "hold": {
3140
+ "LW": "09:23",
3141
+ "BP": "09:40",
3142
+ "GK": "09:47",
3143
+ "ZH": "09:51"
3144
+ },
3145
+ "train": "37332"
3146
+ },
3147
+ {
3148
+ "hold": {
3149
+ "LW": "09:26",
3150
+ "HDG": "09:37",
3151
+ "ZH": "10:01"
3152
+ },
3153
+ "train": "37432"
3154
+ },
3155
+ {
3156
+ "hold": {
3157
+ "ZH": "09:08",
3158
+ "FWD": "09:31"
3159
+ },
3160
+ "train": "37423"
3161
+ },
3162
+ {
3163
+ "hold": {
3164
+ "FWD": "09:35",
3165
+ "LWC": "09:42"
3166
+ },
3167
+ "train": "37323"
3168
+ }
3169
+ ]
3170
+ },
3171
+ {
3172
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
3173
+ "task_id": "gnlw-2025-04-26-1700-180m-blockage-0",
3174
+ "split": "eval",
3175
+ "difficulty": "blockage",
3176
+ "day": "2025-04-26",
3177
+ "runs": 30,
3178
+ "reward": 0.0,
3179
+ "submitted": false,
3180
+ "feasible": false,
3181
+ "cost": null,
3182
+ "optimal_cost": 447,
3183
+ "floor_cost": 433,
3184
+ "greedy_cost": 2402,
3185
+ "real_cost": 59,
3186
+ "hardness": {
3187
+ "runs": 30,
3188
+ "sets": 7,
3189
+ "stops": 200,
3190
+ "conflicts": 10,
3191
+ "single_track": 0,
3192
+ "headway": 0,
3193
+ "platforms": 0,
3194
+ "closed": 10,
3195
+ "decisions": 31,
3196
+ "holds": 25,
3197
+ "short_turns": 6,
3198
+ "cancels": 0,
3199
+ "turn_points": 2,
3200
+ "avoidable": 14,
3201
+ "optimal": 447,
3202
+ "greedy_gap": 0.8,
3203
+ "closed_min": 36,
3204
+ "slow_min": 0,
3205
+ "track_min": 0,
3206
+ "single_closed": true,
3207
+ "mid_line": false,
3208
+ "late_sets": 0
3209
+ },
3210
+ "violations": 0,
3211
+ "agent_turns": 9,
3212
+ "checks": 4,
3213
+ "seconds": 3456.6,
3214
+ "input_tokens": 1424299,
3215
+ "cache_read_tokens": 663424,
3216
+ "cache_write_tokens": 0,
3217
+ "output_tokens": 358778,
3218
+ "served_by": [
3219
+ "zai-org/GLM-5.3-Flash"
3220
+ ],
3221
+ "end_reason": "no_tool_call",
3222
+ "errors": 0,
3223
+ "plan": null
3224
+ },
3225
+ {
3226
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
3227
+ "task_id": "gnlw-2025-04-27-0800-210m-compound-0",
3228
+ "split": "eval",
3229
+ "difficulty": "compound",
3230
+ "day": "2025-04-27",
3231
+ "runs": 15,
3232
+ "reward": 0.847471,
3233
+ "submitted": true,
3234
+ "feasible": true,
3235
+ "cost": 393,
3236
+ "optimal_cost": 382,
3237
+ "floor_cost": 378,
3238
+ "greedy_cost": 1155,
3239
+ "real_cost": 39,
3240
+ "hardness": {
3241
+ "runs": 15,
3242
+ "sets": 3,
3243
+ "stops": 100,
3244
+ "conflicts": 20,
3245
+ "single_track": 3,
3246
+ "headway": 14,
3247
+ "platforms": 0,
3248
+ "closed": 3,
3249
+ "decisions": 20,
3250
+ "holds": 16,
3251
+ "short_turns": 4,
3252
+ "cancels": 0,
3253
+ "turn_points": 2,
3254
+ "avoidable": 4,
3255
+ "optimal": 382,
3256
+ "greedy_gap": 0.8,
3257
+ "closed_min": 36,
3258
+ "slow_min": 80,
3259
+ "track_min": 0,
3260
+ "single_closed": true,
3261
+ "mid_line": false,
3262
+ "late_sets": 1
3263
+ },
3264
+ "violations": 0,
3265
+ "agent_turns": 4,
3266
+ "checks": 2,
3267
+ "seconds": 1169.0,
3268
+ "input_tokens": 258427,
3269
+ "cache_read_tokens": 132288,
3270
+ "cache_write_tokens": 0,
3271
+ "output_tokens": 127858,
3272
+ "served_by": [
3273
+ "zai-org/GLM-5.3-Flash"
3274
+ ],
3275
+ "end_reason": "submitted",
3276
+ "errors": 0,
3277
+ "plan": [
3278
+ {
3279
+ "train": "37426",
3280
+ "to": "ZH"
3281
+ },
3282
+ {
3283
+ "train": "37321",
3284
+ "from": "ZH",
3285
+ "hold": {
3286
+ "FWD": "09:05"
3287
+ }
3288
+ },
3289
+ {
3290
+ "train": "37328",
3291
+ "to": "BP"
3292
+ },
3293
+ {
3294
+ "train": "37423",
3295
+ "from": "BP",
3296
+ "hold": {
3297
+ "FWD": "09:33"
3298
+ }
3299
+ },
3300
+ {
3301
+ "train": "37325",
3302
+ "hold": {
3303
+ "GN": "09:43"
3304
+ }
3305
+ }
3306
+ ]
3307
+ },
3308
+ {
3309
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
3310
+ "task_id": "gnlw-2025-04-27-0830-210m-blockage-0",
3311
+ "split": "eval",
3312
+ "difficulty": "blockage",
3313
+ "day": "2025-04-27",
3314
+ "runs": 18,
3315
+ "reward": 0.314963,
3316
+ "submitted": true,
3317
+ "feasible": true,
3318
+ "cost": 1380,
3319
+ "optimal_cost": 1283,
3320
+ "floor_cost": 1283,
3321
+ "greedy_cost": 1624,
3322
+ "real_cost": 45,
3323
+ "hardness": {
3324
+ "runs": 18,
3325
+ "sets": 3,
3326
+ "stops": 122,
3327
+ "conflicts": 10,
3328
+ "single_track": 0,
3329
+ "headway": 0,
3330
+ "platforms": 0,
3331
+ "closed": 10,
3332
+ "decisions": 15,
3333
+ "holds": 3,
3334
+ "short_turns": 12,
3335
+ "cancels": 0,
3336
+ "turn_points": 2,
3337
+ "avoidable": 0,
3338
+ "optimal": 1283,
3339
+ "greedy_gap": 0.7991,
3340
+ "closed_min": 157,
3341
+ "slow_min": 0,
3342
+ "track_min": 0,
3343
+ "single_closed": false,
3344
+ "mid_line": true,
3345
+ "late_sets": 0
3346
+ },
3347
+ "violations": 0,
3348
+ "agent_turns": 3,
3349
+ "checks": 1,
3350
+ "seconds": 661.3,
3351
+ "input_tokens": 142460,
3352
+ "cache_read_tokens": 69824,
3353
+ "cache_write_tokens": 0,
3354
+ "output_tokens": 67383,
3355
+ "served_by": [
3356
+ "zai-org/GLM-5.3-Flash"
3357
+ ],
3358
+ "end_reason": "submitted",
3359
+ "errors": 0,
3360
+ "plan": [
3361
+ {
3362
+ "train": "37430",
3363
+ "to": "HDG"
3364
+ },
3365
+ {
3366
+ "train": "37325",
3367
+ "from": "HDG"
3368
+ },
3369
+ {
3370
+ "train": "37336",
3371
+ "to": "HDG"
3372
+ },
3373
+ {
3374
+ "train": "37431",
3375
+ "from": "HDG"
3376
+ },
3377
+ {
3378
+ "train": "37321",
3379
+ "to": "BP"
3380
+ },
3381
+ {
3382
+ "train": "37332",
3383
+ "from": "BP"
3384
+ },
3385
+ {
3386
+ "train": "37427",
3387
+ "to": "BP"
3388
+ },
3389
+ {
3390
+ "train": "37438",
3391
+ "from": "BP"
3392
+ },
3393
+ {
3394
+ "train": "37423",
3395
+ "to": "BP"
3396
+ },
3397
+ {
3398
+ "train": "37434",
3399
+ "from": "BP"
3400
+ },
3401
+ {
3402
+ "train": "37329",
3403
+ "to": "BP"
3404
+ },
3405
+ {
3406
+ "train": "37340",
3407
+ "from": "BP"
3408
+ }
3409
+ ]
3410
+ },
3411
+ {
3412
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
3413
+ "task_id": "gnlw-2025-05-26-0730-150m-blockage-0",
3414
+ "split": "eval",
3415
+ "difficulty": "blockage",
3416
+ "day": "2025-05-26",
3417
+ "runs": 26,
3418
+ "reward": 0.971174,
3419
+ "submitted": true,
3420
+ "feasible": true,
3421
+ "cost": 859,
3422
+ "optimal_cost": 857,
3423
+ "floor_cost": 848,
3424
+ "greedy_cost": null,
3425
+ "real_cost": 165,
3426
+ "hardness": {
3427
+ "runs": 26,
3428
+ "sets": 7,
3429
+ "stops": 174,
3430
+ "conflicts": 24,
3431
+ "single_track": 3,
3432
+ "headway": 4,
3433
+ "platforms": 0,
3434
+ "closed": 17,
3435
+ "decisions": 32,
3436
+ "holds": 14,
3437
+ "short_turns": 18,
3438
+ "cancels": 0,
3439
+ "turn_points": 1,
3440
+ "avoidable": 9,
3441
+ "optimal": 857,
3442
+ "greedy_gap": 0.8231,
3443
+ "closed_min": 130,
3444
+ "slow_min": 0,
3445
+ "track_min": 0,
3446
+ "single_closed": true,
3447
+ "mid_line": false,
3448
+ "late_sets": 1
3449
+ },
3450
+ "violations": 0,
3451
+ "agent_turns": 4,
3452
+ "checks": 1,
3453
+ "seconds": 698.6,
3454
+ "input_tokens": 164449,
3455
+ "cache_read_tokens": 79360,
3456
+ "cache_write_tokens": 0,
3457
+ "output_tokens": 72465,
3458
+ "served_by": [
3459
+ "zai-org/GLM-5.3-Flash"
3460
+ ],
3461
+ "end_reason": "submitted",
3462
+ "errors": 0,
3463
+ "plan": [
3464
+ {
3465
+ "train": "37315",
3466
+ "to": "HDG"
3467
+ },
3468
+ {
3469
+ "train": "37426",
3470
+ "from": "HDG"
3471
+ },
3472
+ {
3473
+ "train": "37417",
3474
+ "to": "HDG"
3475
+ },
3476
+ {
3477
+ "train": "37328",
3478
+ "from": "HDG"
3479
+ },
3480
+ {
3481
+ "train": "37317",
3482
+ "to": "HDG"
3483
+ },
3484
+ {
3485
+ "train": "37428",
3486
+ "from": "HDG"
3487
+ },
3488
+ {
3489
+ "train": "37419",
3490
+ "hold": {
3491
+ "GN": "07:57"
3492
+ },
3493
+ "to": "HDG"
3494
+ },
3495
+ {
3496
+ "train": "37330",
3497
+ "from": "HDG"
3498
+ },
3499
+ {
3500
+ "train": "37421",
3501
+ "to": "HDG"
3502
+ },
3503
+ {
3504
+ "train": "37332",
3505
+ "from": "HDG"
3506
+ },
3507
+ {
3508
+ "train": "37321",
3509
+ "to": "HDG"
3510
+ },
3511
+ {
3512
+ "train": "37432",
3513
+ "from": "HDG"
3514
+ },
3515
+ {
3516
+ "train": "37319",
3517
+ "to": "HDG"
3518
+ },
3519
+ {
3520
+ "train": "37430",
3521
+ "from": "HDG"
3522
+ },
3523
+ {
3524
+ "train": "37423",
3525
+ "to": "HDG"
3526
+ },
3527
+ {
3528
+ "train": "37334",
3529
+ "from": "HDG"
3530
+ },
3531
+ {
3532
+ "train": "37323",
3533
+ "to": "HDG"
3534
+ },
3535
+ {
3536
+ "train": "37434",
3537
+ "from": "HDG"
3538
+ },
3539
+ {
3540
+ "train": "37326",
3541
+ "hold": {
3542
+ "GK": "08:09"
3543
+ }
3544
+ }
3545
+ ]
3546
+ },
3547
+ {
3548
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
3549
+ "task_id": "gnlw-2025-05-26-1630-150m-blockage-0",
3550
+ "split": "eval",
3551
+ "difficulty": "blockage",
3552
+ "day": "2025-05-26",
3553
+ "runs": 26,
3554
+ "reward": 0.722475,
3555
+ "submitted": true,
3556
+ "feasible": true,
3557
+ "cost": 290,
3558
+ "optimal_cost": 254,
3559
+ "floor_cost": 185,
3560
+ "greedy_cost": null,
3561
+ "real_cost": 55,
3562
+ "hardness": {
3563
+ "runs": 26,
3564
+ "sets": 7,
3565
+ "stops": 174,
3566
+ "conflicts": 8,
3567
+ "single_track": 0,
3568
+ "headway": 0,
3569
+ "platforms": 0,
3570
+ "closed": 8,
3571
+ "decisions": 27,
3572
+ "holds": 25,
3573
+ "short_turns": 2,
3574
+ "cancels": 0,
3575
+ "turn_points": 1,
3576
+ "avoidable": 69,
3577
+ "optimal": 254,
3578
+ "greedy_gap": 0.8,
3579
+ "closed_min": 10,
3580
+ "slow_min": 0,
3581
+ "track_min": 0,
3582
+ "single_closed": true,
3583
+ "mid_line": false,
3584
+ "late_sets": 0
3585
+ },
3586
+ "violations": 0,
3587
+ "agent_turns": 8,
3588
+ "checks": 5,
3589
+ "seconds": 2191.8,
3590
+ "input_tokens": 1088436,
3591
+ "cache_read_tokens": 744512,
3592
+ "cache_write_tokens": 0,
3593
+ "output_tokens": 233550,
3594
+ "served_by": [
3595
+ "zai-org/GLM-5.3-Flash"
3596
+ ],
3597
+ "end_reason": "submitted",
3598
+ "errors": 0,
3599
+ "plan": [
3600
+ {
3601
+ "train": "37460",
3602
+ "hold": {
3603
+ "BP": "17:00"
3604
+ }
3605
+ },
3606
+ {
3607
+ "train": "37360",
3608
+ "hold": {
3609
+ "ZH": "17:00"
3610
+ }
3611
+ },
3612
+ {
3613
+ "train": "37353",
3614
+ "hold": {
3615
+ "ZH": "17:00"
3616
+ }
3617
+ },
3618
+ {
3619
+ "train": "37362",
3620
+ "hold": {
3621
+ "FWD": "17:03",
3622
+ "ZH": "17:20"
3623
+ }
3624
+ },
3625
+ {
3626
+ "train": "37462",
3627
+ "hold": {
3628
+ "FWD": "17:06"
3629
+ }
3630
+ },
3631
+ {
3632
+ "train": "37455",
3633
+ "hold": {
3634
+ "GN": "17:00",
3635
+ "ZH": "17:12",
3636
+ "FWD": "17:34"
3637
+ }
3638
+ },
3639
+ {
3640
+ "train": "37355",
3641
+ "hold": {
3642
+ "FWD": "17:40"
3643
+ }
3644
+ },
3645
+ {
3646
+ "train": "37364",
3647
+ "hold": {
3648
+ "LW": "17:26"
3649
+ }
3650
+ },
3651
+ {
3652
+ "train": "37357",
3653
+ "hold": {
3654
+ "ZH": "17:51"
3655
+ }
3656
+ },
3657
+ {
3658
+ "train": "37368",
3659
+ "hold": {
3660
+ "LW": "18:17"
3661
+ }
3662
+ },
3663
+ {
3664
+ "train": "37457",
3665
+ "hold": {
3666
+ "FWD": "17:57"
3667
+ }
3668
+ },
3669
+ {
3670
+ "train": "37466",
3671
+ "hold": {
3672
+ "HDG": "18:00"
3673
+ }
3674
+ }
3675
+ ]
3676
+ },
3677
+ {
3678
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
3679
+ "task_id": "gnlw-2025-05-26-1800-150m-blockage-0",
3680
+ "split": "eval",
3681
+ "difficulty": "blockage",
3682
+ "day": "2025-05-26",
3683
+ "runs": 25,
3684
+ "reward": 0.957344,
3685
+ "submitted": true,
3686
+ "feasible": true,
3687
+ "cost": 1018,
3688
+ "optimal_cost": 1014,
3689
+ "floor_cost": 968,
3690
+ "greedy_cost": null,
3691
+ "real_cost": 41,
3692
+ "hardness": {
3693
+ "runs": 25,
3694
+ "sets": 7,
3695
+ "stops": 158,
3696
+ "conflicts": 17,
3697
+ "single_track": 0,
3698
+ "headway": 0,
3699
+ "platforms": 0,
3700
+ "closed": 17,
3701
+ "decisions": 31,
3702
+ "holds": 17,
3703
+ "short_turns": 12,
3704
+ "cancels": 2,
3705
+ "turn_points": 1,
3706
+ "avoidable": 46,
3707
+ "optimal": 1014,
3708
+ "greedy_gap": 0.824,
3709
+ "closed_min": 130,
3710
+ "slow_min": 0,
3711
+ "track_min": 0,
3712
+ "single_closed": true,
3713
+ "mid_line": true,
3714
+ "late_sets": 0
3715
+ },
3716
+ "violations": 0,
3717
+ "agent_turns": 6,
3718
+ "checks": 4,
3719
+ "seconds": 1307.8,
3720
+ "input_tokens": 506659,
3721
+ "cache_read_tokens": 369152,
3722
+ "cache_write_tokens": 0,
3723
+ "output_tokens": 136057,
3724
+ "served_by": [
3725
+ "zai-org/GLM-5.3-Flash"
3726
+ ],
3727
+ "end_reason": "submitted",
3728
+ "errors": 0,
3729
+ "plan": [
3730
+ {
3731
+ "train": "37357",
3732
+ "to": "HDG"
3733
+ },
3734
+ {
3735
+ "train": "37459",
3736
+ "to": "HDG"
3737
+ },
3738
+ {
3739
+ "train": "37359",
3740
+ "to": "HDG"
3741
+ },
3742
+ {
3743
+ "train": "37461",
3744
+ "to": "HDG"
3745
+ },
3746
+ {
3747
+ "train": "37361",
3748
+ "to": "HDG"
3749
+ },
3750
+ {
3751
+ "train": "37463",
3752
+ "to": "HDG"
3753
+ },
3754
+ {
3755
+ "train": "37465",
3756
+ "to": "HDG"
3757
+ },
3758
+ {
3759
+ "train": "37468",
3760
+ "from": "HDG",
3761
+ "hold": {
3762
+ "HDG": "18:29"
3763
+ }
3764
+ },
3765
+ {
3766
+ "train": "37370",
3767
+ "from": "HDG",
3768
+ "hold": {
3769
+ "HDG": "18:51"
3770
+ }
3771
+ },
3772
+ {
3773
+ "train": "37470",
3774
+ "from": "HDG",
3775
+ "hold": {
3776
+ "HDG": "18:59"
3777
+ }
3778
+ },
3779
+ {
3780
+ "train": "37372",
3781
+ "from": "HDG",
3782
+ "hold": {
3783
+ "HDG": "19:21"
3784
+ }
3785
+ },
3786
+ {
3787
+ "train": "37472",
3788
+ "from": "HDG",
3789
+ "hold": {
3790
+ "HDG": "19:29"
3791
+ }
3792
+ },
3793
+ {
3794
+ "train": "37374",
3795
+ "from": "HDG",
3796
+ "hold": {
3797
+ "HDG": "19:51"
3798
+ }
3799
+ },
3800
+ {
3801
+ "train": "37376",
3802
+ "from": "HDG",
3803
+ "hold": {
3804
+ "HDG": "20:24",
3805
+ "GK": "20:41"
3806
+ }
3807
+ },
3808
+ {
3809
+ "train": "37467",
3810
+ "hold": {
3811
+ "FWD": "20:27"
3812
+ }
3813
+ },
3814
+ {
3815
+ "train": "37365",
3816
+ "hold": {
3817
+ "FWD": "20:22"
3818
+ }
3819
+ },
3820
+ {
3821
+ "train": "37474",
3822
+ "hold": {
3823
+ "LWC": "20:15"
3824
+ }
3825
+ },
3826
+ {
3827
+ "train": "37368",
3828
+ "cancel": true
3829
+ },
3830
+ {
3831
+ "train": "37363",
3832
+ "cancel": true
3833
+ }
3834
+ ]
3835
+ },
3836
+ {
3837
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
3838
+ "task_id": "gnlw-2025-05-27-0830-210m-blockage-0",
3839
+ "split": "eval",
3840
+ "difficulty": "blockage",
3841
+ "day": "2025-05-27",
3842
+ "runs": 34,
3843
+ "reward": 0.204715,
3844
+ "submitted": true,
3845
+ "feasible": true,
3846
+ "cost": 1121,
3847
+ "optimal_cost": 795,
3848
+ "floor_cost": 768,
3849
+ "greedy_cost": null,
3850
+ "real_cost": 285,
3851
+ "hardness": {
3852
+ "runs": 34,
3853
+ "sets": 7,
3854
+ "stops": 226,
3855
+ "conflicts": 28,
3856
+ "single_track": 1,
3857
+ "headway": 0,
3858
+ "platforms": 0,
3859
+ "closed": 27,
3860
+ "decisions": 44,
3861
+ "holds": 30,
3862
+ "short_turns": 12,
3863
+ "cancels": 2,
3864
+ "turn_points": 1,
3865
+ "avoidable": 27,
3866
+ "optimal": 795,
3867
+ "greedy_gap": 0.8529,
3868
+ "closed_min": 98,
3869
+ "slow_min": 0,
3870
+ "track_min": 0,
3871
+ "single_closed": true,
3872
+ "mid_line": false,
3873
+ "late_sets": 0
3874
+ },
3875
+ "violations": 0,
3876
+ "agent_turns": 5,
3877
+ "checks": 3,
3878
+ "seconds": 1610.8,
3879
+ "input_tokens": 428158,
3880
+ "cache_read_tokens": 264576,
3881
+ "cache_write_tokens": 0,
3882
+ "output_tokens": 170879,
3883
+ "served_by": [
3884
+ "zai-org/GLM-5.3-Flash"
3885
+ ],
3886
+ "end_reason": "submitted",
3887
+ "errors": 0,
3888
+ "plan": [
3889
+ {
3890
+ "hold": {
3891
+ "HDG": "08:32"
3892
+ },
3893
+ "to": "ZH",
3894
+ "train": "37428"
3895
+ },
3896
+ {
3897
+ "from": "ZH",
3898
+ "train": "37425"
3899
+ },
3900
+ {
3901
+ "hold": {
3902
+ "FWD": "08:35"
3903
+ },
3904
+ "train": "37319"
3905
+ },
3906
+ {
3907
+ "hold": {
3908
+ "BP": "08:36"
3909
+ },
3910
+ "train": "37328"
3911
+ },
3912
+ {
3913
+ "hold": {
3914
+ "BP": "09:29"
3915
+ },
3916
+ "to": "ZH",
3917
+ "train": "37330"
3918
+ },
3919
+ {
3920
+ "from": "ZH",
3921
+ "train": "37325"
3922
+ },
3923
+ {
3924
+ "to": "ZH",
3925
+ "train": "37430"
3926
+ },
3927
+ {
3928
+ "from": "ZH",
3929
+ "train": "37427"
3930
+ },
3931
+ {
3932
+ "hold": {
3933
+ "BP": "10:01"
3934
+ },
3935
+ "to": "ZH",
3936
+ "train": "37332"
3937
+ },
3938
+ {
3939
+ "from": "ZH",
3940
+ "train": "37327"
3941
+ },
3942
+ {
3943
+ "to": "ZH",
3944
+ "train": "37432"
3945
+ },
3946
+ {
3947
+ "from": "ZH",
3948
+ "train": "37429"
3949
+ },
3950
+ {
3951
+ "cancel": true,
3952
+ "train": "37423"
3953
+ },
3954
+ {
3955
+ "cancel": true,
3956
+ "train": "37334"
3957
+ },
3958
+ {
3959
+ "cancel": true,
3960
+ "train": "37323"
3961
+ },
3962
+ {
3963
+ "cancel": true,
3964
+ "train": "37434"
3965
+ }
3966
+ ]
3967
+ },
3968
+ {
3969
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
3970
+ "task_id": "gnlw-2025-05-27-1700-180m-blockage-0",
3971
+ "split": "eval",
3972
+ "difficulty": "blockage",
3973
+ "day": "2025-05-27",
3974
+ "runs": 30,
3975
+ "reward": 0.06,
3976
+ "submitted": true,
3977
+ "feasible": false,
3978
+ "cost": null,
3979
+ "optimal_cost": 791,
3980
+ "floor_cost": 670,
3981
+ "greedy_cost": null,
3982
+ "real_cost": 253,
3983
+ "hardness": {
3984
+ "runs": 30,
3985
+ "sets": 7,
3986
+ "stops": 200,
3987
+ "conflicts": 17,
3988
+ "single_track": 0,
3989
+ "headway": 0,
3990
+ "platforms": 0,
3991
+ "closed": 17,
3992
+ "decisions": 27,
3993
+ "holds": 21,
3994
+ "short_turns": 6,
3995
+ "cancels": 0,
3996
+ "turn_points": 2,
3997
+ "avoidable": 121,
3998
+ "optimal": 791,
3999
+ "greedy_gap": 0.8467,
4000
+ "closed_min": 35,
4001
+ "slow_min": 0,
4002
+ "track_min": 0,
4003
+ "single_closed": true,
4004
+ "mid_line": true,
4005
+ "late_sets": 0
4006
+ },
4007
+ "violations": 29,
4008
+ "agent_turns": 12,
4009
+ "checks": 7,
4010
+ "seconds": 3352.7,
4011
+ "input_tokens": 2662189,
4012
+ "cache_read_tokens": 2171520,
4013
+ "cache_write_tokens": 0,
4014
+ "output_tokens": 434564,
4015
+ "served_by": [
4016
+ "zai-org/GLM-5.3-Flash"
4017
+ ],
4018
+ "end_reason": "submitted",
4019
+ "errors": 0,
4020
+ "plan": [
4021
+ {
4022
+ "hold": {
4023
+ "ZH": "17:55"
4024
+ },
4025
+ "train": "37355"
4026
+ },
4027
+ {
4028
+ "hold": {
4029
+ "ZH": "18:02"
4030
+ },
4031
+ "train": "37457"
4032
+ },
4033
+ {
4034
+ "hold": {
4035
+ "BP": "17:58"
4036
+ },
4037
+ "train": "37364"
4038
+ },
4039
+ {
4040
+ "hold": {
4041
+ "BP": "18:04"
4042
+ },
4043
+ "to": "ZH",
4044
+ "train": "37462"
4045
+ },
4046
+ {
4047
+ "hold": {
4048
+ "GN": "17:49",
4049
+ "ZH": "18:20"
4050
+ },
4051
+ "train": "37357"
4052
+ },
4053
+ {
4054
+ "hold": {
4055
+ "DWE": "18:04",
4056
+ "GK": "18:33"
4057
+ },
4058
+ "train": "37464"
4059
+ },
4060
+ {
4061
+ "from": "ZH",
4062
+ "hold": {
4063
+ "FWD": "18:58",
4064
+ "ZH": "18:41"
4065
+ },
4066
+ "train": "37459"
4067
+ },
4068
+ {
4069
+ "hold": {
4070
+ "FWD": "19:18",
4071
+ "ZH": "18:55"
4072
+ },
4073
+ "train": "37461"
4074
+ },
4075
+ {
4076
+ "hold": {
4077
+ "FWD": "19:24",
4078
+ "ZH": "19:05"
4079
+ },
4080
+ "train": "37359"
4081
+ },
4082
+ {
4083
+ "hold": {
4084
+ "LW": "18:30"
4085
+ },
4086
+ "to": "BP",
4087
+ "train": "37466"
4088
+ },
4089
+ {
4090
+ "hold": {
4091
+ "BP": "18:58",
4092
+ "FWD": "18:02"
4093
+ },
4094
+ "train": "37366"
4095
+ },
4096
+ {
4097
+ "hold": {
4098
+ "BP": "19:12",
4099
+ "GK": "19:27",
4100
+ "LW": "18:38"
4101
+ },
4102
+ "train": "37368"
4103
+ },
4104
+ {
4105
+ "hold": {
4106
+ "GK": "19:36"
4107
+ },
4108
+ "train": "37370"
4109
+ },
4110
+ {
4111
+ "from": "BP",
4112
+ "hold": {
4113
+ "BP": "19:16",
4114
+ "FWD": "19:36",
4115
+ "HDG": "19:49"
4116
+ },
4117
+ "train": "37463"
4118
+ },
4119
+ {
4120
+ "hold": {
4121
+ "GK": "19:55"
4122
+ },
4123
+ "train": "37470"
4124
+ },
4125
+ {
4126
+ "hold": {
4127
+ "FWD": "20:23",
4128
+ "ZH": "19:57"
4129
+ },
4130
+ "train": "37363"
4131
+ },
4132
+ {
4133
+ "hold": {
4134
+ "ZH": "19:45"
4135
+ },
4136
+ "train": "37465"
4137
+ },
4138
+ {
4139
+ "hold": {
4140
+ "GN": "20:16"
4141
+ },
4142
+ "train": "37365"
4143
+ },
4144
+ {
4145
+ "hold": {
4146
+ "BP": "20:15",
4147
+ "GK": "20:15",
4148
+ "LW": "19:36"
4149
+ },
4150
+ "train": "37372"
4151
+ },
4152
+ {
4153
+ "hold": {
4154
+ "BP": "20:32",
4155
+ "GK": "20:46"
4156
+ },
4157
+ "train": "37472"
4158
+ },
4159
+ {
4160
+ "hold": {
4161
+ "BP": "20:24",
4162
+ "GK": "20:31"
4163
+ },
4164
+ "train": "37374"
4165
+ },
4166
+ {
4167
+ "hold": {
4168
+ "BP": "20:22",
4169
+ "GK": "20:29"
4170
+ },
4171
+ "train": "37474"
4172
+ },
4173
+ {
4174
+ "hold": {
4175
+ "ZH": "20:38"
4176
+ },
4177
+ "train": "37467"
4178
+ }
4179
+ ]
4180
+ },
4181
+ {
4182
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
4183
+ "task_id": "gnlw-2025-05-27-1830-180m-blockage-0",
4184
+ "split": "eval",
4185
+ "difficulty": "blockage",
4186
+ "day": "2025-05-27",
4187
+ "runs": 27,
4188
+ "reward": 0.0,
4189
+ "submitted": false,
4190
+ "feasible": false,
4191
+ "cost": null,
4192
+ "optimal_cost": 596,
4193
+ "floor_cost": 560,
4194
+ "greedy_cost": null,
4195
+ "real_cost": 170,
4196
+ "hardness": {
4197
+ "runs": 27,
4198
+ "sets": 7,
4199
+ "stops": 162,
4200
+ "conflicts": 25,
4201
+ "single_track": 8,
4202
+ "headway": 0,
4203
+ "platforms": 0,
4204
+ "closed": 17,
4205
+ "decisions": 27,
4206
+ "holds": 17,
4207
+ "short_turns": 10,
4208
+ "cancels": 0,
4209
+ "turn_points": 1,
4210
+ "avoidable": 36,
4211
+ "optimal": 596,
4212
+ "greedy_gap": 0.8593,
4213
+ "closed_min": 59,
4214
+ "slow_min": 0,
4215
+ "track_min": 0,
4216
+ "single_closed": true,
4217
+ "mid_line": false,
4218
+ "late_sets": 1
4219
+ },
4220
+ "violations": 0,
4221
+ "agent_turns": 3,
4222
+ "checks": 0,
4223
+ "seconds": 1204.6,
4224
+ "input_tokens": 88076,
4225
+ "cache_read_tokens": 11456,
4226
+ "cache_write_tokens": 0,
4227
+ "output_tokens": 128020,
4228
+ "served_by": [
4229
+ "zai-org/GLM-5.3-Flash"
4230
+ ],
4231
+ "end_reason": "no_tool_call",
4232
+ "errors": 0,
4233
+ "plan": null
4234
+ },
4235
+ {
4236
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
4237
+ "task_id": "gnlw-2025-05-28-1830-210m-compound-0",
4238
+ "split": "eval",
4239
+ "difficulty": "compound",
4240
+ "day": "2025-05-28",
4241
+ "runs": 31,
4242
+ "reward": 0.740479,
4243
+ "submitted": true,
4244
+ "feasible": true,
4245
+ "cost": 466,
4246
+ "optimal_cost": 436,
4247
+ "floor_cost": 383,
4248
+ "greedy_cost": 1271,
4249
+ "real_cost": 69,
4250
+ "hardness": {
4251
+ "runs": 31,
4252
+ "sets": 7,
4253
+ "stops": 186,
4254
+ "conflicts": 23,
4255
+ "single_track": 10,
4256
+ "headway": 11,
4257
+ "platforms": 1,
4258
+ "closed": 1,
4259
+ "decisions": 36,
4260
+ "holds": 32,
4261
+ "short_turns": 4,
4262
+ "cancels": 0,
4263
+ "turn_points": 1,
4264
+ "avoidable": 53,
4265
+ "optimal": 436,
4266
+ "greedy_gap": 0.8,
4267
+ "closed_min": 10,
4268
+ "slow_min": 0,
4269
+ "track_min": 0,
4270
+ "single_closed": true,
4271
+ "mid_line": false,
4272
+ "late_sets": 1
4273
+ },
4274
+ "violations": 0,
4275
+ "agent_turns": 11,
4276
+ "checks": 8,
4277
+ "seconds": 2370.8,
4278
+ "input_tokens": 2102819,
4279
+ "cache_read_tokens": 1567360,
4280
+ "cache_write_tokens": 0,
4281
+ "output_tokens": 266912,
4282
+ "served_by": [
4283
+ "zai-org/GLM-5.3-Flash"
4284
+ ],
4285
+ "end_reason": "submitted",
4286
+ "errors": 0,
4287
+ "plan": [
4288
+ {
4289
+ "train": "37368",
4290
+ "hold": {
4291
+ "BP": "18:43"
4292
+ }
4293
+ },
4294
+ {
4295
+ "train": "37468",
4296
+ "hold": {
4297
+ "BP": "18:46",
4298
+ "ZH": "18:59"
4299
+ }
4300
+ },
4301
+ {
4302
+ "train": "37359",
4303
+ "to": "HDG"
4304
+ },
4305
+ {
4306
+ "train": "37470",
4307
+ "from": "HDG"
4308
+ },
4309
+ {
4310
+ "train": "37361",
4311
+ "to": "HDG",
4312
+ "hold": {
4313
+ "GN": "19:00"
4314
+ }
4315
+ },
4316
+ {
4317
+ "train": "37472",
4318
+ "from": "HDG",
4319
+ "hold": {
4320
+ "HDG": "19:34"
4321
+ }
4322
+ },
4323
+ {
4324
+ "train": "37370",
4325
+ "hold": {
4326
+ "HDG": "18:55",
4327
+ "GK": "19:13"
4328
+ }
4329
+ },
4330
+ {
4331
+ "train": "37463",
4332
+ "hold": {
4333
+ "GN": "19:03"
4334
+ }
4335
+ },
4336
+ {
4337
+ "train": "37363",
4338
+ "hold": {
4339
+ "ZH": "19:19",
4340
+ "FWD": "19:37"
4341
+ }
4342
+ },
4343
+ {
4344
+ "train": "37465",
4345
+ "hold": {
4346
+ "GN": "19:25",
4347
+ "FWD": "19:56"
4348
+ }
4349
+ },
4350
+ {
4351
+ "train": "37467",
4352
+ "hold": {
4353
+ "FWD": "20:25"
4354
+ }
4355
+ },
4356
+ {
4357
+ "train": "37374"
4358
+ },
4359
+ {
4360
+ "train": "37474",
4361
+ "hold": {
4362
+ "LW": "19:50"
4363
+ }
4364
+ },
4365
+ {
4366
+ "train": "37365",
4367
+ "hold": {
4368
+ "FWD": "20:05"
4369
+ }
4370
+ },
4371
+ {
4372
+ "train": "37471",
4373
+ "hold": {
4374
+ "FWD": "21:25"
4375
+ }
4376
+ },
4377
+ {
4378
+ "train": "37373",
4379
+ "hold": {
4380
+ "FWD": "22:04"
4381
+ }
4382
+ }
4383
+ ]
4384
+ },
4385
+ {
4386
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
4387
+ "task_id": "gnlw-2025-05-30-0630-180m-blockage-0",
4388
+ "split": "eval",
4389
+ "difficulty": "blockage",
4390
+ "day": "2025-05-30",
4391
+ "runs": 29,
4392
+ "reward": 0.0,
4393
+ "submitted": false,
4394
+ "feasible": false,
4395
+ "cost": null,
4396
+ "optimal_cost": 454,
4397
+ "floor_cost": 385,
4398
+ "greedy_cost": 1397,
4399
+ "real_cost": 1128,
4400
+ "hardness": {
4401
+ "runs": 29,
4402
+ "sets": 7,
4403
+ "stops": 201,
4404
+ "conflicts": 13,
4405
+ "single_track": 1,
4406
+ "headway": 0,
4407
+ "platforms": 0,
4408
+ "closed": 12,
4409
+ "decisions": 37,
4410
+ "holds": 33,
4411
+ "short_turns": 4,
4412
+ "cancels": 0,
4413
+ "turn_points": 2,
4414
+ "avoidable": 69,
4415
+ "optimal": 454,
4416
+ "greedy_gap": 0.8,
4417
+ "closed_min": 28,
4418
+ "slow_min": 0,
4419
+ "track_min": 0,
4420
+ "single_closed": true,
4421
+ "mid_line": true,
4422
+ "late_sets": 0
4423
+ },
4424
+ "violations": 0,
4425
+ "agent_turns": 2,
4426
+ "checks": 0,
4427
+ "seconds": 1182.1,
4428
+ "input_tokens": 75775,
4429
+ "cache_read_tokens": 6080,
4430
+ "cache_write_tokens": 0,
4431
+ "output_tokens": 128000,
4432
+ "served_by": [
4433
+ "zai-org/GLM-5.3-Flash"
4434
+ ],
4435
+ "end_reason": "no_tool_call",
4436
+ "errors": 0,
4437
+ "plan": null
4438
+ },
4439
+ {
4440
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
4441
+ "task_id": "gnlw-2025-05-31-0800-210m-compound-0",
4442
+ "split": "eval",
4443
+ "difficulty": "compound",
4444
+ "day": "2025-05-31",
4445
+ "runs": 32,
4446
+ "reward": 0.1875,
4447
+ "submitted": true,
4448
+ "feasible": false,
4449
+ "cost": null,
4450
+ "optimal_cost": 1179,
4451
+ "floor_cost": 1017,
4452
+ "greedy_cost": 5859,
4453
+ "real_cost": 90,
4454
+ "hardness": {
4455
+ "runs": 32,
4456
+ "sets": 7,
4457
+ "stops": 218,
4458
+ "conflicts": 43,
4459
+ "single_track": 6,
4460
+ "headway": 16,
4461
+ "platforms": 2,
4462
+ "closed": 19,
4463
+ "decisions": 30,
4464
+ "holds": 20,
4465
+ "short_turns": 10,
4466
+ "cancels": 0,
4467
+ "turn_points": 2,
4468
+ "avoidable": 162,
4469
+ "optimal": 1179,
4470
+ "greedy_gap": 0.8,
4471
+ "closed_min": 39,
4472
+ "slow_min": 0,
4473
+ "track_min": 0,
4474
+ "single_closed": true,
4475
+ "mid_line": false,
4476
+ "late_sets": 2
4477
+ },
4478
+ "violations": 3,
4479
+ "agent_turns": 12,
4480
+ "checks": 7,
4481
+ "seconds": 3479.2,
4482
+ "input_tokens": 2488326,
4483
+ "cache_read_tokens": 1455168,
4484
+ "cache_write_tokens": 0,
4485
+ "output_tokens": 335738,
4486
+ "served_by": [
4487
+ "zai-org/GLM-5.3-Flash"
4488
+ ],
4489
+ "end_reason": "submitted",
4490
+ "errors": 0,
4491
+ "plan": [
4492
+ {
4493
+ "hold": {
4494
+ "GK": "08:49"
4495
+ },
4496
+ "train": "37419"
4497
+ },
4498
+ {
4499
+ "to": "BP",
4500
+ "train": "37426"
4501
+ },
4502
+ {
4503
+ "from": "BP",
4504
+ "hold": {
4505
+ "BP": "09:17",
4506
+ "FWD": "09:30"
4507
+ },
4508
+ "train": "37423"
4509
+ },
4510
+ {
4511
+ "hold": {
4512
+ "GN": "08:49"
4513
+ },
4514
+ "to": "BP",
4515
+ "train": "37319"
4516
+ },
4517
+ {
4518
+ "from": "BP",
4519
+ "train": "37430"
4520
+ },
4521
+ {
4522
+ "hold": {
4523
+ "GN": "08:52",
4524
+ "GK": "09:08"
4525
+ },
4526
+ "to": "BP",
4527
+ "train": "37421"
4528
+ },
4529
+ {
4530
+ "from": "BP",
4531
+ "train": "37332"
4532
+ },
4533
+ {
4534
+ "hold": {
4535
+ "FWD": "08:50"
4536
+ },
4537
+ "train": "37328"
4538
+ },
4539
+ {
4540
+ "hold": {
4541
+ "FWD": "08:53",
4542
+ "BP": "09:04"
4543
+ },
4544
+ "train": "37428"
4545
+ },
4546
+ {
4547
+ "hold": {
4548
+ "ZH": "09:07"
4549
+ },
4550
+ "to": "BP",
4551
+ "train": "37321"
4552
+ },
4553
+ {
4554
+ "from": "BP",
4555
+ "train": "37432"
4556
+ },
4557
+ {
4558
+ "hold": {
4559
+ "DWE": "09:39"
4560
+ },
4561
+ "to": "BP",
4562
+ "train": "37330"
4563
+ },
4564
+ {
4565
+ "from": "BP",
4566
+ "hold": {
4567
+ "FWD": "10:11"
4568
+ },
4569
+ "train": "37325"
4570
+ },
4571
+ {
4572
+ "hold": {
4573
+ "ZH": "09:27"
4574
+ },
4575
+ "train": "37323"
4576
+ },
4577
+ {
4578
+ "hold": {
4579
+ "LW": "09:52"
4580
+ },
4581
+ "train": "37334"
4582
+ },
4583
+ {
4584
+ "hold": {
4585
+ "LW": "09:55",
4586
+ "HDG": "10:04"
4587
+ },
4588
+ "train": "37434"
4589
+ },
4590
+ {
4591
+ "hold": {
4592
+ "FWD": "10:01"
4593
+ },
4594
+ "train": "37425"
4595
+ },
4596
+ {
4597
+ "hold": {
4598
+ "FWD": "10:30"
4599
+ },
4600
+ "train": "37427"
4601
+ },
4602
+ {
4603
+ "hold": {
4604
+ "ZH": "10:18",
4605
+ "FWD": "10:36"
4606
+ },
4607
+ "train": "37327"
4608
+ },
4609
+ {
4610
+ "hold": {
4611
+ "GN": "10:26",
4612
+ "ZH": "10:47"
4613
+ },
4614
+ "train": "37429"
4615
+ },
4616
+ {
4617
+ "hold": {
4618
+ "GN": "10:40",
4619
+ "ZH": "10:51",
4620
+ "BP": "11:03"
4621
+ },
4622
+ "train": "37329"
4623
+ },
4624
+ {
4625
+ "hold": {
4626
+ "LW": "10:22"
4627
+ },
4628
+ "train": "37336"
4629
+ },
4630
+ {
4631
+ "hold": {
4632
+ "LW": "10:25",
4633
+ "HDG": "10:33",
4634
+ "GK": "10:54"
4635
+ },
4636
+ "train": "37436"
4637
+ },
4638
+ {
4639
+ "hold": {
4640
+ "FWD": "11:30"
4641
+ },
4642
+ "train": "37431"
4643
+ },
4644
+ {
4645
+ "hold": {
4646
+ "LW": "11:26",
4647
+ "HDG": "11:36"
4648
+ },
4649
+ "train": "37440"
4650
+ }
4651
+ ]
4652
+ },
4653
+ {
4654
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
4655
+ "task_id": "gnlw-2025-05-31-0900-180m-compound-0",
4656
+ "split": "eval",
4657
+ "difficulty": "compound",
4658
+ "day": "2025-05-31",
4659
+ "runs": 30,
4660
+ "reward": 0.985586,
4661
+ "submitted": true,
4662
+ "feasible": true,
4663
+ "cost": 227,
4664
+ "optimal_cost": 226,
4665
+ "floor_cost": 216,
4666
+ "greedy_cost": 1092,
4667
+ "real_cost": 76,
4668
+ "hardness": {
4669
+ "runs": 30,
4670
+ "sets": 7,
4671
+ "stops": 200,
4672
+ "conflicts": 14,
4673
+ "single_track": 3,
4674
+ "headway": 5,
4675
+ "platforms": 0,
4676
+ "closed": 6,
4677
+ "decisions": 49,
4678
+ "holds": 45,
4679
+ "short_turns": 4,
4680
+ "cancels": 0,
4681
+ "turn_points": 1,
4682
+ "avoidable": 10,
4683
+ "optimal": 226,
4684
+ "greedy_gap": 0.8,
4685
+ "closed_min": 26,
4686
+ "slow_min": 0,
4687
+ "track_min": 0,
4688
+ "single_closed": true,
4689
+ "mid_line": false,
4690
+ "late_sets": 1
4691
+ },
4692
+ "violations": 0,
4693
+ "agent_turns": 4,
4694
+ "checks": 2,
4695
+ "seconds": 720.7,
4696
+ "input_tokens": 235008,
4697
+ "cache_read_tokens": 142912,
4698
+ "cache_write_tokens": 0,
4699
+ "output_tokens": 89136,
4700
+ "served_by": [
4701
+ "zai-org/GLM-5.3-Flash"
4702
+ ],
4703
+ "end_reason": "submitted",
4704
+ "errors": 0,
4705
+ "plan": [
4706
+ {
4707
+ "hold": {
4708
+ "HDG": "09:01"
4709
+ },
4710
+ "train": "37430"
4711
+ },
4712
+ {
4713
+ "hold": {
4714
+ "FWD": "09:05"
4715
+ },
4716
+ "train": "37321"
4717
+ },
4718
+ {
4719
+ "to": "HDG",
4720
+ "train": "37423"
4721
+ },
4722
+ {
4723
+ "from": "HDG",
4724
+ "train": "37334"
4725
+ },
4726
+ {
4727
+ "to": "HDG",
4728
+ "train": "37323"
4729
+ },
4730
+ {
4731
+ "from": "HDG",
4732
+ "hold": {
4733
+ "BP": "10:15"
4734
+ },
4735
+ "train": "37434"
4736
+ },
4737
+ {
4738
+ "hold": {
4739
+ "GN": "09:27"
4740
+ },
4741
+ "train": "37425"
4742
+ }
4743
+ ]
4744
+ },
4745
+ {
4746
+ "model": "hf:zai-org/GLM-5.3-Flash:together@default",
4747
+ "task_id": "gnlw-2025-06-22-1600-240m-compound-0",
4748
+ "split": "eval",
4749
+ "difficulty": "compound",
4750
+ "day": "2025-06-22",
4751
+ "runs": 18,
4752
+ "reward": 1.0,
4753
+ "submitted": true,
4754
+ "feasible": true,
4755
+ "cost": 668,
4756
+ "optimal_cost": 668,
4757
+ "floor_cost": 668,
4758
+ "greedy_cost": null,
4759
+ "real_cost": 27,
4760
+ "hardness": {
4761
+ "runs": 18,
4762
+ "sets": 3,
4763
+ "stops": 108,
4764
+ "conflicts": 22,
4765
+ "single_track": 1,
4766
+ "headway": 0,
4767
+ "platforms": 0,
4768
+ "closed": 21,
4769
+ "decisions": 22,
4770
+ "holds": 16,
4771
+ "short_turns": 4,
4772
+ "cancels": 2,
4773
+ "turn_points": 1,
4774
+ "avoidable": 0,
4775
+ "optimal": 668,
4776
+ "greedy_gap": 0.8444,
4777
+ "closed_min": 92,
4778
+ "slow_min": 0,
4779
+ "track_min": 0,
4780
+ "single_closed": true,
4781
+ "mid_line": false,
4782
+ "late_sets": 1
4783
+ },
4784
+ "violations": 0,
4785
+ "agent_turns": 4,
4786
+ "checks": 2,
4787
+ "seconds": 760.2,
4788
+ "input_tokens": 214568,
4789
+ "cache_read_tokens": 139392,
4790
+ "cache_write_tokens": 0,
4791
+ "output_tokens": 66685,
4792
+ "served_by": [
4793
+ "zai-org/GLM-5.3-Flash"
4794
+ ],
4795
+ "end_reason": "submitted",
4796
+ "errors": 0,
4797
+ "plan": [
4798
+ {
4799
+ "train": "37458",
4800
+ "to": "BP"
4801
+ },
4802
+ {
4803
+ "train": "37353",
4804
+ "from": "BP"
4805
+ },
4806
+ {
4807
+ "train": "37360",
4808
+ "to": "BP"
4809
+ },
4810
+ {
4811
+ "train": "37455",
4812
+ "from": "BP"
4813
+ },
4814
+ {
4815
+ "train": "37451",
4816
+ "cancel": true
4817
+ },
4818
+ {
4819
+ "train": "37462",
4820
+ "cancel": true
4821
+ },
4822
+ {
4823
+ "train": "37364",
4824
+ "hold": {
4825
+ "BP": "17:42"
4826
+ }
4827
+ }
4828
+ ]
4829
+ }
4830
+ ]
4831
+ }
runs/2026-10-08-qwen3-8-2-4t-eval45/index.json ADDED
The diff for this file is too large to render. See raw diff
 
runs/calib-sonnet-5-5/index.json ADDED
@@ -0,0 +1,1900 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "run": "calib-sonnet-5-5",
3
+ "episodes": [
4
+ {
5
+ "model": "claude-sonnet-5-5@high",
6
+ "task_id": "gnlw-2025-01-05-0800-210m-compound-0",
7
+ "split": "train",
8
+ "difficulty": "compound",
9
+ "day": "2025-01-05",
10
+ "runs": 15,
11
+ "reward": 0.200941,
12
+ "submitted": true,
13
+ "feasible": true,
14
+ "cost": 1263,
15
+ "optimal_cost": 892,
16
+ "floor_cost": 882,
17
+ "greedy_cost": 3955,
18
+ "real_cost": 42,
19
+ "hardness": {
20
+ "runs": 15,
21
+ "sets": 3,
22
+ "stops": 100,
23
+ "conflicts": 6,
24
+ "single_track": 1,
25
+ "headway": 0,
26
+ "platforms": 0,
27
+ "closed": 5,
28
+ "decisions": 23,
29
+ "holds": 16,
30
+ "short_turns": 6,
31
+ "cancels": 1,
32
+ "turn_points": 1,
33
+ "avoidable": 10,
34
+ "optimal": 892,
35
+ "greedy_gap": 0.8,
36
+ "closed_min": 67,
37
+ "slow_min": 10,
38
+ "track_min": 31,
39
+ "single_closed": false,
40
+ "mid_line": true,
41
+ "late_sets": 1
42
+ },
43
+ "violations": 0,
44
+ "agent_turns": 5,
45
+ "checks": 4,
46
+ "seconds": 110.3,
47
+ "input_tokens": 90086,
48
+ "cache_read_tokens": 62969,
49
+ "cache_write_tokens": 27097,
50
+ "output_tokens": 11803,
51
+ "served_by": [
52
+ "claude-sonnet-5-5"
53
+ ],
54
+ "end_reason": "submitted",
55
+ "errors": 0,
56
+ "plan": [
57
+ {
58
+ "train": "37426",
59
+ "hold": {
60
+ "FWD": "09:09"
61
+ }
62
+ },
63
+ {
64
+ "train": "37419",
65
+ "to": "BP"
66
+ },
67
+ {
68
+ "train": "37430",
69
+ "from": "BP"
70
+ },
71
+ {
72
+ "train": "37328",
73
+ "cancel": true
74
+ },
75
+ {
76
+ "train": "37423",
77
+ "cancel": true
78
+ },
79
+ {
80
+ "train": "37321",
81
+ "cancel": true
82
+ },
83
+ {
84
+ "train": "37332",
85
+ "cancel": true
86
+ }
87
+ ]
88
+ },
89
+ {
90
+ "model": "claude-sonnet-5-5@high",
91
+ "task_id": "gnlw-2025-01-07-0703-180m-real-0",
92
+ "split": "train",
93
+ "difficulty": "real",
94
+ "day": "2025-01-07",
95
+ "runs": 26,
96
+ "reward": 0.37582,
97
+ "submitted": true,
98
+ "feasible": true,
99
+ "cost": 435,
100
+ "optimal_cost": 335,
101
+ "floor_cost": 303,
102
+ "greedy_cost": 1021,
103
+ "real_cost": 414,
104
+ "hardness": {
105
+ "runs": 26,
106
+ "sets": 6,
107
+ "stops": 176,
108
+ "conflicts": 20,
109
+ "single_track": 5,
110
+ "headway": 9,
111
+ "platforms": 0,
112
+ "closed": 6,
113
+ "decisions": 34,
114
+ "holds": 30,
115
+ "short_turns": 4,
116
+ "cancels": 0,
117
+ "turn_points": 2,
118
+ "avoidable": 32,
119
+ "optimal": 335,
120
+ "greedy_gap": 0.8,
121
+ "closed_min": 32,
122
+ "slow_min": 0,
123
+ "track_min": 0,
124
+ "single_closed": true,
125
+ "mid_line": false,
126
+ "late_sets": 0
127
+ },
128
+ "violations": 0,
129
+ "agent_turns": 6,
130
+ "checks": 5,
131
+ "seconds": 187.8,
132
+ "input_tokens": 174728,
133
+ "cache_read_tokens": 126106,
134
+ "cache_write_tokens": 48598,
135
+ "output_tokens": 23026,
136
+ "served_by": [
137
+ "claude-sonnet-5-5"
138
+ ],
139
+ "end_reason": "submitted",
140
+ "errors": 0,
141
+ "plan": [
142
+ {
143
+ "train": "37313",
144
+ "to": "HDG"
145
+ },
146
+ {
147
+ "train": "37424",
148
+ "from": "HDG"
149
+ },
150
+ {
151
+ "train": "37415",
152
+ "hold": {
153
+ "HDG": "07:35"
154
+ }
155
+ },
156
+ {
157
+ "train": "37315",
158
+ "hold": {
159
+ "HDG": "07:40"
160
+ }
161
+ },
162
+ {
163
+ "train": "37324",
164
+ "cancel": true
165
+ },
166
+ {
167
+ "train": "37319",
168
+ "cancel": true
169
+ }
170
+ ]
171
+ },
172
+ {
173
+ "model": "claude-sonnet-5-5@high",
174
+ "task_id": "gnlw-2025-01-13-1630-150m-blockage-0",
175
+ "split": "train",
176
+ "difficulty": "blockage",
177
+ "day": "2025-01-13",
178
+ "runs": 26,
179
+ "reward": 0.814541,
180
+ "submitted": true,
181
+ "feasible": true,
182
+ "cost": 256,
183
+ "optimal_cost": 232,
184
+ "floor_cost": 150,
185
+ "greedy_cost": 1412,
186
+ "real_cost": 473,
187
+ "hardness": {
188
+ "runs": 26,
189
+ "sets": 7,
190
+ "stops": 174,
191
+ "conflicts": 5,
192
+ "single_track": 0,
193
+ "headway": 0,
194
+ "platforms": 0,
195
+ "closed": 5,
196
+ "decisions": 17,
197
+ "holds": 17,
198
+ "short_turns": 0,
199
+ "cancels": 0,
200
+ "turn_points": 0,
201
+ "avoidable": 82,
202
+ "optimal": 232,
203
+ "greedy_gap": 0.8,
204
+ "closed_min": 20,
205
+ "slow_min": 0,
206
+ "track_min": 0,
207
+ "single_closed": true,
208
+ "mid_line": true,
209
+ "late_sets": 0
210
+ },
211
+ "violations": 0,
212
+ "agent_turns": 8,
213
+ "checks": 7,
214
+ "seconds": 226.0,
215
+ "input_tokens": 279241,
216
+ "cache_read_tokens": 221034,
217
+ "cache_write_tokens": 58175,
218
+ "output_tokens": 26644,
219
+ "served_by": [
220
+ "claude-sonnet-5-5"
221
+ ],
222
+ "end_reason": "submitted",
223
+ "errors": 0,
224
+ "plan": [
225
+ {
226
+ "train": "37362",
227
+ "hold": {
228
+ "BP": "17:19"
229
+ }
230
+ },
231
+ {
232
+ "train": "37355",
233
+ "hold": {
234
+ "GN": "17:12"
235
+ }
236
+ },
237
+ {
238
+ "train": "37455",
239
+ "hold": {
240
+ "ZH": "17:23"
241
+ },
242
+ "to": "HDG"
243
+ },
244
+ {
245
+ "train": "37366",
246
+ "from": "HDG"
247
+ },
248
+ {
249
+ "train": "37462",
250
+ "hold": {
251
+ "BP": "17:22",
252
+ "GK": "17:29"
253
+ }
254
+ },
255
+ {
256
+ "train": "37457",
257
+ "hold": {
258
+ "GN": "17:26",
259
+ "FWD": "17:56"
260
+ }
261
+ },
262
+ {
263
+ "train": "37357",
264
+ "hold": {
265
+ "GN": "17:42"
266
+ }
267
+ },
268
+ {
269
+ "train": "37466",
270
+ "hold": {
271
+ "HDG": "18:00"
272
+ }
273
+ }
274
+ ]
275
+ },
276
+ {
277
+ "model": "claude-sonnet-5-5@high",
278
+ "task_id": "gnlw-2025-01-14-1800-150m-loop-0",
279
+ "split": "train",
280
+ "difficulty": "loop",
281
+ "day": "2025-01-14",
282
+ "runs": 25,
283
+ "reward": 0.553439,
284
+ "submitted": true,
285
+ "feasible": true,
286
+ "cost": 510,
287
+ "optimal_cost": 423,
288
+ "floor_cost": 310,
289
+ "greedy_cost": 1411,
290
+ "real_cost": 85,
291
+ "hardness": {
292
+ "runs": 25,
293
+ "sets": 7,
294
+ "stops": 158,
295
+ "conflicts": 19,
296
+ "single_track": 13,
297
+ "headway": 6,
298
+ "platforms": 0,
299
+ "closed": 0,
300
+ "decisions": 25,
301
+ "holds": 21,
302
+ "short_turns": 4,
303
+ "cancels": 0,
304
+ "turn_points": 2,
305
+ "avoidable": 113,
306
+ "optimal": 423,
307
+ "greedy_gap": 0.7999,
308
+ "closed_min": 0,
309
+ "slow_min": 69,
310
+ "track_min": 113,
311
+ "single_closed": false,
312
+ "mid_line": false,
313
+ "late_sets": 1
314
+ },
315
+ "violations": 0,
316
+ "agent_turns": 5,
317
+ "checks": 4,
318
+ "seconds": 445.4,
319
+ "input_tokens": 218660,
320
+ "cache_read_tokens": 141886,
321
+ "cache_write_tokens": 76754,
322
+ "output_tokens": 60181,
323
+ "served_by": [
324
+ "claude-sonnet-5-5"
325
+ ],
326
+ "end_reason": "submitted",
327
+ "errors": 0,
328
+ "plan": [
329
+ {
330
+ "train": "37359",
331
+ "to": "HDG",
332
+ "hold": {
333
+ "DWE": "18:55",
334
+ "FWD": "19:00"
335
+ }
336
+ },
337
+ {
338
+ "train": "37470",
339
+ "from": "HDG",
340
+ "hold": {
341
+ "BP": "19:23"
342
+ }
343
+ },
344
+ {
345
+ "train": "37368",
346
+ "hold": {
347
+ "BP": "18:43"
348
+ }
349
+ },
350
+ {
351
+ "train": "37468",
352
+ "hold": {
353
+ "BP": "18:49",
354
+ "ZH": "19:08"
355
+ }
356
+ },
357
+ {
358
+ "train": "37463",
359
+ "hold": {
360
+ "GN": "18:56",
361
+ "FWD": "19:33"
362
+ }
363
+ },
364
+ {
365
+ "train": "37363",
366
+ "hold": {
367
+ "GN": "19:12"
368
+ }
369
+ },
370
+ {
371
+ "train": "37465",
372
+ "hold": {
373
+ "GN": "19:29"
374
+ }
375
+ },
376
+ {
377
+ "train": "37365",
378
+ "hold": {
379
+ "GN": "19:47"
380
+ }
381
+ },
382
+ {
383
+ "train": "37372",
384
+ "hold": {
385
+ "LW": "19:19"
386
+ }
387
+ },
388
+ {
389
+ "train": "37472",
390
+ "hold": {
391
+ "BP": "19:49"
392
+ }
393
+ },
394
+ {
395
+ "train": "37374",
396
+ "hold": {
397
+ "LW": "19:51"
398
+ }
399
+ },
400
+ {
401
+ "train": "37467",
402
+ "hold": {
403
+ "FWD": "20:28"
404
+ }
405
+ }
406
+ ]
407
+ },
408
+ {
409
+ "model": "claude-sonnet-5-5@high",
410
+ "task_id": "gnlw-2025-01-16-1802-180m-real-0",
411
+ "split": "train",
412
+ "difficulty": "real",
413
+ "day": "2025-01-16",
414
+ "runs": 24,
415
+ "reward": 0.41036,
416
+ "submitted": true,
417
+ "feasible": true,
418
+ "cost": 1335,
419
+ "optimal_cost": 1154,
420
+ "floor_cost": 983,
421
+ "greedy_cost": null,
422
+ "real_cost": 1127,
423
+ "hardness": {
424
+ "runs": 24,
425
+ "sets": 6,
426
+ "stops": 151,
427
+ "conflicts": 45,
428
+ "single_track": 10,
429
+ "headway": 32,
430
+ "platforms": 1,
431
+ "closed": 2,
432
+ "decisions": 21,
433
+ "holds": 12,
434
+ "short_turns": 9,
435
+ "cancels": 0,
436
+ "turn_points": 3,
437
+ "avoidable": 171,
438
+ "optimal": 1154,
439
+ "greedy_gap": 0.8583,
440
+ "closed_min": 36,
441
+ "slow_min": 0,
442
+ "track_min": 0,
443
+ "single_closed": false,
444
+ "mid_line": true,
445
+ "late_sets": 0
446
+ },
447
+ "violations": 0,
448
+ "agent_turns": 6,
449
+ "checks": 5,
450
+ "seconds": 475.8,
451
+ "input_tokens": 329656,
452
+ "cache_read_tokens": 242247,
453
+ "cache_write_tokens": 87385,
454
+ "output_tokens": 63115,
455
+ "served_by": [
456
+ "claude-sonnet-5-5"
457
+ ],
458
+ "end_reason": "submitted",
459
+ "errors": 0,
460
+ "plan": [
461
+ {
462
+ "train": "37355",
463
+ "to": "HDG"
464
+ },
465
+ {
466
+ "train": "37466",
467
+ "from": "HDG",
468
+ "to": "ZH",
469
+ "hold": {
470
+ "HDG": "18:33"
471
+ }
472
+ },
473
+ {
474
+ "train": "37463",
475
+ "from": "ZH"
476
+ },
477
+ {
478
+ "train": "37457",
479
+ "to": "HDG"
480
+ },
481
+ {
482
+ "train": "37368",
483
+ "from": "HDG",
484
+ "hold": {
485
+ "HDG": "18:37",
486
+ "FWD": "18:40",
487
+ "BP": "18:49"
488
+ }
489
+ },
490
+ {
491
+ "train": "37357",
492
+ "to": "HDG",
493
+ "hold": {
494
+ "BP": "18:26",
495
+ "FWD": "18:40"
496
+ }
497
+ },
498
+ {
499
+ "train": "37468",
500
+ "from": "HDG",
501
+ "hold": {
502
+ "HDG": "18:47"
503
+ }
504
+ },
505
+ {
506
+ "train": "37459",
507
+ "hold": {
508
+ "GK": "18:20",
509
+ "BP": "18:33",
510
+ "FWD": "18:43"
511
+ }
512
+ },
513
+ {
514
+ "train": "37359",
515
+ "hold": {
516
+ "ZH": "18:29",
517
+ "FWD": "18:50"
518
+ }
519
+ },
520
+ {
521
+ "train": "37464",
522
+ "to": "BP"
523
+ },
524
+ {
525
+ "train": "37461",
526
+ "from": "BP",
527
+ "to": "HDG"
528
+ },
529
+ {
530
+ "train": "37372",
531
+ "from": "HDG"
532
+ },
533
+ {
534
+ "train": "37363",
535
+ "hold": {
536
+ "ZH": "19:21"
537
+ }
538
+ },
539
+ {
540
+ "train": "37365",
541
+ "hold": {
542
+ "GN": "19:40"
543
+ }
544
+ }
545
+ ]
546
+ },
547
+ {
548
+ "model": "claude-sonnet-5-5@high",
549
+ "task_id": "gnlw-2025-02-02-1830-180m-blockage-0",
550
+ "split": "train",
551
+ "difficulty": "blockage",
552
+ "day": "2025-02-02",
553
+ "runs": 15,
554
+ "reward": 0.327054,
555
+ "submitted": true,
556
+ "feasible": true,
557
+ "cost": 726,
558
+ "optimal_cost": 634,
559
+ "floor_cost": 634,
560
+ "greedy_cost": 1483,
561
+ "real_cost": 226,
562
+ "hardness": {
563
+ "runs": 15,
564
+ "sets": 3,
565
+ "stops": 100,
566
+ "conflicts": 20,
567
+ "single_track": 0,
568
+ "headway": 0,
569
+ "platforms": 0,
570
+ "closed": 20,
571
+ "decisions": 20,
572
+ "holds": 15,
573
+ "short_turns": 5,
574
+ "cancels": 0,
575
+ "turn_points": 2,
576
+ "avoidable": 0,
577
+ "optimal": 634,
578
+ "greedy_gap": 0.8,
579
+ "closed_min": 57,
580
+ "slow_min": 0,
581
+ "track_min": 0,
582
+ "single_closed": true,
583
+ "mid_line": false,
584
+ "late_sets": 0
585
+ },
586
+ "violations": 0,
587
+ "agent_turns": 3,
588
+ "checks": 2,
589
+ "seconds": 122.4,
590
+ "input_tokens": 37433,
591
+ "cache_read_tokens": 14314,
592
+ "cache_write_tokens": 23107,
593
+ "output_tokens": 14308,
594
+ "served_by": [
595
+ "claude-sonnet-5-5"
596
+ ],
597
+ "end_reason": "submitted",
598
+ "errors": 0,
599
+ "plan": [
600
+ {
601
+ "train": "37459",
602
+ "hold": {
603
+ "LWC": "19:37"
604
+ }
605
+ },
606
+ {
607
+ "train": "37470",
608
+ "cancel": true
609
+ },
610
+ {
611
+ "train": "37365",
612
+ "cancel": true
613
+ },
614
+ {
615
+ "train": "37361",
616
+ "to": "BP"
617
+ },
618
+ {
619
+ "train": "37372",
620
+ "from": "BP"
621
+ },
622
+ {
623
+ "train": "37463",
624
+ "hold": {
625
+ "BP": "19:37"
626
+ }
627
+ }
628
+ ]
629
+ },
630
+ {
631
+ "model": "claude-sonnet-5-5@high",
632
+ "task_id": "gnlw-2025-02-03-0900-150m-loop-0",
633
+ "split": "train",
634
+ "difficulty": "loop",
635
+ "day": "2025-02-03",
636
+ "runs": 26,
637
+ "reward": 0.735231,
638
+ "submitted": true,
639
+ "feasible": true,
640
+ "cost": 398,
641
+ "optimal_cost": 356,
642
+ "floor_cost": 247,
643
+ "greedy_cost": 2860,
644
+ "real_cost": 515,
645
+ "hardness": {
646
+ "runs": 26,
647
+ "sets": 7,
648
+ "stops": 174,
649
+ "conflicts": 20,
650
+ "single_track": 14,
651
+ "headway": 5,
652
+ "platforms": 1,
653
+ "closed": 0,
654
+ "decisions": 26,
655
+ "holds": 24,
656
+ "short_turns": 2,
657
+ "cancels": 0,
658
+ "turn_points": 1,
659
+ "avoidable": 109,
660
+ "optimal": 356,
661
+ "greedy_gap": 0.8,
662
+ "closed_min": 0,
663
+ "slow_min": 33,
664
+ "track_min": 131,
665
+ "single_closed": false,
666
+ "mid_line": false,
667
+ "late_sets": 1
668
+ },
669
+ "violations": 0,
670
+ "agent_turns": 5,
671
+ "checks": 4,
672
+ "seconds": 389.3,
673
+ "input_tokens": 235299,
674
+ "cache_read_tokens": 159342,
675
+ "cache_write_tokens": 75937,
676
+ "output_tokens": 52360,
677
+ "served_by": [
678
+ "claude-sonnet-5-5"
679
+ ],
680
+ "end_reason": "submitted",
681
+ "errors": 0,
682
+ "plan": [
683
+ {
684
+ "train": "37421",
685
+ "hold": {
686
+ "HDG": "09:03"
687
+ }
688
+ },
689
+ {
690
+ "train": "37321",
691
+ "hold": {
692
+ "FWD": "09:06"
693
+ }
694
+ },
695
+ {
696
+ "train": "37323",
697
+ "to": "HDG",
698
+ "hold": {
699
+ "GN": "09:27"
700
+ }
701
+ },
702
+ {
703
+ "train": "37425",
704
+ "hold": {
705
+ "GN": "09:30",
706
+ "FWD": "10:08"
707
+ }
708
+ },
709
+ {
710
+ "train": "37325",
711
+ "hold": {
712
+ "FWD": "10:14"
713
+ }
714
+ },
715
+ {
716
+ "train": "37427",
717
+ "hold": {
718
+ "FWD": "10:31"
719
+ }
720
+ },
721
+ {
722
+ "train": "37327",
723
+ "hold": {
724
+ "FWD": "10:37"
725
+ }
726
+ },
727
+ {
728
+ "train": "37430",
729
+ "hold": {
730
+ "ZH": "09:35"
731
+ }
732
+ },
733
+ {
734
+ "train": "37332",
735
+ "hold": {
736
+ "BP": "09:43"
737
+ }
738
+ },
739
+ {
740
+ "train": "37432",
741
+ "hold": {
742
+ "BP": "09:46",
743
+ "GK": "09:56"
744
+ }
745
+ },
746
+ {
747
+ "train": "37434",
748
+ "from": "HDG"
749
+ },
750
+ {
751
+ "train": "37436",
752
+ "hold": {
753
+ "HDG": "10:34"
754
+ }
755
+ },
756
+ {
757
+ "train": "37336",
758
+ "hold": {
759
+ "BP": "10:42"
760
+ }
761
+ }
762
+ ]
763
+ },
764
+ {
765
+ "model": "claude-sonnet-5-5@high",
766
+ "task_id": "gnlw-2025-02-22-0754-180m-real-0",
767
+ "split": "train",
768
+ "difficulty": "real",
769
+ "day": "2025-02-22",
770
+ "runs": 27,
771
+ "reward": 0.407882,
772
+ "submitted": true,
773
+ "feasible": true,
774
+ "cost": 401,
775
+ "optimal_cost": 244,
776
+ "floor_cost": 111,
777
+ "greedy_cost": 3096,
778
+ "real_cost": 1637,
779
+ "hardness": {
780
+ "runs": 27,
781
+ "sets": 7,
782
+ "stops": 183,
783
+ "conflicts": 18,
784
+ "single_track": 18,
785
+ "headway": 0,
786
+ "platforms": 0,
787
+ "closed": 0,
788
+ "decisions": 28,
789
+ "holds": 28,
790
+ "short_turns": 0,
791
+ "cancels": 0,
792
+ "turn_points": 0,
793
+ "avoidable": 133,
794
+ "optimal": 244,
795
+ "greedy_gap": 0.8,
796
+ "closed_min": 0,
797
+ "slow_min": 180,
798
+ "track_min": 0,
799
+ "single_closed": false,
800
+ "mid_line": false,
801
+ "late_sets": 0
802
+ },
803
+ "violations": 0,
804
+ "agent_turns": 5,
805
+ "checks": 4,
806
+ "seconds": 212.7,
807
+ "input_tokens": 123680,
808
+ "cache_read_tokens": 75327,
809
+ "cache_write_tokens": 48333,
810
+ "output_tokens": 26914,
811
+ "served_by": [
812
+ "claude-sonnet-5-5"
813
+ ],
814
+ "end_reason": "submitted",
815
+ "errors": 0,
816
+ "plan": [
817
+ {
818
+ "train": "37419",
819
+ "hold": {
820
+ "FWD": "08:28"
821
+ }
822
+ },
823
+ {
824
+ "train": "37421",
825
+ "hold": {
826
+ "FWD": "08:59"
827
+ }
828
+ },
829
+ {
830
+ "train": "37423",
831
+ "hold": {
832
+ "FWD": "09:30"
833
+ }
834
+ },
835
+ {
836
+ "train": "37425",
837
+ "hold": {
838
+ "FWD": "10:01"
839
+ }
840
+ },
841
+ {
842
+ "train": "37427",
843
+ "hold": {
844
+ "FWD": "10:32"
845
+ }
846
+ },
847
+ {
848
+ "train": "37429",
849
+ "hold": {
850
+ "GN": "10:25",
851
+ "FWD": "11:03"
852
+ }
853
+ },
854
+ {
855
+ "train": "37430",
856
+ "hold": {
857
+ "HDG": "09:02"
858
+ }
859
+ },
860
+ {
861
+ "train": "37432",
862
+ "hold": {
863
+ "HDG": "09:33"
864
+ }
865
+ },
866
+ {
867
+ "train": "37434",
868
+ "hold": {
869
+ "HDG": "10:04"
870
+ }
871
+ },
872
+ {
873
+ "train": "37436",
874
+ "hold": {
875
+ "HDG": "10:35"
876
+ }
877
+ },
878
+ {
879
+ "train": "37438",
880
+ "hold": {
881
+ "HDG": "11:06"
882
+ }
883
+ },
884
+ {
885
+ "train": "37319",
886
+ "hold": {
887
+ "FWD": "08:34"
888
+ }
889
+ },
890
+ {
891
+ "train": "37321",
892
+ "hold": {
893
+ "FWD": "09:05"
894
+ }
895
+ },
896
+ {
897
+ "train": "37323",
898
+ "hold": {
899
+ "FWD": "09:36"
900
+ }
901
+ },
902
+ {
903
+ "train": "37325",
904
+ "hold": {
905
+ "GN": "09:40",
906
+ "FWD": "10:07"
907
+ }
908
+ },
909
+ {
910
+ "train": "37327",
911
+ "hold": {
912
+ "GN": "10:10",
913
+ "FWD": "10:38"
914
+ }
915
+ },
916
+ {
917
+ "train": "37329",
918
+ "hold": {
919
+ "GN": "10:42",
920
+ "FWD": "11:09"
921
+ }
922
+ }
923
+ ]
924
+ },
925
+ {
926
+ "model": "claude-sonnet-5-5@high",
927
+ "task_id": "gnlw-2025-02-27-1630-180m-compound-0",
928
+ "split": "train",
929
+ "difficulty": "compound",
930
+ "day": "2025-02-27",
931
+ "runs": 30,
932
+ "reward": 0.200001,
933
+ "submitted": true,
934
+ "feasible": true,
935
+ "cost": 2478,
936
+ "optimal_cost": 1035,
937
+ "floor_cost": 931,
938
+ "greedy_cost": 3334,
939
+ "real_cost": 96,
940
+ "hardness": {
941
+ "runs": 30,
942
+ "sets": 7,
943
+ "stops": 200,
944
+ "conflicts": 45,
945
+ "single_track": 8,
946
+ "headway": 20,
947
+ "platforms": 3,
948
+ "closed": 14,
949
+ "decisions": 41,
950
+ "holds": 29,
951
+ "short_turns": 12,
952
+ "cancels": 0,
953
+ "turn_points": 3,
954
+ "avoidable": 104,
955
+ "optimal": 1035,
956
+ "greedy_gap": 0.8,
957
+ "closed_min": 39,
958
+ "slow_min": 0,
959
+ "track_min": 0,
960
+ "single_closed": true,
961
+ "mid_line": false,
962
+ "late_sets": 1
963
+ },
964
+ "violations": 0,
965
+ "agent_turns": 7,
966
+ "checks": 6,
967
+ "seconds": 614.6,
968
+ "input_tokens": 493351,
969
+ "cache_read_tokens": 369633,
970
+ "cache_write_tokens": 123690,
971
+ "output_tokens": 82412,
972
+ "served_by": [
973
+ "claude-sonnet-5-5"
974
+ ],
975
+ "end_reason": "submitted",
976
+ "errors": 0,
977
+ "plan": [
978
+ {
979
+ "train": "37451",
980
+ "to": "HDG"
981
+ },
982
+ {
983
+ "train": "37362",
984
+ "from": "HDG",
985
+ "hold": {
986
+ "HDG": "17:09",
987
+ "FWD": "17:17"
988
+ }
989
+ },
990
+ {
991
+ "train": "37460",
992
+ "hold": {
993
+ "FWD": "17:09"
994
+ }
995
+ },
996
+ {
997
+ "train": "37453",
998
+ "hold": {
999
+ "BP": "17:09"
1000
+ }
1001
+ },
1002
+ {
1003
+ "train": "37353",
1004
+ "hold": {
1005
+ "BP": "17:16"
1006
+ }
1007
+ },
1008
+ {
1009
+ "train": "37455",
1010
+ "hold": {
1011
+ "GN": "16:57",
1012
+ "BP": "17:19"
1013
+ }
1014
+ },
1015
+ {
1016
+ "train": "37462",
1017
+ "cancel": true
1018
+ },
1019
+ {
1020
+ "train": "37459",
1021
+ "cancel": true
1022
+ },
1023
+ {
1024
+ "train": "37464",
1025
+ "hold": {
1026
+ "LW": "17:40"
1027
+ }
1028
+ },
1029
+ {
1030
+ "train": "37364",
1031
+ "hold": {
1032
+ "LW": "17:45",
1033
+ "FWD": "17:54",
1034
+ "BP": "18:04"
1035
+ }
1036
+ },
1037
+ {
1038
+ "train": "37366",
1039
+ "hold": {
1040
+ "LW": "17:48",
1041
+ "FWD": "18:02"
1042
+ }
1043
+ },
1044
+ {
1045
+ "train": "37355",
1046
+ "hold": {
1047
+ "GN": "17:40"
1048
+ }
1049
+ },
1050
+ {
1051
+ "train": "37457",
1052
+ "hold": {
1053
+ "GN": "17:43"
1054
+ }
1055
+ },
1056
+ {
1057
+ "train": "37357",
1058
+ "hold": {
1059
+ "GN": "17:53"
1060
+ }
1061
+ },
1062
+ {
1063
+ "train": "37466",
1064
+ "hold": {
1065
+ "LW": "18:25"
1066
+ }
1067
+ },
1068
+ {
1069
+ "train": "37468",
1070
+ "hold": {
1071
+ "LW": "18:29"
1072
+ }
1073
+ },
1074
+ {
1075
+ "train": "37368",
1076
+ "hold": {
1077
+ "LW": "18:34",
1078
+ "FWD": "18:43",
1079
+ "BP": "18:53"
1080
+ }
1081
+ },
1082
+ {
1083
+ "train": "37359",
1084
+ "hold": {
1085
+ "GN": "18:25",
1086
+ "FWD": "18:54"
1087
+ }
1088
+ },
1089
+ {
1090
+ "train": "37461",
1091
+ "hold": {
1092
+ "GN": "18:28",
1093
+ "FWD": "18:57"
1094
+ }
1095
+ },
1096
+ {
1097
+ "train": "37463",
1098
+ "hold": {
1099
+ "GN": "19:10"
1100
+ }
1101
+ },
1102
+ {
1103
+ "train": "37363",
1104
+ "hold": {
1105
+ "GN": "19:21",
1106
+ "FWD": "19:44"
1107
+ }
1108
+ },
1109
+ {
1110
+ "train": "37470",
1111
+ "hold": {
1112
+ "LW": "19:14"
1113
+ }
1114
+ },
1115
+ {
1116
+ "train": "37372",
1117
+ "hold": {
1118
+ "LW": "19:19",
1119
+ "FWD": "19:28",
1120
+ "BP": "19:38"
1121
+ }
1122
+ },
1123
+ {
1124
+ "train": "37472",
1125
+ "hold": {
1126
+ "LW": "19:22",
1127
+ "BP": "19:41"
1128
+ }
1129
+ }
1130
+ ]
1131
+ },
1132
+ {
1133
+ "model": "claude-sonnet-5-5@high",
1134
+ "task_id": "gnlw-2025-03-17-0830-210m-blockage-0",
1135
+ "split": "train",
1136
+ "difficulty": "blockage",
1137
+ "day": "2025-03-17",
1138
+ "runs": 34,
1139
+ "reward": 0.214259,
1140
+ "submitted": true,
1141
+ "feasible": true,
1142
+ "cost": 821,
1143
+ "optimal_cost": 525,
1144
+ "floor_cost": 478,
1145
+ "greedy_cost": null,
1146
+ "real_cost": 508,
1147
+ "hardness": {
1148
+ "runs": 34,
1149
+ "sets": 7,
1150
+ "stops": 226,
1151
+ "conflicts": 5,
1152
+ "single_track": 0,
1153
+ "headway": 0,
1154
+ "platforms": 0,
1155
+ "closed": 5,
1156
+ "decisions": 36,
1157
+ "holds": 30,
1158
+ "short_turns": 6,
1159
+ "cancels": 0,
1160
+ "turn_points": 2,
1161
+ "avoidable": 47,
1162
+ "optimal": 525,
1163
+ "greedy_gap": 0.8294,
1164
+ "closed_min": 33,
1165
+ "slow_min": 0,
1166
+ "track_min": 0,
1167
+ "single_closed": false,
1168
+ "mid_line": true,
1169
+ "late_sets": 0
1170
+ },
1171
+ "violations": 0,
1172
+ "agent_turns": 9,
1173
+ "checks": 8,
1174
+ "seconds": 300.8,
1175
+ "input_tokens": 445638,
1176
+ "cache_read_tokens": 360486,
1177
+ "cache_write_tokens": 85116,
1178
+ "output_tokens": 37113,
1179
+ "served_by": [
1180
+ "claude-sonnet-5-5"
1181
+ ],
1182
+ "end_reason": "submitted",
1183
+ "errors": 0,
1184
+ "plan": [
1185
+ {
1186
+ "train": "37421",
1187
+ "to": "HDG",
1188
+ "hold": {
1189
+ "DWE": "09:24"
1190
+ }
1191
+ },
1192
+ {
1193
+ "train": "37321",
1194
+ "to": "HDG",
1195
+ "hold": {
1196
+ "BP": "09:24"
1197
+ }
1198
+ },
1199
+ {
1200
+ "train": "37423",
1201
+ "to": "HDG",
1202
+ "hold": {
1203
+ "GK": "09:26"
1204
+ }
1205
+ },
1206
+ {
1207
+ "train": "37323",
1208
+ "to": "HDG",
1209
+ "hold": {
1210
+ "GN": "09:21",
1211
+ "DWE": "09:43"
1212
+ }
1213
+ },
1214
+ {
1215
+ "train": "37330",
1216
+ "to": "ZH",
1217
+ "hold": {
1218
+ "FWD": "09:23"
1219
+ }
1220
+ },
1221
+ {
1222
+ "train": "37430",
1223
+ "hold": {
1224
+ "FWD": "09:26"
1225
+ }
1226
+ },
1227
+ {
1228
+ "train": "37332",
1229
+ "from": "HDG",
1230
+ "hold": {
1231
+ "HDG": "09:34",
1232
+ "BP": "09:45"
1233
+ }
1234
+ },
1235
+ {
1236
+ "train": "37432",
1237
+ "from": "HDG",
1238
+ "hold": {
1239
+ "HDG": "09:38"
1240
+ }
1241
+ },
1242
+ {
1243
+ "train": "37334",
1244
+ "from": "HDG",
1245
+ "hold": {
1246
+ "GK": "10:09"
1247
+ }
1248
+ },
1249
+ {
1250
+ "train": "37434",
1251
+ "from": "HDG"
1252
+ },
1253
+ {
1254
+ "train": "37325",
1255
+ "from": "ZH"
1256
+ }
1257
+ ]
1258
+ },
1259
+ {
1260
+ "model": "claude-sonnet-5-5@high",
1261
+ "task_id": "gnlw-2025-03-19-0613-180m-real-0",
1262
+ "split": "train",
1263
+ "difficulty": "real",
1264
+ "day": "2025-03-19",
1265
+ "runs": 26,
1266
+ "reward": 1.0,
1267
+ "submitted": true,
1268
+ "feasible": true,
1269
+ "cost": 38,
1270
+ "optimal_cost": 38,
1271
+ "floor_cost": 17,
1272
+ "greedy_cost": 950,
1273
+ "real_cost": 1752,
1274
+ "hardness": {
1275
+ "runs": 26,
1276
+ "sets": 7,
1277
+ "stops": 174,
1278
+ "conflicts": 11,
1279
+ "single_track": 1,
1280
+ "headway": 0,
1281
+ "platforms": 10,
1282
+ "closed": 0,
1283
+ "decisions": 24,
1284
+ "holds": 24,
1285
+ "short_turns": 0,
1286
+ "cancels": 0,
1287
+ "turn_points": 0,
1288
+ "avoidable": 21,
1289
+ "optimal": 38,
1290
+ "greedy_gap": 0.8,
1291
+ "closed_min": 0,
1292
+ "slow_min": 0,
1293
+ "track_min": 180,
1294
+ "single_closed": false,
1295
+ "mid_line": false,
1296
+ "late_sets": 0
1297
+ },
1298
+ "violations": 0,
1299
+ "agent_turns": 3,
1300
+ "checks": 2,
1301
+ "seconds": 137.7,
1302
+ "input_tokens": 50578,
1303
+ "cache_read_tokens": 21047,
1304
+ "cache_write_tokens": 29519,
1305
+ "output_tokens": 17651,
1306
+ "served_by": [
1307
+ "claude-sonnet-5-5"
1308
+ ],
1309
+ "end_reason": "submitted",
1310
+ "errors": 0,
1311
+ "plan": [
1312
+ {
1313
+ "train": "37420",
1314
+ "hold": {
1315
+ "HDG": "06:31"
1316
+ }
1317
+ },
1318
+ {
1319
+ "train": "37413",
1320
+ "hold": {
1321
+ "DWE": "06:53"
1322
+ }
1323
+ },
1324
+ {
1325
+ "train": "37313",
1326
+ "hold": {
1327
+ "BP": "06:58"
1328
+ }
1329
+ },
1330
+ {
1331
+ "train": "37415",
1332
+ "hold": {
1333
+ "DWE": "07:23"
1334
+ }
1335
+ },
1336
+ {
1337
+ "train": "37315",
1338
+ "hold": {
1339
+ "ZH": "07:20"
1340
+ }
1341
+ },
1342
+ {
1343
+ "train": "37417",
1344
+ "hold": {
1345
+ "DWE": "07:53"
1346
+ }
1347
+ },
1348
+ {
1349
+ "train": "37317",
1350
+ "hold": {
1351
+ "BP": "07:58"
1352
+ }
1353
+ },
1354
+ {
1355
+ "train": "37419",
1356
+ "hold": {
1357
+ "DWE": "08:23"
1358
+ }
1359
+ },
1360
+ {
1361
+ "train": "37319",
1362
+ "hold": {
1363
+ "ZH": "08:20"
1364
+ }
1365
+ },
1366
+ {
1367
+ "train": "37421",
1368
+ "hold": {
1369
+ "DWE": "08:53"
1370
+ }
1371
+ },
1372
+ {
1373
+ "train": "37321",
1374
+ "hold": {
1375
+ "BP": "08:58"
1376
+ }
1377
+ }
1378
+ ]
1379
+ },
1380
+ {
1381
+ "model": "claude-sonnet-5-5@high",
1382
+ "task_id": "gnlw-2025-03-27-1800-180m-compound-0",
1383
+ "split": "train",
1384
+ "difficulty": "compound",
1385
+ "day": "2025-03-27",
1386
+ "runs": 29,
1387
+ "reward": 0.850483,
1388
+ "submitted": true,
1389
+ "feasible": true,
1390
+ "cost": 546,
1391
+ "optimal_cost": 534,
1392
+ "floor_cost": 518,
1393
+ "greedy_cost": 1519,
1394
+ "real_cost": 344,
1395
+ "hardness": {
1396
+ "runs": 29,
1397
+ "sets": 7,
1398
+ "stops": 182,
1399
+ "conflicts": 36,
1400
+ "single_track": 3,
1401
+ "headway": 20,
1402
+ "platforms": 1,
1403
+ "closed": 12,
1404
+ "decisions": 30,
1405
+ "holds": 22,
1406
+ "short_turns": 8,
1407
+ "cancels": 0,
1408
+ "turn_points": 1,
1409
+ "avoidable": 16,
1410
+ "optimal": 534,
1411
+ "greedy_gap": 0.8,
1412
+ "closed_min": 59,
1413
+ "slow_min": 0,
1414
+ "track_min": 0,
1415
+ "single_closed": true,
1416
+ "mid_line": false,
1417
+ "late_sets": 1
1418
+ },
1419
+ "violations": 0,
1420
+ "agent_turns": 4,
1421
+ "checks": 3,
1422
+ "seconds": 122.5,
1423
+ "input_tokens": 101088,
1424
+ "cache_read_tokens": 63335,
1425
+ "cache_write_tokens": 37737,
1426
+ "output_tokens": 14374,
1427
+ "served_by": [
1428
+ "claude-sonnet-5-5"
1429
+ ],
1430
+ "end_reason": "submitted",
1431
+ "errors": 0,
1432
+ "plan": [
1433
+ {
1434
+ "train": "37457",
1435
+ "to": "HDG"
1436
+ },
1437
+ {
1438
+ "train": "37368",
1439
+ "from": "HDG"
1440
+ },
1441
+ {
1442
+ "train": "37357",
1443
+ "to": "HDG"
1444
+ },
1445
+ {
1446
+ "train": "37468",
1447
+ "from": "HDG"
1448
+ },
1449
+ {
1450
+ "train": "37459",
1451
+ "to": "HDG",
1452
+ "hold": {
1453
+ "ZH": "18:11",
1454
+ "FWD": "18:33"
1455
+ }
1456
+ },
1457
+ {
1458
+ "train": "37370",
1459
+ "from": "HDG"
1460
+ },
1461
+ {
1462
+ "train": "37359",
1463
+ "to": "HDG"
1464
+ },
1465
+ {
1466
+ "train": "37470",
1467
+ "from": "HDG",
1468
+ "hold": {
1469
+ "HDG": "19:09"
1470
+ }
1471
+ },
1472
+ {
1473
+ "train": "37361",
1474
+ "hold": {
1475
+ "GN": "18:43"
1476
+ }
1477
+ },
1478
+ {
1479
+ "train": "37461",
1480
+ "hold": {
1481
+ "HDG": "19:04"
1482
+ }
1483
+ },
1484
+ {
1485
+ "train": "37365",
1486
+ "hold": {
1487
+ "GN": "19:40"
1488
+ }
1489
+ }
1490
+ ]
1491
+ },
1492
+ {
1493
+ "model": "claude-sonnet-5-5@high",
1494
+ "task_id": "gnlw-2025-04-07-1730-240m-compound-0",
1495
+ "split": "train",
1496
+ "difficulty": "compound",
1497
+ "day": "2025-04-07",
1498
+ "runs": 35,
1499
+ "reward": 0.735395,
1500
+ "submitted": true,
1501
+ "feasible": true,
1502
+ "cost": 637,
1503
+ "optimal_cost": 587,
1504
+ "floor_cost": 438,
1505
+ "greedy_cost": null,
1506
+ "real_cost": 94,
1507
+ "hardness": {
1508
+ "runs": 35,
1509
+ "sets": 7,
1510
+ "stops": 214,
1511
+ "conflicts": 32,
1512
+ "single_track": 9,
1513
+ "headway": 17,
1514
+ "platforms": 2,
1515
+ "closed": 4,
1516
+ "decisions": 25,
1517
+ "holds": 21,
1518
+ "short_turns": 4,
1519
+ "cancels": 0,
1520
+ "turn_points": 2,
1521
+ "avoidable": 149,
1522
+ "optimal": 587,
1523
+ "greedy_gap": 0.84,
1524
+ "closed_min": 30,
1525
+ "slow_min": 0,
1526
+ "track_min": 10,
1527
+ "single_closed": true,
1528
+ "mid_line": true,
1529
+ "late_sets": 1
1530
+ },
1531
+ "violations": 0,
1532
+ "agent_turns": 5,
1533
+ "checks": 4,
1534
+ "seconds": 299.9,
1535
+ "input_tokens": 199091,
1536
+ "cache_read_tokens": 133278,
1537
+ "cache_write_tokens": 65793,
1538
+ "output_tokens": 39094,
1539
+ "served_by": [
1540
+ "claude-sonnet-5-5"
1541
+ ],
1542
+ "end_reason": "submitted",
1543
+ "errors": 0,
1544
+ "plan": [
1545
+ {
1546
+ "train": "37355",
1547
+ "to": "HDG",
1548
+ "hold": {
1549
+ "FWD": "18:03"
1550
+ }
1551
+ },
1552
+ {
1553
+ "train": "37457",
1554
+ "hold": {
1555
+ "DWE": "18:01",
1556
+ "FWD": "18:08"
1557
+ }
1558
+ },
1559
+ {
1560
+ "train": "37366",
1561
+ "hold": {
1562
+ "LW": "18:00",
1563
+ "FWD": "18:11"
1564
+ }
1565
+ },
1566
+ {
1567
+ "train": "37466",
1568
+ "from": "HDG",
1569
+ "hold": {
1570
+ "HDG": "18:11",
1571
+ "FWD": "18:14"
1572
+ }
1573
+ },
1574
+ {
1575
+ "train": "37459",
1576
+ "hold": {
1577
+ "GN": "17:57",
1578
+ "HDG": "18:37"
1579
+ }
1580
+ },
1581
+ {
1582
+ "train": "37359",
1583
+ "hold": {
1584
+ "ZH": "18:26",
1585
+ "FWD": "18:41"
1586
+ }
1587
+ },
1588
+ {
1589
+ "train": "37368",
1590
+ "hold": {
1591
+ "LW": "18:26"
1592
+ }
1593
+ },
1594
+ {
1595
+ "train": "37361",
1596
+ "hold": {
1597
+ "GN": "18:44",
1598
+ "FWD": "19:09"
1599
+ }
1600
+ },
1601
+ {
1602
+ "train": "37463",
1603
+ "hold": {
1604
+ "FWD": "19:26"
1605
+ }
1606
+ }
1607
+ ]
1608
+ },
1609
+ {
1610
+ "model": "claude-sonnet-5-5@high",
1611
+ "task_id": "gnlw-2025-05-01-0800-180m-blockage-0",
1612
+ "split": "train",
1613
+ "difficulty": "blockage",
1614
+ "day": "2025-05-01",
1615
+ "runs": 30,
1616
+ "reward": 0.428932,
1617
+ "submitted": true,
1618
+ "feasible": true,
1619
+ "cost": 675,
1620
+ "optimal_cost": 543,
1621
+ "floor_cost": 432,
1622
+ "greedy_cost": 1278,
1623
+ "real_cost": 58,
1624
+ "hardness": {
1625
+ "runs": 30,
1626
+ "sets": 7,
1627
+ "stops": 200,
1628
+ "conflicts": 8,
1629
+ "single_track": 0,
1630
+ "headway": 0,
1631
+ "platforms": 0,
1632
+ "closed": 8,
1633
+ "decisions": 27,
1634
+ "holds": 21,
1635
+ "short_turns": 6,
1636
+ "cancels": 0,
1637
+ "turn_points": 2,
1638
+ "avoidable": 111,
1639
+ "optimal": 543,
1640
+ "greedy_gap": 0.784,
1641
+ "closed_min": 26,
1642
+ "slow_min": 0,
1643
+ "track_min": 0,
1644
+ "single_closed": false,
1645
+ "mid_line": true,
1646
+ "late_sets": 0
1647
+ },
1648
+ "violations": 0,
1649
+ "agent_turns": 5,
1650
+ "checks": 4,
1651
+ "seconds": 350.5,
1652
+ "input_tokens": 179882,
1653
+ "cache_read_tokens": 108749,
1654
+ "cache_write_tokens": 71113,
1655
+ "output_tokens": 44922,
1656
+ "served_by": [
1657
+ "claude-sonnet-5-5"
1658
+ ],
1659
+ "end_reason": "submitted",
1660
+ "errors": 0,
1661
+ "plan": [
1662
+ {
1663
+ "train": "37419",
1664
+ "hold": {
1665
+ "GK": "08:36"
1666
+ },
1667
+ "to": "HDG"
1668
+ },
1669
+ {
1670
+ "train": "37319",
1671
+ "hold": {
1672
+ "GN": "08:32"
1673
+ },
1674
+ "to": "HDG"
1675
+ },
1676
+ {
1677
+ "train": "37421",
1678
+ "hold": {
1679
+ "GN": "08:35",
1680
+ "BP": "08:57"
1681
+ }
1682
+ },
1683
+ {
1684
+ "train": "37321",
1685
+ "hold": {
1686
+ "GN": "08:46",
1687
+ "BP": "09:04"
1688
+ }
1689
+ },
1690
+ {
1691
+ "train": "37426",
1692
+ "hold": {
1693
+ "DWE": "08:36"
1694
+ },
1695
+ "to": "ZH"
1696
+ },
1697
+ {
1698
+ "train": "37423",
1699
+ "from": "ZH",
1700
+ "hold": {
1701
+ "ZH": "09:04",
1702
+ "FWD": "09:28"
1703
+ }
1704
+ },
1705
+ {
1706
+ "train": "37328",
1707
+ "hold": {
1708
+ "FWD": "08:37",
1709
+ "BP": "08:49"
1710
+ }
1711
+ },
1712
+ {
1713
+ "train": "37428",
1714
+ "hold": {
1715
+ "FWD": "08:42",
1716
+ "BP": "08:52"
1717
+ }
1718
+ },
1719
+ {
1720
+ "train": "37330",
1721
+ "from": "HDG"
1722
+ },
1723
+ {
1724
+ "train": "37430",
1725
+ "from": "HDG",
1726
+ "hold": {
1727
+ "GK": "09:22"
1728
+ }
1729
+ },
1730
+ {
1731
+ "train": "37432",
1732
+ "hold": {
1733
+ "HDG": "09:31"
1734
+ }
1735
+ },
1736
+ {
1737
+ "train": "37323",
1738
+ "hold": {
1739
+ "GN": "09:12"
1740
+ }
1741
+ }
1742
+ ]
1743
+ },
1744
+ {
1745
+ "model": "claude-sonnet-5-5@high",
1746
+ "task_id": "gnlw-2025-05-11-0800-180m-loop-0",
1747
+ "split": "train",
1748
+ "difficulty": "loop",
1749
+ "day": "2025-05-11",
1750
+ "runs": 15,
1751
+ "reward": 0.832784,
1752
+ "submitted": true,
1753
+ "feasible": true,
1754
+ "cost": 211,
1755
+ "optimal_cost": 194,
1756
+ "floor_cost": 149,
1757
+ "greedy_cost": null,
1758
+ "real_cost": 38,
1759
+ "hardness": {
1760
+ "runs": 15,
1761
+ "sets": 4,
1762
+ "stops": 86,
1763
+ "conflicts": 3,
1764
+ "single_track": 1,
1765
+ "headway": 0,
1766
+ "platforms": 2,
1767
+ "closed": 0,
1768
+ "decisions": 10,
1769
+ "holds": 10,
1770
+ "short_turns": 0,
1771
+ "cancels": 0,
1772
+ "turn_points": 0,
1773
+ "avoidable": 45,
1774
+ "optimal": 194,
1775
+ "greedy_gap": 0.8267,
1776
+ "closed_min": 0,
1777
+ "slow_min": 64,
1778
+ "track_min": 170,
1779
+ "single_closed": false,
1780
+ "mid_line": false,
1781
+ "late_sets": 0
1782
+ },
1783
+ "violations": 0,
1784
+ "agent_turns": 4,
1785
+ "checks": 3,
1786
+ "seconds": 76.3,
1787
+ "input_tokens": 56624,
1788
+ "cache_read_tokens": 36772,
1789
+ "cache_write_tokens": 19836,
1790
+ "output_tokens": 8659,
1791
+ "served_by": [
1792
+ "claude-sonnet-5-5"
1793
+ ],
1794
+ "end_reason": "submitted",
1795
+ "errors": 0,
1796
+ "plan": [
1797
+ {
1798
+ "train": "37328",
1799
+ "hold": {
1800
+ "BP": "08:37"
1801
+ }
1802
+ },
1803
+ {
1804
+ "train": "37423",
1805
+ "hold": {
1806
+ "FWD": "09:34"
1807
+ }
1808
+ },
1809
+ {
1810
+ "train": "37427",
1811
+ "hold": {
1812
+ "FWD": "10:35",
1813
+ "HDG": "10:42"
1814
+ }
1815
+ }
1816
+ ]
1817
+ },
1818
+ {
1819
+ "model": "claude-sonnet-5-5@high",
1820
+ "task_id": "gnlw-2025-05-20-1530-180m-loop-0",
1821
+ "split": "train",
1822
+ "difficulty": "loop",
1823
+ "day": "2025-05-20",
1824
+ "runs": 30,
1825
+ "reward": 0.828085,
1826
+ "submitted": true,
1827
+ "feasible": true,
1828
+ "cost": 144,
1829
+ "optimal_cost": 129,
1830
+ "floor_cost": 105,
1831
+ "greedy_cost": 803,
1832
+ "real_cost": 1630,
1833
+ "hardness": {
1834
+ "runs": 30,
1835
+ "sets": 7,
1836
+ "stops": 202,
1837
+ "conflicts": 7,
1838
+ "single_track": 1,
1839
+ "headway": 4,
1840
+ "platforms": 2,
1841
+ "closed": 0,
1842
+ "decisions": 21,
1843
+ "holds": 21,
1844
+ "short_turns": 0,
1845
+ "cancels": 0,
1846
+ "turn_points": 0,
1847
+ "avoidable": 24,
1848
+ "optimal": 129,
1849
+ "greedy_gap": 0.8,
1850
+ "closed_min": 0,
1851
+ "slow_min": 0,
1852
+ "track_min": 150,
1853
+ "single_closed": false,
1854
+ "mid_line": false,
1855
+ "late_sets": 0
1856
+ },
1857
+ "violations": 0,
1858
+ "agent_turns": 4,
1859
+ "checks": 3,
1860
+ "seconds": 169.6,
1861
+ "input_tokens": 111337,
1862
+ "cache_read_tokens": 67253,
1863
+ "cache_write_tokens": 44068,
1864
+ "output_tokens": 21782,
1865
+ "served_by": [
1866
+ "claude-sonnet-5-5"
1867
+ ],
1868
+ "end_reason": "submitted",
1869
+ "errors": 0,
1870
+ "plan": [
1871
+ {
1872
+ "train": "37449",
1873
+ "hold": {
1874
+ "HDG": "16:01"
1875
+ }
1876
+ },
1877
+ {
1878
+ "train": "37358",
1879
+ "hold": {
1880
+ "LW": "15:55",
1881
+ "HDG": "16:03",
1882
+ "FWD": "16:10"
1883
+ }
1884
+ },
1885
+ {
1886
+ "train": "37349",
1887
+ "hold": {
1888
+ "BP": "15:59"
1889
+ }
1890
+ },
1891
+ {
1892
+ "train": "37458",
1893
+ "hold": {
1894
+ "BP": "16:19"
1895
+ }
1896
+ }
1897
+ ]
1898
+ }
1899
+ ]
1900
+ }
tasks/rail-v1-eval/manifest.json ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "rail-v1-eval",
3
+ "split": "eval",
4
+ "tasks": 45,
5
+ "file": "tasks.jsonl",
6
+ "tiers": {
7
+ "blockage": 20,
8
+ "compound": 17,
9
+ "loop": 4,
10
+ "real": 4
11
+ },
12
+ "sha256": "2b4347efbc1b0aef26225aeebd451e64f4a3bf427f41a0a7e4618795d5db034b",
13
+ "candidates": 134,
14
+ "days": [
15
+ "2025-01-20",
16
+ "2025-01-21",
17
+ "2025-01-22",
18
+ "2025-01-23",
19
+ "2025-01-25",
20
+ "2025-01-26",
21
+ "2025-02-11",
22
+ "2025-02-12",
23
+ "2025-02-13",
24
+ "2025-02-14",
25
+ "2025-02-15",
26
+ "2025-02-16",
27
+ "2025-03-10",
28
+ "2025-03-11",
29
+ "2025-03-12",
30
+ "2025-03-13",
31
+ "2025-03-16",
32
+ "2025-04-23",
33
+ "2025-04-25",
34
+ "2025-04-26",
35
+ "2025-04-27",
36
+ "2025-05-26",
37
+ "2025-05-27",
38
+ "2025-05-28",
39
+ "2025-05-30",
40
+ "2025-05-31",
41
+ "2025-06-22"
42
+ ],
43
+ "proven_optimal": 45,
44
+ "runs": [
45
+ 15,
46
+ 38
47
+ ],
48
+ "source": "Rijden de Treinen train archive and disruptions, 2024-2025 (CC BY 4.0); OpenStreetMap (ODbL); see data/gnlw/SOURCE.md",
49
+ "selected_from": "pool-eval",
50
+ "selection": {
51
+ "calibration_episodes": 16,
52
+ "features": [
53
+ "runs",
54
+ "conflicts",
55
+ "decisions",
56
+ "holds",
57
+ "short_turns",
58
+ "turn_points",
59
+ "avoidable",
60
+ "slow_min",
61
+ "track_min",
62
+ "single_closed"
63
+ ],
64
+ "lambda": 3.0,
65
+ "weights": {
66
+ "intercept": 0.5618,
67
+ "runs": 0.0715,
68
+ "conflicts": 0.0631,
69
+ "decisions": -0.0708,
70
+ "holds": -0.0433,
71
+ "short_turns": -0.0466,
72
+ "turn_points": -0.1022,
73
+ "avoidable": 0.0245,
74
+ "slow_min": -0.0429,
75
+ "track_min": 0.1,
76
+ "single_closed": 0.0544
77
+ },
78
+ "loo_mae": 0.1343,
79
+ "loo_spearman": 0.6676,
80
+ "calibration_mean_reward": 0.5572,
81
+ "pool": 72,
82
+ "chosen": 45,
83
+ "per_tier": {
84
+ "blockage": 20,
85
+ "compound": 17,
86
+ "loop": 4,
87
+ "real": 4
88
+ },
89
+ "predicted_mean_chosen": 0.443,
90
+ "predicted_mean_pool": 0.5628
91
+ }
92
+ }
tasks/rail-v1-eval/selection.json ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "calibration_episodes": 16,
3
+ "features": [
4
+ "runs",
5
+ "conflicts",
6
+ "decisions",
7
+ "holds",
8
+ "short_turns",
9
+ "turn_points",
10
+ "avoidable",
11
+ "slow_min",
12
+ "track_min",
13
+ "single_closed"
14
+ ],
15
+ "lambda": 3.0,
16
+ "weights": {
17
+ "intercept": 0.5618,
18
+ "runs": 0.0715,
19
+ "conflicts": 0.0631,
20
+ "decisions": -0.0708,
21
+ "holds": -0.0433,
22
+ "short_turns": -0.0466,
23
+ "turn_points": -0.1022,
24
+ "avoidable": 0.0245,
25
+ "slow_min": -0.0429,
26
+ "track_min": 0.1,
27
+ "single_closed": 0.0544
28
+ },
29
+ "loo_mae": 0.1343,
30
+ "loo_spearman": 0.6676,
31
+ "calibration_mean_reward": 0.5572,
32
+ "pool": 72,
33
+ "chosen": 45,
34
+ "per_tier": {
35
+ "blockage": 20,
36
+ "compound": 17,
37
+ "loop": 4,
38
+ "real": 4
39
+ },
40
+ "predicted_mean_chosen": 0.443,
41
+ "predicted_mean_pool": 0.5628,
42
+ "predictions": {
43
+ "gnlw-2025-02-12-0900-180m-real-0": 0.1894,
44
+ "gnlw-2025-04-26-0630-180m-blockage-0": 0.2287,
45
+ "gnlw-2025-03-16-1700-210m-compound-0": 0.2512,
46
+ "gnlw-2025-04-27-0830-210m-blockage-0": 0.2549,
47
+ "gnlw-2025-01-20-1800-180m-compound-0": 0.2581,
48
+ "gnlw-2025-01-26-1700-210m-compound-0": 0.273,
49
+ "gnlw-2025-02-14-1730-210m-blockage-0": 0.2856,
50
+ "gnlw-2025-02-15-0630-180m-blockage-0": 0.3048,
51
+ "gnlw-2025-04-27-0800-210m-compound-0": 0.3232,
52
+ "gnlw-2025-05-31-0900-180m-compound-0": 0.3594,
53
+ "gnlw-2025-01-25-1700-180m-blockage-0": 0.363,
54
+ "gnlw-2025-02-15-1630-150m-loop-0": 0.3773,
55
+ "gnlw-2025-05-30-0630-180m-blockage-0": 0.3973,
56
+ "gnlw-2025-02-13-0954-180m-real-0": 0.4004,
57
+ "gnlw-2025-02-11-0900-150m-blockage-0": 0.4051,
58
+ "gnlw-2025-01-23-0900-180m-compound-0": 0.4195,
59
+ "gnlw-2025-04-26-1700-180m-blockage-0": 0.4247,
60
+ "gnlw-2025-02-13-1530-180m-blockage-0": 0.4299,
61
+ "gnlw-2025-03-12-1730-210m-loop-0": 0.441,
62
+ "gnlw-2025-02-16-1730-240m-compound-0": 0.4457,
63
+ "gnlw-2025-05-26-0730-150m-blockage-0": 0.4553,
64
+ "gnlw-2025-01-25-0630-180m-loop-0": 0.4555,
65
+ "gnlw-2025-04-23-1630-150m-blockage-0": 0.4577,
66
+ "gnlw-2025-03-12-1530-180m-blockage-0": 0.4677,
67
+ "gnlw-2025-01-23-0800-210m-compound-0": 0.472,
68
+ "gnlw-2025-01-25-0730-180m-compound-0": 0.4929,
69
+ "gnlw-2025-05-27-0830-210m-blockage-0": 0.4951,
70
+ "gnlw-2025-05-26-1800-150m-blockage-0": 0.4972,
71
+ "gnlw-2025-03-13-0706-180m-real-0": 0.5019,
72
+ "gnlw-2025-06-22-1600-240m-compound-0": 0.5068,
73
+ "gnlw-2025-03-16-1630-180m-compound-0": 0.5089,
74
+ "gnlw-2025-03-10-0700-210m-blockage-0": 0.5156,
75
+ "gnlw-2025-02-15-0700-240m-compound-0": 0.5177,
76
+ "gnlw-2025-01-21-0626-180m-real-0": 0.525,
77
+ "gnlw-2025-02-16-1700-210m-compound-0": 0.5306,
78
+ "gnlw-2025-05-27-1700-180m-blockage-0": 0.5325,
79
+ "gnlw-2025-02-16-1530-210m-compound-0": 0.5331,
80
+ "gnlw-2025-01-22-1600-240m-compound-0": 0.5439,
81
+ "gnlw-2025-01-25-1800-150m-blockage-0": 0.5567,
82
+ "gnlw-2025-04-25-1730-210m-blockage-0": 0.5697,
83
+ "gnlw-2025-05-26-1630-150m-blockage-0": 0.5737,
84
+ "gnlw-2025-05-28-1830-210m-compound-0": 0.5761,
85
+ "gnlw-2025-01-22-1800-150m-loop-0": 0.5792,
86
+ "gnlw-2025-05-27-1830-180m-blockage-0": 0.5939,
87
+ "gnlw-2025-03-11-0730-150m-blockage-0": 0.5968,
88
+ "gnlw-2025-05-31-0800-210m-compound-0": 0.605,
89
+ "gnlw-2025-04-25-0700-210m-blockage-0": 0.6173,
90
+ "gnlw-2025-02-14-1830-180m-blockage-0": 0.6397,
91
+ "gnlw-2025-04-27-1700-210m-compound-0": 0.645,
92
+ "gnlw-2025-05-27-0730-150m-blockage-0": 0.6544,
93
+ "gnlw-2025-05-31-0630-180m-blockage-0": 0.6643,
94
+ "gnlw-2025-02-12-1830-180m-blockage-0": 0.6643,
95
+ "gnlw-2025-02-12-0700-210m-blockage-0": 0.6749,
96
+ "gnlw-2025-04-26-0630-210m-compound-0": 0.6751,
97
+ "gnlw-2025-04-25-0730-180m-compound-0": 0.6791,
98
+ "gnlw-2025-04-25-0630-180m-loop-0": 0.6867,
99
+ "gnlw-2025-04-27-1700-180m-blockage-0": 0.7217,
100
+ "gnlw-2025-02-12-1800-180m-compound-0": 0.7405,
101
+ "gnlw-2025-01-25-1830-180m-loop-0": 0.7664,
102
+ "gnlw-2025-03-12-0830-210m-loop-0": 0.7672,
103
+ "gnlw-2025-04-25-1800-180m-compound-0": 0.7813,
104
+ "gnlw-2025-03-14-0821-180m-real-0": 0.7952,
105
+ "gnlw-2025-02-15-0800-180m-loop-0": 0.8057,
106
+ "gnlw-2025-02-10-0830-210m-loop-0": 0.8268,
107
+ "gnlw-2025-01-25-0630-180m-blockage-0": 0.8333,
108
+ "gnlw-2025-02-14-1700-180m-loop-0": 0.834,
109
+ "gnlw-2025-02-14-0730-180m-compound-0": 0.8568,
110
+ "gnlw-2025-04-23-1700-180m-blockage-0": 0.8771,
111
+ "gnlw-2025-04-26-0700-240m-compound-0": 0.9031,
112
+ "gnlw-2025-06-22-0830-240m-compound-0": 0.9419,
113
+ "gnlw-2025-03-12-1700-210m-compound-0": 0.974,
114
+ "gnlw-2025-05-27-0830-240m-compound-0": 1.0029
115
+ }
116
+ }
tasks/rail-v1-eval/tasks.jsonl ADDED
The diff for this file is too large to render. See raw diff
 
tasks/rail-v1-train/manifest.json ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "rail-v1-train",
3
+ "split": "train",
4
+ "tasks": 159,
5
+ "file": "tasks.jsonl",
6
+ "tiers": {
7
+ "blockage": 65,
8
+ "compound": 51,
9
+ "loop": 39,
10
+ "real": 4
11
+ },
12
+ "sha256": "4b1abf5acec175d2f24c5ec6ade8721a82932eda280d514ff3d9285e0a8501b2",
13
+ "candidates": 292,
14
+ "days": [
15
+ "2025-01-01",
16
+ "2025-01-02",
17
+ "2025-01-03",
18
+ "2025-01-04",
19
+ "2025-01-05",
20
+ "2025-01-07",
21
+ "2025-01-08",
22
+ "2025-01-13",
23
+ "2025-01-14",
24
+ "2025-01-15",
25
+ "2025-01-16",
26
+ "2025-01-19",
27
+ "2025-01-28",
28
+ "2025-01-29",
29
+ "2025-01-30",
30
+ "2025-02-01",
31
+ "2025-02-02",
32
+ "2025-02-03",
33
+ "2025-02-04",
34
+ "2025-02-05",
35
+ "2025-02-07",
36
+ "2025-02-08",
37
+ "2025-02-19",
38
+ "2025-02-20",
39
+ "2025-02-22",
40
+ "2025-02-24",
41
+ "2025-02-26",
42
+ "2025-02-27",
43
+ "2025-02-28",
44
+ "2025-03-03",
45
+ "2025-03-06",
46
+ "2025-03-09",
47
+ "2025-03-17",
48
+ "2025-03-19",
49
+ "2025-03-20",
50
+ "2025-03-22",
51
+ "2025-03-24",
52
+ "2025-03-25",
53
+ "2025-03-26",
54
+ "2025-03-27",
55
+ "2025-03-29",
56
+ "2025-03-30",
57
+ "2025-03-31",
58
+ "2025-04-01",
59
+ "2025-04-02",
60
+ "2025-04-03",
61
+ "2025-04-04",
62
+ "2025-04-05",
63
+ "2025-04-06",
64
+ "2025-04-07",
65
+ "2025-04-08",
66
+ "2025-04-09",
67
+ "2025-04-10",
68
+ "2025-04-11",
69
+ "2025-04-12",
70
+ "2025-04-14",
71
+ "2025-04-15",
72
+ "2025-04-17",
73
+ "2025-04-19",
74
+ "2025-04-29",
75
+ "2025-04-30",
76
+ "2025-05-01",
77
+ "2025-05-03",
78
+ "2025-05-04",
79
+ "2025-05-05",
80
+ "2025-05-06",
81
+ "2025-05-07",
82
+ "2025-05-08",
83
+ "2025-05-09",
84
+ "2025-05-11",
85
+ "2025-05-12",
86
+ "2025-05-13",
87
+ "2025-05-15",
88
+ "2025-05-17",
89
+ "2025-05-19",
90
+ "2025-05-20",
91
+ "2025-05-21",
92
+ "2025-06-02",
93
+ "2025-06-04",
94
+ "2025-06-06",
95
+ "2025-06-07",
96
+ "2025-06-08",
97
+ "2025-06-09",
98
+ "2025-06-10"
99
+ ],
100
+ "proven_optimal": 159,
101
+ "runs": [
102
+ 15,
103
+ 38
104
+ ],
105
+ "source": "Rijden de Treinen train archive and disruptions, 2024-2025 (CC BY 4.0); OpenStreetMap (ODbL); see data/gnlw/SOURCE.md"
106
+ }
tasks/rail-v1-train/tasks.jsonl ADDED
The diff for this file is too large to render. See raw diff