Spaces:
Running on Zero
Running on Zero
Add bounded native CPU drive recorder
Browse files
replay.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bounded, measured playback using the installed native benchmark command.
|
| 2 |
+
|
| 3 |
+
Each sample is a deterministic fresh CPU evaluation of the same policy up to
|
| 4 |
+
that tick. This is not a recording of a previous training generation. Native
|
| 5 |
+
bench captures car state before turnover, accepts only positive tick counts,
|
| 6 |
+
and disables wall/idle elimination. With population one its preserved parent
|
| 7 |
+
is the supplied policy; every dump is checked against that exact parameter
|
| 8 |
+
vector before any recording is returned.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import hashlib
|
| 12 |
+
import json
|
| 13 |
+
import math
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
import stat
|
| 16 |
+
import struct
|
| 17 |
+
import subprocess
|
| 18 |
+
import tempfile
|
| 19 |
+
import time
|
| 20 |
+
|
| 21 |
+
from worker import (
|
| 22 |
+
OUTPUT_NAMES,
|
| 23 |
+
native_environment,
|
| 24 |
+
network_input,
|
| 25 |
+
track_input,
|
| 26 |
+
vehicle_scene_template,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
MAX_TICKS = 900
|
| 31 |
+
SAMPLE_INTERVAL_TICKS = 6
|
| 32 |
+
MAX_WALL_SECONDS = 30.0
|
| 33 |
+
MAX_SCENE_BYTES = 16 * 1024 * 1024
|
| 34 |
+
MAX_DUMP_BYTES = 512 * 1024
|
| 35 |
+
MAX_RESPONSE_BYTES = 2 * 1024 * 1024
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class ReplayError(RuntimeError):
|
| 39 |
+
"""The native recorder could not produce a complete verified recording."""
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class ReplayTimeout(ReplayError, TimeoutError):
|
| 43 |
+
"""The recording exhausted its native subprocess or total wall budget."""
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _remaining(deadline):
|
| 47 |
+
seconds = deadline - time.monotonic()
|
| 48 |
+
if seconds <= 0:
|
| 49 |
+
raise ReplayTimeout('Replay recording exceeded its time limit; try fewer ticks')
|
| 50 |
+
return seconds
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _run(command, *, work, env, deadline, stage, timeout=None):
|
| 54 |
+
remaining = _remaining(deadline)
|
| 55 |
+
try:
|
| 56 |
+
subprocess.run(
|
| 57 |
+
command,
|
| 58 |
+
cwd=work,
|
| 59 |
+
env=env,
|
| 60 |
+
stdin=subprocess.DEVNULL,
|
| 61 |
+
stdout=subprocess.DEVNULL,
|
| 62 |
+
stderr=subprocess.DEVNULL,
|
| 63 |
+
shell=False,
|
| 64 |
+
check=True,
|
| 65 |
+
timeout=min(remaining, timeout) if timeout is not None else remaining,
|
| 66 |
+
)
|
| 67 |
+
except subprocess.TimeoutExpired as exc:
|
| 68 |
+
raise ReplayTimeout('Replay recording exceeded its time limit; try fewer ticks') from exc
|
| 69 |
+
except (OSError, subprocess.CalledProcessError) as exc:
|
| 70 |
+
raise ReplayError('Native replay ' + stage + ' failed') from exc
|
| 71 |
+
_remaining(deadline)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _object(pairs):
|
| 75 |
+
result = {}
|
| 76 |
+
for key, value in pairs:
|
| 77 |
+
if key in result:
|
| 78 |
+
raise ValueError('Duplicate JSON key')
|
| 79 |
+
result[key] = value
|
| 80 |
+
return result
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _read_json(path, limit, label):
|
| 84 |
+
try:
|
| 85 |
+
info = path.lstat()
|
| 86 |
+
if not stat.S_ISREG(info.st_mode) or info.st_size > limit:
|
| 87 |
+
raise ValueError('Invalid output file')
|
| 88 |
+
with path.open('rb') as stream:
|
| 89 |
+
data = stream.read(limit + 1)
|
| 90 |
+
if len(data) > limit:
|
| 91 |
+
raise ValueError('Output exceeds limit')
|
| 92 |
+
value = json.loads(
|
| 93 |
+
data,
|
| 94 |
+
object_pairs_hook=_object,
|
| 95 |
+
parse_constant=lambda _: (_ for _ in ()).throw(ValueError('Nonfinite JSON')),
|
| 96 |
+
)
|
| 97 |
+
if not isinstance(value, dict):
|
| 98 |
+
raise ValueError('Expected JSON object')
|
| 99 |
+
return value
|
| 100 |
+
except (OSError, ValueError, RecursionError) as exc:
|
| 101 |
+
raise ReplayError('Native replay returned an invalid ' + label) from exc
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _number(value, label):
|
| 105 |
+
finite = False
|
| 106 |
+
if type(value) in (int, float):
|
| 107 |
+
try:
|
| 108 |
+
finite = math.isfinite(value)
|
| 109 |
+
except OverflowError:
|
| 110 |
+
pass
|
| 111 |
+
if not finite:
|
| 112 |
+
raise ReplayError('Native replay returned an invalid ' + label)
|
| 113 |
+
return value
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _vector(value, label):
|
| 117 |
+
if not isinstance(value, list) or len(value) != 2:
|
| 118 |
+
raise ReplayError('Native replay returned an invalid ' + label)
|
| 119 |
+
return [_number(component, label) for component in value]
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _counter(value, label):
|
| 123 |
+
# Keep integer values exact in the browser's JSON number representation.
|
| 124 |
+
if type(value) is not int or not 0 <= value <= 2**53 - 1:
|
| 125 |
+
raise ReplayError('Native replay returned an invalid ' + label)
|
| 126 |
+
return value
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _parameter_bytes(values):
|
| 130 |
+
# Native Network.params stores float64 values, layer by layer: each weight
|
| 131 |
+
# row, followed by that layer's bias. Comparing bytes also preserves -0.0.
|
| 132 |
+
return b''.join(struct.pack('!d', _number(value, 'policy parameter')) for value in values)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _frame(dump, tick, expected_parameters):
|
| 136 |
+
agents = dump.get('agents')
|
| 137 |
+
networks = dump.get('next_networks')
|
| 138 |
+
if (not isinstance(agents, list) or len(agents) != 1 or not isinstance(agents[0], dict)
|
| 139 |
+
or not isinstance(networks, list) or len(networks) != 1
|
| 140 |
+
or not isinstance(networks[0], list)
|
| 141 |
+
or len(networks[0]) * 8 != len(expected_parameters)):
|
| 142 |
+
raise ReplayError('Native replay must contain exactly one car and its policy')
|
| 143 |
+
if _parameter_bytes(networks[0]) != expected_parameters:
|
| 144 |
+
raise ReplayError('Native replay changed the selected policy; recording discarded')
|
| 145 |
+
|
| 146 |
+
agent = agents[0]
|
| 147 |
+
position = _vector(agent.get('position'), 'car position')
|
| 148 |
+
velocity = _vector(agent.get('velocity'), 'car velocity')
|
| 149 |
+
if type(agent.get('active')) is not bool:
|
| 150 |
+
raise ReplayError('Native replay returned an invalid active state')
|
| 151 |
+
frame = {
|
| 152 |
+
'tick': tick,
|
| 153 |
+
'x': position[0],
|
| 154 |
+
'y': position[1],
|
| 155 |
+
'rotation': _number(agent.get('rotation'), 'car rotation'),
|
| 156 |
+
'vx': velocity[0],
|
| 157 |
+
'vy': velocity[1],
|
| 158 |
+
'active': agent['active'],
|
| 159 |
+
}
|
| 160 |
+
for field in ('angular_velocity', 'boost_energy'):
|
| 161 |
+
if field in agent:
|
| 162 |
+
frame[field] = _number(agent[field], field)
|
| 163 |
+
for field in ('collision_count', 'update_count'):
|
| 164 |
+
if field in agent:
|
| 165 |
+
frame[field] = _counter(agent[field], field)
|
| 166 |
+
if 'metrics' in agent:
|
| 167 |
+
metrics = agent['metrics']
|
| 168 |
+
if (not isinstance(metrics, dict) or len(metrics) > 64
|
| 169 |
+
or any(not isinstance(key, str) or not 1 <= len(key) <= 96 for key in metrics)):
|
| 170 |
+
raise ReplayError('Native replay returned invalid metrics')
|
| 171 |
+
frame['metrics'] = {
|
| 172 |
+
key: None if value is None else _number(value, 'metric')
|
| 173 |
+
for key, value in metrics.items()
|
| 174 |
+
}
|
| 175 |
+
return frame
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def record_replay(track_bytes, network_bytes, *, binary, importer, scene_template, ticks=300):
|
| 179 |
+
"""Record one unchanged policy for 1–900 native 60 Hz ticks.
|
| 180 |
+
|
| 181 |
+
``binary``, ``importer`` and the Formula ``scene_template`` path must come
|
| 182 |
+
from trusted application configuration. Uploads only supply saved track
|
| 183 |
+
and game network bytes. Samples include tick 1, every sixth tick, and the
|
| 184 |
+
requested endpoint. The importer spawn is labelled separately because the
|
| 185 |
+
native reset settles the vehicle before the first measured tick.
|
| 186 |
+
"""
|
| 187 |
+
deadline = time.monotonic() + MAX_WALL_SECONDS
|
| 188 |
+
if type(ticks) is not int or not 1 <= ticks <= MAX_TICKS:
|
| 189 |
+
raise ValueError('Replay ticks must be an integer from 1 to 900')
|
| 190 |
+
track = track_input(track_bytes)
|
| 191 |
+
template = network_input(network_bytes)
|
| 192 |
+
binary = Path(binary).resolve()
|
| 193 |
+
importer = Path(importer).resolve()
|
| 194 |
+
scene_template = vehicle_scene_template(scene_template, template['Type'])
|
| 195 |
+
if not all(path.is_file() for path in (binary, importer, scene_template)):
|
| 196 |
+
raise ReplayError('Native replay tools or vehicle template unavailable')
|
| 197 |
+
|
| 198 |
+
network = template['NeuralNetwork']
|
| 199 |
+
parameters = []
|
| 200 |
+
for matrix, bias in zip(network['Weights'], network['Biases']):
|
| 201 |
+
parameters.extend(value for row in matrix for value in row)
|
| 202 |
+
parameters.extend(bias)
|
| 203 |
+
expected_parameters = _parameter_bytes(parameters)
|
| 204 |
+
sample_ticks = sorted({1, ticks, *range(SAMPLE_INTERVAL_TICKS, ticks + 1, SAMPLE_INTERVAL_TICKS)})
|
| 205 |
+
environment = native_environment()
|
| 206 |
+
|
| 207 |
+
with tempfile.TemporaryDirectory(prefix='tiga-replay-') as directory:
|
| 208 |
+
work = Path(directory)
|
| 209 |
+
environment.update(TMPDIR=directory)
|
| 210 |
+
|
| 211 |
+
def save(name, value):
|
| 212 |
+
path = work / name
|
| 213 |
+
path.write_text(json.dumps(value, allow_nan=False), encoding='utf-8')
|
| 214 |
+
return str(path)
|
| 215 |
+
|
| 216 |
+
saved_track = save('track.json', track)
|
| 217 |
+
scene_path = work / 'scene.json'
|
| 218 |
+
_run(
|
| 219 |
+
[str(importer), saved_track, 'Replay track', str(scene_template), str(scene_path)],
|
| 220 |
+
work=work, env=environment, deadline=deadline, stage='track import', timeout=10.0,
|
| 221 |
+
)
|
| 222 |
+
scene = _read_json(scene_path, MAX_SCENE_BYTES, 'scene')
|
| 223 |
+
spawn_position = _vector(scene.get('reset_position'), 'requested spawn position')
|
| 224 |
+
spawn_rotation = _number(scene.get('reset_rotation'), 'requested spawn rotation')
|
| 225 |
+
spawn_path = save('spawn.json', {'frames': [{'position': spawn_position, 'rotation': spawn_rotation}]})
|
| 226 |
+
names = [f'sensor-{index}' for index in range(network['Shape'][0])]
|
| 227 |
+
network_path = save('network.json', {
|
| 228 |
+
'shape': network['Shape'], 'weights': network['Weights'], 'biases': network['Biases'],
|
| 229 |
+
'inputs': names, 'outputs': [OUTPUT_NAMES[index] for index in template['Outputs']],
|
| 230 |
+
})
|
| 231 |
+
model_path = save('model.json', {'sensor_layout': {'names': names, 'sensors': template['Sensors']}})
|
| 232 |
+
dump_path = work / 'state.json'
|
| 233 |
+
command = [
|
| 234 |
+
str(binary), '--math-profile', 'proton', '--threads', '1', 'bench',
|
| 235 |
+
'--scene', str(scene_path), '--network', network_path, '--model', model_path,
|
| 236 |
+
'--spawn-trace', spawn_path, '--population', '1', '--warmup-ticks', '0',
|
| 237 |
+
'--seed', '0', '--spawn-index', '0', '--batch-count', '1', '--dump-state', str(dump_path),
|
| 238 |
+
]
|
| 239 |
+
frames = []
|
| 240 |
+
for tick in sample_ticks:
|
| 241 |
+
# A successful command without a new dump must not reuse a prior
|
| 242 |
+
# sample. A single file also bounds temporary storage independently
|
| 243 |
+
# of the recording length.
|
| 244 |
+
dump_path.unlink(missing_ok=True)
|
| 245 |
+
_run(command + ['--ticks', str(tick)], work=work, env=environment,
|
| 246 |
+
deadline=deadline, stage='evaluation')
|
| 247 |
+
dump = _read_json(dump_path, MAX_DUMP_BYTES, 'car state')
|
| 248 |
+
frames.append(_frame(dump, tick, expected_parameters))
|
| 249 |
+
_remaining(deadline)
|
| 250 |
+
|
| 251 |
+
result = {
|
| 252 |
+
'schema': 'ald.recorded-replay',
|
| 253 |
+
'version': 1,
|
| 254 |
+
'kind': 'fresh_cpu_evaluation',
|
| 255 |
+
'physics_hz': 60,
|
| 256 |
+
'sample_interval_ticks': SAMPLE_INTERVAL_TICKS,
|
| 257 |
+
'first_sample_tick': 1,
|
| 258 |
+
'control_hz': 60,
|
| 259 |
+
'track_sha256': hashlib.sha256(track_bytes).hexdigest(),
|
| 260 |
+
'network_sha256': hashlib.sha256(network_bytes).hexdigest(),
|
| 261 |
+
'vehicle_type': template['Type'],
|
| 262 |
+
'requested_ticks': ticks,
|
| 263 |
+
'execution_backend': 'cpu',
|
| 264 |
+
'math_profile': 'proton',
|
| 265 |
+
'collision_elimination': False,
|
| 266 |
+
'idle_elimination': False,
|
| 267 |
+
'seed': 0,
|
| 268 |
+
'population': 1,
|
| 269 |
+
'warmup_ticks': 0,
|
| 270 |
+
'policy_weights_verified': True,
|
| 271 |
+
'sampling_method': 'deterministic_cumulative_native_bench',
|
| 272 |
+
'position_units': 'game_world_units',
|
| 273 |
+
'velocity_units': 'game_world_units_per_second',
|
| 274 |
+
'rotation_units': 'radians',
|
| 275 |
+
'native_spawn_kind': 'requested_before_native_reset_settle',
|
| 276 |
+
'native_spawn': {'x': spawn_position[0], 'y': spawn_position[1], 'rotation': spawn_rotation},
|
| 277 |
+
'frames': frames,
|
| 278 |
+
}
|
| 279 |
+
native_track = scene.get('track')
|
| 280 |
+
if isinstance(native_track, dict):
|
| 281 |
+
for field in ('tile_size', 'tile_map_position'):
|
| 282 |
+
if field in native_track:
|
| 283 |
+
result[field] = _vector(native_track[field], field)
|
| 284 |
+
if len(json.dumps(result, allow_nan=False).encode('utf-8')) > MAX_RESPONSE_BYTES:
|
| 285 |
+
raise ReplayError('Native replay recording exceeds the output limit')
|
| 286 |
+
_remaining(deadline)
|
| 287 |
+
return result
|