diff --git a/apps/game-api/src/app.ts b/apps/game-api/src/app.ts index 673771d..90531d1 100644 --- a/apps/game-api/src/app.ts +++ b/apps/game-api/src/app.ts @@ -21,22 +21,14 @@ import { experimentExportRequestSchema, experimentExportPreviewSchema, experimentExportResponseSchema, - experimentImportRequestSchema, - experimentImportResponseSchema, healthResponseSchema, modelCatalogResponseSchema, modelVerificationSchema, - PERSONALITY_MAX_LENGTH, resetSimulationResponseSchema, - restoreDefaultPersonalitiesResponseSchema, simulationSnapshotSchema, singleTickResponseSchema, - updateAgentPersonalityRequestSchema, - updateAgentPersonalityResponseSchema, updateExperimentModelsRequestSchema, updateExperimentModelsResponseSchema, - updateExperimentBehaviorRequestSchema, - updateExperimentBehaviorResponseSchema, verifyModelRequestSchema, verifyModelResponseSchema, worldSnapshotSchema, @@ -441,48 +433,6 @@ export function createApp(options: AppOptions = {}) { } }); - app.post('/api/simulation/experiment/behavior', async (context) => { - const request = updateExperimentBehaviorRequestSchema.safeParse( - await context.req.json().catch(() => undefined), - ); - if (!request.success) - return context.json( - apiErrorSchema.parse({ - error: { - code: 'invalid_behavior_configuration', - message: 'The behavior configuration is invalid.', - }, - }), - 400, - ); - try { - return context.json( - updateExperimentBehaviorResponseSchema.parse({ - snapshot: service.updateBehaviorConfiguration(request.data), - }), - ); - } catch (error) { - if (error instanceof SimulationConflictError) - return context.json( - apiErrorSchema.parse({ - error: { - code: 'behavior_configuration_conflict', - message: error.message, - }, - }), - 409, - ); - if (error instanceof SimulationValidationError) - return context.json( - apiErrorSchema.parse({ - error: { code: error.code, message: error.message }, - }), - 400, - ); - throw error; - } - }); - app.post('/api/simulation/tick', async (context) => { try { const response = await mutationPromise(context, 'tick', async () => { @@ -558,73 +508,6 @@ export function createApp(options: AppOptions = {}) { } }); - app.post('/api/simulation/agents/:agentId/personality', async (context) => { - const request = updateAgentPersonalityRequestSchema.safeParse( - await context.req.json().catch(() => undefined), - ); - if (!request.success) { - return context.json( - apiErrorSchema.parse({ - error: { - code: 'invalid_personality', - message: `Personality must contain 1 to ${PERSONALITY_MAX_LENGTH} characters.`, - }, - }), - 400, - ); - } - try { - const agent = service.updateAgentPersonality( - context.req.param('agentId'), - request.data.personality, - ); - return context.json( - updateAgentPersonalityResponseSchema.parse({ - snapshot: service.getSnapshot(), - agent, - }), - ); - } catch (error) { - if (error instanceof SimulationConflictError) { - return context.json( - apiErrorSchema.parse({ - error: { code: 'personality_conflict', message: error.message }, - }), - 409, - ); - } - if (error instanceof SimulationValidationError) { - return context.json( - apiErrorSchema.parse({ - error: { code: error.code, message: error.message }, - }), - error.code === 'unknown_agent' ? 404 : 400, - ); - } - throw error; - } - }); - - app.post('/api/simulation/personalities/restore-defaults', (context) => { - try { - return context.json( - restoreDefaultPersonalitiesResponseSchema.parse({ - snapshot: service.restoreDefaultPersonalities(), - }), - ); - } catch (error) { - if (error instanceof SimulationConflictError) { - return context.json( - apiErrorSchema.parse({ - error: { code: 'personality_conflict', message: error.message }, - }), - 409, - ); - } - throw error; - } - }); - app.post('/api/simulation/experiment/export/preview', async (context) => { const request = experimentExportRequestSchema.safeParse( await context.req.json().catch(() => undefined), @@ -754,52 +637,6 @@ export function createApp(options: AppOptions = {}) { } }); - app.post('/api/simulation/experiment/import', async (context) => { - const request = experimentImportRequestSchema.safeParse( - await context.req.json().catch(() => undefined), - ); - if (!request.success) - return context.json( - apiErrorSchema.parse({ - error: { - code: 'invalid_import', - message: 'The experiment import is invalid.', - }, - }), - 400, - ); - const currentCatalog = modelCatalogResponseSchema.parse( - await catalog.getCatalog(false), - ); - service.setCompatibleModels(currentCatalog.models); - try { - return context.json( - experimentImportResponseSchema.parse( - service.importModelConfiguration(request.data.document), - ), - ); - } catch (error) { - if (error instanceof SimulationConflictError) - return context.json( - apiErrorSchema.parse({ - error: { - code: 'model_configuration_conflict', - message: error.message, - }, - }), - 409, - ); - if (error instanceof SimulationValidationError) - return context.json( - apiErrorSchema.parse({ - error: { code: 'invalid_import', message: error.message }, - }), - 400, - ); - throw error; - } - }); - app.notFound((context) => context.json( apiErrorSchema.parse({ diff --git a/apps/game-api/src/experiment-export.ts b/apps/game-api/src/experiment-export.ts index 3527fb2..16643f5 100644 --- a/apps/game-api/src/experiment-export.ts +++ b/apps/game-api/src/experiment-export.ts @@ -4,15 +4,11 @@ import { experimentExportPreviewSchema, experimentExportRequestSchema, experimentMetricsSchema, - PERSONALITY_PROFILES, - NEUTRAL_AGENT_COLOR, - STRATEGY_PROFILES, type Agent, type AgentId, - type AgentObservation, - type AgentTurnRecord, - type EventId, - type AllianceEvent, + type AppliedScenario, + type ExperimentAttemptAccounting, + type ExperimentConfigurationEvent, type ExperimentExportDocument, type ExperimentExportPreview, type ExperimentExportRequest, @@ -20,25 +16,15 @@ import { type ExperimentId, type ExperimentMetrics, type ExperimentModelConfiguration, - type ModelId, - type ReasoningProfile, - type DiplomacyRejectionReason, - type DiplomacyResult, - type ExportedCommunication, - type ExportedControlChange, - type ExperimentConfigurationEvent, - type ProviderMetadata, - type WorldSnapshot, - type BehaviorConfiguration, - type AppliedScenario, type ExperimentTickSummary, - type AgentGoalState, - type MemoryEntry, - type SimulatedPlayerEvent, + type ExportedControlChange, type ProviderAttemptRecord, type ProviderAttemptRetention, - type ExperimentAttemptAccounting, + type SimulatedPlayerEvent, type SwarmTickRecord, + type WorldAction, + type WorldActionResult, + type WorldSnapshot, } from '@hexzero/shared'; export interface ExperimentSource { @@ -47,19 +33,15 @@ export interface ExperimentSource { startedAt: string; providerMode: 'openrouter' | 'scripted-test'; retentionLimit: number; - totalCompletedTurns: number; - turns: readonly AgentTurnRecord[]; - swarmTicks?: readonly SwarmTickRecord[]; + totalCompletedTicks: number; initialAgents: readonly Agent[]; currentAgents: readonly Agent[]; configurationEvents: readonly ExperimentConfigurationEvent[]; initialWorld: WorldSnapshot; currentWorld: WorldSnapshot; modelConfiguration: ExperimentModelConfiguration; - behaviorConfiguration: BehaviorConfiguration; scenario: AppliedScenario; - agentGoals: readonly { agentId: AgentId; goal: AgentGoalState | null }[]; - agentMemories: readonly { agentId: AgentId; entries: MemoryEntry[] }[]; + swarmTicks?: readonly SwarmTickRecord[]; simulatedPlayerEvents: readonly SimulatedPlayerEvent[]; providerAttempts?: readonly ProviderAttemptRecord[]; attemptRetention?: ProviderAttemptRetention; @@ -76,107 +58,12 @@ export class ExperimentExportValidationError extends Error { } } -export class ExperimentMetricAccumulator { - readonly #records = new Map(); - - constructor(agentIds: readonly AgentId[]) { - this.#records.set('aggregate', mutableMetrics()); - for (const agentId of agentIds) - this.#records.set(agentId, mutableMetrics()); - } - - add(turn: AgentTurnRecord): void { - addToMutable(this.#records.get('aggregate')!, turn, true); - const agent = this.#records.get(turn.agentId); - if (agent) addToMutable(agent, turn); - for (const event of turn.allianceEvents) - for (const affectedId of allianceEventAgentIds(event)) { - if (affectedId === turn.agentId) continue; - const affected = this.#records.get(affectedId); - if (affected) addAllianceEventMetric(affected, event, affectedId); - } - if ( - turn.outcome !== 'provider-error' && - turn.outcome !== 'lost-tick' && - turn.outcome !== 'operator-skipped' && - turn.communicationResult.requested && - turn.communicationResult.accepted && - turn.communicationResult.event.channel === 'direct' - ) { - const recipient = this.#records.get( - turn.communicationResult.event.recipientId, - ); - if (recipient) recipient.directMessagesReceived += 1; - } - if ( - turn.outcome !== 'provider-error' && - turn.outcome !== 'lost-tick' && - turn.outcome !== 'operator-skipped' && - turn.communicationResult.requested && - turn.communicationResult.accepted && - turn.communicationResult.event.channel === 'zero' - ) { - for (const recipientId of turn.communicationResult.event.recipientIds) { - const recipient = this.#records.get(recipientId); - if (recipient) { - recipient.zeroRecipientDeliveries += 1; - recipient.zeroDirectiveRecipients.add(recipientId); - } - } - } - if ( - turn.outcome === 'accepted' && - turn.worldActionResult.event.type === 'hex-captured' - ) { - const previousControllerAgentId = - turn.worldActionResult.event.previousControllerAgentId; - const displaced = - previousControllerAgentId === null - ? undefined - : this.#records.get(previousControllerAgentId); - if (displaced) displaced.territoryLostThroughCapture += 1; - } - } - - snapshot(agentIds: readonly AgentId[]): ExperimentMetrics { - return experimentMetricsSchema.parse({ - aggregate: finalizeMutable(this.#records.get('aggregate')!), - byAgent: agentIds.map((agentId) => ({ - agentId, - metrics: finalizeMutable( - this.#records.get(agentId) ?? mutableMetrics(), - ), - })), - }); - } -} - -function addAllianceEventMetric( - metrics: MutableMetrics, - event: AllianceEvent, - agentId?: AgentId, -): void { - if (agentId && !allianceEventAgentIds(event).includes(agentId)) return; - if (event.type === 'alliance-proposed') { - if (!agentId || event.agentId === agentId) { - metrics.proposalsCreated += 1; - metrics.proposalsSent += 1; - } - if (!agentId || event.recipientAgentId === agentId) - metrics.proposalsReceived += 1; - } else if (event.type === 'alliance-proposal-closed') { - if (event.reason === 'expired') metrics.proposalsExpired += 1; - else metrics.proposalsInvalidated += 1; - } else if (event.type === 'alliance-formed') { - metrics.alliancesFormed += 1; - metrics.alliancesJoined += agentId ? 1 : event.memberAgentIds.length; - } else if (event.type === 'agent-joined-alliance') { - if (!agentId || event.joinedAgentId === agentId) - metrics.alliancesJoined += 1; - } else if (event.type === 'agent-left-alliance') { - if (!agentId || event.leftAgentId === agentId) metrics.alliancesLeft += 1; - } else if (event.type === 'alliance-dissolved') - metrics.alliancesDissolved += 1; +/** One resolved (accepted or rejected) world-action decision from a committed swarm tick. */ +interface ResolvedWorldAction { + tickNumber: number; + agentId: AgentId; + action: WorldAction; + actionResult: WorldActionResult; } const metricTokenFields = [ @@ -188,526 +75,61 @@ const metricTokenFields = [ 'cacheWriteTokens', ] as const; -interface MutableMetrics { - turns: number; - accepted: number; - rejected: number; - providerErrors: number; - lostTicks: number; - operatorSkipped: number; - modelCalls: number; - failedModelAttempts: number; - automaticRepairAttempts: number; - automaticTransportRetries: number; - manualRetryAttempts: number; - unattendedRetryAttempts: number; - manualSkips: number; - unattendedSkips: number; - recoveredByUnattendedRetry: number; - skippedAfterUnattendedRecovery: number; - retriedTurns: number; - recoveredAutomatically: number; - recoveredManually: number; - recoveredByRetry: number; - requestedMoves: number; - requestedInfections: number; - requestedCaptures: number; - requestedWaits: number; - acceptedMovements: number; - infections: number; - successfulCaptures: number; - acceptedWaits: number; - rejectedWorldActions: number; - territoryGainedThroughInfection: number; - territoryGainedThroughCapture: number; - territoryLostThroughCapture: number; - publicMessagesRequested: number; - publicMessagesAccepted: number; - publicMessagesRejected: number; - directMessagesRequested: number; - allianceMessagesRequested: number; - allianceMessagesDelivered: number; - allianceMessagesRejected: number; - directMessagesDelivered: number; - directMessagesRejected: number; - publicMessagesSent: number; - directMessagesSent: number; - directMessagesReceived: number; - zeroBroadcastsRequested: number; - zeroBroadcastsDelivered: number; - zeroBroadcastsRejected: number; - zeroRecipientDeliveries: number; - zeroDirectiveRecipients: Set; - patientZeroRepliers: Set; - directRepliesToPatientZero: number; - firstZeroDirectiveTurn: number | null; - mostRecentZeroDirective: { - eventId: EventId; - turnNumber: number; - occurredAt: string; - agentId: AgentId; - recipientCount: number; - modelId: ModelId; - reasoningProfile: ReasoningProfile; - personalityId: AgentObservation['behavior']['personalityId']; - strategyId: AgentObservation['behavior']['strategyId']; - } | null; - diplomacyProposalsRequested: number; - diplomacyAcceptancesRequested: number; - diplomacyDeparturesRequested: number; - diplomacyProposalsAccepted: number; - diplomacyAcceptancesAccepted: number; - diplomacyDeparturesAccepted: number; - diplomacyRejected: number; - diplomacyRejections: Map< - string, - { - type: - 'propose-alliance' | 'accept-alliance' | 'leave-alliance' | 'invalid'; - reason: DiplomacyRejectionReason; - count: number; - } - >; - proposalsCreated: number; - proposalsSent: number; - proposalsReceived: number; - proposalsExpired: number; - proposalsInvalidated: number; - alliancesFormed: number; - alliancesJoined: number; - alliancesLeft: number; - alliancesDissolved: number; - alliedCaptureAttempts: number; - alliedCaptureRejections: number; - latencyTotal: number; - latencyCount: number; - tokens: Record<(typeof metricTokenFields)[number], number>; - tokenFieldsComplete: Record<(typeof metricTokenFields)[number], boolean>; - tokenFieldsKnown: Record<(typeof metricTokenFields)[number], boolean>; - knownCostCredits: string; - attemptsWithUnknownTokenUsage: number; - attemptsWithUnknownCost: number; - turnsWithUnknownCost: Set; - visited: Set; -} - -function mutableMetrics(): MutableMetrics { - return { - turns: 0, - accepted: 0, - rejected: 0, - providerErrors: 0, - lostTicks: 0, - operatorSkipped: 0, - modelCalls: 0, - failedModelAttempts: 0, - automaticRepairAttempts: 0, - automaticTransportRetries: 0, - manualRetryAttempts: 0, - unattendedRetryAttempts: 0, - manualSkips: 0, - unattendedSkips: 0, - recoveredByUnattendedRetry: 0, - skippedAfterUnattendedRecovery: 0, - retriedTurns: 0, - recoveredAutomatically: 0, - recoveredManually: 0, - recoveredByRetry: 0, - requestedMoves: 0, - requestedInfections: 0, - requestedCaptures: 0, - requestedWaits: 0, - acceptedMovements: 0, - infections: 0, - successfulCaptures: 0, - acceptedWaits: 0, - rejectedWorldActions: 0, - territoryGainedThroughInfection: 0, - territoryGainedThroughCapture: 0, - territoryLostThroughCapture: 0, - publicMessagesRequested: 0, - publicMessagesAccepted: 0, - publicMessagesRejected: 0, - directMessagesRequested: 0, - allianceMessagesRequested: 0, - allianceMessagesDelivered: 0, - allianceMessagesRejected: 0, - directMessagesDelivered: 0, - directMessagesRejected: 0, - publicMessagesSent: 0, - directMessagesSent: 0, - directMessagesReceived: 0, - zeroBroadcastsRequested: 0, - zeroBroadcastsDelivered: 0, - zeroBroadcastsRejected: 0, - zeroRecipientDeliveries: 0, - zeroDirectiveRecipients: new Set(), - patientZeroRepliers: new Set(), - directRepliesToPatientZero: 0, - firstZeroDirectiveTurn: null, - mostRecentZeroDirective: null, - diplomacyProposalsRequested: 0, - diplomacyAcceptancesRequested: 0, - diplomacyDeparturesRequested: 0, - diplomacyProposalsAccepted: 0, - diplomacyAcceptancesAccepted: 0, - diplomacyDeparturesAccepted: 0, - diplomacyRejected: 0, - diplomacyRejections: new Map(), - proposalsCreated: 0, - proposalsSent: 0, - proposalsReceived: 0, - proposalsExpired: 0, - proposalsInvalidated: 0, - alliancesFormed: 0, - alliancesJoined: 0, - alliancesLeft: 0, - alliancesDissolved: 0, - alliedCaptureAttempts: 0, - alliedCaptureRejections: 0, - latencyTotal: 0, - latencyCount: 0, - tokens: Object.fromEntries( - metricTokenFields.map((field) => [field, 0]), - ) as MutableMetrics['tokens'], - tokenFieldsComplete: Object.fromEntries( - metricTokenFields.map((field) => [field, true]), - ) as MutableMetrics['tokenFieldsComplete'], - tokenFieldsKnown: Object.fromEntries( - metricTokenFields.map((field) => [field, false]), - ) as MutableMetrics['tokenFieldsKnown'], - knownCostCredits: '0', - attemptsWithUnknownTokenUsage: 0, - attemptsWithUnknownCost: 0, - turnsWithUnknownCost: new Set(), - visited: new Set(), - }; -} - -function addToMutable( - metrics: MutableMetrics, - turn: AgentTurnRecord, - aggregate = false, -): void { - metrics.turns += 1; - if (turn.outcome === 'provider-error') metrics.providerErrors += 1; - else if (turn.outcome === 'lost-tick') metrics.lostTicks += 1; - else if (turn.outcome === 'operator-skipped') { - metrics.operatorSkipped += 1; - if (turn.skipKind === 'unattended') { - metrics.unattendedSkips += 1; - metrics.skippedAfterUnattendedRecovery += 1; - } else metrics.manualSkips += 1; - } else metrics[turn.outcome] += 1; - metrics.visited.add(turn.observation.currentCell.cell); - if ( - turn.outcome !== 'provider-error' && - turn.outcome !== 'lost-tick' && - turn.outcome !== 'operator-skipped' - ) { - if (turn.worldAction.type === 'move') metrics.requestedMoves += 1; - if (turn.worldAction.type === 'infect') metrics.requestedInfections += 1; - if (turn.worldAction.type === 'capture') metrics.requestedCaptures += 1; - if (turn.worldAction.type === 'wait') metrics.requestedWaits += 1; - if (turn.outcome === 'rejected') metrics.rejectedWorldActions += 1; - if (turn.communicationResult.requested) { - const channel = turn.communicationResult.accepted - ? turn.communicationResult.event.channel - : turn.communicationResult.attempt.channel; - if (channel === 'public') metrics.publicMessagesRequested += 1; - else if (channel === 'direct') metrics.directMessagesRequested += 1; - else if (channel === 'alliance') metrics.allianceMessagesRequested += 1; - else metrics.zeroBroadcastsRequested += 1; - if (turn.communicationResult.accepted) { - if (turn.communicationResult.event.channel === 'public') { - metrics.publicMessagesAccepted += 1; - metrics.publicMessagesSent += 1; - } else if (turn.communicationResult.event.channel === 'direct') { - metrics.directMessagesDelivered += 1; - metrics.directMessagesSent += 1; - if (aggregate) metrics.directMessagesReceived += 1; - } else if (turn.communicationResult.event.channel === 'alliance') - metrics.allianceMessagesDelivered += 1; - else { - metrics.zeroBroadcastsDelivered += 1; - metrics.zeroRecipientDeliveries += - turn.communicationResult.event.recipientIds.length; - for (const id of turn.communicationResult.event.recipientIds) - metrics.zeroDirectiveRecipients.add(id); - metrics.firstZeroDirectiveTurn ??= turn.turnNumber; - metrics.mostRecentZeroDirective = { - eventId: turn.communicationResult.event.id, - turnNumber: turn.turnNumber, - occurredAt: turn.communicationResult.event.occurredAt, - agentId: turn.agentId, - recipientCount: turn.communicationResult.event.recipientIds.length, - modelId: turn.provider.model, - reasoningProfile: - turn.modelAttempts.at(-1)?.reasoningProfile ?? 'provider-default', - personalityId: turn.observation.behavior.personalityId, - strategyId: turn.observation.behavior.strategyId, - }; - } - } else if (turn.communicationResult.attempt.channel === 'public') { - metrics.publicMessagesRejected += 1; - } else if (turn.communicationResult.attempt.channel === 'direct') - metrics.directMessagesRejected += 1; - else if (turn.communicationResult.attempt.channel === 'alliance') - metrics.allianceMessagesRejected += 1; - else metrics.zeroBroadcastsRejected += 1; - } - if ( - turn.communicationResult.requested && - turn.communicationResult.accepted && - turn.communicationResult.event.channel === 'direct' && - turn.observation.patientZero.agentId !== null && - turn.agentId !== turn.observation.patientZero.agentId && - turn.communicationResult.event.recipientId === - turn.observation.patientZero.agentId - ) { - metrics.directRepliesToPatientZero += 1; - metrics.patientZeroRepliers.add(turn.agentId); - } - if (turn.diplomacyResult.requested) { - const type = turn.diplomacyResult.accepted - ? turn.diplomacyResult.intent.type - : turn.diplomacyResult.attempt.type; - if (type === 'propose-alliance') metrics.diplomacyProposalsRequested += 1; - if (type === 'accept-alliance') - metrics.diplomacyAcceptancesRequested += 1; - if (type === 'leave-alliance') metrics.diplomacyDeparturesRequested += 1; - if (!turn.diplomacyResult.accepted) { - metrics.diplomacyRejected += 1; - const key = `${type}:${turn.diplomacyResult.reason}`; - const existing = metrics.diplomacyRejections.get(key); - metrics.diplomacyRejections.set(key, { - type, - reason: turn.diplomacyResult.reason, - count: (existing?.count ?? 0) + 1, +function resolvedActionsFromTicks( + ticks: readonly SwarmTickRecord[], + zeroAgentId: AgentId, +): ResolvedWorldAction[] { + const resolved: ResolvedWorldAction[] = []; + for (const tick of ticks) { + if (tick.zeroAction && tick.zeroActionResult) + resolved.push({ + tickNumber: tick.tickNumber, + agentId: zeroAgentId, + action: tick.zeroAction, + actionResult: tick.zeroActionResult, + }); + for (const worker of tick.workers) + if (worker.action && worker.actionResult) + resolved.push({ + tickNumber: tick.tickNumber, + agentId: worker.agentId, + action: worker.action, + actionResult: worker.actionResult, }); - } else if (type === 'propose-alliance') - metrics.diplomacyProposalsAccepted += 1; - else if (type === 'accept-alliance') - metrics.diplomacyAcceptancesAccepted += 1; - else metrics.diplomacyDeparturesAccepted += 1; - } - if ( - turn.worldAction.type === 'capture' && - !turn.worldActionResult.accepted && - turn.worldActionResult.reason === 'allied-controller' - ) { - metrics.alliedCaptureAttempts += 1; - metrics.alliedCaptureRejections += 1; - } - } - for (const event of turn.allianceEvents) - addAllianceEventMetric( - metrics, - event, - aggregate ? undefined : turn.agentId, - ); - if ( - turn.outcome === 'accepted' && - turn.worldActionResult.event.type === 'agent-moved' - ) { - metrics.acceptedMovements += 1; - metrics.visited.add(turn.worldActionResult.event.toCell); - } - if ( - turn.outcome === 'accepted' && - turn.worldActionResult.event.type === 'hex-infected' - ) { - metrics.infections += 1; - metrics.territoryGainedThroughInfection += 1; - } - if ( - turn.outcome === 'accepted' && - turn.worldActionResult.event.type === 'hex-captured' - ) { - metrics.successfulCaptures += 1; - metrics.territoryGainedThroughCapture += 1; - if (aggregate) metrics.territoryLostThroughCapture += 1; - } - if ( - turn.outcome === 'accepted' && - turn.worldActionResult.event.type === 'agent-waited' - ) - metrics.acceptedWaits += 1; - const attempts = usageAttempts(turn); - metrics.modelCalls += attempts.length; - metrics.failedModelAttempts += attempts.filter(({ failed }) => failed).length; - metrics.automaticRepairAttempts += attempts.filter( - ({ kind }) => kind === 'automatic-repair', - ).length; - metrics.automaticTransportRetries += attempts.filter( - ({ kind }) => kind === 'automatic-transport-retry', - ).length; - metrics.manualRetryAttempts += attempts.filter( - ({ kind }) => kind === 'manual-retry', - ).length; - metrics.unattendedRetryAttempts += attempts.filter( - ({ kind }) => kind === 'unattended-retry', - ).length; - const retried = attempts.some(({ kind }) => kind !== 'initial'); - const manuallyRetried = attempts.some(({ kind }) => kind === 'manual-retry'); - const unattendedRetried = attempts.some( - ({ kind }) => kind === 'unattended-retry', - ); - if (retried) metrics.retriedTurns += 1; - if ( - retried && - turn.outcome !== 'provider-error' && - turn.outcome !== 'lost-tick' && - turn.outcome !== 'operator-skipped' - ) - metrics.recoveredByRetry += 1; - if ( - retried && - turn.outcome !== 'provider-error' && - turn.outcome !== 'lost-tick' && - turn.outcome !== 'operator-skipped' - ) { - if (manuallyRetried) metrics.recoveredManually += 1; - else if (unattendedRetried) { - metrics.recoveredAutomatically += 1; - metrics.recoveredByUnattendedRetry += 1; - } else metrics.recoveredAutomatically += 1; - } - for (const { provider } of attempts) { - if (provider) { - metrics.latencyTotal += provider.latencyMs; - metrics.latencyCount += 1; - } - for (const field of metricTokenFields) { - const value = provider?.[field]; - if (value === undefined) metrics.tokenFieldsComplete[field] = false; - else { - metrics.tokens[field] += value; - metrics.tokenFieldsKnown[field] = true; - } - } - if (metricTokenFields.some((field) => provider?.[field] === undefined)) - metrics.attemptsWithUnknownTokenUsage += 1; - if (provider?.costCredits === undefined) { - metrics.attemptsWithUnknownCost += 1; - metrics.turnsWithUnknownCost.add(turn.turnNumber); - } else - metrics.knownCostCredits = addDecimalValue( - metrics.knownCostCredits, - provider.costCredits, - ); } + return resolved; } -function usageAttempts(turn: AgentTurnRecord) { - if (turn.modelAttempts.length > 0) - return turn.modelAttempts.map((attempt) => ({ - provider: attempt.provider, - failed: attempt.failure !== undefined, - kind: attempt.kind, - })); - return turn.provider - ? [ - { - provider: turn.provider, - failed: - turn.outcome === 'provider-error' || turn.outcome === 'lost-tick', - kind: 'initial' as const, - }, - ] - : []; +function selectTickNumbers( + source: ExperimentSource, + request: ExperimentExportRequest, +): Set | 'all' { + if (request.turns.mode === 'entire-retained') return 'all'; + const allTicks = (source.swarmTicks ?? []).map( + ({ tickNumber }) => tickNumber, + ); + if (request.turns.mode === 'range') { + const { fromTurn, toTurn } = request.turns; + return new Set(allTicks.filter((n) => n >= fromTurn && n <= toTurn)); + } + return new Set(allTicks.slice(-request.turns.count)); } -function finalizeMutable(metrics: MutableMetrics) { - const tokens: Record = {}; - if (metrics.turns > 0) - for (const field of metricTokenFields) - if (metrics.tokenFieldsKnown[field]) - tokens[field] = metrics.tokens[field]; - return { - totalTurns: metrics.turns, - accepted: metrics.accepted, - rejected: metrics.rejected, - providerErrors: metrics.providerErrors, - lostTicks: metrics.lostTicks, - operatorSkipped: metrics.operatorSkipped, - modelCalls: metrics.modelCalls, - failedModelAttempts: metrics.failedModelAttempts, - automaticRepairAttempts: metrics.automaticRepairAttempts, - automaticTransportRetries: metrics.automaticTransportRetries, - manualRetryAttempts: metrics.manualRetryAttempts, - unattendedRetryAttempts: metrics.unattendedRetryAttempts, - manualSkips: metrics.manualSkips, - unattendedSkips: metrics.unattendedSkips, - recoveredByUnattendedRetry: metrics.recoveredByUnattendedRetry, - skippedAfterUnattendedRecovery: metrics.skippedAfterUnattendedRecovery, - retriedTurns: metrics.retriedTurns, - recoveredAutomatically: metrics.recoveredAutomatically, - recoveredManually: metrics.recoveredManually, - recoveredByRetry: metrics.recoveredByRetry, - requestedMoves: metrics.requestedMoves, - requestedInfections: metrics.requestedInfections, - requestedCaptures: metrics.requestedCaptures, - requestedWaits: metrics.requestedWaits, - acceptedMovements: metrics.acceptedMovements, - successfullyInfectedCells: metrics.infections, - successfulCaptures: metrics.successfulCaptures, - acceptedWaits: metrics.acceptedWaits, - rejectedWorldActions: metrics.rejectedWorldActions, - territoryGainedThroughInfection: metrics.territoryGainedThroughInfection, - territoryGainedThroughCapture: metrics.territoryGainedThroughCapture, - territoryLostThroughCapture: metrics.territoryLostThroughCapture, - publicMessagesRequested: metrics.publicMessagesRequested, - publicMessagesAccepted: metrics.publicMessagesAccepted, - publicMessagesRejected: metrics.publicMessagesRejected, - directMessagesRequested: metrics.directMessagesRequested, - directMessagesDelivered: metrics.directMessagesDelivered, - directMessagesRejected: metrics.directMessagesRejected, - allianceMessagesRequested: metrics.allianceMessagesRequested, - allianceMessagesDelivered: metrics.allianceMessagesDelivered, - allianceMessagesRejected: metrics.allianceMessagesRejected, - publicMessagesSent: metrics.publicMessagesSent, - directMessagesSent: metrics.directMessagesSent, - directMessagesReceived: metrics.directMessagesReceived, - zeroBroadcastsRequested: metrics.zeroBroadcastsRequested, - zeroBroadcastsDelivered: metrics.zeroBroadcastsDelivered, - zeroBroadcastsRejected: metrics.zeroBroadcastsRejected, - zeroRecipientDeliveries: metrics.zeroRecipientDeliveries, - uniqueZeroDirectiveRecipients: metrics.zeroDirectiveRecipients.size, - directRepliesToPatientZero: metrics.directRepliesToPatientZero, - uniquePatientZeroRepliers: metrics.patientZeroRepliers.size, - firstZeroDirectiveTurn: metrics.firstZeroDirectiveTurn, - mostRecentZeroDirective: metrics.mostRecentZeroDirective, - diplomacyProposalsRequested: metrics.diplomacyProposalsRequested, - diplomacyAcceptancesRequested: metrics.diplomacyAcceptancesRequested, - diplomacyDeparturesRequested: metrics.diplomacyDeparturesRequested, - diplomacyProposalsAccepted: metrics.diplomacyProposalsAccepted, - diplomacyAcceptancesAccepted: metrics.diplomacyAcceptancesAccepted, - diplomacyDeparturesAccepted: metrics.diplomacyDeparturesAccepted, - diplomacyRejected: metrics.diplomacyRejected, - diplomacyRejections: [...metrics.diplomacyRejections.values()], - proposalsCreated: metrics.proposalsCreated, - proposalsSent: metrics.proposalsSent, - proposalsReceived: metrics.proposalsReceived, - proposalsExpired: metrics.proposalsExpired, - proposalsInvalidated: metrics.proposalsInvalidated, - alliancesFormed: metrics.alliancesFormed, - alliancesJoined: metrics.alliancesJoined, - alliancesLeft: metrics.alliancesLeft, - alliancesDissolved: metrics.alliancesDissolved, - alliedCaptureAttempts: metrics.alliedCaptureAttempts, - alliedCaptureRejections: metrics.alliedCaptureRejections, - uniqueVisitedCells: metrics.visited.size, - ...(metrics.latencyCount > 0 - ? { averageLatencyMs: metrics.latencyTotal / metrics.latencyCount } - : {}), - tokens, - tokenUsageComplete: metrics.attemptsWithUnknownTokenUsage === 0, - attemptsWithUnknownTokenUsage: metrics.attemptsWithUnknownTokenUsage, - knownCostCredits: Number(metrics.knownCostCredits), - attemptsWithUnknownCost: metrics.attemptsWithUnknownCost, - turnsWithUnknownCost: metrics.turnsWithUnknownCost.size, - }; +function requestFilteredActions( + source: ExperimentSource, + request: ExperimentExportRequest, + tickNumbers: Set | 'all', +): ResolvedWorldAction[] { + const zeroAgentId = source.scenario.patientZeroAgentId; + const all = resolvedActionsFromTicks(source.swarmTicks ?? [], zeroAgentId); + return all.filter( + ({ tickNumber, action, actionResult }) => + request.outcomes.includes( + actionResult.accepted ? 'accepted' : 'rejected', + ) && + request.actions.includes(action.type) && + (tickNumbers === 'all' || tickNumbers.has(tickNumber)), + ); } export function createExperimentExport( @@ -725,29 +147,39 @@ export function createExperimentExport( const request = parsed.data; const selectedAgentIds = resolveAgentIds(source, request); const selectedSet = new Set(selectedAgentIds); - const filtered = filterTurns(source, request, selectedSet); - const communications = filterCommunications(source, request, selectedSet); - const controlChanges = filterControlChanges(source, request, selectedSet); - const allianceEvents = filterAllianceEvents(source, request, selectedSet); - const providerAttempts = filterProviderAttempts(source, request, selectedSet); - const firstRetainedTurn = source.turns[0]?.turnNumber; - const lastRetainedTurn = source.turns.at(-1)?.turnNumber; - const requestedRangeExtendsBeyondRetention = rangeExtendsBeyondRetention( + const tickNumbers = selectTickNumbers(source, request); + const requestFiltered = requestFilteredActions(source, request, tickNumbers); + const agentFiltered = requestFiltered.filter(({ agentId }) => + selectedSet.has(agentId), + ); + const controlChanges = filterControlChanges( + requestFiltered, request, + selectedSet, + ); + const providerAttempts = filterProviderAttempts( source, - firstRetainedTurn, - lastRetainedTurn, + selectedSet, + tickNumbers, ); - const droppedRecords = source.totalCompletedTurns - source.turns.length; + const retainedTicks = source.swarmTicks?.length ?? 0; + const firstRetainedTick = source.swarmTicks?.[0]?.tickNumber; + const lastRetainedTick = source.swarmTicks?.at(-1)?.tickNumber; + const droppedRecords = source.totalCompletedTicks - retainedTicks; const retention = { limit: source.retentionLimit, - totalCompletedTurns: source.totalCompletedTurns, - retainedTurns: source.turns.length, - firstRetainedTurn, - lastRetainedTurn, + totalCompletedTurns: source.totalCompletedTicks, + retainedTurns: retainedTicks, + firstRetainedTurn: firstRetainedTick, + lastRetainedTurn: lastRetainedTick, droppedRecords, complete: droppedRecords === 0, - requestedRangeExtendsBeyondRetention, + requestedRangeExtendsBeyondRetention: rangeExtendsBeyondRetention( + request, + source, + firstRetainedTick, + lastRetainedTick, + ), }; const include = inclusionsFor(request); const exportedSwarmTicks: SwarmTickRecord[] | undefined = @@ -756,21 +188,13 @@ export function createExperimentExport( request.turns.mode === 'entire-retained' ? [...structuredClone(source.swarmTicks ?? [])] : undefined; - const selectedTickNumbers = new Set([ - ...filtered - .map(({ tickNumber }) => tickNumber) - .filter((tick): tick is number => tick !== undefined), - ...(exportedSwarmTicks ?? []).map(({ tickNumber }) => tickNumber), - ...source.simulatedPlayerEvents - .filter( - (event) => - (request.agents.mode === 'all' && - request.turns.mode === 'entire-retained') || - (event.type === 'simulated-player-agent-captured' && - selectedSet.has(event.capturedAgentId)), - ) - .map(({ originatingTick }) => originatingTick), - ]); + const simulatedPlayerEventsIncluded = source.simulatedPlayerEvents.filter( + (event) => + (request.agents.mode === 'all' && + request.turns.mode === 'entire-retained') || + (event.type === 'simulated-player-agent-captured' && + selectedSet.has(event.capturedAgentId)), + ); const currentAgentsById = new Map( source.currentAgents.map((agent) => [agent.id, agent]), ); @@ -783,16 +207,7 @@ export function createExperimentExport( currentAgentsById.get(agentId) ?? initialAgentsById.get(agentId), ) .filter((agent): agent is Agent => agent !== undefined) - .map((agent) => - include.personality - ? structuredClone(agent) - : { - id: agent.id, - name: agent.name, - color: agent.color, - currentCell: agent.currentCell, - }, - ); + .map((agent) => structuredClone(agent)); const document: ExperimentExportDocument = { schemaVersion: source.schemaVersion, generatedAt, @@ -802,7 +217,6 @@ export function createExperimentExport( providerMode: source.providerMode, swarmPlannerContractVersion: SWARM_PLANNER_CONTRACT_VERSION, modelConfiguration: structuredClone(source.modelConfiguration), - behaviorConfiguration: structuredClone(source.behaviorConfiguration), scenario: structuredClone(source.scenario), ...(request.level === 'full-safe' ? { initialAgents: structuredClone([...source.initialAgents]) } @@ -812,73 +226,42 @@ export function createExperimentExport( filters: structuredClone(request), selection: { selectedAgentIds, - matchingTurnCount: filtered.length, ...(source.schemaVersion === 10 || source.schemaVersion === 11 ? { matchingTickCount: exportedSwarmTicks?.length ?? - new Set( - filtered.map(({ tickNumber }) => tickNumber).filter(Boolean), - ).size, + new Set(agentFiltered.map(({ tickNumber }) => tickNumber)).size, } : {}), ...(exportedSwarmTicks ? { matchingSwarmTickCount: exportedSwarmTicks.length } : {}), - matchingCommunicationCount: communications.length, matchingControlChangeCount: controlChanges.length, - matchingDiplomacyEventCount: allianceEvents.length, ...(source.schemaVersion === 11 ? { matchingProviderAttemptCount: providerAttempts.length } : {}), - matchingSimulatedPlayerEventCount: source.simulatedPlayerEvents.filter( - (event) => selectedTickNumbers.has(event.originatingTick), - ).length, - firstMatchingTurn: filtered[0]?.turnNumber, - lastMatchingTurn: filtered.at(-1)?.turnNumber, + matchingSimulatedPlayerEventCount: simulatedPlayerEventsIncluded.length, }, agents: selectedAgents, - currentGoals: selectedAgentIds.map((agentId) => - structuredClone( - source.agentGoals.find((entry) => entry.agentId === agentId) ?? { - agentId, - goal: null, - }, - ), - ), - currentMemories: selectedAgentIds.map((agentId) => - structuredClone( - source.agentMemories.find((entry) => entry.agentId === agentId) ?? { - agentId, - entries: [], - }, - ), - ), configurationEvents: source.configurationEvents - .filter((event) => - 'type' in event - ? event.scope === 'global' || - (event.agentId !== undefined && selectedSet.has(event.agentId)) - : include.personalityHistory && selectedSet.has(event.agentId), + .filter( + (event) => + event.scope === 'global' || + (event.agentId !== undefined && selectedSet.has(event.agentId)), ) .map((event) => structuredClone(event)), ...(include.metrics ? { metrics: calculateExperimentMetrics( - filtered, + agentFiltered, selectedAgentIds, - communications, + providerAttempts, controlChanges, - allianceEvents, ), currentTerritory: currentTerritory( source.currentWorld, source.currentAgents, ), - currentAlliances: currentAlliances( - source.currentWorld, - source.currentAgents, - ), simulatedPlayerMetrics: source.currentWorld.simulatedPlayer ?.metrics ?? { movements: 0, @@ -896,31 +279,30 @@ export function createExperimentExport( ...(request.level === 'full-safe' ? { worldEvents: [ - ...filtered.flatMap((turn) => { - if ( - turn.outcome !== 'accepted' || - turn.worldActionResult.event.type === 'hex-captured' - ) - return []; - return [structuredClone(turn.worldActionResult.event)]; - }), - ...source.simulatedPlayerEvents.filter((event) => { - return selectedTickNumbers.has(event.originatingTick); - }), + ...agentFiltered.flatMap(({ actionResult }) => + actionResult.accepted && + actionResult.event.type !== 'hex-captured' + ? [structuredClone(actionResult.event)] + : [], + ), + ...simulatedPlayerEventsIncluded, ], } : {}), - ...(include.communications - ? { communications: structuredClone(communications) } - : {}), ...(include.controlChanges ? { controlChanges: structuredClone(controlChanges) } : {}), - allianceEvents: structuredClone(allianceEvents), - turns: filtered.map((turn) => exportTurn(turn, request)), ...(exportedSwarmTicks ? { swarmTicks: exportedSwarmTicks } : {}), ...(source.schemaVersion === 10 || source.schemaVersion === 11 - ? { tickSummaries: summarizeTicks(filtered) } + ? { + tickSummaries: summarizeTicks( + (source.swarmTicks ?? []).filter( + (tick) => + tickNumbers === 'all' || tickNumbers.has(tick.tickNumber), + ), + providerAttempts, + ), + } : {}), ...(source.schemaVersion === 11 ? { @@ -942,8 +324,6 @@ function exportWorldState(world: WorldSnapshot): ExperimentExportWorldState { generatedAt: world.generatedAt, hexes: structuredClone(world.hexes), agents: structuredClone(world.agents), - alliances: structuredClone(world.alliances), - pendingAllianceProposals: structuredClone(world.pendingAllianceProposals), simulatedPlayer: structuredClone(world.simulatedPlayer), }; } @@ -957,44 +337,12 @@ function currentTerritory(world: WorldSnapshot, agents: readonly Agent[]) { (counts.get(hex.controllerAgentId) ?? 0) + 1, ); } - return agents.map(({ id, name, color }) => { - const alliance = world.alliances.find(({ memberAgentIds }) => - memberAgentIds.includes(id), - ); - return { - agentId: id, - name, - color, - allianceId: alliance?.id ?? null, - effectiveColor: alliance?.color ?? NEUTRAL_AGENT_COLOR, - controlledCellCount: counts.get(id) ?? 0, - }; - }); -} - -function currentAlliances(world: WorldSnapshot, agents: readonly Agent[]) { - const territory = currentTerritory(world, agents); - return world.alliances.map((alliance) => { - const members = alliance.memberAgentIds.map((agentId) => { - const entry = territory.find( - (candidate) => candidate.agentId === agentId, - )!; - return { - agentId, - name: entry.name, - controlledCellCount: entry.controlledCellCount, - }; - }); - return { - allianceId: alliance.id, - color: alliance.color, - totalControlledCellCount: members.reduce( - (sum, member) => sum + member.controlledCellCount, - 0, - ), - members, - }; - }); + return agents.map(({ id, name, color }) => ({ + agentId: id, + name, + color, + controlledCellCount: counts.get(id) ?? 0, + })); } export function createExperimentPreview( @@ -1005,29 +353,6 @@ export function createExperimentPreview( const document = createExperimentExport(source, request, generatedAt); const serialized = serializeExperimentExport(document); const serializedUtf8Bytes = new TextEncoder().encode(serialized).byteLength; - const metrics = calculateExperimentMetrics( - filterTurns( - source, - document.filters, - new Set(document.selection.selectedAgentIds), - ), - document.selection.selectedAgentIds, - filterCommunications( - source, - document.filters, - new Set(document.selection.selectedAgentIds), - ), - filterControlChanges( - source, - document.filters, - new Set(document.selection.selectedAgentIds), - ), - filterAllianceEvents( - source, - document.filters, - new Set(document.selection.selectedAgentIds), - ), - ); const ledgerKnownCost = (document.providerAttempts ?? []).reduce( (sum, attempt) => sum + Number(attempt.actualCostCredits ?? 0), 0, @@ -1037,31 +362,25 @@ export function createExperimentPreview( ).length; return experimentExportPreviewSchema.parse({ experimentId: source.id, - matchingTurnCount: document.selection.matchingTurnCount, ...(document.selection.matchingTickCount === undefined ? {} : { matchingTickCount: document.selection.matchingTickCount }), ...(document.selection.matchingSwarmTickCount === undefined ? {} : { matchingSwarmTickCount: document.selection.matchingSwarmTickCount }), - matchingCommunicationCount: document.selection.matchingCommunicationCount, matchingControlChangeCount: document.selection.matchingControlChangeCount, - matchingDiplomacyEventCount: document.selection.matchingDiplomacyEventCount, matchingProviderAttemptCount: document.selection.matchingProviderAttemptCount ?? 0, selectedAgentCount: document.selection.selectedAgentIds.length, - firstMatchingTurn: document.selection.firstMatchingTurn, - lastMatchingTurn: document.selection.lastMatchingTurn, retention: document.retention, knownCostCredits: document.schemaVersion === 11 ? ledgerKnownCost - : metrics.aggregate.knownCostCredits, + : (document.metrics?.aggregate.knownCostCredits ?? 0), attemptsWithUnknownCost: document.schemaVersion === 11 ? ledgerUnknownCost - : metrics.aggregate.attemptsWithUnknownCost, - turnsWithUnknownCost: metrics.aggregate.turnsWithUnknownCost, + : (document.metrics?.aggregate.attemptsWithUnknownCost ?? 0), serializedUtf8Bytes, approximateAiInputTokens: Math.ceil(serializedUtf8Bytes / 4), tokenEstimateMethod: 'ceil(UTF-8 bytes / 4)', @@ -1087,55 +406,19 @@ function resolveAgentIds( return [...selected]; } -function filterTurns( - source: ExperimentSource, - request: ExperimentExportRequest, - selected: Set, -): AgentTurnRecord[] { - let turns = source.turns.filter( - (turn) => - selected.has(turn.agentId) && - request.outcomes.includes(turn.outcome) && - (turn.outcome === 'provider-error' || - turn.outcome === 'lost-tick' || - turn.outcome === 'operator-skipped' || - request.actions.includes(turn.worldAction.type)), - ); - if (request.turns.mode === 'range') { - const range = request.turns; - turns = turns.filter( - ({ turnNumber }) => - turnNumber >= range.fromTurn && turnNumber <= range.toTurn, - ); - } else if (request.turns.mode === 'latest') { - turns = turns.slice(-request.turns.count); - } - return turns.map((turn) => structuredClone(turn)); -} - function filterProviderAttempts( source: ExperimentSource, - request: ExperimentExportRequest, selected: Set, + tickNumbers: Set | 'all', ): ProviderAttemptRecord[] { let attempts = (source.providerAttempts ?? []).filter(({ agentId }) => selected.has(agentId), ); - if (request.turns.mode === 'range') { - const { fromTurn, toTurn } = request.turns; - attempts = attempts.filter( - ({ intendedTurnNumber }) => - intendedTurnNumber >= fromTurn && intendedTurnNumber <= toTurn, - ); - } else if (request.turns.mode === 'latest') { - const fromTurn = Math.max( - 1, - source.totalCompletedTurns - request.turns.count + 1, - ); + if (tickNumbers !== 'all') attempts = attempts.filter( - ({ intendedTurnNumber }) => intendedTurnNumber >= fromTurn, + ({ intendedTickNumber }) => + intendedTickNumber !== undefined && tickNumbers.has(intendedTickNumber), ); - } return structuredClone(attempts).sort( (left, right) => left.startedAt.localeCompare(right.startedAt) || @@ -1143,71 +426,8 @@ function filterProviderAttempts( ); } -function filterCommunications( - source: ExperimentSource, - request: ExperimentExportRequest, - selected: Set, -): ExportedCommunication[] { - let communications = source.turns.flatMap((turn) => { - if ( - turn.outcome === 'provider-error' || - turn.outcome === 'lost-tick' || - turn.outcome === 'operator-skipped' || - !turn.communicationResult.requested - ) - return []; - const result = turn.communicationResult; - const communication = result.accepted ? result.event : result.attempt; - const selectedByParticipant = - communication.channel !== 'direct' - ? selected.has(communication.agentId) - : selected.has(communication.agentId) || - (communication.recipientId !== null && - selected.has(communication.recipientId)); - const selectedByChannel = - request.communications.channel === 'all' || - request.communications.channel === communication.channel; - const status = result.accepted - ? ('accepted' as const) - : ('rejected' as const); - const selectedByStatus = - request.communications.status === 'all' || - request.communications.status === status; - if (!selectedByParticipant || !selectedByChannel || !selectedByStatus) - return []; - return [ - { - ...structuredClone(communication), - originatingTurn: turn.turnNumber, - status, - ...(!result.accepted - ? { - rejectionReason: result.reason, - rejectionDetails: result.details, - } - : {}), - }, - ]; - }); - if (request.turns.mode === 'range') { - const range = request.turns; - communications = communications.filter( - ({ originatingTurn }) => - originatingTurn >= range.fromTurn && originatingTurn <= range.toTurn, - ); - } else if (request.turns.mode === 'latest') { - const firstIncludedTurn = source.turns.at(-request.turns.count)?.turnNumber; - communications = firstIncludedTurn - ? communications.filter( - ({ originatingTurn }) => originatingTurn >= firstIncludedTurn, - ) - : communications; - } - return communications; -} - function filterControlChanges( - source: ExperimentSource, + requestFiltered: readonly ResolvedWorldAction[], request: ExperimentExportRequest, selected: Set, ): ExportedControlChange[] { @@ -1216,69 +436,18 @@ function filterControlChanges( !request.actions.includes('capture') ) return []; - let controlChanges = source.turns.flatMap((turn) => { + return requestFiltered.flatMap(({ tickNumber, actionResult }) => { + if (!actionResult.accepted || actionResult.event.type !== 'hex-captured') + return []; + const event = actionResult.event; if ( - turn.outcome !== 'accepted' || - turn.worldActionResult.event.type !== 'hex-captured' || - (!selected.has(turn.worldActionResult.event.controllerAgentId) && - (turn.worldActionResult.event.previousControllerAgentId === null || - !selected.has( - turn.worldActionResult.event.previousControllerAgentId, - ))) + !selected.has(event.controllerAgentId) && + (event.previousControllerAgentId === null || + !selected.has(event.previousControllerAgentId)) ) return []; - return [ - { - ...structuredClone(turn.worldActionResult.event), - originatingTurn: turn.turnNumber, - }, - ]; + return [{ ...structuredClone(event), originatingTurn: tickNumber }]; }); - if (request.turns.mode === 'range') { - const range = request.turns; - controlChanges = controlChanges.filter( - ({ originatingTurn }) => - originatingTurn >= range.fromTurn && originatingTurn <= range.toTurn, - ); - } else if (request.turns.mode === 'latest') { - controlChanges = controlChanges.slice(-request.turns.count); - } - return controlChanges; -} - -function filterAllianceEvents( - source: ExperimentSource, - request: ExperimentExportRequest, - selected: Set, -): AllianceEvent[] { - let events = source.turns.flatMap(({ allianceEvents }) => - allianceEvents.filter((event) => - allianceEventAgentIds(event).some((id) => selected.has(id)), - ), - ); - if (request.turns.mode === 'range') { - const range = request.turns; - events = events.filter( - ({ turnNumber }) => - turnNumber >= range.fromTurn && turnNumber <= range.toTurn, - ); - } else if (request.turns.mode === 'latest') { - const first = source.turns.at(-request.turns.count)?.turnNumber; - if (first) events = events.filter(({ turnNumber }) => turnNumber >= first); - } - return events.map((event) => structuredClone(event)); -} - -function allianceEventAgentIds(event: AllianceEvent): AgentId[] { - if (event.type === 'alliance-proposed') - return [event.agentId, event.recipientAgentId]; - if (event.type === 'alliance-proposal-closed') - return [event.proposerAgentId, event.recipientAgentId]; - if (event.type === 'alliance-formed') return event.memberAgentIds; - if (event.type === 'agent-joined-alliance') return event.memberAgentIds; - if (event.type === 'agent-left-alliance') - return [event.leftAgentId, ...event.remainingMemberAgentIds]; - return event.formerMemberAgentIds; } function rangeExtendsBeyondRetention( @@ -1288,963 +457,256 @@ function rangeExtendsBeyondRetention( last?: number, ): boolean { if (request.turns.mode === 'entire-retained') - return source.totalCompletedTurns > source.turns.length; + return source.totalCompletedTicks > (source.swarmTicks?.length ?? 0); if (request.turns.mode !== 'range') return false; if (!first || !last) return true; return request.turns.fromTurn < first || request.turns.toTurn > last; } function inclusionsFor(request: ExperimentExportRequest) { - if (request.level === 'full-safe') - return { - personality: true, - personalityHistory: true, - metrics: true, - initialWorld: true, - currentWorld: true, - communications: true, - controlChanges: true, - }; if (request.level === 'custom') { const custom = request.custom!; return { - personality: custom.personalityTextHistory, - personalityHistory: custom.personalityTextHistory, metrics: custom.computedMetrics, initialWorld: custom.initialWorldState, currentWorld: custom.currentWorldState, - communications: custom.communications, controlChanges: custom.controlChanges, }; } return { - personality: true, - personalityHistory: false, metrics: true, - initialWorld: false, - currentWorld: false, - communications: true, + initialWorld: request.level === 'full-safe', + currentWorld: request.level === 'full-safe', controlChanges: true, }; } -function compactProvider(provider: ProviderMetadata): ProviderMetadata { - return { - provider: provider.provider, - model: provider.model, - ...(provider.selectedModel === undefined - ? {} - : { selectedModel: provider.selectedModel }), - ...(provider.resolvedModel === undefined - ? {} - : { resolvedModel: provider.resolvedModel }), - ...(provider.requestId === undefined - ? {} - : { requestId: provider.requestId }), - ...(provider.httpStatus === undefined - ? {} - : { httpStatus: provider.httpStatus }), - ...(provider.finishReason === undefined - ? {} - : { finishReason: provider.finishReason }), - ...(provider.nativeFinishReason === undefined - ? {} - : { nativeFinishReason: provider.nativeFinishReason }), - latencyMs: provider.latencyMs, - ...(provider.promptTokens === undefined - ? {} - : { promptTokens: provider.promptTokens }), - ...(provider.completionTokens === undefined - ? {} - : { completionTokens: provider.completionTokens }), - ...(provider.totalTokens === undefined - ? {} - : { totalTokens: provider.totalTokens }), - ...(provider.reasoningTokens === undefined - ? {} - : { reasoningTokens: provider.reasoningTokens }), - ...(provider.cachedReadTokens === undefined - ? {} - : { cachedReadTokens: provider.cachedReadTokens }), - ...(provider.cacheWriteTokens === undefined - ? {} - : { cacheWriteTokens: provider.cacheWriteTokens }), - ...(provider.costCredits === undefined - ? {} - : { costCredits: provider.costCredits }), - }; -} - -function exportTurn( - turn: AgentTurnRecord, - request: ExperimentExportRequest, -): ExperimentExportDocument['turns'][number] { - const standard = request.level === 'standard'; - const full = request.level === 'full-safe'; - const custom = request.level === 'custom' ? request.custom! : undefined; - const includeObservation = full || standard || custom?.turnObservations; - const includePersonality = full || standard || custom?.personalityTextHistory; - const includeValidation = full || standard || custom?.validationDetails; - const includeEvent = full || standard || custom?.resultingEvents; - const includeProvider = - request.level !== 'custom' || Boolean(custom?.providerUsageMetadata); - const base: ExperimentExportDocument['turns'][number] = { - turnNumber: turn.turnNumber, - ...(turn.tickNumber === undefined ? {} : { tickNumber: turn.tickNumber }), - ...(turn.tickPosition === undefined - ? {} - : { tickPosition: turn.tickPosition }), - ...(turn.virtualTime === undefined - ? {} - : { virtualTime: turn.virtualTime }), - ...(turn.tickIntervalMinutes === undefined - ? {} - : { tickIntervalMinutes: turn.tickIntervalMinutes }), - startedAt: turn.startedAt, - completedAt: turn.completedAt, - agentId: turn.agentId, - behavior: structuredClone(turn.behavior ?? turn.observation.behavior), - outcome: turn.outcome, - modelAttempts: turn.modelAttempts.map((attempt) => ({ - ...structuredClone(attempt), - ...(includeProvider ? {} : { provider: undefined }), - })), - ...(turn.outcome === 'provider-error' || - turn.outcome === 'lost-tick' || - turn.outcome === 'operator-skipped' - ? { failure: structuredClone(turn.failure) } - : { - worldAction: structuredClone(turn.worldAction), - ...(turn.communication - ? { communication: structuredClone(turn.communication) } - : {}), - ...(turn.diplomacy - ? { diplomacy: structuredClone(turn.diplomacy) } - : {}), - ...(turn.goalRevision - ? { goalRevision: structuredClone(turn.goalRevision) } - : {}), - goalRevisionResult: structuredClone(turn.goalRevisionResult), - ...(turn.memoryOperation - ? { memoryOperation: structuredClone(turn.memoryOperation) } - : {}), - memoryOperationResult: structuredClone(turn.memoryOperationResult), - summary: turn.summary, - worldActionSummary: turn.worldActionResult.accepted - ? summarizeEvent(turn.worldActionResult.event) - : `Rejected: ${turn.worldActionResult.reason}.`, - ...(turn.communicationResult.requested - ? { - communicationSummary: turn.communicationResult.accepted - ? summarizeCommunication( - turn.communicationResult.event.channel, - turn.communicationResult.event.channel === 'direct' - ? turn.communicationResult.event.recipientId - : undefined, - turn.communicationResult.event.channel === 'direct' - ? turn.communicationResult.event.distance - : undefined, - ) - : `Rejected: ${turn.communicationResult.reason}.`, - } - : {}), - ...(turn.diplomacyResult.requested - ? { - diplomacySummary: turn.diplomacyResult.accepted - ? `Accepted: ${turn.diplomacyResult.intent.type}.` - : `Rejected: ${turn.diplomacyResult.reason}.`, - } - : {}), - }), - }; - if (includePersonality) base.personality = turn.observation.personality; - if (includeObservation) { - const observation: Partial = structuredClone( - turn.observation, - ); - if (!includePersonality) delete observation.personality; - if (custom && !custom.nearbyAgents) delete observation.nearbyAgents; - if (custom && !custom.recentEvents) delete observation.recentEvents; - if (custom && !custom.recentPublicMessages) - delete observation.recentPublicMessages; - if (custom && !custom.recentDirectMessages) - delete observation.recentDirectMessages; - if (custom && !custom.recentControlChanges) - delete observation.recentControlChanges; - if (custom && !custom.recentControlChanges && observation.playerPressure) - observation.playerPressure = { - ...observation.playerPressure, - recentThreats: [], - }; - const globalThreatFeed = - observation.patientZeroGlobalView?.playerThreatFeed; - if (custom && !custom.recentControlChanges && globalThreatFeed) - observation.patientZeroGlobalView = { - ...observation.patientZeroGlobalView!, - playerThreatFeed: { - events: [], - totalEventCount: globalThreatFeed.totalEventCount, - truncated: globalThreatFeed.totalEventCount > 0, - }, - }; - base.observation = observation; - } - if ( - turn.outcome !== 'provider-error' && - turn.outcome !== 'lost-tick' && - turn.outcome !== 'operator-skipped' && - (includeValidation || includeEvent) - ) { - base.worldActionResult = structuredClone(turn.worldActionResult); - base.communicationResult = structuredClone(turn.communicationResult); - base.diplomacyResult = structuredClone(turn.diplomacyResult); - } - if (includeProvider && turn.provider) - base.provider = full - ? structuredClone(turn.provider) - : compactProvider(turn.provider); - return base; -} - function summarizeTicks( - records: readonly AgentTurnRecord[], + ticks: readonly SwarmTickRecord[], + attempts: readonly ProviderAttemptRecord[], ): ExperimentTickSummary[] { - const groups = new Map(); - for (const record of records) { - if (record.tickNumber === undefined) continue; - groups.set(record.tickNumber, [ - ...(groups.get(record.tickNumber) ?? []), - record, - ]); - } - return [...groups.entries()].map(([tickNumber, tickRecords]) => { - const attempts = tickRecords.flatMap(({ modelAttempts }) => modelAttempts); - const latencies = attempts.map( - ({ provider, failure }) => provider?.latencyMs ?? failure?.latencyMs ?? 0, + return ticks.map((tick) => { + const tickAttempts = attempts.filter( + ({ intendedTickNumber }) => intendedTickNumber === tick.tickNumber, + ); + const latencies = tickAttempts.flatMap(({ provider }) => + provider ? [provider.latencyMs] : [], ); - const knownCosts = attempts.flatMap(({ provider }) => - provider?.costCredits === undefined ? [] : [provider.costCredits], + const knownCosts = tickAttempts.flatMap(({ actualCostCredits }) => + actualCostCredits === undefined ? [] : [Number(actualCostCredits)], ); return { - tickNumber, - virtualTime: tickRecords[0]!.virtualTime!, - intervalMinutes: tickRecords[0]!.tickIntervalMinutes!, - agentRecordCount: tickRecords.length, - lostTicks: tickRecords.filter(({ outcome }) => outcome === 'lost-tick') - .length, - deadlineMisses: tickRecords.filter( - (record) => - record.outcome === 'lost-tick' && record.failure.code === 'timeout', - ).length, - providerCallCount: attempts.length, + tickNumber: tick.tickNumber, + virtualTime: tick.virtualTime, + intervalMinutes: tick.tickIntervalMinutes, + agentRecordCount: Math.max( + 1, + tick.workers.length + (tick.zeroActionResult ? 1 : 0), + ), + lostTicks: 0, + deadlineMisses: 0, + providerCallCount: tickAttempts.length, aggregateDecisionLatencyMs: latencies.reduce( (total, latency) => total + latency, 0, ), maximumDecisionLatencyMs: Math.max(0, ...latencies), knownCostCredits: knownCosts.reduce((total, cost) => total + cost, 0), - attemptsWithUnknownCost: attempts.length - knownCosts.length, - }; - }); -} - -function summarizeEvent( - event: Extract< - AgentTurnRecord, - { outcome: 'accepted' } - >['worldActionResult']['event'], -): string { - if (event.type === 'agent-moved') - return `Moved from ${event.fromCell} to ${event.toCell}.`; - if (event.type === 'hex-infected') return `Infected ${event.cell}.`; - if (event.type === 'hex-captured') - return `Captured ${event.cell} from ${event.previousControllerAgentId}.`; - return 'Waited.'; -} - -function summarizeCommunication( - channel: 'public' | 'direct' | 'alliance' | 'zero', - recipientId?: AgentId, - distance?: number, -): string { - return channel === 'public' - ? 'Published to world chat.' - : channel === 'alliance' - ? 'Delivered to current alliance members.' - : channel === 'zero' - ? 'Delivered privately to all other active agents.' - : `Delivered directly to ${recipientId} from distance ${distance}.`; -} - -export function calculateExperimentMetrics( - turns: readonly AgentTurnRecord[], - agentIds: readonly AgentId[], - communications: readonly ExportedCommunication[] = [], - controlChanges: readonly ExportedControlChange[] = [], - allianceEvents: readonly AllianceEvent[] = [], -): ExperimentMetrics { - const metricFor = ( - records: readonly AgentTurnRecord[], - relevantCommunications: readonly ExportedCommunication[], - relevantControlChanges: readonly ExportedControlChange[], - agentId?: AgentId, - scopedAgentIds: readonly AgentId[] = agentIds, - ) => { - const attempts = records.flatMap(usageAttempts); - const latencies = attempts.flatMap(({ provider }) => - provider ? [provider.latencyMs] : [], - ); - const tokens: Record = {}; - for (const field of metricTokenFields) { - const known = attempts - .map(({ provider }) => provider?.[field]) - .filter((value): value is number => value !== undefined); - if (known.length > 0) - tokens[field] = known.reduce((sum, value) => sum + value, 0); - } - const attemptsWithUnknownTokenUsage = attempts.filter(({ provider }) => - metricTokenFields.some((field) => provider?.[field] === undefined), - ).length; - const visited = new Set(); - for (const turn of records) { - visited.add(turn.observation.currentCell.cell); - if ( - turn.outcome === 'accepted' && - turn.worldActionResult.event.type === 'agent-moved' - ) - visited.add(turn.worldActionResult.event.toCell); - } - const costs = attempts.flatMap(({ provider }) => - provider?.costCredits === undefined ? [] : [provider.costCredits], - ); - const manualRetryAttempts = attempts.filter( - ({ kind }) => kind === 'manual-retry', - ).length; - const automaticRepairAttempts = attempts.filter( - ({ kind }) => kind === 'automatic-repair', - ).length; - const automaticTransportRetries = attempts.filter( - ({ kind }) => kind === 'automatic-transport-retry', - ).length; - const retriedTurns = records.filter((turn) => - usageAttempts(turn).some(({ kind }) => kind !== 'initial'), - ); - const movement = movementMetrics(records); - const mostRecentZeroTurn = records.findLast( - (turn) => - turn.outcome !== 'provider-error' && - turn.outcome !== 'lost-tick' && - turn.outcome !== 'operator-skipped' && - turn.communicationResult.requested && - turn.communicationResult.accepted && - turn.communicationResult.event.channel === 'zero', - ); - const mostRecentZeroDirective = - mostRecentZeroTurn && - mostRecentZeroTurn.outcome !== 'provider-error' && - mostRecentZeroTurn.outcome !== 'lost-tick' && - mostRecentZeroTurn.outcome !== 'operator-skipped' && - mostRecentZeroTurn.communicationResult.requested && - mostRecentZeroTurn.communicationResult.accepted && - mostRecentZeroTurn.communicationResult.event.channel === 'zero' - ? { - eventId: mostRecentZeroTurn.communicationResult.event.id, - turnNumber: mostRecentZeroTurn.turnNumber, - occurredAt: mostRecentZeroTurn.communicationResult.event.occurredAt, - agentId: mostRecentZeroTurn.agentId, - recipientCount: - mostRecentZeroTurn.communicationResult.event.recipientIds.length, - modelId: mostRecentZeroTurn.provider.model, - reasoningProfile: - mostRecentZeroTurn.modelAttempts.at(-1)?.reasoningProfile ?? - 'provider-default', - personalityId: - mostRecentZeroTurn.observation.behavior.personalityId, - strategyId: mostRecentZeroTurn.observation.behavior.strategyId, - } - : null; - return { - totalTurns: records.length, - accepted: records.filter(({ outcome }) => outcome === 'accepted').length, - rejected: records.filter(({ outcome }) => outcome === 'rejected').length, - providerErrors: records.filter( - ({ outcome }) => outcome === 'provider-error', - ).length, - lostTicks: records.filter(({ outcome }) => outcome === 'lost-tick') - .length, - operatorSkipped: records.filter( - ({ outcome }) => outcome === 'operator-skipped', - ).length, - modelCalls: attempts.length, - failedModelAttempts: attempts.filter(({ failed }) => failed).length, - automaticRepairAttempts, - automaticTransportRetries, - manualRetryAttempts, - retriedTurns: retriedTurns.length, - recoveredAutomatically: retriedTurns.filter( - (turn) => - !usageAttempts(turn).some(({ kind }) => kind === 'manual-retry') && - turn.outcome !== 'provider-error' && - turn.outcome !== 'lost-tick' && - turn.outcome !== 'operator-skipped', - ).length, - recoveredManually: retriedTurns.filter( - (turn) => - usageAttempts(turn).some(({ kind }) => kind === 'manual-retry') && - turn.outcome !== 'provider-error' && - turn.outcome !== 'lost-tick' && - turn.outcome !== 'operator-skipped', - ).length, - recoveredByRetry: retriedTurns.filter( - ({ outcome }) => - outcome !== 'provider-error' && - outcome !== 'lost-tick' && - outcome !== 'operator-skipped', - ).length, - requestedMoves: records.filter( - (turn) => - turn.outcome !== 'provider-error' && - turn.outcome !== 'lost-tick' && - turn.outcome !== 'operator-skipped' && - turn.worldAction.type === 'move', - ).length, - requestedInfections: records.filter( - (turn) => - turn.outcome !== 'provider-error' && - turn.outcome !== 'lost-tick' && - turn.outcome !== 'operator-skipped' && - turn.worldAction.type === 'infect', - ).length, - requestedCaptures: records.filter( - (turn) => - turn.outcome !== 'provider-error' && - turn.outcome !== 'lost-tick' && - turn.outcome !== 'operator-skipped' && - turn.worldAction.type === 'capture', - ).length, - requestedWaits: records.filter( - (turn) => - turn.outcome !== 'provider-error' && - turn.outcome !== 'lost-tick' && - turn.outcome !== 'operator-skipped' && - turn.worldAction.type === 'wait', - ).length, - acceptedMovements: records.filter( - (turn) => - turn.outcome === 'accepted' && - turn.worldActionResult.event.type === 'agent-moved', - ).length, - successfullyInfectedCells: records.filter( - (turn) => - turn.outcome === 'accepted' && - turn.worldActionResult.event.type === 'hex-infected', - ).length, - successfulCaptures: records.filter( - (turn) => - turn.outcome === 'accepted' && - turn.worldActionResult.event.type === 'hex-captured', - ).length, - acceptedWaits: records.filter( - (turn) => - turn.outcome === 'accepted' && - turn.worldActionResult.event.type === 'agent-waited', - ).length, - rejectedWorldActions: records.filter( - ({ outcome }) => outcome === 'rejected', - ).length, - territoryGainedThroughInfection: records.filter( - (turn) => - turn.outcome === 'accepted' && - turn.worldActionResult.event.type === 'hex-infected', - ).length, - territoryGainedThroughCapture: agentId - ? relevantControlChanges.filter( - ({ controllerAgentId }) => controllerAgentId === agentId, - ).length - : relevantControlChanges.filter(({ controllerAgentId }) => - scopedAgentIds.includes(controllerAgentId), - ).length, - territoryLostThroughCapture: agentId - ? relevantControlChanges.filter( - ({ previousControllerAgentId }) => - previousControllerAgentId === agentId, - ).length - : relevantControlChanges.filter( - ({ previousControllerAgentId }) => - previousControllerAgentId !== null && - scopedAgentIds.includes(previousControllerAgentId), - ).length, - ...communicationMetrics(relevantCommunications, scopedAgentIds, agentId), - mostRecentZeroDirective, - ...diplomacyMetrics( - records, - agentId - ? allianceEvents - : allianceEvents.filter((event) => - scopedAgentIds.includes(event.agentId), - ), - agentId, - ), - uniqueVisitedCells: visited.size, - eligibleNearbyAgentObservations: records.reduce( - (sum, turn) => - sum + - turn.observation.nearbyAgents.filter( - ({ directMessageLegal }) => directMessageLegal, - ).length, - 0, - ), - ...movement, - ...(latencies.length > 0 - ? { - averageLatencyMs: - latencies.reduce((sum, value) => sum + value, 0) / - latencies.length, - } - : {}), - tokens, - tokenUsageComplete: attemptsWithUnknownTokenUsage === 0, - attemptsWithUnknownTokenUsage, - knownCostCredits: sumDecimalNumbers(costs), - attemptsWithUnknownCost: attempts.filter( - ({ provider }) => provider?.costCredits === undefined, - ).length, - turnsWithUnknownCost: records.filter((turn) => - usageAttempts(turn).some( - ({ provider }) => provider?.costCredits === undefined, - ), - ).length, + attemptsWithUnknownCost: tickAttempts.length - knownCosts.length, }; - }; - const assignmentFor = (turn: AgentTurnRecord) => - turn.behavior ?? turn.observation.behavior; - const metricsForBehavior = (matches: (turn: AgentTurnRecord) => boolean) => { - const records = turns.filter(matches); - const scopedAgentIds = [...new Set(records.map(({ agentId }) => agentId))]; - return metricFor( - records, - communications, - controlChanges, - undefined, - scopedAgentIds, - ); - }; - const combinations = [ - ...new Map( - turns.map((turn) => { - const assignment = assignmentFor(turn); - return [ - `${assignment.personalityId}:${assignment.strategyId}`, - assignment, - ]; - }), - ).values(), - ]; - return experimentMetricsSchema.parse({ - aggregate: metricFor(turns, communications, controlChanges), - byAgent: agentIds.map((agentId) => ({ - agentId, - metrics: metricFor( - turns.filter((turn) => turn.agentId === agentId), - communications, - controlChanges, - agentId, - ), - })), - byPersonality: PERSONALITY_PROFILES.map(({ id: personalityId }) => ({ - personalityId, - metrics: metricsForBehavior( - (turn) => assignmentFor(turn).personalityId === personalityId, - ), - })), - byStrategy: STRATEGY_PROFILES.map(({ id: strategyId }) => ({ - strategyId, - metrics: metricsForBehavior( - (turn) => assignmentFor(turn).strategyId === strategyId, - ), - })), - byBehaviorCombination: combinations.map( - ({ personalityId, strategyId }) => ({ - personalityId, - strategyId, - metrics: metricsForBehavior((turn) => { - const assignment = assignmentFor(turn); - return ( - assignment.personalityId === personalityId && - assignment.strategyId === strategyId - ); - }), - }), - ), }); } -function diplomacyMetrics( - records: readonly AgentTurnRecord[], - events: readonly AllianceEvent[], - agentId?: AgentId, -) { - const relevantEvents = agentId - ? events.filter((event) => allianceEventAgentIds(event).includes(agentId)) - : events; - const completed = records.filter( - ( - turn, - ): turn is Exclude< - AgentTurnRecord, - { outcome: 'provider-error' | 'lost-tick' | 'operator-skipped' } - > & { - diplomacyResult: Exclude; - } => - turn.outcome !== 'provider-error' && - turn.outcome !== 'lost-tick' && - turn.outcome !== 'operator-skipped' && - turn.diplomacyResult.requested, +function attemptMetrics(attempts: readonly ProviderAttemptRecord[]) { + const failedModelAttempts = attempts.filter( + ({ outcome }) => outcome !== 'completed', + ).length; + const automaticRepairAttempts = attempts.filter( + ({ kind }) => kind === 'automatic-repair', + ).length; + const automaticTransportRetries = attempts.filter( + ({ kind }) => kind === 'automatic-transport-retry', + ).length; + const manualRetryAttempts = attempts.filter( + ({ kind }) => kind === 'manual-retry', + ).length; + const unattendedRetryAttempts = attempts.filter( + ({ kind }) => kind === 'unattended-retry', + ).length; + const groups = new Map(); + for (const attempt of attempts) { + const key = `${attempt.agentId}:${attempt.intendedTickNumber ?? attempt.intendedTurnNumber}`; + groups.set(key, [...(groups.get(key) ?? []), attempt]); + } + const retriedGroups = [...groups.values()].filter((group) => + group.some((attempt) => attempt.kind !== 'initial'), ); - const requested = (type: string) => - completed.filter((turn) => { - const result = turn.diplomacyResult; - return ( - result.requested && - (result.accepted ? result.intent.type : result.attempt.type) === type - ); - }); - const accepted = (type: string) => - requested(type).filter( - (turn) => turn.diplomacyResult.requested && turn.diplomacyResult.accepted, + const recovered = (predicate: (group: ProviderAttemptRecord[]) => boolean) => + retriedGroups.filter( + (group) => + predicate(group) && + group.some((attempt) => attempt.outcome === 'completed'), ).length; - const formed = relevantEvents.filter( - (event): event is Extract => - event.type === 'alliance-formed', + const latencies = attempts.flatMap(({ provider }) => + provider ? [provider.latencyMs] : [], ); - const completedDurations = formed.flatMap((created) => { - const dissolved = relevantEvents.find( - (event) => - event.type === 'alliance-dissolved' && - event.allianceId === created.allianceId && - event.turnNumber >= created.turnNumber, - ); - return dissolved ? [dissolved.turnNumber - created.turnNumber] : []; - }); - const allianceSizes = relevantEvents.flatMap((event) => - event.type === 'alliance-formed' - ? [event.memberAgentIds.length] - : event.type === 'agent-joined-alliance' - ? [event.memberAgentIds.length] - : [], + const tokens: Record = {}; + for (const field of metricTokenFields) { + const known = attempts + .map(({ provider }) => provider?.[field]) + .filter((value): value is number => value !== undefined); + if (known.length > 0) + tokens[field] = known.reduce((sum, value) => sum + value, 0); + } + const attemptsWithUnknownTokenUsage = attempts.filter(({ provider }) => + metricTokenFields.some((field) => provider?.[field] === undefined), + ).length; + const costs = attempts.flatMap(({ actualCostCredits }) => + actualCostCredits === undefined ? [] : [Number(actualCostCredits)], ); return { - diplomacyProposalsRequested: requested('propose-alliance').length, - diplomacyAcceptancesRequested: requested('accept-alliance').length, - diplomacyDeparturesRequested: requested('leave-alliance').length, - diplomacyProposalsAccepted: accepted('propose-alliance'), - diplomacyAcceptancesAccepted: accepted('accept-alliance'), - diplomacyDeparturesAccepted: accepted('leave-alliance'), - diplomacyRejected: completed.filter( - (turn) => !turn.diplomacyResult.accepted, - ).length, - diplomacyRejections: groupedDiplomacyRejections(completed), - proposalsCreated: relevantEvents.filter( - (event) => - event.type === 'alliance-proposed' && - (!agentId || event.agentId === agentId), - ).length, - proposalsSent: relevantEvents.filter( - (event) => - event.type === 'alliance-proposed' && - (!agentId || event.agentId === agentId), - ).length, - proposalsReceived: relevantEvents.filter( - (event) => - event.type === 'alliance-proposed' && - (!agentId || event.recipientAgentId === agentId), - ).length, - proposalsExpired: relevantEvents.filter( - (event) => - event.type === 'alliance-proposal-closed' && event.reason === 'expired', - ).length, - proposalsInvalidated: relevantEvents.filter( - (event) => - event.type === 'alliance-proposal-closed' && - event.reason === 'invalidated', - ).length, - alliancesFormed: relevantEvents.filter( - (event) => event.type === 'alliance-formed', - ).length, - alliancesJoined: relevantEvents.reduce( - (count, event) => - count + - (event.type === 'alliance-formed' - ? agentId - ? 1 - : event.memberAgentIds.length - : event.type === 'agent-joined-alliance' && - (!agentId || event.joinedAgentId === agentId) - ? 1 - : 0), - 0, + modelCalls: attempts.length, + failedModelAttempts, + automaticRepairAttempts, + automaticTransportRetries, + manualRetryAttempts, + unattendedRetryAttempts, + retriedTurns: retriedGroups.length, + recoveredByUnattendedRetry: recovered( + (group) => + group.some((attempt) => attempt.kind === 'unattended-retry') && + !group.some((attempt) => attempt.kind === 'manual-retry'), ), - alliancesLeft: relevantEvents.filter( - (event) => - event.type === 'agent-left-alliance' && - (!agentId || event.leftAgentId === agentId), - ).length, - alliancesDissolved: relevantEvents.filter( - (event) => event.type === 'alliance-dissolved', - ).length, - firstAllianceTurn: formed.length - ? Math.min(...formed.map(({ turnNumber }) => turnNumber)) - : null, - maximumAllianceSize: Math.max(0, ...allianceSizes), - completedAllianceDurationTurnsTotal: completedDurations.reduce( - (sum, duration) => sum + duration, - 0, + recoveredManually: recovered((group) => + group.some((attempt) => attempt.kind === 'manual-retry'), ), - completedAllianceDurationTurnsAverage: completedDurations.length - ? completedDurations.reduce((sum, duration) => sum + duration, 0) / - completedDurations.length - : 0, - alliedCaptureAttempts: records.filter( - (turn) => - turn.outcome !== 'provider-error' && - turn.outcome !== 'lost-tick' && - turn.outcome !== 'operator-skipped' && - turn.worldAction.type === 'capture' && - !turn.worldActionResult.accepted && - turn.worldActionResult.reason === 'allied-controller', - ).length, - alliedCaptureRejections: records.filter( - (turn) => - turn.outcome !== 'provider-error' && - turn.outcome !== 'lost-tick' && - turn.outcome !== 'operator-skipped' && - turn.worldAction.type === 'capture' && - !turn.worldActionResult.accepted && - turn.worldActionResult.reason === 'allied-controller', + recoveredAutomatically: recovered( + (group) => !group.some((attempt) => attempt.kind === 'manual-retry'), + ), + recoveredByRetry: recovered(() => true), + ...(latencies.length > 0 + ? { + averageLatencyMs: + latencies.reduce((sum, value) => sum + value, 0) / latencies.length, + } + : {}), + tokens, + tokenUsageComplete: attemptsWithUnknownTokenUsage === 0, + attemptsWithUnknownTokenUsage, + knownCostCredits: costs.reduce((sum, value) => sum + value, 0), + attemptsWithUnknownCost: attempts.filter( + ({ actualCostCredits }) => actualCostCredits === undefined, ).length, }; } -function movementMetrics(records: readonly AgentTurnRecord[]) { - const moves = records.flatMap((turn) => { - if ( - turn.outcome !== 'accepted' || - turn.worldActionResult.event.type !== 'agent-moved' - ) - return []; - const moved = turn.worldActionResult.event; - const option = turn.observation.actionAvailability.moveOptions.find( - ({ targetCell }) => targetCell === moved.toCell, - ); - return option - ? [{ turn, direction: option.direction, toCell: option.targetCell }] - : []; - }); - const counts = new Map<(typeof moves)[number]['direction'], number>(); - let longest = 0; - let streak = 0; - let previousDirection: (typeof moves)[number]['direction'] | undefined; - let previousMoveAt: string | undefined; - let directionChangesAfterCommunication = 0; - const visited = new Set( - records[0] ? [records[0].observation.currentCell.cell] : [], - ); - let recentCellRevisits = 0; - for (const { turn, direction, toCell } of moves) { - const priorMoveAt = previousMoveAt; - counts.set(direction, (counts.get(direction) ?? 0) + 1); - streak = direction === previousDirection ? streak + 1 : 1; - longest = Math.max(longest, streak); - if ( - previousDirection && - priorMoveAt && - direction !== previousDirection && - (turn.observation.recentDirectMessages.some( - ({ direction: messageDirection, occurredAt }) => - messageDirection === 'inbound' && - occurredAt > priorMoveAt && - occurredAt <= turn.completedAt, - ) || - turn.observation.recentAllianceMessages.some( - ({ senderId, occurredAt }) => - senderId !== turn.agentId && - occurredAt > priorMoveAt && - occurredAt <= turn.completedAt, - )) - ) - directionChangesAfterCommunication += 1; - previousDirection = direction; - previousMoveAt = turn.completedAt; - if (visited.has(toCell)) recentCellRevisits += 1; - visited.add(toCell); - } - return { - movementDirectionDistribution: [...counts.entries()] - .map(([direction, count]) => ({ direction, count })) - .toSorted((a, b) => a.direction.localeCompare(b.direction)), - longestRepeatedDirectionStreak: longest, - recentCellRevisits, - directionChangesAfterCommunication, - }; -} - -function groupedDiplomacyRejections(records: readonly AgentTurnRecord[]) { - const grouped = new Map< - string, - { - type: - 'propose-alliance' | 'accept-alliance' | 'leave-alliance' | 'invalid'; - reason: DiplomacyRejectionReason; - count: number; - } - >(); - for (const turn of records) { - if ( - turn.outcome === 'provider-error' || - turn.outcome === 'lost-tick' || - turn.outcome === 'operator-skipped' || - !turn.diplomacyResult.requested || - turn.diplomacyResult.accepted - ) - continue; - const { type } = turn.diplomacyResult.attempt; - const { reason } = turn.diplomacyResult; - const key = `${type}:${reason}`; - grouped.set(key, { - type, - reason, - count: (grouped.get(key)?.count ?? 0) + 1, - }); - } - return [...grouped.values()]; -} - -function communicationMetrics( - communications: readonly ExportedCommunication[], - agentIds: readonly AgentId[], +function metricCountsFor( + scopeActions: readonly ResolvedWorldAction[], + scopeAttempts: readonly ProviderAttemptRecord[], + controlChanges: readonly ExportedControlChange[], + scopedAgentIds: readonly AgentId[], agentId?: AgentId, ) { - const authoredBySelection = ({ agentId: senderId }: ExportedCommunication) => - agentId ? senderId === agentId : agentIds.includes(senderId); - const receivedBySelection = (communication: ExportedCommunication) => - communication.channel === 'direct' && - (agentId - ? communication.recipientId === agentId - : communication.recipientId !== undefined && - communication.recipientId !== null && - agentIds.includes(communication.recipientId)); - const publicAuthored = communications.filter( - (communication) => - communication.channel === 'public' && authoredBySelection(communication), - ); - const directAuthored = communications.filter( - (communication) => - communication.channel === 'direct' && authoredBySelection(communication), - ); - const allianceAuthored = communications.filter( - (communication) => - communication.channel === 'alliance' && - authoredBySelection(communication), - ); - const zeroAuthored = communications.filter( - (communication) => - communication.channel === 'zero' && authoredBySelection(communication), - ); - const patientZeroId = communications.find( - ({ channel, status }) => channel === 'zero' && status === 'accepted', - )?.agentId; - const repliesToPatientZero = patientZeroId - ? directAuthored.filter( - ({ status, recipientId, agentId: senderId }) => - status === 'accepted' && - recipientId === patientZeroId && - senderId !== patientZeroId, - ) - : []; - const deliveredDirect = directAuthored.filter( - (communication) => - communication.status === 'accepted' && - communication.distance !== null && - communication.distance !== undefined, - ); - const directDistanceTotal = deliveredDirect.reduce( - (sum, communication) => sum + (communication.distance ?? 0), - 0, + const accepted = scopeActions.filter( + ({ actionResult }) => actionResult.accepted, + ).length; + const rejected = scopeActions.length - accepted; + const visited = new Set(); + const firstMove = scopeActions.find( + ({ actionResult }) => + actionResult.accepted && actionResult.event.type === 'agent-moved', ); + if ( + firstMove && + firstMove.actionResult.accepted && + firstMove.actionResult.event.type === 'agent-moved' + ) + visited.add(firstMove.actionResult.event.fromCell); + for (const { actionResult } of scopeActions) + if (actionResult.accepted && actionResult.event.type === 'agent-moved') + visited.add(actionResult.event.toCell); + const successfullyInfectedCells = scopeActions.filter( + ({ actionResult }) => + actionResult.accepted && actionResult.event.type === 'hex-infected', + ).length; + const territoryGainedThroughCapture = agentId + ? controlChanges.filter( + ({ controllerAgentId }) => controllerAgentId === agentId, + ).length + : controlChanges.filter(({ controllerAgentId }) => + scopedAgentIds.includes(controllerAgentId), + ).length; + const territoryLostThroughCapture = agentId + ? controlChanges.filter( + ({ previousControllerAgentId }) => + previousControllerAgentId === agentId, + ).length + : controlChanges.filter( + ({ previousControllerAgentId }) => + previousControllerAgentId !== null && + scopedAgentIds.includes(previousControllerAgentId), + ).length; return { - publicMessagesRequested: publicAuthored.length, - publicMessagesAccepted: publicAuthored.filter( - ({ status }) => status === 'accepted', - ).length, - publicMessagesRejected: publicAuthored.filter( - ({ status }) => status === 'rejected', - ).length, - directMessagesRequested: directAuthored.length, - directMessagesDelivered: directAuthored.filter( - ({ status }) => status === 'accepted', - ).length, - directMessagesRejected: directAuthored.filter( - ({ status }) => status === 'rejected', + totalTurns: scopeActions.length, + accepted, + rejected, + providerErrors: 0, + requestedMoves: scopeActions.filter(({ action }) => action.type === 'move') + .length, + requestedInfections: scopeActions.filter( + ({ action }) => action.type === 'infect', ).length, - allianceMessagesRequested: allianceAuthored.length, - allianceMessagesDelivered: allianceAuthored.filter( - ({ status }) => status === 'accepted', + requestedCaptures: scopeActions.filter( + ({ action }) => action.type === 'capture', ).length, - allianceMessagesRejected: allianceAuthored.filter( - ({ status }) => status === 'rejected', + requestedWaits: scopeActions.filter(({ action }) => action.type === 'wait') + .length, + acceptedMovements: scopeActions.filter( + ({ actionResult }) => + actionResult.accepted && actionResult.event.type === 'agent-moved', ).length, - zeroBroadcastsRequested: zeroAuthored.length, - zeroBroadcastsDelivered: zeroAuthored.filter( - ({ status }) => status === 'accepted', + successfullyInfectedCells, + successfulCaptures: scopeActions.filter( + ({ actionResult }) => + actionResult.accepted && actionResult.event.type === 'hex-captured', ).length, - zeroBroadcastsRejected: zeroAuthored.filter( - ({ status }) => status === 'rejected', + acceptedWaits: scopeActions.filter( + ({ actionResult }) => + actionResult.accepted && actionResult.event.type === 'agent-waited', ).length, - zeroRecipientDeliveries: zeroAuthored.reduce( - (sum, communication) => - sum + - (communication.status === 'accepted' - ? (communication.recipientIds?.length ?? 0) - : 0), - 0, + rejectedWorldActions: rejected, + territoryGainedThroughInfection: successfullyInfectedCells, + territoryGainedThroughCapture, + territoryLostThroughCapture, + uniqueVisitedCells: visited.size, + ...attemptMetrics(scopeAttempts), + }; +} + +export function calculateExperimentMetrics( + resolvedActions: readonly ResolvedWorldAction[], + agentIds: readonly AgentId[], + providerAttempts: readonly ProviderAttemptRecord[] = [], + controlChanges: readonly ExportedControlChange[] = [], +): ExperimentMetrics { + return experimentMetricsSchema.parse({ + aggregate: metricCountsFor( + resolvedActions, + providerAttempts, + controlChanges, + agentIds, ), - uniqueZeroDirectiveRecipients: new Set( - zeroAuthored.flatMap(({ recipientIds }) => recipientIds ?? []), - ).size, - directRepliesToPatientZero: repliesToPatientZero.length, - uniquePatientZeroRepliers: new Set( - repliesToPatientZero.map(({ agentId: senderId }) => senderId), - ).size, - firstZeroDirectiveTurn: - zeroAuthored.find(({ status }) => status === 'accepted') - ?.originatingTurn ?? null, - publicMessagesSent: publicAuthored.filter( - ({ status }) => status === 'accepted', - ).length, - directMessagesSent: directAuthored.filter( - ({ status }) => status === 'accepted', - ).length, - directMessagesReceived: communications.filter( - (communication) => - communication.status === 'accepted' && - receivedBySelection(communication), - ).length, - uniqueDirectMessagePairs: new Set( - directAuthored.flatMap((communication) => - communication.recipientId - ? [`${communication.agentId}:${communication.recipientId}`] - : [], + byAgent: agentIds.map((agentId) => ({ + agentId, + metrics: metricCountsFor( + resolvedActions.filter((action) => action.agentId === agentId), + providerAttempts.filter((attempt) => attempt.agentId === agentId), + controlChanges, + [agentId], + agentId, ), - ).size, - directMessageDistanceTotalKm: directDistanceTotal, - directMessageDistanceAverageKm: deliveredDirect.length - ? directDistanceTotal / deliveredDirect.length - : 0, - directMessageDistanceMaximumKm: Math.max( - 0, - ...deliveredDirect.map(({ distance }) => distance ?? 0), - ), - }; + })), + }); } export function serializeExperimentExport( @@ -2254,43 +716,3 @@ export function serializeExperimentExport( ? JSON.stringify(document, null, 2) : JSON.stringify(document); } - -export function addDecimalValue(left: string, right: number): string { - const leftParts = decimalParts(left); - const rightParts = decimalParts(right); - const scale = Math.max(leftParts.scale, rightParts.scale); - const leftInteger = - leftParts.integer * 10n ** BigInt(scale - leftParts.scale); - const rightInteger = - rightParts.integer * 10n ** BigInt(scale - rightParts.scale); - return decimalString(leftInteger + rightInteger, scale); -} - -function sumDecimalNumbers(values: readonly number[]): number { - return Number(values.reduce(addDecimalValue, '0')); -} - -function decimalParts(value: number | string): { - integer: bigint; - scale: number; -} { - const [mantissa, exponentText = '0'] = value - .toString() - .toLowerCase() - .split('e'); - const exponent = Number(exponentText); - const [whole, fraction = ''] = mantissa!.split('.'); - let integer = BigInt(`${whole}${fraction}`); - let scale = fraction.length - exponent; - if (scale < 0) { - integer *= 10n ** BigInt(-scale); - scale = 0; - } - return { integer, scale }; -} - -function decimalString(integer: bigint, scale: number): string { - if (scale === 0) return integer.toString(); - const digits = integer.toString().padStart(scale + 1, '0'); - return `${digits.slice(0, -scale)}.${digits.slice(-scale)}`; -} diff --git a/apps/game-api/src/live-swarm-comparison.ts b/apps/game-api/src/live-swarm-comparison.ts index c38ec2c..9565e74 100644 --- a/apps/game-api/src/live-swarm-comparison.ts +++ b/apps/game-api/src/live-swarm-comparison.ts @@ -1,5 +1,5 @@ import { type ReflexProvider, type SwarmPlanner } from '@hexzero/agent-runtime'; -import { assignBehavior, type CompatibleModel } from '@hexzero/shared'; +import { type CompatibleModel } from '@hexzero/shared'; import { generateDeterministicRoster } from '@hexzero/world-engine'; import { SimulationService } from './simulation-service'; import type { CompiledReflexObservation } from './reflex-execution'; @@ -340,7 +340,6 @@ function exportRequest() { turns: { mode: 'entire-retained' }, outcomes: ['accepted', 'rejected', 'provider-error', 'operator-skipped'], actions: ['move', 'infect', 'capture', 'wait'], - communications: { channel: 'all', status: 'all' }, serialization: 'compact', level: 'full-safe', } as const; @@ -407,16 +406,6 @@ async function runVariant( overrides: [], locked: false, }, - // This transitional scenario field remains required until PR 2 removes - // legacy behavior configuration from the shared world setup schema. - behaviorConfiguration: { - ...request.behaviorConfiguration, - assignments: assignBehavior( - roster.map(({ id }) => id), - `live-behavior-${seed}`, - 'balanced-random', - ), - }, }); const ticks: LiveComparisonTick[] = []; let stoppedReason: LiveComparisonRun['final']['stoppedReason'] = 'tick-cap'; diff --git a/apps/game-api/src/observation-history.test.ts b/apps/game-api/src/observation-history.test.ts deleted file mode 100644 index c12e323..0000000 --- a/apps/game-api/src/observation-history.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - agentIdSchema, - worldEventSchema, - type AgentId, - type WorldEvent, -} from '@hexzero/shared'; -import { ObservationHistory } from './observation-history'; - -const actor = agentIdSchema.parse('00000000-0000-4000-8000-000000000001'); -const peer = agentIdSchema.parse('00000000-0000-4000-8000-000000000002'); -const outsider = agentIdSchema.parse('00000000-0000-4000-8000-000000000003'); -const cellA = '892a94d232bffff'; -const cellB = '892a94d2323ffff'; - -describe('ObservationHistory', () => { - it('retains independent factual streams through more than 120 unrelated events', () => { - const history = new ObservationHistory(); - history.ingest([ - ...range(7).map((index) => movement(actor, index)), - ...range(10).map((index) => waited(actor, index)), - ...range(15).map((index) => publicMessage(actor, index)), - ...range(7).map((index) => directMessage(actor, peer, index)), - ...range(7).map((index) => allianceMessage(actor, [peer], index)), - ...range(7).map((index) => zeroMessage(actor, [peer], index)), - ...range(7).map((index) => capture(actor, peer, index)), - ...range(14).map((index) => proposed(actor, peer, index)), - ]); - history.ingest( - range(130).map((index) => directMessage(outsider, peer, index + 100)), - ); - - expect(ids(history.movements(actor))).toEqual( - range(6).map((index) => eventId(index + 1)), - ); - expect(history.actions()).toHaveLength(8); - expect(history.publicMessages()).toHaveLength(12); - expect(history.directMessages(actor)).toHaveLength(6); - expect(history.directMessages(outsider)).toHaveLength(6); - expect(history.directMessages(agent('4'))).toEqual([]); - expect(history.allianceMessages(actor)).toHaveLength(6); - expect(history.allianceMessages(peer)).toHaveLength(6); - expect(history.allianceMessages(outsider)).toEqual([]); - expect(history.zeroMessages(actor)).toHaveLength(6); - expect(history.zeroMessages(peer)).toHaveLength(6); - expect(history.zeroMessages(outsider)).toEqual([]); - expect(history.controlChanges(actor)).toHaveLength(6); - expect(history.controlChanges(peer)).toHaveLength(6); - expect(history.captures()).toHaveLength(6); - expect(history.allianceEvents(8)).toHaveLength(8); - expect(history.allianceEvents(12)).toHaveLength(12); - }); - - it('initializes existing facts in order and ingests an event ID only once', () => { - const first = directMessage(actor, peer, 1); - const second = directMessage(peer, actor, 2); - const history = new ObservationHistory([first]); - history.ingest([first, second]); - - expect(ids(history.directMessages(actor))).toEqual([first.id, second.id]); - expect(ids(history.directMessages(peer))).toEqual([first.id, second.id]); - }); -}); - -function range(length: number): number[] { - return Array.from({ length }, (_, index) => index); -} - -function agent(last: string): AgentId { - return agentIdSchema.parse(`00000000-0000-4000-8000-00000000000${last}`); -} - -function eventId(index: number): string { - return `10000000-0000-4000-8000-${String(index).padStart(12, '0')}`; -} - -function event(input: Record, index: number): WorldEvent { - return worldEventSchema.parse({ - id: eventId(index), - occurredAt: `2026-08-25T12:${String(Math.floor(index / 60)).padStart(2, '0')}:${String(index % 60).padStart(2, '0')}.000Z`, - ...input, - }); -} - -function movement(agentId: AgentId, index: number): WorldEvent { - return event( - { type: 'agent-moved', agentId, fromCell: cellA, toCell: cellB }, - index, - ); -} - -function waited(agentId: AgentId, index: number): WorldEvent { - return event({ type: 'agent-waited', agentId }, index + 20); -} - -function publicMessage(agentId: AgentId, index: number): WorldEvent { - return event( - { - type: 'public-message-sent', - channel: 'public', - agentId, - message: `public ${index}`, - playerVisible: true, - }, - index + 40, - ); -} - -function directMessage( - agentId: AgentId, - recipientId: AgentId, - index: number, -): WorldEvent { - return event( - { - type: 'direct-message-sent', - channel: 'direct', - agentId, - recipientId, - message: `direct ${index}`, - distance: 1, - playerVisible: false, - }, - index + 1000, - ); -} - -function allianceMessage( - agentId: AgentId, - recipientIds: AgentId[], - index: number, -): WorldEvent { - return event( - { - type: 'alliance-message-sent', - channel: 'alliance', - agentId, - recipientIds, - allianceId: '20000000-0000-4000-8000-000000000001', - message: `alliance ${index}`, - playerVisible: false, - }, - index + 70, - ); -} - -function zeroMessage( - agentId: AgentId, - recipientIds: AgentId[], - index: number, -): WorldEvent { - return event( - { - type: 'zero-message-sent', - channel: 'zero', - agentId, - recipientIds, - message: `zero ${index}`, - playerVisible: false, - }, - index + 80, - ); -} - -function capture( - controllerAgentId: AgentId, - previousControllerAgentId: AgentId, - index: number, -): WorldEvent { - return event( - { - type: 'hex-captured', - agentId: controllerAgentId, - controllerAgentId, - previousControllerAgentId, - cell: cellA, - }, - index + 90, - ); -} - -function proposed( - agentId: AgentId, - recipientAgentId: AgentId, - index: number, -): WorldEvent { - return event( - { - type: 'alliance-proposed', - agentId, - recipientAgentId, - proposalId: `30000000-0000-4000-8000-${String(index).padStart(12, '0')}`, - allianceId: null, - turnNumber: index + 1, - expirationTurn: index + 10, - }, - index + 110, - ); -} - -function ids(events: readonly WorldEvent[]): string[] { - return events.map(({ id }) => id); -} diff --git a/apps/game-api/src/observation-history.ts b/apps/game-api/src/observation-history.ts deleted file mode 100644 index 6b11cad..0000000 --- a/apps/game-api/src/observation-history.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { - RECENT_ALLIANCE_EVENT_LIMIT, - RECENT_CONTROL_CHANGE_LIMIT, - RECENT_DIRECT_MESSAGE_LIMIT, - RECENT_PUBLIC_MESSAGE_LIMIT, - RECENT_ZERO_MESSAGE_LIMIT, - RECENT_ZERO_STRATEGIC_EVENT_LIMIT, - type AgentId, - type AllianceEvent, - type WorldEvent, -} from '@hexzero/shared'; - -type EventOf = Extract; -type ActionEvent = EventOf< - 'agent-moved' | 'hex-infected' | 'hex-captured' | 'agent-waited' ->; - -const RECENT_MOVEMENT_LIMIT = 6; -const RECENT_ACTION_LIMIT = 8; - -/** - * Commit-owned factual history for agent observations. This is deliberately - * independent from WorldState.events, whose 120-event retention serves the - * operator display rather than the observation contract. - */ -export class ObservationHistory { - #movements = new Map[]>(); - #actions: ActionEvent[] = []; - #publicMessages: EventOf<'public-message-sent'>[] = []; - #directMessages = new Map[]>(); - #allianceMessages = new Map[]>(); - #zeroMessages = new Map[]>(); - #controlChanges = new Map[]>(); - #allianceEvents: AllianceEvent[] = []; - #captures: EventOf<'hex-captured'>[] = []; - - constructor(initialEvents: readonly WorldEvent[] = []) { - this.ingest(initialEvents); - } - - ingest(events: readonly WorldEvent[]): void { - // Engine event IDs are authoritative and unique. The batch set protects - // initialization/call-site mistakes, while #contains scans only bounded - // retained facts and therefore cannot become a lifetime event registry. - const batchEventIds = new Set(); - for (const event of events) { - if (batchEventIds.has(event.id) || this.#contains(event.id)) continue; - batchEventIds.add(event.id); - switch (event.type) { - case 'agent-moved': - appendFor( - this.#movements, - event.agentId, - event, - RECENT_MOVEMENT_LIMIT, - ); - this.#actions = append(this.#actions, event, RECENT_ACTION_LIMIT); - break; - case 'hex-infected': - case 'agent-waited': - this.#actions = append(this.#actions, event, RECENT_ACTION_LIMIT); - break; - case 'hex-captured': - this.#actions = append(this.#actions, event, RECENT_ACTION_LIMIT); - this.#captures = append( - this.#captures, - event, - RECENT_CONTROL_CHANGE_LIMIT, - ); - appendFor( - this.#controlChanges, - event.controllerAgentId, - event, - RECENT_CONTROL_CHANGE_LIMIT, - ); - if (event.previousControllerAgentId !== null) - appendFor( - this.#controlChanges, - event.previousControllerAgentId, - event, - RECENT_CONTROL_CHANGE_LIMIT, - ); - break; - case 'public-message-sent': - this.#publicMessages = append( - this.#publicMessages, - event, - RECENT_PUBLIC_MESSAGE_LIMIT, - ); - break; - case 'direct-message-sent': - for (const participant of [event.agentId, event.recipientId]) - appendFor( - this.#directMessages, - participant, - event, - RECENT_DIRECT_MESSAGE_LIMIT, - ); - break; - case 'alliance-message-sent': - for (const participant of new Set([ - event.agentId, - ...event.recipientIds, - ])) - appendFor( - this.#allianceMessages, - participant, - event, - RECENT_DIRECT_MESSAGE_LIMIT, - ); - break; - case 'zero-message-sent': - for (const participant of new Set([ - event.agentId, - ...event.recipientIds, - ])) - appendFor( - this.#zeroMessages, - participant, - event, - RECENT_ZERO_MESSAGE_LIMIT, - ); - break; - case 'alliance-proposed': - case 'alliance-proposal-closed': - case 'alliance-formed': - case 'agent-joined-alliance': - case 'agent-left-alliance': - case 'alliance-dissolved': - this.#allianceEvents = append( - this.#allianceEvents, - event, - RECENT_ZERO_STRATEGIC_EVENT_LIMIT, - ); - break; - case 'simulated-player-moved': - case 'hex-disinfected': - case 'simulated-player-clean-blocked': - case 'simulated-player-agent-captured': - break; - } - } - } - - movements(agentId: AgentId) { - return structuredClone(this.#movements.get(agentId) ?? []); - } - actions() { - return structuredClone(this.#actions); - } - publicMessages() { - return structuredClone(this.#publicMessages); - } - directMessages(agentId: AgentId) { - return structuredClone(this.#directMessages.get(agentId) ?? []); - } - allianceMessages(agentId: AgentId) { - return structuredClone(this.#allianceMessages.get(agentId) ?? []); - } - zeroMessages(agentId: AgentId) { - return structuredClone(this.#zeroMessages.get(agentId) ?? []); - } - controlChanges(agentId: AgentId) { - return structuredClone(this.#controlChanges.get(agentId) ?? []); - } - allianceEvents(limit: number = RECENT_ALLIANCE_EVENT_LIMIT) { - return structuredClone(this.#allianceEvents.slice(-limit)); - } - captures() { - return structuredClone(this.#captures); - } - - #contains(eventId: string): boolean { - return [ - ...this.#movements.values(), - this.#actions, - this.#publicMessages, - ...this.#directMessages.values(), - ...this.#allianceMessages.values(), - ...this.#zeroMessages.values(), - ...this.#controlChanges.values(), - this.#allianceEvents, - this.#captures, - ].some((events) => events.some(({ id }) => id === eventId)); - } -} - -function append(items: readonly T[], item: T, limit: number): T[] { - return [...items, structuredClone(item)].slice(-limit); -} - -function appendFor( - map: Map, - agentId: AgentId, - item: T, - limit: number, -): void { - map.set(agentId, append(map.get(agentId) ?? [], item, limit)); -} diff --git a/apps/game-api/src/reflex-execution.ts b/apps/game-api/src/reflex-execution.ts index 1400870..1ad457e 100644 --- a/apps/game-api/src/reflex-execution.ts +++ b/apps/game-api/src/reflex-execution.ts @@ -76,7 +76,7 @@ function actionDescription( if (action.type === 'infect') return 'Infect the current open cell and establish local territory.'; if (action.type === 'capture') - return 'Capture the abandoned infected current cell from a non-allied controller.'; + return 'Capture the abandoned infected current cell from another controller.'; const status = cellStatus(state, action.targetCell, directive.agentId); const terrain = status === 'open' diff --git a/apps/game-api/src/simulation-service.swarm.test.ts b/apps/game-api/src/simulation-service.swarm.test.ts index d736072..3ff3b8d 100644 --- a/apps/game-api/src/simulation-service.swarm.test.ts +++ b/apps/game-api/src/simulation-service.swarm.test.ts @@ -8,7 +8,6 @@ import { type SwarmPlanner, } from '@hexzero/agent-runtime'; import { - assignBehavior, h3CellSchema, type CompatibleModel, type SwarmPlan, @@ -401,14 +400,6 @@ describe('zero-swarm SimulationService tick', () => { overrides: [], locked: false, }, - behaviorConfiguration: { - ...request.behaviorConfiguration, - assignments: assignBehavior( - roster.map(({ id }) => id), - request.behaviorConfiguration.seed, - 'balanced-random', - ), - }, }); await simulation.executeNextTick(); @@ -498,14 +489,6 @@ describe('zero-swarm SimulationService tick', () => { overrides: [], locked: false, }, - behaviorConfiguration: { - ...request.behaviorConfiguration, - assignments: assignBehavior( - roster.map(({ id }) => id), - request.behaviorConfiguration.seed, - 'balanced-random', - ), - }, }); await expect(simulation.executeNextTick()).resolves.toBeNull(); @@ -524,7 +507,6 @@ describe('zero-swarm SimulationService tick', () => { turns: { mode: 'entire-retained' }, outcomes: ['accepted', 'rejected', 'provider-error', 'operator-skipped'], actions: ['move', 'infect', 'capture', 'wait'], - communications: { channel: 'all', status: 'all' }, level: 'full-safe', serialization: 'compact', }); @@ -569,14 +551,6 @@ describe('zero-swarm SimulationService tick', () => { overrides: [], locked: false, }, - behaviorConfiguration: { - ...request.behaviorConfiguration, - assignments: assignBehavior( - roster.map(({ id }) => id), - request.behaviorConfiguration.seed, - 'balanced-random', - ), - }, }); await simulation.executeNextTick(); @@ -623,14 +597,6 @@ describe('zero-swarm SimulationService tick', () => { overrides: [], locked: false, }, - behaviorConfiguration: { - ...request.behaviorConfiguration, - assignments: assignBehavior( - roster.map(({ id }) => id), - request.behaviorConfiguration.seed, - 'balanced-random', - ), - }, }); await simulation.executeNextTick(); @@ -640,7 +606,6 @@ describe('zero-swarm SimulationService tick', () => { turns: { mode: 'entire-retained' }, outcomes: ['accepted', 'rejected', 'provider-error', 'operator-skipped'], actions: ['move', 'infect', 'capture', 'wait'], - communications: { channel: 'all', status: 'all' }, level: 'full-safe', serialization: 'compact', }); @@ -1060,7 +1025,6 @@ describe('zero-swarm SimulationService tick', () => { turns: { mode: 'entire-retained' }, outcomes: ['accepted', 'rejected', 'provider-error', 'operator-skipped'], actions: ['move', 'infect', 'capture', 'wait'], - communications: { channel: 'all', status: 'all' }, level: 'full-safe', serialization: 'compact', }); diff --git a/apps/game-api/src/simulation-service.ts b/apps/game-api/src/simulation-service.ts index 9f36bb1..a8d7f1d 100644 --- a/apps/game-api/src/simulation-service.ts +++ b/apps/game-api/src/simulation-service.ts @@ -5,47 +5,27 @@ import { type SwarmPlanner, } from '@hexzero/agent-runtime'; import { - agentIdSchema, - archivedAppliedScenarioSchema, - assignBehavior, - behaviorConfigurationSchema, experimentIdSchema, experimentExportDocumentSchema, experimentExportPreviewSchema, experimentModelConfigurationSchema, modelSupportsReasoningProfile, - createMemoryId, updateExperimentModelsRequestSchema, - updateExperimentBehaviorRequestSchema, h3CellSchema, - RECENT_ALLIANCE_EVENT_LIMIT, - RECENT_ZERO_STRATEGIC_EVENT_LIMIT, - PERSONALITY_MAX_LENGTH, OPENROUTER_PROVIDER_TIMEOUT_MS, WORLD_SCENARIO_LIMITS, - PATIENT_ZERO_DIPLOMACY_SUMMARY_LIMITS, PATIENT_ZERO_PLAYER_THREAT_FEED_LIMIT, PATIENT_ZERO_PRESSURE_WINDOW_TICKS, - MEMORY_ENTRY_LIMIT, - personalitySchema, - providerMetadataSchema, swarmDirectiveSchema, swarmPlanSchema, swarmTickRecordSchema, simulationSnapshotSchema, type Agent, type AgentId, - type AgentGoalState, - type GoalRevisionResult, - type RequestedGoalRevision, - type MemoryEntry, - type MemoryOperationResult, - type RequestedMemoryOperation, type ExperimentExportDocument, type ExperimentExportPreview, type ExperimentId, type ExperimentModelConfiguration, - type BehaviorConfiguration, type CompatibleModel, type ModelId, type ExperimentConfigurationEvent, @@ -56,8 +36,6 @@ import { type SimulationSnapshot, type SimulationStatus, type WorldEvent, - type AllianceEvent, - type AllianceProposalId, type SimulatedPlayerEvent, type SwarmPlan, type CompletedSwarmDirective, @@ -78,10 +56,6 @@ import { createWorldFromScenario, defaultWorldSetupRequest, previewWorldSetup, - DEVELOPMENT_AGENT_BLUEPRINTS, - getAgentAlliance, - getEffectiveAgentColor, - physicalDistanceKm, seededTickIntervalMinutes, seededTickOrder, advanceSimulatedPlayer, @@ -90,10 +64,10 @@ import { type WorldState, } from '@hexzero/world-engine'; import { + calculateExperimentMetrics, createExperimentExport, createExperimentPreview, type ExperimentSource, - ExperimentMetricAccumulator, } from './experiment-export'; import { geographicDirectionBetweenCells } from './geographic-direction'; import { @@ -184,7 +158,6 @@ export function selectMostRecentPatientZeroThreats< export function calculatePatientZeroPressureContext( events: readonly WorldEvent[], subjectAgentId: AgentId, - currentAllianceMemberIds: readonly AgentId[] | null, currentTick: number, ): PatientZeroPressureContext { const startTick = Math.max( @@ -234,9 +207,6 @@ export function calculatePatientZeroPressureContext( tick -= 1 ) consecutiveAffectedTicks += 1; - const memberIds = currentAllianceMemberIds - ? new Set(currentAllianceMemberIds) - : null; return { window: { tickCount: currentTick - startTick + 1, @@ -247,14 +217,6 @@ export function calculatePatientZeroPressureContext( ...countsFor(subjectEvents), consecutiveAffectedTicks, }, - currentAlliance: memberIds - ? countsFor( - relevant.filter((event) => { - const subject = eventSubject(event); - return subject !== null && memberIds.has(subject); - }), - ) - : null, }; } @@ -294,13 +256,11 @@ export class SimulationTurnCancelledError extends Error { } export type SimulationValidationCode = - | 'invalid_agent_id' | 'unknown_agent' - | 'invalid_personality' + | 'invalid_request' | 'invalid_model_configuration' | 'models_unavailable' - | 'experiment_budget_exhausted' - | 'invalid_behavior_configuration'; + | 'experiment_budget_exhausted'; export class SimulationValidationError extends Error { constructor( @@ -354,14 +314,10 @@ export class SimulationService { #initialExperimentAgents: Agent[]; #initialExperimentWorld: SimulationSnapshot['world']; #configurationEvents: ExperimentConfigurationEvent[] = []; - #experimentMetrics: ExperimentMetricAccumulator; #modelConfiguration: ExperimentModelConfiguration; - #behaviorConfiguration: BehaviorConfiguration; #scenario: AppliedScenario; #availableModelIds = new Set(); #availableModels = new Map(); - #agentGoals = new Map(); - #agentMemories = new Map(); #simulatedPlayerEvents: SimulatedPlayerEvent[] = []; #attemptAccounting: AttemptAccounting; #swarmTicks: SwarmTickRecord[] = []; @@ -406,9 +362,6 @@ export class SimulationService { ...this.#state.agents.values(), ]); this.#initialExperimentWorld = this.#worldSnapshot(); - this.#experimentMetrics = new ExperimentMetricAccumulator([ - ...this.#state.agents.keys(), - ]); const scriptedModel = swarmPlanner.mode === 'scripted-swarm-test' ? ('deterministic-script' as ModelId) @@ -420,21 +373,9 @@ export class SimulationService { locked: false, }); if (scriptedModel) this.#availableModelIds.add(scriptedModel); - this.#behaviorConfiguration = behaviorConfigurationSchema.parse({ - registryVersion: 1, - assignmentMode: 'balanced-random', - seed: this.#experimentId, - assignments: assignBehavior( - [...this.#state.agents.keys()], - this.#experimentId, - 'balanced-random', - ), - locked: false, - }); this.#scenario = { ...createDefaultAppliedScenario(RESET_GENERATED_AT), modelConfiguration: structuredClone(this.#modelConfiguration), - behaviorConfiguration: structuredClone(this.#behaviorConfiguration), }; this.#attemptAccounting = attemptAccountingForScenario( this.#scenario.executionLimits, @@ -470,26 +411,17 @@ export class SimulationService { : {}), }, modelConfiguration: this.#modelConfiguration, - ...(agents.length > 0 - ? { behaviorConfiguration: this.#behaviorConfiguration } - : {}), resolvedModels: agents.map(({ id }) => this.#resolvedModel(id)), - agentGoals: agents.map(({ id }) => ({ - agentId: id, - goal: structuredClone(this.#agentGoals.get(id) ?? null), - })), - agentMemories: agents.map(({ id }) => ({ - agentId: id, - entries: structuredClone(this.#agentMemories.get(id) ?? []), - })), swarmTicks: structuredClone(this.#swarmTicks), experiment: { id: this.#experimentId, startedAt: this.#experimentStartedAt, attemptAccounting: this.#attemptAccounting.snapshot(), - metrics: this.#experimentMetrics.snapshot(agents.map(({ id }) => id)), + metrics: calculateExperimentMetrics( + [], + agents.map(({ id }) => id), + ), currentTerritory: this.#territoryScoreboard(), - currentAlliances: this.#allianceTerritorySummaries(), simulatedPlayerMetrics: this.#state.simulatedPlayer?.metrics ?? { movements: 0, cellsDisinfected: 0, @@ -517,8 +449,6 @@ export class SimulationService { this.#activeAgentId = null; this.#activeRequestController = null; this.#cancellationRequested = false; - this.#agentGoals = new Map(); - this.#agentMemories = new Map(); this.#swarmTicks = []; this.#experimentSwarmTicks = []; this.#lastValidSwarmPlan = null; @@ -533,9 +463,6 @@ export class SimulationService { ...this.#state.agents.values(), ]); this.#initialExperimentWorld = this.#worldSnapshot(); - this.#experimentMetrics = new ExperimentMetricAccumulator([ - ...this.#state.agents.keys(), - ]); this.#attemptAccounting = attemptAccountingForScenario( this.#scenario.executionLimits, this.#experimentRetentionLimit, @@ -544,10 +471,6 @@ export class SimulationService { ...structuredClone(this.#scenario.modelConfiguration), locked: false, }; - this.#behaviorConfiguration = { - ...structuredClone(this.#scenario.behaviorConfiguration), - locked: false, - }; this.#status = this.#swarmPlanner.configured && this.#reflexProvider.configured ? 'paused' @@ -568,9 +491,7 @@ export class SimulationService { ? 'invalid-radius' : field === 'modelConfiguration' ? 'model-agent-mismatch' - : field === 'behaviorConfiguration' - ? 'behavior-coverage-mismatch' - : 'invalid-roster'; + : 'invalid-roster'; return { feasible: false, errors: [ @@ -626,7 +547,7 @@ export class SimulationService { const parsed = worldSetupRequestSchema.safeParse(input); if (!parsed.success) throw new SimulationValidationError( - 'invalid_behavior_configuration', + 'invalid_request', 'The scenario request is invalid.', ); const checked = this.previewWorldSetup(parsed.data); @@ -638,7 +559,7 @@ export class SimulationService { const preview = previewWorldSetup(parsed.data, RESET_GENERATED_AT); if (!preview.feasible) throw new SimulationValidationError( - 'invalid_behavior_configuration', + 'invalid_request', preview.errors[0]?.message ?? 'The scenario is infeasible.', ); const nextState = toWorldState(preview.world); @@ -646,18 +567,12 @@ export class SimulationService { ...preview.scenario.modelConfiguration, locked: false, }); - const nextBehavior = behaviorConfigurationSchema.parse({ - ...preview.scenario.behaviorConfiguration, - locked: false, - }); this.#state = nextState; this.#scenario = { ...preview.scenario, modelConfiguration: nextModels, - behaviorConfiguration: nextBehavior, }; this.#modelConfiguration = nextModels; - this.#behaviorConfiguration = nextBehavior; this.#completedSwarmDecisionCount = 0; this.#completedTickCount = 0; this.#virtualTime = RESET_GENERATED_AT; @@ -666,8 +581,6 @@ export class SimulationService { this.#activeAgentId = null; this.#activeRequestController = null; this.#cancellationRequested = false; - this.#agentGoals = new Map(); - this.#agentMemories = new Map(); this.#swarmTicks = []; this.#experimentSwarmTicks = []; this.#lastValidSwarmPlan = null; @@ -682,9 +595,6 @@ export class SimulationService { ...this.#state.agents.values(), ]); this.#initialExperimentWorld = this.#worldSnapshot(); - this.#experimentMetrics = new ExperimentMetricAccumulator([ - ...this.#state.agents.keys(), - ]); this.#attemptAccounting = attemptAccountingForScenario( this.#scenario.executionLimits, this.#experimentRetentionLimit, @@ -763,286 +673,6 @@ export class SimulationService { return this.getSnapshot(); } - updateBehaviorConfiguration(input: unknown): SimulationSnapshot { - if (this.#busy || this.#verificationBusy || this.#completedTickCount > 0) - throw new SimulationConflictError( - 'Behavior is locked after the experiment begins. Reset to create new assignments.', - ); - const parsed = updateExperimentBehaviorRequestSchema.safeParse(input); - if (!parsed.success) - throw new SimulationValidationError( - 'invalid_behavior_configuration', - 'The behavior configuration is invalid.', - ); - const agentIds = [...this.#state.agents.keys()]; - const assignments = - parsed.data.assignmentMode === 'manual' - ? parsed.data.assignments.map((assignment) => ({ - ...assignment, - manual: true, - })) - : assignBehavior( - agentIds, - parsed.data.seed, - parsed.data.assignmentMode, - ); - if ( - assignments.length !== agentIds.length || - assignments.some(({ agentId }) => !this.#state.agents.has(agentId)) - ) - throw new SimulationValidationError( - 'invalid_behavior_configuration', - 'Behavior assignments must cover the current roster exactly.', - ); - this.#behaviorConfiguration = behaviorConfigurationSchema.parse({ - registryVersion: 1, - ...parsed.data, - assignments, - locked: false, - }); - this.#scenario = { - ...this.#scenario, - behaviorConfiguration: structuredClone(this.#behaviorConfiguration), - }; - return this.getSnapshot(); - } - - importModelConfiguration(document: unknown): { - snapshot: SimulationSnapshot; - legacy: boolean; - message: string; - } { - if (this.#busy || this.#verificationBusy) - throw new SimulationConflictError( - 'Import is unavailable while a model request is active.', - ); - if ( - typeof document !== 'object' || - document === null || - Array.isArray(document) - ) - throw new SimulationValidationError( - 'invalid_model_configuration', - 'The experiment import is invalid.', - ); - const root = document as Record; - const version = root.schemaVersion; - if ( - version !== 5 && - version !== 6 && - version !== 7 && - version !== 8 && - version !== 9 && - version !== 10 && - version !== 11 - ) - throw new SimulationValidationError( - 'invalid_model_configuration', - 'Only schema-version 5 through 11 experiment exports can be imported.', - ); - if (version === 5) { - const legacyConfiguration: ExperimentModelConfiguration = { - globalModelId: null, - globalReasoningProfile: 'provider-default', - overrides: [], - locked: false, - }; - this.#recordModelConfigurationChanges( - this.#modelConfiguration, - legacyConfiguration, - ); - this.#modelConfiguration = legacyConfiguration; - return { - snapshot: this.getSnapshot(), - legacy: true, - message: - 'Legacy experiment preserved. Select compatible models before continuing.', - }; - } - const experiment = - typeof root.experiment === 'object' && root.experiment !== null - ? (root.experiment as Record) - : undefined; - const configuration = experimentModelConfigurationSchema.safeParse( - experiment?.modelConfiguration, - ); - if (!configuration.success) - throw new SimulationValidationError( - 'invalid_model_configuration', - 'The imported model assignment is invalid.', - ); - const importedPatientZero = - (version === 9 || version === 10) && - typeof experiment?.scenario === 'object' && - experiment.scenario !== null - ? (archivedAppliedScenarioSchema.safeParse(experiment.scenario).data - ?.patientZeroAgentId ?? null) - : null; - const knownAgents = new Set(this.#state.agents.keys()); - if (importedPatientZero && !knownAgents.has(importedPatientZero)) - throw new SimulationValidationError( - 'unknown_agent', - 'The imported Patient Zero designation references an unknown agent.', - ); - if ( - configuration.data.overrides.some( - ({ agentId }) => !knownAgents.has(agentId), - ) - ) - throw new SimulationValidationError( - 'unknown_agent', - 'The imported model assignment references an unknown agent.', - ); - const importedConfiguration: ExperimentModelConfiguration = { - globalModelId: configuration.data.globalModelId, - globalReasoningProfile: configuration.data.globalReasoningProfile, - overrides: structuredClone(configuration.data.overrides), - locked: false, - }; - if ( - (version === 8 || version === 9 || version === 10) && - experiment?.behaviorConfiguration !== undefined - ) { - const importedBehavior = behaviorConfigurationSchema.safeParse( - experiment.behaviorConfiguration, - ); - const knownBehaviorAgents = new Set(this.#state.agents.keys()); - if ( - !importedBehavior.success || - importedBehavior.data.assignments.some( - ({ agentId }) => !knownBehaviorAgents.has(agentId), - ) - ) - throw new SimulationValidationError( - 'invalid_behavior_configuration', - 'The imported behavior assignment contains an unknown or unsupported profile.', - ); - this.#behaviorConfiguration = { - ...structuredClone(importedBehavior.data), - locked: this.#completedTickCount > 0, - }; - } - this.#recordModelConfigurationChanges( - this.#modelConfiguration, - importedConfiguration, - ); - this.#modelConfiguration = importedConfiguration; - this.#scenario = { - ...this.#scenario, - patientZeroAgentId: - importedPatientZero ?? this.#scenario.patientZeroAgentId, - }; - return { - snapshot: this.getSnapshot(), - legacy: false, - message: this.getSnapshot().resolvedModels.every( - ({ available }) => available, - ) - ? 'Model assignments imported.' - : 'Model assignments imported; unavailable models or reasoning profiles require explicit replacement.', - }; - } - - updateAgentPersonality( - agentIdInput: unknown, - personalityInput: unknown, - ): Agent { - if (this.#busy || this.#verificationBusy) { - throw new SimulationConflictError( - 'Personality changes are unavailable while model execution is in progress.', - ); - } - const agentIdResult = agentIdSchema.safeParse(agentIdInput); - if (!agentIdResult.success) { - throw new SimulationValidationError( - 'invalid_agent_id', - 'The agent ID is invalid.', - ); - } - const personalityResult = personalitySchema.safeParse(personalityInput); - if (!personalityResult.success) { - throw new SimulationValidationError( - 'invalid_personality', - `Personality must contain 1 to ${PERSONALITY_MAX_LENGTH} characters.`, - ); - } - const agent = this.#state.agents.get(agentIdResult.data); - if (!agent) { - throw new SimulationValidationError( - 'unknown_agent', - 'The requested agent does not exist.', - ); - } - const updated = { ...agent, personality: personalityResult.data }; - const agents = new Map(this.#state.agents); - agents.set(agent.id, updated); - this.#state = { ...this.#state, agents }; - this.#scenario = { - ...this.#scenario, - roster: this.#scenario.roster.map((entry) => - entry.id === updated.id - ? { ...entry, personality: updated.personality } - : entry, - ), - }; - if (agent.personality !== updated.personality) { - this.#configurationEvents = [ - ...this.#configurationEvents, - { - timestamp: this.#now(), - agentId: agent.id, - previousPersonality: agent.personality, - newPersonality: updated.personality, - operation: 'custom-edit', - }, - ]; - } - return updated; - } - - restoreDefaultPersonalities(): SimulationSnapshot { - if (this.#busy || this.#verificationBusy) { - throw new SimulationConflictError( - 'Personality changes are unavailable while model execution is in progress.', - ); - } - const defaults = new Map( - DEVELOPMENT_AGENT_BLUEPRINTS.map(({ id, personality }) => [ - agentIdSchema.parse(id), - personality, - ]), - ); - const configurationEvents: ExperimentConfigurationEvent[] = []; - this.#state = { - ...this.#state, - agents: new Map( - [...this.#state.agents].map(([id, agent]) => { - const personality = defaults.get(id) ?? agent.personality; - if (personality !== agent.personality) - configurationEvents.push({ - timestamp: this.#now(), - agentId: id, - previousPersonality: agent.personality, - newPersonality: personality, - operation: 'restore-default', - }); - return [id, { ...agent, personality }]; - }), - ), - }; - this.#configurationEvents = [ - ...this.#configurationEvents, - ...configurationEvents, - ]; - this.#scenario = { - ...this.#scenario, - roster: [...this.#state.agents.values()].map( - ({ currentCell: _currentCell, ...agent }) => agent, - ), - }; - return this.getSnapshot(); - } - previewExperimentExport(request: unknown): ExperimentExportPreview { if (this.#busy || this.#verificationBusy) throw new SimulationConflictError( @@ -1489,10 +1119,6 @@ export class SimulationService { currentCell, ]), ); - this.#behaviorConfiguration = { - ...this.#behaviorConfiguration, - locked: true, - }; this.#status = this.#attemptAccounting.snapshot().exhausted ? 'budget-exhausted' : 'paused'; @@ -1568,19 +1194,6 @@ export class SimulationService { /** Remove cognition state that belongs to agents captured by the engine. */ #pruneCapturedRosterState(): void { const active = new Set(this.#state.agents.keys()); - this.#agentGoals = new Map( - [...this.#agentGoals].filter(([agentId]) => active.has(agentId)), - ); - this.#agentMemories = new Map( - [...this.#agentMemories].filter(([agentId]) => active.has(agentId)), - ); - const assignments = this.#behaviorConfiguration.assignments.filter( - ({ agentId }) => active.has(agentId), - ); - this.#behaviorConfiguration = { - ...this.#behaviorConfiguration, - assignments, - }; this.#modelConfiguration = { ...this.#modelConfiguration, overrides: this.#modelConfiguration.overrides.filter(({ agentId }) => @@ -1650,10 +1263,6 @@ export class SimulationService { hexes: [...this.#state.hexes].map(([cell, hex]) => ({ cell, ...hex })), agents: structuredClone([...this.#state.agents.values()]), events: structuredClone([...this.#state.events]), - alliances: structuredClone([...(this.#state.alliances?.values() ?? [])]), - pendingAllianceProposals: structuredClone([ - ...(this.#state.pendingAllianceProposals?.values() ?? []), - ]), simulatedPlayer: structuredClone(this.#state.simulatedPlayer ?? null), }; } @@ -2037,31 +1646,18 @@ export class SimulationService { ? 'openrouter' : 'scripted-test', retentionLimit: this.#experimentRetentionLimit, - totalCompletedTurns: 0, - turns: [], + totalCompletedTicks: this.#completedTickCount, initialAgents: this.#initialExperimentAgents, currentAgents: [...this.#state.agents.values()], configurationEvents: this.#configurationEvents, initialWorld: this.#initialExperimentWorld, currentWorld: this.#worldSnapshot(), modelConfiguration: this.#modelConfiguration, - behaviorConfiguration: - this.#state.agents.size > 0 - ? this.#behaviorConfiguration - : this.#scenario.behaviorConfiguration, scenario: this.#scenario, schemaVersion: 11, providerAttempts: this.#attemptAccounting.ledger(), attemptRetention: this.#attemptAccounting.retention(), attemptAccounting: this.#attemptAccounting.snapshot(), - agentGoals: [...this.#state.agents.keys()].map((agentId) => ({ - agentId, - goal: structuredClone(this.#agentGoals.get(agentId) ?? null), - })), - agentMemories: [...this.#state.agents.keys()].map((agentId) => ({ - agentId, - entries: structuredClone(this.#agentMemories.get(agentId) ?? []), - })), simulatedPlayerEvents: structuredClone(this.#simulatedPlayerEvents), swarmTicks: structuredClone(this.#experimentSwarmTicks), }; @@ -2118,91 +1714,9 @@ export class SimulationService { agentId: id, name, color, - allianceId: getAgentAlliance(this.#state, id)?.id ?? null, - effectiveColor: getEffectiveAgentColor(this.#state, id), controlledCellCount: counts.get(id) ?? 0, })); } - - #allianceTerritorySummaries() { - const scoreboard = this.#territoryScoreboard(); - return [...(this.#state.alliances?.values() ?? [])].map((alliance) => { - const members = alliance.memberAgentIds.map((agentId) => { - const entry = scoreboard.find( - (candidate) => candidate.agentId === agentId, - ); - if (!entry) throw new Error('An alliance member does not exist.'); - return { - agentId, - name: entry.name, - controlledCellCount: entry.controlledCellCount, - }; - }); - return { - allianceId: alliance.id, - color: alliance.color, - totalControlledCellCount: members.reduce( - (sum, member) => sum + member.controlledCellCount, - 0, - ), - members, - }; - }); - } -} - -export function selectDiplomacyBlockerExamples< - T extends { agentId: AgentId; reason: string }, ->( - state: WorldState, - actingAgentId: AgentId, - blockers: readonly T[], - reasonPriority: readonly T['reason'][], -): T[] { - const actingAlliance = getAgentAlliance(state, actingAgentId); - const relationshipPriority = (blockedAgentId: AgentId) => { - const blockedAlliance = getAgentAlliance(state, blockedAgentId); - if (!actingAlliance) return blockedAlliance ? 1 : 0; - if (!blockedAlliance) return 0; - return blockedAlliance.id === actingAlliance.id ? 2 : 1; - }; - return blockers - .toSorted( - (left, right) => - relationshipPriority(left.agentId) - - relationshipPriority(right.agentId) || - reasonPriority.indexOf(left.reason) - - reasonPriority.indexOf(right.reason) || - left.agentId.localeCompare(right.agentId), - ) - .slice(0, 4); -} - -function isAllianceEvent(event: WorldEvent): event is AllianceEvent { - return ( - event.type === 'alliance-proposed' || - event.type === 'alliance-proposal-closed' || - event.type === 'alliance-formed' || - event.type === 'alliance-dissolved' || - event.type === 'agent-joined-alliance' || - event.type === 'agent-left-alliance' - ); -} - -function allianceEventsSince( - before: WorldState, - after: WorldState, -): AllianceEvent[] { - return after.events.slice(before.events.length).filter(isAllianceEvent); -} - -function stableOrder(input: string): number { - let hash = 2166136261; - for (let index = 0; index < input.length; index += 1) { - hash ^= input.charCodeAt(index); - hash = Math.imul(hash, 16777619); - } - return hash >>> 0; } function gridRingDistance(from: H3Cell, to: H3Cell): number { @@ -2212,236 +1726,3 @@ function gridRingDistance(from: H3Cell, to: H3Cell): number { return 999; } } - -function summarizeEvent( - event: Extract< - WorldEvent, - { - type: 'agent-moved' | 'hex-infected' | 'hex-captured' | 'agent-waited'; - } - >, - state: WorldState, -): string { - const name = state.agents.get(event.agentId)?.name ?? 'An agent'; - if (event.type === 'agent-moved') return `${name} moved to ${event.toCell}.`; - if (event.type === 'hex-infected') return `${name} infected ${event.cell}.`; - if (event.type === 'hex-captured') { - const previous = - (event.previousControllerAgentId === null - ? undefined - : state.agents.get(event.previousControllerAgentId)?.name) ?? - 'another agent'; - return `${name} captured ${event.cell} from ${previous}.`; - } - return `${name} waited.`; -} - -function summarizeAllianceEvent( - event: AllianceEvent, - state: WorldState, -): string { - const name = (id: AgentId) => state.agents.get(id)?.name ?? 'An agent'; - if (event.type === 'alliance-proposed') - return `${name(event.agentId)} proposed an alliance with ${name(event.recipientAgentId)}.`; - if (event.type === 'alliance-formed') - return `${event.memberAgentIds.map(name).join(' and ')} formed an alliance.`; - if (event.type === 'agent-joined-alliance') - return `${name(event.joinedAgentId)} joined the alliance.`; - if (event.type === 'agent-left-alliance') - return `${name(event.leftAgentId)} left the alliance.`; - if (event.type === 'alliance-dissolved') return 'The alliance dissolved.'; - return `The proposal from ${name(event.proposerAgentId)} to ${name(event.recipientAgentId)} was ${event.reason}.`; -} - -export function applyGoalRevision( - current: AgentGoalState | undefined, - requested: RequestedGoalRevision | undefined, - tick: number, -): { goal: AgentGoalState | undefined; result: GoalRevisionResult } { - if (!requested) return { goal: current, result: { requested: false } }; - if (requested.operation === 'establish') { - if (current) - return { - goal: current, - result: { - requested: true, - accepted: false, - operation: requested.operation, - reason: 'goal-already-active', - }, - }; - return { - goal: { - longTermGoal: requested.longTermGoal, - shortTermGoal: requested.shortTermGoal, - planSummary: requested.planSummary, - establishedAtTick: tick, - revisedAtTick: tick, - }, - result: { - requested: true, - accepted: true, - operation: requested.operation, - }, - }; - } - if (!current) - return { - goal: undefined, - result: { - requested: true, - accepted: false, - operation: requested.operation, - reason: 'goal-not-active', - }, - }; - if (requested.operation === 'keep') - return { - goal: current, - result: { - requested: true, - accepted: true, - operation: requested.operation, - }, - }; - if (requested.operation === 'revise') - return { - goal: { - longTermGoal: requested.longTermGoal, - shortTermGoal: requested.shortTermGoal, - planSummary: requested.planSummary, - establishedAtTick: current.establishedAtTick, - revisedAtTick: tick, - }, - result: { - requested: true, - accepted: true, - operation: requested.operation, - }, - }; - return { - goal: undefined, - result: { requested: true, accepted: true, operation: requested.operation }, - }; -} - -export function applyMemoryOperation( - current: readonly MemoryEntry[], - requested: RequestedMemoryOperation | undefined, - agentId: AgentId, - tick: number, -): { entries: MemoryEntry[]; result: MemoryOperationResult } { - const entries = current.map((entry) => structuredClone(entry)); - if (!requested) return { entries, result: { requested: false } }; - if (requested.operation === 'keep') - return { - entries, - result: { requested: true, accepted: true, operation: 'keep' }, - }; - if (requested.operation === 'remember') { - if (entries.length >= MEMORY_ENTRY_LIMIT) - return { - entries, - result: { - requested: true, - accepted: false, - operation: 'remember', - reason: 'memory-full', - }, - }; - const id = createMemoryId(agentId, tick); - return { - entries: [ - ...entries, - { - id, - text: requested.text, - createdAtTick: tick, - revisedAtTick: tick, - }, - ], - result: { - requested: true, - accepted: true, - operation: 'remember', - memoryId: id, - }, - }; - } - const index = entries.findIndex(({ id }) => id === requested.memoryId); - if (index < 0) - return { - entries, - result: { - requested: true, - accepted: false, - operation: requested.operation, - reason: 'memory-not-found', - }, - }; - if (requested.operation === 'forget') { - entries.splice(index, 1); - return { - entries, - result: { - requested: true, - accepted: true, - operation: 'forget', - memoryId: requested.memoryId, - }, - }; - } - entries[index] = { - ...entries[index]!, - text: requested.text, - revisedAtTick: tick, - }; - return { - entries, - result: { - requested: true, - accepted: true, - operation: 'revise', - memoryId: requested.memoryId, - }, - }; -} - -function safeRecoveryProviderMetadata( - value: unknown, - provider: ProviderMetadata['provider'], - selectedModel: ModelId, -): ProviderMetadata { - const raw = value && typeof value === 'object' ? value : {}; - let safe = providerMetadataSchema.parse({ - provider, - model: selectedModel, - latencyMs: 0, - }); - const fields = [ - 'model', - 'selectedModel', - 'resolvedModel', - 'requestId', - 'httpStatus', - 'finishReason', - 'nativeFinishReason', - 'latencyMs', - 'promptTokens', - 'completionTokens', - 'totalTokens', - 'reasoningTokens', - 'cachedReadTokens', - 'cacheWriteTokens', - 'costCredits', - ] as const; - for (const field of fields) { - if (!(field in raw)) continue; - const parsed = providerMetadataSchema.safeParse({ - ...safe, - [field]: (raw as Record)[field], - }); - if (parsed.success) safe = parsed.data; - } - return safe; -} diff --git a/apps/game-api/src/swarm-comparison.ts b/apps/game-api/src/swarm-comparison.ts index e9d84f7..1fc5f26 100644 --- a/apps/game-api/src/swarm-comparison.ts +++ b/apps/game-api/src/swarm-comparison.ts @@ -6,7 +6,6 @@ import { type SwarmPlanner, } from '@hexzero/agent-runtime'; import { - assignBehavior, reflexDecisionSchema, type CompatibleModel, type ProviderMetadata, @@ -432,16 +431,6 @@ function createService(variant: OfflineComparisonVariant, seed: string) { overrides: [], locked: false, }, - // This transitional scenario field remains required until PR 2 removes - // legacy behavior configuration from the shared world setup schema. - behaviorConfiguration: { - ...request.behaviorConfiguration, - assignments: assignBehavior( - roster.map(({ id }) => id), - `offline-behavior-${seed}`, - 'balanced-random', - ), - }, }); return service; } diff --git a/apps/world-lab/src/app/styles.css b/apps/world-lab/src/app/styles.css index e6a792f..f4b4f72 100644 --- a/apps/world-lab/src/app/styles.css +++ b/apps/world-lab/src/app/styles.css @@ -1403,89 +1403,11 @@ dd { border-color: #79bda6; color: #d7e7e1; } -.behavior-trace-panel, -.agent-inspector [id$='-goals'], -.agent-inspector [id$='-memories'], .agent-inspector [id$='-history'], .agent-inspector [id$='-configuration'], .agent-inspector [id$='-latest'] { scroll-margin-top: 46px; } -.behavior-trace-panel { - margin-top: 14px; - padding-top: 2px; -} -.behavior-trace-heading { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 10px; -} -.behavior-trace-heading h3 { - margin-top: 0; -} -.behavior-trace-heading p { - margin: 2px 0 0; - color: #82938e; - font-size: 0.69rem; - line-height: 1.4; -} -.behavior-trace-heading > span { - flex: 0 0 auto; - color: #7f928b; - font-size: 0.66rem; -} -.behavior-trace { - display: grid; - gap: 6px; - margin: 8px 0 0; - padding: 0; - list-style: none; -} -.behavior-trace > li { - min-width: 0; - border: 1px solid #34433f; - border-radius: 5px; - background: #121917; -} -.behavior-trace summary { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 7px 8px; - color: #c9d6d1; - cursor: pointer; - font-size: 0.71rem; - font-weight: 650; -} -.behavior-trace-body { - display: grid; - gap: 7px; - padding: 0 8px 8px; - border-top: 1px solid #2b3834; -} -.behavior-trace-block, -.behavior-trace-decision { - min-width: 0; - padding-top: 7px; - color: #aebdb8; - font-size: 0.71rem; - line-height: 1.45; -} -.behavior-trace-block > strong, -.behavior-trace-decision strong { - color: #d5e0dc; -} -.behavior-trace-block ul { - margin: 4px 0 0; - padding-left: 17px; -} -.behavior-trace-block p, -.behavior-trace-decision p { - margin: 4px 0 0; - overflow-wrap: anywhere; -} .trace-cell-button { padding: 1px 4px; border: 1px solid #45645a; @@ -1500,17 +1422,6 @@ dd { border-color: #80c9b0; color: #d9f2e9; } -.personality-heading { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-top: 17px; -} -.personality-heading h3 { - margin: 0; -} -.personality-heading button, .editor-actions button { padding: 5px 9px; border: 1px solid #465752; @@ -1519,52 +1430,10 @@ dd { background: #202927; cursor: pointer; } -.personality-heading button:disabled, .editor-actions button:disabled { color: #71807b; cursor: not-allowed; } -.active-personality { - margin-top: 8px; -} -.active-personality p { - color: #d1dcd8; - font-size: 0.8rem; - line-height: 1.55; -} -.active-personality span { - display: inline-block; - margin-top: 7px; - padding: 2px 7px; - border: 1px solid #3c4b47; - border-radius: 999px; - color: #91a59f; - font-size: 0.68rem; -} -.personality-editor { - display: grid; - gap: 10px; - margin-top: 10px; -} -.personality-editor label { - display: grid; - gap: 5px; - color: #aab9b4; - font-size: 0.74rem; -} -.personality-editor select, -.personality-editor textarea { - width: 100%; - padding: 8px; - border: 1px solid #43524e; - border-radius: 5px; - background: #111816; - color: #e0e9e6; -} -.personality-editor textarea { - resize: vertical; - line-height: 1.45; -} .editor-meta { display: flex; justify-content: space-between; @@ -1638,24 +1507,6 @@ dd { margin: 0; padding-left: 18px; } -.communication-history, -.observation-communications { - margin: 0; - padding-left: 18px; - color: #bcc9c5; - font-size: 0.77rem; - line-height: 1.5; -} -.communication-history li { - margin-bottom: 9px; - overflow-wrap: anywhere; -} -.communication-history p { - margin-top: 3px; - color: #d8e2df; - white-space: pre-wrap; -} -.communication-history small, .world-chat-feed small { color: #82938e; } @@ -1694,10 +1545,6 @@ dd { border-left: 2px solid #4e766a; padding-left: 8px; } -.observation-communications li { - margin-top: 4px; - overflow-wrap: anywhere; -} .event-panel ol { list-style: none; padding: 0; @@ -2247,10 +2094,6 @@ dt, border-color: color-mix(in srgb, var(--vanilla-custard) 80%, white); box-shadow: 0 0 0 1px #0009; } -.communication-history li { - border-left: 3px solid transparent; - padding-left: 8px; -} .stale-export { border: 1px solid color-mix(in srgb, var(--warning) 60%, var(--line)); background: color-mix(in srgb, var(--warning) 12%, var(--surface)); diff --git a/apps/world-lab/src/components/behavior-trace.test.ts b/apps/world-lab/src/components/behavior-trace.test.ts deleted file mode 100644 index 2c487d1..0000000 --- a/apps/world-lab/src/components/behavior-trace.test.ts +++ /dev/null @@ -1,377 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - agentIdSchema, - agentTurnRecordSchema, - type AgentTurnRecord, -} from '@hexzero/shared'; -import { BEHAVIOR_TRACE_LIMIT, deriveBehaviorTrace } from './behavior-trace'; - -const agentId = agentIdSchema.parse('128f3f38-6b7d-4db7-9e95-751b4ce2681e'); -const otherAgentId = agentIdSchema.parse( - '2507bb46-7ae4-45ca-8dda-644c4f85ca14', -); -const currentCell = '892b6b5a2c7ffff'; -const adjacentCell = '892b6b5a2d3ffff'; - -function acceptedTurn( - turnNumber: number, - options: { - move?: boolean; - inboundMessage?: boolean; - territoryChange?: boolean; - continuity?: boolean; - playerThreats?: boolean; - } = {}, -): AgentTurnRecord { - const move = options.move ?? false; - const occurredAt = `2026-08-23T12:00:${String(turnNumber).padStart(2, '0')}.000Z`; - return agentTurnRecordSchema.parse({ - turnNumber, - agentId, - startedAt: occurredAt, - completedAt: occurredAt, - observation: { - agentId, - agentName: 'Ember', - personality: 'A deliberate test agent.', - currentCell: { - cell: currentCell, - state: 'open', - controllerAgentId: null, - controllerAllianceId: null, - effectiveColor: null, - }, - captureEligibility: { - eligible: false, - blockedReason: 'capture-open-cell', - }, - actionAvailability: { - moveTargetCellIds: [adjacentCell], - moveOptions: [ - { - targetCell: adjacentCell, - direction: 'NE', - destinationState: 'open', - controllerRelationship: 'open', - recentlyOccupied: false, - nearbyAgentCount: 1, - }, - ], - infect: { available: true }, - capture: { available: false, reason: 'capture-open-cell' }, - wait: { available: true }, - }, - adjacentCells: [ - { - cell: adjacentCell, - state: 'open', - controllerAgentId: null, - controllerAllianceId: null, - effectiveColor: null, - }, - ], - nearbyAgents: [], - recentEvents: [], - recentPublicMessages: [], - recentDirectMessages: options.inboundMessage - ? [ - { - eventId: '67aa21b9-fc78-4b04-9f92-9862bf346f96', - senderId: otherAgentId, - senderName: 'Rook', - recipientId: agentId, - recipientName: 'Ember', - direction: 'inbound', - message: 'Hold the eastern route.', - occurredAt, - distance: 2, - }, - ] - : [], - territoryScoreboard: [ - { - agentId, - name: 'Ember', - color: '#d55e00', - allianceId: null, - effectiveColor: '#d55e00', - controlledCellCount: 0, - }, - ], - actingAllianceId: null, - actingAlliance: null, - activeAlliances: [], - inboundAllianceProposals: [], - outboundAllianceProposals: [], - recentAllianceEvents: [], - recentControlChanges: options.territoryChange - ? [ - { - eventId: '87aa21b9-fc78-4b04-9f92-9862bf346f96', - direction: 'lost', - otherAgentId, - otherAgentName: 'Rook', - cell: adjacentCell, - occurredAt, - }, - ] - : [], - ...(options.playerThreats - ? { - patientZero: { - agentId, - agentName: 'Ember', - isPatientZero: true, - directRangeBypass: true, - }, - patientZeroGlobalView: { - agents: [], - individualTerritory: [ - { - agentId, - name: 'Ember', - color: '#d55e00', - allianceId: null, - effectiveColor: '#d55e00', - controlledCellCount: 0, - }, - ], - allianceTerritory: [], - alliances: [], - activeAllianceProposals: [], - recentStrategicEvents: [], - recentTerritoryChanges: [], - playerThreatFeed: { - events: [ - { - eventId: '97aa21b9-fc78-4b04-9f92-9862bf346f96', - kind: 'territory-disinfected', - cell: adjacentCell, - occurredAt, - affectedAgentId: agentId, - affectedAgentName: 'Ember', - affectedAllianceId: null, - affectedAllianceColor: null, - pressureContext: { - window: { tickCount: 6, startTick: 3, endTick: 8 }, - subject: { - totalEvents: 3, - disinfections: 2, - blockedCleans: 1, - consecutiveAffectedTicks: 2, - }, - currentAlliance: null, - }, - }, - { - eventId: 'a7aa21b9-fc78-4b04-9f92-9862bf346f96', - kind: 'occupied-clean-blocked', - cell: currentCell, - occurredAt, - blockingAgentId: otherAgentId, - blockingAgentName: 'Rook', - blockingAllianceId: null, - blockingAllianceColor: null, - pressureContext: { - window: { tickCount: 6, startTick: 3, endTick: 8 }, - subject: { - totalEvents: 2, - disinfections: 0, - blockedCleans: 2, - consecutiveAffectedTicks: 2, - }, - currentAlliance: null, - }, - }, - ], - totalEventCount: 3, - truncated: true, - }, - }, - playerPressure: { - enabled: true, - recentThreats: [ - { - eventId: '97aa21b9-fc78-4b04-9f92-9862bf346f96', - kind: 'territory-disinfected', - cell: adjacentCell, - occurredAt, - distanceCells: 0, - affectedOwnTerritory: true, - }, - ], - }, - } - : {}), - }, - outcome: 'accepted', - worldAction: move - ? { type: 'move', targetCell: adjacentCell } - : { type: 'wait' }, - summary: move ? 'Respond to the eastern-route warning.' : 'Hold position.', - worldActionResult: { - accepted: true, - event: move - ? { - id: `77bb21b9-fc78-4b04-9f92-9862bf346f9${turnNumber}`, - type: 'agent-moved', - agentId, - fromCell: currentCell, - toCell: adjacentCell, - occurredAt, - } - : { - id: `77bb21b9-fc78-4b04-9f92-9862bf346f9${turnNumber}`, - type: 'agent-waited', - agentId, - occurredAt, - }, - }, - communicationResult: { requested: false }, - diplomacyResult: { requested: false }, - ...(options.continuity - ? { - goalRevision: { - operation: 'establish', - longTermGoal: 'Hold the eastern corridor.', - shortTermGoal: 'Wait for a safe route.', - planSummary: 'Observe before moving.', - reason: 'Set a retained objective.', - }, - goalRevisionResult: { - requested: true, - accepted: true, - operation: 'establish', - }, - memoryOperation: { - operation: 'remember', - text: 'Rook contested the eastern route.', - }, - memoryOperationResult: { - requested: true, - accepted: false, - operation: 'remember', - reason: 'memory-full', - }, - } - : {}), - provider: { - provider: 'scripted-test', - model: 'test', - latencyMs: 0, - }, - }); -} - -describe('deriveBehaviorTrace', () => { - it('places new evidence beside legal choices, chosen direction, and action change', () => { - const trace = deriveBehaviorTrace( - [ - acceptedTurn(1), - acceptedTurn(2, { - move: true, - inboundMessage: true, - territoryChange: true, - }), - ], - agentId, - ); - - expect(trace).toHaveLength(2); - expect(trace[0]).toMatchObject({ - hasPreviousObservation: true, - legalActions: ['Move NE', 'Infect', 'Wait'], - chosenAction: `Move NE → ${adjacentCell}`, - chosenCell: adjacentCell, - actionPattern: 'Changed wait → move NE.', - evidence: [ - { - kind: 'direct', - label: 'Inbound from Rook: Hold the eastern route.', - }, - { - kind: 'territory', - label: `Lost ${adjacentCell} to Rook`, - cell: adjacentCell, - }, - ], - }); - expect(trace[0]!.observedChanges).toContain( - '2 new retained evidence items entered the retained observation.', - ); - expect(trace[1]!.observedChanges).toEqual([ - 'First retained observation for this agent.', - ]); - }); - - it('stays bounded and newest-first', () => { - const turns = Array.from({ length: 8 }, (_, index) => - acceptedTurn(index + 1), - ); - const trace = deriveBehaviorTrace(turns, agentId); - expect(trace).toHaveLength(BEHAVIOR_TRACE_LIMIT); - expect(trace.map(({ turn }) => turn.turnNumber)).toEqual([ - 8, 7, 6, 5, 4, 3, - ]); - expect(deriveBehaviorTrace(turns, agentId, 99)).toHaveLength( - BEHAVIOR_TRACE_LIMIT, - ); - }); - - it('reports repeated actions and independent goal and memory continuity', () => { - const trace = deriveBehaviorTrace( - [acceptedTurn(1), acceptedTurn(2, { continuity: true })], - agentId, - ); - expect(trace[0]).toMatchObject({ - actionPattern: 'Repeated wait.', - continuity: [ - 'Goal establish: accepted', - 'Memory remember: rejected (memory-full)', - ], - }); - }); - - it('shows local and Patient Zero cleaner evidence without duplicating one event', () => { - const trace = deriveBehaviorTrace( - [acceptedTurn(1), acceptedTurn(2, { playerThreats: true })], - agentId, - ); - const evidence = trace[0]!.evidence; - expect(evidence.slice(0, 2)).toEqual([ - expect.objectContaining({ - kind: 'patient-zero-threat', - label: 'Patient Zero global cleaner feed: 2/3 displayed · truncated', - }), - expect.objectContaining({ - kind: 'player-threat', - label: expect.stringContaining('own territory disinfected'), - }), - ]); - expect(evidence).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - kind: 'player-threat', - label: expect.stringContaining('own territory disinfected'), - cell: adjacentCell, - }), - expect.objectContaining({ - kind: 'patient-zero-threat', - label: 'Patient Zero global cleaner feed: 2/3 displayed · truncated', - }), - expect.objectContaining({ - kind: 'patient-zero-threat', - label: expect.stringContaining('Rook blocked a clean'), - cell: currentCell, - }), - ]), - ); - expect( - evidence.filter(({ label }) => label.includes('Ember lost')), - ).toHaveLength(0); - expect(evidence[1]!.label).toContain( - 'subject 3 total (2 disinfected, 1 blocked), 2 consecutive', - ); - expect(evidence[1]!.label).toContain('current alliance unaffiliated'); - }); -}); diff --git a/apps/world-lab/src/components/behavior-trace.ts b/apps/world-lab/src/components/behavior-trace.ts deleted file mode 100644 index 35a9569..0000000 --- a/apps/world-lab/src/components/behavior-trace.ts +++ /dev/null @@ -1,442 +0,0 @@ -import type { AgentId, AgentTurnRecord, H3Cell } from '@hexzero/shared'; - -export const BEHAVIOR_TRACE_LIMIT = 6; -const BEHAVIOR_TRACE_EVIDENCE_LIMIT = 6; - -type CompletedTurn = Extract< - AgentTurnRecord, - { outcome: 'accepted' | 'rejected' } ->; - -export interface BehaviorTraceEvidence { - kind: - | 'direct' - | 'public' - | 'alliance-message' - | 'zero-message' - | 'world-event' - | 'territory' - | 'alliance-event' - | 'player-threat' - | 'patient-zero-threat'; - label: string; - cell?: H3Cell; -} - -export interface BehaviorTraceEntry { - turn: AgentTurnRecord; - hasPreviousObservation: boolean; - observedChanges: string[]; - evidence: BehaviorTraceEvidence[]; - evidenceTruncated: boolean; - legalActions: string[]; - chosenAction: string; - chosenCell?: H3Cell; - actionPattern?: string; - continuity: string[]; -} - -function isCompletedTurn(turn: AgentTurnRecord): turn is CompletedTurn { - return turn.outcome === 'accepted' || turn.outcome === 'rejected'; -} - -function unseenBy( - current: readonly T[], - previous: readonly T[] | undefined, - idFor: (item: T) => string, -): T[] { - const priorIds = new Set(previous?.map(idFor) ?? []); - return current.filter((item) => !priorIds.has(idFor(item))); -} - -function goalSignature( - goal: AgentTurnRecord['observation']['currentGoal'], -): string { - return goal - ? [ - goal.longTermGoal, - goal.shortTermGoal, - goal.planSummary, - goal.establishedAtTick, - goal.revisedAtTick, - ].join('\u0000') - : ''; -} - -function memorySignature( - memory: AgentTurnRecord['observation']['currentMemory'], -): string { - return memory - .map( - ({ id, text, createdAtTick, revisedAtTick }) => - `${id}\u0000${text}\u0000${createdAtTick}\u0000${revisedAtTick}`, - ) - .join('\u0001'); -} - -function ownTerritoryCount( - turn: AgentTurnRecord, - agentId: AgentId, -): number | undefined { - return turn.observation.territoryScoreboard.find( - (entry) => entry.agentId === agentId, - )?.controlledCellCount; -} - -function collectEvidence( - turn: AgentTurnRecord, - previous: AgentTurnRecord | undefined, - agentId: AgentId, -): BehaviorTraceEvidence[] { - const observation = turn.observation; - const prior = previous?.observation; - const inboundDirect = unseenBy( - observation.recentDirectMessages.filter( - ({ direction }) => direction === 'inbound', - ), - prior?.recentDirectMessages.filter( - ({ direction }) => direction === 'inbound', - ), - ({ eventId }) => eventId, - ).map(({ senderName, message }): BehaviorTraceEvidence => ({ - kind: 'direct', - label: `Inbound from ${senderName}: ${message}`, - })); - const publicMessages = unseenBy( - observation.recentPublicMessages.filter( - ({ senderId }) => senderId !== agentId, - ), - prior?.recentPublicMessages.filter(({ senderId }) => senderId !== agentId), - ({ eventId }) => eventId, - ).map(({ senderName, message }): BehaviorTraceEvidence => ({ - kind: 'public', - label: `Public from ${senderName}: ${message}`, - })); - const allianceMessages = unseenBy( - observation.recentAllianceMessages.filter( - ({ senderId }) => senderId !== agentId, - ), - prior?.recentAllianceMessages.filter( - ({ senderId }) => senderId !== agentId, - ), - ({ eventId }) => eventId, - ).map(({ senderName, message }): BehaviorTraceEvidence => ({ - kind: 'alliance-message', - label: `Alliance from ${senderName}: ${message}`, - })); - const zeroMessages = unseenBy( - observation.recentZeroMessages.filter( - ({ senderId }) => senderId !== agentId, - ), - prior?.recentZeroMessages.filter(({ senderId }) => senderId !== agentId), - ({ eventId }) => eventId, - ).map(({ senderName, message }): BehaviorTraceEvidence => ({ - kind: 'zero-message', - label: `Patient Zero from ${senderName}: ${message}`, - })); - const territory = unseenBy( - observation.recentControlChanges, - prior?.recentControlChanges, - ({ eventId }) => eventId, - ).map(({ direction, cell, otherAgentName }): BehaviorTraceEvidence => ({ - kind: 'territory', - label: `${direction === 'gained' ? 'Gained' : 'Lost'} ${cell} ${ - direction === 'gained' ? 'from' : 'to' - } ${otherAgentName}`, - cell, - })); - const allianceEvents = unseenBy( - observation.recentAllianceEvents, - prior?.recentAllianceEvents, - ({ event }) => event.id, - ).map(({ summary }): BehaviorTraceEvidence => ({ - kind: 'alliance-event', - label: summary, - })); - const worldEvents = unseenBy( - observation.recentEvents.filter( - ({ agentId: actorId }) => actorId !== agentId, - ), - prior?.recentEvents.filter(({ agentId: actorId }) => actorId !== agentId), - ({ type, agentId: actorId, occurredAt, summary }) => - `${type}:${actorId}:${occurredAt}:${summary}`, - ).map(({ summary }): BehaviorTraceEvidence => ({ - kind: 'world-event', - label: `World: ${summary}`, - })); - const localPlayerThreats = unseenBy( - observation.playerPressure.recentThreats, - prior?.playerPressure.recentThreats, - ({ eventId }) => eventId, - ).map( - ({ - eventId, - kind, - cell, - distanceCells, - }): BehaviorTraceEvidence & { - eventId: string; - } => ({ - eventId, - kind: 'player-threat', - label: - kind === 'territory-disinfected' - ? `Local cleaner threat: own territory disinfected at ${cell}` - : `Local cleaner threat: disinfection at ${cell} (${distanceCells} cells away)`, - cell, - }), - ); - const localPlayerEventIds = new Set( - localPlayerThreats.map(({ eventId }) => eventId), - ); - const globalFeed = observation.patientZeroGlobalView?.playerThreatFeed; - const priorGlobalFeed = prior?.patientZeroGlobalView?.playerThreatFeed; - const pressureSuffix = ( - event: NonNullable['events'][number] | undefined, - ): string => { - const pressure = event?.pressureContext; - if (!pressure) return ''; - const alliance = pressure.currentAlliance - ? `; current alliance ${pressure.currentAlliance.totalEvents} total (${pressure.currentAlliance.disinfections} disinfected, ${pressure.currentAlliance.blockedCleans} blocked)` - : '; current alliance unaffiliated'; - return ` · ticks ${pressure.window.startTick}–${pressure.window.endTick}: subject ${pressure.subject.totalEvents} total (${pressure.subject.disinfections} disinfected, ${pressure.subject.blockedCleans} blocked), ${pressure.subject.consecutiveAffectedTicks} consecutive${alliance}`; - }; - const globalPlayerThreats = globalFeed - ? unseenBy( - globalFeed.events, - priorGlobalFeed?.events, - ({ eventId }) => eventId, - ) - .filter(({ eventId }) => !localPlayerEventIds.has(eventId)) - .map((event): BehaviorTraceEvidence => ({ - kind: 'patient-zero-threat', - label: - event.kind === 'territory-disinfected' - ? `Patient Zero global cleaner feed: ${event.affectedAgentName}${ - event.affectedAllianceId - ? ` (${event.affectedAllianceId})` - : '' - } lost ${event.cell}${pressureSuffix(event)}` - : `Patient Zero global cleaner feed: ${event.blockingAgentName}${ - event.blockingAllianceId - ? ` (${event.blockingAllianceId})` - : '' - } blocked a clean at ${event.cell}${pressureSuffix(event)}`, - cell: event.cell, - })) - : []; - const globalFeedSummary: BehaviorTraceEvidence[] = - globalFeed && globalFeed.totalEventCount > 0 - ? [ - { - kind: 'patient-zero-threat', - label: `Patient Zero global cleaner feed: ${globalFeed.events.length}/${globalFeed.totalEventCount} displayed${globalFeed.truncated ? ' · truncated' : ''}`, - }, - ] - : []; - return [ - ...globalFeedSummary, - ...localPlayerThreats.map(({ eventId, kind, label, cell }) => ({ - kind, - label: `${label}${pressureSuffix( - globalFeed?.events.find((event) => event.eventId === eventId), - )}`, - cell, - })), - ...globalPlayerThreats, - ...inboundDirect, - ...zeroMessages, - ...allianceMessages, - ...territory, - ...allianceEvents, - ...worldEvents, - ...publicMessages, - ]; -} - -function observedChanges( - turn: AgentTurnRecord, - previous: AgentTurnRecord | undefined, - agentId: AgentId, - evidence: readonly BehaviorTraceEvidence[], -): string[] { - if (!previous) return ['First retained observation for this agent.']; - const changes: string[] = []; - const current = turn.observation; - const prior = previous.observation; - if (current.currentCell.cell !== prior.currentCell.cell) - changes.push( - `Observed cell changed ${prior.currentCell.cell} → ${current.currentCell.cell}.`, - ); - if (current.currentCell.state !== prior.currentCell.state) - changes.push( - `Current-cell state changed ${prior.currentCell.state} → ${current.currentCell.state}.`, - ); - const previousTerritory = ownTerritoryCount(previous, agentId); - const currentTerritory = ownTerritoryCount(turn, agentId); - if ( - previousTerritory !== undefined && - currentTerritory !== undefined && - previousTerritory !== currentTerritory - ) - changes.push( - `Controlled territory changed ${previousTerritory} → ${currentTerritory}.`, - ); - if (current.actingAllianceId !== prior.actingAllianceId) - changes.push( - `Alliance changed ${prior.actingAllianceId ?? 'unaffiliated'} → ${ - current.actingAllianceId ?? 'unaffiliated' - }.`, - ); - if (goalSignature(current.currentGoal) !== goalSignature(prior.currentGoal)) - changes.push('Strategic goal context changed.'); - if ( - memorySignature(current.currentMemory) !== - memorySignature(prior.currentMemory) - ) - changes.push('Compact memory context changed.'); - if (evidence.length) - changes.push( - `${evidence.length} new retained evidence item${ - evidence.length === 1 ? '' : 's' - } entered the retained observation.`, - ); - return changes.length - ? changes - : ['No retained observation change detected.']; -} - -function legalActions(turn: AgentTurnRecord): string[] { - const availability = turn.observation.actionAvailability; - if (!availability) return ['Legacy affordances unavailable']; - const moves = availability.moveOptions.length - ? availability.moveOptions.map(({ direction }) => `Move ${direction}`) - : [ - `${availability.moveTargetCellIds.length} legal move target${ - availability.moveTargetCellIds.length === 1 ? '' : 's' - }`, - ]; - return [ - ...moves, - ...(availability.infect.available ? ['Infect'] : []), - ...(availability.capture.available ? ['Capture'] : []), - 'Wait', - ]; -} - -function actionDescription(turn: CompletedTurn): { - label: string; - pattern: string; - direction?: string; - cell?: H3Cell; -} { - if (turn.worldAction.type === 'move') { - const targetCell = turn.worldAction.targetCell; - const direction = turn.observation.actionAvailability?.moveOptions.find( - (option) => option.targetCell === targetCell, - )?.direction; - return { - label: `Move${direction ? ` ${direction}` : ''} → ${targetCell}`, - pattern: `move${direction ? ` ${direction}` : ''}`, - direction, - cell: targetCell, - }; - } - const label = - turn.worldAction.type[0]!.toUpperCase() + turn.worldAction.type.slice(1); - return { label, pattern: turn.worldAction.type }; -} - -function actionPattern( - turn: AgentTurnRecord, - previousCompleted: CompletedTurn | undefined, -): string | undefined { - if (!isCompletedTurn(turn) || !previousCompleted) return undefined; - const current = actionDescription(turn); - const previous = actionDescription(previousCompleted); - if (current.pattern === previous.pattern) - return `Repeated ${current.pattern}.`; - if (current.direction && previous.direction) - return `Changed direction ${previous.direction} → ${current.direction}.`; - return `Changed ${previous.pattern} → ${current.pattern}.`; -} - -function continuity(turn: AgentTurnRecord): string[] { - if (!isCompletedTurn(turn)) return []; - const entries: string[] = []; - if (turn.goalRevisionResult.requested) - entries.push( - `Goal ${turn.goalRevisionResult.operation}: ${ - turn.goalRevisionResult.accepted - ? 'accepted' - : `rejected (${turn.goalRevisionResult.reason})` - }`, - ); - if (turn.memoryOperationResult.requested) - entries.push( - `Memory ${turn.memoryOperationResult.operation}: ${ - turn.memoryOperationResult.accepted - ? 'accepted' - : `rejected (${turn.memoryOperationResult.reason})` - }`, - ); - if (turn.communicationResult.requested) - entries.push( - `Communication ${ - turn.communicationResult.accepted - ? turn.communicationResult.event.channel - : turn.communicationResult.attempt.channel - }: ${turn.communicationResult.accepted ? 'accepted' : 'rejected'}`, - ); - if (turn.diplomacyResult.requested) - entries.push( - `Diplomacy ${ - turn.diplomacyResult.accepted - ? turn.diplomacyResult.intent.type - : turn.diplomacyResult.attempt.type - }: ${turn.diplomacyResult.accepted ? 'accepted' : 'rejected'}`, - ); - return entries; -} - -export function deriveBehaviorTrace( - turns: readonly AgentTurnRecord[], - agentId: AgentId, - limit = BEHAVIOR_TRACE_LIMIT, -): BehaviorTraceEntry[] { - const agentTurns = turns.filter((turn) => turn.agentId === agentId); - const boundedLimit = Number.isFinite(limit) - ? Math.max(0, Math.min(BEHAVIOR_TRACE_LIMIT, Math.floor(limit))) - : BEHAVIOR_TRACE_LIMIT; - const start = Math.max(0, agentTurns.length - boundedLimit); - return agentTurns - .slice(start) - .map((turn, visibleIndex) => { - const sourceIndex = start + visibleIndex; - const previous = agentTurns[sourceIndex - 1]; - const previousCompleted = agentTurns - .slice(0, sourceIndex) - .findLast(isCompletedTurn); - const allEvidence = collectEvidence(turn, previous, agentId); - const chosen: ReturnType = isCompletedTurn(turn) - ? actionDescription(turn) - : { - label: `No completed action · ${turn.outcome}`, - pattern: turn.outcome, - }; - const pattern = actionPattern(turn, previousCompleted); - return { - turn, - hasPreviousObservation: Boolean(previous), - observedChanges: observedChanges(turn, previous, agentId, allEvidence), - evidence: allEvidence.slice(0, BEHAVIOR_TRACE_EVIDENCE_LIMIT), - evidenceTruncated: allEvidence.length > BEHAVIOR_TRACE_EVIDENCE_LIMIT, - legalActions: legalActions(turn), - chosenAction: chosen.label, - ...(chosen.cell ? { chosenCell: chosen.cell } : {}), - ...(pattern ? { actionPattern: pattern } : {}), - continuity: continuity(turn), - }; - }) - .reverse(); -} diff --git a/apps/world-lab/src/components/personality-presets.ts b/apps/world-lab/src/components/personality-presets.ts deleted file mode 100644 index 08ff48a..0000000 --- a/apps/world-lab/src/components/personality-presets.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { personalitySchema } from '@hexzero/shared'; - -export const PERSONALITY_PRESET_IDS = [ - 'aggressive-infector', - 'explorer', - 'territorial', - 'opportunist', - 'agent-seeking', -] as const; - -export type PersonalityPresetId = (typeof PERSONALITY_PRESET_IDS)[number]; - -export interface PersonalityPreset { - readonly id: PersonalityPresetId; - readonly name: string; - readonly personality: string; -} - -const personalityPresetDefinitions = [ - { - id: 'aggressive-infector', - name: 'Aggressive infector', - personality: - 'Prioritize infecting the current cell whenever it is open. When it is already infected, move decisively to an adjacent open cell; wait only when no useful adjacent move is available.', - }, - { - id: 'explorer', - name: 'Explorer', - personality: - 'Favor movement and variety. Choose adjacent open cells when possible, avoid repeatedly lingering near the same activity, and infect only when movement offers little new territory to observe.', - }, - { - id: 'territorial', - name: 'Territorial', - personality: - 'Build a compact infected area. Infect the current cell when it is open; otherwise prefer adjacent cells near visible infected cells and avoid drifting away from the local cluster.', - }, - { - id: 'opportunist', - name: 'Opportunist', - personality: - 'Exploit the clearest immediate opportunity in each observation. Infect an open current cell, move to a useful adjacent open cell when already infected, and wait when neither improves the situation.', - }, - { - id: 'agent-seeking', - name: 'Agent-seeking', - personality: - 'Seek visible nearby agents. Prefer an adjacent move that brings you closer to an observed agent, infect the current cell when useful, and wait only when no adjacent move improves proximity.', - }, -] as const satisfies readonly PersonalityPreset[]; - -export const PERSONALITY_PRESETS: readonly PersonalityPreset[] = - personalityPresetDefinitions.map((preset) => ({ - ...preset, - personality: personalitySchema.parse(preset.personality), - })); - -export function matchingPersonalityPreset(personality: string) { - return PERSONALITY_PRESETS.find( - (preset) => preset.personality === personality, - ); -} diff --git a/apps/world-lab/src/components/ui-color.test.ts b/apps/world-lab/src/components/ui-color.test.ts index f561ac3..278d24c 100644 --- a/apps/world-lab/src/components/ui-color.test.ts +++ b/apps/world-lab/src/components/ui-color.test.ts @@ -4,29 +4,19 @@ import { neutralAgentColor, resolveAgentColor } from './ui-color'; const agentId = '11111111-1111-4111-8111-111111111111' as AgentId; const otherId = '22222222-2222-4222-8222-222222222222' as AgentId; -const state = (allied: boolean) => +const state = () => ({ world: { agents: [{ id: agentId, color: '#123456' }], - alliances: allied - ? [{ color: '#abcdef', memberAgentIds: [agentId] }] - : [], }, }) as unknown as Pick; -describe('effective agent color resolution', () => { - it('prefers current alliance color and updates when membership changes', () => { - expect(resolveAgentColor(state(true), agentId, '#654321')).toBe('#abcdef'); - expect(resolveAgentColor(state(false), agentId, '#654321')).toBe( - neutralAgentColor, - ); +describe('agent color resolution', () => { + it("resolves to the agent's own color", () => { + expect(resolveAgentColor(state(), agentId)).toBe('#123456'); }); - it('falls back through retained, base, and neutral colors', () => { - expect(resolveAgentColor(state(false), agentId)).toBe(neutralAgentColor); - expect(resolveAgentColor(state(false), otherId, '#654321')).toBe( - neutralAgentColor, - ); - expect(resolveAgentColor(state(false), otherId)).toBe(neutralAgentColor); + it('falls back to the neutral color when the agent is unknown', () => { + expect(resolveAgentColor(state(), otherId)).toBe(neutralAgentColor); }); }); diff --git a/apps/world-lab/src/components/ui-color.ts b/apps/world-lab/src/components/ui-color.ts index 5c6b128..9cef231 100644 --- a/apps/world-lab/src/components/ui-color.ts +++ b/apps/world-lab/src/components/ui-color.ts @@ -9,10 +9,7 @@ export const neutralAgentColor = NEUTRAL_AGENT_COLOR; export function resolveAgentColor( snapshot: Pick, agentId: AgentId, - _retainedEffectiveColor?: string | null, ): string { - const currentAlliance = snapshot.world.alliances.find(({ memberAgentIds }) => - memberAgentIds.includes(agentId), - ); - return currentAlliance?.color ?? neutralAgentColor; + const agent = snapshot.world.agents.find(({ id }) => id === agentId); + return agent?.color ?? neutralAgentColor; } diff --git a/apps/world-lab/src/components/world-lab.test.tsx b/apps/world-lab/src/components/world-lab.test.tsx index 532d056..9070134 100644 --- a/apps/world-lab/src/components/world-lab.test.tsx +++ b/apps/world-lab/src/components/world-lab.test.tsx @@ -1,7 +1,7 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { NEUTRAL_AGENT_COLOR, simulationSnapshotSchema } from '@hexzero/shared'; +import { simulationSnapshotSchema } from '@hexzero/shared'; import { createDefaultAppliedScenario, createDevelopmentWorld, @@ -44,7 +44,6 @@ const metrics = { uniqueVisitedCells: 0, tokens: {}, knownCostCredits: 0, - turnsWithUnknownCost: 0, }; const snapshot = simulationSnapshotSchema.parse({ world, @@ -87,11 +86,8 @@ const snapshot = simulationSnapshotSchema.parse({ agentId: id, name, color, - allianceId: null, - effectiveColor: NEUTRAL_AGENT_COLOR, controlledCellCount: 0, })), - currentAlliances: [], simulatedPlayerMetrics: { movements: 0, cellsDisinfected: 0, @@ -308,12 +304,9 @@ describe('WorldLab swarm workspace', () => { it('requests a bounded full-safe swarm export preview before enabling export actions', async () => { const preview = { experimentId: snapshot.experiment.id, - matchingTurnCount: 0, matchingTickCount: 0, matchingSwarmTickCount: 0, - matchingCommunicationCount: 0, matchingControlChangeCount: 0, - matchingDiplomacyEventCount: 0, matchingProviderAttemptCount: 0, selectedAgentCount: world.agents.length, retention: { @@ -326,7 +319,6 @@ describe('WorldLab swarm workspace', () => { }, knownCostCredits: 0, attemptsWithUnknownCost: 0, - turnsWithUnknownCost: 0, serializedUtf8Bytes: 100, approximateAiInputTokens: 25, tokenEstimateMethod: 'ceil(UTF-8 bytes / 4)' as const, diff --git a/apps/world-lab/src/components/world-lab.tsx b/apps/world-lab/src/components/world-lab.tsx index ce8b8dd..8896e78 100644 --- a/apps/world-lab/src/components/world-lab.tsx +++ b/apps/world-lab/src/components/world-lab.tsx @@ -11,29 +11,19 @@ import { type KeyboardEvent as ReactKeyboardEvent, } from 'react'; import { - PERSONALITY_MAX_LENGTH, NEUTRAL_AGENT_COLOR, - PERSONALITY_PROFILES, - STRATEGY_PROFILES, - assignBehavior, archiveExperimentExportResponseSchema, cancelSimulationResponseSchema, cancelledTickResponseSchema, experimentExportPreviewSchema, experimentExportRequestSchema, experimentExportResponseSchema, - experimentImportResponseSchema, modelCatalogResponseSchema, - personalitySchema, resetSimulationResponseSchema, reasoningProfilesForModel, - restoreDefaultPersonalitiesResponseSchema, simulationSnapshotSchema, singleTickResponseSchema, - updateAgentPersonalityRequestSchema, - updateAgentPersonalityResponseSchema, updateExperimentModelsResponseSchema, - updateExperimentBehaviorResponseSchema, verifyModelResponseSchema, worldSetupPreviewResponseSchema, applyWorldSetupResponseSchema, @@ -42,7 +32,6 @@ import { defaultWorldSetupResponseSchema, WORLD_RADIUS_PRESETS, type AgentId, - type AgentTurnRecord, type CustomExportOptions, type ExperimentExportDocument, type ExperimentExportPreview, @@ -53,20 +42,13 @@ import { type ModelVerification, type ReasoningProfile, type ExperimentModelConfiguration, - type BehaviorConfiguration, type SimulationSnapshot, type WorldSetupRequest, type WorldSetupPreviewResponse, } from '@hexzero/shared'; -import { - matchingPersonalityPreset, - PERSONALITY_PRESETS, -} from './personality-presets'; - import { WorldMap } from './world-map'; import { buildModelOptions } from './model-options'; import { resolveAgentColor } from './ui-color'; -import { BEHAVIOR_TRACE_LIMIT, deriveBehaviorTrace } from './behavior-trace'; import { SwarmActivityPanel, SwarmAgentInspector, @@ -101,33 +83,7 @@ export function WorldLab() { const [inspectorTab, setInspectorTab] = useState< 'scoreboard' | 'agent' | 'hex' | 'run' >('agent'); - const [activityTab, setActivityTab] = useState< - 'chat' | 'private' | 'events' | 'recovery' | 'swarm' - >('chat'); const [snapshot, setSnapshot] = useState(null); - const [privateCommsUnread, setPrivateCommsUnread] = useState(0); - const privateCommCount = - snapshot?.world.events.filter( - (event) => - event.type === 'direct-message-sent' || - event.type === 'alliance-message-sent' || - event.type === 'zero-message-sent', - ).length ?? 0; - const previousPrivateCommCount = useRef(null); - useEffect(() => { - if (previousPrivateCommCount.current === null) { - if (snapshot) previousPrivateCommCount.current = privateCommCount; - return; - } - const added = Math.max( - 0, - privateCommCount - previousPrivateCommCount.current, - ); - if (activityTab !== 'private' && added) - setPrivateCommsUnread((count) => count + added); - if (activityTab === 'private') setPrivateCommsUnread(0); - previousPrivateCommCount.current = privateCommCount; - }, [activityTab, privateCommCount, snapshot]); const [selectedCell, setSelectedCell] = useState(null); const [selectedAgentId, setSelectedAgentId] = useState(null); const [running, setRunning] = useState(false); @@ -138,10 +94,6 @@ export function WorldLab() { const [reconciling, setReconciling] = useState(false); const [recoveryNotice, setRecoveryNotice] = useState(null); const [resetting, setResetting] = useState(false); - const [personalityPending, setPersonalityPending] = useState(false); - const [personalityNotice, setPersonalityNotice] = useState( - null, - ); const [speed, setSpeed] = useState(1_000); const [uiError, setUiError] = useState(null); const [exportOpen, setExportOpen] = useState(false); @@ -394,36 +346,6 @@ export function WorldLab() { } }; - const updateBehavior = async ( - configuration: Omit, - ): Promise => { - if (configurationPendingRef.current) return false; - configurationPendingRef.current = true; - setConfigurationPending(true); - setUiError(null); - try { - const response = await fetch(`${apiBase}/experiment/behavior`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(configuration), - }); - if (!response.ok) throw new Error('behavior update rejected'); - applySnapshot( - updateExperimentBehaviorResponseSchema.parse(await response.json()) - .snapshot, - ); - return true; - } catch { - setUiError( - 'Behavior assignments could not be saved. They may be locked after turn one.', - ); - return false; - } finally { - configurationPendingRef.current = false; - setConfigurationPending(false); - } - }; - const verifyModel = async ( modelId: string, reasoningProfile: ReasoningProfile, @@ -456,34 +378,6 @@ export function WorldLab() { } }; - const importExperiment = async (file: File): Promise => { - if (file.size > 5_000_000) { - setUiError('Experiment import files must be 5 MB or smaller.'); - return; - } - try { - const document = JSON.parse(await file.text()) as unknown; - const response = await fetch(`${apiBase}/experiment/import`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ document }), - }); - const body = await response.json(); - if (!response.ok) { - const error = body as { error?: { message?: string } }; - setUiError( - error.error?.message ?? 'The experiment import was rejected.', - ); - return; - } - const payload = experimentImportResponseSchema.parse(body); - applySnapshot(payload.snapshot); - setPersonalityNotice(payload.message); - } catch { - setUiError('The selected file is not a valid experiment export.'); - } - }; - const executeTurn = useCallback(async () => { if (inFlightRef.current) return; if ( @@ -656,89 +550,6 @@ export function WorldLab() { } }; - const updatePersonality = async ( - agentId: AgentId, - personality: string, - ): Promise => { - const request = updateAgentPersonalityRequestSchema.safeParse({ - personality, - }); - if (!request.success) return false; - setPersonalityPending(true); - setPersonalityNotice(null); - setUiError(null); - try { - const response = await fetch(`${apiBase}/agents/${agentId}/personality`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(request.data), - }); - if (response.status === 409) { - setUiError( - 'Personality changes are unavailable until the current tick completes.', - ); - return false; - } - if (!response.ok) { - setUiError('The personality was rejected safely by the Game API.'); - return false; - } - const payload = updateAgentPersonalityResponseSchema.parse( - await response.json(), - ); - applySnapshot(payload.snapshot); - setPersonalityNotice(`${payload.agent.name}'s personality was updated.`); - return true; - } catch { - setUiError( - 'Personality update failed safely. The existing personality was left intact.', - ); - return false; - } finally { - setPersonalityPending(false); - } - }; - - const restoreDefaultPersonalities = async () => { - if ( - !window.confirm( - 'Restore milestone default personalities for applicable active agents? World progress will be preserved.', - ) - ) - return; - setPersonalityPending(true); - setPersonalityNotice(null); - setUiError(null); - try { - const response = await fetch( - `${apiBase}/personalities/restore-defaults`, - { - method: 'POST', - }, - ); - if (response.status === 409) { - setUiError( - 'Default personalities cannot be restored until the current tick completes.', - ); - return; - } - if (!response.ok) throw new Error('restore personalities failed'); - const payload = restoreDefaultPersonalitiesResponseSchema.parse( - await response.json(), - ); - applySnapshot(payload.snapshot); - setPersonalityNotice( - 'Default personalities restored. World progress was preserved.', - ); - } catch { - setUiError( - 'Restoring default personalities failed safely. Existing configuration was left intact.', - ); - } finally { - setPersonalityPending(false); - } - }; - const fullyInfected = snapshot?.world.hexes.every(({ state }) => state === 'infected') ?? false; if (!snapshot) { @@ -767,12 +578,6 @@ export function WorldLab() { ({ id }) => id === selectedHex.controllerAgentId, ) : undefined; - const selectedHexAlliance = selectedHexController - ? snapshot.world.alliances.find(({ memberAgentIds }) => - memberAgentIds.includes(selectedHexController.id), - ) - : undefined; - const latestTurn = undefined; const status = resetting ? 'resetting' : reconciling @@ -790,29 +595,18 @@ export function WorldLab() { ? 'running' : 'paused'; const activeTick = snapshot.status === 'waiting-for-model'; + const swarmMode = true; const terminal = snapshot.status === 'patient-zero-captured' || snapshot.status === 'infection-eliminated'; - // PR2 removes the inactive social panels; World Lab now always enters via - // the swarm workspace. - const swarmMode = true; - const visibleActivityTab = swarmMode ? 'swarm' : activityTab; const zeroAgentId = snapshot.scenario.patientZeroAgentId; const zeroModel = snapshot.resolvedModels.find( ({ agentId }) => agentId === zeroAgentId, ); - const personalityControlsDisabled = - running || - inFlight || - resetting || - personalityPending || - snapshot.activeAgentId !== null || - activeTick; const exportMutationPending = running || inFlight || resetting || - personalityPending || snapshot.activeAgentId !== null || activeTick; const modelsReady = Boolean(zeroModel?.available); @@ -820,14 +614,6 @@ export function WorldLab() { const reasoningUnavailable = snapshot.resolvedModels.some( ({ issue }) => issue === 'reasoning-unavailable', ); - const publicMessages = snapshot.world.events.filter( - ( - event, - ): event is Extract< - SimulationSnapshot['world']['events'][number], - { type: 'public-message-sent' } - > => event.type === 'public-message-sent', - ); return (
)} - {!swarmMode && ( -
-
Public messages
-
- {snapshot.experiment.metrics.aggregate.publicMessagesSent} -
-
- )} - {!swarmMode && ( -
-
Direct messages
-
- {snapshot.experiment.metrics.aggregate.directMessagesSent} -
-
- )}
Provider attempts
@@ -1016,7 +786,6 @@ export function WorldLab() { includes OpenRouter amounts only when returned by the provider.

)} - {!swarmMode && }
- {!swarmMode && ( - - )}
)} - {(!modelsReady || - uiError || - fullyInfected || - terminal || - personalityNotice) && ( + {(!modelsReady || uiError || fullyInfected || terminal) && (
{uiError ?? (fullyInfected @@ -1312,12 +1061,11 @@ export function WorldLab() { ? snapshot.status === 'patient-zero-captured' ? 'Patient Zero was captured by the simulated player. This experiment is complete; reset or apply a new World Setup to run again.' : 'All infection has been eliminated. This experiment is complete; reset or apply a new World Setup to run again.' - : (personalityNotice ?? - (swarmMode - ? 'Select an available model for Agent Zero before starting.' - : reasoningUnavailable - ? 'A saved reasoning profile is no longer advertised by its model. Select an available profile before starting.' - : 'Select an available compatible model for every agent before starting.')))} + : swarmMode + ? 'Select an available model for Agent Zero before starting.' + : reasoningUnavailable + ? 'A saved reasoning profile is no longer advertised by its model. Select an available profile before starting.' + : 'Select an available compatible model for every agent before starting.')}
)} {!swarmMode && !snapshot.providerConfigured && ( @@ -1387,7 +1135,6 @@ export function WorldLab() { longitude={snapshot.scenario.center.longitude} hexes={snapshot.world.hexes} agents={snapshot.world.agents} - alliances={swarmMode ? [] : snapshot.world.alliances} patientZeroAgentId={snapshot.scenario.patientZeroAgentId} simulatedPlayer={snapshot.world.simulatedPlayer} selectedCell={selectedCell} @@ -1450,113 +1197,43 @@ export function WorldLab() { role="tabpanel" id={`inspector-${inspectorTab}`} > - {inspectorTab === 'agent' && - selectedAgent && - (swarmMode ? ( - cell === selectedAgent.currentCell, - )!.state - } - controlledCellCount={ - snapshot.experiment.currentTerritory.find( - ({ agentId }) => agentId === selectedAgent.id, - )?.controlledCellCount ?? 0 - } - onHighlightCell={setSelectedCell} - /> - ) : ( - cell === selectedAgent.currentCell, - )!.state - } - latestTurn={latestTurn} - turns={[]} - directMessages={snapshot.world.events.filter( - ( - event, - ): event is Extract< - SimulationSnapshot['world']['events'][number], - { type: 'direct-message-sent' } - > => - event.type === 'direct-message-sent' && - (event.agentId === selectedAgent.id || - event.recipientId === selectedAgent.id), - )} - agents={snapshot.world.agents} - mutationDisabled={personalityControlsDisabled} - mutationPending={personalityPending} - onApplyPersonality={updatePersonality} - onHighlightCell={setSelectedCell} - metrics={ - snapshot.experiment.metrics.byAgent.find( - ({ agentId }) => agentId === selectedAgent.id, - )?.metrics - } - controlledCellCount={ - snapshot.experiment.currentTerritory.find( - ({ agentId }) => agentId === selectedAgent.id, - )?.controlledCellCount ?? 0 - } - controlChanges={snapshot.world.events.filter( - ( - event, - ): event is Extract< - SimulationSnapshot['world']['events'][number], - { type: 'hex-captured' } - > => - event.type === 'hex-captured' && - (event.controllerAgentId === selectedAgent.id || - event.previousControllerAgentId === - selectedAgent.id), - )} - /> - ))} + {inspectorTab === 'agent' && selectedAgent && ( + cell === selectedAgent.currentCell, + )!.state + } + controlledCellCount={ + snapshot.experiment.currentTerritory.find( + ({ agentId }) => agentId === selectedAgent.id, + )?.controlledCellCount ?? 0 + } + onHighlightCell={setSelectedCell} + /> + )} {inspectorTab === 'hex' && selectedHex && ( )} - {inspectorTab === 'scoreboard' && - (swarmMode ? ( - - ) : ( - <> - - - - ))} - {inspectorTab === 'run' && - (swarmMode ? ( - - ) : ( - - ))} + {inspectorTab === 'scoreboard' && ( + + )} + {inspectorTab === 'run' && ( + + )} @@ -1565,41 +1242,7 @@ export function WorldLab() { aria-label="Activity dock" >
-
- {(swarmMode - ? ['swarm'] - : (['chat', 'private', 'events', 'recovery'] as const) - ).map((tab) => ( - - ))} -
+ Swarm activity
- {!chatCollapsed && visibleActivityTab === 'swarm' && swarmMode && ( - - )} - {!chatCollapsed && visibleActivityTab === 'chat' && !swarmMode && ( - - )} - {!chatCollapsed && - visibleActivityTab === 'private' && - !swarmMode && ( - - )} - {!chatCollapsed && visibleActivityTab === 'events' && ( - - )} - {!chatCollapsed && - visibleActivityTab === 'recovery' && - !swarmMode && } + {!chatCollapsed && } ) : ( @@ -1650,96 +1261,41 @@ export function WorldLab() { onSelectAgent={selectAgentForInspection} onOpenWorldSetup={() => setSetupOpen(true)} > - {snapshot.providerMode === 'openrouter' || swarmMode ? ( - + {selectedAgent && ( + cell === selectedAgent.currentCell, + )!.state + } + controlledCellCount={ + snapshot.experiment.currentTerritory.find( + ({ agentId }) => agentId === selectedAgent.id, + )?.controlledCellCount ?? 0 } - verifications={modelVerifications} - verifyingModelId={verifyingModelId} - onRefresh={refreshCatalog} - onUpdate={updateModels} - onUpdateBehavior={updateBehavior} - onVerify={verifyModel} - onImport={importExperiment} /> - ) : ( -

- Deterministic test model assignments are active. -

)} - {selectedAgent && - (swarmMode ? ( - cell === selectedAgent.currentCell, - )!.state - } - controlledCellCount={ - snapshot.experiment.currentTerritory.find( - ({ agentId }) => agentId === selectedAgent.id, - )?.controlledCellCount ?? 0 - } - /> - ) : ( - cell === selectedAgent.currentCell, - )!.state - } - latestTurn={latestTurn} - turns={[]} - directMessages={snapshot.world.events.filter( - ( - event, - ): event is Extract< - SimulationSnapshot['world']['events'][number], - { type: 'direct-message-sent' } - > => - event.type === 'direct-message-sent' && - (event.agentId === selectedAgent.id || - event.recipientId === selectedAgent.id), - )} - agents={snapshot.world.agents} - mutationDisabled={personalityControlsDisabled} - mutationPending={personalityPending} - onApplyPersonality={updatePersonality} - metrics={ - snapshot.experiment.metrics.byAgent.find( - ({ agentId }) => agentId === selectedAgent.id, - )?.metrics - } - controlledCellCount={ - snapshot.experiment.currentTerritory.find( - ({ agentId }) => agentId === selectedAgent.id, - )?.controlledCellCount ?? 0 - } - controlChanges={snapshot.world.events.filter( - ( - event, - ): event is Extract< - SimulationSnapshot['world']['events'][number], - { type: 'hex-captured' } - > => - event.type === 'hex-captured' && - (event.controllerAgentId === selectedAgent.id || - event.previousControllerAgentId === selectedAgent.id), - )} - /> - ))} )}
@@ -1792,25 +1348,19 @@ function AgentsWorkspace({ function HexInspector({ hex, controller, - alliance, selectedAgent, - swarmMode = false, }: { hex: SimulationSnapshot['world']['hexes'][number]; controller?: SimulationSnapshot['world']['agents'][number]; - alliance?: SimulationSnapshot['world']['alliances'][number]; selectedAgent?: SimulationSnapshot['world']['agents'][number]; - swarmMode?: boolean; }) { const relationship = !selectedAgent ? 'No agent selected' : controller?.id === selectedAgent.id ? 'Controlled by selected agent' - : alliance?.memberAgentIds.includes(selectedAgent.id) - ? 'Controlled by an ally' - : controller - ? 'Controlled by another agent' - : 'Open'; + : controller + ? 'Controlled by another agent' + : 'Open'; return (
- {!swarmMode && ( - <> -
-
Alliance
-
{alliance?.id ?? 'None'}
-
-
-
Relationship
-
{relationship}
-
- - )} +
+
Relationship
+
{relationship}
+
); } -function RunHealthSummary({ +function Spinner() { + return