diff --git a/src/config/deprecated/server.json b/src/config/deprecated/server.json index d713d1c8e..d14d05546 100644 --- a/src/config/deprecated/server.json +++ b/src/config/deprecated/server.json @@ -39,6 +39,10 @@ "maxJoinedPerCycle": 1, "maxSyncingPerCycle": 5, "maxRotatedPerCycle": 1, + "maxProblematicNodeRemovalsPerCycle": 1, + "problematicNodeConsecutiveRefuteThreshold": 6, + "problematicNodeRefutePercentageThreshold": 0.1, + "problematicNodeHistoryLength": 100, "firstCycleJoin": 10, "maxPercentOfDelta": 40, "minScaleReqsNeeded": 5, diff --git a/src/config/server.ts b/src/config/server.ts index 0781d8a03..9b87843dd 100644 --- a/src/config/server.ts +++ b/src/config/server.ts @@ -69,6 +69,12 @@ const SERVER_CONFIG: StrictServerConfiguration = { maxSyncTimeFloor: 1200, maxNodeForSyncTime: 9, maxRotatedPerCycle: 1, + enableProblematicNodeRemoval: true, + enableProblematicNodeRemovalOnCycle: 10, + maxProblematicNodeRemovalsPerCycle: 1, + problematicNodeConsecutiveRefuteThreshold: 6, + problematicNodeRefutePercentageThreshold: 0.1, + problematicNodeHistoryLength: 100, firstCycleJoin: 10, maxPercentOfDelta: 40, minScaleReqsNeeded: 5, diff --git a/src/debug/debug.ts b/src/debug/debug.ts index 0910dbaac..a4f9b70fe 100644 --- a/src/debug/debug.ts +++ b/src/debug/debug.ts @@ -6,6 +6,10 @@ import Trie from 'trie-prefix-tree' import { isDebugModeMiddleware, isDebugModeMiddlewareMedium } from '../network/debugMiddleware' import { nestedCountersInstance } from '../utils/nestedCounters' import { logFlags } from '../logger' +import { currentCycle } from '../p2p/CycleCreator' +import * as ProblemNodeHandler from '../p2p/ProblemNodeHandler' + +import { nodes, NodeWithRefuteCycles } from '../p2p/NodeList' const tar = require('tar-fs') const fs = require('fs') @@ -133,6 +137,30 @@ class Debug { } return res.json({ success: true }) }) + this.network.registerExternalGet('debug_problemNodeTrackerDump', isDebugModeMiddleware, (req, res) => { + try { + const dump: Record = {} + + // Collect data for all nodes that have any refute history + for (const [nodeId, node] of nodes as Map) { + if (node.refuteCycles?.size > 0) { + const refuteCycles = Array.from(node.refuteCycles).sort((a, b) => a - b) + dump[nodeId] = { + refuteCycles, + stats: { + refutePercentage: ProblemNodeHandler.getRefutePercentage(node.refuteCycles, currentCycle), + consecutiveRefutes: ProblemNodeHandler.getConsecutiveRefutes(refuteCycles, currentCycle), + isProblematic: ProblemNodeHandler.isNodeProblematic(node, currentCycle) + } + } + } + } + + return res.json({ success: true, data: { nodeHistories: dump } }) + } catch (e) { + return res.json({ success: false, error: e.message }) + } + }) } } diff --git a/src/p2p/ModeSystemFuncs.ts b/src/p2p/ModeSystemFuncs.ts index b54d87299..2fe572665 100644 --- a/src/p2p/ModeSystemFuncs.ts +++ b/src/p2p/ModeSystemFuncs.ts @@ -10,6 +10,7 @@ import * as CycleCreator from './CycleCreator' import * as CycleChain from './CycleChain' import { logFlags } from '../logger' import { Utils } from '@shardus/types' +import { getProblematicNodes } from './ProblemNodeHandler' interface ToAcceptResult { add: number @@ -239,7 +240,16 @@ export function calculateToAcceptV2(prevRecord: P2P.CycleCreatorTypes.CycleRecor return { add, remove } } -// need to think about and maybe ask Omar about using prev record for determining mode, could use next record +const getApoptosizedNodes = (txs: P2P.RotationTypes.Txs & P2P.ApoptosisTypes.Txs): string[] => { + const apoptosizedNodesList = [] + for (const request of txs.apoptosis) { + const node = NodeList.nodes.get(request.id) + if (node) { + apoptosizedNodesList.push(node.id) + } + } + return apoptosizedNodesList +} /** Returns the number of expired nodes and the list of removed nodes using calculateToAcceptV2 */ export function getExpiredRemovedV2( @@ -374,12 +384,105 @@ export function getExpiredRemovedV2( return { expired, removed } } -/** Returns a linearly interpolated value between `amountToShrink` and the same - * multiplied by a `scaleFactor`. The result depends on the - * `scaleInfluenceForShrink` */ -function getScaledAmountToShrink(): number { - const nonScaledAmount = config.p2p.amountToShrink - const scaledAmount = config.p2p.amountToShrink * CycleCreator.scaleFactor - const scaleInfluence = config.p2p.scaleInfluenceForShrink - return Math.floor(lerp(nonScaledAmount, scaledAmount, scaleInfluence)) -} +/** Returns the number of expired nodes and the list of removed nodes using calculateToAcceptV2 + * this list includes problematic nodes + expired nodes. +*/ +export function getExpiredRemovedV3( + prevRecord: P2P.CycleCreatorTypes.CycleRecord, + lastLoggedCycle: number, + txs: P2P.RotationTypes.Txs & P2P.ApoptosisTypes.Txs, + info: (...msg: string[]) => void +): { problematic: number; expired: number; removed: string[] } { + + // clear state from last run + NodeList.potentiallyRemoved.clear() + + // Don't expire/remove any if nodeExpiryAge is negative + if (config.p2p.nodeExpiryAge < 0) return { problematic: 0, expired: 0, removed: [] } + + const active = NodeList.activeByIdOrder.length + const start = prevRecord.start + let expireTimestamp = start - config.p2p.nodeExpiryAge + if (expireTimestamp < 0) expireTimestamp = 0 + + // calculate the target number of nodes + const { add, remove } = calculateToAcceptV2(prevRecord) + nestedCountersInstance.countEvent( + 'p2p', + `results of getExpiredRemovedV2.calculateToAcceptV2: add: ${add}, remove: ${remove}` + ) + + // get list of nodes that have been requested to be removed + const apoptosizedNodesList = getApoptosizedNodes(txs) + const numApoptosizedRemovals = apoptosizedNodesList.length + + // Get the set of problematic nodes + const problematicWithApoptosizedNodes = getProblematicNodes(prevRecord) + // filter out apoptosized nodes from the problematic nodes + const problematicNodes = problematicWithApoptosizedNodes.filter(id => !apoptosizedNodesList.includes(id)) + const numProblematicRemovals = Math.min( + problematicNodes.length, + config.p2p.maxProblematicNodeRemovalsPerCycle || 1, + ) + + // get list of expired nodes + const expirationTimeThreshold = Math.max(start - config.p2p.nodeExpiryAge, 0) + // expired, non-apoptosized, non-syncing nodes + const expiredNodes = NodeList.byJoinOrder.filter(node => node.activeTimestamp <= expirationTimeThreshold && node.status !== 'syncing' && !apoptosizedNodesList.includes(node.id)).map(node => node.id) + const numExpiredNodes = expiredNodes.length + + // we can remove `remove` nodes, but we *must* remove the number of apoptosized nodes, + // as well as the number of problematic nodes (determined by config.p2p.maxProblematicNodeRemovalsPerCycle, if any) + // the remainder is the number of expired nodes we can remove this cycle + const numExpiredRemovals = remove - numApoptosizedRemovals - numProblematicRemovals + + const cycle = CycleChain.newest.counter + + if (cycle > lastLoggedCycle && remove > 0) { + lastLoggedCycle = cycle + info( + 'scale down dump:' + + Utils.safeStringify({ + cycle, + scaleFactor: CycleCreator.scaleFactor, + desired: prevRecord.desired, + active, + maxRemove: remove, + expired: numExpiredNodes, + }) + ) + } + + nestedCountersInstance.countEvent( + 'p2p', + `results of getExpiredRemovedV2: numApoptosizedRemovals: ${numApoptosizedRemovals}, numProblematicRemovals: ${numProblematicRemovals}, numExpiredRemovals: ${numExpiredRemovals}, removed: ${remove}` + ) + + // array that hold all the nodes to remove + // maintains the sort order provided in activeByIdOrder + const toRemoveUnsorted = problematicNodes + .slice(0, numProblematicRemovals) + .concat( + expiredNodes.slice(0, numExpiredRemovals) + ) + + // maintains the sort order provided in activeByIdOrder + const toRemove = NodeList.byJoinOrder + .filter(node => toRemoveUnsorted.includes(node.id)) + + + + + const removed = []; + // Process nodes for removal + for (const node of toRemove) { + nestedCountersInstance.countEvent( + 'p2p', + `getExpiredRemovedV2: adding node to removed: ${node.id}` + ) + NodeList.potentiallyRemoved.add(node.id) + insertSorted(removed, node.id) + } + + return { problematic: problematicNodes.length, expired: numExpiredNodes, removed } +} \ No newline at end of file diff --git a/src/p2p/NodeList.ts b/src/p2p/NodeList.ts index 0f5b3285f..652da1438 100644 --- a/src/p2p/NodeList.ts +++ b/src/p2p/NodeList.ts @@ -305,13 +305,33 @@ export function removeNodes( for (const id of ids) removeNode(id, raiseEvents, cycle) } +// create shorthand type for node with refuteCycles +export type NodeWithRefuteCycles = P2P.NodeListTypes.Node & { refuteCycles: Set } + export function updateNode( update: P2P.NodeListTypes.Update, raiseEvents: boolean, cycle: P2P.CycleCreatorTypes.CycleRecord | null ) { - const node = nodes.get(update.id) + const node = nodes.get(update.id) as NodeWithRefuteCycles if (node) { + // Initialize refuteCycles if it doesn't exist + if (!node.refuteCycles) { + node.refuteCycles = new Set(); + } + + if (config.p2p.enableProblematicNodeRemoval && cycle.counter >= config.p2p.enableProblematicNodeRemovalOnCycle) { + // Track refutes if this update is from a cycle record + if (cycle && cycle.refuted?.includes(node.id)) { + node.refuteCycles.add(cycle.counter); + } + + // Clean up old refutes using sliding window + const windowStart = Math.max(1, cycle.counter - config.p2p.problematicNodeHistoryLength); + const oldRefutes = Array.from(node.refuteCycles).filter(c => c < windowStart); + oldRefutes.forEach(c => node.refuteCycles.delete(c)); + } + // Update node properties for (const key of Object.keys(update)) { node[key] = update[key] diff --git a/src/p2p/ProblemNodeHandler.ts b/src/p2p/ProblemNodeHandler.ts new file mode 100644 index 000000000..6d101cced --- /dev/null +++ b/src/p2p/ProblemNodeHandler.ts @@ -0,0 +1,58 @@ +import { P2P } from '@shardus/types' +import * as NodeList from './NodeList' +import { config } from './Context' +import { NodeWithRefuteCycles } from './NodeList'; + +export function isNodeProblematic(node: NodeWithRefuteCycles, currentCycle: number): boolean { + if (!node.refuteCycles) return false; + + // Check consecutive refutes + const refuteCyclesArray = Array.from(node.refuteCycles as Set).sort((a: number, b: number) => a - b); + const consecutiveRefutes = getConsecutiveRefutes(refuteCyclesArray, currentCycle); + if (consecutiveRefutes >= config.p2p.problematicNodeConsecutiveRefuteThreshold) { + return true; + } + + // Check refute percentage in recent history + const refutePercentage = getRefutePercentage(node.refuteCycles, currentCycle); + if (refutePercentage >= config.p2p.problematicNodeRefutePercentageThreshold) { + return true; + } + + return false; +} + +export function getConsecutiveRefutes(refuteCycles: number[], currentCycle: number): number { + return refuteCycles[refuteCycles.length - 1] !== currentCycle ? 0 : + refuteCycles.filter((cycle, index) => { + return index === 0 || cycle === refuteCycles[index - 1] + 1; + }).length; +} + +export function getRefutePercentage(refuteCycles: Set, currentCycle: number): number { + const windowStart = Math.max(1, currentCycle - config.p2p.problematicNodeHistoryLength + 1); + const windowSize = Math.min(config.p2p.problematicNodeHistoryLength, currentCycle); + const recentRefutes = Array.from(refuteCycles) + .filter(cycle => cycle >= windowStart && cycle <= currentCycle).length; + + return recentRefutes / windowSize; +} + +export function getProblematicNodes(prevRecord: P2P.CycleCreatorTypes.CycleRecord): string[] { + const problematicNodes = new Set(); + + for (const node of NodeList.activeByIdOrder) { + if (isNodeProblematic(node as NodeWithRefuteCycles, prevRecord.counter)) { + problematicNodes.add(node.id); + } + } + + // Sort by refute percentage + return Array.from(problematicNodes).sort((a, b) => { + const nodeA = NodeList.nodes.get(a) as NodeWithRefuteCycles; + const nodeB = NodeList.nodes.get(b) as NodeWithRefuteCycles; + const percentageA = getRefutePercentage(nodeA.refuteCycles, prevRecord.counter); + const percentageB = getRefutePercentage(nodeB.refuteCycles, prevRecord.counter); + return percentageB - percentageA; + }); +} \ No newline at end of file diff --git a/src/p2p/Rotation.ts b/src/p2p/Rotation.ts index b0b230511..5c6bcf706 100644 --- a/src/p2p/Rotation.ts +++ b/src/p2p/Rotation.ts @@ -9,7 +9,7 @@ import * as CycleCreator from './CycleCreator' import * as CycleChain from './CycleChain' import { nestedCountersInstance } from '../utils/nestedCounters' import { currentCycle } from './CycleCreator' -import { getExpiredRemovedV2 } from './ModeSystemFuncs' +import { getExpiredRemovedV2, getExpiredRemovedV3 } from './ModeSystemFuncs' import { logFlags } from '../logger' import { Utils } from '@shardus/types' @@ -81,14 +81,27 @@ export function updateRecord( nestedCountersInstance.countEvent('p2p', `results of getExpiredRemoved: expired: ${expired} removed: ${removed.length}`, 1) if (logFlags && logFlags.verbose) console.log(`results of getExpiredRemoved: expired: ${expired} removed: ${removed.length} array: ${removed}`) } - - // Allow the autoscale module to set this value - const { expired, removed } = getExpiredRemovedV2(prev, lastLoggedCycle, txs, info) - nestedCountersInstance.countEvent('p2p', `results of getExpiredRemovedV2: expired: ${expired} removed: ${removed.length}`, 1) - if (logFlags && logFlags.verbose) console.log(`results of getExpiredRemovedV2: expired: ${expired} removed: ${removed.length} array: ${removed}`) - - record.expired = expired - record.removed = removed // already sorted + + // we only want to use the problematic node removal logic if we are past the enableProblematicNodeRemovalOnCycle and have a full history of refutes + // note: we may want to wait an additional config.p2p.problematicNodeHistoryLength cycles before we start removing problematic nodes + // this would give us a full history of refutes before we start removing problematic nodes + if (!config.p2p.enableProblematicNodeRemoval || currentCycle < config.p2p.enableProblematicNodeRemovalOnCycle) { + // Allow the autoscale module to set this value + const { expired, removed } = getExpiredRemovedV2(prev, lastLoggedCycle, txs, info) + nestedCountersInstance.countEvent('p2p', `results of getExpiredRemovedV2: expired: ${expired} removed: ${removed.length}`, 1) + if (logFlags && logFlags.verbose) console.log(`results of getExpiredRemovedV2: expired: ${expired} removed: ${removed.length} array: ${removed}`) + + record.expired = expired + record.removed = removed // already sorted + } else { + const { expired, removed, problematic } = getExpiredRemovedV3(prev, lastLoggedCycle, txs, info) + nestedCountersInstance.countEvent('p2p', `results of getExpiredRemovedV2: expired: ${expired} removed: ${removed.length} problematic: ${problematic}`, 1) + if (logFlags && logFlags.verbose) console.log(`results of getExpiredRemovedV2: expired: ${expired} removed: ${removed.length} array: ${removed} problematic: ${problematic}`) + + // record.problematic = problematic // may want to write this to cycle record for + record.expired = expired + record.removed = removed // already sorted + } } export function parseRecord(record: P2P.CycleCreatorTypes.CycleRecord): P2P.CycleParserTypes.Change { diff --git a/src/p2p/debugBehavior.ts b/src/p2p/debugBehavior.ts new file mode 100644 index 000000000..aecab5afe --- /dev/null +++ b/src/p2p/debugBehavior.ts @@ -0,0 +1,53 @@ +// Track oscillation state +let isDelaying = false; +let lastToggleTime = 0; +const OSCILLATION_PERIOD = 30000; // 30 seconds +const BAD_BEHAVIOR_DURATION = 15000; // 15 seconds +const RESPONSE_DELAY = 8000; // 8 second delay during bad behavior + +// Flag to enable/disable debug behavior +let debugOscillatingBehavior = false; + +export function setDebugOscillatingBehavior(enabled: boolean): void { + debugOscillatingBehavior = enabled; + console.log(`[Debug Behavior] ${enabled ? 'Enabled' : 'Disabled'} oscillating behavior`); +} + +export function shouldDelayResponse(): boolean { + if (!debugOscillatingBehavior) { + return false; + } + + const now = Date.now(); + + // Toggle behavior state every OSCILLATION_PERIOD + if (now - lastToggleTime > OSCILLATION_PERIOD) { + isDelaying = !isDelaying; + lastToggleTime = now; + console.log(`[Debug Behavior] Switching to ${isDelaying ? 'bad' : 'good'} behavior mode`); + } + + // If we're in bad behavior mode and haven't exceeded the duration + if (isDelaying && now - lastToggleTime < BAD_BEHAVIOR_DURATION) { + return true; + } + + return false; +} + +export function getResponseDelay(): number { + return shouldDelayResponse() ? RESPONSE_DELAY : 0; +} + +// For monitoring purposes +export function getCurrentBehaviorState(): string { + if (!debugOscillatingBehavior) { + return 'Normal (Debug Mode Disabled)'; + } + return isDelaying ? 'Bad (Delaying Responses)' : 'Good (Normal Operation)'; +} + +// For use in isDownCheck +export function shouldReportDown(): boolean { + return shouldDelayResponse() && Math.random() < 0.2; // 20% chance to report as down during bad behavior +} \ No newline at end of file diff --git a/src/shardus/shardus-types.ts b/src/shardus/shardus-types.ts index 44f18efa4..cf174630f 100644 --- a/src/shardus/shardus-types.ts +++ b/src/shardus/shardus-types.ts @@ -751,6 +751,23 @@ export interface ServerConfiguration { maxNodeForSyncTime?: number /** The maxRotatedPerCycle parameter is an Integer specifying the maximum number of nodes that can that can be rotated out of the network each cycle. */ maxRotatedPerCycle?: number + + /** Problematic Node configurations */ + /** enable problematic node removal */ + enableProblematicNodeRemoval?: boolean + /** enable problematic node removal on a specific cycle. This is to allow the network to stabilize before removing problematic nodes. + * enableProblematicNodeRemoval must be true for this to take effect*/ + enableProblematicNodeRemovalOnCycle?: number + /** The maxProblematicNodeRemovalsPerCycle parameter is an Integer specifying the maximum number of problematic nodes that can be removed from the network each cycle. */ + maxProblematicNodeRemovalsPerCycle?: number + /** The problematicNodeConsecutiveRefuteThreshold parameter is an Integer specifying the number of consecutive refutes a node must have before it is considered problematic. */ + problematicNodeConsecutiveRefuteThreshold?: number + /** The problematicNodeRefutePercentageThreshold parameter is a 0-1 fraction specifying the percentage of refutes a node must have before it is considered problematic. */ + problematicNodeRefutePercentageThreshold?: number + /** The problematicNodeHistoryLength parameter is an Integer specifying the number of cycles to consider when determining if a node is problematic. */ + problematicNodeHistoryLength?: number + /** end of problematic node configurations */ + /** A fixed boost to let more nodes in when we have just the one seed node in the network */ firstCycleJoin?: number diff --git a/test/unit/src/p2p/ProblemNodeHandler.test.ts b/test/unit/src/p2p/ProblemNodeHandler.test.ts new file mode 100644 index 000000000..435ad3bde --- /dev/null +++ b/test/unit/src/p2p/ProblemNodeHandler.test.ts @@ -0,0 +1,233 @@ +import { isNodeProblematic, getConsecutiveRefutes, getRefutePercentage, getProblematicNodes } from '../../../../src/p2p/ProblemNodeHandler' +import { P2P } from '@shardus/types' +import { NodeWithRefuteCycles } from '../../../../src/p2p/NodeList' +import * as NodeList from '../../../../src/p2p/NodeList' +import * as Context from '../../../../src/p2p/Context' + +// Mock NodeList module +jest.mock('../../../../src/p2p/NodeList', () => ({ + activeByIdOrder: [], + nodes: new Map(), +})) + +// Mock Context module +jest.mock('../../../../src/p2p/Context', () => ({ + config: { + p2p: { + problematicNodeConsecutiveRefuteThreshold: 3, + problematicNodeRefutePercentageThreshold: 0.1, + problematicNodeHistoryLength: 100, + } + } +})) + +describe('ProblemNodeHandler', () => { + let mockNode: NodeWithRefuteCycles + + beforeEach(() => { + // Reset config values before each test + (Context.config as any).p2p.problematicNodeConsecutiveRefuteThreshold = 3; + (Context.config as any).p2p.problematicNodeRefutePercentageThreshold = 0.1; + (Context.config as any).p2p.problematicNodeHistoryLength = 100; + + // Create a mock node for testing + mockNode = { + id: 'node1', + refuteCycles: new Set(), + } as NodeWithRefuteCycles + + // Clear NodeList mocks before each test + NodeList.activeByIdOrder.length = 0 + NodeList.nodes.clear() + }) + + describe('isNodeProblematic', () => { + it('should return false if node has no refuteCycles', () => { + (mockNode.refuteCycles as any) = undefined + expect(isNodeProblematic(mockNode, 1)).toBe(false) + }) + + it('should return true if node has consecutive refutes above threshold', () => { + mockNode.refuteCycles = new Set([98, 99, 100]) + expect(isNodeProblematic(mockNode, 100)).toBe(true) + }) + + it('should return false if consecutive refutes are below threshold', () => { + mockNode.refuteCycles = new Set([97, 98, 99]) + expect(isNodeProblematic(mockNode, 100)).toBe(false) + }) + + it('should return true if refute percentage is above threshold', () => { + // Add 11 refutes in last 100 cycles (11%) + for (let i = 90; i <= 100; i++) { + mockNode.refuteCycles.add(i) + } + expect(isNodeProblematic(mockNode, 100)).toBe(true) + }) + + it('should return false if refute percentage is below threshold', () => { + // Add 9 refutes in last 100 cycles (9%), spread out to avoid consecutive threshold + mockNode.refuteCycles.add(10) + mockNode.refuteCycles.add(20) + mockNode.refuteCycles.add(30) + mockNode.refuteCycles.add(40) + mockNode.refuteCycles.add(50) + mockNode.refuteCycles.add(60) + mockNode.refuteCycles.add(70) + mockNode.refuteCycles.add(80) + mockNode.refuteCycles.add(90) + expect(isNodeProblematic(mockNode, 100)).toBe(false) + }) + }) + + describe('getConsecutiveRefutes', () => { + it('should return 0 if current cycle is not in refutes', () => { + expect(getConsecutiveRefutes([97, 98, 99], 100)).toBe(0) + }) + + it('should count consecutive refutes up to current cycle', () => { + expect(getConsecutiveRefutes([98, 99, 100], 100)).toBe(3) + }) + + it('should only count consecutive sequences', () => { + expect(getConsecutiveRefutes([97, 99, 100], 100)).toBe(2) + }) + + it('should handle empty refute array', () => { + expect(getConsecutiveRefutes([], 100)).toBe(0) + }) + + it('should handle single refute at current cycle', () => { + expect(getConsecutiveRefutes([100], 100)).toBe(1) + }) + }) + + describe('getRefutePercentage', () => { + it('should calculate correct percentage in full window', () => { + const refuteCycles = new Set([96, 97, 98, 99, 100]) + expect(getRefutePercentage(refuteCycles, 100)).toBe(0.05) // 5/100 = 5% + }) + + it('should handle empty refute set', () => { + const refuteCycles = new Set() + expect(getRefutePercentage(refuteCycles, 100)).toBe(0) + }) + + it('should only count refutes within window', () => { + const refuteCycles = new Set([1, 2, 98, 99, 100, 100]) + expect(getRefutePercentage(refuteCycles, 102)).toBe(0.03) // Only counting 98,99,100 + }) + + it('should handle early cycles with smaller window', () => { + const refuteCycles = new Set([1, 2, 3]) + expect(getRefutePercentage(refuteCycles, 3)).toBe(1) // 3/3 = 100% + }) + }) + + describe('getProblematicNodes', () => { + let mockCycleRecord: P2P.CycleCreatorTypes.CycleRecord + + beforeEach(() => { + mockCycleRecord = { + counter: 100, + refuted: [], + lost: [], + active: 0, + start: Date.now(), + mode: 'processing', + desired: 100 + } as any + }) + + it('should return empty array when no problematic nodes exist', () => { + // Add a non-problematic node + const node: NodeWithRefuteCycles = { + id: 'node1', + refuteCycles: new Set([90]), // Only 1% refute rate + } as NodeWithRefuteCycles + + NodeList.activeByIdOrder.push(node) + NodeList.nodes.set(node.id, node) + + const result = getProblematicNodes(mockCycleRecord) + expect(result).toEqual([]) + }) + + it('should sort problematic nodes by refute percentage', () => { + // Create nodes with different refute percentages + const node1: NodeWithRefuteCycles = { + id: 'node1', + refuteCycles: new Set([2, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]), // 11% refute rate + } as NodeWithRefuteCycles + + const node2: NodeWithRefuteCycles = { + id: 'node2', + refuteCycles: new Set([90, 92, 94, 96, 98, 100]), // 6% refute rate + } as NodeWithRefuteCycles + + const node3: NodeWithRefuteCycles = { + id: 'node3', + refuteCycles: new Set([98, 99, 100]), // 3% refute rate but 3 consecutive + } as NodeWithRefuteCycles + + // Add nodes to NodeList mock + NodeList.activeByIdOrder.push(node1, node2, node3) + NodeList.nodes.set(node1.id, node1) + NodeList.nodes.set(node2.id, node2) + NodeList.nodes.set(node3.id, node3) + + const result = getProblematicNodes(mockCycleRecord) + // Should contain node1 (11% refutes) and node3 (3 consecutive refutes) + // Sorted by refute percentage (node1 first, then node3) + expect(result).toEqual(['node1', 'node3']) + }) + + it('should handle empty NodeList', () => { + const result = getProblematicNodes(mockCycleRecord) + expect(result).toEqual([]) + }) + + it('should identify nodes with consecutive refutes', () => { + const node: NodeWithRefuteCycles = { + id: 'node1', + refuteCycles: new Set([98, 99, 100]), // 3 consecutive refutes + } as NodeWithRefuteCycles + + NodeList.activeByIdOrder.push(node) + NodeList.nodes.set(node.id, node) + + const result = getProblematicNodes(mockCycleRecord) + expect(result).toContain('node1') + }) + + it('should identify nodes with high refute percentage', () => { + const node: NodeWithRefuteCycles = { + id: 'node1', + refuteCycles: new Set(), // Will add 11 refutes (11%) + } as NodeWithRefuteCycles + + for (let i = 90; i <= 100; i++) { + node.refuteCycles.add(i) + } + + NodeList.activeByIdOrder.push(node) + NodeList.nodes.set(node.id, node) + + const result = getProblematicNodes(mockCycleRecord) + expect(result).toContain('node1') + }) + + it('should not include nodes that are neither consecutive nor percentage problematic', () => { + const node: NodeWithRefuteCycles = { + id: 'node1', + refuteCycles: new Set([95, 97, 99]), // Non-consecutive and only 3% + } as NodeWithRefuteCycles + + NodeList.activeByIdOrder.push(node) + NodeList.nodes.set(node.id, node) + + const result = getProblematicNodes(mockCycleRecord) + expect(result).not.toContain('node1') + }) + }) +}) \ No newline at end of file