import { Plan } from "../interiors/plan.ts"; import { createRobotActivity, type RobotActivityController, type RobotActivitySnapshot, type RobotActivityState, } from "../interiors/robotActivity.ts"; import { resolveRobotOperations, type ResolvedRobotOperations, type RobotJobKind, } from "../interiors/robotOperations.ts"; import { LUMBRIDGE_HQ } from "../offices/lumbridge-hq.ts"; import { MATEO_COURT } from "../offices/mateo-court.ts"; import { LUMBRIDGE_HQ_ROBOT_OPERATIONS } from "../offices/operations/lumbridge-hq.ts"; import { MATEO_COURT_ROBOT_OPERATIONS } from "../offices/operations/mateo-court.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 ArenaFieldSpec, type ArenaManifest, type ArenaScenario } from "./types.ts"; export interface OfficeJobsAction { x: number; z: number; interact: boolean; } export interface OfficeJobsObservation { officeId: string; robotId: string; levelId: string; x: number; z: number; mode: string; phase: string; jobKind: RobotJobKind | "none"; payload: "parcel" | null; battery: number; jobProgress: number; nextStationId: string; nextX: number; nextZ: number; deltaX: number; deltaZ: number; distanceToNextM: number; canInteract: boolean; blockedStreak: number; recoveryCount: number; completedJobs: number; } export type OfficeJobsReward = Record< "navigation" | "job" | "success" | "time" | "control" | "collision" | "interaction" | "safety", number >; interface OfficeJobsScenarioParameters { officeId: "lumbridge-hq" | "mateo-court"; robotId: string; jobId: string; } interface OfficeJobsSimulationSnapshot { activity: RobotActivitySnapshot; previousDistanceM: number; previousProgress: number; blockedStreak: number; wrongInteractions: number; initialCompletedJobs: number; } const MAX_STEPS = 720; // Slightly inside robotActivity's 0.30 m arrival radius so an agent never sees // an interaction affordance the authoritative controller would still reject. const INTERACTION_RADIUS_M = 0.295; const WRONG_INTERACTION_LIMIT = 8; const SF_PLAN = new Plan(LUMBRIDGE_HQ, { depth: "public", warn: false }); const LA_PLAN = new Plan(MATEO_COURT, { depth: "public", warn: false }); const SF_OPERATIONS = resolveRobotOperations(SF_PLAN, LUMBRIDGE_HQ_ROBOT_OPERATIONS); const LA_OPERATIONS = resolveRobotOperations(LA_PLAN, MATEO_COURT_ROBOT_OPERATIONS); const DEFINITIONS = [ { id: "train-sf-display-inspection", split: "train" as const, parameters: { officeId: "lumbridge-hq" as const, robotId: "sf-studio-activity-01", jobId: "sf-studio-inspect-display" }, }, { id: "train-la-directory-inspection", split: "train" as const, parameters: { officeId: "mateo-court" as const, robotId: "la-office-activity-01", jobId: "la-l1-inspect-directory" }, }, { id: "dev-sf-parcel-delivery", split: "dev" as const, parameters: { officeId: "lumbridge-hq" as const, robotId: "sf-studio-activity-01", jobId: "sf-studio-deliver" }, }, { id: "dev-la-loft-delivery", split: "dev" as const, parameters: { officeId: "mateo-court" as const, robotId: "la-office-activity-02", jobId: "la-l2-deliver" }, }, ] as const; export const OFFICE_JOBS_SCENARIOS = new ArenaScenarioRegistry( "office-jobs-v1", DEFINITIONS, (parameters) => ({ ...parameters }), ); export const OFFICE_JOBS_MANIFEST: ArenaManifest = Object.freeze({ apiVersion: ARENA_API_VERSION, id: "office-jobs-v1", // 2 rather than 1: `ArenaScenarioRegistry` now selects a bare `{ split }` // request by hashing the seed against each scenario id instead of indexing // definition order, so a seed run before that change may resolve to a // different scenario after it. `version` is what a snapshot, a trace and a // results table are pinned to, and a selection change that nothing recorded // is exactly the silent remap the new selector exists to prevent. version: 2, title: "Seeded office robot jobs", description: "Headless job execution over explicit simulated SF/LA operations and resolved office collision.", simulator: "Plan + robotRoutes + fixed-step robotActivity", fixedStepSeconds: 0.1, maxSteps: MAX_STEPS, actionFields: ["x", "z", "interact"], observationFields: [ "officeId", "robotId", "levelId", "x", "z", "mode", "phase", "jobKind", "payload", "battery", "jobProgress", "nextStationId", "nextX", "nextZ", "deltaX", "deltaZ", "distanceToNextM", "canInteract", "blockedStreak", "recoveryCount", "completedJobs", ], actionSpace: [ { name: "x", kind: "float", low: -1, high: 1, unit: "normalized drive demand" }, { name: "z", kind: "float", low: -1, high: 1, unit: "normalized drive demand" }, { name: "interact", kind: "bool" }, ] satisfies readonly ArenaFieldSpec[], // `mode`, `phase`, `jobKind` and `payload` are closed vocabularies in // `robotActivity.ts` and `robotOperations.ts` and are one-hot here rather than // hashed: a controller with five modes that a trainer sees as five unrelated // reals is a controller a trainer cannot condition on. observationSpace: [ { name: "officeId", kind: "enum", values: ["lumbridge-hq", "mateo-court"] }, { name: "robotId", kind: "id" }, { name: "levelId", kind: "enum", values: ["level-1", "level-2"] }, { name: "x", kind: "float", low: 0, high: 40, unit: "m" }, { name: "z", kind: "float", low: 0, high: 40, unit: "m" }, // `RobotActivityMode` in robotOperations.ts, member for member. { name: "mode", kind: "enum", values: ["idle", "patrol", "deliver", "inspect", "charge", "blocked-recovery"], }, // Every string `navigationPhase`, `activityPhase` and the recovery paths in // robotActivity.ts can assign. `phase` is typed `string` there rather than // as a union, so this list is a transcription and the spaces test walks a // rollout of every scenario asserting nothing outside it is ever observed. { name: "phase", kind: "enum", values: [ "scheduled-idle", "patrolling", "patrol-check", "to-pickup", "loading-parcel", "to-dropoff", "delivering-parcel", "to-inspection", "inspecting", "to-charge", "charging", "awaiting-interaction", "replanning", "backoff-and-replan", "terminal", ], }, // `RobotJobKind` plus the `"none"` this observation substitutes for `null`. { name: "jobKind", kind: "enum", values: ["none", "patrol", "deliver", "inspect", "charge"], }, // One member and a legal absence: `payload` is `"parcel" | null`, and a null // encodes as the zero vector rather than as a second category. That is the // documented meaning of an out-of-vocabulary value and it is the right one // here — "carrying nothing" is not a thing being carried. { name: "payload", kind: "enum", values: ["parcel"] }, { name: "battery", kind: "float", low: 0, high: 1, unit: "fraction" }, { name: "jobProgress", kind: "float", low: 0, high: 1, unit: "fraction" }, { name: "nextStationId", kind: "id" }, { name: "nextX", kind: "float", low: 0, high: 40, unit: "m" }, { name: "nextZ", kind: "float", low: 0, high: 40, unit: "m" }, { name: "deltaX", kind: "float", low: -40, high: 40, unit: "m" }, { name: "deltaZ", kind: "float", low: -40, high: 40, unit: "m" }, { name: "distanceToNextM", kind: "float", low: 0, high: 60, unit: "m" }, { name: "canInteract", kind: "bool" }, { name: "blockedStreak", kind: "float", low: 0, high: 720, unit: "steps" }, { name: "recoveryCount", kind: "float", low: 0, high: 8, unit: "count" }, { name: "completedJobs", kind: "float", low: 0, high: 32, unit: "count" }, ] satisfies readonly ArenaFieldSpec[], rewardComponents: { navigation: "Bounded reduction in distance to the current authored job station.", job: "Dense progress through pickup, delivery, inspection, or charge phases.", success: "Sparse bonus for completing the assigned job.", time: "Per-step pressure that keeps inaction below zero.", control: "Small cost on planar action magnitude.", collision: "Cost for commanded movement that fails against resolved collision.", interaction: "Penalty for interaction away from the current work station.", safety: "Terminal collision-stall or repeated wrong-interaction penalty.", }, safetyTerminals: ["collision-stall", "wrong-interaction-limit"], scenarioIds: { train: OFFICE_JOBS_SCENARIOS.ids("train"), dev: OFFICE_JOBS_SCENARIOS.ids("dev"), }, baselines: { inaction: "x=0,z=0,interact=false never completes a job and accumulates the time floor.", scripted: "Follow the exposed current route waypoint and interact only inside the station radius.", }, }); export const OFFICE_JOBS_INACTION: Readonly = Object.freeze({ x: 0, z: 0, interact: false, }); export function officeJobsScriptedBaseline(observation: OfficeJobsObservation): OfficeJobsAction { if (observation.canInteract) return { x: 0, z: 0, interact: true }; const length = Math.sqrt( observation.deltaX * observation.deltaX + observation.deltaZ * observation.deltaZ, ); if (length <= 1e-9) return { x: 0, z: 0, interact: false }; const magnitude = Math.min(1, length / (1.05 * OFFICE_JOBS_MANIFEST.fixedStepSeconds)); return { x: observation.deltaX / length * magnitude, z: observation.deltaZ / length * magnitude, interact: false, }; } export class OfficeJobsEnvironment extends BaseArenaEnvironment< OfficeJobsAction, OfficeJobsObservation, OfficeJobsReward, OfficeJobsSimulationSnapshot, OfficeJobsScenarioParameters > { readonly manifest = OFFICE_JOBS_MANIFEST; protected readonly registry = OFFICE_JOBS_SCENARIOS; protected readonly sourceHashes = ARENA_SOURCE_HASHES["office-jobs-v1"]!; private activity: RobotActivityController | null = null; private operations: ResolvedRobotOperations | null = null; private robotId = ""; private previousDistanceM = 0; private previousProgress = 0; private blockedStreak = 0; private wrongInteractions = 0; private initialCompletedJobs = 0; protected resetSimulation( scenario: ArenaScenario, ): OfficeJobsObservation { const selected = setupFor(scenario.parameters.officeId); this.operations = selected.operations; this.robotId = scenario.parameters.robotId; this.activity = createRobotActivity(selected.plan, selected.operations, { seed: scenario.seed, robotIds: [this.robotId], controlledRobotIds: [this.robotId], initialJobIds: { [this.robotId]: scenario.parameters.jobId }, }); const robot = this.robot(); this.previousDistanceM = this.distanceToNext(robot); this.previousProgress = robot.progress; this.blockedStreak = 0; this.wrongInteractions = 0; this.initialCompletedJobs = robot.completedJobs; return this.observation(); } protected normalizeAction(action: OfficeJobsAction): OfficeJobsAction { const x = Number.isFinite(action?.x) ? action.x : 0; const z = Number.isFinite(action?.z) ? action.z : 0; const length = Math.sqrt(x * x + z * z); return { x: length > 1 ? x / length : x, z: length > 1 ? z / length : z, interact: action?.interact === true, }; } protected advanceSimulation( action: OfficeJobsAction, ): SimulationTransition { const before = this.robot(); const beforePosition = { ...before.position }; const canInteract = this.canInteract(before); if (action.interact && !canInteract) this.wrongInteractions += 1; const after = this.requireActivity().step({ [this.robotId]: action }).robots[0]!; const movedX = after.position.x - beforePosition.x; const movedZ = after.position.z - beforePosition.z; const moved = Math.sqrt(movedX * movedX + movedZ * movedZ); const demand = Math.sqrt(action.x * action.x + action.z * action.z); const blocked = demand > 0.2 && moved < 0.012; this.blockedStreak = blocked ? this.blockedStreak + 1 : 0; const distance = this.distanceToNext(after); const navigationProgress = Math.max(-0.3, Math.min(0.3, this.previousDistanceM - distance)); const jobProgress = Math.max(-0.5, Math.min(0.5, after.progress - this.previousProgress)); this.previousDistanceM = distance; this.previousProgress = after.progress; const success = after.completedJobs > this.initialCompletedJobs; const collisionStall = after.mode === "blocked-recovery"; const wrongInteractionFailure = this.wrongInteractions >= WRONG_INTERACTION_LIMIT; const controllerFailure = after.terminalReason; const terminated = success || collisionStall || wrongInteractionFailure || controllerFailure !== null; const terminalReason = success ? "job-complete" : collisionStall ? "collision-stall" : wrongInteractionFailure ? "wrong-interaction-limit" : controllerFailure ?? undefined; return { observation: this.observation(), rewardComponents: { navigation: navigationProgress * 0.18, job: jobProgress * 1.2, success: success ? 8 : 0, time: -0.006, control: -0.001 * demand ** 2, collision: blocked ? -0.08 : 0, interaction: action.interact && !canInteract ? -0.12 : 0, safety: collisionStall || wrongInteractionFailure || controllerFailure ? -2 : 0, }, terminated, terminalReason, }; } protected simulationSnapshot(): OfficeJobsSimulationSnapshot { return { activity: this.requireActivity().snapshot(), previousDistanceM: this.previousDistanceM, previousProgress: this.previousProgress, blockedStreak: this.blockedStreak, wrongInteractions: this.wrongInteractions, initialCompletedJobs: this.initialCompletedJobs, }; } protected restoreSimulation(snapshot: OfficeJobsSimulationSnapshot): OfficeJobsObservation { this.resetSimulation(this.currentScenario()); if ( !Number.isFinite(snapshot.previousDistanceM) || !Number.isFinite(snapshot.previousProgress) || !Number.isSafeInteger(snapshot.blockedStreak) || snapshot.blockedStreak < 0 || !Number.isSafeInteger(snapshot.wrongInteractions) || snapshot.wrongInteractions < 0 || !Number.isSafeInteger(snapshot.initialCompletedJobs) || snapshot.initialCompletedJobs < 0 ) throw new Error("office jobs simulation snapshot is invalid"); this.requireActivity().restore(snapshot.activity); this.previousDistanceM = snapshot.previousDistanceM; this.previousProgress = snapshot.previousProgress; this.blockedStreak = snapshot.blockedStreak; this.wrongInteractions = snapshot.wrongInteractions; this.initialCompletedJobs = snapshot.initialCompletedJobs; return this.observation(); } private observation(): OfficeJobsObservation { const robot = this.robot(); const station = this.nextStation(robot); const waypoint = robot.route[robot.routeIndex] ?? station?.position ?? robot.position; const distance = station ? this.distanceToNext(robot) : 0; return { officeId: this.requireOperations().definition.officeId, robotId: robot.id, levelId: robot.levelId, x: robot.position.x, z: robot.position.z, mode: robot.mode, phase: robot.phase, jobKind: robot.activeJobKind ?? "none", payload: robot.payload, battery: robot.battery, jobProgress: robot.progress, nextStationId: station?.id ?? "", nextX: waypoint.x, nextZ: waypoint.z, deltaX: waypoint.x - robot.position.x, deltaZ: waypoint.z - robot.position.z, distanceToNextM: distance, canInteract: this.canInteract(robot), blockedStreak: this.blockedStreak, recoveryCount: robot.recoveryCount, completedJobs: robot.completedJobs, }; } private nextStation(robot: RobotActivityState) { const id = robot.activeStationIds[robot.stationIndex]; return id ? this.requireOperations().stations.get(id) : undefined; } private distanceToNext(robot: RobotActivityState): number { const station = this.nextStation(robot); if (!station) return 0; const dx = station.position.x - robot.position.x; const dz = station.position.z - robot.position.z; return Math.sqrt(dx * dx + dz * dz); } private canInteract(robot: RobotActivityState): boolean { return this.distanceToNext(robot) <= INTERACTION_RADIUS_M; } private robot(): RobotActivityState { const robot = this.requireActivity().states()[0]; if (!robot) throw new Error("office jobs environment has no controlled robot"); return robot; } private requireActivity(): RobotActivityController { if (!this.activity) throw new Error("office jobs environment has not been reset"); return this.activity; } private requireOperations(): ResolvedRobotOperations { if (!this.operations) throw new Error("office jobs environment has not been reset"); return this.operations; } } function setupFor(officeId: OfficeJobsScenarioParameters["officeId"]) { return officeId === "lumbridge-hq" ? { plan: SF_PLAN, operations: SF_OPERATIONS } : { plan: LA_PLAN, operations: LA_OPERATIONS }; }