feat(arena): add deterministic headless RL environments
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
/** 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 {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/** 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;
|
||||
|
||||
Reference in New Issue
Block a user