UNPKG

simullm

Version:

Event-driven Agent-Based Modeling framework for TypeScript

766 lines (643 loc) 28.2 kB
import { describe, it, expect } from "bun:test"; import { createSimulation, createAgent, } from "./simulation.ts"; describe("ABM Framework", () => { describe("Event-Driven Framework (Primary API)", () => { type TestAction = | { type: "START" } | { type: "INCREMENT"; amount: number } | { type: "DOUBLE" } | { type: "COMPLETED"; agentId: string }; describe("EventSimulation", () => { it("should create simulation with correct initial state", () => { const simulation = createSimulation<number, TestAction>({ initialGlobalState: 42, agents: [], shouldExit: () => false, }); expect(simulation.getGlobalState()).toBe(42); }); it("should handle agent with initial internal state", () => { const agent = createAgent<number, TestAction, string>( "test-agent", () => {}, "initial-state" ); const simulation = createSimulation<number, TestAction>({ initialGlobalState: 0, agents: [agent], shouldExit: () => false, }); expect(simulation.getAgentInternalState("test-agent")).toBe("initial-state"); }); it("should dispatch actions to all agents", async () => { const receivedActions: TestAction[] = []; const agent1 = createAgent<number, TestAction>( "agent1", (action, context) => { receivedActions.push(action); } ); const agent2 = createAgent<number, TestAction>( "agent2", (action, context) => { receivedActions.push(action); } ); const simulation = createSimulation<number, TestAction>({ initialGlobalState: 0, agents: [agent1, agent2], shouldExit: () => false, }); await simulation.dispatch({ type: "START" }); expect(receivedActions).toHaveLength(2); expect(receivedActions[0]).toEqual({ type: "START" }); expect(receivedActions[1]).toEqual({ type: "START" }); }); it("should allow agents to update global state", async () => { const agent = createAgent<number, TestAction>( "counter", (action, context) => { if (action.type === "INCREMENT") { context.updateGlobalState(state => state + action.amount); } } ); const simulation = createSimulation<number, TestAction>({ initialGlobalState: 10, agents: [agent], shouldExit: () => false, }); await simulation.dispatch({ type: "INCREMENT", amount: 5 }); expect(simulation.getGlobalState()).toBe(15); }); it("should allow agents to update their internal state", async () => { interface InternalState { counter: number; } const agent = createAgent<number, TestAction, InternalState>( "stateful-agent", (action, context) => { if (action.type === "INCREMENT") { context.updateInternalState((state: InternalState) => ({ ...state, counter: state.counter + action.amount, })); } }, { counter: 0 } ); const simulation = createSimulation<number, TestAction>({ initialGlobalState: 0, agents: [agent], shouldExit: () => false, }); await simulation.dispatch({ type: "INCREMENT", amount: 3 }); expect(simulation.getAgentInternalState("stateful-agent")).toEqual({ counter: 3 }); }); it("should handle cascading actions", async () => { const actionLog: string[] = []; const agent1 = createAgent<number, TestAction>( "agent1", (action, context) => { actionLog.push(`agent1 received ${action.type}`); if (action.type === "START") { context.dispatch({ type: "INCREMENT", amount: 1 }); } } ); const agent2 = createAgent<number, TestAction>( "agent2", (action, context) => { actionLog.push(`agent2 received ${action.type}`); if (action.type === "INCREMENT") { context.dispatch({ type: "DOUBLE" }); context.updateGlobalState(state => state + action.amount); } } ); const agent3 = createAgent<number, TestAction>( "agent3", (action, context) => { actionLog.push(`agent3 received ${action.type}`); if (action.type === "DOUBLE") { context.updateGlobalState(state => state * 2); } } ); const simulation = createSimulation<number, TestAction>({ initialGlobalState: 0, agents: [agent1, agent2, agent3], shouldExit: () => false, }); await simulation.dispatch({ type: "START" }); // Should process: START → INCREMENT → DOUBLE expect(actionLog).toEqual([ "agent1 received START", "agent2 received START", "agent3 received START", "agent1 received INCREMENT", "agent2 received INCREMENT", "agent3 received INCREMENT", "agent1 received DOUBLE", "agent2 received DOUBLE", "agent3 received DOUBLE", ]); // Global state: 0 + 1 = 1, then 1 * 2 = 2 expect(simulation.getGlobalState()).toBe(2); }); it("should handle async agent actions", async () => { const agent = createAgent<number, TestAction>( "async-agent", async (action, context) => { if (action.type === "INCREMENT") { await new Promise(resolve => setTimeout(resolve, 1)); context.updateGlobalState(state => state + action.amount); } } ); const simulation = createSimulation<number, TestAction>({ initialGlobalState: 10, agents: [agent], shouldExit: () => false, }); await simulation.dispatch({ type: "INCREMENT", amount: 5 }); expect(simulation.getGlobalState()).toBe(15); }); describe("Exit Conditions", () => { it("should exit simulation based on action count", async () => { let receivedActionCount = 0; const agent = createAgent<number, TestAction>( "test-agent", (action, context) => { receivedActionCount++; if (action.type === "INCREMENT") { context.updateGlobalState(state => state + action.amount); // Dispatch another action to test cascading context.dispatch({ type: "DOUBLE" }); } } ); const simulation = createSimulation<number, TestAction>({ initialGlobalState: 0, agents: [agent], shouldExit: ({ actionCount }) => actionCount >= 3, }); // Dispatch actions that would normally cascade await simulation.dispatch({ type: "INCREMENT", amount: 1 }); await simulation.dispatch({ type: "INCREMENT", amount: 2 }); expect(simulation.getActionCount()).toBe(3); expect(receivedActionCount).toBe(3); // Should stop after 3 processed actions }); it("should exit simulation based on global state", async () => { const agent = createAgent<number, TestAction>( "counter-agent", (action, context) => { if (action.type === "INCREMENT") { context.updateGlobalState(state => state + action.amount); } } ); const simulation = createSimulation<number, TestAction>({ initialGlobalState: 0, agents: [agent], shouldExit: ({ globalState }) => globalState >= 10, }); await simulation.dispatch({ type: "INCREMENT", amount: 5 }); // globalState = 5 await simulation.dispatch({ type: "INCREMENT", amount: 8 }); // globalState = 13, should exit after this await simulation.dispatch({ type: "INCREMENT", amount: 1 }); // This should not be processed expect(simulation.getGlobalState()).toBe(13); // Should stop after reaching >= 10 expect(simulation.getActionCount()).toBe(2); }); it("should exit simulation based on agent internal state", async () => { interface CounterState { value: number; } const agent = createAgent<number, TestAction, CounterState>( "stateful-agent", (action, context) => { if (action.type === "INCREMENT") { context.updateInternalState((state: CounterState) => ({ value: state.value + action.amount, })); } }, { value: 0 } ); const simulation = createSimulation<number, TestAction>({ initialGlobalState: 0, agents: [agent], shouldExit: ({ agentStates }) => agentStates["stateful-agent"]?.value >= 5, }); await simulation.dispatch({ type: "INCREMENT", amount: 2 }); // value = 2 await simulation.dispatch({ type: "INCREMENT", amount: 4 }); // value = 6, should exit after this await simulation.dispatch({ type: "INCREMENT", amount: 1 }); // Should not be processed expect(simulation.getAgentInternalState("stateful-agent")).toEqual({ value: 6 }); expect(simulation.getActionCount()).toBe(2); }); it("should exit simulation based on last action type", async () => { const agent = createAgent<number, TestAction>( "test-agent", (action, context) => { if (action.type === "INCREMENT") { context.updateGlobalState(state => state + action.amount); } } ); const simulation = createSimulation<number, TestAction>({ initialGlobalState: 0, agents: [agent], shouldExit: ({ lastAction }) => lastAction.type === "DOUBLE", }); await simulation.dispatch({ type: "INCREMENT", amount: 1 }); // globalState = 1 await simulation.dispatch({ type: "DOUBLE" }); // Should exit after this await simulation.dispatch({ type: "INCREMENT", amount: 5 }); // Should not be processed expect(simulation.getGlobalState()).toBe(1); // Only first increment processed expect(simulation.getActionCount()).toBe(2); }); it("should prevent infinite loops with complex exit conditions", async () => { const agent = createAgent<number, TestAction>( "recursive-agent", (action, context) => { if (action.type === "START") { context.dispatch({ type: "INCREMENT", amount: 1 }); } else if (action.type === "INCREMENT") { context.updateGlobalState(state => state + action.amount); if (context.globalState < 5) { context.dispatch({ type: "INCREMENT", amount: 1 }); } } } ); const simulation = createSimulation<number, TestAction>({ initialGlobalState: 0, agents: [agent], shouldExit: ({ globalState, actionCount }) => globalState >= 3 || actionCount >= 10, }); await simulation.dispatch({ type: "START" }); expect(simulation.getGlobalState()).toBe(3); expect(simulation.getActionCount()).toBeLessThanOrEqual(10); }); it("should provide correct exit context", async () => { let capturedContext: any = null; const agent = createAgent<number, TestAction>( "test-agent", (action, context) => { if (action.type === "INCREMENT") { context.updateGlobalState(state => state + action.amount); } } ); const simulation = createSimulation<number, TestAction>({ initialGlobalState: 5, agents: [agent], shouldExit: (context) => { capturedContext = context; return context.actionCount >= 2; }, }); await simulation.dispatch({ type: "INCREMENT", amount: 3 }); await simulation.dispatch({ type: "DOUBLE" }); expect(capturedContext).toMatchObject({ globalState: 8, // 5 + 3 actionCount: 2, lastAction: { type: "DOUBLE" }, agentStates: { "test-agent": undefined }, }); }); it("should handle shouldExit throwing errors gracefully", async () => { const agent = createAgent<number, TestAction>( "test-agent", (action, context) => { if (action.type === "INCREMENT") { context.updateGlobalState(state => state + action.amount); } } ); const simulation = createSimulation<number, TestAction>({ initialGlobalState: 0, agents: [agent], shouldExit: ({ actionCount }) => { if (actionCount === 2) { throw new Error("Exit condition error"); } return false; }, }); // First dispatch should succeed await simulation.dispatch({ type: "INCREMENT", amount: 1 }); // Second dispatch should throw when exit condition throws await expect(simulation.dispatch({ type: "INCREMENT", amount: 2 })).rejects.toThrow("Exit condition error"); }); it("should correctly report simulation exit status", async () => { const agent = createAgent<number, TestAction>( "test-agent", (action, context) => { if (action.type === "INCREMENT") { context.updateGlobalState(state => state + action.amount); } } ); const simulation = createSimulation<number, TestAction>({ initialGlobalState: 0, agents: [agent], shouldExit: ({ actionCount }) => actionCount >= 2, }); expect(simulation.hasSimulationExited()).toBe(false); await simulation.dispatch({ type: "INCREMENT", amount: 1 }); expect(simulation.hasSimulationExited()).toBe(false); await simulation.dispatch({ type: "INCREMENT", amount: 2 }); expect(simulation.hasSimulationExited()).toBe(true); // Further dispatches should be ignored await simulation.dispatch({ type: "INCREMENT", amount: 100 }); expect(simulation.getGlobalState()).toBe(3); // Should still be 1 + 2 = 3 expect(simulation.getActionCount()).toBe(2); }); it("should resolve exit promise when simulation exits", async () => { const agent = createAgent<number, TestAction>( "test-agent", (action, context) => { if (action.type === "INCREMENT") { context.updateGlobalState(state => state + action.amount); } } ); const simulation = createSimulation<number, TestAction>({ initialGlobalState: 0, agents: [agent], shouldExit: ({ actionCount }) => actionCount >= 2, }); // Start dispatching actions simulation.dispatch({ type: "INCREMENT", amount: 1 }); simulation.dispatch({ type: "INCREMENT", amount: 2 }); // Wait for simulation to complete await simulation.exit(); expect(simulation.hasSimulationExited()).toBe(true); expect(simulation.getGlobalState()).toBe(3); expect(simulation.getActionCount()).toBe(2); }); it("should handle exit promise with async actions", async () => { const agent = createAgent<number, TestAction>( "async-agent", async (action, context) => { if (action.type === "INCREMENT") { await new Promise(resolve => setTimeout(resolve, 10)); context.updateGlobalState(state => state + action.amount); } } ); const simulation = createSimulation<number, TestAction>({ initialGlobalState: 0, agents: [agent], shouldExit: ({ globalState }) => globalState >= 5, }); // Start dispatching actions simulation.dispatch({ type: "INCREMENT", amount: 3 }); simulation.dispatch({ type: "INCREMENT", amount: 4 }); // Wait for simulation to complete await simulation.exit(); expect(simulation.hasSimulationExited()).toBe(true); expect(simulation.getGlobalState()).toBe(7); }); it("should handle exit promise with cascading actions", async () => { const agent = createAgent<number, TestAction>( "cascading-agent", (action, context) => { if (action.type === "START") { context.dispatch({ type: "INCREMENT", amount: 1 }); } else if (action.type === "INCREMENT") { context.updateGlobalState(state => state + action.amount); if (context.globalState < 3) { context.dispatch({ type: "INCREMENT", amount: 1 }); } } } ); const simulation = createSimulation<number, TestAction>({ initialGlobalState: 0, agents: [agent], shouldExit: ({ globalState }) => globalState >= 3, }); // Start the simulation simulation.dispatch({ type: "START" }); // Wait for simulation to complete await simulation.exit(); expect(simulation.hasSimulationExited()).toBe(true); expect(simulation.getGlobalState()).toBe(3); }); it("should handle multiple awaits on same exit promise", async () => { const agent = createAgent<number, TestAction>( "test-agent", (action, context) => { if (action.type === "INCREMENT") { context.updateGlobalState(state => state + action.amount); } } ); const simulation = createSimulation<number, TestAction>({ initialGlobalState: 0, agents: [agent], shouldExit: ({ actionCount }) => actionCount >= 1, }); // Start dispatching simulation.dispatch({ type: "INCREMENT", amount: 5 }); // Multiple awaits should all resolve const [result1, result2, result3] = await Promise.all([ simulation.exit(), simulation.exit(), simulation.exit(), ]); expect(result1).toBeUndefined(); expect(result2).toBeUndefined(); expect(result3).toBeUndefined(); expect(simulation.hasSimulationExited()).toBe(true); }); it("should process actions dispatched asynchronously by agents - REPRODUCTION TEST", async () => { let messageCount = 0; const receivedMessages: string[] = []; const alice = createAgent<number, TestAction>( "alice", (action, context) => { if (action.type === "START") { messageCount++; receivedMessages.push(`Alice message ${messageCount}`); // Simulate async action dispatch (like setTimeout in the bug report) setTimeout(() => { if (messageCount < 3) { context.dispatch({ type: "START" }); } }, 10); } } ); const simulation = createSimulation<number, TestAction>({ initialGlobalState: 0, agents: [alice], shouldExit: ({ actionCount }) => actionCount >= 5, // Allow plenty of actions }); await simulation.dispatch({ type: "START" }); // Wait a bit for async actions to potentially process await new Promise(resolve => setTimeout(resolve, 100)); // If the bug exists, Alice should only speak once // If fixed, Alice should speak 3 times expect(messageCount).toBe(3); expect(receivedMessages).toEqual([ "Alice message 1", "Alice message 2", "Alice message 3" ]); }); describe("Agent Coordination", () => { it("should provide allAgents in context for coordination", async () => { interface CoordinationState { role: string; status: string; } let capturedAllAgents: any = null; const coordinator = createAgent<number, TestAction, CoordinationState>( "coordinator", (action, context) => { if (action.type === "START") { capturedAllAgents = context.allAgents; context.updateInternalState(state => ({ ...state, status: "coordinating" })); } }, { role: "leader", status: "idle" } ); const worker1 = createAgent<number, TestAction, CoordinationState>( "worker1", (action, context) => {}, { role: "worker", status: "waiting" } ); const worker2 = createAgent<number, TestAction, CoordinationState>( "worker2", (action, context) => {}, { role: "worker", status: "ready" } ); const simulation = createSimulation<number, TestAction>({ initialGlobalState: 0, agents: [coordinator, worker1, worker2], shouldExit: ({ actionCount }) => actionCount >= 1, }); await simulation.dispatch({ type: "START" }); expect(capturedAllAgents).toEqual([ { id: "coordinator", internalState: { role: "leader", status: "idle" } }, { id: "worker1", internalState: { role: "worker", status: "waiting" } }, { id: "worker2", internalState: { role: "worker", status: "ready" } }, ]); }); it("should allow agents to coordinate based on other agents' states", async () => { interface TaskState { taskQueue: string[]; isWorking: boolean; } const taskDispatcher = createAgent<number, TestAction, TaskState>( "dispatcher", (action, context) => { if (action.type === "START") { // Find available workers based on their internal states const availableWorkers = context.allAgents .filter(agent => agent.id !== "dispatcher" && !agent.internalState?.isWorking) .map(agent => agent.id); if (availableWorkers.length > 0) { // Assign tasks to available workers availableWorkers.forEach(workerId => { context.dispatch({ type: "COMPLETED", agentId: workerId }); }); context.updateInternalState(state => ({ ...state, taskQueue: state.taskQueue.filter((_: string, index: number) => index >= availableWorkers.length) })); } } }, { taskQueue: ["task1", "task2", "task3"], isWorking: false } ); const worker1 = createAgent<number, TestAction, TaskState>( "worker1", (action, context) => { if (action.type === "COMPLETED" && action.agentId === "worker1") { context.updateInternalState(state => ({ ...state, isWorking: true })); context.updateGlobalState(state => state + 1); } }, { taskQueue: [], isWorking: false } ); const worker2 = createAgent<number, TestAction, TaskState>( "worker2", (action, context) => { if (action.type === "COMPLETED" && action.agentId === "worker2") { context.updateInternalState(state => ({ ...state, isWorking: true })); context.updateGlobalState(state => state + 1); } }, { taskQueue: [], isWorking: true } // Already working ); const simulation = createSimulation<number, TestAction>({ initialGlobalState: 0, agents: [taskDispatcher, worker1, worker2], shouldExit: ({ actionCount }) => actionCount >= 5, }); await simulation.dispatch({ type: "START" }); // Only worker1 should have been assigned a task (worker2 was already working) expect(simulation.getGlobalState()).toBe(1); expect(simulation.getAgentInternalState("worker1").isWorking).toBe(true); expect(simulation.getAgentInternalState("worker2").isWorking).toBe(true); // Task queue should be reduced by 1 (only worker1 got a task) expect(simulation.getAgentInternalState("dispatcher").taskQueue).toEqual(["task2", "task3"]); }); it("should provide consistent allAgents state across action processing", async () => { let allAgentsSnapshots: any[] = []; const observer = createAgent<number, TestAction>( "observer", (action, context) => { // Capture allAgents state at each action allAgentsSnapshots.push(JSON.parse(JSON.stringify(context.allAgents))); if (action.type === "START") { context.dispatch({ type: "INCREMENT", amount: 1 }); } } ); const counter = createAgent<number, TestAction, { count: number }>( "counter", (action, context) => { // Capture allAgents state when this agent processes actions if (action.type === "INCREMENT") { allAgentsSnapshots.push(JSON.parse(JSON.stringify(context.allAgents))); context.updateInternalState(state => ({ count: state.count + action.amount })); context.updateGlobalState(state => state + action.amount); } }, { count: 0 } ); const simulation = createSimulation<number, TestAction>({ initialGlobalState: 0, agents: [observer, counter], shouldExit: ({ actionCount }) => actionCount >= 2, }); await simulation.dispatch({ type: "START" }); // Should have captured 3 snapshots: // 1. Observer during START action // 2. Counter during START action // 3. Counter during INCREMENT action expect(allAgentsSnapshots).toHaveLength(3); // All snapshots should show counter with initial state (0) // because allAgents reflects state at the START of each action processing allAgentsSnapshots.forEach(snapshot => { expect(snapshot).toContainEqual({ id: "counter", internalState: { count: 0 } }); }); // But after actions are processed, the counter's state should be updated expect(simulation.getAgentInternalState("counter")).toEqual({ count: 1 }); }); }); }); }); }); });