import type { RouteAiProfile, RouteObservation } from "@mmapp/contracts/mcp-routing" export function fresh(timestamp: string | null, seconds: number, now: number): boolean { const measured = timestamp === null ? NaN : Date.parse(timestamp) return Number.isFinite(measured) && measured <= now && now - measured <= seconds * 1000 } export function rankRoutingCandidates(profile: RouteAiProfile, observations: RouteObservation[], now = Date.now()) { function leg(src: number, dst: number, iface: string, remoteIface?: string) { const possible = observations.flatMap(observation => { if (observation.family !== profile.family || observation.table !== profile.table) return [] if (observation.srcServerId === src && observation.dstServerId === dst && observation.srcInterface === iface && (!remoteIface || observation.dstInterface === remoteIface)) return [observation] if (observation.srcServerId === dst && observation.dstServerId === src && observation.dstInterface === iface && (!remoteIface || observation.srcInterface === remoteIface)) return [{ ...observation, dlMbps: observation.ulMbps, ulMbps: observation.dlMbps }] return [] }) const peerInterfaces = new Set(possible.map(value => value.srcServerId === src ? value.dstInterface : value.srcInterface)) if (!remoteIface && peerInterfaces.size > 1) return undefined // Collectors run independently. A new ping must not erase a still fresh // bandwidth sample for the same exact directional link/family/table. const latest = (field: "pingAt" | "bandwidthAt") => possible.filter(value => value[field] !== null || value.sourceVerified === false) .sort((a, b) => Date.parse(b.recordedAt ?? b[field]!) - Date.parse(a.recordedAt ?? a[field]!) || (b.recordedAt ?? b[field] ?? "").localeCompare(a.recordedAt ?? a[field] ?? ""))[0] const ping = latest("pingAt"), bandwidth = latest("bandwidthAt"), base = ping ?? bandwidth return base ? { ...base, pingMs: ping?.contextVerified ? ping.pingMs : null, lossPct: ping?.contextVerified ? ping.lossPct : null, pingAt: ping?.pingAt ?? null, dlMbps: bandwidth?.contextVerified ? bandwidth.dlMbps : null, ulMbps: bandwidth?.contextVerified ? bandwidth.ulMbps : null, bandwidthAt: bandwidth?.bandwidthAt ?? null, sampleIds: [...new Set([ping?.id, bandwidth?.id].filter((id): id is string => !!id))] } : undefined } return profile.candidates.map(candidate => { const first = leg(profile.serverId, candidate.jumpHostId, candidate.wanInterface, candidate.wanRemoteInterface) const second = leg(candidate.jumpHostId, candidate.exitNodeId, candidate.downstreamInterface, candidate.downstreamRemoteInterface) const reasons: string[] = [] if (profile.excludedCandidateIds.includes(candidate.id)) reasons.push("Путь исключён") if (profile.pinnedCandidateId && profile.pinnedCandidateId !== candidate.id) reasons.push("Закреплён другой путь") const legs = [first, second] if (legs.some(value => !value)) reasons.push("Нет подтверждённых измерений обоих участков") const pingKnown = legs.every(value => value?.pingMs !== null && value?.pingMs !== undefined && fresh(value.pingAt, profile.pingFreshnessSec, now)) const lossKnown = legs.every(value => value?.lossPct !== null && value?.lossPct !== undefined && fresh(value.pingAt, profile.pingFreshnessSec, now)) const speedKnown = legs.every(value => value?.dlMbps !== null && value?.dlMbps !== undefined && value.ulMbps !== null && fresh(value.bandwidthAt, profile.bandwidthFreshnessSec, now)) const pingMs = pingKnown ? first!.pingMs! + second!.pingMs! : null const lossPct = lossKnown ? 100 * (1 - (1 - first!.lossPct! / 100) * (1 - second!.lossPct! / 100)) : null const dlMbps = speedKnown ? Math.min(first!.dlMbps!, second!.dlMbps!) : null const ulMbps = speedKnown ? Math.min(first!.ulMbps!, second!.ulMbps!) : null if ((profile.weights.ping > 0 || profile.maxPingMs !== null) && pingMs === null) reasons.push("RTT неизвестен или устарел") if ((profile.weights.loss > 0 || profile.maxLossPct !== null) && lossPct === null) reasons.push("Потери неизвестны или устарели") if ((profile.weights.speed > 0 || profile.minBandwidthMbps !== null) && dlMbps === null) reasons.push("Bandwidth неизвестен или устарел") if (pingMs !== null && profile.maxPingMs !== null && pingMs > profile.maxPingMs) reasons.push("Превышен RTT") if (lossPct !== null && profile.maxLossPct !== null && lossPct > profile.maxLossPct) reasons.push("Превышены потери") if (dlMbps !== null && ulMbps !== null && profile.minBandwidthMbps !== null && Math.min(dlMbps, ulMbps) < profile.minBandwidthMbps) reasons.push("Недостаточна пропускная способность") const score = reasons.length ? null : Math.round((profile.weights.ping * Math.max(0, 100 - (pingMs ?? 0) * 0.6) + profile.weights.speed * Math.min(100, ((dlMbps ?? 0) + (ulMbps ?? 0)) / 18) + profile.weights.loss * (100 - (lossPct ?? 0))) / 100) return { candidate, eligible: !reasons.length, score, reasons, pingMs, lossPct, dlMbps, ulMbps, observationIds: legs.flatMap(value => value?.sampleIds ?? []), observedPath: false as const } }).sort((a, b) => (b.score ?? -1) - (a.score ?? -1) || a.candidate.id.localeCompare(b.candidate.id)) } export interface PathHealth { id: string; candidateId: string; status: "passed" | "failed" | "unknown"; observedAt: string } export interface ControllerState { pendingCandidateId?: string; consecutive?: number; observationKey?: string; lastSwitchAt?: string; switches?: string[]; operationId?: string; pausedReason?: string; failureCount?: number; failureKey?: string; failureObservedAt?: string; failingCandidateId?: string } const pathHealthFreshnessSec = 90 export function decideRoutingSwitch(profile: RouteAiProfile, state: ControllerState, ranking: ReturnType, currentCandidateId: string | null, now = Date.now(), health: PathHealth[] = [], chargedSwitchesPerHour?: number) { const next: ControllerState = { ...state, switches: (state.switches ?? []).filter(at => now - Date.parse(at) < 3600000) } if (chargedSwitchesPerHour !== undefined && (!Number.isSafeInteger(chargedSwitchesPerHour) || chargedSwitchesPerHour < next.switches!.length || chargedSwitchesPerHour > 4096)) throw Error("Invalid common routing charge count") const switchCount = chargedSwitchesPerHour ?? next.switches!.length const best = ranking.find(candidate => candidate.eligible), current = ranking.find(candidate => candidate.candidate.id === currentCandidateId) const currentHealth = health.find(value => value.candidateId === currentCandidateId && fresh(value.observedAt, pathHealthFreshnessSec, now)) if (currentHealth?.status === "failed" && currentCandidateId && !state.pausedReason) { const observed = Date.parse(currentHealth.observedAt), previous = Date.parse(state.failureObservedAt ?? "") const sameCandidate = state.failingCandidateId === currentCandidateId if (state.failureKey === currentHealth.id) { if (!sameCandidate || observed !== previous) return { state: next, switchTo: null, reason: "Ожидание новой проверки отказа" } } else { if (observed <= previous) return { state: next, switchTo: null, reason: "Ожидание новой проверки отказа" } const continues = sameCandidate && fresh(state.failureObservedAt ?? null, pathHealthFreshnessSec, observed) next.failureCount = continues ? (state.failureCount ?? 0) + 1 : 1 next.failureKey = currentHealth.id; next.failureObservedAt = currentHealth.observedAt; next.failingCandidateId = currentCandidateId } const reserve = ranking.find(value => value.eligible && value.candidate.id !== currentCandidateId && health.some(check => check.candidateId === value.candidate.id && check.status === "passed" && fresh(check.observedAt, pathHealthFreshnessSec, now))) if ((next.failureCount ?? 0) < 3 || !reserve) return { state: next, switchTo: null, reason: "Failover ожидает три отдельных отказа и подтверждённый резерв" } if (switchCount >= profile.maxSwitchesPerHour) return { state: { ...next, pausedReason: "Лимит переключений" }, switchTo: null, reason: "Исчерпан часовой бюджет, включая failover" } if (next.lastSwitchAt && now - Date.parse(next.lastSwitchAt) < profile.cooldownSec * 1000) return { state: next, switchTo: null, reason: "Failover ожидает окончания cooldown" } return { state: { ...next, failureCount: 0 }, switchTo: reserve.candidate.id, reason: "Failover: три подтверждённых отказа и проверенный резерв" } } next.failureCount = 0; next.failureKey = undefined; next.failureObservedAt = undefined; next.failingCandidateId = undefined if (state.pausedReason || !currentCandidateId || !best || best.candidate.id === currentCandidateId) return { state: { ...next, pendingCandidateId: undefined, consecutive: 0 }, switchTo: null, reason: "Нет подтверждённого улучшения или текущего пути" } if (!current?.eligible || current.score === null) return { state: next, switchTo: null, reason: "Состояние текущего пути неизвестно; failover требует отдельных подтверждённых проверок отказа" } const gain = best.score! - current.score if (gain < Math.max(0, profile.switchThreshold - profile.hysteresis)) return { state: { ...next, consecutive: 0, pendingCandidateId: undefined }, switchTo: null, reason: "Сработал гистерезис" } if (gain < profile.switchThreshold) return { state: next, switchTo: null, reason: "Порог улучшения не достигнут" } const observationKey = [...best.observationIds, ...current.observationIds].sort().join(":") if (next.pendingCandidateId !== undefined && next.pendingCandidateId !== best.candidate.id) { next.pendingCandidateId = best.candidate.id next.consecutive = 0 } if (observationKey !== next.observationKey) { next.consecutive = next.pendingCandidateId === best.candidate.id ? (next.consecutive ?? 0) + 1 : 1 next.pendingCandidateId = best.candidate.id; next.observationKey = observationKey } if (next.pendingCandidateId !== best.candidate.id || (next.consecutive ?? 0) < profile.confirmationSamples) return { state: next, switchTo: null, reason: "Ожидание новых измерений" } if (next.lastSwitchAt && now - Date.parse(next.lastSwitchAt) < profile.cooldownSec * 1000) return { state: next, switchTo: null, reason: "Cooldown" } if (switchCount >= profile.maxSwitchesPerHour) return { state: { ...next, pausedReason: "Лимит переключений" }, switchTo: null, reason: "Исчерпан часовой бюджет переключений" } return { state: next, switchTo: best.candidate.id, reason: "Улучшение подтверждено свежими наблюдениями" } }