1
0

feat(arena): add deterministic headless RL environments

This commit is contained in:
2026-08-19 01:10:30 -07:00
parent 79793faed2
commit 747744fa6c
23 changed files with 2387 additions and 1 deletions
+3
View File
@@ -67,6 +67,9 @@ jobs:
- name: client tests
run: npm test
- name: arena source hashes
run: npm run arena:source-hashes
- name: server tests
run: npm test --workspace @lumbridge/tera-api
+106
View File
@@ -0,0 +1,106 @@
# Tera Arena environments
`@lumbridge/tera/arena` is Tera's renderer-independent RL boundary. It imports
no Three.js scene, canvas, DOM input, network client, or asset code. The four
shipped environments wrap the same fixed-step controllers and plans used by the
interactive client:
| Environment | Existing simulator | Goal | Safety terminal |
|---|---|---|---|
| `drive-101-v1` | `VehicleController` + California transport pack | complete a short US-101 or I-5 route leg | guardrail contact |
| `office-nav-v1` | `Plan(FRONTIER_VALLEY)` + `createWalker` | reach a collision-clear office waypoint | repeated collision stall |
| `crow-nav-v1` | `ActorController` in crow flight mode | reach a 3D waypoint | altitude/horizontal envelope contact |
| `california-flight-v1` | `AircraftController` | reach a geographic/altitude waypoint | California flight-envelope contact |
## Contract
```ts
import {
Drive101Environment,
driveScriptedBaseline,
} from "@lumbridge/tera/arena";
const env = new Drive101Environment();
env.reset(115, { split: "train" });
for (;;) {
const result = env.step(driveScriptedBaseline());
if (result.terminated || result.truncated) break;
}
const checkpoint = env.snapshot(); // JSON-safe and checksummed
env.restore(checkpoint);
const trace = env.trace(); // actions, component rewards and per-step checksums
new Drive101Environment().replay(trace); // throws at the first divergence
```
Every environment implements `tera.arena/v1`:
- `reset(seed, scenario)` accepts an exact public scenario id or
`{ split: "train" | "dev", id? }`. Omitting `id` selects a scenario from the
requested split deterministically from the uint32-normalized seed.
- `step(action)` returns an observation, total reward, named reward components,
Gym-style `terminated`/`truncated`, and `info`.
- `info` pins the semantic environment manifest, materialized scenario, simulator
sources, environment sources, seed, step and state checksum.
- `snapshot`/`restore` round-trip controller state exactly. Snapshots from another
version, scenario, or modified payload are rejected.
- `trace` is a canonical, checksummed action/reward/state envelope. `replay`
verifies the initial state, every transition, the cumulative reward and final
state. The checksum is FNV-1a-64 over canonical UTF-8 JSON; source pins are
SHA-256 values checked from the repository by `npm run arena:source-hashes`.
Stepping a completed episode is an error. A task outcome sets `terminated`;
only the manifest's step cap sets `truncated` with reason `max-steps`.
## Scenarios and evaluation boundary
Train and dev ids are public, explicit, and disjoint in each manifest. Seeded
jitter is applied only when a scenario is materialized and is included in its
hash. This package intentionally contains no “private” split: a private eval
published in the client package is not private. Evaluation operators should
hold out their scenario definitions and instantiate the same contract in their
own package or service.
`ARENA_MANIFESTS` is the machine-readable catalogue. Each manifest declares its
action and observation fields, component reward meanings, public split ids,
fixed step, maximum steps, safety terminals, and baseline claims.
## Reward and baseline policy
Rewards are counterweighted rather than progress-only. Progress and sparse
success compete with time, control/energy, lane/altitude/heading, collision and
safety costs. A policy therefore cannot collect unbounded reward by circling,
vibrating controls, leaning on a wall, or remaining still.
The tests execute every train and dev scenario at two seeds and require:
1. the documented inaction action to finish below zero without reaching a goal;
2. the exported scripted baseline to terminate at the goal with positive return;
3. scripted return to exceed inaction return;
4. all safety terminal classes to be reachable;
5. seeded reset, snapshot continuation, trace replay and tamper rejection to be exact.
These are smoke-proof baselines, not optimal policies. They exist to make reward
regressions and impossible tasks fail in CI before any training budget is spent.
## Adding an environment
Keep the environment under `src/arena/`, wrap an existing renderer-neutral
controller or plan, expose train/dev scenario ids and a manifest, and add it to
`ARENA_MANIFESTS`. Do not import a scene adapter to obtain simulation state.
Update `scripts/check-arena-source-hashes.mjs`, pin the new SHA-256 values, and
add the same determinism, floor, scripted, safety, snapshot and replay proofs.
Run the complete gate:
```bash
npm ci
npm run arena:source-hashes
npm test
npm run typecheck
npm run build
npm run provenance
npm run licenses
```
+7
View File
@@ -81,6 +81,13 @@
"origin": "original",
"sourceLicense": "Apache-2.0",
"lineage": "Original coarse simulation graph; every node, segment, route, and anchor carries a provenance field validated by tests."
},
{
"path": "src/arena/",
"kind": "renderer-independent-reinforcement-learning-environments",
"origin": "original",
"sourceLicense": "Apache-2.0",
"lineage": "Original environment contracts and reward/scenario layers wrapping Tera's repository-authored deterministic controllers and plans; source hashes are pinned and checked in CI."
}
]
}
+14
View File
@@ -72,6 +72,19 @@ npm install
npm run dev
```
## Headless RL environments
Tera also exports a versioned, renderer-independent Arena contract with four
deterministic environments: US-101/I-5 driving, Frontier Valley office
navigation, crow waypoint flight, and California electric-aircraft flight.
They share the client controllers and office plan, but require no canvas, DOM,
Three.js scene, network service, or new runtime dependency.
Import them from `@lumbridge/tera/arena`. Seeded train/dev scenarios,
component rewards, safety terminals, maximum steps, snapshots, checksummed
traces, exact replay and executable inaction/scripted baseline proofs are
documented in [ARENA.md](ARENA.md).
## Using the engine
```ts
@@ -137,6 +150,7 @@ answer is an RTL-SDR receiver: first-party data with nothing to comply with.
src/engine/ renderer — terrain, blocks, structures, markers, flights, scene, minimap
src/cities/ data packs — pure geography, no code
src/transport/ serializable route packs and renderer-independent simulation
src/arena/ versioned headless RL contract, scenarios, traces and environments
src/assets/ original procedural asset library
src/adapters/ where outside data plugs in
src/tools/ instruments — god-only, dynamically imported, never statically
+6
View File
@@ -5,12 +5,18 @@
"license": "Apache-2.0",
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts",
"./arena": "./src/arena/index.ts",
"./*": "./src/*"
},
"workspaces": [
"server"
],
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"arena:source-hashes": "node scripts/check-arena-source-hashes.mjs",
"provenance": "node scripts/check-provenance.mjs && node scripts/check-no-binaries.mjs",
"licenses": "node scripts/check-dependency-licenses.mjs",
"sbom": "npm sbom --package-lock-only --sbom-format=spdx",
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { ARENA_SOURCE_HASHES } from "../src/arena/sourceHashes.ts";
const root = fileURLToPath(new URL("../", import.meta.url));
const sharedEnvironment = [
"src/arena/base.ts",
"src/arena/checksum.ts",
"src/arena/random.ts",
"src/arena/scenarios.ts",
"src/arena/types.ts",
];
const sourceSets = {
"drive-101-v1": {
environment: [...sharedEnvironment, "src/arena/drive101.ts"],
simulator: [
"src/transport/california.ts",
"src/transport/types.ts",
"src/transport/vehicleController.ts",
"src/transport/vehicleSim.ts",
],
},
"office-nav-v1": {
environment: [...sharedEnvironment, "src/arena/officeNav.ts"],
simulator: [
"src/interiors/plan.ts",
"src/interiors/types.ts",
"src/interiors/walker.ts",
"src/offices/frontier-valley.ts",
],
},
"crow-nav-v1": {
environment: [...sharedEnvironment, "src/arena/crowNav.ts"],
simulator: ["src/actors/controller.ts"],
},
"california-flight-v1": {
environment: [...sharedEnvironment, "src/arena/californiaFlight.ts"],
simulator: ["src/aircraft/controller.ts"],
},
};
async function digest(paths) {
const hash = createHash("sha256");
for (const relative of [...paths].sort()) {
hash.update(relative);
hash.update("\0");
hash.update(await readFile(new URL(`../${relative}`, import.meta.url)));
hash.update("\0");
}
return `sha256:${hash.digest("hex")}`;
}
let failed = false;
for (const [envId, sets] of Object.entries(sourceSets)) {
const actual = {
environment: await digest(sets.environment),
simulator: await digest(sets.simulator),
};
const expected = ARENA_SOURCE_HASHES[envId];
try {
assert.deepEqual(actual, expected);
console.log(`arena source hashes: ${envId} ok`);
} catch {
failed = true;
console.error(`arena source hashes: ${envId} mismatch`);
console.error(JSON.stringify(actual, null, 2));
}
}
if (failed) process.exitCode = 1;
+18
View File
@@ -396,6 +396,24 @@ export class ActorController {
return { ...this.current, identity: copyIdentity(this.current.identity) };
}
/** Restore a trusted JSON snapshot for deterministic environment checkpointing. */
restore(snapshot: ActorControllerSnapshot): void {
const numeric = Object.entries(snapshot)
.filter(([, value]) => typeof value === "number")
.every(([, value]) => Number.isFinite(value));
if (
!numeric || !validKind(snapshot.kind) ||
(snapshot.mode !== "ground" && snapshot.mode !== "flight") ||
(snapshot.altitudeBoundContact !== "none" &&
snapshot.altitudeBoundContact !== "minimum" && snapshot.altitudeBoundContact !== "maximum") ||
!Number.isSafeInteger(snapshot.elapsedSteps) || snapshot.elapsedSteps < 0
) throw new RangeError("actor snapshot is incompatible or invalid");
Object.assign(this.current, snapshot, { identity: checkedIdentity(snapshot.identity) });
this.applyHorizontalBounds();
if (this.current.mode === "flight" && this.current.kind === "crow") this.applyAltitudeBounds();
this.accumulator = 0;
}
/** Replace profile/identity without changing kind, pose or position. */
setIdentity(identity: ActorIdentity): void {
this.current.identity = checkedIdentity(identity);
+19
View File
@@ -334,6 +334,25 @@ export class AircraftController {
return { ...this.current };
}
/** Restore a trusted JSON snapshot for deterministic environment checkpointing. */
restore(snapshot: AircraftControllerSnapshot): void {
const numeric = Object.entries(snapshot)
.filter(([, value]) => typeof value === "number")
.every(([, value]) => Number.isFinite(value));
const envelope = this.options.envelope;
if (
!numeric || (snapshot.mode !== "manual" && snapshot.mode !== "assisted") ||
snapshot.lat < envelope.minLat || snapshot.lat > envelope.maxLat ||
snapshot.lng < envelope.minLng || snapshot.lng > envelope.maxLng ||
snapshot.altitudeM < envelope.minAltitudeM || snapshot.altitudeM > envelope.maxAltitudeM ||
snapshot.speedMps < this.options.minimumSpeedMps ||
snapshot.speedMps > this.options.maximumSpeedMps ||
!Number.isSafeInteger(snapshot.elapsedSteps) || snapshot.elapsedSteps < 0
) throw new RangeError("aircraft snapshot is incompatible or invalid");
Object.assign(this.current, snapshot);
this.accumulator = 0;
}
reset(): void {
this.accumulator = 0;
Object.assign(this.current, this.options.initialPosition, {
+273
View File
@@ -0,0 +1,273 @@
import { arenaChecksum } from "./checksum.ts";
import type { ArenaScenarioRegistry } from "./scenarios.ts";
import {
ARENA_API_VERSION,
type ArenaEnvironment,
type ArenaInfo,
type ArenaManifest,
type ArenaReplayResult,
type ArenaResetResult,
type ArenaScenario,
type ArenaScenarioRequest,
type ArenaSnapshot,
type ArenaSourceHashes,
type ArenaStepResult,
type ArenaTraceEnvelope,
type ArenaTraceStep,
} from "./types.ts";
export interface SimulationTransition<O, R extends Record<string, number>> {
observation: O;
rewardComponents: R;
terminated?: boolean;
terminalReason?: string;
}
/** Shared episode, checksum, snapshot and replay semantics for concrete environments. */
export abstract class BaseArenaEnvironment<
A,
O,
R extends Record<string, number>,
S,
P extends object,
> implements ArenaEnvironment<A, O, R, S> {
abstract readonly manifest: ArenaManifest;
protected abstract readonly registry: ArenaScenarioRegistry<P>;
protected abstract readonly sourceHashes: ArenaSourceHashes;
private scenario: ArenaScenario<P> | null = null;
private stepIndex = 0;
private cumulativeReward = 0;
private terminated = false;
private truncated = false;
private terminalReason: string | null = null;
private initialStateChecksum = "";
private frames: ArenaTraceStep<A, R>[] = [];
protected abstract resetSimulation(scenario: ArenaScenario<P>): O;
protected abstract normalizeAction(action: A): A;
protected abstract advanceSimulation(action: A): SimulationTransition<O, R>;
protected abstract simulationSnapshot(): S;
protected abstract restoreSimulation(snapshot: S): O;
reset(seed: number, request: string | ArenaScenarioRequest): ArenaResetResult<O> {
this.scenario = this.registry.resolve(seed, request);
this.stepIndex = 0;
this.cumulativeReward = 0;
this.terminated = false;
this.truncated = false;
this.terminalReason = null;
this.frames = [];
const observation = this.resetSimulation(this.scenario);
this.initialStateChecksum = arenaChecksum(this.statePayload());
return { observation, info: this.info() };
}
step(rawAction: A): ArenaStepResult<O, R> {
this.requireReset();
if (this.terminated || this.truncated) {
throw new Error("arena episode is complete; call reset before step");
}
const action = this.normalizeAction(rawAction);
const transition = this.advanceSimulation(action);
const values = Object.values(transition.rewardComponents);
if (values.some((value) => !Number.isFinite(value))) {
throw new Error("arena reward components must be finite");
}
const reward = values.reduce((sum, value) => sum + value, 0);
this.stepIndex += 1;
this.cumulativeReward += reward;
this.terminated = transition.terminated === true;
this.truncated = !this.terminated && this.stepIndex >= this.manifest.maxSteps;
this.terminalReason = transition.terminalReason ?? (this.truncated ? "max-steps" : null);
const stateChecksum = arenaChecksum(this.statePayload());
this.frames.push({
index: this.stepIndex,
action: structuredClone(action),
reward,
rewardComponents: structuredClone(transition.rewardComponents),
terminated: this.terminated,
truncated: this.truncated,
terminalReason: this.terminalReason,
stateChecksum,
});
return {
observation: transition.observation,
reward,
rewardComponents: structuredClone(transition.rewardComponents),
terminated: this.terminated,
truncated: this.truncated,
info: this.info(stateChecksum),
};
}
snapshot(): ArenaSnapshot<S> {
const scenario = this.requireReset();
const core = {
apiVersion: ARENA_API_VERSION,
envId: this.manifest.id,
envVersion: this.manifest.version,
envHash: this.envHash(),
seed: scenario.seed,
scenarioId: scenario.id,
scenarioSplit: scenario.split,
scenarioHash: scenario.hash,
step: this.stepIndex,
cumulativeReward: this.cumulativeReward,
terminated: this.terminated,
truncated: this.truncated,
terminalReason: this.terminalReason,
simulation: structuredClone(this.simulationSnapshot()),
};
return { ...core, checksum: arenaChecksum(core) };
}
restore(snapshot: ArenaSnapshot<S>): ArenaResetResult<O> {
const { checksum, ...core } = snapshot;
if (arenaChecksum(core) !== checksum) throw new Error("arena snapshot checksum mismatch");
if (
snapshot.apiVersion !== ARENA_API_VERSION || snapshot.envId !== this.manifest.id ||
snapshot.envVersion !== this.manifest.version || snapshot.envHash !== this.envHash()
) throw new Error("arena snapshot is incompatible with this environment");
if (
!Number.isSafeInteger(snapshot.step) || snapshot.step < 0 ||
snapshot.step > this.manifest.maxSteps || !Number.isFinite(snapshot.cumulativeReward) ||
typeof snapshot.terminated !== "boolean" || typeof snapshot.truncated !== "boolean" ||
(snapshot.terminated && snapshot.truncated) ||
(snapshot.terminalReason !== null && typeof snapshot.terminalReason !== "string")
) throw new Error("arena snapshot episode state is invalid");
const resolved = this.registry.resolve(snapshot.seed, {
split: snapshot.scenarioSplit,
id: snapshot.scenarioId,
});
if (resolved.hash !== snapshot.scenarioHash) throw new Error("arena scenario hash mismatch");
this.scenario = resolved;
this.stepIndex = snapshot.step;
this.cumulativeReward = snapshot.cumulativeReward;
this.terminated = snapshot.terminated;
this.truncated = snapshot.truncated;
this.terminalReason = snapshot.terminalReason;
this.frames = [];
const observation = this.restoreSimulation(structuredClone(snapshot.simulation));
this.initialStateChecksum = arenaChecksum(this.statePayload());
return { observation, info: this.info() };
}
trace(): ArenaTraceEnvelope<A, R> {
const scenario = this.requireReset();
const core = {
apiVersion: ARENA_API_VERSION,
envId: this.manifest.id,
envVersion: this.manifest.version,
envHash: this.envHash(),
scenarioId: scenario.id,
scenarioSplit: scenario.split,
scenarioHash: scenario.hash,
sourceHashes: this.sourceHashes,
seed: scenario.seed,
initialStateChecksum: this.initialStateChecksum,
steps: structuredClone(this.frames),
finalStateChecksum: arenaChecksum(this.statePayload()),
cumulativeReward: this.cumulativeReward,
};
return { ...core, checksum: arenaChecksum(core) };
}
replay(trace: ArenaTraceEnvelope<A, R>): ArenaReplayResult<O> {
const { checksum, ...core } = trace;
if (arenaChecksum(core) !== checksum) throw new Error("arena trace checksum mismatch");
if (
trace.apiVersion !== ARENA_API_VERSION || trace.envId !== this.manifest.id ||
trace.envVersion !== this.manifest.version ||
trace.envHash !== this.envHash() || arenaChecksum(trace.sourceHashes) !== arenaChecksum(this.sourceHashes)
) throw new Error("arena trace is incompatible with this environment");
if (
!Array.isArray(trace.steps) || trace.steps.length > this.manifest.maxSteps ||
!Number.isFinite(trace.cumulativeReward)
) throw new Error("arena trace episode state is invalid");
let reset = this.reset(trace.seed, { split: trace.scenarioSplit, id: trace.scenarioId });
if (trace.scenarioHash !== reset.info.scenarioHash || trace.initialStateChecksum !== reset.info.stateChecksum) {
throw new Error("arena trace initial state mismatch");
}
let observation = reset.observation;
for (let offset = 0; offset < trace.steps.length; offset += 1) {
const expected = trace.steps[offset]!;
if (
expected.index !== offset + 1 || !Number.isFinite(expected.reward) ||
typeof expected.terminated !== "boolean" || typeof expected.truncated !== "boolean" ||
(expected.terminated && expected.truncated)
) throw new Error(`arena trace frame ${offset + 1} is invalid`);
const actual = this.step(expected.action);
observation = actual.observation;
if (
actual.info.stateChecksum !== expected.stateChecksum || actual.reward !== expected.reward ||
actual.terminated !== expected.terminated || actual.truncated !== expected.truncated ||
arenaChecksum(actual.rewardComponents) !== arenaChecksum(expected.rewardComponents)
) throw new Error(`arena trace diverged at step ${expected.index}`);
}
const replayed = this.trace();
if (
replayed.finalStateChecksum !== trace.finalStateChecksum ||
replayed.cumulativeReward !== trace.cumulativeReward
) throw new Error("arena trace final state mismatch");
return {
observation,
steps: this.stepIndex,
cumulativeReward: this.cumulativeReward,
finalStateChecksum: replayed.finalStateChecksum,
};
}
protected currentScenario(): ArenaScenario<P> {
return this.requireReset();
}
protected currentStep(): number {
return this.stepIndex;
}
private requireReset(): ArenaScenario<P> {
if (!this.scenario) throw new Error("arena environment must be reset before use");
return this.scenario;
}
private envHash(): string {
return arenaChecksum(this.manifest);
}
private statePayload(): object {
const scenario = this.requireReset();
return {
envId: this.manifest.id,
envVersion: this.manifest.version,
seed: scenario.seed,
scenarioHash: scenario.hash,
step: this.stepIndex,
cumulativeReward: this.cumulativeReward,
terminated: this.terminated,
truncated: this.truncated,
terminalReason: this.terminalReason,
simulation: this.simulationSnapshot(),
};
}
private info(checksum = arenaChecksum(this.statePayload())): ArenaInfo {
const scenario = this.requireReset();
return {
apiVersion: ARENA_API_VERSION,
envId: this.manifest.id,
envVersion: this.manifest.version,
envHash: this.envHash(),
scenarioId: scenario.id,
scenarioSplit: scenario.split,
scenarioHash: scenario.hash,
simulatorHash: this.sourceHashes.simulator,
environmentSourceHash: this.sourceHashes.environment,
seed: scenario.seed,
step: this.stepIndex,
maxSteps: this.manifest.maxSteps,
terminalReason: this.terminalReason,
stateChecksum: checksum,
};
}
}
+315
View File
@@ -0,0 +1,315 @@
import {
AircraftController,
type AircraftControllerSnapshot,
type AircraftGeographicPoint,
} from "../aircraft/controller.ts";
import { BaseArenaEnvironment, type SimulationTransition } from "./base.ts";
import { ARENA_SOURCE_HASHES } from "./sourceHashes.ts";
import { ArenaScenarioRegistry } from "./scenarios.ts";
import {
ARENA_API_VERSION,
type ArenaManifest,
type ArenaScenario,
} from "./types.ts";
export interface CaliforniaFlightAction {
throttle: number;
yaw: number;
pitch: number;
roll: number;
}
export interface CaliforniaFlightObservation {
lat: number;
lng: number;
altitudeM: number;
headingDeg: number;
pitchDeg: number;
rollDeg: number;
speedMps: number;
verticalSpeedMps: number;
goalLat: number;
goalLng: number;
goalAltitudeM: number;
distanceToGoalM: number;
bearingToGoalDeg: number;
altitudeErrorM: number;
envelopeContact: boolean;
}
export type CaliforniaFlightReward = Record<
"progress" | "success" | "time" | "altitude" | "control" | "safety",
number
>;
interface FlightScenarioParameters {
startLat: number;
startLng: number;
startAltitudeM: number;
startHeadingDeg: number;
goalLat: number;
goalLng: number;
goalAltitudeM: number;
}
interface FlightSimulationSnapshot {
controller: AircraftControllerSnapshot;
previousGoalDistanceM: number;
}
const EARTH_RADIUS_M = 6_371_000;
const FIXED_STEP = 0.1;
const MAX_STEPS = 500;
const SUCCESS_RADIUS_M = 90;
const DEFINITIONS = [
{
id: "train-la-east-leg",
split: "train" as const,
parameters: {
startLat: 34.0522, startLng: -118.2437, startAltitudeM: 1_200, startHeadingDeg: 0,
goalLat: 34.0522, goalLng: -118.2325, goalAltitudeM: 1_200,
},
},
{
id: "train-bay-west-climb",
split: "train" as const,
parameters: {
startLat: 37.70, startLng: -122.30, startAltitudeM: 1_350, startHeadingDeg: 15,
goalLat: 37.70, goalLng: -122.313, goalAltitudeM: 1_450,
},
},
{
id: "dev-socal-southeast",
split: "dev" as const,
parameters: {
startLat: 34.20, startLng: -118.40, startAltitudeM: 1_500, startHeadingDeg: 330,
goalLat: 34.194, goalLng: -118.391, goalAltitudeM: 1_380,
},
},
{
id: "dev-bay-northeast",
split: "dev" as const,
parameters: {
startLat: 37.62, startLng: -122.35, startAltitudeM: 1_100, startHeadingDeg: 280,
goalLat: 37.628, goalLng: -122.341, goalAltitudeM: 1_260,
},
},
] as const;
export const CALIFORNIA_FLIGHT_SCENARIOS = new ArenaScenarioRegistry<FlightScenarioParameters>(
"california-flight-v1",
DEFINITIONS,
(base, random) => {
const northJitter = random.between(-0.00015, 0.00015);
const eastJitter = random.between(-0.00015, 0.00015);
return {
...base,
startLat: base.startLat + northJitter,
startLng: base.startLng + eastJitter,
startAltitudeM: base.startAltitudeM + random.between(-12, 12),
startHeadingDeg: base.startHeadingDeg + random.between(-3, 3),
goalLat: base.goalLat - northJitter,
goalLng: base.goalLng - eastJitter,
goalAltitudeM: base.goalAltitudeM + random.between(-12, 12),
};
},
);
export const CALIFORNIA_FLIGHT_MANIFEST: ArenaManifest = Object.freeze({
apiVersion: ARENA_API_VERSION,
id: "california-flight-v1",
version: 1,
title: "California electric-flight waypoint",
description: "Manual fixed-wing waypoint control over Tera's renderer-neutral aircraft simulator.",
simulator: "AircraftController",
fixedStepSeconds: FIXED_STEP,
maxSteps: MAX_STEPS,
actionFields: ["throttle", "yaw", "pitch", "roll"],
observationFields: [
"lat", "lng", "altitudeM", "headingDeg", "pitchDeg", "rollDeg", "speedMps",
"verticalSpeedMps", "goalLat", "goalLng", "goalAltitudeM", "distanceToGoalM",
"bearingToGoalDeg", "altitudeErrorM", "envelopeContact",
],
rewardComponents: {
progress: "Reduction in three-dimensional waypoint distance.",
success: "Sparse arrival bonus.",
time: "Per-step pressure; straight-ahead inaction misses lateral goals.",
altitude: "Counterweight on altitude error.",
control: "Small cost on throttle and surface demand.",
safety: "Terminal California flight-envelope penalty.",
},
safetyTerminals: ["flight-envelope-contact"],
scenarioIds: {
train: CALIFORNIA_FLIGHT_SCENARIOS.ids("train"),
dev: CALIFORNIA_FLIGHT_SCENARIOS.ids("dev"),
},
baselines: {
inaction: "Zero surfaces and throttle fly straight past the lateral waypoint and remain below zero return.",
scripted: "Proportional bearing, bank, yaw and altitude guidance completes every public scenario.",
},
});
export const CALIFORNIA_FLIGHT_INACTION: Readonly<CaliforniaFlightAction> = Object.freeze({
throttle: 0, yaw: 0, pitch: 0, roll: 0,
});
function horizontalDistanceM(a: AircraftGeographicPoint, b: AircraftGeographicPoint): number {
const mean = (a.lat + b.lat) / 2 * Math.PI / 180;
const north = (b.lat - a.lat) * Math.PI / 180 * EARTH_RADIUS_M;
const east = (b.lng - a.lng) * Math.PI / 180 * Math.cos(mean) * EARTH_RADIUS_M;
return Math.hypot(north, east);
}
function distance3dM(
a: AircraftGeographicPoint & { altitudeM: number },
b: AircraftGeographicPoint & { altitudeM: number },
): number {
return Math.hypot(horizontalDistanceM(a, b), b.altitudeM - a.altitudeM);
}
function bearingDeg(a: AircraftGeographicPoint, b: AircraftGeographicPoint): number {
const mean = (a.lat + b.lat) / 2 * Math.PI / 180;
return (Math.atan2((b.lng - a.lng) * Math.cos(mean), b.lat - a.lat) * 180 / Math.PI + 360) % 360;
}
function signedAngleDeg(from: number, to: number): number {
return ((to - from + 540) % 360) - 180;
}
export function californiaFlightScriptedBaseline(
observation: CaliforniaFlightObservation,
): CaliforniaFlightAction {
const headingError = signedAngleDeg(observation.headingDeg, observation.bearingToGoalDeg);
return {
throttle: 0.62,
yaw: Math.max(-0.6, Math.min(0.6, headingError / 80)),
pitch: Math.max(-0.7, Math.min(0.7, observation.altitudeErrorM / 220)),
roll: Math.max(-1, Math.min(1, headingError / 42)),
};
}
export class CaliforniaFlightEnvironment extends BaseArenaEnvironment<
CaliforniaFlightAction,
CaliforniaFlightObservation,
CaliforniaFlightReward,
FlightSimulationSnapshot,
FlightScenarioParameters
> {
readonly manifest = CALIFORNIA_FLIGHT_MANIFEST;
protected readonly registry = CALIFORNIA_FLIGHT_SCENARIOS;
protected readonly sourceHashes = ARENA_SOURCE_HASHES["california-flight-v1"]!;
private controller: AircraftController | null = null;
private previousGoalDistanceM = 0;
protected resetSimulation(scenario: ArenaScenario<FlightScenarioParameters>): CaliforniaFlightObservation {
const parameters = scenario.parameters;
const altitudeMin = Math.min(parameters.startAltitudeM, parameters.goalAltitudeM);
const altitudeMax = Math.max(parameters.startAltitudeM, parameters.goalAltitudeM);
this.controller = new AircraftController({
initialPosition: { lat: parameters.startLat, lng: parameters.startLng },
initialAltitudeM: parameters.startAltitudeM,
initialHeadingDeg: parameters.startHeadingDeg,
initialSpeedMps: 38,
mode: "manual",
fixedStepSeconds: FIXED_STEP,
envelope: {
minLat: Math.min(parameters.startLat, parameters.goalLat) - 0.02,
maxLat: Math.max(parameters.startLat, parameters.goalLat) + 0.02,
minLng: Math.min(parameters.startLng, parameters.goalLng) - 0.02,
maxLng: Math.max(parameters.startLng, parameters.goalLng) + 0.02,
minAltitudeM: altitudeMin - 260,
maxAltitudeM: altitudeMax + 260,
},
});
this.previousGoalDistanceM = this.goalDistance();
return this.observation();
}
protected normalizeAction(action: CaliforniaFlightAction): CaliforniaFlightAction {
const axis = (value: number) => Math.max(-1, Math.min(1, Number.isFinite(value) ? value : 0));
return {
throttle: Math.max(0, Math.min(1, Number.isFinite(action?.throttle) ? action.throttle : 0)),
yaw: axis(action?.yaw),
pitch: axis(action?.pitch),
roll: axis(action?.roll),
};
}
protected advanceSimulation(
action: CaliforniaFlightAction,
): SimulationTransition<CaliforniaFlightObservation, CaliforniaFlightReward> {
const controller = this.requireController();
controller.stepFixed({ ...action, modeRequest: "manual", reset: false });
const state = controller.state();
const distance = this.goalDistance();
const progress = this.previousGoalDistanceM - distance;
this.previousGoalDistanceM = distance;
const success = distance <= SUCCESS_RADIUS_M;
const safety = state.envelopeContact;
const altitudeError = this.currentScenario().parameters.goalAltitudeM - state.altitudeM;
return {
observation: this.observation(),
rewardComponents: {
progress: progress * 0.0025,
success: success ? 3.5 : 0,
time: -0.006,
altitude: -0.000002 * altitudeError ** 2,
control: -0.0005 * (action.throttle ** 2 + action.yaw ** 2 +
action.pitch ** 2 + action.roll ** 2),
safety: safety ? -4 : 0,
},
terminated: success || safety,
terminalReason: success ? "goal" : safety ? "flight-envelope-contact" : undefined,
};
}
protected simulationSnapshot(): FlightSimulationSnapshot {
return {
controller: this.requireController().snapshot(),
previousGoalDistanceM: this.previousGoalDistanceM,
};
}
protected restoreSimulation(snapshot: FlightSimulationSnapshot): CaliforniaFlightObservation {
this.resetSimulation(this.currentScenario());
this.requireController().restore(snapshot.controller);
this.previousGoalDistanceM = snapshot.previousGoalDistanceM;
return this.observation();
}
private observation(): CaliforniaFlightObservation {
const state = this.requireController().state();
const goal = this.currentScenario().parameters;
return {
lat: state.lat,
lng: state.lng,
altitudeM: state.altitudeM,
headingDeg: state.headingDeg,
pitchDeg: state.pitchDeg,
rollDeg: state.rollDeg,
speedMps: state.speedMps,
verticalSpeedMps: state.verticalSpeedMps,
goalLat: goal.goalLat,
goalLng: goal.goalLng,
goalAltitudeM: goal.goalAltitudeM,
distanceToGoalM: this.goalDistance(),
bearingToGoalDeg: bearingDeg(state, { lat: goal.goalLat, lng: goal.goalLng }),
altitudeErrorM: goal.goalAltitudeM - state.altitudeM,
envelopeContact: state.envelopeContact,
};
}
private goalDistance(): number {
const state = this.requireController().state();
const goal = this.currentScenario().parameters;
return distance3dM(state, {
lat: goal.goalLat, lng: goal.goalLng, altitudeM: goal.goalAltitudeM,
});
}
private requireController(): AircraftController {
if (!this.controller) throw new Error("flight environment has not been reset");
return this.controller;
}
}
+44
View File
@@ -0,0 +1,44 @@
/** Canonical JSON and a small cross-runtime checksum (no Node or Web APIs). */
function canonical(value: unknown, stack: Set<object>): string {
if (value === null) return "null";
if (typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
if (typeof value === "number") {
if (!Number.isFinite(value)) throw new TypeError("arena checksums require finite numbers");
return Object.is(value, -0) ? "0" : JSON.stringify(value);
}
if (Array.isArray(value)) {
if (stack.has(value)) throw new TypeError("arena checksums do not accept cycles");
stack.add(value);
const result = `[${value.map((entry) => canonical(entry, stack)).join(",")}]`;
stack.delete(value);
return result;
}
if (typeof value === "object") {
if (stack.has(value)) throw new TypeError("arena checksums do not accept cycles");
stack.add(value);
const record = value as Record<string, unknown>;
const entries = Object.keys(record)
.filter((key) => record[key] !== undefined)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonical(record[key], stack)}`);
stack.delete(value);
return `{${entries.join(",")}}`;
}
throw new TypeError(`arena checksums do not accept ${typeof value}`);
}
export function canonicalJson(value: unknown): string {
return canonical(value, new Set());
}
/** 64-bit FNV-1a over UTF-8, encoded with an algorithm prefix. */
export function arenaChecksum(value: unknown): string {
const bytes = new TextEncoder().encode(canonicalJson(value));
let hash = 0xcbf29ce484222325n;
for (const byte of bytes) {
hash ^= BigInt(byte);
hash = BigInt.asUintN(64, hash * 0x100000001b3n);
}
return `fnv1a64:${hash.toString(16).padStart(16, "0")}`;
}
+285
View File
@@ -0,0 +1,285 @@
import {
ActorController,
type ActorControllerSnapshot,
} from "../actors/controller.ts";
import { BaseArenaEnvironment, type SimulationTransition } from "./base.ts";
import { ARENA_SOURCE_HASHES } from "./sourceHashes.ts";
import { ArenaScenarioRegistry } from "./scenarios.ts";
import {
ARENA_API_VERSION,
type ArenaManifest,
type ArenaScenario,
} from "./types.ts";
export interface CrowNavAction {
forward: number;
turn: number;
pitch: number;
climb: number;
glide: boolean;
}
export interface CrowNavObservation {
x: number;
y: number;
z: number;
yaw: number;
pitch: number;
speedMps: number;
verticalSpeedMps: number;
goalX: number;
goalY: number;
goalZ: number;
deltaX: number;
deltaY: number;
deltaZ: number;
distanceToGoalM: number;
altitudeBoundContact: "none" | "minimum" | "maximum";
}
export type CrowNavReward = Record<
"progress" | "success" | "time" | "energy" | "heading" | "safety",
number
>;
interface CrowScenarioParameters {
startX: number;
startY: number;
startZ: number;
startYaw: number;
goalX: number;
goalY: number;
goalZ: number;
}
interface CrowSimulationSnapshot {
controller: ActorControllerSnapshot;
previousGoalDistanceM: number;
}
const FIXED_STEP = 0.1;
const MAX_STEPS = 300;
const SUCCESS_RADIUS_M = 2.5;
const BOUNDS = Object.freeze({ minX: -120, maxX: 120, minZ: -120, maxZ: 120 });
const DEFINITIONS = [
{
id: "train-east-crosswind",
split: "train" as const,
parameters: { startX: 0, startY: 12, startZ: 0, startYaw: 0, goalX: 60, goalY: 12, goalZ: 0 },
},
{
id: "train-north-climb",
split: "train" as const,
parameters: { startX: 5, startY: 10, startZ: 20, startYaw: -1, goalX: 5, goalY: 16, goalZ: -48 },
},
{
id: "dev-west-return",
split: "dev" as const,
parameters: { startX: 25, startY: 15, startZ: -10, startYaw: 0.4, goalX: -48, goalY: 13, goalZ: -10 },
},
{
id: "dev-south-descent",
split: "dev" as const,
parameters: { startX: -15, startY: 18, startZ: -40, startYaw: -0.8, goalX: -5, goalY: 11, goalZ: 38 },
},
] as const;
export const CROW_NAV_SCENARIOS = new ArenaScenarioRegistry<CrowScenarioParameters>(
"crow-nav-v1",
DEFINITIONS,
(base, random) => ({
...base,
startX: base.startX + random.between(-1, 1),
startY: base.startY + random.between(-0.4, 0.4),
startZ: base.startZ + random.between(-1, 1),
startYaw: base.startYaw + random.between(-0.08, 0.08),
goalX: base.goalX + random.between(-1, 1),
goalY: base.goalY + random.between(-0.4, 0.4),
goalZ: base.goalZ + random.between(-1, 1),
}),
);
export const CROW_NAV_MANIFEST: ArenaManifest = Object.freeze({
apiVersion: ARENA_API_VERSION,
id: "crow-nav-v1",
version: 1,
title: "Crow waypoint navigation",
description: "Three-dimensional waypoint control over Tera's deterministic crow flight controller.",
simulator: "ActorController(kind=crow, mode=flight)",
fixedStepSeconds: FIXED_STEP,
maxSteps: MAX_STEPS,
actionFields: ["forward", "turn", "pitch", "climb", "glide"],
observationFields: [
"x", "y", "z", "yaw", "pitch", "speedMps", "verticalSpeedMps",
"goalX", "goalY", "goalZ", "deltaX", "deltaY", "deltaZ",
"distanceToGoalM", "altitudeBoundContact",
],
rewardComponents: {
progress: "Reduction in 3D distance to the waypoint.",
success: "Sparse waypoint completion bonus.",
time: "Per-step pressure that makes minimum-power inaction negative.",
energy: "Counterweight on powered speed and control demand.",
heading: "Small cost for pointing away from the target.",
safety: "Terminal flight-envelope contact penalty.",
},
safetyTerminals: ["flight-envelope-contact"],
scenarioIds: {
train: CROW_NAV_SCENARIOS.ids("train"),
dev: CROW_NAV_SCENARIOS.ids("dev"),
},
baselines: {
inaction: "Minimum-power straight flight (forward=-1) misses the lateral waypoint and stays below zero return.",
scripted: "Proportional yaw and climb guidance reaches every public waypoint.",
},
});
export const CROW_NAV_INACTION: Readonly<CrowNavAction> = Object.freeze({
forward: -1, turn: 0, pitch: 0, climb: 0.03, glide: false,
});
function wrapAngle(value: number): number {
return ((value + Math.PI) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2) - Math.PI;
}
export function crowNavScriptedBaseline(observation: CrowNavObservation): CrowNavAction {
const desiredYaw = Math.atan2(-observation.deltaX, -observation.deltaZ);
const headingError = wrapAngle(desiredYaw - observation.yaw);
return {
forward: 0.45,
turn: Math.max(-1, Math.min(1, headingError / 0.55)),
pitch: 0,
climb: Math.max(-0.7, Math.min(0.7, observation.deltaY / 12 + 0.03)),
glide: false,
};
}
export class CrowNavEnvironment extends BaseArenaEnvironment<
CrowNavAction,
CrowNavObservation,
CrowNavReward,
CrowSimulationSnapshot,
CrowScenarioParameters
> {
readonly manifest = CROW_NAV_MANIFEST;
protected readonly registry = CROW_NAV_SCENARIOS;
protected readonly sourceHashes = ARENA_SOURCE_HASHES["crow-nav-v1"]!;
private controller: ActorController | null = null;
private previousGoalDistanceM = 0;
protected resetSimulation(scenario: ArenaScenario<CrowScenarioParameters>): CrowNavObservation {
const parameters = scenario.parameters;
this.controller = new ActorController({
kind: "crow",
mode: "flight",
identity: {
id: "arena-crow", displayName: "Arena Crow", authenticated: false, profile: {},
},
position: { x: parameters.startX, y: parameters.startY, z: parameters.startZ },
yaw: parameters.startYaw,
groundY: 0,
minFlightAltitude: 2,
maxFlightAltitude: 40,
horizontalBounds: BOUNDS,
fixedStepSeconds: FIXED_STEP,
});
this.previousGoalDistanceM = this.goalDistance();
return this.observation();
}
protected normalizeAction(action: CrowNavAction): CrowNavAction {
const axis = (value: number) => Math.max(-1, Math.min(1, Number.isFinite(value) ? value : 0));
return {
forward: axis(action?.forward),
turn: axis(action?.turn),
pitch: axis(action?.pitch),
climb: axis(action?.climb),
glide: action?.glide === true,
};
}
protected advanceSimulation(action: CrowNavAction): SimulationTransition<CrowNavObservation, CrowNavReward> {
const controller = this.requireController();
controller.stepFixed({
...action,
right: 0,
sprint: false,
modeRequest: "none",
kindRequest: "none",
reset: false,
});
const state = controller.state();
const distance = this.goalDistance();
const progress = this.previousGoalDistanceM - distance;
this.previousGoalDistanceM = distance;
const success = distance <= SUCCESS_RADIUS_M;
const horizontalContact =
state.x <= BOUNDS.minX || state.x >= BOUNDS.maxX ||
state.z <= BOUNDS.minZ || state.z >= BOUNDS.maxZ;
const safety = horizontalContact || state.altitudeBoundContact !== "none";
const observation = this.observation();
const desiredYaw = Math.atan2(-observation.deltaX, -observation.deltaZ);
const headingError = Math.abs(wrapAngle(desiredYaw - observation.yaw));
return {
observation,
rewardComponents: {
progress: progress * 0.045,
success: success ? 3 : 0,
time: -0.008,
energy: -0.001 * ((action.forward + 1) / 2 + Math.abs(action.turn) +
Math.abs(action.pitch) + Math.abs(action.climb)),
heading: -0.0008 * (headingError / Math.PI) ** 2,
safety: safety ? -3 : 0,
},
terminated: success || safety,
terminalReason: success ? "goal" : safety ? "flight-envelope-contact" : undefined,
};
}
protected simulationSnapshot(): CrowSimulationSnapshot {
return {
controller: this.requireController().snapshot(),
previousGoalDistanceM: this.previousGoalDistanceM,
};
}
protected restoreSimulation(snapshot: CrowSimulationSnapshot): CrowNavObservation {
this.resetSimulation(this.currentScenario());
this.requireController().restore(snapshot.controller);
this.previousGoalDistanceM = snapshot.previousGoalDistanceM;
return this.observation();
}
private observation(): CrowNavObservation {
const state = this.requireController().state();
const goal = this.currentScenario().parameters;
return {
x: state.x,
y: state.y,
z: state.z,
yaw: state.yaw,
pitch: state.pitch,
speedMps: state.speedMps,
verticalSpeedMps: state.verticalSpeedMps,
goalX: goal.goalX,
goalY: goal.goalY,
goalZ: goal.goalZ,
deltaX: goal.goalX - state.x,
deltaY: goal.goalY - state.y,
deltaZ: goal.goalZ - state.z,
distanceToGoalM: this.goalDistance(),
altitudeBoundContact: state.altitudeBoundContact,
};
}
private goalDistance(): number {
const state = this.requireController().state();
const goal = this.currentScenario().parameters;
return Math.hypot(goal.goalX - state.x, goal.goalY - state.y, goal.goalZ - state.z);
}
private requireController(): ActorController {
if (!this.controller) throw new Error("crow environment has not been reset");
return this.controller;
}
}
+253
View File
@@ -0,0 +1,253 @@
import CALIFORNIA_TRANSPORT from "../transport/california.ts";
import {
VehicleController,
type VehicleControllerSnapshot,
} from "../transport/vehicleController.ts";
import { BaseArenaEnvironment, type SimulationTransition } from "./base.ts";
import { ARENA_SOURCE_HASHES } from "./sourceHashes.ts";
import { ArenaScenarioRegistry } from "./scenarios.ts";
import {
ARENA_API_VERSION,
type ArenaManifest,
type ArenaScenario,
} from "./types.ts";
export interface DriveAction {
throttle: number;
brake: number;
steering: number;
handbrake: boolean;
}
export interface DriveObservation {
routeId: string;
progressM: number;
remainingM: number;
lateralOffsetM: number;
speedMps: number;
speedLimitMps: number;
steering: number;
guardrailContact: boolean;
roadName: string;
}
export type DriveReward = Record<
"progress" | "success" | "time" | "lane" | "speed" | "control" | "safety",
number
>;
interface DriveScenarioParameters {
routeId: string;
initialDistanceM: number;
initialLateralOffsetM: number;
initialSpeedMps: number;
targetTravelM: number;
}
interface DriveSimulationSnapshot {
controller: VehicleControllerSnapshot;
travelledM: number;
previousDistanceM: number;
}
const FIXED_STEP = 1 / 30;
const MAX_STEPS = 360;
const TRAVEL_SCALE = 10;
const DEFINITIONS = [
{
id: "train-us101-ventura",
split: "train" as const,
parameters: {
routeId: "la-sf-us-101", initialDistanceM: 25_000,
initialLateralOffsetM: 0, initialSpeedMps: 4, targetTravelM: 850,
},
},
{
id: "train-i5-grapevine",
split: "train" as const,
parameters: {
routeId: "la-sf-i-5", initialDistanceM: 55_000,
initialLateralOffsetM: 0, initialSpeedMps: 4, targetTravelM: 900,
},
},
{
id: "dev-us101-salinas",
split: "dev" as const,
parameters: {
routeId: "la-sf-us-101", initialDistanceM: 390_000,
initialLateralOffsetM: 0, initialSpeedMps: 4, targetTravelM: 950,
},
},
{
id: "dev-i5-bay-approach",
split: "dev" as const,
parameters: {
routeId: "la-sf-i-5", initialDistanceM: 470_000,
initialLateralOffsetM: 0, initialSpeedMps: 4, targetTravelM: 800,
},
},
] as const;
export const DRIVE_101_SCENARIOS = new ArenaScenarioRegistry<DriveScenarioParameters>(
"drive-101-v1",
DEFINITIONS,
(base, random) => ({
...base,
initialDistanceM: base.initialDistanceM + random.between(-750, 750),
initialLateralOffsetM: random.between(-0.15, 0.15),
initialSpeedMps: base.initialSpeedMps + random.between(-0.4, 0.4),
targetTravelM: base.targetTravelM + random.between(-40, 40),
}),
);
export const DRIVE_101_MANIFEST: ArenaManifest = Object.freeze({
apiVersion: ARENA_API_VERSION,
id: "drive-101-v1",
version: 1,
title: "California corridor driving",
description: "Manual route-relative driving on Tera's authored US-101 and I-5 plans.",
simulator: "VehicleController + CALIFORNIA_TRANSPORT",
fixedStepSeconds: FIXED_STEP,
maxSteps: MAX_STEPS,
actionFields: ["throttle", "brake", "steering", "handbrake"],
observationFields: [
"routeId", "progressM", "remainingM", "lateralOffsetM", "speedMps",
"speedLimitMps", "steering", "guardrailContact", "roadName",
],
rewardComponents: {
progress: "Forward route progress, normalized by the episode target.",
success: "Sparse completion bonus.",
time: "Per-step pressure; a stopped or coasting policy has a negative floor.",
lane: "Continuous lane-centering cost.",
speed: "Cost for exceeding 110% of the authored road limit.",
control: "Small counterweight on abrupt or conflicting control demand.",
safety: "Terminal guardrail-contact penalty.",
},
safetyTerminals: ["guardrail-contact"],
scenarioIds: {
train: DRIVE_101_SCENARIOS.ids("train"),
dev: DRIVE_101_SCENARIOS.ids("dev"),
},
baselines: {
inaction: "Zero controls coast below the target and incur the time floor.",
scripted: "throttle=0.72, steering=0 completes every public scenario without contact.",
},
});
export const DRIVE_INACTION: Readonly<DriveAction> = Object.freeze({
throttle: 0, brake: 0, steering: 0, handbrake: false,
});
export function driveScriptedBaseline(): DriveAction {
return { throttle: 0.72, brake: 0, steering: 0, handbrake: false };
}
export class Drive101Environment extends BaseArenaEnvironment<
DriveAction,
DriveObservation,
DriveReward,
DriveSimulationSnapshot,
DriveScenarioParameters
> {
readonly manifest = DRIVE_101_MANIFEST;
protected readonly registry = DRIVE_101_SCENARIOS;
protected readonly sourceHashes = ARENA_SOURCE_HASHES["drive-101-v1"]!;
private controller: VehicleController | null = null;
private travelledM = 0;
private previousDistanceM = 0;
protected resetSimulation(scenario: ArenaScenario<DriveScenarioParameters>): DriveObservation {
const parameters = scenario.parameters;
this.controller = new VehicleController(CALIFORNIA_TRANSPORT, {
routeId: parameters.routeId,
mode: "manual",
initialDistanceM: parameters.initialDistanceM,
initialLateralOffsetM: parameters.initialLateralOffsetM,
initialSpeedMps: parameters.initialSpeedMps,
fixedStepSeconds: FIXED_STEP,
maximumSpeedMps: 42,
guardrailOffsetM: 4.5,
travelScale: TRAVEL_SCALE,
});
this.travelledM = 0;
this.previousDistanceM = this.controller.state().distanceM;
return this.observation();
}
protected normalizeAction(action: DriveAction): DriveAction {
const finite = (value: number) => Number.isFinite(value) ? value : 0;
return {
throttle: Math.max(0, Math.min(1, finite(action?.throttle))),
brake: Math.max(0, Math.min(1, finite(action?.brake))),
steering: Math.max(-1, Math.min(1, finite(action?.steering))),
handbrake: action?.handbrake === true,
};
}
protected advanceSimulation(action: DriveAction): SimulationTransition<DriveObservation, DriveReward> {
const controller = this.requireController();
controller.stepFixed({ ...action, modeRequest: "manual", reset: false });
const state = controller.state();
const delta = Math.max(0, state.distanceM - this.previousDistanceM);
this.previousDistanceM = state.distanceM;
this.travelledM += delta;
const target = this.currentScenario().parameters.targetTravelM;
const success = this.travelledM >= target;
const limitMps = state.speedLimitMph * 0.44704;
const overspeed = Math.max(0, state.speedMps - limitMps * 1.1);
const safety = state.guardrailContact;
return {
observation: this.observation(),
rewardComponents: {
progress: delta / target * 2,
success: success ? 3 : 0,
time: -0.004,
lane: -0.002 * (state.lateralOffsetM / 2.5) ** 2,
speed: -0.004 * overspeed ** 2,
control: -0.0005 * (action.steering ** 2 + action.brake ** 2 +
(action.throttle > 0 && action.brake > 0 ? 1 : 0)),
safety: safety ? -3 : 0,
},
terminated: success || safety,
terminalReason: success ? "goal" : safety ? "guardrail-contact" : undefined,
};
}
protected simulationSnapshot(): DriveSimulationSnapshot {
return {
controller: this.requireController().snapshot(),
travelledM: this.travelledM,
previousDistanceM: this.previousDistanceM,
};
}
protected restoreSimulation(snapshot: DriveSimulationSnapshot): DriveObservation {
this.resetSimulation(this.currentScenario());
this.requireController().restore(snapshot.controller);
this.travelledM = snapshot.travelledM;
this.previousDistanceM = snapshot.previousDistanceM;
return this.observation();
}
private observation(): DriveObservation {
const state = this.requireController().state();
const target = this.currentScenario().parameters.targetTravelM;
return {
routeId: state.routeId,
progressM: this.travelledM,
remainingM: Math.max(0, target - this.travelledM),
lateralOffsetM: state.lateralOffsetM,
speedMps: state.speedMps,
speedLimitMps: state.speedLimitMph * 0.44704,
steering: state.steering,
guardrailContact: state.guardrailContact,
roadName: state.roadName,
};
}
private requireController(): VehicleController {
if (!this.controller) throw new Error("drive environment has not been reset");
return this.controller;
}
}
+79
View File
@@ -0,0 +1,79 @@
export { BaseArenaEnvironment, type SimulationTransition } from "./base.ts";
export { arenaChecksum, canonicalJson } from "./checksum.ts";
export { ArenaRandom, deriveArenaSeed, normalizeArenaSeed } from "./random.ts";
export {
ArenaScenarioRegistry,
type ArenaScenarioDefinition,
type ScenarioSampler,
} from "./scenarios.ts";
export { ARENA_SOURCE_HASHES } from "./sourceHashes.ts";
export {
ARENA_API_VERSION,
type ArenaEnvironment,
type ArenaInfo,
type ArenaManifest,
type ArenaReplayResult,
type ArenaResetResult,
type ArenaScenario,
type ArenaScenarioRequest,
type ArenaSnapshot,
type ArenaSourceHashes,
type ArenaSplit,
type ArenaStepResult,
type ArenaTraceEnvelope,
type ArenaTraceStep,
} from "./types.ts";
export {
DRIVE_101_MANIFEST,
DRIVE_101_SCENARIOS,
DRIVE_INACTION,
Drive101Environment,
driveScriptedBaseline,
type DriveAction,
type DriveObservation,
type DriveReward,
} from "./drive101.ts";
export {
OFFICE_NAV_INACTION,
OFFICE_NAV_MANIFEST,
OFFICE_NAV_SCENARIOS,
OfficeNavEnvironment,
officeNavScriptedBaseline,
type OfficeNavAction,
type OfficeNavObservation,
type OfficeNavReward,
} from "./officeNav.ts";
export {
CROW_NAV_INACTION,
CROW_NAV_MANIFEST,
CROW_NAV_SCENARIOS,
CrowNavEnvironment,
crowNavScriptedBaseline,
type CrowNavAction,
type CrowNavObservation,
type CrowNavReward,
} from "./crowNav.ts";
export {
CALIFORNIA_FLIGHT_INACTION,
CALIFORNIA_FLIGHT_MANIFEST,
CALIFORNIA_FLIGHT_SCENARIOS,
CaliforniaFlightEnvironment,
californiaFlightScriptedBaseline,
type CaliforniaFlightAction,
type CaliforniaFlightObservation,
type CaliforniaFlightReward,
} from "./californiaFlight.ts";
import { CALIFORNIA_FLIGHT_MANIFEST } from "./californiaFlight.ts";
import { CROW_NAV_MANIFEST } from "./crowNav.ts";
import { DRIVE_101_MANIFEST } from "./drive101.ts";
import { OFFICE_NAV_MANIFEST } from "./officeNav.ts";
/** Machine-readable public environment catalogue. */
export const ARENA_MANIFESTS = Object.freeze([
DRIVE_101_MANIFEST,
OFFICE_NAV_MANIFEST,
CROW_NAV_MANIFEST,
CALIFORNIA_FLIGHT_MANIFEST,
]);
+250
View File
@@ -0,0 +1,250 @@
import { Plan } from "../interiors/plan.ts";
import {
createWalker,
type WalkerController,
type WalkerState,
} from "../interiors/walker.ts";
import { FRONTIER_VALLEY } from "../offices/frontier-valley.ts";
import { BaseArenaEnvironment, type SimulationTransition } from "./base.ts";
import { ARENA_SOURCE_HASHES } from "./sourceHashes.ts";
import { ArenaScenarioRegistry } from "./scenarios.ts";
import {
ARENA_API_VERSION,
type ArenaManifest,
type ArenaScenario,
} from "./types.ts";
export interface OfficeNavAction {
x: number;
z: number;
}
export interface OfficeNavObservation {
levelId: string;
x: number;
z: number;
goalX: number;
goalZ: number;
deltaX: number;
deltaZ: number;
distanceToGoalM: number;
travelledM: number;
blockedStreak: number;
}
export type OfficeNavReward = Record<
"progress" | "success" | "time" | "control" | "collision" | "safety",
number
>;
interface OfficeScenarioParameters {
levelId: string;
startX: number;
startZ: number;
goalX: number;
goalZ: number;
}
interface OfficeSimulationSnapshot {
walker: WalkerState;
previousGoalDistanceM: number;
blockedStreak: number;
}
const FIXED_STEP = 0.1;
const MAX_STEPS = 160;
const SUCCESS_RADIUS_M = 0.45;
const PLAN = new Plan(FRONTIER_VALLEY, { depth: "public", warn: false });
const DEFINITIONS = [
{
id: "train-hangar-crossing",
split: "train" as const,
parameters: { levelId: "level-1", startX: 20, startZ: 18, goalX: 34, goalZ: 18 },
},
{
id: "train-galley-aisle",
split: "train" as const,
parameters: { levelId: "level-1", startX: 18, startZ: 21, goalX: 18, goalZ: 27 },
},
{
id: "dev-apron-approach",
split: "dev" as const,
parameters: { levelId: "level-1", startX: 36, startZ: 19, goalX: 46, goalZ: 19 },
},
{
id: "dev-south-aisle",
split: "dev" as const,
parameters: { levelId: "level-1", startX: 22, startZ: 20, goalX: 32, goalZ: 25 },
},
] as const;
export const OFFICE_NAV_SCENARIOS = new ArenaScenarioRegistry<OfficeScenarioParameters>(
"office-nav-v1",
DEFINITIONS,
(base, random) => {
const offsetX = random.between(-0.08, 0.08);
const offsetZ = random.between(-0.08, 0.08);
return {
...base,
startX: base.startX + offsetX,
startZ: base.startZ + offsetZ,
goalX: base.goalX - offsetX,
goalZ: base.goalZ - offsetZ,
};
},
);
export const OFFICE_NAV_MANIFEST: ArenaManifest = Object.freeze({
apiVersion: ARENA_API_VERSION,
id: "office-nav-v1",
version: 1,
title: "Frontier Valley office navigation",
description: "Headless navigation through the resolved public office plan and exact wall collision.",
simulator: "Plan(FRONTIER_VALLEY) + createWalker",
fixedStepSeconds: FIXED_STEP,
maxSteps: MAX_STEPS,
actionFields: ["x", "z"],
observationFields: [
"levelId", "x", "z", "goalX", "goalZ", "deltaX", "deltaZ",
"distanceToGoalM", "travelledM", "blockedStreak",
],
rewardComponents: {
progress: "Reduction in Euclidean distance to the goal.",
success: "Sparse arrival bonus.",
time: "Negative inaction floor and path-efficiency pressure.",
control: "Small cost on action magnitude.",
collision: "Cost when commanded travel is blocked by the resolved plan.",
safety: "Terminal repeated-collision penalty.",
},
safetyTerminals: ["collision-stall"],
scenarioIds: {
train: OFFICE_NAV_SCENARIOS.ids("train"),
dev: OFFICE_NAV_SCENARIOS.ids("dev"),
},
baselines: {
inaction: "x=0,z=0 reaches no goal and accumulates the time floor.",
scripted: "A normalized direct-to-goal vector completes all collision-clear public scenarios.",
},
});
export const OFFICE_NAV_INACTION: Readonly<OfficeNavAction> = Object.freeze({ x: 0, z: 0 });
export function officeNavScriptedBaseline(observation: OfficeNavObservation): OfficeNavAction {
const length = Math.hypot(observation.deltaX, observation.deltaZ);
if (length === 0) return { x: 0, z: 0 };
return { x: observation.deltaX / length, z: observation.deltaZ / length };
}
export class OfficeNavEnvironment extends BaseArenaEnvironment<
OfficeNavAction,
OfficeNavObservation,
OfficeNavReward,
OfficeSimulationSnapshot,
OfficeScenarioParameters
> {
readonly manifest = OFFICE_NAV_MANIFEST;
protected readonly registry = OFFICE_NAV_SCENARIOS;
protected readonly sourceHashes = ARENA_SOURCE_HASHES["office-nav-v1"]!;
private walker: WalkerController | null = null;
private previousGoalDistanceM = 0;
private blockedStreak = 0;
protected resetSimulation(scenario: ArenaScenario<OfficeScenarioParameters>): OfficeNavObservation {
const parameters = scenario.parameters;
this.walker = createWalker(PLAN, {
levelId: parameters.levelId,
position: { x: parameters.startX, z: parameters.startZ },
speed: 2,
fixedStep: FIXED_STEP,
maxCatchUpSteps: 1,
});
this.previousGoalDistanceM = this.goalDistance();
this.blockedStreak = 0;
return this.observation();
}
protected normalizeAction(action: OfficeNavAction): OfficeNavAction {
const x = Number.isFinite(action?.x) ? action.x : 0;
const z = Number.isFinite(action?.z) ? action.z : 0;
const length = Math.hypot(x, z);
return length > 1 ? { x: x / length, z: z / length } : { x, z };
}
protected advanceSimulation(
action: OfficeNavAction,
): SimulationTransition<OfficeNavObservation, OfficeNavReward> {
const walker = this.requireWalker();
const before = walker.state();
const after = walker.tick(FIXED_STEP, action);
const moved = Math.hypot(
after.position.x - before.position.x,
after.position.z - before.position.z,
);
const demand = Math.hypot(action.x, action.z);
const blocked = demand > 0.2 && moved < demand * 2 * FIXED_STEP * 0.2;
this.blockedStreak = blocked ? this.blockedStreak + 1 : 0;
const distance = this.goalDistance();
const progress = this.previousGoalDistanceM - distance;
this.previousGoalDistanceM = distance;
const success = distance <= SUCCESS_RADIUS_M;
const failure = this.blockedStreak >= 8;
return {
observation: this.observation(),
rewardComponents: {
progress: progress * 0.22,
success: success ? 2.5 : 0,
time: -0.01,
control: -0.001 * demand ** 2,
collision: blocked ? -0.08 : 0,
safety: failure ? -1.5 : 0,
},
terminated: success || failure,
terminalReason: success ? "goal" : failure ? "collision-stall" : undefined,
};
}
protected simulationSnapshot(): OfficeSimulationSnapshot {
return {
walker: this.requireWalker().state(),
previousGoalDistanceM: this.previousGoalDistanceM,
blockedStreak: this.blockedStreak,
};
}
protected restoreSimulation(snapshot: OfficeSimulationSnapshot): OfficeNavObservation {
this.resetSimulation(this.currentScenario());
this.requireWalker().restore(snapshot.walker);
this.previousGoalDistanceM = snapshot.previousGoalDistanceM;
this.blockedStreak = snapshot.blockedStreak;
return this.observation();
}
private observation(): OfficeNavObservation {
const state = this.requireWalker().state();
const goal = this.currentScenario().parameters;
return {
levelId: state.levelId,
x: state.position.x,
z: state.position.z,
goalX: goal.goalX,
goalZ: goal.goalZ,
deltaX: goal.goalX - state.position.x,
deltaZ: goal.goalZ - state.position.z,
distanceToGoalM: this.goalDistance(),
travelledM: state.distance,
blockedStreak: this.blockedStreak,
};
}
private goalDistance(): number {
const state = this.requireWalker().state();
const goal = this.currentScenario().parameters;
return Math.hypot(goal.goalX - state.position.x, goal.goalZ - state.position.z);
}
private requireWalker(): WalkerController {
if (!this.walker) throw new Error("office environment has not been reset");
return this.walker;
}
}
+35
View File
@@ -0,0 +1,35 @@
/** Seed normalization and deterministic PRNG used only for scenario materialization. */
export function normalizeArenaSeed(value: number): number {
if (!Number.isSafeInteger(value)) throw new RangeError("arena seed must be a safe integer");
return value >>> 0;
}
export class ArenaRandom {
private state: number;
constructor(seed: number) {
this.state = normalizeArenaSeed(seed) || 0x6d2b79f5;
}
next(): number {
let value = this.state += 0x6d2b79f5;
value = Math.imul(value ^ value >>> 15, value | 1);
value ^= value + Math.imul(value ^ value >>> 7, value | 61);
this.state = value >>> 0;
return ((value ^ value >>> 14) >>> 0) / 4_294_967_296;
}
between(min: number, max: number): number {
return min + (max - min) * this.next();
}
}
export function deriveArenaSeed(seed: number, label: string): number {
let value = normalizeArenaSeed(seed) ^ 0x811c9dc5;
for (let index = 0; index < label.length; index += 1) {
value ^= label.charCodeAt(index);
value = Math.imul(value, 0x01000193) >>> 0;
}
return value;
}
+75
View File
@@ -0,0 +1,75 @@
import { arenaChecksum } from "./checksum.ts";
import { ArenaRandom, deriveArenaSeed, normalizeArenaSeed } from "./random.ts";
import type { ArenaScenario, ArenaScenarioRequest, ArenaSplit } from "./types.ts";
export interface ArenaScenarioDefinition<P extends object> {
id: string;
split: ArenaSplit;
parameters: P;
}
export type ScenarioSampler<P extends object> = (parameters: Readonly<P>, random: ArenaRandom) => P;
/** Immutable public scenario catalogue with seeded, reproducible materialization. */
export class ArenaScenarioRegistry<P extends object> {
readonly envId: string;
readonly definitions: readonly ArenaScenarioDefinition<P>[];
private readonly byId = new Map<string, ArenaScenarioDefinition<P>>();
private readonly sampler: ScenarioSampler<P>;
constructor(
envId: string,
definitions: readonly ArenaScenarioDefinition<P>[],
sampler: ScenarioSampler<P>,
) {
if (definitions.length === 0) throw new RangeError("arena scenario registry cannot be empty");
this.envId = envId;
this.definitions = definitions.map((definition) => ({
...definition,
parameters: structuredClone(definition.parameters),
}));
this.sampler = sampler;
for (const definition of this.definitions) {
if (!definition.id || this.byId.has(definition.id)) {
throw new RangeError(`duplicate or empty scenario id: ${definition.id}`);
}
this.byId.set(definition.id, definition);
}
for (const split of ["train", "dev"] as const) {
if (!this.definitions.some((definition) => definition.split === split)) {
throw new RangeError(`${envId} must expose at least one ${split} scenario`);
}
}
}
ids(split: ArenaSplit): readonly string[] {
return this.definitions
.filter((definition) => definition.split === split)
.map((definition) => definition.id);
}
resolve(seedValue: number, request: string | ArenaScenarioRequest): ArenaScenario<P> {
const seed = normalizeArenaSeed(seedValue);
let definition: ArenaScenarioDefinition<P> | undefined;
if (typeof request === "string") {
definition = this.byId.get(request);
} else if (request.id !== undefined) {
definition = this.byId.get(request.id);
if (definition?.split !== request.split) definition = undefined;
} else {
const candidates = this.definitions.filter((entry) => entry.split === request.split);
definition = candidates[seed % candidates.length];
}
if (!definition) throw new RangeError(`unknown ${this.envId} scenario`);
const random = new ArenaRandom(deriveArenaSeed(seed, `${this.envId}:${definition.id}`));
const parameters = this.sampler(definition.parameters, random);
const hash = arenaChecksum({
envId: this.envId,
id: definition.id,
split: definition.split,
seed,
parameters,
});
return { id: definition.id, split: definition.split, seed, parameters, hash };
}
}
+24
View File
@@ -0,0 +1,24 @@
import type { ArenaSourceHashes } from "./types.ts";
/**
* SHA-256 values are generated from committed source files by
* `npm run arena:source-hashes`. Placeholder values are replaced before commit.
*/
export const ARENA_SOURCE_HASHES: Readonly<Record<string, ArenaSourceHashes>> = Object.freeze({
"drive-101-v1": {
environment: "sha256:a8f3bb5c04985215a2b98b75dd2ce82a13b794b9f4933536c5821a49cf1f8125",
simulator: "sha256:ef16e0d24ab20e2457857df367f2a984dd39d4785a035ee7ae7699a06c0d6c32",
},
"office-nav-v1": {
environment: "sha256:870b1924a7ac641d2523d52a537f6b1538803f9cc732bbfc422522a65b4eb09a",
simulator: "sha256:34bb82d70b471cb3734d196f6ad1c685b9154083cf7e319c6089127f7a87eaf3",
},
"crow-nav-v1": {
environment: "sha256:0617c19010ac35f24a899841c91852e9db0fad10d583541a99ae10a581b1da05",
simulator: "sha256:5f26de3073e629d0da92a3fa3396876912802f0fa4fdbd00dc08adc05cb1877d",
},
"california-flight-v1": {
environment: "sha256:8833a25d5da376278ae56da1056ae7fa20ed243b8de0b3ef1c10d5317afbcb3f",
simulator: "sha256:7aa1338f6717abe9ae7c5fb1ddd789975b5cecfd1c8cbd8ed3aae9509288c506",
},
});
+146
View File
@@ -0,0 +1,146 @@
/** Renderer-independent, JSON-safe reinforcement-learning contract. */
export const ARENA_API_VERSION = "tera.arena/v1" as const;
export type ArenaSplit = "train" | "dev";
export interface ArenaScenarioRequest {
/** Public splits only. Held-out/private evaluation belongs outside this package. */
split: ArenaSplit;
/** Omit to choose deterministically from the requested split using the seed. */
id?: string;
}
export interface ArenaScenario<P extends object = Record<string, number | string | boolean>> {
id: string;
split: ArenaSplit;
seed: number;
parameters: P;
hash: string;
}
export interface ArenaSourceHashes {
/** Hash of the environment implementation source, pinned and checked in CI. */
environment: string;
/** Hash of the renderer-independent controller/plan sources the environment wraps. */
simulator: string;
}
export interface ArenaInfo {
apiVersion: typeof ARENA_API_VERSION;
envId: string;
envVersion: number;
envHash: string;
scenarioId: string;
scenarioSplit: ArenaSplit;
scenarioHash: string;
simulatorHash: string;
environmentSourceHash: string;
seed: number;
step: number;
maxSteps: number;
terminalReason: string | null;
stateChecksum: string;
}
export interface ArenaResetResult<O> {
observation: O;
info: ArenaInfo;
}
export interface ArenaStepResult<O, R extends Record<string, number>> {
observation: O;
reward: number;
rewardComponents: R;
terminated: boolean;
truncated: boolean;
info: ArenaInfo;
}
export interface ArenaManifest {
apiVersion: typeof ARENA_API_VERSION;
id: string;
version: number;
title: string;
description: string;
simulator: string;
fixedStepSeconds: number;
maxSteps: number;
actionFields: readonly string[];
observationFields: readonly string[];
rewardComponents: Readonly<Record<string, string>>;
safetyTerminals: readonly string[];
scenarioIds: Readonly<Record<ArenaSplit, readonly string[]>>;
baselines: {
inaction: string;
scripted: string;
};
}
export interface ArenaSnapshot<S> {
apiVersion: typeof ARENA_API_VERSION;
envId: string;
envVersion: number;
envHash: string;
seed: number;
scenarioId: string;
scenarioSplit: ArenaSplit;
scenarioHash: string;
step: number;
cumulativeReward: number;
terminated: boolean;
truncated: boolean;
terminalReason: string | null;
simulation: S;
checksum: string;
}
export interface ArenaTraceStep<A, R extends Record<string, number>> {
index: number;
action: A;
reward: number;
rewardComponents: R;
terminated: boolean;
truncated: boolean;
terminalReason: string | null;
stateChecksum: string;
}
export interface ArenaTraceEnvelope<A, R extends Record<string, number>> {
apiVersion: typeof ARENA_API_VERSION;
envId: string;
envVersion: number;
envHash: string;
scenarioId: string;
scenarioSplit: ArenaSplit;
scenarioHash: string;
sourceHashes: ArenaSourceHashes;
seed: number;
initialStateChecksum: string;
steps: readonly ArenaTraceStep<A, R>[];
finalStateChecksum: string;
cumulativeReward: number;
checksum: string;
}
export interface ArenaReplayResult<O> {
observation: O;
steps: number;
cumulativeReward: number;
finalStateChecksum: string;
}
export interface ArenaEnvironment<
A,
O,
R extends Record<string, number>,
S,
> {
readonly manifest: ArenaManifest;
reset(seed: number, scenario: string | ArenaScenarioRequest): ArenaResetResult<O>;
step(action: A): ArenaStepResult<O, R>;
snapshot(): ArenaSnapshot<S>;
restore(snapshot: ArenaSnapshot<S>): ArenaResetResult<O>;
trace(): ArenaTraceEnvelope<A, R>;
replay(trace: ArenaTraceEnvelope<A, R>): ArenaReplayResult<O>;
}
+10
View File
@@ -0,0 +1,10 @@
/** Tera's renderer-independent public package surface. */
export * from "./arena/index.ts";
export * from "./actors/controller.ts";
export * from "./aircraft/controller.ts";
export * from "./interiors/plan.ts";
export * from "./interiors/types.ts";
export * from "./interiors/walker.ts";
export * from "./transport/types.ts";
export * from "./transport/vehicleController.ts";
export * from "./transport/vehicleSim.ts";
+16 -1
View File
@@ -67,6 +67,8 @@ export interface WalkerController {
tick(elapsedSeconds: number, action: WalkerAction): WalkerState;
/** Return to the original spawn, or atomically adopt another valid spawn. */
reset(spawn?: WalkerSpawn): WalkerState;
/** Restore a trusted JSON snapshot without changing the configured spawn. */
restore(snapshot: WalkerState): WalkerState;
}
/**
@@ -138,7 +140,20 @@ export function createWalker(plan: WalkerPlan, options: WalkerOptions): WalkerCo
return snapshot();
}
return { state: snapshot, tick, reset };
function restore(next: WalkerState): WalkerState {
if (
next.levelId !== spawn.levelId || !finitePoint(next.position) || !finitePoint(next.facing) ||
!Number.isFinite(next.distance) || next.distance < 0 ||
!validPosition(plan, next.levelId, next.position, radius)
) throw new RangeError("walker snapshot is incompatible or invalid");
position = copy(next.position);
facing = normalizedFacing(next.facing);
distance = next.distance;
accumulator = 0;
return snapshot();
}
return { state: snapshot, tick, reset, restore };
}
function normalizedFacing(value: Point2 | undefined): Point2 {
+316
View File
@@ -0,0 +1,316 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
ARENA_API_VERSION,
ARENA_MANIFESTS,
ARENA_SOURCE_HASHES,
CALIFORNIA_FLIGHT_INACTION,
CALIFORNIA_FLIGHT_SCENARIOS,
CROW_NAV_INACTION,
CROW_NAV_SCENARIOS,
DRIVE_101_SCENARIOS,
DRIVE_INACTION,
OFFICE_NAV_INACTION,
OFFICE_NAV_SCENARIOS,
CaliforniaFlightEnvironment,
CrowNavEnvironment,
Drive101Environment,
OfficeNavEnvironment,
arenaChecksum,
californiaFlightScriptedBaseline,
crowNavScriptedBaseline,
driveScriptedBaseline,
officeNavScriptedBaseline,
type ArenaEnvironment,
type ArenaManifest,
type ArenaScenarioRegistry,
type ArenaStepResult,
} from "../index.ts";
type NumericRewards = Record<string, number>;
type AnyEnvironment = ArenaEnvironment<unknown, unknown, NumericRewards, unknown>;
interface EnvironmentCase {
name: string;
create(): AnyEnvironment;
registry: ArenaScenarioRegistry<object>;
inaction: unknown;
scripted(observation: unknown): unknown;
}
const CASES: EnvironmentCase[] = [
{
name: "drive",
create: () => new Drive101Environment() as AnyEnvironment,
registry: DRIVE_101_SCENARIOS as ArenaScenarioRegistry<object>,
inaction: DRIVE_INACTION,
scripted: () => driveScriptedBaseline(),
},
{
name: "office",
create: () => new OfficeNavEnvironment() as AnyEnvironment,
registry: OFFICE_NAV_SCENARIOS as ArenaScenarioRegistry<object>,
inaction: OFFICE_NAV_INACTION,
scripted: (observation) => officeNavScriptedBaseline(
observation as Parameters<typeof officeNavScriptedBaseline>[0],
),
},
{
name: "crow",
create: () => new CrowNavEnvironment() as AnyEnvironment,
registry: CROW_NAV_SCENARIOS as ArenaScenarioRegistry<object>,
inaction: CROW_NAV_INACTION,
scripted: (observation) => crowNavScriptedBaseline(
observation as Parameters<typeof crowNavScriptedBaseline>[0],
),
},
{
name: "flight",
create: () => new CaliforniaFlightEnvironment() as AnyEnvironment,
registry: CALIFORNIA_FLIGHT_SCENARIOS as ArenaScenarioRegistry<object>,
inaction: CALIFORNIA_FLIGHT_INACTION,
scripted: (observation) => californiaFlightScriptedBaseline(
observation as Parameters<typeof californiaFlightScriptedBaseline>[0],
),
},
];
function run(
entry: EnvironmentCase,
scenarioId: string,
seed: number,
policy: (observation: unknown) => unknown,
): { total: number; final: ArenaStepResult<unknown, NumericRewards> } {
const environment = entry.create();
let observation = environment.reset(seed, scenarioId).observation;
let total = 0;
let final: ArenaStepResult<unknown, NumericRewards> | undefined;
for (let step = 0; step < environment.manifest.maxSteps; step += 1) {
final = environment.step(policy(observation));
observation = final.observation;
total += final.reward;
if (final.terminated || final.truncated) break;
}
if (!final) throw new Error("environment manifest must permit at least one step");
return { total, final };
}
describe("arena contract and manifests", () => {
it("exports four versioned renderer-independent manifests with disjoint public splits", () => {
assert.deepEqual(ARENA_MANIFESTS.map((manifest: ArenaManifest) => manifest.id), [
"drive-101-v1", "office-nav-v1", "crow-nav-v1", "california-flight-v1",
]);
for (const manifest of ARENA_MANIFESTS) {
assert.equal(manifest.apiVersion, ARENA_API_VERSION);
assert.ok(manifest.version >= 1 && manifest.maxSteps > 0);
assert.ok(manifest.safetyTerminals.length > 0);
assert.ok(Object.keys(manifest.rewardComponents).length >= 6);
assert.equal(
manifest.scenarioIds.train.some((id) => manifest.scenarioIds.dev.includes(id)),
false,
);
assert.match(ARENA_SOURCE_HASHES[manifest.id]!.environment, /^sha256:[0-9a-f]{64}$/);
assert.match(ARENA_SOURCE_HASHES[manifest.id]!.simulator, /^sha256:[0-9a-f]{64}$/);
}
});
it("selects and materializes scenarios deterministically by split, id and seed", () => {
for (const entry of CASES) {
const a = entry.create().reset(0x1234abcd, { split: "train" });
const b = entry.create().reset(0x1234abcd, { split: "train" });
assert.deepEqual(a, b, entry.name);
assert.equal(a.info.scenarioSplit, "train");
assert.match(a.info.scenarioHash, /^fnv1a64:[0-9a-f]{16}$/);
const different = entry.create().reset(0x1234abce, a.info.scenarioId);
assert.notEqual(different.info.scenarioHash, a.info.scenarioHash);
assert.throws(
() => entry.create().reset(1, { split: "dev", id: entry.registry.ids("train")[0] }),
/unknown/,
);
}
});
it("sanitizes non-finite and out-of-range actions before state or reward hashing", () => {
const malformed = {
throttle: Number.POSITIVE_INFINITY,
brake: Number.NaN,
steering: -99,
handbrake: false,
};
const first = new Drive101Environment();
const second = new Drive101Environment();
first.reset(4, "train-us101-ventura");
second.reset(4, "train-us101-ventura");
const a = first.step(malformed);
const b = second.step({ throttle: 0, brake: 0, steering: -1, handbrake: false });
assert.deepEqual(a, b);
assert.ok(Number.isFinite(a.reward));
});
it("uses terminated for outcomes, truncated only for max-step exhaustion", () => {
for (const entry of CASES) {
const id = entry.registry.ids("train")[0]!;
const idle = run(entry, id, 5, () => entry.inaction).final;
assert.equal(idle.terminated, false, entry.name);
assert.equal(idle.truncated, true, entry.name);
assert.equal(idle.info.terminalReason, "max-steps", entry.name);
const scripted = run(entry, id, 5, entry.scripted).final;
assert.equal(scripted.terminated, true, entry.name);
assert.equal(scripted.truncated, false, entry.name);
assert.equal(scripted.info.terminalReason, "goal", entry.name);
}
});
});
describe("arena baseline proofs", () => {
it("keeps inaction below zero and proves a positive scripted completion on every public scenario", () => {
for (const entry of CASES) {
for (const definition of entry.registry.definitions) {
for (const seed of [1, 0xdecafbad]) {
const idle = run(entry, definition.id, seed, () => entry.inaction);
assert.ok(idle.total < 0, `${entry.name}/${definition.id}/${seed} inaction=${idle.total}`);
assert.notEqual(idle.final.info.terminalReason, "goal");
const scripted = run(entry, definition.id, seed, entry.scripted);
assert.equal(
scripted.final.info.terminalReason,
"goal",
`${entry.name}/${definition.id}/${seed}`,
);
assert.ok(scripted.total > 0, `${entry.name}/${definition.id}/${seed}=${scripted.total}`);
assert.ok(scripted.total > idle.total);
}
}
}
});
it("exposes reachable safety terminals rather than reward-only safety labels", () => {
const drive = new Drive101Environment();
drive.reset(2, "train-us101-ventura");
let driveReason: string | null = null;
for (let index = 0; index < drive.manifest.maxSteps; index += 1) {
const result = drive.step({ throttle: 1, brake: 0, steering: 1, handbrake: false });
driveReason = result.info.terminalReason;
if (result.terminated) break;
}
assert.equal(driveReason, "guardrail-contact");
const office = new OfficeNavEnvironment();
office.reset(2, "train-hangar-crossing");
let officeReason: string | null = null;
for (let index = 0; index < office.manifest.maxSteps; index += 1) {
const result = office.step({ x: 0, z: 1 });
officeReason = result.info.terminalReason;
if (result.terminated) break;
}
assert.equal(officeReason, "collision-stall");
const crow = new CrowNavEnvironment();
crow.reset(2, "train-east-crosswind");
let crowReason: string | null = null;
for (let index = 0; index < crow.manifest.maxSteps; index += 1) {
const result = crow.step({ forward: 1, turn: 0, pitch: 0, climb: 1, glide: false });
crowReason = result.info.terminalReason;
if (result.terminated) break;
}
assert.equal(crowReason, "flight-envelope-contact");
const flight = new CaliforniaFlightEnvironment();
flight.reset(2, "train-la-east-leg");
let flightReason: string | null = null;
for (let index = 0; index < flight.manifest.maxSteps; index += 1) {
const result = flight.step({ throttle: 1, yaw: 0, pitch: 1, roll: 0 });
flightReason = result.info.terminalReason;
if (result.terminated) break;
}
assert.equal(flightReason, "flight-envelope-contact");
});
});
describe("arena snapshot, trace and replay", () => {
it("restores each simulator bit-for-bit and preserves the next transition", () => {
for (const entry of CASES) {
const environment = entry.create();
let observation = environment.reset(91, entry.registry.ids("dev")[0]!).observation;
for (let index = 0; index < 12; index += 1) {
observation = environment.step(entry.scripted(observation)).observation;
}
const checkpoint = environment.snapshot();
const action = entry.scripted(observation);
const expected = environment.step(action);
const restored = environment.restore(checkpoint);
assert.equal(restored.info.step, checkpoint.step);
const actual = environment.step(action);
assert.deepEqual(actual, expected, entry.name);
}
});
it("replays checksummed action traces to the exact final state", () => {
for (const entry of CASES) {
const environment = entry.create();
let observation = environment.reset(144, entry.registry.ids("train")[0]!).observation;
for (let index = 0; index < 30; index += 1) {
const result = environment.step(entry.scripted(observation));
observation = result.observation;
if (result.terminated) break;
}
const trace = environment.trace();
assert.equal(arenaChecksum({ ...trace, checksum: undefined }), trace.checksum);
const replay = entry.create().replay(trace);
assert.equal(replay.finalStateChecksum, trace.finalStateChecksum, entry.name);
assert.equal(replay.cumulativeReward, trace.cumulativeReward, entry.name);
assert.equal(replay.steps, trace.steps.length, entry.name);
}
});
it("rejects tampered snapshots and traces before applying them", () => {
const environment = new Drive101Environment();
environment.reset(7, "train-us101-ventura");
environment.step(driveScriptedBaseline());
const snapshot = environment.snapshot();
assert.throws(
() => environment.restore({ ...snapshot, cumulativeReward: snapshot.cumulativeReward + 1 }),
/checksum mismatch/,
);
const trace = environment.trace();
assert.throws(
() => new Drive101Environment().replay({ ...trace, cumulativeReward: trace.cumulativeReward + 1 }),
/checksum mismatch/,
);
const invalidCore = { ...snapshot, step: -1 };
const { checksum: _oldChecksum, ...invalidWithoutChecksum } = invalidCore;
assert.throws(
() => environment.restore({
...invalidWithoutChecksum,
checksum: arenaChecksum(invalidWithoutChecksum),
}),
/episode state is invalid/,
);
const invalidFrames = trace.steps.map((frame, index) => ({
...frame,
index: index === 0 ? 2 : frame.index,
}));
const { checksum: _traceChecksum, ...invalidTraceCore } = { ...trace, steps: invalidFrames };
assert.throws(
() => new Drive101Environment().replay({
...invalidTraceCore,
checksum: arenaChecksum(invalidTraceCore),
}),
/frame 1 is invalid/,
);
});
it("refuses post-terminal stepping until reset", () => {
const environment = new OfficeNavEnvironment();
let observation = environment.reset(5, "train-galley-aisle").observation;
for (;;) {
const result = environment.step(officeNavScriptedBaseline(observation));
observation = result.observation;
if (result.terminated) break;
}
assert.throws(() => environment.step(OFFICE_NAV_INACTION), /episode is complete/);
});
});
+18
View File
@@ -257,6 +257,24 @@ export class VehicleController {
return { ...this.current };
}
/** Restore a trusted JSON snapshot for deterministic environment checkpointing. */
restore(snapshot: VehicleControllerSnapshot): void {
const numeric = Object.entries(snapshot)
.filter(([, value]) => typeof value === "number")
.every(([, value]) => Number.isFinite(value));
if (
!numeric || snapshot.routeId !== this.path.route.id ||
snapshot.direction !== this.options.direction ||
(snapshot.mode !== "manual" && snapshot.mode !== "assisted") ||
snapshot.distanceM < 0 || snapshot.distanceM > this.path.lengthM ||
Math.abs(snapshot.lateralOffsetM) > this.options.guardrailOffsetM ||
snapshot.speedMps < 0 || snapshot.speedMps > this.options.maximumSpeedMps ||
!Number.isSafeInteger(snapshot.elapsedSteps) || snapshot.elapsedSteps < 0
) throw new RangeError("vehicle snapshot is incompatible or invalid");
Object.assign(this.current, snapshot);
this.accumulator = 0;
}
/** Restore the configured spawn state and clear pending fractional time. */
reset(): void {
this.accumulator = 0;