TiGa-RCE commited on
Commit
fb32e2e
·
verified ·
1 Parent(s): c2215d3

Add bounded native CPU drive recorder

Browse files
Files changed (1) hide show
  1. replay.py +287 -0
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