Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ The active zero-swarm sequence supersedes the earlier social-agent direction
for new experiments while retaining `legacy-multi-agent` as a comparison mode.
PRs A-C established the Jev reflex seam, Agent Zero planner/tick, and World Lab
presentation. PR D makes directives persistent across ticks and wakes Zero on
fixed review and material events. PR E will add a stronger optional deterministic
human-pressure simulator. PR F will compare zero-swarm, legacy, and deterministic
worker baselines before deciding which legacy systems to retire. See ADRs
0028-0031. Historical milestones below remain as implementation history.
fixed review and material events. PR E added optional deterministic
`trail-hunter-v1` capture pressure while retaining `casual-cleaner`. PR F adds
seeded offline comparisons of zero-swarm, legacy, and deterministic worker
policies. The first comparison retains legacy mode pending real-provider trials;
see `docs/ZERO_SWARM_COMPARISON.md`. See ADRs 0028-0032. Historical milestones
below remain as implementation history.

## Experimental Patient Zero coordinator

Expand Down
1 change: 1 addition & 0 deletions apps/game-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"build": "esbuild src/server.ts --bundle --platform=node --format=esm --outfile=dist/server.js",
"compare:offline": "node --import tsx src/swarm-comparison-cli.ts",
"dev": "tsx watch src/server.ts",
"lint": "eslint .",
"start": "node dist/server.js",
Expand Down
142 changes: 96 additions & 46 deletions apps/game-api/src/simulation-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,10 @@ import { ObservationHistory } from './observation-history';
import { AttemptAccounting } from './attempt-accounting';
import {
chooseReflexWorldAction,
compileReflexObservation,
ReflexSelectionCancelledError,
type CompiledReflexObservation,
type ReflexSelection,
} from './reflex-execution';

