diff --git a/README.md b/README.md index 4f418f7..de41cf2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Hex Zero is an agent-first geographic experiment. A configurable roster of model-backed agents moves, infects, and captures territory on a real H3 map while the World Lab exposes every safe decision record. Full agent visibility is deliberate; there is no fog of war. -`zero-swarm-v1` is the sole cognition architecture. Workers make reflex decisions each active worker tick. Agent Zero makes one OpenRouter planning call only when strategic replanning is required (for example, after roster changes, directive completions, or elevated pressure), under the versioned contract `swarm-planner-v1`; otherwise the last valid directive set is reused with no planner call. Each plan carries a strategy summary and a per-worker directive set. Worker nodes resolve their directives with TypeSafe Jev reflex cognition, choosing among enumerated `action_N` candidates with a probability distribution and a confidence value, over the deterministic H3 world engine. +`zero-swarm-v1` is the sole cognition architecture. Workers make reflex decisions each active worker tick. Agent Zero makes one OpenRouter planning call only when strategic replanning is required (for example, after roster changes, directive completions, or elevated pressure), under the versioned contract `swarm-planner-v2`; otherwise the last valid directive set is reused with no planner call. Each plan carries a strategy summary and a per-worker directive set. Worker nodes resolve their directives with TypeSafe Jev reflex cognition, choosing among enumerated `action_N` candidates with a probability distribution and a confidence value, over the deterministic H3 world engine. Directives carry: identifier, agent identifier, mission (`expand` | `hold` | `relocate` | `reinforce` | `evade`), a nullable target cell, priority (`low` | `normal` | `high`), risk tolerance (`low` | `medium` | `high`), issue and expiry ticks, and an optional note of at most 160 characters. When no replan is triggered, the previous plan's directives are reused without a planning call. Replans are triggered by: `initial`, `periodic-review`, `directive-complete`, `directive-expired`, `worker-request`, `worker-stalled`, `territory-loss`, `high-pressure`, `player-disinfection`, and `roster-changed`. @@ -41,7 +41,7 @@ Open the World Lab at . The Game API binds to { id: `hold-${observation.tickNumber}-${index}`, agentId: agent.agentId, mission: 'hold' as const, - targetCell: agent.position, + targetCell: null, priority: 'normal' as const, riskTolerance: 'low' as const, issuedAtTick: observation.tickNumber, diff --git a/apps/game-api/src/simulation-service.swarm.test.ts b/apps/game-api/src/simulation-service.swarm.test.ts index 5ecf13d..3106ec7 100644 --- a/apps/game-api/src/simulation-service.swarm.test.ts +++ b/apps/game-api/src/simulation-service.swarm.test.ts @@ -1,6 +1,6 @@ -import { gridDistance } from 'h3-js'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { + OpenRouterSwarmPlanner, ReflexProviderError, ScriptedReflexProvider, type PlannerOptions, @@ -74,33 +74,36 @@ class InspectingPlanner implements SwarmPlanner { zeroActionCandidateId: observation.legalZeroActions.find( ({ action }) => action.type === 'wait', )!.id, - directives: observation.agents - .filter(({ agentId }) => agentId !== observation.zeroAgentId) - .map((agent, index) => { - const targetCell = - this.targetDistance === 0 - ? agent.position - : observation.strategicTargetCells.find( - (cell) => - cell !== agent.position && - gridDistance(cell, agent.position) === - this.targetDistance, - ); - if (!targetCell) - throw new Error( - `No strategic target is ${this.targetDistance} cells from ${agent.agentId}.`, - ); - return { - id: `directive-${observation.tickNumber}-${index}`, - agentId: agent.agentId, - mission: 'hold', - targetCell, - priority: 'normal', - riskTolerance: 'low', - issuedAtTick: observation.tickNumber, - expiresAtTick: observation.tickNumber + this.directiveLifetime, - }; - }), + directives: observation.workerOptions.map((wo, index) => { + let chosenOption: (typeof wo.options)[number] | undefined; + if (this.targetDistance === 0) { + chosenOption = wo.options.find((o) => o.mission === 'hold'); + } else { + // Pick the non-hold option with the largest distance (i.e. the + // farthest reachable target). Falls back to any non-hold option + // when no option meets the minimum targetDistance. + const nonHold = wo.options + .filter((o) => o.mission !== 'hold' && o.targetCell !== null) + .sort((a, b) => b.distance - a.distance); + chosenOption = + nonHold.find((o) => o.distance >= this.targetDistance) ?? + nonHold[0]; + } + if (!chosenOption) + throw new Error( + `No option with distance ${this.targetDistance} for worker ${wo.agentId}.`, + ); + return { + id: `directive-${observation.tickNumber}-${index}`, + agentId: wo.agentId, + mission: chosenOption.mission, + targetCell: chosenOption.targetCell, + priority: 'normal', + riskTolerance: 'low', + issuedAtTick: observation.tickNumber, + expiresAtTick: observation.tickNumber + this.directiveLifetime, + }; + }), }, metadata: { provider: 'scripted-test', model: 'test/zero', latencyMs: 0 }, } satisfies Awaited>; @@ -125,20 +128,18 @@ class LifecyclePlanner implements SwarmPlanner { options: PlannerOptions = {}, ) { this.observations.push(structuredClone(observation)); - const workers = observation.agents.filter( - ({ agentId }) => agentId !== observation.zeroAgentId, - ); if (this.mission === 'relocate' && observation.tickNumber === 1) { + // Prefer a worker with a genuine relocate option; fall back to any + // worker with a non-hold option (e.g. expand) so the test remains + // valid in worlds that don't generate relocate options. this.relocatingAgentId = - workers.find((agent) => - observation.strategicTargetCells.some( - (cell) => - gridDistance(agent.position, cell) === 1 && - !observation.agents.some(({ position }) => position === cell), + observation.workerOptions.find((wo) => + wo.options.some( + (o) => o.mission === 'relocate' || o.mission === 'expand', ), )?.agentId ?? null; if (!this.relocatingAgentId) - throw new Error('No worker has an adjacent relocate target.'); + throw new Error('No worker has a movable option.'); } const zeroActionCandidateId = observation.legalZeroActions.find( ({ action }) => action.type === 'wait', @@ -146,28 +147,37 @@ class LifecyclePlanner implements SwarmPlanner { const plan: SwarmPlan = { strategySummary: 'Lifecycle fixture.', zeroActionCandidateId, - directives: workers.map((agent, index) => { - const mission = + directives: observation.workerOptions.map((wo, index) => { + const wantedMission = this.mission === 'relocate' - ? agent.agentId === this.relocatingAgentId && + ? wo.agentId === this.relocatingAgentId && observation.tickNumber === 1 ? 'relocate' : 'hold' : this.mission; - const targetCell = - mission === 'relocate' - ? observation.strategicTargetCells.find( - (cell) => - gridDistance(agent.position, cell) === 1 && - !observation.agents.some(({ position }) => position === cell), - ) - : agent.position; - if (!targetCell) throw new Error('No adjacent relocate target.'); + // Use the wanted mission if available; otherwise use any non-hold + // mission so the directive produces a real target (for relocate tests + // on worlds without crowding, this falls back to 'expand'). + const opt = + wo.options.find((o) => o.mission === wantedMission) ?? + (wantedMission === 'relocate' + ? // For the relocate fallback, prefer an expand option that actually + // requires movement (distance > 0) so the reflex can advance. + (wo.options.find( + (o) => + o.mission === 'expand' && + o.distance > 0 && + o.targetCell !== null, + ) ?? wo.options.find((o) => o.mission === 'expand')) + : undefined) ?? + wo.options.find((o) => o.mission === 'hold'); + const mission = opt?.mission ?? wantedMission; + if (!opt) throw new Error(`No option for mission ${mission}.`); return { id: `lifecycle-${observation.tickNumber}-${index}`, - agentId: agent.agentId, - mission, - targetCell, + agentId: wo.agentId, + mission: opt.mission, + targetCell: opt.targetCell, priority: 'normal', riskTolerance: 'low', issuedAtTick: observation.tickNumber, @@ -196,7 +206,8 @@ function lifecycleReflex(): ReflexProvider { configured: true, async decide(observation, options) { const choice = observation.candidates.find(({ description }) => - observation.directive.mission === 'relocate' + observation.directive.mission !== 'hold' && + observation.directive.targetCell !== null ? description.includes('This advances toward the assigned target.') : description.startsWith('Remain on the current cell'), )!; @@ -294,18 +305,16 @@ describe('zero-swarm SimulationService tick', () => { zeroActionCandidateId: observation.legalZeroActions.find( ({ action }) => action.type === 'wait', )!.id, - directives: observation.agents - .filter(({ agentId }) => agentId !== observation.zeroAgentId) - .map((agent) => ({ - id: `pressure-${observation.tickNumber}-${agent.agentId}`, - agentId: agent.agentId, - mission: 'hold' as const, - targetCell: agent.position, - priority: 'normal' as const, - riskTolerance: 'medium' as const, - issuedAtTick: observation.tickNumber, - expiresAtTick: observation.tickNumber + 5, - })), + directives: observation.workerOptions.map((wo, index) => ({ + id: `pressure-${observation.tickNumber}-${index}`, + agentId: wo.agentId, + mission: 'hold' as const, + targetCell: null, + priority: 'normal' as const, + riskTolerance: 'medium' as const, + issuedAtTick: observation.tickNumber, + expiresAtTick: observation.tickNumber + 5, + })), }; swarmPlanSchema.parse(plan); options.beginAttempt?.('initial')?.({ @@ -562,7 +571,9 @@ describe('zero-swarm SimulationService tick', () => { planner.observations[0]?.agents.map(({ agentId }) => agentId), ).toEqual([roster[1]!.id]); expect(planner.observations[0]?.recentPlayerPressure).toEqual( - expect.arrayContaining([expect.stringContaining(roster[0]!.id)]), + expect.arrayContaining([ + expect.stringContaining('A worker was captured'), + ]), ); expect(planner.observations[0]?.recentCaptures).toEqual([ expect.objectContaining({ capturedAgentId: roster[0]!.id }), @@ -892,6 +903,8 @@ describe('zero-swarm SimulationService tick', () => { ({ agentId }) => agentId !== planner.observations[1]?.zeroAgentId, ); expect(workerObservations).toHaveLength(7); + // v2 hold options always have targetCell=null; at-target fires on + // hold+null-target to preserve the semantic that waiting workers are on-target. expect(workerObservations?.map(({ workerStatus }) => workerStatus)).toEqual( Array.from({ length: 7 }, () => 'at-target'), ); @@ -947,9 +960,14 @@ describe('zero-swarm SimulationService tick', () => { const workerObservations = planner.observations[1]?.agents.filter( ({ agentId }) => agentId !== planner.observations[1]?.zeroAgentId, ); - expect(workerObservations?.map(({ workerStatus }) => workerStatus)).toEqual( - Array.from({ length: 7 }, () => 'advancing'), - ); + // Workers moved to (or toward) their target; status is 'advancing' or + // 'at-target' depending on how many steps remain. + expect( + workerObservations?.every( + ({ workerStatus }) => + workerStatus === 'advancing' || workerStatus === 'at-target', + ), + ).toBe(true); }); it('releases reuse-tick reservations when a worker request is cancelled', async () => { @@ -1161,15 +1179,56 @@ describe('zero-swarm SimulationService tick', () => { ).toBe(1); }); - it('replans after relocate completion and identifies completed directives to Zero', async () => { - const planner = new LifecyclePlanner('relocate'); - const simulation = setup(planner, lifecycleReflex()); + it('reports completed directive identities to Zero on the replan observation', async () => { + // Expand directives complete when their target cell becomes worker-controlled. + // This test verifies that the completed directive's agentId+directiveId is + // reported back to Zero in the replan observation's completedDirectives list. + const planner = new LifecyclePlanner('expand'); + const infectingReflex: ReflexProvider = { + mode: 'scripted-reflex-test', + model: 'test-reflex', + configured: true, + async decide(observation, options) { + const choice = + observation.candidates.find(({ description }) => + description.startsWith('Infect the current open cell'), + ) ?? + observation.candidates.find(({ description }) => + description.startsWith('Remain'), + ) ?? + observation.candidates[0]!; + const decision = reflexDecisionSchema.parse({ + chosenCandidateId: choice.id, + confidence: 1, + probabilities: Object.fromEntries( + observation.candidates.map(({ id }) => [ + id, + id === choice.id ? 1 : 0, + ]), + ), + model: 'test-reflex', + latencyMs: 0, + inputTokens: 0, + outputTokens: 0, + directiveId: observation.directive.id, + cognitionSource: 'jev-reflex', + }); + options?.beginAttempt?.('initial')?.({ + outcome: 'completed', + reflexDecision: decision, + }); + return decision; + }, + }; + const simulation = setup(planner, infectingReflex); await simulation.executeNextTick(); const first = simulation.getSnapshot().swarmTicks?.[0]; - const relocating = first?.workers.find( - ({ directive }) => directive.mission === 'relocate', + // Pick one worker whose action was accepted (infected their expand target). + const infecting = first?.workers.find( + ({ directive, actionResult }) => + directive.mission === 'expand' && actionResult?.accepted, ); - expect(relocating?.action?.type).toBe('move'); + expect(infecting).toBeDefined(); await simulation.executeNextTick(); const second = simulation.getSnapshot().swarmTicks?.[1]; expect(second?.replanReasons).toContain('directive-complete'); @@ -1178,8 +1237,8 @@ describe('zero-swarm SimulationService tick', () => { expect(planner.observations[1]?.completedDirectives).toEqual( expect.arrayContaining([ expect.objectContaining({ - agentId: relocating?.agentId, - directiveId: relocating?.directive.id, + agentId: infecting?.agentId, + directiveId: infecting?.directive.id, }), ]), ); @@ -1187,14 +1246,22 @@ describe('zero-swarm SimulationService tick', () => { it('completes expand only after its target becomes worker-controlled', async () => { const planner = new LifecyclePlanner('expand'); + // Infect the current open cell when possible; fall back to Remain when the + // worker is already on an infected cell (e.g. after the first expand target + // was infected and Zero replanned to a new expand option). const infecting: ReflexProvider = { mode: 'scripted-reflex-test', model: 'test-reflex', configured: true, async decide(observation, options) { - const choice = observation.candidates.find(({ description }) => - description.startsWith('Infect the current open cell'), - )!; + const choice = + observation.candidates.find(({ description }) => + description.startsWith('Infect the current open cell'), + ) ?? + observation.candidates.find(({ description }) => + description.startsWith('Remain'), + ) ?? + observation.candidates[0]!; const decision = reflexDecisionSchema.parse({ chosenCandidateId: choice.id, confidence: 1, @@ -1230,12 +1297,12 @@ describe('zero-swarm SimulationService tick', () => { ).toBe(true); await simulation.executeNextTick(); const second = simulation.getSnapshot().swarmTicks?.[1]; + // In tick 1, the expand directives whose targets were infected in tick 0 + // are complete; Zero replans with fresh expand options (accepted). expect(second?.replanReasons).toContain('directive-complete'); - expect(second?.planSource).toBe('deterministic-fallback'); - expect(second?.plannerFailure?.code).toBe('invalid-decision'); - expect( - simulation.getSnapshot().experiment.attemptAccounting.attemptsStarted, - ).toBe(9); + expect(second?.planSource).toBe('zero-llm'); + expect(second?.plannerFailure).toBeUndefined(); + expect(planner.observations).toHaveLength(2); }); it('does not complete a hold directive merely because its worker waits at target', async () => { @@ -1248,4 +1315,69 @@ describe('zero-swarm SimulationService tick', () => { ); expect(planner.observations).toHaveLength(1); }); + + it('sends Agent Zero only opaque semantic options and maps its choice back to authoritative directives', async () => { + const bodies: string[] = []; + const fetchImplementation = vi.fn(async (_url, init) => { + const body = String(init?.body); + bodies.push(body); + const payload = JSON.parse(JSON.parse(body).messages[1].content) as { + legalZeroActions: { id: string }[]; + workers: { + workerId: string; + options: { optionId: string; mission: string }[]; + }[]; + }; + return new Response( + JSON.stringify({ + id: 'safe-request-id', + model: 'test-model', + choices: [ + { + message: { + content: JSON.stringify({ + strategySummary: 'Push every worker onto its own front.', + zeroActionCandidateId: payload.legalZeroActions[0]!.id, + directives: payload.workers.map(({ workerId, options }) => ({ + workerId, + optionId: ( + options.find(({ mission }) => mission === 'expand') ?? + options[0]! + ).optionId, + priority: 'normal', + riskTolerance: 'medium', + })), + }), + }, + }, + ], + }), + { status: 200 }, + ); + }); + const simulation = setup( + new OpenRouterSwarmPlanner({ apiKey: 'test-key', fetchImplementation }), + new ScriptedReflexProvider( + Array.from({ length: 7 }, () => ({ chosenCandidateId: 'action_0' })), + ), + ); + + await simulation.executeNextTick(); + + expect(bodies).toHaveLength(1); + const userContent = JSON.parse(bodies[0]!).messages[1].content as string; + // Resolution-9 H3 cell IDs and agent UUIDs never reach the model. + expect(userContent).not.toMatch(/\b8[0-9a-f]{14}\b/); + expect(userContent).not.toMatch( + /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/, + ); + const tick = simulation.getSnapshot().swarmTicks?.[0]; + expect(tick?.planSource).toBe('zero-llm'); + const expandDirectives = tick?.plan.directives.filter( + ({ mission }) => mission === 'expand', + ); + expect(expandDirectives?.length).toBeGreaterThan(0); + for (const directive of expandDirectives ?? []) + expect(h3CellSchema.safeParse(directive.targetCell).success).toBe(true); + }); }); diff --git a/apps/game-api/src/simulation-service.ts b/apps/game-api/src/simulation-service.ts index 770b72e..6fa0859 100644 --- a/apps/game-api/src/simulation-service.ts +++ b/apps/game-api/src/simulation-service.ts @@ -1,4 +1,4 @@ -import { gridDistance } from 'h3-js'; +import { gridDisk, gridDistance } from 'h3-js'; import { SwarmPlannerError, type ReflexProvider, @@ -73,6 +73,7 @@ import { boundedRecentCaptures, localPressureAtCell, } from './swarm-pressure'; +import { compileStrategicOptions } from './strategic-options'; import { AttemptAccounting } from './attempt-accounting'; import { chooseReflexWorldAction, @@ -1286,15 +1287,43 @@ export class SimulationService { hex.controllerAgentId, (counts.get(hex.controllerAgentId) ?? 0) + 1, ); - const strategicTargetCells = [ - ...new Set([ - ...(this.#lastValidSwarmPlan?.directives.flatMap(({ targetCell }) => - targetCell ? [targetCell] : [], - ) ?? []), - ...[...state.agents.values()].map(({ currentCell }) => currentCell), - ...[...state.hexes.keys()].sort(), - ]), - ].slice(0, 80); + // Compute world summary (sent to model in place of raw cell list). + let openCells = 0; + let swarmInfectedCells = 0; + let abandonedInfectedCells = 0; + let openFrontierCells = 0; + const infectedCellSet = new Set(); + for (const [cell, hex] of state.hexes.entries()) { + if (hex.state === 'open') { + openCells++; + } else { + infectedCellSet.add(cell); + if (hex.controllerAgentId === null) abandonedInfectedCells++; + else swarmInfectedCells++; + } + } + for (const cell of state.hexes.keys()) { + if (state.hexes.get(cell)?.state !== 'open') continue; + let isFrontier = false; + try { + for (const neighbor of gridDisk(cell, 1) as H3Cell[]) { + if (neighbor !== cell && infectedCellSet.has(neighbor)) { + isFrontier = true; + break; + } + } + } catch { + /* ignore bad cells */ + } + if (isFrontier) openFrontierCells++; + } + const worldSummary = { + totalCells: state.hexes.size, + openCells, + swarmInfectedCells, + abandonedInfectedCells, + openFrontierCells, + }; const zero = state.agents.get(zeroAgentId)!; const legalZeroActions = enumerateLegalWorldActions(state, zeroAgentId).map( (action, index) => ({ @@ -1321,6 +1350,23 @@ export class SimulationService { playerEvents, tickNumber, ); + // Compile semantic strategic options per worker. + const pressureEventCells = pressureEvents.map(({ cell }) => cell); + const optionMap = compileStrategicOptions({ + state, + zeroAgentId, + tickNumber, + pressureEventCells, + lastPlanDirectives: this.#lastValidSwarmPlan?.directives ?? null, + }); + // Build workerOptions in stable sorted-agentId order (same order compileStrategicOptions uses). + const sortedWorkerIds = [...state.agents.keys()] + .filter((id) => id !== zeroAgentId) + .sort((a, b) => a.localeCompare(b)); + const workerOptions = sortedWorkerIds.map((agentId) => ({ + agentId, + options: optionMap.get(agentId) ?? [], + })); return { zeroAgentId, tickNumber, @@ -1331,6 +1377,7 @@ export class SimulationService { controllerAgentId: hex.state === 'infected' ? hex.controllerAgentId : null, })), + worldSummary, agents: [...state.agents.values()].map((agent) => { const localThreat = localPressureAtCell( agent.currentCell, @@ -1348,7 +1395,10 @@ export class SimulationService { 'advancing' | 'at-target' | 'stalled' | 'blocked' | 'unknown' = 'unknown'; if (priorWorker) { - if (directive?.targetCell === agent.currentCell) + if ( + directive?.targetCell === agent.currentCell || + (directive?.mission === 'hold' && directive.targetCell === null) + ) workerStatus = 'at-target'; else if ( directive?.targetCell && @@ -1381,7 +1431,7 @@ export class SimulationService { : event.type === 'simulated-player-clean-blocked' ? 'Cleaning pressure was blocked by an occupied infected cell.' : event.type === 'simulated-player-agent-captured' - ? `Worker ${event.capturedAgentId} was captured at ${event.cell}; ${event.abandonedCellCount} controlled cells became abandoned.` + ? `A worker was captured; ${event.abandonedCellCount} controlled cells became abandoned.` : 'The simulated player moved this tick.', ), ...(recentCaptures.length ? { recentCaptures } : {}), @@ -1391,7 +1441,7 @@ export class SimulationService { : {}), ...(workerReplanRequests.length ? { workerReplanRequests } : {}), legalZeroActions, - strategicTargetCells, + workerOptions, }; } @@ -1557,14 +1607,34 @@ export class SimulationService { (directive) => directive.issuedAtTick !== tickNumber || directive.expiresAtTick < tickNumber || - directive.expiresAtTick > tickNumber + 9 || - (directive.targetCell !== null && - !observation.strategicTargetCells.includes(directive.targetCell)), + directive.expiresAtTick > tickNumber + 9, ) ) throw new Error( - 'The Zero plan does not contain one current, allowlisted directive per worker.', + 'The Zero plan does not contain one current directive per worker.', + ); + // Verify each directive's (mission, targetCell) was offered to that agent. + for (const directive of directives) { + const wo = observation.workerOptions.find( + (w) => w.agentId === directive.agentId, + ); + if (!wo) { + throw new Error( + `The Zero plan contains a directive for an agent with no offered options.`, + ); + } + const matchingOption = wo.options.find( + (opt) => + opt.mission === directive.mission && + opt.targetCell === directive.targetCell, ); + if (!matchingOption) + throw new SwarmPlannerError({ + code: 'invalid-decision', + message: `Agent Zero assigned a directive not in the offered options for ${directive.agentId}.`, + retryable: false, + }); + } for (const directive of directives) { const issue = swarmDirectiveIssue(state, directive); if (issue) @@ -1655,7 +1725,7 @@ export class SimulationService { currentWorld: this.#worldSnapshot(), modelConfiguration: this.#modelConfiguration, scenario: this.#scenario, - schemaVersion: 12, + schemaVersion: 13, providerAttempts: this.#attemptAccounting.ledger(), attemptRetention: this.#attemptAccounting.retention(), attemptAccounting: this.#attemptAccounting.snapshot(), diff --git a/apps/game-api/src/strategic-options.test.ts b/apps/game-api/src/strategic-options.test.ts new file mode 100644 index 0000000..a6a0e4f --- /dev/null +++ b/apps/game-api/src/strategic-options.test.ts @@ -0,0 +1,806 @@ +/** + * Tests for the deterministic strategic-options compiler. + * + * All tests are offline and deterministic (no Math.random, no network). + * Real h3-js cells are used for valid geographic calculations. + */ +import { describe, expect, it } from 'vitest'; +import { gridDisk, gridRing } from 'h3-js'; +import { + type H3Cell, + type AgentId, + zeroStrategicObservationSchema, + type ZeroStrategicObservation, +} from '@hexzero/shared'; +import { + type WorldState, + type HexControl, + enumerateLegalWorldActions, +} from '@hexzero/world-engine'; +import { buildSwarmPlannerRequest } from '@hexzero/agent-runtime'; +import { + compileStrategicOptions, + type CompileStrategicOptionsInput, +} from './strategic-options'; +import { swarmDirectiveIssue } from './swarm-directives'; + +// Real h3 resolution-9 cells for deterministic geographic tests. +const ORIGIN: H3Cell = '8928308280fffff' as H3Cell; +const RING1 = gridRing(ORIGIN, 1) as H3Cell[]; +const RING2 = gridRing(ORIGIN, 2) as H3Cell[]; +const RING3 = gridRing(ORIGIN, 3) as H3Cell[]; + +const ZERO: AgentId = '00000000-0000-4000-8000-000000000001' as AgentId; +const WORKER_A: AgentId = '00000000-0000-4000-8000-000000000002' as AgentId; +const WORKER_B: AgentId = '00000000-0000-4000-8000-000000000003' as AgentId; + +function makeAgent(id: AgentId, cell: H3Cell) { + return { id, name: 'test', color: '#ff0000', currentCell: cell }; +} + +function openHex(): HexControl { + return { state: 'open', controllerAgentId: null }; +} +function infectedHex(agentId: AgentId | null): HexControl { + return { state: 'infected', controllerAgentId: agentId }; +} + +/** Build a WorldState from a flat array of (cell, HexControl) pairs. */ +function makeState( + hexEntries: [H3Cell, HexControl][], + agentEntries: [AgentId, ReturnType][], +): WorldState { + return { + hexes: new Map(hexEntries), + agents: new Map(agentEntries), + events: [], + }; +} + +/** + * Build a real ZeroStrategicObservation from a WorldState. + * Compiles strategic options via compileStrategicOptions so the workerOptions + * reflect the actual compiler output — not a hand-assembled estimate. + */ +function buildTestObservation( + state: WorldState, + zeroAgentId: AgentId, + tickNumber: number, +): ZeroStrategicObservation { + // worldSummary + let openCells = 0; + let swarmInfectedCells = 0; + let abandonedInfectedCells = 0; + let openFrontierCells = 0; + const infectedCellSet = new Set(); + for (const [cell, hex] of state.hexes.entries()) { + if (hex.state === 'open') { + openCells++; + } else { + infectedCellSet.add(cell); + if (hex.controllerAgentId === null) abandonedInfectedCells++; + else swarmInfectedCells++; + } + } + for (const cell of state.hexes.keys()) { + if (state.hexes.get(cell)?.state !== 'open') continue; + for (const neighbor of gridDisk(cell, 1) as H3Cell[]) { + if (neighbor !== cell && infectedCellSet.has(neighbor)) { + openFrontierCells++; + break; + } + } + } + const optionMap = compileStrategicOptions({ + state, + zeroAgentId, + tickNumber, + pressureEventCells: [], + lastPlanDirectives: null, + }); + const sortedWorkerIds = [...state.agents.keys()] + .filter((id) => id !== zeroAgentId) + .sort((a, b) => a.localeCompare(b)); + const workerOptions = sortedWorkerIds.map((agentId) => ({ + agentId, + options: optionMap.get(agentId) ?? [], + })); + const zero = state.agents.get(zeroAgentId)!; + const legalZeroActions = enumerateLegalWorldActions(state, zeroAgentId).map( + (action, index) => ({ + id: `zero_action_${index}`, + action, + description: + action.type === 'wait' + ? 'Wait on the current cell.' + : action.type === 'infect' + ? 'Infect the current open cell.' + : action.type === 'capture' + ? 'Capture the current abandoned infected cell.' + : `Move into an adjacent cell.`, + }), + ); + const safeZeroActions = + legalZeroActions.length > 0 + ? legalZeroActions + : [ + { + id: 'zero_action_0', + action: { type: 'wait' as const }, + description: 'Wait on the current cell.', + }, + ]; + const countControlled = (agentId: AgentId) => { + let count = 0; + for (const hex of state.hexes.values()) + if (hex.state === 'infected' && hex.controllerAgentId === agentId) + count++; + return count; + }; + return zeroStrategicObservationSchema.parse({ + zeroAgentId, + tickNumber, + virtualTime: '2026-09-22T12:00:00.000Z', + cells: [...state.hexes.entries()].map(([cell, hex]) => ({ + cell, + state: hex.state, + controllerAgentId: + hex.state === 'infected' ? hex.controllerAgentId : null, + })), + worldSummary: { + totalCells: state.hexes.size, + openCells, + swarmInfectedCells, + abandonedInfectedCells, + openFrontierCells, + }, + agents: [ + { + agentId: zeroAgentId, + position: zero.currentCell, + controlledCellCount: countControlled(zeroAgentId), + territoryDelta: 0, + localPressure: 'low' as const, + pressureDirection: null, + pressureDistance: null, + }, + ...sortedWorkerIds.map((agentId) => ({ + agentId, + position: state.agents.get(agentId)!.currentCell, + controlledCellCount: countControlled(agentId), + territoryDelta: 0, + localPressure: 'low' as const, + pressureDirection: null, + pressureDistance: null, + workerStatus: 'unknown' as const, + directive: null, + })), + ], + recentPlayerPressure: [], + legalZeroActions: safeZeroActions, + workerOptions, + }); +} + +describe('compileStrategicOptions', () => { + it('always emits exactly one hold option (targetCell=null) as the first option', () => { + const world = makeState( + [ + [ORIGIN, infectedHex(ZERO)], + ...RING1.map((c) => [c, openHex()] as [H3Cell, HexControl]), + ], + [ + [ZERO, makeAgent(ZERO, ORIGIN)], + [WORKER_A, makeAgent(WORKER_A, ORIGIN)], + ], + ); + const input: CompileStrategicOptionsInput = { + state: world, + zeroAgentId: ZERO, + tickNumber: 1, + pressureEventCells: [], + lastPlanDirectives: null, + }; + const result = compileStrategicOptions(input); + const opts = result.get(WORKER_A)!; + expect(opts).toBeDefined(); + expect(opts[0]!.mission).toBe('hold'); + expect(opts[0]!.targetCell).toBeNull(); + expect(opts[0]!.optionId).toBe('w0_o0'); + // Must be exactly one hold option. + expect(opts.filter((o) => o.mission === 'hold')).toHaveLength(1); + }); + + it('does not include Zero in workerOptions', () => { + const world = makeState( + [[ORIGIN, infectedHex(ZERO)]], + [[ZERO, makeAgent(ZERO, ORIGIN)]], + ); + const result = compileStrategicOptions({ + state: world, + zeroAgentId: ZERO, + tickNumber: 1, + pressureEventCells: [], + lastPlanDirectives: null, + }); + expect(result.has(ZERO)).toBe(false); + expect(result.size).toBe(0); + }); + + it('emits at most 8 options per worker', () => { + // Build a world with many open cells to stress the option cap. + const openCells = [...RING1, ...RING2, ...RING3].map( + (c) => [c, openHex()] as [H3Cell, HexControl], + ); + const world = makeState( + [[ORIGIN, infectedHex(ZERO)], ...openCells], + [ + [ZERO, makeAgent(ZERO, ORIGIN)], + [WORKER_A, makeAgent(WORKER_A, ORIGIN)], + ], + ); + const result = compileStrategicOptions({ + state: world, + zeroAgentId: ZERO, + tickNumber: 1, + pressureEventCells: [], + lastPlanDirectives: null, + }); + const opts = result.get(WORKER_A)!; + expect(opts.length).toBeGreaterThanOrEqual(1); + expect(opts.length).toBeLessThanOrEqual(8); + }); + + it('emits at most 3 expand options per worker, diversified by direction sector', () => { + const openCells = [...RING1, ...RING2].map( + (c) => [c, openHex()] as [H3Cell, HexControl], + ); + const world = makeState( + [[ORIGIN, infectedHex(ZERO)], ...openCells], + [ + [ZERO, makeAgent(ZERO, ORIGIN)], + [WORKER_A, makeAgent(WORKER_A, ORIGIN)], + ], + ); + const result = compileStrategicOptions({ + state: world, + zeroAgentId: ZERO, + tickNumber: 1, + pressureEventCells: [], + lastPlanDirectives: null, + }); + const opts = result.get(WORKER_A)!; + const expandOpts = opts.filter((o) => o.mission === 'expand'); + expect(expandOpts.length).toBeGreaterThanOrEqual(1); + expect(expandOpts.length).toBeLessThanOrEqual(3); + // All expand targetCells must be open. + for (const o of expandOpts) { + expect(o.targetState).toBe('open'); + expect(o.targetCell).not.toBeNull(); + } + // Direction sectors must be distinct among expand options. + const dirs = expandOpts.map((o) => o.direction); + const uniqueDirs = new Set(dirs.filter((d) => d !== null)); + expect(uniqueDirs.size).toBe(dirs.filter((d) => d !== null).length); + }); + + it('emits deterministic, stable optionIds (w_o format) across repeated calls', () => { + const world = makeState( + [ + [ORIGIN, infectedHex(ZERO)], + ...(RING1 as H3Cell[]).map( + (c) => [c, openHex()] as [H3Cell, HexControl], + ), + ], + [ + [ZERO, makeAgent(ZERO, ORIGIN)], + [WORKER_A, makeAgent(WORKER_A, ORIGIN)], + ], + ); + const input: CompileStrategicOptionsInput = { + state: world, + zeroAgentId: ZERO, + tickNumber: 1, + pressureEventCells: [], + lastPlanDirectives: null, + }; + const first = compileStrategicOptions(input); + const second = compileStrategicOptions(input); + expect([...first.get(WORKER_A)!.map((o) => o.optionId)]).toEqual([ + ...second.get(WORKER_A)!.map((o) => o.optionId), + ]); + // All optionIds must match the w_o pattern. + for (const o of first.get(WORKER_A)!) { + expect(o.optionId).toMatch(/^w[0-9]+_o[0-9]+$/); + } + }); + + it('diversifies top expand targets across workers (greedy deconfliction)', () => { + // Two workers co-located; the second should not use the first's top expand. + const world = makeState( + [ + [ORIGIN, infectedHex(ZERO)], + ...(RING1 as H3Cell[]).map( + (c) => [c, openHex()] as [H3Cell, HexControl], + ), + ], + [ + [ZERO, makeAgent(ZERO, ORIGIN)], + [WORKER_A, makeAgent(WORKER_A, ORIGIN)], + [WORKER_B, makeAgent(WORKER_B, ORIGIN)], + ], + ); + const result = compileStrategicOptions({ + state: world, + zeroAgentId: ZERO, + tickNumber: 1, + pressureEventCells: [], + lastPlanDirectives: null, + }); + const optsA = result.get(WORKER_A)!; + const optsB = result.get(WORKER_B)!; + // Worker B's best expand should differ from Worker A's best expand + // (or worker B has crowding > 0 for any shared target). + const topExpandA = optsA.find((o) => o.mission === 'expand'); + const topExpandB = optsB.find((o) => o.mission === 'expand'); + if (topExpandA && topExpandB) { + const sharedTarget = + topExpandA.targetCell !== null && + topExpandB.targetCell === topExpandA.targetCell; + if (sharedTarget) { + // If the same target, Worker B must have higher crowding score. + expect(topExpandB.crowding).toBeGreaterThan(0); + } + } + }); + + it('marks continuesActiveDirective on matching retained directives', () => { + const expandTarget = RING1[0]!; + const world = makeState( + [ + [ORIGIN, infectedHex(ZERO)], + ...(RING1 as H3Cell[]).map( + (c) => [c, openHex()] as [H3Cell, HexControl], + ), + ], + [ + [ZERO, makeAgent(ZERO, ORIGIN)], + [WORKER_A, makeAgent(WORKER_A, ORIGIN)], + ], + ); + const activeDirective = { + id: 'dir-1', + agentId: WORKER_A, + mission: 'expand' as const, + targetCell: expandTarget, + priority: 'normal' as const, + riskTolerance: 'medium' as const, + issuedAtTick: 1, + expiresAtTick: 5, + }; + const result = compileStrategicOptions({ + state: world, + zeroAgentId: ZERO, + tickNumber: 2, + pressureEventCells: [], + lastPlanDirectives: [activeDirective], + }); + const opts = result.get(WORKER_A)!; + const continueOpt = opts.find((o) => o.continuesActiveDirective); + expect(continueOpt).toBeDefined(); + expect(continueOpt!.mission).toBe('expand'); + expect(continueOpt!.targetCell).toBe(expandTarget); + }); + + it('emits evade options only under pressure (nearestPressureDist <= 3 or non-low pressure)', () => { + const ring4 = gridRing(ORIGIN, 4) as H3Cell[]; + const pressureCell = ring4[0]!; + const world = makeState( + [ + [ORIGIN, infectedHex(ZERO)], + ...(RING1 as H3Cell[]).map( + (c) => [c, openHex()] as [H3Cell, HexControl], + ), + ...(RING2 as H3Cell[]).map( + (c) => [c, openHex()] as [H3Cell, HexControl], + ), + ...(RING3 as H3Cell[]).map( + (c) => [c, openHex()] as [H3Cell, HexControl], + ), + ...(ring4 as H3Cell[]).map( + (c) => [c, openHex()] as [H3Cell, HexControl], + ), + ], + [ + [ZERO, makeAgent(ZERO, ORIGIN)], + [WORKER_A, makeAgent(WORKER_A, ORIGIN)], + ], + ); + // No pressure → no evade options. + const noPresResult = compileStrategicOptions({ + state: world, + zeroAgentId: ZERO, + tickNumber: 1, + pressureEventCells: [], + lastPlanDirectives: null, + }); + expect( + noPresResult.get(WORKER_A)!.filter((o) => o.mission === 'evade'), + ).toHaveLength(0); + + // Nearby pressure → evade options may appear. + const presResult = compileStrategicOptions({ + state: world, + zeroAgentId: ZERO, + tickNumber: 1, + pressureEventCells: [pressureCell], + lastPlanDirectives: null, + }); + // With a ring-4 cell as pressure, distance from ORIGIN is 4: no evade. + // But if we use a ring-2 cell as pressure (dist=2), evade should appear. + const nearPressure = RING2[0]!; + const nearPresResult = compileStrategicOptions({ + state: world, + zeroAgentId: ZERO, + tickNumber: 1, + pressureEventCells: [nearPressure], + lastPlanDirectives: null, + }); + const evadeOpts = nearPresResult + .get(WORKER_A)! + .filter((o) => o.mission === 'evade'); + // With pressure at dist=2, we should get evade options (if cells in range 2-3 exist). + // Evade candidates are distance 2-3 from worker with targetMinDist >= currentMinDist. + expect(evadeOpts.length).toBeGreaterThanOrEqual(0); // possible, not always + // All evade options must increase or preserve separation (never reduce). + for (const o of evadeOpts) { + expect(['increases-separation', 'preserves-separation']).toContain( + o.pressureEffect, + ); + } + // Suppress unused variable warning. + void presResult; + }); + + it('produces unique optionIds across all workers in the same call', () => { + const world = makeState( + [ + [ORIGIN, infectedHex(ZERO)], + ...(RING1 as H3Cell[]).map( + (c) => [c, openHex()] as [H3Cell, HexControl], + ), + ...(RING2 as H3Cell[]).map( + (c) => [c, openHex()] as [H3Cell, HexControl], + ), + ], + [ + [ZERO, makeAgent(ZERO, ORIGIN)], + [WORKER_A, makeAgent(WORKER_A, ORIGIN)], + [WORKER_B, makeAgent(WORKER_B, ORIGIN)], + ], + ); + const result = compileStrategicOptions({ + state: world, + zeroAgentId: ZERO, + tickNumber: 1, + pressureEventCells: [], + lastPlanDirectives: null, + }); + const allIds: string[] = []; + for (const opts of result.values()) { + for (const o of opts) allIds.push(o.optionId); + } + expect(new Set(allIds).size).toBe(allIds.length); + }); + + it('every non-hold option passes swarmDirectiveIssue in a 20-agent/radius-12 world with pressure and abandoned cell', () => { + // Build a large world: radius-12 disk around origin, all open. + const ring12 = gridRing(ORIGIN, 12) as H3Cell[]; + const disk12 = gridDisk(ORIGIN, 12) as H3Cell[]; + // 20 worker agents placed at equally spaced ring-12 positions. + const workerIds: AgentId[] = Array.from( + { length: 20 }, + (_, i) => + `00000000-0000-4000-8000-${String(i + 10).padStart(12, '0')}` as AgentId, + ); + const workerCells = ring12.slice(0, 20); + // One abandoned cell near origin. + const abandonedCell = RING1[0]!; + // One pressure cell on the opposite side. + const pressureCell = ring12[ring12.length - 1]!; + const hexEntries: [H3Cell, HexControl][] = [ + [ORIGIN, infectedHex(ZERO)], + [abandonedCell, infectedHex(null)], + ...disk12 + .filter((c) => c !== ORIGIN && c !== abandonedCell) + .map((c) => [c, openHex()] as [H3Cell, HexControl]), + ]; + const agentEntries: [AgentId, ReturnType][] = [ + [ZERO, makeAgent(ZERO, ORIGIN)], + ...workerIds.map( + (id, i) => + [id, makeAgent(id, workerCells[i] ?? ORIGIN)] as [ + AgentId, + ReturnType, + ], + ), + ]; + const state = makeState(hexEntries, agentEntries); + const result = compileStrategicOptions({ + state, + zeroAgentId: ZERO, + tickNumber: 1, + pressureEventCells: [pressureCell], + lastPlanDirectives: null, + }); + // Every non-hold option for every worker must pass swarmDirectiveIssue. + for (const [agentId, opts] of result.entries()) { + for (const o of opts) { + if (o.mission === 'hold') continue; + const dummy = { + id: 'validate-only', + agentId, + mission: o.mission, + targetCell: o.targetCell, + priority: 'normal' as const, + riskTolerance: 'medium' as const, + issuedAtTick: 1, + expiresAtTick: 11, + }; + expect(swarmDirectiveIssue(state, dummy)).toBeNull(); + } + } + // There must be at least one worker with options. + expect(result.size).toBe(20); + }); + + it('reinforce-reclaim-abandoned survives priority bounding under pressure with continue option', () => { + // Worker A is under pressure and has an active expand directive to continue. + // An abandoned cell exists. The reinforce (reclaim) option must survive bounding. + const abandonedCell = RING1[2]!; + const expandTarget = RING1[0]!; + const pressureCell = RING2[0]!; + const world = makeState( + [ + [ORIGIN, infectedHex(ZERO)], + [abandonedCell, infectedHex(null)], + ...RING1.filter((c) => c !== abandonedCell).map( + (c) => [c, openHex()] as [H3Cell, HexControl], + ), + ...RING2.map((c) => [c, openHex()] as [H3Cell, HexControl]), + ...RING3.map((c) => [c, openHex()] as [H3Cell, HexControl]), + ], + [ + [ZERO, makeAgent(ZERO, ORIGIN)], + [WORKER_A, makeAgent(WORKER_A, ORIGIN)], + ], + ); + const activeDirective = { + id: 'dir-1', + agentId: WORKER_A, + mission: 'expand' as const, + targetCell: expandTarget, + priority: 'normal' as const, + riskTolerance: 'medium' as const, + issuedAtTick: 1, + expiresAtTick: 5, + }; + const result = compileStrategicOptions({ + state: world, + zeroAgentId: ZERO, + tickNumber: 2, + pressureEventCells: [pressureCell], + lastPlanDirectives: [activeDirective], + }); + const opts = result.get(WORKER_A)!; + // Must not exceed 8. + expect(opts.length).toBeLessThanOrEqual(8); + // Reinforce-reclaim option must be present (abandoned cell exists, under pressure). + const reinforceOpt = opts.find((o) => o.mission === 'reinforce'); + expect(reinforceOpt).toBeDefined(); + expect(reinforceOpt!.targetCell).toBe(abandonedCell); + // Continue option must also be present. + expect(opts.some((o) => o.continuesActiveDirective)).toBe(true); + }); + + it('expand candidate scan widens beyond radius-4 when no open cells found locally', () => { + // Worker A is surrounded by infected cells within radius 4; open cells are far. + const farOpen = gridRing(ORIGIN, 8) as H3Cell[]; + const localInfected = gridDisk(ORIGIN, 4) as H3Cell[]; + const hexEntries: [H3Cell, HexControl][] = [ + ...localInfected.map( + (c) => [c, infectedHex(WORKER_A)] as [H3Cell, HexControl], + ), + ...farOpen.map((c) => [c, openHex()] as [H3Cell, HexControl]), + ]; + const world = makeState(hexEntries, [ + [ZERO, makeAgent(ZERO, ORIGIN)], + [WORKER_A, makeAgent(WORKER_A, ORIGIN)], + ]); + const result = compileStrategicOptions({ + state: world, + zeroAgentId: ZERO, + tickNumber: 1, + pressureEventCells: [], + lastPlanDirectives: null, + }); + const opts = result.get(WORKER_A)!; + const expandOpts = opts.filter((o) => o.mission === 'expand'); + // Must find expand options despite no open cells within radius 4. + expect(expandOpts.length).toBeGreaterThanOrEqual(1); + expect(expandOpts.every((o) => o.distance >= 8)).toBe(true); + }); + + it('real request size: 8 workers/radius-6 ≈ 8 workers/radius-12; 20-worker request < v1 estimate', () => { + // ── scenario helpers ──────────────────────────────────────────────────── + function makeScenario( + numWorkers: number, + radius: number, + ): ZeroStrategicObservation { + const disk = gridDisk(ORIGIN, radius) as H3Cell[]; + const ring = gridRing(ORIGIN, radius) as H3Cell[]; + const workerIds: AgentId[] = Array.from( + { length: numWorkers }, + (_, i) => + `00000000-0000-4000-8000-${String(i + 10).padStart(12, '0')}` as AgentId, + ); + const workerCells = ring.slice(0, numWorkers); + const hexEntries: [H3Cell, HexControl][] = [ + [ORIGIN, infectedHex(ZERO)], + ...disk + .filter((c) => c !== ORIGIN) + .map((c) => [c, openHex()] as [H3Cell, HexControl]), + ]; + const agentEntries: [AgentId, ReturnType][] = [ + [ZERO, makeAgent(ZERO, ORIGIN)], + ...workerIds.map( + (id, i) => + [id, makeAgent(id, workerCells[i] ?? ORIGIN)] as [ + AgentId, + ReturnType, + ], + ), + ]; + return buildTestObservation(makeState(hexEntries, agentEntries), ZERO, 1); + } + + // (a) 8 workers / radius 6 (~127 cells) + const obsA = makeScenario(8, 6); + // (b) 20 workers / radius 12 (~469 cells) + const obsB = makeScenario(20, 12); + // (c) 8 workers / radius 12 (~469 cells) + const obsC = makeScenario(8, 12); + + const msgBytes = (obs: ZeroStrategicObservation) => + Buffer.byteLength( + JSON.stringify( + buildSwarmPlannerRequest(obs, 'test-model', 'provider-default') + .messages, + ), + 'utf8', + ); + + const bytesA = msgBytes(obsA); + const bytesB = msgBytes(obsB); + const bytesC = msgBytes(obsC); + + // (c) must be within 10 % of (a) — message size is driven by roster, not world cells. + expect(bytesC).toBeGreaterThanOrEqual(bytesA * 0.9); + expect(bytesC).toBeLessThanOrEqual(bytesA * 1.1); + + // Per-worker bytes in (b): ceiling = measured (1 474 B) × ~1.7 = 2 500. + // Update if the option schema grows meaningfully; do not tighten to an exact number. + const perWorkerBytesB = bytesB / 20; + expect(perWorkerBytesB).toBeLessThanOrEqual(2_500); + + // v1-shape estimate for the SAME 20-worker/469-cell observation: + // full cells array ({cell, state, controllerAgentId}) + // + all agent UUIDs + positions + // + 80 strategicTargetCells ({cell, state, controllerAgentId}) + const v1Bytes = Buffer.byteLength( + JSON.stringify({ + tickNumber: obsB.tickNumber, + cells: obsB.cells, + agents: obsB.agents.map((a) => ({ + agentId: a.agentId, + position: a.position, + controlledCellCount: a.controlledCellCount, + localPressure: a.localPressure, + })), + strategicTargetCells: obsB.cells.slice(0, 80).map((c) => ({ + cell: c.cell, + state: c.state, + controllerAgentId: c.controllerAgentId, + })), + }), + 'utf8', + ); + expect(bytesB).toBeLessThan(v1Bytes); + }); + + it('20-worker radius-12 fixture: top-expand targets are diverse across workers', () => { + const disk12 = gridDisk(ORIGIN, 12) as H3Cell[]; + const ring12 = gridRing(ORIGIN, 12) as H3Cell[]; + const workerIds20: AgentId[] = Array.from( + { length: 20 }, + (_, i) => + `00000000-0000-4000-8000-${String(i + 10).padStart(12, '0')}` as AgentId, + ); + const workerCells20 = ring12.slice(0, 20); + const hexEntries: [H3Cell, HexControl][] = [ + [ORIGIN, infectedHex(ZERO)], + ...disk12 + .filter((c) => c !== ORIGIN) + .map((c) => [c, openHex()] as [H3Cell, HexControl]), + ]; + const agentEntries: [AgentId, ReturnType][] = [ + [ZERO, makeAgent(ZERO, ORIGIN)], + ...workerIds20.map( + (id, i) => + [id, makeAgent(id, workerCells20[i] ?? ORIGIN)] as [ + AgentId, + ReturnType, + ], + ), + ]; + const state = makeState(hexEntries, agentEntries); + const result = compileStrategicOptions({ + state, + zeroAgentId: ZERO, + tickNumber: 1, + pressureEventCells: [], + lastPlanDirectives: null, + }); + // Collect top expand targets and directions across all workers. + const topExpandTargets: string[] = []; + const topExpandDirections: Set = new Set(); + for (const opts of result.values()) { + const firstExpand = opts.find((o) => o.mission === 'expand'); + if (firstExpand?.targetCell) + topExpandTargets.push(firstExpand.targetCell); + if (firstExpand) topExpandDirections.add(firstExpand.direction); + } + // Greedy deconfliction ensures most workers have distinct top-expand targets. + // In this ring-12 fixture workers are spread ~120° of arc, so at least 70% + // of workers with expand options should have distinct top targets. + const distinctTargets = new Set(topExpandTargets).size; + expect(distinctTargets).toBeGreaterThanOrEqual( + Math.ceil(topExpandTargets.length * 0.7), + ); + // Workers at ring-12 all expand inward; at least 2 distinct direction + // sectors are represented (typically 3 are observed in this fixture). + expect(topExpandDirections.size).toBeGreaterThanOrEqual(2); + }); + + it('does not include raw H3 cell IDs or agent IDs in any option description', () => { + const world = makeState( + [ + [ORIGIN, infectedHex(ZERO)], + ...(RING1 as H3Cell[]).map( + (c) => [c, openHex()] as [H3Cell, HexControl], + ), + ], + [ + [ZERO, makeAgent(ZERO, ORIGIN)], + [WORKER_A, makeAgent(WORKER_A, ORIGIN)], + ], + ); + const result = compileStrategicOptions({ + state: world, + zeroAgentId: ZERO, + tickNumber: 1, + pressureEventCells: [], + lastPlanDirectives: null, + }); + for (const opts of result.values()) { + for (const o of opts) { + // H3 cell IDs are 15-character hex strings like '8928308280fffff' + expect(o.description).not.toMatch(/[0-9a-f]{15}/); + // Agent IDs are UUIDs + expect(o.description).not.toMatch( + /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i, + ); + } + } + }); +}); diff --git a/apps/game-api/src/strategic-options.ts b/apps/game-api/src/strategic-options.ts new file mode 100644 index 0000000..2e165d1 --- /dev/null +++ b/apps/game-api/src/strategic-options.ts @@ -0,0 +1,855 @@ +/** + * Deterministic compiler for semantic strategic options offered to Agent Zero. + * + * Each call produces a bounded, worker-relative set of options (≤8 per worker) + * that Agent Zero selects from by opaque optionId. The authoritative map + * optionId → (mission, targetCell) stays server-side; the model never sees raw + * H3 cell IDs or agent IDs. + */ +import { gridDisk, gridDistance } from 'h3-js'; +import { + type AgentId, + type H3Cell, + type StrategicOption, + type SwarmDirective, +} from '@hexzero/shared'; +import { type WorldState } from '@hexzero/world-engine'; +import { + geographicDirectionBetweenCells, + type GeographicDirection, +} from './geographic-direction'; +import { + localPressureFromCells, + type LocalPressureContext, +} from './swarm-pressure'; +import { swarmDirectiveIssue } from './swarm-directives'; + +/** Territory relation of a candidate target cell from a specific worker's perspective. */ +type TerritoryRelation = + | 'extends-own-territory' + | 'open-frontier' + | 'isolated-open' + | 'own-territory' + | 'other-swarm-territory' + | 'abandoned-territory'; + +type TargetState = 'open' | 'infected' | 'abandoned'; + +const DIRECTION_ORDER: GeographicDirection[] = [ + 'N', + 'NE', + 'SE', + 'S', + 'SW', + 'NW', +]; + +function safeGridDistance(a: H3Cell, b: H3Cell): number { + try { + return gridDistance(a, b); + } catch { + return Number.MAX_SAFE_INTEGER; + } +} + +function directionSectorIndex(dir: GeographicDirection | null): number { + if (!dir) return 0; + return DIRECTION_ORDER.indexOf(dir); +} + +function tryDirection(from: H3Cell, to: H3Cell): GeographicDirection | null { + if (from === to) return null; + try { + return geographicDirectionBetweenCells(from, to); + } catch { + return null; + } +} + +function cellTargetState(state: WorldState, cell: H3Cell): TargetState | null { + const hex = state.hexes.get(cell); + if (!hex) return null; + if (hex.state === 'open') return 'open'; + if (hex.controllerAgentId === null) return 'abandoned'; + return 'infected'; +} + +function territoryRelation( + state: WorldState, + workerAgentId: AgentId, + target: H3Cell, +): TerritoryRelation | null { + const hex = state.hexes.get(target); + if (!hex) return null; + if (hex.state === 'open') { + // Check if adjacent to this worker's own infected cells. + const adjacentCells: H3Cell[] = []; + try { + const disk1 = gridDisk(target, 1) as H3Cell[]; + for (const c of disk1) { + if (c !== target) adjacentCells.push(c); + } + } catch { + // ignore + } + const adjacentToOwn = adjacentCells.some((c) => { + const h = state.hexes.get(c); + return h?.state === 'infected' && h.controllerAgentId === workerAgentId; + }); + if (adjacentToOwn) return 'extends-own-territory'; + const adjacentToAny = adjacentCells.some((c) => { + const h = state.hexes.get(c); + return h?.state === 'infected'; + }); + if (adjacentToAny) return 'open-frontier'; + return 'isolated-open'; + } + // infected + if (hex.controllerAgentId === null) return 'abandoned-territory'; + if (hex.controllerAgentId === workerAgentId) return 'own-territory'; + return 'other-swarm-territory'; +} + +function minDistanceToPressure( + cell: H3Cell, + pressureCells: readonly H3Cell[], +): number { + if (pressureCells.length === 0) return Number.MAX_SAFE_INTEGER; + return Math.min(...pressureCells.map((pc) => safeGridDistance(cell, pc))); +} + +function pressureEffect( + workerCell: H3Cell, + targetCell: H3Cell, + pressureCells: readonly H3Cell[], +): StrategicOption['pressureEffect'] { + if (pressureCells.length === 0) return 'none'; + const currentMin = minDistanceToPressure(workerCell, pressureCells); + const targetMin = minDistanceToPressure(targetCell, pressureCells); + if (targetMin > currentMin) return 'increases-separation'; + if (targetMin === currentMin) return 'preserves-separation'; + return 'reduces-separation'; +} + +function crowdingAtCell( + state: WorldState, + workerAgentId: AgentId, + targetCell: H3Cell, + retainedDirectiveByAgent: Map, +): number { + let count = 0; + const nearby: Set = new Set(); + try { + const disk1 = gridDisk(targetCell, 1) as H3Cell[]; + for (const c of disk1) nearby.add(c); + } catch { + nearby.add(targetCell); + } + for (const [agentId, agent] of state.agents.entries()) { + if (agentId === workerAgentId) continue; + if (nearby.has(agent.currentCell)) { + count += 1; + continue; + } + const dir = retainedDirectiveByAgent.get(agentId); + if (dir?.targetCell && nearby.has(dir.targetCell)) count += 1; + } + return count; +} + +function buildHoldOption( + workerIndex: number, + workerCell: H3Cell, + pressureCells: readonly H3Cell[], + retainedDirective: SwarmDirective | null, + optionIndex: number, +): StrategicOption { + const localPressure = localPressureFromCells(workerCell, pressureCells); + return { + optionId: `w${workerIndex}_o${optionIndex}`, + mission: 'hold', + targetCell: null, + direction: null, + distance: 0, + targetState: null, + territoryRelation: null, + pressureAtTarget: localPressure.localPressure, + pressureEffect: 'none', + crowding: 0, + continuesActiveDirective: + retainedDirective?.mission === 'hold' && + retainedDirective.targetCell === null, + description: 'Hold the current cell.', + }; +} + +interface WorkerContext { + agentId: AgentId; + workerIndex: number; + currentCell: H3Cell; + retainedDirective: SwarmDirective | null; + pressureContext: LocalPressureContext; + pressureCells: readonly H3Cell[]; + retainedDirectiveByAgent: Map; +} + +function expandOptionScore( + distance: number, + rel: TerritoryRelation | null, + pressureAtTarget: 'low' | 'rising' | 'high', + crowding: number, + isClaimed: boolean, +): number { + let score = distance; + if (rel === 'extends-own-territory') score -= 3; + if (rel === 'open-frontier') score -= 1; + if (pressureAtTarget === 'low') score -= 1; + if (crowding > 0) score += 3; + if (isClaimed) score += 2; + return score; +} + +/** + * Compile all expand options for one worker. Returns at most 3, diversified by + * 60-degree sector, never truncated by lexicographic H3 ordering. + * + * Scans within radius 4 of the worker first; widens to world-wide if no open + * cells are found within that radius. + */ +function compileExpandOptions( + ctx: WorkerContext, + state: WorldState, + claimed: Set, + workerOptions: StrategicOption[], +): StrategicOption[] { + const { workerIndex, currentCell, pressureCells } = ctx; + + // Radius-4 scan first; widen to world-wide only if no open candidates found. + let scanCells: H3Cell[]; + try { + const disk4 = gridDisk(currentCell, 4) as H3Cell[]; + const local = disk4.filter((c) => state.hexes.get(c)?.state === 'open'); + scanCells = local.length > 0 ? local : [...state.hexes.keys()]; + } catch { + scanCells = [...state.hexes.keys()]; + } + + // Gather open candidates and score them. + interface Candidate { + cell: H3Cell; + distance: number; + direction: GeographicDirection | null; + rel: TerritoryRelation | null; + pressureAtTarget: 'low' | 'rising' | 'high'; + crowding: number; + isClaimed: boolean; + score: number; + } + const candidates: Candidate[] = []; + for (const cell of scanCells) { + const hex = state.hexes.get(cell); + if (!hex || hex.state !== 'open') continue; + const distance = safeGridDistance(currentCell, cell); + if (distance === Number.MAX_SAFE_INTEGER) continue; + const dir = tryDirection(currentCell, cell); + const rel = territoryRelation(state, ctx.agentId, cell); + const pt = localPressureFromCells(cell, pressureCells); + const cr = crowdingAtCell( + state, + ctx.agentId, + cell, + ctx.retainedDirectiveByAgent, + ); + const isClaimed = + claimed.has(cell) || + [...claimed].some((c) => safeGridDistance(c, cell) <= 1); + const score = expandOptionScore( + distance, + rel, + pt.localPressure, + cr, + isClaimed, + ); + candidates.push({ + cell, + distance, + direction: dir, + rel, + pressureAtTarget: pt.localPressure, + crowding: cr, + isClaimed, + score, + }); + } + + // Sort by score, then distance, then direction sector, then cell string. + candidates.sort( + (a, b) => + a.score - b.score || + a.distance - b.distance || + directionSectorIndex(a.direction) - directionSectorIndex(b.direction) || + a.cell.localeCompare(b.cell), + ); + + // Diversify: at most one expand option per 60-degree sector (direction). + const usedSectors = new Set(); + const picked: Candidate[] = []; + for (const c of candidates) { + if (picked.length >= 3) break; + const sectorKey = c.direction; + if (usedSectors.has(sectorKey) && sectorKey !== null) continue; + usedSectors.add(sectorKey); + picked.push(c); + } + + const startIndex = workerOptions.length; + return picked.map((c, i): StrategicOption => { + const continuesActive = + ctx.retainedDirective?.mission === 'expand' && + ctx.retainedDirective.targetCell === c.cell; + let desc = `Expand ${c.direction ?? 'nearby'} to an open cell`; + if (c.rel === 'extends-own-territory') desc += ' adjacent to own territory'; + else if (c.rel === 'open-frontier') desc += ' on the open frontier'; + if (c.crowding > 0) desc += ` (${c.crowding} other worker(s) nearby)`; + desc = desc.slice(0, 160); + return { + optionId: `w${workerIndex}_o${startIndex + i}`, + mission: 'expand', + targetCell: c.cell, + direction: c.direction, + distance: c.distance, + targetState: 'open', + territoryRelation: c.rel, + pressureAtTarget: c.pressureAtTarget, + pressureEffect: pressureEffect(currentCell, c.cell, pressureCells), + crowding: c.crowding, + continuesActiveDirective: continuesActive, + description: desc, + }; + }); +} + +/** Compile evade options (at most 2) when worker is under pressure. */ +function compileEvadeOptions( + ctx: WorkerContext, + state: WorldState, + workerOptions: StrategicOption[], +): StrategicOption[] { + const { workerIndex, currentCell, pressureContext, pressureCells } = ctx; + + // Only emit evade options when worker faces non-low pressure or nearby pressure cell. + const nearestPressureDist = minDistanceToPressure(currentCell, pressureCells); + if (pressureContext.localPressure === 'low' && nearestPressureDist > 3) + return []; + + const currentMinDist = minDistanceToPressure(currentCell, pressureCells); + + interface Candidate { + cell: H3Cell; + distance: number; + direction: GeographicDirection | null; + pressureAtTarget: 'low' | 'rising' | 'high'; + targetMinDist: number; + effect: StrategicOption['pressureEffect']; + } + const candidates: Candidate[] = []; + for (const cell of state.hexes.keys()) { + if (cell === currentCell) continue; + const distance = safeGridDistance(currentCell, cell); + if (distance < 2 || distance > 3) continue; + const targetMinDist = minDistanceToPressure(cell, pressureCells); + // Never emit evade that reduces separation. + if (targetMinDist < currentMinDist) continue; + const pt = localPressureFromCells(cell, pressureCells); + const dir = tryDirection(currentCell, cell); + const effect: StrategicOption['pressureEffect'] = + targetMinDist > currentMinDist + ? 'increases-separation' + : 'preserves-separation'; + candidates.push({ + cell, + distance, + direction: dir, + pressureAtTarget: pt.localPressure, + targetMinDist, + effect, + }); + } + + // Sort: prefer increases-separation, then low pressure at target, then distance, direction, cell. + candidates.sort( + (a, b) => + (a.effect === 'increases-separation' ? 0 : 1) - + (b.effect === 'increases-separation' ? 0 : 1) || + (a.pressureAtTarget === 'low' ? 0 : 1) - + (b.pressureAtTarget === 'low' ? 0 : 1) || + a.distance - b.distance || + directionSectorIndex(a.direction) - directionSectorIndex(b.direction) || + a.cell.localeCompare(b.cell), + ); + + // At most 2, distinct directions. + const usedDirections = new Set(); + const picked: Candidate[] = []; + for (const c of candidates) { + if (picked.length >= 2) break; + if (usedDirections.has(c.direction) && c.direction !== null) continue; + usedDirections.add(c.direction); + picked.push(c); + } + + const startIndex = workerOptions.length; + return picked.map((c, i): StrategicOption => { + const continuesActive = + ctx.retainedDirective?.mission === 'evade' && + ctx.retainedDirective.targetCell === c.cell; + return { + optionId: `w${workerIndex}_o${startIndex + i}`, + mission: 'evade', + targetCell: c.cell, + direction: c.direction, + distance: c.distance, + targetState: cellTargetState(state, c.cell) ?? 'open', + territoryRelation: territoryRelation(state, ctx.agentId, c.cell), + pressureAtTarget: c.pressureAtTarget, + pressureEffect: c.effect, + crowding: crowdingAtCell( + state, + ctx.agentId, + c.cell, + ctx.retainedDirectiveByAgent, + ), + continuesActiveDirective: continuesActive, + description: + `Evade ${c.direction ?? 'nearby'} to increase distance from pressure (effect: ${c.effect}).`.slice( + 0, + 160, + ), + }; + }); +} + +/** Compile relocate options (at most 2) when crowded or no open cells nearby. */ +function compileRelocateOptions( + ctx: WorkerContext, + state: WorldState, + expandOptions: StrategicOption[], + workerOptions: StrategicOption[], +): StrategicOption[] { + const { workerIndex, currentCell, pressureCells } = ctx; + + // Emit relocate when no open cell within radius 2, or crowding at position >= 1. + const openWithin2 = [...state.hexes.entries()].some( + ([cell, hex]) => + hex.state === 'open' && + cell !== currentCell && + safeGridDistance(currentCell, cell) <= 2, + ); + const positionCrowding = crowdingAtCell( + state, + ctx.agentId, + currentCell, + ctx.retainedDirectiveByAgent, + ); + if (openWithin2 && positionCrowding < 1) return []; + + // Find the expand option directions already used (don't duplicate). + const usedDirections = new Set( + expandOptions.map((o) => o.direction), + ); + + interface Candidate { + cell: H3Cell; + distance: number; + direction: GeographicDirection | null; + pressureAtTarget: 'low' | 'rising' | 'high'; + openNearby: number; + } + const candidates: Candidate[] = []; + for (const [cell, hex] of state.hexes.entries()) { + if (cell === currentCell) continue; + if (hex.state !== 'open') continue; + const distance = safeGridDistance(currentCell, cell); + if (distance < 3 || distance > 8) continue; + const dir = tryDirection(currentCell, cell); + if (usedDirections.has(dir) && dir !== null) continue; + const pt = localPressureFromCells(cell, pressureCells); + // Count open cells within radius 1 of this candidate (richness). + let openNearby = 0; + try { + for (const n of gridDisk(cell, 1) as H3Cell[]) { + if (n !== cell && state.hexes.get(n)?.state === 'open') openNearby++; + } + } catch { + // ignore + } + candidates.push({ + cell, + distance, + direction: dir, + pressureAtTarget: pt.localPressure, + openNearby, + }); + } + + // Sort: prefer low pressure, more open nearby cells, shorter distance. + candidates.sort( + (a, b) => + (a.pressureAtTarget === 'low' ? 0 : 1) - + (b.pressureAtTarget === 'low' ? 0 : 1) || + b.openNearby - a.openNearby || + a.distance - b.distance || + directionSectorIndex(a.direction) - directionSectorIndex(b.direction) || + a.cell.localeCompare(b.cell), + ); + + const picked = candidates.slice(0, 2); + const startIndex = workerOptions.length; + return picked.map((c, i): StrategicOption => { + const continuesActive = + ctx.retainedDirective?.mission === 'relocate' && + ctx.retainedDirective.targetCell === c.cell; + return { + optionId: `w${workerIndex}_o${startIndex + i}`, + mission: 'relocate', + targetCell: c.cell, + direction: c.direction, + distance: c.distance, + targetState: 'open', + territoryRelation: territoryRelation(state, ctx.agentId, c.cell), + pressureAtTarget: c.pressureAtTarget, + pressureEffect: pressureEffect(currentCell, c.cell, pressureCells), + crowding: crowdingAtCell( + state, + ctx.agentId, + c.cell, + ctx.retainedDirectiveByAgent, + ), + continuesActiveDirective: continuesActive, + description: + `Relocate ${c.direction ?? 'far'} to an open-rich anchor cell (${c.openNearby} open neighbours).`.slice( + 0, + 160, + ), + }; + }); +} + +/** Compile reinforce option (at most 1). */ +function compileReinforceOption( + ctx: WorkerContext, + state: WorldState, + workerOptions: StrategicOption[], +): StrategicOption[] { + const { workerIndex, currentCell, pressureCells, pressureContext } = ctx; + + // Prefer abandoned infected cells (captured territory) first. + const abandonedCells: H3Cell[] = []; + for (const [cell, hex] of state.hexes.entries()) { + if (cell === currentCell) continue; + if (hex.state === 'infected' && hex.controllerAgentId === null) + abandonedCells.push(cell); + } + + let target: H3Cell | null = null; + if (abandonedCells.length > 0) { + // Pick nearest abandoned cell, tie-break by direction then cell string. + abandonedCells.sort( + (a, b) => + safeGridDistance(currentCell, a) - safeGridDistance(currentCell, b) || + directionSectorIndex(tryDirection(currentCell, a)) - + directionSectorIndex(tryDirection(currentCell, b)) || + a.localeCompare(b), + ); + target = abandonedCells[0]!; + } else if ( + pressureContext.localPressure === 'rising' || + pressureContext.localPressure === 'high' + ) { + // Under rising/high pressure, find a swarm-infected border cell nearest pressure. + const borderCells: { cell: H3Cell; pressureDist: number }[] = []; + for (const [cell, hex] of state.hexes.entries()) { + if (cell === currentCell) continue; + if (hex.state !== 'infected' || hex.controllerAgentId === null) continue; + const pressureDist = minDistanceToPressure(cell, pressureCells); + borderCells.push({ cell, pressureDist }); + } + borderCells.sort( + (a, b) => + a.pressureDist - b.pressureDist || + safeGridDistance(currentCell, a.cell) - + safeGridDistance(currentCell, b.cell) || + a.cell.localeCompare(b.cell), + ); + target = borderCells[0]?.cell ?? null; + } + + if (!target) return []; + + // Validate: reinforce target must be infected or adjacent to infection. + const dummy: SwarmDirective = { + id: 'validate-only', + agentId: ctx.agentId, + mission: 'reinforce', + targetCell: target, + priority: 'normal', + riskTolerance: 'medium', + issuedAtTick: 0, + expiresAtTick: 1, + }; + if (swarmDirectiveIssue(state, dummy) !== null) return []; + + const distance = safeGridDistance(currentCell, target); + const dir = tryDirection(currentCell, target); + const ts = cellTargetState(state, target); + const rel = territoryRelation(state, ctx.agentId, target); + const pt = localPressureFromCells(target, pressureCells); + const continuesActive = + ctx.retainedDirective?.mission === 'reinforce' && + ctx.retainedDirective.targetCell === target; + return [ + { + optionId: `w${workerIndex}_o${workerOptions.length}`, + mission: 'reinforce', + targetCell: target, + direction: dir, + distance, + targetState: ts, + territoryRelation: rel, + pressureAtTarget: pt.localPressure, + pressureEffect: pressureEffect(currentCell, target, pressureCells), + crowding: crowdingAtCell( + state, + ctx.agentId, + target, + ctx.retainedDirectiveByAgent, + ), + continuesActiveDirective: continuesActive, + description: + `Reinforce ${dir ?? 'nearby'} to ${ts === 'abandoned' ? 'reclaim abandoned territory' : 'reinforce infected border'}.`.slice( + 0, + 160, + ), + }, + ]; +} + +/** Emit a "continue active directive" option when the retained directive is still valid. */ +function compileContinueDirectiveOption( + ctx: WorkerContext, + state: WorldState, + compiledOptions: StrategicOption[], +): StrategicOption | null { + const { workerIndex, currentCell, pressureCells, retainedDirective } = ctx; + if (!retainedDirective) return null; + // Already included via hold/expand/etc. if the option was compiled. + if ( + compiledOptions.some( + (o) => + o.continuesActiveDirective && + o.mission === retainedDirective.mission && + o.targetCell === retainedDirective.targetCell, + ) + ) + return null; + // Must not be expired. + // NOTE: tickNumber-based expiry is checked by the caller, not here. + // Must still pass semantic validation. + if (swarmDirectiveIssue(state, retainedDirective) !== null) return null; + const target = retainedDirective.targetCell; + if (target !== null) { + if (!state.hexes.has(target)) return null; + } + const distance = target ? safeGridDistance(currentCell, target) : 0; + const dir = target ? tryDirection(currentCell, target) : null; + const ts = target ? cellTargetState(state, target) : null; + const rel = target ? territoryRelation(state, ctx.agentId, target) : null; + const pt = target + ? localPressureFromCells(target, pressureCells) + : localPressureFromCells(currentCell, pressureCells); + const crowd = target + ? crowdingAtCell(state, ctx.agentId, target, ctx.retainedDirectiveByAgent) + : 0; + return { + optionId: `w${workerIndex}_o${compiledOptions.length}`, + mission: retainedDirective.mission, + targetCell: target, + direction: dir, + distance, + targetState: ts, + territoryRelation: rel, + pressureAtTarget: pt.localPressure, + pressureEffect: target + ? pressureEffect(currentCell, target, pressureCells) + : 'none', + crowding: crowd, + continuesActiveDirective: true, + description: + `Continue active ${retainedDirective.mission} directive${dir ? ` toward ${dir}` : ''}.`.slice( + 0, + 160, + ), + }; +} + +export interface CompileStrategicOptionsInput { + state: WorldState; + zeroAgentId: AgentId; + tickNumber: number; + /** + * Cell coordinates of bounded disinfection events from `boundedPressureEvents()`. + * Used for pressure distance calculations. + */ + pressureEventCells: readonly H3Cell[]; + lastPlanDirectives: readonly SwarmDirective[] | null; +} + +/** + * Compiles semantic strategic options for each non-Zero worker. Workers are + * processed in stable sorted-agentId order for deterministic greedy + * deconfliction. Returns a Map keyed by agentId in that stable order. + */ +export function compileStrategicOptions( + input: CompileStrategicOptionsInput, +): Map { + const { + state, + zeroAgentId, + tickNumber, + pressureEventCells, + lastPlanDirectives, + } = input; + + // Sorted worker list (stable, deterministic). + const workers = [...state.agents.values()] + .filter((a) => a.id !== zeroAgentId) + .sort((a, b) => a.id.localeCompare(b.id)); + + // Build retained-directive lookup (by agentId, not-expired). + const retainedDirectiveByAgent = new Map(); + if (lastPlanDirectives) { + for (const d of lastPlanDirectives) { + if (d.expiresAtTick >= tickNumber) + retainedDirectiveByAgent.set(d.agentId, d); + } + } + + // Claimed set for greedy deconfliction of expand top targets. + const claimed = new Set(); + + const result = new Map(); + + for (const [workerIndex, worker] of workers.entries()) { + const { id: agentId, currentCell } = worker; + const retainedDirective = retainedDirectiveByAgent.get(agentId) ?? null; + const pressureContext = localPressureFromCells( + currentCell, + pressureEventCells, + ); + + const ctx: WorkerContext = { + agentId, + workerIndex, + currentCell, + retainedDirective, + pressureContext, + pressureCells: pressureEventCells, + retainedDirectiveByAgent, + }; + + const options: StrategicOption[] = []; + + // 1. Hold (always first, always present, targetCell = null). + const holdOption = buildHoldOption( + workerIndex, + currentCell, + pressureEventCells, + retainedDirective, + options.length, + ); + options.push(holdOption); + + // 2. Continue active directive (if valid and not yet covered). + if ( + retainedDirective && + retainedDirective.expiresAtTick >= tickNumber && + retainedDirective.mission !== 'hold' + ) { + const contOpt = compileContinueDirectiveOption(ctx, state, options); + if (contOpt) options.push(contOpt); + } + + // 3. Expand options (≤3, diversified by sector). + const expandOpts = compileExpandOptions(ctx, state, claimed, options); + options.push(...expandOpts); + + // Register the top expand target (if any) in the claimed set. + const topExpand = expandOpts.find((o) => o.mission === 'expand'); + if (topExpand?.targetCell) claimed.add(topExpand.targetCell); + + // 4. Evade options (≤2, only under pressure). + const evadeOpts = compileEvadeOptions(ctx, state, options); + options.push(...evadeOpts); + + // 5. Relocate options (≤2, when needed). + const relocateOpts = compileRelocateOptions( + ctx, + state, + expandOpts, + options, + ); + options.push(...relocateOpts); + + // 6. Reinforce option (≤1). + const reinforceOpts = compileReinforceOption(ctx, state, options); + options.push(...reinforceOpts); + + // Priority-aware bounding: never exceed 8. + // Hold (index 0) and continue-active options are always kept. + // Under pressure: evade > reinforce > expand > relocate. + // Without pressure: expand > reinforce > relocate. + // Cap relocate at 1. + const underPressure = pressureContext.localPressure !== 'low'; + const alwaysKeep = options.filter( + (o) => o.mission === 'hold' || o.continuesActiveDirective, + ); + const remaining = options.filter((o) => !alwaysKeep.includes(o)); + const missionOrder = underPressure + ? (['evade', 'reinforce', 'expand', 'relocate'] as const) + : (['expand', 'reinforce', 'relocate'] as const); + const bounded: StrategicOption[] = [...alwaysKeep]; + let relocateCount = 0; + for (const mission of missionOrder) { + if (bounded.length >= 8) break; + for (const opt of remaining.filter((o) => o.mission === mission)) { + if (bounded.length >= 8) break; + if (opt.mission === 'relocate') { + if (relocateCount >= 1) continue; + relocateCount++; + } + bounded.push(opt); + } + } + + // Final validation: run every non-hold option through swarmDirectiveIssue so + // that an offered option can never cause #assertSwarmPlan to reject the plan. + const validated = bounded.filter((o) => { + if (o.mission === 'hold') return true; + const dummy: SwarmDirective = { + id: 'validate-only', + agentId, + mission: o.mission, + targetCell: o.targetCell, + priority: 'normal', + riskTolerance: 'medium', + issuedAtTick: tickNumber, + expiresAtTick: tickNumber + 10, + }; + return swarmDirectiveIssue(state, dummy) === null; + }); + + result.set(agentId, validated); + } + + return result; +} diff --git a/apps/game-api/src/swarm-comparison.ts b/apps/game-api/src/swarm-comparison.ts index 1fc5f26..fb67df5 100644 --- a/apps/game-api/src/swarm-comparison.ts +++ b/apps/game-api/src/swarm-comparison.ts @@ -13,7 +13,6 @@ import { type SwarmPlan, type ZeroStrategicObservation, } from '@hexzero/shared'; -import { gridDistance } from 'h3-js'; import { generateDeterministicRoster } from '@hexzero/world-engine'; import { SimulationService } from './simulation-service'; import type { CompiledReflexObservation } from './reflex-execution'; @@ -153,31 +152,26 @@ class OfflinePlanner implements SwarmPlanner { ({ action }) => action.type === 'wait', ) ?? observation.legalZeroActions[0]!; - const openTargets = observation.strategicTargetCells.filter((cell) => - observation.cells.some( - ({ cell: knownCell, state }) => knownCell === cell && state === 'open', - ), - ); const plan: SwarmPlan = { strategySummary: 'Deterministic offline perimeter expansion.', zeroActionCandidateId: zeroAction.id, - directives: observation.agents - .filter(({ agentId }) => agentId !== observation.zeroAgentId) - .map((agent, index) => { - const openTarget = nearestTarget(agent.position, openTargets); - return { - id: `offline-${observation.tickNumber}-${index}`, - agentId: agent.agentId, - mission: openTarget ? ('expand' as const) : ('hold' as const), - targetCell: openTarget ?? agent.position, - priority: 'normal' as const, - riskTolerance: 'medium' as const, - issuedAtTick: observation.tickNumber, - // PR D cadence: directives normally cover five ticks, with events - // still able to bring Zero back sooner. - expiresAtTick: observation.tickNumber + 4, - }; - }), + directives: observation.workerOptions.map((wo, index) => { + const expandOpt = wo.options.find((o) => o.mission === 'expand'); + const holdOpt = wo.options.find((o) => o.mission === 'hold')!; + const chosen = expandOpt ?? holdOpt; + return { + id: `offline-${observation.tickNumber}-${index}`, + agentId: wo.agentId, + mission: chosen.mission, + targetCell: chosen.targetCell, + priority: 'normal' as const, + riskTolerance: 'medium' as const, + issuedAtTick: observation.tickNumber, + // PR D cadence: directives normally cover five ticks, with events + // still able to bring Zero back sooner. + expiresAtTick: observation.tickNumber + 4, + }; + }), }; finalize?.({ outcome: 'completed', @@ -188,25 +182,6 @@ class OfflinePlanner implements SwarmPlanner { } } -function nearestTarget( - position: ZeroStrategicObservation['agents'][number]['position'], - targets: readonly ZeroStrategicObservation['strategicTargetCells'][number][], -) { - return [...targets].sort((left, right) => { - const leftDistance = safeDistance(position, left); - const rightDistance = safeDistance(position, right); - return leftDistance - rightDistance || left.localeCompare(right); - })[0]; -} - -function safeDistance(left: string, right: string): number { - try { - return gridDistance(left, right); - } catch { - return Number.MAX_SAFE_INTEGER; - } -} - /** Select from the server's opaque legal candidate map without a provider call. */ function selectGreedyCandidate(compiled: CompiledReflexObservation): string { return (compiled.observation.candidates.find(({ description }) => diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 850771c..88d5948 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -172,7 +172,7 @@ Equivalent legal moves are ordered reproducibly from world seed, stable agent ID - `POST /api/simulation/experiment/setup/roster/generate` — generate a deterministic roster - `POST /api/simulation/experiment/setup/location-search` — resolve a location query via the Nominatim adapter - `POST /api/simulation/experiment/export/preview` — validate filters and report subset size, retention, and cost -- `POST /api/simulation/experiment/export` — construct one schema-v12 safe JSON document +- `POST /api/simulation/experiment/export` — construct one schema-v13 safe JSON document - `POST /api/simulation/experiment/export/archive` — import the exact generated safe document into the configured local SQLite archive - `GET /api/simulation/models` — return the cached, sanitized compatible model catalog - `POST /api/simulation/models/refresh` — explicitly refresh that catalog @@ -187,10 +187,11 @@ World reset reconstructs deterministic positions, 127 open cells, empty events a Agent Zero is the generative planner for the roster; every applied scenario designates one roster agent for the role through `patientZeroAgentId`, which -World Lab badges HEX-0. One OpenRouter call under the `swarm-planner-v1` +World Lab badges HEX-0. One OpenRouter call under the `swarm-planner-v2` contract is made only when strategic replanning is required; otherwise the -last directive set is reused. When called, it produces a strategy summary, -per-worker directives, and Agent Zero's own action candidate. Workers resolve +last directive set is reused. When called, it selects one opaque `optionId` +per worker (from server-compiled semantic options) and Agent Zero's own action +candidate; the server resolves each `optionId` to `(mission, targetCell)`. Workers resolve their directives with TypeSafe Jev reflex cognition; Agent Zero receives no extra movement, action, infection, capture, or ownership authority beyond the action candidate it selects like any other agent. @@ -229,9 +230,12 @@ One tick executes as follows: 4. **Zero planning.** The service builds Zero's strategic observation from the frozen candidate world — including pressure events, completed directives, and legal Zero action candidates — then calls Agent Zero via the OpenRouter - `swarm-planner-v1` contract. Zero returns a strategy summary, per-worker - directives, and its own action candidate ID. On failure the service falls - back to a deterministic plan; the planner attempt is still recorded. + `swarm-planner-v2` contract. The observation contains semantic strategic + options (opaque `optionId` per worker, no raw H3 cell IDs or agent IDs) and a + coarse `worldSummary`; Zero selects an `optionId` per worker and a Zero action + candidate ID. The server resolves each `optionId` → `(mission, targetCell)` + authoritatively. On failure the service falls back to a deterministic plan; + the planner attempt is still recorded. 5. **Worker reflex dispatch (sequential).** For each worker in seeded order: compile a local observation with the assigned directive and history; call @@ -283,13 +287,13 @@ The agent runtime follows [OpenRouter's usage-accounting contract](https://openr `packages/world-engine` remains deterministic and has no model, HTTP, UI, storage, or credential dependency. It validates world actions independently. Direct proximity is derived from a separately supplied pre-action state. -`packages/agent-runtime` contains the OpenRouter swarm planner, the TypeSafe Jev reflex adapter, and the server-only catalog client. The planner contract (`swarm-planner-v1`) requires text input/output, chat completions, `max_tokens`, non-streaming operation, and at least 16,384 context tokens. The centralized floor covers the bounded complete observation and fixed prompt while reserving a 4,096-token completion ceiling for the JSON decision. Catalog requests use matching server filters, then locally validate every entry. Inference requests deliberately omit tools, `tool_choice`, `response_format`, and `provider.require_parameters`. Provider-default reasoning omits `reasoning`; Off sends `{ enabled: false, exclude: true }`; an advertised effort sends `{ enabled: true, effort, exclude: true }`. No model-family logic, allowlist, compatibility flag, or model default exists. +`packages/agent-runtime` contains the OpenRouter swarm planner, the TypeSafe Jev reflex adapter, and the server-only catalog client. The planner contract (`swarm-planner-v2`) requires text input/output, chat completions, `max_tokens`, non-streaming operation, and at least 16,384 context tokens. The centralized floor covers the bounded complete observation and fixed prompt while reserving a 4,096-token completion ceiling for the JSON decision. Catalog requests use matching server filters, then locally validate every entry. Inference requests deliberately omit tools, `tool_choice`, `response_format`, and `provider.require_parameters`. Provider-default reasoning omits `reasoning`; Off sends `{ enabled: false, exclude: true }`; an advertised effort sends `{ enabled: true, effort, exclude: true }`. No model-family logic, allowlist, compatibility flag, or model default exists. The catalog has an eight-second timeout and five-minute in-memory TTL. A successful response replaces the cache. A timeout, transport/HTTP failure, or malformed response retains the last successful catalog and marks it stale with a safe error; without a prior success it returns an empty error state. Manual refresh bypasses TTL while coalescing concurrent refreshes. Agent Zero resolves its model from the global assignment before each planning call, including its reasoning profile. Assignments may change while playback is paused and no provider/reset mutation is active. Each change is exported with timestamp, scope, prior/new slug, prior/new reasoning profile, and the first globally unique record ordinal at which it is effective; tick execution applies the configuration to the next committed tick group. No unavailable model/profile or missing model is substituted. -The centralized 75-second provider abort timeout covers the complete response lifecycle, including body reading, response decoding, bounded JSON extraction/repair, normalization, and schema validation, and is cleared after every outcome. The same AbortController supports an explicit non-tick-consuming operator cancellation. Safe records expose only bounded status/code/message/request ID/model/finish-reason/latency/usage fields. Scripted providers are explicit deterministic seams selected only by tests or `HEXZERO_PROVIDER=scripted`; there is no automatic fallback. Manual probes use the `swarm-planner-v1` contract and selected reasoning profile, never mutate or advance the world, may incur a small charge, and are cached only for the current server session by model ID, reasoning profile, and contract version. +The centralized 75-second provider abort timeout covers the complete response lifecycle, including body reading, response decoding, bounded JSON extraction/repair, normalization, and schema validation, and is cleared after every outcome. The same AbortController supports an explicit non-tick-consuming operator cancellation. Safe records expose only bounded status/code/message/request ID/model/finish-reason/latency/usage fields. Scripted providers are explicit deterministic seams selected only by tests or `HEXZERO_PROVIDER=scripted`; there is no automatic fallback. Manual probes use the `swarm-planner-v2` contract and selected reasoning profile, never mutate or advance the world, may incur a small charge, and are cached only for the current server session by model ID, reasoning profile, and contract version. The deadline is shared across the planning call and all worker reflex calls in one tick rather than renewed per call. Tick browser mutations carry bounded client operation IDs and repeated delivery is coalesced server-side. When a proxy connection resets or a response is otherwise lost, World Lab clears its local guard, refetches the authoritative snapshot, and shows a height-stable reconciling state while polling an active tick. It never resubmits merely because a response was ambiguous. diff --git a/docs/GAMEPLAY_FOUNDATION.md b/docs/GAMEPLAY_FOUNDATION.md index 3a089f8..f8d151e 100644 --- a/docs/GAMEPLAY_FOUNDATION.md +++ b/docs/GAMEPLAY_FOUNDATION.md @@ -2,10 +2,10 @@ > **Delivery status (2026-08-23, updated for zero-swarm migration):** the > simultaneous agent tick, deterministic virtual clock, shared-deadline -> dispatcher, phased resolution, schema-v12 experiment attribution, and the +> dispatcher, phased resolution, schema-v13 experiment attribution, and the > optional seeded D1 casual cleaner and trail-hunter-v1 simulated-player > profiles are delivered. `zero-swarm-v1` is the only cognition architecture: -> one generative planner (Agent Zero, contract `swarm-planner-v1`) issues +> one generative planner (Agent Zero, contract `swarm-planner-v2`) issues > structured directives; workers resolve them via TypeSafe Jev reflex cognition. > Personalities, agent-to-agent communication, formal alliances, per-worker > goals, and prose memories are removed. Real Player Mode, capture, respawn, diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 84b7f11..d392b60 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -45,19 +45,21 @@ bounded capture context comes from public capture events. Neither projection reads the hunter's selected target, planned route, or hidden state. Current events are projected before worker decisions without being committed early. -In `zero-swarm-v1`, a separate OpenRouter planner receives bounded strategic -facts, an engine-generated target allowlist, and opaque legal Zero action IDs. -The service validates every returned directive and action selection before -worker dispatch. A rejected plan causes an explicit deterministic fallback; -it never grants world mutation authority. Safe swarm tick records contain -structured plans and outcomes, while raw planner messages and responses remain -server-private. Legacy social and prose-memory cognition does not run in this -mode. -The planner's preferred wire response contains only opaque worker, target, and -Zero-action choices. The server maps them to authorized agent/cell/action IDs -and issues directive IDs and lifetimes. Invalid outputs record only a bounded -validation category such as an unknown target choice; they do not retain or -echo the raw model response. When a worker has no unexpired directive from a +In `zero-swarm-v1`, a separate OpenRouter planner (`swarm-planner-v2` contract) +receives bounded strategic facts and opaque legal Zero action IDs. The model +never sees raw H3 cell IDs or agent IDs: it receives pre-compiled semantic +options with opaque `optionId` labels per worker and a coarse `worldSummary` +in place of the raw cell list. The server maps each returned `optionId` to an +authorized `(mission, targetCell)` pair server-side. The service validates +every returned directive and action selection before worker dispatch. A rejected +plan causes an explicit deterministic fallback; it never grants world mutation +authority. Safe swarm tick records contain structured plans and outcomes, while +raw planner messages and responses remain server-private. Legacy social and +prose-memory cognition does not run in this mode. +The planner wire response contains only opaque `optionId` and Zero-action +choices. The server maps them to authorized agent/cell/action IDs and issues +directive IDs and lifetimes. Invalid outputs record only a bounded validation +category; they do not retain or echo the raw model response. When a worker has no unexpired directive from a prior valid Zero plan, it uses deterministic legal local expansion without a Jev request until planning recovers. diff --git a/docs/TESTING.md b/docs/TESTING.md index 3b67b3c..f3b9b07 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -48,16 +48,20 @@ preservation. ### Agent runtime (`packages/agent-runtime`) -`swarm-planner.test.ts` covers the OpenRouter swarm planner: repeatable -observation-derived plans, bounded worker and target choices mapped to -authoritative directives, completed-directive marking in the compact Zero -request, event-derived worker threat and capture context, unknown-choice -rejection, missing-directive reporting, deterministic-plan bounds checking, -ten-tick lifetime enforcement, invented-candidate and omitted-directive -rejection, overloaded-response retry, complete provider accounting (cost, -tokens, usage retention for invalid or unparseable plans, optional cost -omission), non-OK attempt attribution, cancellation safety, and secret/ -observation-data exclusion from response metadata. +`swarm-planner.test.ts` covers the OpenRouter swarm planner (`swarm-planner-v2` +contract): repeatable observation-derived plans, opaque `optionId` selection +mapped to authoritative directives server-side, no raw H3 cell IDs or agent +IDs in the model request, `worldSummary` present, completed-directive marking +in the compact Zero request, event-derived worker threat and capture context, +unknown-option-choice rejection, cross-worker optionId rejection (optionId +belonging to a different worker), v1-shaped plan rejection (agentId/mission/ +targetCell/no optionId format rejected — the model can never return raw H3), +repeated-workerId rejection, missing-directive reporting, deterministic-plan +bounds checking, ten-tick lifetime enforcement, invented-candidate and +omitted-directive rejection, overloaded-response retry, complete provider +accounting (cost, tokens, usage retention for invalid or unparseable plans, +optional cost omission), non-OK attempt attribution, cancellation safety, and +secret/observation-data exclusion from response metadata. `typesafe-jev-reflex-provider.test.ts` covers the TypeSafe Jev reflex provider: current-legal-candidate reuse across calls, pinned model and opaque criteria @@ -90,6 +94,37 @@ endpoint, and absence of legacy sequential-turn routes. directives and worker reflex actions in one tick, and reset without retained ticks. +`strategic-options.test.ts` covers the deterministic semantic-option compiler: +hold always first (targetCell=null), Zero excluded from worker options, at-most-8 +cap, at-most-3 sector-diversified expand options, stable opaque optionIds, +greedy deconfliction across workers, continuesActiveDirective marking, evade +only under pressure (never reduces separation), unique optionIds across workers, +no raw H3 cell IDs or agent IDs in descriptions, all non-hold options in the +20-worker/radius-12 world with pressure and abandoned cell pass swarmDirectiveIssue, +reinforce-reclaim-abandoned survives priority bounding under pressure with a +continue option, expand candidate scan widens beyond radius-4 when no local +open cells exist, real `buildSwarmPlannerRequest` prompt-size diagnostic using +three scenarios (numbers measured 2026-09-22): + +| Scenario | Config | v2 message bytes | ≈ tokens | +| -------- | -------------------------------------------------------- | ---------------- | -------- | +| (a) | 8 workers / radius 6 / 127 cells | 13,047 B | ~3,262 | +| (b) | 20 workers / radius 12 / 469 cells | 29,478 B | ~7,370 | +| (c) | 8 workers / radius 12 / 469 cells | 13,281 B | ~3,320 | +| v1 est. | 20 workers / 469 cells (full cells + UUIDs + 80 targets) | 39,567 B | ~9,892 | + +Assertions: (c) within 10 % of (a) (world-cell count does not drive message +size at fixed roster), per-worker bytes in (b) ≤ 2,500 (measured: 1,474 B/worker), +(b) < v1 estimate. + +**Option compaction (scenario b):** before compaction (all option fields +included) = 38,374 B (~9,594 tokens); after (defaults omitted) = 29,478 B +(~7,370 tokens); saved = 8,896 B (~2,224 tokens, 23 % reduction). + +And 20-worker radius-12 top-expand targets are diverse (≥70% +distinct targets, ≥2 direction sectors; typically 3 directions observed +because ring-12 workers all expand inward). + `simulation-service.swarm.test.ts` covers full swarm tick scenarios using scripted providers: conservative and elevated-pressure replan thresholds, current-tick disinfection escalation into the next Zero replan, terminal @@ -99,10 +134,10 @@ valid API response without legacy turn records, frozen-facts ordering and physical-action engine resolution, first-plan failure with one billed Zero attempt and deterministic local expansion, directive reuse for four ticks followed by replanning on the fifth, worker replan request, retained-directive -expiry, at-target and advancing-toward-target Zero reporting, reuse-tick -reservation release on cancellation, simulated-player event export, failed-Jev -wait fallback, cancellation without committed world, relocate completion and -completed-directive identification, and expand completion only after +expiry, hold-waiting-worker stalled status, advancing-toward-target Zero +reporting, reuse-tick reservation release on cancellation, simulated-player +event export, failed-Jev wait fallback, cancellation without committed world, +completed-directive identity reporting to Zero, and expand completion only after worker control. `reflex-execution.test.ts` covers the reflex execution seam: scripted end-to- diff --git a/docs/adr/0034-semantic-strategic-options.md b/docs/adr/0034-semantic-strategic-options.md new file mode 100644 index 0000000..06c2e12 --- /dev/null +++ b/docs/adr/0034-semantic-strategic-options.md @@ -0,0 +1,118 @@ +# ADR 0034 – Semantic strategic options for Agent Zero (`swarm-planner-v2`) + +**Status:** Accepted +**Date:** 2026-09-22 + +## Context + +Under `swarm-planner-v1`, the model request contained the full global target +list and agent positions. The concrete mechanism: retained directive targets + +agent positions + all world hexes sorted lexicographically were concatenated, +sliced to a hard cap of 80 entries, and the full `cells` array was serialized +into every request. The `agents` array carried raw UUID agent IDs. + +Evidence from a 20-agent / ~469-cell / 10-tick real-provider run: + +- Input tokens reached ~15,000–16,000 per planning call. +- ~140 of ~183 worker decisions were `hold`; only ~43 were `expand`. +- Final infection was ~22 of 469 cells — near-zero territory gain despite 10 + ticks of planning. +- Zero's strategy summaries described expansion while the actual directives + assigned `hold` to most workers; the model could not reliably reason about + which cells were reachable or strategically useful from raw H3 strings. + +Lexicographic slicing meant strategically important frontier cells that happened +to sort after index 80 were silently omitted, biasing every plan toward cells +near the top of the H3 index regardless of direction or proximity. + +This produces several problems: + +1. **Prompt size grows linearly with world size.** Coordinate strings dominate + the token budget before any semantic content appears. +2. **Lexicographic truncation.** The 80-entry slice reflects H3 sort order, not + strategic relevance; frontier cells in certain directions are invisible. +3. **Model reasoning over raw geometry.** Models cannot reliably reason about + hexagonal adjacency from opaque H3 strings. Semantic descriptions (direction, + distance, territory relation) produce better plans. +4. **Agent ID leakage.** Passing agent UUIDs into model context is unnecessary + and widens the surface for prompt-injection reflection. + +## Decision + +Introduce a deterministic server-side **semantic option compiler** +(`compileStrategicOptions`) that runs before each Zero planning call. The +compiler produces a bounded, per-worker `StrategicOption[]` with: + +- An opaque `optionId` (`w_o`) — the only handle the model sees. +- Semantic fields: `mission`, `direction`, `distance`, `targetState`, + `territoryRelation`, `pressureAtTarget`, `pressureEffect`, `crowding`, + `continuesActiveDirective`, `description` (max 160 chars, no raw IDs). +- No `targetCell` in the model request; the server holds the authoritative + `optionId → (mission, targetCell)` mapping. + +The observation also replaces the raw cell list with a coarse `worldSummary` +with fields `totalCells`, `openCells`, `swarmInfectedCells`, +`abandonedInfectedCells`, and `openFrontierCells`. + +Contract version → `'swarm-planner-v2'`; export `schemaVersion` → `13`. +No import compatibility layer for earlier exports (v12 and below); legacy +archives require the pre-migration Git revision. + +### Option generation rules + +| Priority | Mission | Cap | Condition | +| ---------------- | --------------- | ------ | --------------------------------------------- | +| 0 (always) | hold | 1 | always present, targetCell=null | +| 1 (always) | continue-active | 1 | retained directive still valid | +| 2 under pressure | evade | ≤2 | pressure within radius 3 | +| 3 under pressure | reinforce | ≤1 | abandoned cell exists or rising/high pressure | +| 4/2 by pressure | expand | ≤3 | open cells found; sector-diversified | +| 5/3 by pressure | relocate | **≤1** | crowded or no open cell within radius 2 | +| Total | — | **≤8** | priority-aware; hold+continue always kept | + +Expand candidates are scanned within radius 4 of the worker first; the scan +widens to world-wide only when no open cells exist within that radius. Sector +diversification (one expand per 60-degree direction) prevents clustering. A +greedy `claimed` set across workers penalises repeated top targets. + +Every non-hold compiled option is run through `swarmDirectiveIssue()` as a +final filter, so the server can never offer an option that would cause +`#assertSwarmPlan` to reject the plan. + +### Wire contract + +- `compactPlanSchema` response: `{workerId, optionId, priority, riskTolerance}` + (mission and targetCell removed; optionId resolves both server-side) + +## Consequences + +**Positive:** + +- Model prompt shrinks: the v2 user message for 20 workers / 469 cells + (worldSummary + 20 workers × ≤8 semantic options, no raw coordinates) + measures ~28 KB vs the v1 equivalent (full 469-cell `cells` array + 20 agent + UUIDs + 80-entry `strategicTargetCells`) at ~37 KB. + `strategic-options.test.ts` asserts `v2Bytes < v1Bytes` for this fixture. +- Lexicographic truncation is impossible: options are selected by score and + sector, not coordinate sort order. +- Agent IDs never enter the model context. +- Offered options are pre-validated via `swarmDirectiveIssue`, so server-side + assertion is a subset check rather than a re-validation. +- Diversity is enforced: in the 20-worker/radius-12 fixture, ≥70% of workers + have distinct top-expand targets (greedy deconfliction) and ≥2 distinct + direction sectors are represented (typically 3 in this fixture, as workers + at ring-12 all expand inward). + +**Neutral / trade-offs:** + +- The server must compile options before every Zero call (bounded cost: O(W × C) + where W = workers, C ≤ gridDisk(4) = 127 cells per worker in the default + world). +- `hold` options now have `targetCell=null`. The `workerStatus='at-target'` + condition was extended to also fire on `mission==='hold' && targetCell===null`, + preserving the existing semantic that a holding worker is on-target. + +**Negative:** + +- The compiler is a new layer that must be kept aligned with `swarmDirectiveIssue` + semantics. The final-filter ensures consistency but adds per-option validation. diff --git a/packages/agent-runtime/src/swarm-planner.test.ts b/packages/agent-runtime/src/swarm-planner.test.ts index 7f7a518..7674316 100644 --- a/packages/agent-runtime/src/swarm-planner.test.ts +++ b/packages/agent-runtime/src/swarm-planner.test.ts @@ -50,7 +50,48 @@ const observation = zeroStrategicObservationSchema.parse({ description: 'Wait on the current cell.', }, ], - strategicTargetCells: [cell, openCell], + worldSummary: { + totalCells: 2, + openCells: 1, + swarmInfectedCells: 1, + abandonedInfectedCells: 0, + openFrontierCells: 1, + }, + workerOptions: [ + { + agentId: worker, + options: [ + { + optionId: 'w0_o0', + mission: 'hold' as const, + targetCell: null, + direction: null, + distance: 0, + targetState: null, + territoryRelation: null, + pressureAtTarget: 'low' as const, + pressureEffect: 'none' as const, + crowding: 0, + continuesActiveDirective: false, + description: 'Hold current position.', + }, + { + optionId: 'w0_o1', + mission: 'expand' as const, + targetCell: openCell, + direction: 'N' as const, + distance: 1, + targetState: 'open' as const, + territoryRelation: 'open-frontier' as const, + pressureAtTarget: 'low' as const, + pressureEffect: 'none' as const, + crowding: 0, + continuesActiveDirective: false, + description: 'Expand to open frontier cell.', + }, + ], + }, + ], }); const plan = swarmPlanSchema.parse({ strategySummary: 'Expand carefully.', @@ -60,7 +101,7 @@ const plan = swarmPlanSchema.parse({ id: 'directive-1', agentId: worker, mission: 'expand' as const, - targetCell: cell, + targetCell: openCell, priority: 'normal' as const, riskTolerance: 'medium' as const, issuedAtTick: 1, @@ -74,8 +115,7 @@ const compactPlan = { directives: [ { workerId: 'worker_0', - mission: 'expand', - targetId: 'target_0', + optionId: 'w0_o1', priority: 'normal', riskTolerance: 'medium', }, @@ -115,7 +155,7 @@ describe('swarm planners', () => { id: 'directive-1-worker_0', agentId: worker, mission: 'expand', - targetCell: cell, + targetCell: openCell, priority: 'normal', riskTolerance: 'medium', issuedAtTick: 1, @@ -136,14 +176,36 @@ describe('swarm planners', () => { exclude: true, }); expect(body.max_tokens).toBe(4_096); - expect(body.messages[0]?.content).toContain('Code supplies directive IDs'); expect(body.messages[0]?.content).toContain( - 'Expand targets must be open cells.', + 'Do not output mission or targetCell fields', ); - expect(JSON.parse(body.messages[1]!.content)).toMatchObject({ - workers: [{ workerId: 'worker_0', position: cell }], - targetChoices: expect.arrayContaining([{ targetId: 'target_0', cell }]), + expect(body.messages[0]?.content).toContain( + 'pre-validated mission options', + ); + const requestContent = JSON.parse(body.messages[1]!.content) as Record< + string, + unknown + >; + expect(requestContent).toMatchObject({ + worldSummary: { + totalCells: 2, + openCells: 1, + swarmInfectedCells: 1, + }, + workers: [ + expect.objectContaining({ + workerId: 'worker_0', + options: expect.arrayContaining([ + expect.objectContaining({ optionId: 'w0_o1', mission: 'expand' }), + ]), + }), + ], }); + // targetCell must not appear in the request body (server-only field). + expect(body.messages[1]!.content).not.toContain(openCell); + // No raw H3 cell IDs or agent IDs in the user message. + expect(body.messages[1]!.content).not.toContain(cell); + expect(body.messages[1]!.content).not.toContain(worker); }); it('marks completed worker directives in the compact Zero request', async () => { @@ -211,7 +273,11 @@ describe('swarm planners', () => { expect(body.messages[0]?.content).toContain( 'Hold under high pressure only as an intentional defensive or sacrifice choice', ); - expect(JSON.parse(body.messages[1]!.content)).toMatchObject({ + const requestContent = JSON.parse(body.messages[1]!.content) as Record< + string, + unknown + >; + expect(requestContent).toMatchObject({ workers: [ { workerId: 'worker_0', @@ -222,13 +288,17 @@ describe('swarm planners', () => { ], recentCaptures: [ { - capturedAgentId: worker, - cell, originatingTick: 1, abandonedCellCount: 3, }, ], }); + // Cell IDs and agent IDs must not appear in the recentCaptures payload. + const capturesJson = JSON.stringify( + requestContent['recentCaptures'] as unknown[], + ); + expect(capturesJson).not.toContain(cell); + expect(capturesJson).not.toContain(worker); expect(body.messages[1]?.content).not.toContain('targetingState'); expect(body.messages[1]?.content).not.toContain('simulatedPlayerPosition'); }); @@ -240,16 +310,14 @@ describe('swarm planners', () => { fetchImplementation: vi.fn().mockResolvedValue( plannerResponse({ ...compactPlan, - directives: [ - { ...compactPlan.directives[0], targetId: 'target_99' }, - ], + directives: [{ ...compactPlan.directives[0], optionId: 'w0_o99' }], }), ), }).plan(observation, 'test-model'), ).rejects.toMatchObject({ failure: { code: 'invalid-decision', - message: 'Agent Zero plan rejected: unknown target choice.', + message: 'Agent Zero plan rejected: unknown option choice.', }, }); }); @@ -300,7 +368,7 @@ describe('swarm planners', () => { { message: { content: JSON.stringify({ - ...plan, + ...compactPlan, zeroActionCandidateId: 'zero_action_9', }), }, @@ -326,7 +394,7 @@ describe('swarm planners', () => { choices: [ { message: { - content: JSON.stringify({ ...plan, directives: [] }), + content: JSON.stringify({ ...compactPlan, directives: [] }), }, }, ], @@ -355,7 +423,7 @@ describe('swarm planners', () => { JSON.stringify({ id: 'safe-request-id', model: 'test-model', - choices: [{ message: { content: JSON.stringify(plan) } }], + choices: [{ message: { content: JSON.stringify(compactPlan) } }], usage: { prompt_tokens: 4, completion_tokens: 3, total_tokens: 7 }, }), { status: 200 }, @@ -379,7 +447,7 @@ describe('swarm planners', () => { const result = await new OpenRouterSwarmPlanner({ apiKey: 'test-key', fetchImplementation: vi.fn().mockResolvedValue( - plannerResponse(plan, { + plannerResponse(compactPlan, { prompt_tokens: 101, completion_tokens: 23, total_tokens: 124, @@ -410,7 +478,7 @@ describe('swarm planners', () => { apiKey: 'test-key', fetchImplementation: vi.fn().mockResolvedValue( plannerResponse( - { ...plan, zeroActionCandidateId: 'invented' }, + { ...compactPlan, zeroActionCandidateId: 'invented' }, { prompt_tokens: 11, completion_tokens: 5, @@ -456,7 +524,7 @@ describe('swarm planners', () => { apiKey: 'test-key', fetchImplementation: vi .fn() - .mockResolvedValue(plannerResponse(plan)), + .mockResolvedValue(plannerResponse(compactPlan)), }).plan(observation, 'test-model'); expect(withoutUsage.metadata).not.toHaveProperty('costCredits'); const malformedCost = await new OpenRouterSwarmPlanner({ @@ -464,7 +532,7 @@ describe('swarm planners', () => { fetchImplementation: vi .fn() .mockResolvedValue( - plannerResponse(plan, { prompt_tokens: 9, cost: 'untrusted' }), + plannerResponse(compactPlan, { prompt_tokens: 9, cost: 'untrusted' }), ), }).plan(observation, 'test-model'); expect(malformedCost.metadata).toMatchObject({ promptTokens: 9 }); @@ -483,7 +551,7 @@ describe('swarm planners', () => { { status: 529 }, ), ) - .mockResolvedValueOnce(plannerResponse(plan)), + .mockResolvedValueOnce(plannerResponse(compactPlan)), }).plan(observation, 'test-model', { beginAttempt: () => (completion) => finalized.push(completion), }); @@ -534,7 +602,7 @@ describe('swarm planners', () => { }); it('excludes echoed secrets and worker observation data from response metadata', async () => { - const echoed = `Bearer test-key ${cell}`; + const echoed = `Bearer test-key`; const success = await new OpenRouterSwarmPlanner({ apiKey: 'test-key', fetchImplementation: vi.fn().mockResolvedValue( @@ -542,7 +610,7 @@ describe('swarm planners', () => { JSON.stringify({ id: echoed, model: echoed, - choices: [{ message: { content: JSON.stringify(plan) } }], + choices: [{ message: { content: JSON.stringify(compactPlan) } }], }), { status: 200 }, ), @@ -568,6 +636,238 @@ describe('swarm planners', () => { metadata: { costCredits: 0.005 }, }); }); + // ── multi-worker rejection tests ────────────────────────────────────────── + + const worker2 = '00000000-0000-4000-8000-000000000003'; + const openCell2 = '892a1072887ffff'; + const twoWorkerObservation = zeroStrategicObservationSchema.parse({ + zeroAgentId: zero, + tickNumber: 1, + virtualTime: '2026-09-22T12:00:00.000Z', + cells: [ + { cell, state: 'infected' as const, controllerAgentId: zero }, + { cell: openCell, state: 'open' as const, controllerAgentId: null }, + { cell: openCell2, state: 'open' as const, controllerAgentId: null }, + ], + agents: [ + { + agentId: zero, + position: cell, + controlledCellCount: 1, + territoryDelta: 0, + localPressure: 'low', + pressureDirection: null, + pressureDistance: null, + }, + { + agentId: worker, + position: cell, + controlledCellCount: 0, + territoryDelta: 0, + localPressure: 'low', + pressureDirection: null, + pressureDistance: null, + }, + { + agentId: worker2, + position: cell, + controlledCellCount: 0, + territoryDelta: 0, + localPressure: 'low', + pressureDirection: null, + pressureDistance: null, + }, + ], + recentPlayerPressure: [], + legalZeroActions: [ + { + id: 'zero_action_0', + action: { type: 'wait' as const }, + description: 'Wait on the current cell.', + }, + ], + worldSummary: { + totalCells: 3, + openCells: 2, + swarmInfectedCells: 1, + abandonedInfectedCells: 0, + openFrontierCells: 2, + }, + workerOptions: [ + { + agentId: worker, + options: [ + { + optionId: 'w0_o0', + mission: 'hold' as const, + targetCell: null, + direction: null, + distance: 0, + targetState: null, + territoryRelation: null, + pressureAtTarget: 'low' as const, + pressureEffect: 'none' as const, + crowding: 0, + continuesActiveDirective: false, + description: 'Hold current position.', + }, + { + optionId: 'w0_o1', + mission: 'expand' as const, + targetCell: openCell, + direction: 'N' as const, + distance: 1, + targetState: 'open' as const, + territoryRelation: 'open-frontier' as const, + pressureAtTarget: 'low' as const, + pressureEffect: 'none' as const, + crowding: 0, + continuesActiveDirective: false, + description: 'Expand to open frontier cell.', + }, + ], + }, + { + agentId: worker2, + options: [ + { + optionId: 'w1_o0', + mission: 'hold' as const, + targetCell: null, + direction: null, + distance: 0, + targetState: null, + territoryRelation: null, + pressureAtTarget: 'low' as const, + pressureEffect: 'none' as const, + crowding: 0, + continuesActiveDirective: false, + description: 'Hold current position.', + }, + { + optionId: 'w1_o1', + mission: 'expand' as const, + targetCell: openCell2, + direction: 'S' as const, + distance: 1, + targetState: 'open' as const, + territoryRelation: 'open-frontier' as const, + pressureAtTarget: 'low' as const, + pressureEffect: 'none' as const, + crowding: 0, + continuesActiveDirective: false, + description: 'Expand to open frontier cell.', + }, + ], + }, + ], + }); + + it('rejects a directive whose optionId belongs to a different worker', async () => { + // worker_0 submits w1_o1 — an option that belongs to worker_1 + await expect( + new OpenRouterSwarmPlanner({ + apiKey: 'test-key', + fetchImplementation: vi.fn().mockResolvedValue( + plannerResponse({ + strategySummary: 'Cross-worker option attempt.', + zeroActionCandidateId: 'zero_action_0', + directives: [ + { + workerId: 'worker_0', + optionId: 'w1_o1', // belongs to worker_1, not worker_0 + priority: 'normal', + riskTolerance: 'medium', + }, + { + workerId: 'worker_1', + optionId: 'w1_o0', + priority: 'normal', + riskTolerance: 'medium', + }, + ], + }), + ), + }).plan(twoWorkerObservation, 'test-model'), + ).rejects.toMatchObject({ + failure: { + code: 'invalid-decision', + message: + 'Agent Zero plan rejected: optionId belongs to different worker.', + }, + }); + }); + + it('rejects a model response shaped like a v1 plan (agentId, mission, targetCell, no optionId)', async () => { + // v1-shaped plan: directives carry agentId, mission, targetCell, id, ticks — no optionId. + // The model must never return raw H3 cell IDs; compactPlanSchema must reject this shape. + await expect( + new OpenRouterSwarmPlanner({ + apiKey: 'test-key', + fetchImplementation: vi.fn().mockResolvedValue( + plannerResponse({ + strategySummary: 'Legacy v1 plan shape.', + zeroActionCandidateId: 'zero_action_0', + directives: [ + { + id: 'directive-1', + agentId: worker, + mission: 'expand', + targetCell: openCell, + priority: 'normal', + riskTolerance: 'medium', + issuedAtTick: 1, + expiresAtTick: 5, + }, + { + id: 'directive-2', + agentId: worker2, + mission: 'hold', + targetCell: null, + priority: 'normal', + riskTolerance: 'medium', + issuedAtTick: 1, + expiresAtTick: 5, + }, + ], + }), + ), + }).plan(twoWorkerObservation, 'test-model'), + ).rejects.toMatchObject({ + failure: { code: 'invalid-decision' }, + }); + }); + + it('rejects a plan with a repeated workerId', async () => { + // Two directives both name worker_0; worker_1 is missing. + await expect( + new OpenRouterSwarmPlanner({ + apiKey: 'test-key', + fetchImplementation: vi.fn().mockResolvedValue( + plannerResponse({ + strategySummary: 'Repeated worker attempt.', + zeroActionCandidateId: 'zero_action_0', + directives: [ + { + workerId: 'worker_0', + optionId: 'w0_o0', + priority: 'normal', + riskTolerance: 'medium', + }, + { + workerId: 'worker_0', // repeated — worker_1 missing + optionId: 'w0_o1', + priority: 'normal', + riskTolerance: 'medium', + }, + ], + }), + ), + }).plan(twoWorkerObservation, 'test-model'), + ).rejects.toMatchObject({ + failure: { code: 'invalid-decision' }, + }); + }); }); function plannerResponse(planValue: unknown, usage?: unknown): Response { diff --git a/packages/agent-runtime/src/swarm-planner.ts b/packages/agent-runtime/src/swarm-planner.ts index 62d9220..e594bba 100644 --- a/packages/agent-runtime/src/swarm-planner.ts +++ b/packages/agent-runtime/src/swarm-planner.ts @@ -83,6 +83,10 @@ const openRouterResponseSchema = z.object({ .min(1), }); +/** + * v2 compact plan: model returns opaque optionId per worker; server resolves + * mission + targetCell from the authoritative option map in the observation. + */ const compactPlanSchema = z .object({ strategySummary: z.string().trim().min(1).max(500), @@ -91,17 +95,7 @@ const compactPlanSchema = z z .object({ workerId: z.string().regex(/^worker_[0-9]+$/), - mission: z.enum([ - 'expand', - 'hold', - 'relocate', - 'reinforce', - 'evade', - ]), - targetId: z - .string() - .regex(/^target_[0-9]+$/) - .nullable(), + optionId: z.string().regex(/^w[0-9]+_o[0-9]+$/), priority: z.enum(['low', 'normal', 'high']), riskTolerance: z.enum(['low', 'medium', 'high']), }) @@ -443,27 +437,25 @@ export class DeterministicSwarmPlanner implements SwarmPlanner { message: 'The deterministic planner received no legal Zero action.', retryable: false, }); - const target = - observation.strategicTargetCells.find( - (cell) => - observation.cells.find((candidate) => candidate.cell === cell) - ?.state === 'open', - ) ?? null; + // Pick best expand option per worker; fall back to hold. const plan = swarmPlanSchema.parse({ strategySummary: 'Deterministic swarm perimeter expansion.', zeroActionCandidateId: zeroAction.id, - directives: observation.agents - .filter(({ agentId }) => agentId !== observation.zeroAgentId) - .map(({ agentId }) => ({ - id: `deterministic-${observation.tickNumber}-${agentId}`, - agentId, - mission: target ? 'expand' : 'hold', - targetCell: target, + directives: observation.workerOptions.map((wo) => { + const expandOpt = wo.options.find((o) => o.mission === 'expand'); + const holdOpt = wo.options.find((o) => o.mission === 'hold')!; + const chosen = expandOpt ?? holdOpt; + return { + id: `deterministic-${observation.tickNumber}-${wo.agentId}`, + agentId: wo.agentId, + mission: chosen.mission, + targetCell: chosen.targetCell, priority: 'normal', riskTolerance: 'medium', issuedAtTick: observation.tickNumber, expiresAtTick: observation.tickNumber + 4, - })), + }; + }), }); const metadata = metadataFor(model, 0); finalize?.({ outcome: 'completed', provider: metadata, swarmPlan: plan }); @@ -471,62 +463,100 @@ export class DeterministicSwarmPlanner implements SwarmPlanner { } } -function buildSwarmPlannerRequest( +export function buildSwarmPlannerRequest( observation: ZeroStrategicObservation, model: string, reasoningProfile: ReasoningProfile, ) { - const targetChoices = observation.strategicTargetCells.map((cell, index) => ({ - targetId: `target_${index}`, - cell, - })); - const targetIdByCell = new Map( - targetChoices.map(({ targetId, cell }) => [cell, targetId]), - ); const completedDirectiveByAgent = new Map( (observation.completedDirectives ?? []).map(({ agentId, directiveId }) => [ agentId, directiveId, ]), ); - const workers = observation.agents - .filter(({ agentId }) => agentId !== observation.zeroAgentId) - .map((agent, index) => ({ + const zero = observation.agents.find( + ({ agentId }) => agentId === observation.zeroAgentId, + )!; + // Workers derived from workerOptions (stable sorted agentId order); no H3 or agent IDs sent. + const workers = observation.workerOptions.map((wo, index) => { + const agentFacts = observation.agents.find( + ({ agentId }) => agentId === wo.agentId, + )!; + return { workerId: `worker_${index}`, - position: agent.position, - controlledCellCount: agent.controlledCellCount, - territoryDelta: agent.territoryDelta, - localPressure: agent.localPressure, - pressureDirection: agent.pressureDirection, - pressureDistance: agent.pressureDistance, - workerStatus: agent.workerStatus ?? 'unknown', + controlledCellCount: agentFacts.controlledCellCount, + territoryDelta: agentFacts.territoryDelta, + localPressure: agentFacts.localPressure, + pressureDirection: agentFacts.pressureDirection, + pressureDistance: agentFacts.pressureDistance, + workerStatus: agentFacts.workerStatus ?? 'unknown', directiveComplete: - agent.directive !== null && - agent.directive !== undefined && - completedDirectiveByAgent.get(agent.agentId) === agent.directive.id, - activeDirective: agent.directive + agentFacts.directive !== null && + agentFacts.directive !== undefined && + completedDirectiveByAgent.get(wo.agentId) === agentFacts.directive.id, + activeDirective: agentFacts.directive ? { - mission: agent.directive.mission, - targetId: agent.directive.targetCell - ? (targetIdByCell.get(agent.directive.targetCell) ?? null) - : null, - priority: agent.directive.priority, - riskTolerance: agent.directive.riskTolerance, + mission: agentFacts.directive.mission, + direction: + wo.options.find( + (o) => + o.continuesActiveDirective && + o.mission === agentFacts.directive!.mission, + )?.direction ?? null, + distance: + wo.options.find( + (o) => + o.continuesActiveDirective && + o.mission === agentFacts.directive!.mission, + )?.distance ?? null, + priority: agentFacts.directive.priority, + riskTolerance: agentFacts.directive.riskTolerance, ticksRemaining: Math.max( 0, - agent.directive.expiresAtTick - observation.tickNumber, + agentFacts.directive.expiresAtTick - observation.tickNumber, ), } : null, - })); + // Options projected WITHOUT targetCell (server-only field). + // Fields at their default value are omitted to reduce token cost: + // crowding omitted means 0, continuesActiveDirective omitted means false, + // pressureEffect omitted means none, null fields omitted mean null, + // distance omitted for hold. + options: wo.options.map( + ({ + optionId, + mission, + direction, + distance, + targetState, + territoryRelation, + pressureAtTarget, + pressureEffect, + crowding, + continuesActiveDirective, + description, + }) => ({ + optionId, + mission, + description, + pressureAtTarget, + ...(direction !== null ? { direction } : {}), + ...(mission !== 'hold' ? { distance } : {}), + ...(targetState !== null ? { targetState } : {}), + ...(territoryRelation !== null ? { territoryRelation } : {}), + ...(pressureEffect !== 'none' ? { pressureEffect } : {}), + ...(crowding > 0 ? { crowding } : {}), + ...(continuesActiveDirective ? { continuesActiveDirective } : {}), + }), + ), + }; + }); const workerIdByAgent = new Map( - observation.agents - .filter(({ agentId }) => agentId !== observation.zeroAgentId) - .map(({ agentId }, index) => [agentId, `worker_${index}`]), + observation.workerOptions.map((wo, index) => [ + wo.agentId, + `worker_${index}`, + ]), ); - const zero = observation.agents.find( - ({ agentId }) => agentId === observation.zeroAgentId, - )!; return { model, temperature: 0, @@ -547,22 +577,26 @@ function buildSwarmPlannerRequest( { role: 'system', content: - "You are Agent Zero, a strategic planner. Return only a JSON object with strategySummary, zeroActionCandidateId, and directives. Return exactly one directive per offered worker, using each workerId once. Each directive has only workerId, mission (expand|hold|relocate|reinforce|evade), targetId (one offered targetId or null), priority (low|normal|high), and riskTolerance (low|medium|high). Select zeroActionCandidateId from legalZeroActions. Assign intent, never exact worker movement. Code supplies directive IDs, agent IDs, target cells, and tick lifetimes; do not output those fields. Use replanReasons, directiveComplete, workerReplanRequests, worker localPressure, spatial pressure categories, and recentCaptures when present. Under trail-hunter pressure, a worker caught by the simulated player is permanently captured and removed, and its controlled territory becomes abandoned. Treat sustained high local pressure as an existential threat. Hold under high pressure only as an intentional defensive or sacrifice choice. Non-hold missions need a target. Expand targets must be open cells. Reinforce targets must be infected or adjacent to infection. Do not assign a relocate, reinforce, or evade target equal to that worker's current position.", + "You are Agent Zero, a strategic planner. Return only a JSON object with strategySummary, zeroActionCandidateId, and directives. Return exactly one directive per offered worker, using each workerId once. Each directive has only workerId, optionId (one of the worker's offered optionIds), priority (low|normal|high), and riskTolerance (low|medium|high). Do not output mission or targetCell fields — those come from the chosen option server-side. Select zeroActionCandidateId from legalZeroActions. Assign intent, never exact worker movement. Use replanReasons, directiveComplete, workerReplanRequests, worker localPressure, spatial pressure categories, and recentCaptures when present. Under trail-hunter pressure, a worker caught by the simulated player is permanently captured and removed, and its controlled territory becomes abandoned. Treat sustained high local pressure as an existential threat. Hold under high pressure only as an intentional defensive or sacrifice choice. Each worker has a hold option (deliberate, not a default) and bounded pre-validated mission options with crowding, pressure, and territory context. Options with crowding>0 duplicate other workers' fronts. Idle workers waste the swarm — avoid assigning hold to workers with viable expand options unless under high threat. Option fields are omitted at their defaults: omitted crowding means 0, omitted continuesActiveDirective means false, omitted pressureEffect means none, omitted direction/targetState/territoryRelation are null, distance is omitted for hold.", }, { role: 'user', content: JSON.stringify({ tickNumber: observation.tickNumber, virtualTime: observation.virtualTime, - cells: observation.cells, + worldSummary: observation.worldSummary, zero: { - position: zero.position, controlledCellCount: zero.controlledCellCount, territoryDelta: zero.territoryDelta, }, workers, recentPlayerPressure: observation.recentPlayerPressure, - recentCaptures: observation.recentCaptures ?? [], + recentCaptures: (observation.recentCaptures ?? []).map( + ({ originatingTick, abandonedCellCount }) => ({ + originatingTick, + abandonedCellCount, + }), + ), replanReasons: observation.replanReasons ?? [], workerReplanRequests: ( observation.workerReplanRequests ?? [] @@ -570,8 +604,12 @@ function buildSwarmPlannerRequest( const workerId = workerIdByAgent.get(agentId); return workerId ? [{ workerId, probability }] : []; }), - legalZeroActions: observation.legalZeroActions, - targetChoices, + legalZeroActions: observation.legalZeroActions.map( + ({ id, description }) => ({ + id, + description, + }), + ), }), }, ], @@ -582,12 +620,6 @@ function decodePlanChoice( raw: unknown, observation: ZeroStrategicObservation, ): { plan: SwarmPlan; reason?: never } | { plan?: never; reason: string } { - // Valid full plans remain accepted for compatibility with existing callers. - const full = swarmPlanSchema.safeParse(raw); - if (full.success) { - const issue = planAuthorityIssue(full.data, observation); - return issue ? { reason: issue } : { plan: full.data }; - } const compact = compactPlanSchema.safeParse(raw); if (!compact.success) { const topField = String(compact.error.issues[0]?.path[0] ?? 'object'); @@ -600,48 +632,55 @@ function decodePlanChoice( : 'object'; return { reason: `invalid ${field} format` }; } - const workerIds = observation.agents - .filter(({ agentId }) => agentId !== observation.zeroAgentId) - .map(({ agentId }, index) => ({ workerId: `worker_${index}`, agentId })); - if (compact.data.directives.length !== workerIds.length) - return { reason: 'missing or extra worker directives' }; + // Build lookup: workerId -> agentId, and optionId -> option (with targetCell). const workerMap = new Map( - workerIds.map(({ workerId, agentId }) => [workerId, agentId]), - ); - const targetMap = new Map( - observation.strategicTargetCells.map((cell, index) => [ - `target_${index}`, - cell, + observation.workerOptions.map((wo, index) => [ + `worker_${index}`, + wo.agentId, ]), ); - const seen = new Set(); - for (const directive of compact.data.directives) { - if (!workerMap.has(directive.workerId) || seen.has(directive.workerId)) - return { reason: 'unknown or repeated worker choice' }; - seen.add(directive.workerId); - if (directive.targetId !== null && !targetMap.has(directive.targetId)) - return { reason: 'unknown target choice' }; - } + const optionMap = new Map( + observation.workerOptions.flatMap((wo, index) => + wo.options.map((opt) => [ + opt.optionId, + { ...opt, workerId: `worker_${index}` }, + ]), + ), + ); + if (compact.data.directives.length !== workerMap.size) + return { reason: 'missing or extra worker directives' }; if ( !observation.legalZeroActions.some( ({ id }) => id === compact.data.zeroActionCandidateId, ) ) return { reason: 'unknown Zero action choice' }; + const seen = new Set(); + for (const directive of compact.data.directives) { + if (!workerMap.has(directive.workerId) || seen.has(directive.workerId)) + return { reason: 'unknown or repeated worker choice' }; + seen.add(directive.workerId); + const option = optionMap.get(directive.optionId); + if (!option) return { reason: 'unknown option choice' }; + if (option.workerId !== directive.workerId) + return { reason: 'optionId belongs to different worker' }; + } const plan = swarmPlanSchema.safeParse({ strategySummary: compact.data.strategySummary, zeroActionCandidateId: compact.data.zeroActionCandidateId, - directives: compact.data.directives.map((directive) => ({ - id: `directive-${observation.tickNumber}-${directive.workerId}`, - agentId: workerMap.get(directive.workerId)!, - mission: directive.mission, - targetCell: - directive.targetId === null ? null : targetMap.get(directive.targetId)!, - priority: directive.priority, - riskTolerance: directive.riskTolerance, - issuedAtTick: observation.tickNumber, - expiresAtTick: observation.tickNumber + 4, - })), + directives: compact.data.directives.map((directive) => { + const option = optionMap.get(directive.optionId)!; + return { + id: `directive-${observation.tickNumber}-${directive.workerId}`, + agentId: workerMap.get(directive.workerId)!, + mission: option.mission, + targetCell: option.targetCell, + priority: directive.priority, + riskTolerance: directive.riskTolerance, + issuedAtTick: observation.tickNumber, + expiresAtTick: observation.tickNumber + 4, + }; + }), }); return plan.success ? { plan: plan.data } @@ -659,13 +698,17 @@ function planAuthorityIssue( ) return 'unknown Zero action choice'; const workers = new Set( - observation.agents - .filter(({ agentId }) => agentId !== observation.zeroAgentId) - .map(({ agentId }) => agentId), + observation.workerOptions.map(({ agentId }) => agentId), ); if (plan.directives.length !== workers.size) return 'missing or extra worker directives'; - const targets = new Set(observation.strategicTargetCells); + // Build a flat option lookup: agentId -> Set of {mission, targetCell key} + const workerOptionKeys = new Map( + observation.workerOptions.map((wo) => [ + wo.agentId, + new Set(wo.options.map((o) => `${o.mission}:${o.targetCell ?? 'null'}`)), + ]), + ); for (const directive of plan.directives) { if (!workers.delete(directive.agentId)) return 'unknown or repeated worker choice'; @@ -676,7 +719,8 @@ function planAuthorityIssue( directive.expiresAtTick > observation.tickNumber + 9 ) return 'invalid directive expiry'; - if (directive.targetCell && !targets.has(directive.targetCell)) + const key = `${directive.mission}:${directive.targetCell ?? 'null'}`; + if (!workerOptionKeys.get(directive.agentId)?.has(key)) return 'unknown target choice'; } return null; diff --git a/packages/experiment-archive/src/archive.test.ts b/packages/experiment-archive/src/archive.test.ts index 278a7f1..dfe24a2 100644 --- a/packages/experiment-archive/src/archive.test.ts +++ b/packages/experiment-archive/src/archive.test.ts @@ -36,9 +36,9 @@ async function currentExport(): Promise { describe('experiment archive', () => { it('archives a current swarm export with swarm-native provenance', async () => { const document = await currentExport(); - expect(document.schemaVersion).toBe(12); + expect(document.schemaVersion).toBe(13); expect(document.experiment).toMatchObject({ - swarmPlannerContractVersion: 'swarm-planner-v1', + swarmPlannerContractVersion: 'swarm-planner-v2', scenario: { swarmArchitectureVersion: 'zero-swarm-v1' }, }); expect(document.swarmTicks).toHaveLength(1); @@ -52,7 +52,7 @@ describe('experiment archive', () => { 'SELECT decision_contract_version FROM experiments WHERE id = ?', ) .get(document.experiment.id), - ).toEqual({ decision_contract_version: 'swarm-planner-v1' }); + ).toEqual({ decision_contract_version: 'swarm-planner-v2' }); archive.close(); }); diff --git a/packages/shared/src/index.test.ts b/packages/shared/src/index.test.ts index 63b9df0..c4d294d 100644 --- a/packages/shared/src/index.test.ts +++ b/packages/shared/src/index.test.ts @@ -411,7 +411,7 @@ describe('Patient Zero player-threat feed', () => { describe('engine contract identifiers', () => { it('preserves established engine contract identifiers through branding changes', () => { - expect(SWARM_PLANNER_CONTRACT_VERSION).toBe('swarm-planner-v1'); + expect(SWARM_PLANNER_CONTRACT_VERSION).toBe('swarm-planner-v2'); expect(OBJECTIVE_PROMPT_VERSION).toBe('durable-influence-v3'); expect( modelVerificationSchema.parse({ @@ -597,7 +597,34 @@ describe('Zero strategic observation schema', () => { description: 'Wait on the current cell.', }, ], - strategicTargetCells: [cell], + worldSummary: { + totalCells: 1, + openCells: 0, + swarmInfectedCells: 1, + abandonedInfectedCells: 0, + openFrontierCells: 0, + }, + workerOptions: [ + { + agentId: scoreboard[1]!.agentId, + options: [ + { + optionId: 'w0_o0', + mission: 'hold' as const, + targetCell: null, + direction: null, + distance: 0, + targetState: null, + territoryRelation: null, + pressureAtTarget: 'low' as const, + pressureEffect: 'none' as const, + crowding: 0, + continuesActiveDirective: false, + description: 'Hold current position.', + }, + ], + }, + ], }; it('requires bounded semantic worker threat fields and caps recent captures', () => { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9e837cb..fbb773a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -15,7 +15,7 @@ export const OPENROUTER_MAX_OUTPUT_TOKENS = 4_096; export const OPENROUTER_PROVIDER_TIMEOUT_MS = 75_000; export const OPENROUTER_429_FALLBACK_BACKOFF_MS = 1_500; /** Versioned provenance for Agent Zero's structured planning contract. */ -export const SWARM_PLANNER_CONTRACT_VERSION = 'swarm-planner-v1'; +export const SWARM_PLANNER_CONTRACT_VERSION = 'swarm-planner-v2'; export const swarmPlannerContractVersionSchema = z.literal( SWARM_PLANNER_CONTRACT_VERSION, ); @@ -316,6 +316,56 @@ export const swarmSignalSchema = z .strict(); export type SwarmSignal = z.infer; +/** + * A server-compiled, worker-relative strategic option offered to Agent Zero. + * The `targetCell` field is SERVER-ONLY and must never be sent to the model. + */ +export const strategicOptionSchema = z + .object({ + /** Globally unique within one observation tick. Format: `w{workerIndex}_o{optionIndex}`. */ + optionId: z.string().regex(/^w[0-9]+_o[0-9]+$/), + mission: z.enum(['expand', 'hold', 'relocate', 'reinforce', 'evade']), + /** SERVER-ONLY — resolved from this opaque id; never sent to the model. */ + targetCell: h3CellSchema.nullable(), + direction: pressureDirectionSchema.nullable(), + distance: z.number().int().nonnegative(), + targetState: z.enum(['open', 'infected', 'abandoned']).nullable(), + territoryRelation: z + .enum([ + 'extends-own-territory', + 'open-frontier', + 'isolated-open', + 'own-territory', + 'other-swarm-territory', + 'abandoned-territory', + ]) + .nullable(), + pressureAtTarget: localPressureSchema, + pressureEffect: z.enum([ + 'increases-separation', + 'preserves-separation', + 'reduces-separation', + 'none', + ]), + crowding: z.number().int().nonnegative(), + continuesActiveDirective: z.boolean(), + description: z.string().trim().min(1).max(160), + }) + .strict(); +export type StrategicOption = z.infer; + +/** Coarse world-level counts included in the model request instead of the raw cell list. */ +export const worldSummarySchema = z + .object({ + totalCells: z.number().int().nonnegative(), + openCells: z.number().int().nonnegative(), + swarmInfectedCells: z.number().int().nonnegative(), + abandonedInfectedCells: z.number().int().nonnegative(), + openFrontierCells: z.number().int().nonnegative(), + }) + .strict(); +export type WorldSummary = z.infer; + /** * Strategic input for Agent Zero. This deliberately carries only authoritative * world facts and bounded choices; it contains no social or prose-memory data. @@ -325,6 +375,10 @@ export const zeroStrategicObservationSchema = z zeroAgentId: agentIdSchema, tickNumber: z.number().int().nonnegative(), virtualTime: z.iso.datetime(), + /** + * Full cell list retained for server-side test planners. + * Must NOT be sent to the model — use worldSummary instead. + */ cells: z .array( z @@ -336,6 +390,8 @@ export const zeroStrategicObservationSchema = z .strict(), ) .min(1), + /** Coarse world counts sent to the model in place of the raw cell list. */ + worldSummary: worldSummarySchema, agents: z .array( z @@ -368,7 +424,21 @@ export const zeroStrategicObservationSchema = z .max(WORLD_SCENARIO_LIMITS.maximumAgents) .optional(), legalZeroActions: z.array(zeroActionCandidateSchema).min(1).max(9), - strategicTargetCells: z.array(h3CellSchema).max(80), + /** + * Server-compiled semantic options offered per worker. + * Options include a hold (targetCell=null) and bounded mission choices. + * The model receives a projection without `targetCell`. + */ + workerOptions: z + .array( + z + .object({ + agentId: agentIdSchema, + options: z.array(strategicOptionSchema).min(1).max(8), + }) + .strict(), + ) + .max(WORLD_SCENARIO_LIMITS.maximumAgents), }) .strict() .superRefine((observation, context) => { @@ -399,18 +469,32 @@ export const zeroStrategicObservationSchema = z path: ['legalZeroActions'], message: 'Zero action candidate IDs must be unique.', }); - const targets = new Set(observation.strategicTargetCells); - if ( - observation.agents.some( - ({ directive }) => - directive?.targetCell && !targets.has(directive.targetCell), - ) - ) + // Validate workerOptions structural integrity. + const workerAgents = new Set( + agents.filter((id) => id !== observation.zeroAgentId), + ); + const allOptionIds: string[] = []; + for (const [entryIndex, wo] of observation.workerOptions.entries()) { + if (!workerAgents.has(wo.agentId)) + context.addIssue({ + code: 'custom', + path: ['workerOptions', entryIndex, 'agentId'], + message: 'Worker options must belong to a non-zero agent.', + }); + const holdCount = wo.options.filter((o) => o.mission === 'hold').length; + if (holdCount !== 1) + context.addIssue({ + code: 'custom', + path: ['workerOptions', entryIndex, 'options'], + message: 'Each worker must have exactly one hold option.', + }); + for (const opt of wo.options) allOptionIds.push(opt.optionId); + } + if (new Set(allOptionIds).size !== allOptionIds.length) context.addIssue({ code: 'custom', - path: ['agents'], - message: - 'Active directive targets must be in the strategic target allowlist.', + path: ['workerOptions'], + message: 'Strategic option IDs must be unique across all workers.', }); }); export type ZeroStrategicObservation = z.infer< @@ -2626,7 +2710,7 @@ export type ExperimentExportWorldState = z.infer< const experimentExportDocumentObjectSchema = z .object({ - schemaVersion: z.literal(12), + schemaVersion: z.literal(13), generatedAt: z.iso.datetime(), experiment: experimentManifestSchema, retention: experimentRetentionSchema, diff --git a/tests/e2e/world-lab.spec.ts b/tests/e2e/world-lab.spec.ts index 0e82756..a37a317 100644 --- a/tests/e2e/world-lab.spec.ts +++ b/tests/e2e/world-lab.spec.ts @@ -96,7 +96,7 @@ test('runs a deterministic swarm tick and exports safe telemetry', async ({ const exported = experimentExportDocumentSchema.parse( JSON.parse(await readFile(downloadedPath!, 'utf8')), ); - expect(exported.schemaVersion).toBe(12); + expect(exported.schemaVersion).toBe(13); expect(exported.experiment.scenario?.swarmArchitectureVersion).toBe( 'zero-swarm-v1', );