import { adjustPacingFactor, calculateCallsToOriginate, defaultStats, estimateAgentsFreeingSoon, updateEwma, type CampaignStats, type PacingLimits, } from './predictive-engine'; // Harness de simulação em tempo discreto (1 tick = 1 segundo simulado), // determinístico o suficiente para testes de CI: usa um gerador // pseudoaleatório com seed fixa em vez de Math.random(), para que os // invariantes (CPS, concorrência) sejam sempre verificáveis mesmo variando // a "sorte" das chamadas simuladas (agente.md seção 66). export interface SimulationConfig { agentCount: number; maxCps: number; answerRate: number; answerDelaySeconds: number; talkTimeSeconds: number; targetAbandonRate: number; maxWaitForAgentSeconds: number; durationSeconds: number; maxConcurrentCalls: number; seed?: number; } export interface SimulationTick { second: number; originated: number; pacingFactor: number; availableAgents: number; outstanding: number; abandonedThisTick: number; } export interface SimulationResult { ticks: SimulationTick[]; maxCallsInAnySecondWindow: number; maxObservedConcurrency: number; totalOriginated: number; totalAnswered: number; totalAbandoned: number; finalPacingFactor: number; } // PRNG determinístico (mulberry32) — nada de Math.random() aqui, para que // o teste seja 100% reprodutível. function mulberry32(seed: number) { let a = seed; return () => { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } interface SimCall { originatedAt: number; state: 'DIALING' | 'RINGING' | 'QUEUED' | 'CONNECTED'; answerAt?: number; connectAt?: number; endAt?: number; } export function runSimulation(config: SimulationConfig): SimulationResult { const rng = mulberry32(config.seed ?? 42); const limits: PacingLimits = { pacingMin: 0.5, pacingMax: 3, targetAbandonRate: config.targetAbandonRate, maxConcurrentCalls: config.maxConcurrentCalls, }; let stats: CampaignStats = defaultStats(1); const agentBusyUntil: number[] = new Array(config.agentCount).fill(-1); const calls: SimCall[] = []; const ticks: SimulationTick[] = []; const originatedPerSecondWindow: number[] = new Array(config.durationSeconds + 1).fill(0); let totalOriginated = 0; let totalAnswered = 0; let totalAbandoned = 0; let maxObservedConcurrency = 0; for (let second = 0; second < config.durationSeconds; second++) { // 1. Libera agentes cujo atendimento acabou. for (let i = 0; i < agentBusyUntil.length; i++) { if (agentBusyUntil[i] !== -1 && agentBusyUntil[i] <= second) agentBusyUntil[i] = -1; } const availableAgents = agentBusyUntil.filter((busyUntil) => busyUntil === -1).length; const inCallStartedAt = agentBusyUntil .filter((busyUntil) => busyUntil !== -1) .map((busyUntil) => new Date((busyUntil - config.talkTimeSeconds) * 1000)); const agentsLikelyToFreeSoon = estimateAgentsFreeingSoon(inCallStartedAt, stats.avgTalkTimeSeconds, 15, new Date(second * 1000)); // 2. Avança o estado das chamadas em voo (discando -> tocando -> atendida/falhou). let abandonedThisTick = 0; for (const call of calls) { if (call.state === 'DIALING' && second - call.originatedAt >= 1) { call.state = 'RINGING'; } else if (call.state === 'RINGING' && call.answerAt === second) { call.state = 'QUEUED'; } else if (call.state === 'QUEUED' && call.connectAt === undefined) { const freeAgentIndex = agentBusyUntil.findIndex((busyUntil) => busyUntil === -1); if (freeAgentIndex !== -1) { agentBusyUntil[freeAgentIndex] = second + config.talkTimeSeconds; call.connectAt = second; call.state = 'CONNECTED'; } else if (second - (call.answerAt ?? second) >= config.maxWaitForAgentSeconds) { call.endAt = second; abandonedThisTick++; totalAbandoned++; } } else if (call.state === 'CONNECTED' && call.connectAt !== undefined && second - call.connectAt >= config.talkTimeSeconds) { call.endAt = second; } } // Remove chamadas finalizadas (encerradas ou abandonadas) da lista ativa. for (let i = calls.length - 1; i >= 0; i--) { if (calls[i].endAt !== undefined) calls.splice(i, 1); } const dialingCalls = calls.filter((c) => c.state === 'DIALING').length; const ringingCalls = calls.filter((c) => c.state === 'RINGING').length; const connectedWaitingAgent = calls.filter((c) => c.state === 'QUEUED').length; const agentConnectedCalls = calls.filter((c) => c.state === 'CONNECTED').length; const currentConcurrency = calls.length; maxObservedConcurrency = Math.max(maxObservedConcurrency, currentConcurrency); // 3. Ajusta pacing e decide quantas chamadas originar. // Atividade = discagem em curso agora (não conta chamadas já conectadas // há muito tempo com um agente — essas são resíduo histórico do último // ciclo, não evidência de que vale a pena subir o pacing agora). const hasActivity = dialingCalls + ringingCalls + connectedWaitingAgent > 0; stats.pacingFactor = adjustPacingFactor(stats, limits, hasActivity); const desired = calculateCallsToOriginate( stats, { availableAgents, agentsLikelyToFreeSoon, dialingCalls, ringingCalls, connectedWaitingAgent, agentConnectedCalls }, limits, ); // 4. Aplica o teto de CPS (token bucket simplificado: no máximo maxCps // por segundo simulado — o token bucket real do worker usa Redis, mas a // garantia matemática é idêntica). const originated = Math.min(desired, config.maxCps); originatedPerSecondWindow[second] = originated; totalOriginated += originated; for (let i = 0; i < originated; i++) { const willAnswer = rng() < config.answerRate; calls.push({ originatedAt: second, state: 'DIALING', answerAt: willAnswer ? second + config.answerDelaySeconds : undefined, }); if (willAnswer) totalAnswered++; } // Chamadas que não vão atender simplesmente "desaparecem" após o // answerDelay (busy/no-answer) — não ocupam concorrência depois disso. for (let i = calls.length - 1; i >= 0; i--) { if (calls[i].state === 'RINGING' && calls[i].answerAt === undefined) calls.splice(i, 1); } for (let i = calls.length - 1; i >= 0; i--) { const c = calls[i]; if ((c.state === 'DIALING' || c.state === 'RINGING') && c.answerAt === undefined && second - c.originatedAt >= 3) { calls.splice(i, 1); } } // 5. Atualiza EWMA de abandono para a próxima decisão de pacing. const recentAbandonSample = connectedWaitingAgent + abandonedThisTick > 0 ? abandonedThisTick / Math.max(1, connectedWaitingAgent + abandonedThisTick) : 0; // Alpha baixo aqui: abandono é um sinal ruidoso segundo a segundo (poucas // amostras por tick), suavizar mais evita reagir a ruído de curto prazo. stats = { ...stats, abandonRate: updateEwma(stats.abandonRate, recentAbandonSample, 0.05) }; ticks.push({ second, originated, pacingFactor: stats.pacingFactor, availableAgents, outstanding: dialingCalls + ringingCalls, abandonedThisTick, }); } // Maior quantidade originada em qualquer janela deslizante de 1s (aqui // cada "tick" já É uma janela de 1s, então é só o máximo do array). const maxCallsInAnySecondWindow = Math.max(...originatedPerSecondWindow); return { ticks, maxCallsInAnySecondWindow, maxObservedConcurrency, totalOriginated, totalAnswered, totalAbandoned, finalPacingFactor: stats.pacingFactor, }; }