feat: add deterministic office robot jobs
This commit is contained in:
+82
-5
@@ -12,15 +12,19 @@ import {
|
||||
DRIVE_INACTION,
|
||||
OFFICE_NAV_INACTION,
|
||||
OFFICE_NAV_SCENARIOS,
|
||||
OFFICE_JOBS_INACTION,
|
||||
OFFICE_JOBS_SCENARIOS,
|
||||
CaliforniaFlightEnvironment,
|
||||
CrowNavEnvironment,
|
||||
Drive101Environment,
|
||||
OfficeNavEnvironment,
|
||||
OfficeJobsEnvironment,
|
||||
arenaChecksum,
|
||||
californiaFlightScriptedBaseline,
|
||||
crowNavScriptedBaseline,
|
||||
driveScriptedBaseline,
|
||||
officeNavScriptedBaseline,
|
||||
officeJobsScriptedBaseline,
|
||||
type ArenaEnvironment,
|
||||
type ArenaManifest,
|
||||
type ArenaScenarioRegistry,
|
||||
@@ -36,6 +40,7 @@ interface EnvironmentCase {
|
||||
registry: ArenaScenarioRegistry<object>;
|
||||
inaction: unknown;
|
||||
scripted(observation: unknown): unknown;
|
||||
successReason: string;
|
||||
}
|
||||
|
||||
const CASES: EnvironmentCase[] = [
|
||||
@@ -45,6 +50,7 @@ const CASES: EnvironmentCase[] = [
|
||||
registry: DRIVE_101_SCENARIOS as ArenaScenarioRegistry<object>,
|
||||
inaction: DRIVE_INACTION,
|
||||
scripted: () => driveScriptedBaseline(),
|
||||
successReason: "goal",
|
||||
},
|
||||
{
|
||||
name: "office",
|
||||
@@ -54,6 +60,17 @@ const CASES: EnvironmentCase[] = [
|
||||
scripted: (observation) => officeNavScriptedBaseline(
|
||||
observation as Parameters<typeof officeNavScriptedBaseline>[0],
|
||||
),
|
||||
successReason: "goal",
|
||||
},
|
||||
{
|
||||
name: "office-jobs",
|
||||
create: () => new OfficeJobsEnvironment() as AnyEnvironment,
|
||||
registry: OFFICE_JOBS_SCENARIOS as ArenaScenarioRegistry<object>,
|
||||
inaction: OFFICE_JOBS_INACTION,
|
||||
scripted: (observation) => officeJobsScriptedBaseline(
|
||||
observation as Parameters<typeof officeJobsScriptedBaseline>[0],
|
||||
),
|
||||
successReason: "job-complete",
|
||||
},
|
||||
{
|
||||
name: "crow",
|
||||
@@ -63,6 +80,7 @@ const CASES: EnvironmentCase[] = [
|
||||
scripted: (observation) => crowNavScriptedBaseline(
|
||||
observation as Parameters<typeof crowNavScriptedBaseline>[0],
|
||||
),
|
||||
successReason: "goal",
|
||||
},
|
||||
{
|
||||
name: "flight",
|
||||
@@ -72,6 +90,7 @@ const CASES: EnvironmentCase[] = [
|
||||
scripted: (observation) => californiaFlightScriptedBaseline(
|
||||
observation as Parameters<typeof californiaFlightScriptedBaseline>[0],
|
||||
),
|
||||
successReason: "goal",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -96,9 +115,9 @@ function run(
|
||||
}
|
||||
|
||||
describe("arena contract and manifests", () => {
|
||||
it("exports four versioned renderer-independent manifests with disjoint public splits", () => {
|
||||
it("exports five 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",
|
||||
"drive-101-v1", "office-nav-v1", "office-jobs-v1", "crow-nav-v1", "california-flight-v1",
|
||||
]);
|
||||
for (const manifest of ARENA_MANIFESTS) {
|
||||
assert.equal(manifest.apiVersion, ARENA_API_VERSION);
|
||||
@@ -158,7 +177,7 @@ describe("arena contract and manifests", () => {
|
||||
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);
|
||||
assert.equal(scripted.info.terminalReason, entry.successReason, entry.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -170,12 +189,12 @@ describe("arena baseline proofs", () => {
|
||||
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");
|
||||
assert.notEqual(idle.final.info.terminalReason, entry.successReason);
|
||||
|
||||
const scripted = run(entry, definition.id, seed, entry.scripted);
|
||||
assert.equal(
|
||||
scripted.final.info.terminalReason,
|
||||
"goal",
|
||||
entry.successReason,
|
||||
`${entry.name}/${definition.id}/${seed}`,
|
||||
);
|
||||
assert.ok(scripted.total > 0, `${entry.name}/${definition.id}/${seed}=${scripted.total}`);
|
||||
@@ -206,6 +225,26 @@ describe("arena baseline proofs", () => {
|
||||
}
|
||||
assert.equal(officeReason, "collision-stall");
|
||||
|
||||
const officeJobs = new OfficeJobsEnvironment();
|
||||
officeJobs.reset(2, "train-la-directory-inspection");
|
||||
let officeJobsReason: string | null = null;
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
const result = officeJobs.step({ x: 0, z: 0, interact: true });
|
||||
officeJobsReason = result.info.terminalReason;
|
||||
if (result.terminated) break;
|
||||
}
|
||||
assert.equal(officeJobsReason, "wrong-interaction-limit");
|
||||
|
||||
const collisionJobs = new OfficeJobsEnvironment();
|
||||
collisionJobs.reset(2, "dev-sf-parcel-delivery");
|
||||
let collisionJobsReason: string | null = null;
|
||||
for (let index = 0; index < 80; index += 1) {
|
||||
const result = collisionJobs.step({ x: 0, z: 1, interact: false });
|
||||
collisionJobsReason = result.info.terminalReason;
|
||||
if (result.terminated) break;
|
||||
}
|
||||
assert.equal(collisionJobsReason, "collision-stall");
|
||||
|
||||
const crow = new CrowNavEnvironment();
|
||||
crow.reset(2, "train-east-crosswind");
|
||||
let crowReason: string | null = null;
|
||||
@@ -229,6 +268,33 @@ describe("arena baseline proofs", () => {
|
||||
});
|
||||
|
||||
describe("arena snapshot, trace and replay", () => {
|
||||
it("restores and replays every SF/LA office-job scenario", () => {
|
||||
for (const definition of OFFICE_JOBS_SCENARIOS.definitions) {
|
||||
const environment = new OfficeJobsEnvironment();
|
||||
let observation = environment.reset(311, definition.id).observation;
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
observation = environment.step(officeJobsScriptedBaseline(observation)).observation;
|
||||
}
|
||||
const checkpoint = environment.snapshot();
|
||||
const action = officeJobsScriptedBaseline(observation);
|
||||
const expected = environment.step(action);
|
||||
environment.restore(checkpoint);
|
||||
assert.deepEqual(environment.step(action), expected, definition.id);
|
||||
|
||||
const traced = new OfficeJobsEnvironment();
|
||||
observation = traced.reset(311, definition.id).observation;
|
||||
for (let index = 0; index < 50; index += 1) {
|
||||
const result = traced.step(officeJobsScriptedBaseline(observation));
|
||||
observation = result.observation;
|
||||
if (result.terminated) break;
|
||||
}
|
||||
const trace = traced.trace();
|
||||
const replay = new OfficeJobsEnvironment().replay(trace);
|
||||
assert.equal(replay.finalStateChecksum, trace.finalStateChecksum, definition.id);
|
||||
assert.equal(replay.cumulativeReward, trace.cumulativeReward, definition.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("restores each simulator bit-for-bit and preserves the next transition", () => {
|
||||
for (const entry of CASES) {
|
||||
const environment = entry.create();
|
||||
@@ -301,6 +367,17 @@ describe("arena snapshot, trace and replay", () => {
|
||||
}),
|
||||
/frame 1 is invalid/,
|
||||
);
|
||||
|
||||
const jobs = new OfficeJobsEnvironment();
|
||||
jobs.reset(17, "train-sf-display-inspection");
|
||||
const jobsSnapshot = jobs.snapshot();
|
||||
const invalidSimulation = structuredClone(jobsSnapshot.simulation);
|
||||
invalidSimulation.activity.robots[0]!.battery = 9;
|
||||
const { checksum: _jobsChecksum, ...jobsCore } = { ...jobsSnapshot, simulation: invalidSimulation };
|
||||
assert.throws(
|
||||
() => jobs.restore({ ...jobsCore, checksum: arenaChecksum(jobsCore) }),
|
||||
/invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses post-terminal stepping until reset", () => {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { createRobotActivity } from "../interiors/robotActivity.ts";
|
||||
import { resolveRobotOperations } from "../interiors/robotOperations.ts";
|
||||
import { createRobotRouter } from "../interiors/robotRoutes.ts";
|
||||
import { Plan } from "../interiors/plan.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";
|
||||
|
||||
const CASES = [
|
||||
[LUMBRIDGE_HQ, LUMBRIDGE_HQ_ROBOT_OPERATIONS],
|
||||
[MATEO_COURT, MATEO_COURT_ROBOT_OPERATIONS],
|
||||
] as const;
|
||||
|
||||
describe("robot operations and routes", () => {
|
||||
it("resolves explicit Plan anchors and keeps every authored job on one routable floor", () => {
|
||||
for (const [office, definition] of CASES) {
|
||||
const plan = new Plan(office, { depth: "public", warn: false });
|
||||
const operations = resolveRobotOperations(plan, definition);
|
||||
const router = createRobotRouter(plan);
|
||||
for (const robot of operations.robots.values()) {
|
||||
let previous = operations.stations.get(robot.spawnStationId)!;
|
||||
for (const jobId of robot.schedule) {
|
||||
const job = operations.jobs.get(jobId)!;
|
||||
for (const stationId of job.stationIds) {
|
||||
const station = operations.stations.get(stationId)!;
|
||||
assert.equal(station.levelId, robot.levelId, `${robot.id}/${job.id}`);
|
||||
const route = router.route(robot.levelId, previous.position, station.position);
|
||||
assert.ok(route, `${robot.id}/${job.id}/${station.id}`);
|
||||
assert.ok(route.lengthM >= 0);
|
||||
previous = station;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects operations that imply real activity or invent a vertical route", () => {
|
||||
const plan = new Plan(LUMBRIDGE_HQ, { warn: false });
|
||||
assert.throws(
|
||||
() => resolveRobotOperations(plan, {
|
||||
...LUMBRIDGE_HQ_ROBOT_OPERATIONS,
|
||||
disclosure: "Live operations",
|
||||
}),
|
||||
/simulated/,
|
||||
);
|
||||
assert.throws(
|
||||
() => resolveRobotOperations(plan, {
|
||||
...LUMBRIDGE_HQ_ROBOT_OPERATIONS,
|
||||
jobs: [{
|
||||
id: "bad-route",
|
||||
label: "Bad route",
|
||||
kind: "deliver",
|
||||
stationIds: ["sf-l1-parcel-pickup", "sf-l2-parcel-drop"],
|
||||
dwellTicks: 1,
|
||||
}],
|
||||
robots: [],
|
||||
}),
|
||||
/crosses levels/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("robot activity determinism", () => {
|
||||
it("is cadence-independent at the fixed-step boundary and seed-stable", () => {
|
||||
const plan = new Plan(LUMBRIDGE_HQ, { warn: false });
|
||||
const operations = resolveRobotOperations(plan, LUMBRIDGE_HQ_ROBOT_OPERATIONS);
|
||||
const stepped = createRobotActivity(plan, operations, { seed: 73 });
|
||||
const ticked = createRobotActivity(plan, operations, { seed: 73 });
|
||||
for (let index = 0; index < 600; index += 1) stepped.step();
|
||||
for (let index = 0; index < 300; index += 1) ticked.tick(0.2);
|
||||
assert.deepEqual(ticked.snapshot(), stepped.snapshot());
|
||||
|
||||
const different = createRobotActivity(plan, operations, { seed: 74 });
|
||||
assert.notDeepEqual(
|
||||
different.states().map((robot) => [robot.id, robot.scheduleCursor, robot.idleTicks]),
|
||||
stepped.trace().initial.robots.map((robot) => [robot.id, robot.scheduleCursor, robot.idleTicks]),
|
||||
);
|
||||
});
|
||||
|
||||
it("restores the exact next transition and replays JSON-safe traces", () => {
|
||||
const plan = new Plan(MATEO_COURT, { warn: false });
|
||||
const operations = resolveRobotOperations(plan, MATEO_COURT_ROBOT_OPERATIONS);
|
||||
const activity = createRobotActivity(plan, operations, { seed: 0xdecafbad });
|
||||
for (let index = 0; index < 420; index += 1) activity.step();
|
||||
const checkpoint = activity.snapshot();
|
||||
const expected = activity.step();
|
||||
activity.restore(JSON.parse(JSON.stringify(checkpoint)));
|
||||
assert.deepEqual(activity.step(), expected);
|
||||
|
||||
for (let index = 0; index < 60; index += 1) activity.step();
|
||||
const trace = JSON.parse(JSON.stringify(activity.trace()));
|
||||
assert.deepEqual(activity.replay(trace), trace.final);
|
||||
});
|
||||
|
||||
it("rejects tampered snapshots before state mutation", () => {
|
||||
const plan = new Plan(LUMBRIDGE_HQ, { warn: false });
|
||||
const operations = resolveRobotOperations(plan, LUMBRIDGE_HQ_ROBOT_OPERATIONS);
|
||||
const activity = createRobotActivity(plan, operations, { seed: 4 });
|
||||
const before = activity.snapshot();
|
||||
const tampered = structuredClone(before);
|
||||
tampered.robots[0]!.battery = 9;
|
||||
assert.throws(() => activity.restore(tampered), /invalid/);
|
||||
assert.deepEqual(activity.snapshot(), before);
|
||||
});
|
||||
|
||||
it("exposes deterministic blocked recovery for a controlled robot", () => {
|
||||
const plan = new Plan(LUMBRIDGE_HQ, { warn: false });
|
||||
const operations = resolveRobotOperations(plan, LUMBRIDGE_HQ_ROBOT_OPERATIONS);
|
||||
const id = "sf-l1-courier";
|
||||
const activity = createRobotActivity(plan, operations, {
|
||||
seed: 1,
|
||||
robotIds: [id],
|
||||
controlledRobotIds: [id],
|
||||
});
|
||||
let recovered = false;
|
||||
for (let index = 0; index < 80; index += 1) {
|
||||
const robot = activity.step({ [id]: { x: 0, z: 1, interact: false } }).robots[0]!;
|
||||
if (robot.mode === "blocked-recovery") recovered = true;
|
||||
}
|
||||
assert.equal(recovered, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("robot activity soak", () => {
|
||||
it("runs thirty deterministic simulated minutes without a floor escape or terminal", () => {
|
||||
for (const [office, definition] of CASES) {
|
||||
const plan = new Plan(office, { warn: false });
|
||||
const operations = resolveRobotOperations(plan, definition);
|
||||
const activity = createRobotActivity(plan, operations, { seed: 0x5eed1234 });
|
||||
const seen = new Set<string>();
|
||||
for (let tick = 0; tick < 18_000; tick += 1) {
|
||||
for (const robot of activity.step().robots) {
|
||||
seen.add(robot.mode);
|
||||
assert.equal(robot.terminalReason, null, `${office.id}/${robot.id}/${tick}`);
|
||||
assert.ok(plan.roomAt(robot.levelId, robot.position), `${office.id}/${robot.id}/${tick}`);
|
||||
assert.equal(plan.blocked(robot.levelId, robot.position, robot.position, 0.28), false);
|
||||
}
|
||||
}
|
||||
assert.ok(seen.has("patrol"));
|
||||
assert.ok(seen.has("deliver"));
|
||||
assert.ok(seen.has("inspect"));
|
||||
assert.ok(seen.has("charge"));
|
||||
assert.ok(activity.states().every((robot) => robot.completedJobs > 20));
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user