From 747744fa6c358d701913ea298126a0815ca71897 Mon Sep 17 00:00:00 2001 From: Kartios Date: Wed, 19 Aug 2026 01:10:30 -0700 Subject: [PATCH] feat(arena): add deterministic headless RL environments --- .gitea/workflows/gates.yml | 3 + ARENA.md | 106 +++++++++ PROVENANCE.json | 7 + README.md | 14 ++ package.json | 6 + scripts/check-arena-source-hashes.mjs | 75 ++++++ src/actors/controller.ts | 18 ++ src/aircraft/controller.ts | 19 ++ src/arena/base.ts | 273 ++++++++++++++++++++++ src/arena/californiaFlight.ts | 315 +++++++++++++++++++++++++ src/arena/checksum.ts | 44 ++++ src/arena/crowNav.ts | 285 +++++++++++++++++++++++ src/arena/drive101.ts | 253 +++++++++++++++++++++ src/arena/index.ts | 79 +++++++ src/arena/officeNav.ts | 250 ++++++++++++++++++++ src/arena/random.ts | 35 +++ src/arena/scenarios.ts | 75 ++++++ src/arena/sourceHashes.ts | 24 ++ src/arena/types.ts | 146 ++++++++++++ src/index.ts | 10 + src/interiors/walker.ts | 17 +- src/test/arena.test.ts | 316 ++++++++++++++++++++++++++ src/transport/vehicleController.ts | 18 ++ 23 files changed, 2387 insertions(+), 1 deletion(-) create mode 100644 ARENA.md create mode 100644 scripts/check-arena-source-hashes.mjs create mode 100644 src/arena/base.ts create mode 100644 src/arena/californiaFlight.ts create mode 100644 src/arena/checksum.ts create mode 100644 src/arena/crowNav.ts create mode 100644 src/arena/drive101.ts create mode 100644 src/arena/index.ts create mode 100644 src/arena/officeNav.ts create mode 100644 src/arena/random.ts create mode 100644 src/arena/scenarios.ts create mode 100644 src/arena/sourceHashes.ts create mode 100644 src/arena/types.ts create mode 100644 src/index.ts create mode 100644 src/test/arena.test.ts diff --git a/.gitea/workflows/gates.yml b/.gitea/workflows/gates.yml index 180d49e..020f669 100644 --- a/.gitea/workflows/gates.yml +++ b/.gitea/workflows/gates.yml @@ -67,6 +67,9 @@ jobs: - name: client tests run: npm test + - name: arena source hashes + run: npm run arena:source-hashes + - name: server tests run: npm test --workspace @lumbridge/tera-api diff --git a/ARENA.md b/ARENA.md new file mode 100644 index 0000000..ba81437 --- /dev/null +++ b/ARENA.md @@ -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 +``` diff --git a/PROVENANCE.json b/PROVENANCE.json index 0c82d30..da10b6e 100644 --- a/PROVENANCE.json +++ b/PROVENANCE.json @@ -81,6 +81,13 @@ "origin": "original", "sourceLicense": "Apache-2.0", "lineage": "Original coarse simulation graph; every node, segment, route, and anchor carries a provenance field validated by tests." + }, + { + "path": "src/arena/", + "kind": "renderer-independent-reinforcement-learning-environments", + "origin": "original", + "sourceLicense": "Apache-2.0", + "lineage": "Original environment contracts and reward/scenario layers wrapping Tera's repository-authored deterministic controllers and plans; source hashes are pinned and checked in CI." } ] } diff --git a/README.md b/README.md index 100b29f..0ec2726 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,19 @@ npm install npm run dev ``` +## Headless RL environments + +Tera also exports a versioned, renderer-independent Arena contract with four +deterministic environments: US-101/I-5 driving, Frontier Valley office +navigation, crow waypoint flight, and California electric-aircraft flight. +They share the client controllers and office plan, but require no canvas, DOM, +Three.js scene, network service, or new runtime dependency. + +Import them from `@lumbridge/tera/arena`. Seeded train/dev scenarios, +component rewards, safety terminals, maximum steps, snapshots, checksummed +traces, exact replay and executable inaction/scripted baseline proofs are +documented in [ARENA.md](ARENA.md). + ## Using the engine ```ts @@ -137,6 +150,7 @@ answer is an RTL-SDR receiver: first-party data with nothing to comply with. src/engine/ renderer — terrain, blocks, structures, markers, flights, scene, minimap src/cities/ data packs — pure geography, no code src/transport/ serializable route packs and renderer-independent simulation +src/arena/ versioned headless RL contract, scenarios, traces and environments src/assets/ original procedural asset library src/adapters/ where outside data plugs in src/tools/ instruments — god-only, dynamically imported, never statically diff --git a/package.json b/package.json index 1c316eb..34ebff3 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,18 @@ "license": "Apache-2.0", "type": "module", "main": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./arena": "./src/arena/index.ts", + "./*": "./src/*" + }, "workspaces": [ "server" ], "scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", + "arena:source-hashes": "node scripts/check-arena-source-hashes.mjs", "provenance": "node scripts/check-provenance.mjs && node scripts/check-no-binaries.mjs", "licenses": "node scripts/check-dependency-licenses.mjs", "sbom": "npm sbom --package-lock-only --sbom-format=spdx", diff --git a/scripts/check-arena-source-hashes.mjs b/scripts/check-arena-source-hashes.mjs new file mode 100644 index 0000000..f9524c4 --- /dev/null +++ b/scripts/check-arena-source-hashes.mjs @@ -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; diff --git a/src/actors/controller.ts b/src/actors/controller.ts index 9b61ee1..69e9eb0 100644 --- a/src/actors/controller.ts +++ b/src/actors/controller.ts @@ -396,6 +396,24 @@ export class ActorController { return { ...this.current, identity: copyIdentity(this.current.identity) }; } + /** Restore a trusted JSON snapshot for deterministic environment checkpointing. */ + restore(snapshot: ActorControllerSnapshot): void { + const numeric = Object.entries(snapshot) + .filter(([, value]) => typeof value === "number") + .every(([, value]) => Number.isFinite(value)); + if ( + !numeric || !validKind(snapshot.kind) || + (snapshot.mode !== "ground" && snapshot.mode !== "flight") || + (snapshot.altitudeBoundContact !== "none" && + snapshot.altitudeBoundContact !== "minimum" && snapshot.altitudeBoundContact !== "maximum") || + !Number.isSafeInteger(snapshot.elapsedSteps) || snapshot.elapsedSteps < 0 + ) throw new RangeError("actor snapshot is incompatible or invalid"); + Object.assign(this.current, snapshot, { identity: checkedIdentity(snapshot.identity) }); + this.applyHorizontalBounds(); + if (this.current.mode === "flight" && this.current.kind === "crow") this.applyAltitudeBounds(); + this.accumulator = 0; + } + /** Replace profile/identity without changing kind, pose or position. */ setIdentity(identity: ActorIdentity): void { this.current.identity = checkedIdentity(identity); diff --git a/src/aircraft/controller.ts b/src/aircraft/controller.ts index f4572a2..1c2a158 100644 --- a/src/aircraft/controller.ts +++ b/src/aircraft/controller.ts @@ -334,6 +334,25 @@ export class AircraftController { return { ...this.current }; } + /** Restore a trusted JSON snapshot for deterministic environment checkpointing. */ + restore(snapshot: AircraftControllerSnapshot): void { + const numeric = Object.entries(snapshot) + .filter(([, value]) => typeof value === "number") + .every(([, value]) => Number.isFinite(value)); + const envelope = this.options.envelope; + if ( + !numeric || (snapshot.mode !== "manual" && snapshot.mode !== "assisted") || + snapshot.lat < envelope.minLat || snapshot.lat > envelope.maxLat || + snapshot.lng < envelope.minLng || snapshot.lng > envelope.maxLng || + snapshot.altitudeM < envelope.minAltitudeM || snapshot.altitudeM > envelope.maxAltitudeM || + snapshot.speedMps < this.options.minimumSpeedMps || + snapshot.speedMps > this.options.maximumSpeedMps || + !Number.isSafeInteger(snapshot.elapsedSteps) || snapshot.elapsedSteps < 0 + ) throw new RangeError("aircraft snapshot is incompatible or invalid"); + Object.assign(this.current, snapshot); + this.accumulator = 0; + } + reset(): void { this.accumulator = 0; Object.assign(this.current, this.options.initialPosition, { diff --git a/src/arena/base.ts b/src/arena/base.ts new file mode 100644 index 0000000..5645665 --- /dev/null +++ b/src/arena/base.ts @@ -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> { + 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, + S, + P extends object, +> implements ArenaEnvironment { + abstract readonly manifest: ArenaManifest; + protected abstract readonly registry: ArenaScenarioRegistry