function attemptAccountingForScenario(
Expand All @@ -135,6 +138,19 @@ const MAX_TURN_HISTORY = 120;
const MAX_WORLD_EVENT_HISTORY = 120;
const DEFAULT_EXPERIMENT_RETENTION = 5_000;

function chooseDeterministicWorkerAction(
compiled: CompiledReflexObservation,
selectCandidateId: (compiled: CompiledReflexObservation) => string,
): ReflexSelection {
const action = compiled.actions.get(selectCandidateId(compiled));
return {
action: action ?? { type: 'wait' },
observation: compiled.observation,
decision: null,
cognitionSource: 'deterministic-fallback',
};
}

export function selectMostRecentPatientZeroThreats<
T extends { eventId: string; occurredAt: string },
>(events: readonly T[]): T[] {
Expand Down Expand Up @@ -292,6 +308,13 @@ export interface SimulationServiceOptions {
/** Separate strategic and reflex cognition used only by zero-swarm-v1. */
swarmPlanner?: SwarmPlanner;
reflexProvider?: ReflexProvider;
/**
* Offline comparison seam: choose one opaque, already legal candidate without
* calling a reflex provider. Production zero-swarm execution leaves this unset.
*/
deterministicWorkerCandidateSelector?: (
compiled: CompiledReflexObservation,
) => string;
now?: () => string;
createEventId?: () => string;
createExperimentId?: () => string;
Expand All @@ -304,6 +327,8 @@ export class SimulationService {
readonly #provider: AgentProvider;
readonly #swarmPlanner: SwarmPlanner | undefined;
readonly #reflexProvider: ReflexProvider | undefined;
readonly #deterministicWorkerCandidateSelector:
((compiled: CompiledReflexObservation) => string) | undefined;
readonly #now: () => string;
readonly #createEventId: () => string;
readonly #createExperimentId: () => string;
Expand Down Expand Up @@ -354,6 +379,7 @@ export class SimulationService {
provider,
swarmPlanner,
reflexProvider,
deterministicWorkerCandidateSelector,
now = () => new Date().toISOString(),
createEventId = () => crypto.randomUUID(),
createExperimentId = () => crypto.randomUUID(),
Expand All @@ -369,6 +395,8 @@ export class SimulationService {
this.#provider = provider;
this.#swarmPlanner = swarmPlanner;
this.#reflexProvider = reflexProvider;
this.#deterministicWorkerCandidateSelector =
deterministicWorkerCandidateSelector;
this.#now = now;
this.#createEventId = createEventId;
this.#createExperimentId = createExperimentId;
Expand Down Expand Up @@ -1333,7 +1361,7 @@ export class SimulationService {
if (result.outcome === 'lost-tick') continue;
const applied = applyCommunication(
state,
preTickState,
playerAdvance.state,
agentId,
result.decision.decision.communication,
context,
Expand Down Expand Up @@ -1587,8 +1615,18 @@ export class SimulationService {
)
replanReasons.push('roster-changed');
const replan = replanReasons.length > 0;
// Planning ticks reserve Zero plus workers; directive reuse only reserves workers.
if (!this.#attemptAccounting.reserve(agents.length - (replan ? 0 : 1))) {
// Planning ticks reserve Zero plus Jev workers. The explicit deterministic
// comparison seam only reserves Zero's planner call; it never dispatches a
// reflex provider attempt.
const requiredAttempts = this.#deterministicWorkerCandidateSelector
? replan
? 1
: 0
: agents.length - (replan ? 0 : 1);
if (
requiredAttempts > 0 &&
!this.#attemptAccounting.reserve(requiredAttempts)
) {
this.#status = 'budget-exhausted';
throw new SimulationValidationError(
'experiment_budget_exhausted',
Expand Down Expand Up @@ -1696,43 +1734,48 @@ export class SimulationService {
({ agentId }) => agentId === worker.id,
)!;
this.#activeAgentId = worker.id;
const choice = await chooseReflexWorldAction(
candidate,
directive,
this.#reflexProvider,
{
history: {
previousCell: this.#lastSwarmPositions.get(worker.id),
recentCleanedCells: this.#simulatedPlayerEvents
.filter(
(
event,
): event is Extract<
SimulatedPlayerEvent,
{ type: 'hex-disinfected' }
> => event.type === 'hex-disinfected',
)
.slice(-6)
.map(({ cell }) => cell),
captureAlerts: captureAlertsFrom(playerAdvance.events),
territoryDelta:
this.#lastSwarmTerritoryDeltas.get(worker.id) ?? 0,
recentActionOutcome: this.#swarmTicks
.at(-1)
?.workers.find(({ agentId }) => agentId === worker.id)
?.actionResult?.accepted
? 'success'
: 'unknown',
},
signal: controller.signal,
deadlineAtMs,
accounting: this.#attemptAccounting,
initialPermitReserved: true,
intendedTickNumber: tickNumber,
intendedTurnNumber: tickTurnBase + order.indexOf(worker.id) + 1,
now: this.#now,
},
);
const history = {
previousCell: this.#lastSwarmPositions.get(worker.id),
recentCleanedCells: this.#simulatedPlayerEvents
.filter(
(
event,
): event is Extract<
SimulatedPlayerEvent,
{ type: 'hex-disinfected' }
> => event.type === 'hex-disinfected',
)
.slice(-6)
.map(({ cell }) => cell),
captureAlerts: captureAlertsFrom(playerAdvance.events),
territoryDelta: this.#lastSwarmTerritoryDeltas.get(worker.id) ?? 0,
recentActionOutcome: this.#swarmTicks
.at(-1)
?.workers.find(({ agentId }) => agentId === worker.id)?.actionResult
?.accepted
? ('success' as const)
: ('unknown' as const),
};
const choice = this.#deterministicWorkerCandidateSelector
? chooseDeterministicWorkerAction(
compileReflexObservation(candidate, directive, history),
this.#deterministicWorkerCandidateSelector,
)
: await chooseReflexWorldAction(
candidate,
directive,
this.#reflexProvider,
{
history,
signal: controller.signal,
deadlineAtMs,
accounting: this.#attemptAccounting,
initialPermitReserved: true,
intendedTickNumber: tickNumber,
intendedTurnNumber: tickTurnBase + order.indexOf(worker.id) + 1,
now: this.#now,
},
);
selected.set(worker.id, choice);
}
if (controller.signal.aborted) throw new SimulationTurnCancelledError();
Expand Down Expand Up @@ -3001,6 +3044,13 @@ export class SimulationService {
};
}

#historicalAgent(agentId: AgentId): Agent | undefined {
return (
this.#state.agents.get(agentId) ??
this.#initialExperimentAgents.find(({ id }) => id === agentId)
);
}

#buildObservation(
agentId: AgentId,
currentCandidatePlayerEvents: readonly SimulatedPlayerEvent[] = [],
Expand Down Expand Up @@ -3109,7 +3159,7 @@ export class SimulationService {
const recentPublicMessages = this.#observationHistory
.publicMessages()
.map((event) => {
const sender = this.#state.agents.get(event.agentId);
const sender = this.#historicalAgent(event.agentId);
if (!sender) throw new Error('A public-message sender does not exist.');
return {
eventId: event.id,
Expand All @@ -3122,8 +3172,8 @@ export class SimulationService {
const recentDirectMessages = this.#observationHistory
.directMessages(agent.id)
.map((event) => {
const sender = this.#state.agents.get(event.agentId);
const recipient = this.#state.agents.get(event.recipientId);
const sender = this.#historicalAgent(event.agentId);
const recipient = this.#historicalAgent(event.recipientId);
if (!sender || !recipient)
throw new Error('A communication participant does not exist.');
return {
Expand All @@ -3141,7 +3191,7 @@ export class SimulationService {
const recentAllianceMessages = this.#observationHistory
.allianceMessages(agent.id)
.map((event) => {
const sender = this.#state.agents.get(event.agentId);
const sender = this.#historicalAgent(event.agentId);
if (!sender)
throw new Error('An alliance-message sender does not exist.');
return {
Expand All @@ -3156,7 +3206,7 @@ export class SimulationService {
const recentZeroMessages = this.#observationHistory
.zeroMessages(agent.id)
.map((event) => {
const sender = this.#state.agents.get(event.agentId);
const sender = this.#historicalAgent(event.agentId);
if (!sender) throw new Error('A Zero-message sender does not exist.');
return {
eventId: event.id,
Expand All @@ -3175,7 +3225,7 @@ export class SimulationService {
? event.previousControllerAgentId
: event.controllerAgentId;
if (otherAgentId === null) return [];
const otherAgent = this.#state.agents.get(otherAgentId);
const otherAgent = this.#historicalAgent(otherAgentId);
if (!otherAgent)
throw new Error('A control-change participant does not exist.');
return [
Expand Down
89 changes: 89 additions & 0 deletions apps/game-api/src/swarm-comparison-cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
#!/usr/bin/env node
import { runOfflineComparison } from './swarm-comparison.js';

const MAX_TICKS = 60;
const MAX_SEEDS = 16;
const MAX_SEED_LENGTH = 80;

interface Arguments {
ticks?: number;
seeds?: string[];
}

function usage(): string {
return `Usage: pnpm compare:offline [--ticks <1-${MAX_TICKS}>] [--seeds <seed-a,seed-b,...>]

Runs the fixed, offline zero-swarm comparison fixtures and writes one JSON report
to stdout. It does not load provider credentials, contact provider services, or
write an experiment archive.`;
}

function parsePositiveInteger(value: string, option: string): number {
if (!/^[0-9]+$/.test(value)) throw new Error(`${option} must be an integer.`);
const parsed = Number(value);
if (parsed < 1 || parsed > MAX_TICKS)
throw new Error(`${option} must be between 1 and ${MAX_TICKS}.`);
return parsed;
}

function parseSeeds(value: string): string[] {
const seeds = value.split(',').map((seed) => seed.trim());
if (
seeds.length === 0 ||
seeds.length > MAX_SEEDS ||
seeds.some(
(seed) =>
seed.length === 0 ||
seed.length > MAX_SEED_LENGTH ||
!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(seed),
)
) {
throw new Error(
`--seeds must contain 1 to ${MAX_SEEDS} comma-separated identifiers of at most ${MAX_SEED_LENGTH} characters.`,
);
}
return [...new Set(seeds)].sort((left, right) =>
left < right ? -1 : left > right ? 1 : 0,
);
}

function parseArguments(input: readonly string[]): Arguments {
const parsed: Arguments = {};
for (let index = 0; index < input.length; index += 1) {
const option = input[index];
if (option === '--help' || option === '-h') {
process.stdout.write(`${usage()}\n`);
process.exit(0);
}
const value = input[index + 1];
if (!value || value.startsWith('--'))
throw new Error(`Option ${option} requires a value.`);
if (option === '--ticks') {
if (parsed.ticks !== undefined)
throw new Error('--ticks was supplied twice.');
parsed.ticks = parsePositiveInteger(value, '--ticks');
} else if (option === '--seeds') {
if (parsed.seeds !== undefined)
throw new Error('--seeds was supplied twice.');
parsed.seeds = parseSeeds(value);
} else throw new Error(`Unknown option: ${option}`);
index += 1;
}
return parsed;
}

async function main(): Promise<void> {
const options = parseArguments(process.argv.slice(2));
const report = await runOfflineComparison({
...(options.ticks === undefined ? {} : { tickCap: options.ticks }),
...(options.seeds === undefined ? {} : { seeds: options.seeds }),
});
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
}

main().catch((error: unknown) => {
process.stderr.write(
`${error instanceof Error ? error.message : String(error)}\n${usage()}\n`,
);
process.exitCode = 1;
});
Loading
Loading