feat(arena): add deterministic headless RL environments
This commit is contained in:
@@ -67,6 +67,9 @@ jobs:
|
|||||||
- name: client tests
|
- name: client tests
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
|
- name: arena source hashes
|
||||||
|
run: npm run arena:source-hashes
|
||||||
|
|
||||||
- name: server tests
|
- name: server tests
|
||||||
run: npm test --workspace @lumbridge/tera-api
|
run: npm test --workspace @lumbridge/tera-api
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -81,6 +81,13 @@
|
|||||||
"origin": "original",
|
"origin": "original",
|
||||||
"sourceLicense": "Apache-2.0",
|
"sourceLicense": "Apache-2.0",
|
||||||
"lineage": "Original coarse simulation graph; every node, segment, route, and anchor carries a provenance field validated by tests."
|
"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."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,19 @@ npm install
|
|||||||
npm run dev
|
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
|
## Using the engine
|
||||||
|
|
||||||
```ts
|
```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/engine/ renderer — terrain, blocks, structures, markers, flights, scene, minimap
|
||||||
src/cities/ data packs — pure geography, no code
|
src/cities/ data packs — pure geography, no code
|
||||||
src/transport/ serializable route packs and renderer-independent simulation
|
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/assets/ original procedural asset library
|
||||||
src/adapters/ where outside data plugs in
|
src/adapters/ where outside data plugs in
|
||||||
src/tools/ instruments — god-only, dynamically imported, never statically
|
src/tools/ instruments — god-only, dynamically imported, never statically
|
||||||
|
|||||||
@@ -5,12 +5,18 @@
|
|||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./arena": "./src/arena/index.ts",
|
||||||
|
"./*": "./src/*"
|
||||||
|
},
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"server"
|
"server"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc --noEmit && vite build",
|
"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",
|
"provenance": "node scripts/check-provenance.mjs && node scripts/check-no-binaries.mjs",
|
||||||
"licenses": "node scripts/check-dependency-licenses.mjs",
|
"licenses": "node scripts/check-dependency-licenses.mjs",
|
||||||
"sbom": "npm sbom --package-lock-only --sbom-format=spdx",
|
"sbom": "npm sbom --package-lock-only --sbom-format=spdx",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -396,6 +396,24 @@ export class ActorController {
|
|||||||
return { ...this.current, identity: copyIdentity(this.current.identity) };
|
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. */
|
/** Replace profile/identity without changing kind, pose or position. */
|
||||||
setIdentity(identity: ActorIdentity): void {
|
setIdentity(identity: ActorIdentity): void {
|
||||||
this.current.identity = checkedIdentity(identity);
|
this.current.identity = checkedIdentity(identity);
|
||||||
|
|||||||
@@ -334,6 +334,25 @@ export class AircraftController {
|
|||||||
return { ...this.current };
|
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 {
|
reset(): void {
|
||||||
this.accumulator = 0;
|
this.accumulator = 0;
|
||||||
Object.assign(this.current, this.options.initialPosition, {
|
Object.assign(this.current, this.options.initialPosition, {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")}`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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
@@ -67,6 +67,8 @@ export interface WalkerController {
|
|||||||
tick(elapsedSeconds: number, action: WalkerAction): WalkerState;
|
tick(elapsedSeconds: number, action: WalkerAction): WalkerState;
|
||||||
/** Return to the original spawn, or atomically adopt another valid spawn. */
|
/** Return to the original spawn, or atomically adopt another valid spawn. */
|
||||||
reset(spawn?: WalkerSpawn): WalkerState;
|
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 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 {
|
function normalizedFacing(value: Point2 | undefined): Point2 {
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -257,6 +257,24 @@ export class VehicleController {
|
|||||||
return { ...this.current };
|
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. */
|
/** Restore the configured spawn state and clear pending fractional time. */
|
||||||
reset(): void {
|
reset(): void {
|
||||||
this.accumulator = 0;
|
this.accumulator = 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user