; + protected abstract readonly sourceHashes: ArenaSourceHashes; + + private scenario: ArenaScenario

| null = null; + private stepIndex = 0; + private cumulativeReward = 0; + private terminated = false; + private truncated = false; + private terminalReason: string | null = null; + private initialStateChecksum = ""; + private frames: ArenaTraceStep[] = []; + + protected abstract resetSimulation(scenario: ArenaScenario

): O; + protected abstract normalizeAction(action: A): A; + protected abstract advanceSimulation(action: A): SimulationTransition; + protected abstract simulationSnapshot(): S; + protected abstract restoreSimulation(snapshot: S): O; + + reset(seed: number, request: string | ArenaScenarioRequest): ArenaResetResult { + 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 { + 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 { + 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): ArenaResetResult { + 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 { + 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): ArenaReplayResult { + 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

{ + return this.requireReset(); + } + + protected currentStep(): number { + return this.stepIndex; + } + + private requireReset(): ArenaScenario

{ + 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, + }; + } +} diff --git a/src/arena/californiaFlight.ts b/src/arena/californiaFlight.ts new file mode 100644 index 0000000..49ac17f --- /dev/null +++ b/src/arena/californiaFlight.ts @@ -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( + "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 = 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): 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 { + 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; + } +} diff --git a/src/arena/checksum.ts b/src/arena/checksum.ts new file mode 100644 index 0000000..f9c03b9 --- /dev/null +++ b/src/arena/checksum.ts @@ -0,0 +1,44 @@ +/** Canonical JSON and a small cross-runtime checksum (no Node or Web APIs). */ + +function canonical(value: unknown, stack: Set): 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; + 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")}`; +} diff --git a/src/arena/crowNav.ts b/src/arena/crowNav.ts new file mode 100644 index 0000000..6c75b74 --- /dev/null +++ b/src/arena/crowNav.ts @@ -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( + "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 = 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): 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 { + 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; + } +} diff --git a/src/arena/drive101.ts b/src/arena/drive101.ts new file mode 100644 index 0000000..6a3e030 --- /dev/null +++ b/src/arena/drive101.ts @@ -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( + "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 = 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): 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 { + 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; + } +} diff --git a/src/arena/index.ts b/src/arena/index.ts new file mode 100644 index 0000000..d350b54 --- /dev/null +++ b/src/arena/index.ts @@ -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, +]); diff --git a/src/arena/officeNav.ts b/src/arena/officeNav.ts new file mode 100644 index 0000000..608bf01 --- /dev/null +++ b/src/arena/officeNav.ts @@ -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( + "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 = 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): 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 { + 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; + } +} diff --git a/src/arena/random.ts b/src/arena/random.ts new file mode 100644 index 0000000..e7b8b54 --- /dev/null +++ b/src/arena/random.ts @@ -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; +} diff --git a/src/arena/scenarios.ts b/src/arena/scenarios.ts new file mode 100644 index 0000000..07f9be7 --- /dev/null +++ b/src/arena/scenarios.ts @@ -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

{ + id: string; + split: ArenaSplit; + parameters: P; +} + +export type ScenarioSampler

= (parameters: Readonly

, random: ArenaRandom) => P; + +/** Immutable public scenario catalogue with seeded, reproducible materialization. */ +export class ArenaScenarioRegistry

{ + readonly envId: string; + readonly definitions: readonly ArenaScenarioDefinition

[]; + private readonly byId = new Map>(); + private readonly sampler: ScenarioSampler

; + + constructor( + envId: string, + definitions: readonly ArenaScenarioDefinition

[], + sampler: ScenarioSampler

, + ) { + 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

{ + const seed = normalizeArenaSeed(seedValue); + let definition: ArenaScenarioDefinition

| 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 }; + } +} diff --git a/src/arena/sourceHashes.ts b/src/arena/sourceHashes.ts new file mode 100644 index 0000000..43dd41e --- /dev/null +++ b/src/arena/sourceHashes.ts @@ -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> = 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", + }, +}); diff --git a/src/arena/types.ts b/src/arena/types.ts new file mode 100644 index 0000000..3f8ed76 --- /dev/null +++ b/src/arena/types.ts @@ -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

> { + 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 { + observation: O; + info: ArenaInfo; +} + +export interface ArenaStepResult> { + 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>; + safetyTerminals: readonly string[]; + scenarioIds: Readonly>; + baselines: { + inaction: string; + scripted: string; + }; +} + +export interface ArenaSnapshot { + 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> { + index: number; + action: A; + reward: number; + rewardComponents: R; + terminated: boolean; + truncated: boolean; + terminalReason: string | null; + stateChecksum: string; +} + +export interface ArenaTraceEnvelope> { + 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[]; + finalStateChecksum: string; + cumulativeReward: number; + checksum: string; +} + +export interface ArenaReplayResult { + observation: O; + steps: number; + cumulativeReward: number; + finalStateChecksum: string; +} + +export interface ArenaEnvironment< + A, + O, + R extends Record, + S, +> { + readonly manifest: ArenaManifest; + reset(seed: number, scenario: string | ArenaScenarioRequest): ArenaResetResult; + step(action: A): ArenaStepResult; + snapshot(): ArenaSnapshot; + restore(snapshot: ArenaSnapshot): ArenaResetResult; + trace(): ArenaTraceEnvelope; + replay(trace: ArenaTraceEnvelope): ArenaReplayResult; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..d280723 --- /dev/null +++ b/src/index.ts @@ -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"; diff --git a/src/interiors/walker.ts b/src/interiors/walker.ts index 03f6ff0..0a9774a 100644 --- a/src/interiors/walker.ts +++ b/src/interiors/walker.ts @@ -67,6 +67,8 @@ export interface WalkerController { tick(elapsedSeconds: number, action: WalkerAction): WalkerState; /** Return to the original spawn, or atomically adopt another valid spawn. */ reset(spawn?: WalkerSpawn): WalkerState; + /** Restore a trusted JSON snapshot without changing the configured spawn. */ + restore(snapshot: WalkerState): WalkerState; } /** @@ -138,7 +140,20 @@ export function createWalker(plan: WalkerPlan, options: WalkerOptions): WalkerCo return snapshot(); } - return { state: snapshot, tick, reset }; + function restore(next: WalkerState): WalkerState { + if ( + next.levelId !== spawn.levelId || !finitePoint(next.position) || !finitePoint(next.facing) || + !Number.isFinite(next.distance) || next.distance < 0 || + !validPosition(plan, next.levelId, next.position, radius) + ) throw new RangeError("walker snapshot is incompatible or invalid"); + position = copy(next.position); + facing = normalizedFacing(next.facing); + distance = next.distance; + accumulator = 0; + return snapshot(); + } + + return { state: snapshot, tick, reset, restore }; } function normalizedFacing(value: Point2 | undefined): Point2 { diff --git a/src/test/arena.test.ts b/src/test/arena.test.ts new file mode 100644 index 0000000..1f093ec --- /dev/null +++ b/src/test/arena.test.ts @@ -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; +type AnyEnvironment = ArenaEnvironment; + +interface EnvironmentCase { + name: string; + create(): AnyEnvironment; + registry: ArenaScenarioRegistry; + inaction: unknown; + scripted(observation: unknown): unknown; +} + +const CASES: EnvironmentCase[] = [ + { + name: "drive", + create: () => new Drive101Environment() as AnyEnvironment, + registry: DRIVE_101_SCENARIOS as ArenaScenarioRegistry, + inaction: DRIVE_INACTION, + scripted: () => driveScriptedBaseline(), + }, + { + name: "office", + create: () => new OfficeNavEnvironment() as AnyEnvironment, + registry: OFFICE_NAV_SCENARIOS as ArenaScenarioRegistry, + inaction: OFFICE_NAV_INACTION, + scripted: (observation) => officeNavScriptedBaseline( + observation as Parameters[0], + ), + }, + { + name: "crow", + create: () => new CrowNavEnvironment() as AnyEnvironment, + registry: CROW_NAV_SCENARIOS as ArenaScenarioRegistry, + inaction: CROW_NAV_INACTION, + scripted: (observation) => crowNavScriptedBaseline( + observation as Parameters[0], + ), + }, + { + name: "flight", + create: () => new CaliforniaFlightEnvironment() as AnyEnvironment, + registry: CALIFORNIA_FLIGHT_SCENARIOS as ArenaScenarioRegistry, + inaction: CALIFORNIA_FLIGHT_INACTION, + scripted: (observation) => californiaFlightScriptedBaseline( + observation as Parameters[0], + ), + }, +]; + +function run( + entry: EnvironmentCase, + scenarioId: string, + seed: number, + policy: (observation: unknown) => unknown, +): { total: number; final: ArenaStepResult } { + const environment = entry.create(); + let observation = environment.reset(seed, scenarioId).observation; + let total = 0; + let final: ArenaStepResult | 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/); + }); +}); diff --git a/src/transport/vehicleController.ts b/src/transport/vehicleController.ts index a6c0739..4d1672d 100644 --- a/src/transport/vehicleController.ts +++ b/src/transport/vehicleController.ts @@ -257,6 +257,24 @@ export class VehicleController { return { ...this.current }; } + /** Restore a trusted JSON snapshot for deterministic environment checkpointing. */ + restore(snapshot: VehicleControllerSnapshot): void { + const numeric = Object.entries(snapshot) + .filter(([, value]) => typeof value === "number") + .every(([, value]) => Number.isFinite(value)); + if ( + !numeric || snapshot.routeId !== this.path.route.id || + snapshot.direction !== this.options.direction || + (snapshot.mode !== "manual" && snapshot.mode !== "assisted") || + snapshot.distanceM < 0 || snapshot.distanceM > this.path.lengthM || + Math.abs(snapshot.lateralOffsetM) > this.options.guardrailOffsetM || + snapshot.speedMps < 0 || snapshot.speedMps > this.options.maximumSpeedMps || + !Number.isSafeInteger(snapshot.elapsedSteps) || snapshot.elapsedSteps < 0 + ) throw new RangeError("vehicle snapshot is incompatible or invalid"); + Object.assign(this.current, snapshot); + this.accumulator = 0; + } + /** Restore the configured spawn state and clear pending fractional time. */ reset(): void { this.accumulator = 0;