1
0

feat: add deterministic office robot jobs

This commit is contained in:
2026-08-19 02:54:49 -07:00
parent 0557a26e6b
commit e378a03740
18 changed files with 1937 additions and 1779 deletions
+356
View File
@@ -0,0 +1,356 @@
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 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-l1-inspector", jobId: "sf-l1-inspect-display" },
},
{
id: "train-la-directory-inspection",
split: "train" as const,
parameters: { officeId: "mateo-court" as const, robotId: "la-l1-inspector", jobId: "la-l1-inspect-directory" },
},
{
id: "dev-sf-parcel-delivery",
split: "dev" as const,
parameters: { officeId: "lumbridge-hq" as const, robotId: "sf-l1-courier", jobId: "sf-l1-deliver" },
},
{
id: "dev-la-loft-delivery",
split: "dev" as const,
parameters: { officeId: "mateo-court" as const, robotId: "la-l2-courier", jobId: "la-l2-deliver" },
},
] as const;
export const OFFICE_JOBS_SCENARIOS = new ArenaScenarioRegistry<OfficeJobsScenarioParameters>(
"office-jobs-v1",
DEFINITIONS,
(parameters) => ({ ...parameters }),
);
export const OFFICE_JOBS_MANIFEST: ArenaManifest = Object.freeze({
apiVersion: ARENA_API_VERSION,
id: "office-jobs-v1",
version: 1,
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",
],
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<OfficeJobsAction> = 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.hypot(observation.deltaX, 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<OfficeJobsScenarioParameters>,
): 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.hypot(x, z);
return {
x: length > 1 ? x / length : x,
z: length > 1 ? z / length : z,
interact: action?.interact === true,
};
}
protected advanceSimulation(
action: OfficeJobsAction,
): SimulationTransition<OfficeJobsObservation, OfficeJobsReward> {
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 moved = Math.hypot(after.position.x - beforePosition.x, after.position.z - beforePosition.z);
const demand = Math.hypot(action.x, 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);
return station ? Math.hypot(station.position.x - robot.position.x, station.position.z - robot.position.z) : 0;
}
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 };
}