type HLLRegisters = Uint8Array; interface HLL { registers: HLLRegisters; m: number; alphaMM: number; } const createHLL = (precision: number = 10): HLL => { const m = 1 << precision; const alphaMM = m === 16 ? 0.673 * m * m : m === 32 ? 0.697 * m * m : m === 64 ? 0.709 * m * m : (0.7213 / (1 + 1.079 / m)) * m * m; return { registers: new Uint8Array(m), m, alphaMM }; }; const hashString = (value: string): number => { let hash = 0; for (let i = 0; i < value.length; i++) { hash = ((hash << 5) - hash) + value.charCodeAt(i); hash = hash & hash; } hash ^= hash >>> 16; hash = Math.imul(hash, 0x85ebca6b); hash ^= hash >>> 13; hash = Math.imul(hash, 0xc2b2ae35); hash ^= hash >>> 16; return hash >>> 0; }; const rho = (value: number): number => { if (value === 0) return 32; let count = 1; while ((value & 0x80000000) === 0) { count++; value <<= 1; } return count; }; const hllAdd = (hll: HLL, value: string): HLL => { const hash = hashString(value); const index = hash >>> (32 - Math.log2(hll.m)); const w = hash << Math.log2(hll.m); const rank = rho(w); const newRegisters = new Uint8Array(hll.registers); newRegisters[index] = Math.max(newRegisters[index], rank); return { ...hll, registers: newRegisters }; }; const hllEstimate = (hll: HLL): number => { let sum = 0; let zeros = 0; for (let i = 0; i < hll.m; i++) { sum += Math.pow(2, -hll.registers[i]); if (hll.registers[i] === 0) zeros++; } let estimate = hll.alphaMM / sum; if (estimate <= 2.5 * hll.m && zeros > 0) { estimate = hll.m * Math.log(hll.m / zeros); } return Math.round(estimate); }; const hllMerge = (a: HLL, b: HLL): HLL => { const newRegisters = new Uint8Array(a.m); for (let i = 0; i < a.m; i++) { newRegisters[i] = Math.max(a.registers[i], b.registers[i]); } return { ...a, registers: newRegisters }; }; const hllClone = (hll: HLL): HLL => ({ ...hll, registers: new Uint8Array(hll.registers), }); interface DataRecord { readonly id: string; readonly data: Data; readonly recordHLL: HLL; } interface Tombstone { readonly id: string; readonly recordHLL: HLL; readonly tombstoneHLL: HLL; } interface NodeState { readonly id: string; readonly records: ReadonlyMap>; readonly tombstones: ReadonlyMap; readonly peerIds: readonly string[]; readonly stats: { readonly messagesReceived: number; readonly tombstonesGarbageCollected: number; readonly resurrections: number; }; } interface NetworkState { readonly nodes: ReadonlyMap>; } const createRecord = (id: string, data: Data, nodeId: string): DataRecord => ({ id, data, recordHLL: hllAdd(createHLL(), nodeId), }); const createTombstone = (record: DataRecord, nodeId: string): Tombstone => ({ id: record.id, recordHLL: hllClone(record.recordHLL), tombstoneHLL: hllAdd(createHLL(), nodeId), }); const createNode = (id: string): NodeState => ({ id, records: new Map(), tombstones: new Map(), peerIds: [], stats: { messagesReceived: 0, tombstonesGarbageCollected: 0, resurrections: 0 }, }); const addPeerToNode = (node: NodeState, peerId: string): NodeState => { if (node.peerIds.includes(peerId)) return node; return { ...node, peerIds: [...node.peerIds, peerId] }; }; const checkGCStatus = ( tombstone: Tombstone, incomingTombstoneEstimate: number | null, myTombstoneEstimateBeforeMerge: number, myNodeId: string, senderNodeId: string | null ): { shouldGC: boolean; stepDownAsKeeper: boolean } => { const targetCount = hllEstimate(tombstone.recordHLL); const isKeeper = myTombstoneEstimateBeforeMerge >= targetCount; if (isKeeper) { // Keeper step-down logic: // If incoming tombstone has reached the target count, compare estimates. // If incoming estimate >= my estimate before merge, step down. // Use node ID as tie-breaker: higher node ID steps down when estimates are equal. if (incomingTombstoneEstimate !== null && incomingTombstoneEstimate >= targetCount) { if (myTombstoneEstimateBeforeMerge < incomingTombstoneEstimate) { return { shouldGC: true, stepDownAsKeeper: true }; } // Tie-breaker: if estimates are equal, the lexicographically higher node ID steps down if (myTombstoneEstimateBeforeMerge === incomingTombstoneEstimate && senderNodeId !== null && myNodeId > senderNodeId) { return { shouldGC: true, stepDownAsKeeper: true }; } } return { shouldGC: false, stepDownAsKeeper: false }; } // Not yet a keeper - will become one if tombstone count reaches target after merge // (No explicit action needed here, keeper status is inferred from HLL comparison) return { shouldGC: false, stepDownAsKeeper: false }; }; const receiveRecord = ( node: NodeState, incoming: DataRecord ): NodeState => { const newStats = { ...node.stats, messagesReceived: node.stats.messagesReceived + 1 }; if (node.tombstones.has(incoming.id)) { return { ...node, stats: { ...newStats, resurrections: newStats.resurrections + 1 } }; } const existing = node.records.get(incoming.id); const updatedRecord: DataRecord = existing ? { ...existing, recordHLL: hllAdd(hllMerge(existing.recordHLL, incoming.recordHLL), node.id) } : { ...incoming, recordHLL: hllAdd(hllClone(incoming.recordHLL), node.id) }; const newRecords = new Map(node.records); newRecords.set(incoming.id, updatedRecord); return { ...node, records: newRecords, stats: newStats }; }; const receiveTombstone = ( node: NodeState, incoming: Tombstone, senderNodeId: string ): NodeState => { let newStats = { ...node.stats, messagesReceived: node.stats.messagesReceived + 1 }; const record = node.records.get(incoming.id); if (!record) { return { ...node, stats: newStats }; } const existing = node.tombstones.get(incoming.id); const mergedTombstoneHLL = existing ? hllAdd(hllMerge(existing.tombstoneHLL, incoming.tombstoneHLL), node.id) : hllAdd(hllClone(incoming.tombstoneHLL), node.id); let bestFrozenHLL = incoming.recordHLL; if (existing?.recordHLL) { bestFrozenHLL = hllEstimate(existing.recordHLL) > hllEstimate(bestFrozenHLL) ? existing.recordHLL : bestFrozenHLL; } if (hllEstimate(record.recordHLL) > hllEstimate(bestFrozenHLL)) { bestFrozenHLL = hllClone(record.recordHLL); } const updatedTombstone: Tombstone = { id: incoming.id, tombstoneHLL: mergedTombstoneHLL, recordHLL: bestFrozenHLL, }; const myEstimateBeforeMerge = existing ? hllEstimate(existing.tombstoneHLL) : 0; const gcStatus = checkGCStatus( updatedTombstone, hllEstimate(incoming.tombstoneHLL), myEstimateBeforeMerge, node.id, senderNodeId ); // Always delete the record when we have a tombstone const newRecords = new Map(node.records); newRecords.delete(incoming.id); if (gcStatus.stepDownAsKeeper) { // Step down: delete both record and tombstone const newTombstones = new Map(node.tombstones); newTombstones.delete(incoming.id); newStats = { ...newStats, tombstonesGarbageCollected: newStats.tombstonesGarbageCollected + 1 }; return { ...node, records: newRecords, tombstones: newTombstones, stats: newStats }; } const newTombstones = new Map(node.tombstones); newTombstones.set(incoming.id, updatedTombstone); return { ...node, records: newRecords, tombstones: newTombstones, stats: newStats }; }; const createNetwork = (nodeCount: number, connectivityRatio: number): NetworkState => { let nodes = new Map>(); for (let i = 0; i < nodeCount; i++) { nodes.set(`node-${i}`, createNode(`node-${i}`)); } const nodeIds = Array.from(nodes.keys()); for (let i = 0; i < nodeIds.length; i++) { for (let j = i + 1; j < nodeIds.length; j++) { if (Math.random() < connectivityRatio) { nodes = new Map(nodes) .set(nodeIds[i], addPeerToNode(nodes.get(nodeIds[i])!, nodeIds[j])) .set(nodeIds[j], addPeerToNode(nodes.get(nodeIds[j])!, nodeIds[i])); } } } for (let i = 0; i < nodeIds.length; i++) { const nextIdx = (i + 1) % nodeIds.length; nodes = new Map(nodes) .set(nodeIds[i], addPeerToNode(nodes.get(nodeIds[i])!, nodeIds[nextIdx])) .set(nodeIds[nextIdx], addPeerToNode(nodes.get(nodeIds[nextIdx])!, nodeIds[i])); } return { nodes }; }; const createBridgedNetwork = ( clusterSize: number, intraClusterConnectivity: number ): NetworkState => { let nodes = new Map>(); for (let i = 0; i < clusterSize; i++) { nodes.set(`cluster-a-${i}`, createNode(`cluster-a-${i}`)); nodes.set(`cluster-b-${i}`, createNode(`cluster-b-${i}`)); } const clusterA = Array.from(nodes.keys()).filter(id => id.startsWith('cluster-a')); const clusterB = Array.from(nodes.keys()).filter(id => id.startsWith('cluster-b')); const connectCluster = (clusterIds: string[]) => { for (let i = 0; i < clusterIds.length; i++) { for (let j = i + 1; j < clusterIds.length; j++) { if (Math.random() < intraClusterConnectivity) { nodes = new Map(nodes) .set(clusterIds[i], addPeerToNode(nodes.get(clusterIds[i])!, clusterIds[j])) .set(clusterIds[j], addPeerToNode(nodes.get(clusterIds[j])!, clusterIds[i])); } } } for (let i = 0; i < clusterIds.length; i++) { const nextIdx = (i + 1) % clusterIds.length; nodes = new Map(nodes) .set(clusterIds[i], addPeerToNode(nodes.get(clusterIds[i])!, clusterIds[nextIdx])) .set(clusterIds[nextIdx], addPeerToNode(nodes.get(clusterIds[nextIdx])!, clusterIds[i])); } }; connectCluster(clusterA); connectCluster(clusterB); const bridgeA = clusterA[0]; const bridgeB = clusterB[0]; nodes = new Map(nodes) .set(bridgeA, addPeerToNode(nodes.get(bridgeA)!, bridgeB)) .set(bridgeB, addPeerToNode(nodes.get(bridgeB)!, bridgeA)); return { nodes }; }; const forwardTombstoneToAllPeers = ( network: NetworkState, forwardingNodeId: string, tombstone: Tombstone, excludePeerId?: string ): NetworkState => { const forwardingNode = network.nodes.get(forwardingNodeId); if (!forwardingNode) return network; let newNodes = new Map(network.nodes); for (const peerId of forwardingNode.peerIds) { if (peerId === excludePeerId) continue; const peer = newNodes.get(peerId); if (!peer || !peer.records.has(tombstone.id)) continue; const updatedPeer = receiveTombstone(peer, tombstone, forwardingNodeId); newNodes.set(peerId, updatedPeer); // If this peer also stepped down, recursively forward if (!updatedPeer.tombstones.has(tombstone.id) && peer.tombstones.has(tombstone.id)) { const result = forwardTombstoneToAllPeers({ nodes: newNodes }, peerId, tombstone, forwardingNodeId); newNodes = new Map(result.nodes); } } return { nodes: newNodes }; }; const gossipOnce = (network: NetworkState, senderNodeId: string, recordId: string): NetworkState => { const sender = network.nodes.get(senderNodeId); if (!sender || sender.peerIds.length === 0) return network; const record = sender.records.get(recordId); const tombstone = sender.tombstones.get(recordId); if (!record && !tombstone) return network; const peerId = sender.peerIds[Math.floor(Math.random() * sender.peerIds.length)]; const peer = network.nodes.get(peerId); if (!peer) return network; let newNodes = new Map(network.nodes); if (record && !tombstone) { const updatedPeer = receiveRecord(peer, record); newNodes.set(peerId, updatedPeer); } if (tombstone) { if (record && !peer.records.has(recordId)) { const peerWithRecord = receiveRecord(peer, record); newNodes.set(peerId, peerWithRecord); } const currentPeer = newNodes.get(peerId)!; const peerHadTombstone = currentPeer.tombstones.has(recordId); const updatedPeer = receiveTombstone(currentPeer, tombstone, senderNodeId); newNodes.set(peerId, updatedPeer); // If peer stepped down (had tombstone before, doesn't have it now), forward the incoming tombstone if (peerHadTombstone && !updatedPeer.tombstones.has(recordId)) { const result = forwardTombstoneToAllPeers({ nodes: newNodes }, peerId, tombstone, senderNodeId); newNodes = new Map(result.nodes); } if (updatedPeer.tombstones.has(recordId)) { const peerTombstone = updatedPeer.tombstones.get(recordId)!; const senderEstimateBeforeMerge = hllEstimate(tombstone.tombstoneHLL); // Merge HLLs const mergedTombstoneHLL = hllMerge(tombstone.tombstoneHLL, peerTombstone.tombstoneHLL); const bestFrozenHLL = hllEstimate(peerTombstone.recordHLL) > hllEstimate(tombstone.recordHLL) ? peerTombstone.recordHLL : tombstone.recordHLL; const updatedSenderTombstone: Tombstone = { ...tombstone, tombstoneHLL: mergedTombstoneHLL, recordHLL: bestFrozenHLL, }; // Check if sender should step down (peer has higher estimate or wins tie-breaker) const gcStatus = checkGCStatus( updatedSenderTombstone, hllEstimate(peerTombstone.tombstoneHLL), senderEstimateBeforeMerge, senderNodeId, peerId ); if (gcStatus.stepDownAsKeeper) { // Sender steps down - remove their tombstone const currentSender = newNodes.get(senderNodeId)!; const newSenderTombstones = new Map(currentSender.tombstones); newSenderTombstones.delete(recordId); const newSenderStats = { ...currentSender.stats, tombstonesGarbageCollected: currentSender.stats.tombstonesGarbageCollected + 1 }; newNodes.set(senderNodeId, { ...currentSender, tombstones: newSenderTombstones, stats: newSenderStats }); // Forward the peer's tombstone to all sender's other peers const result = forwardTombstoneToAllPeers({ nodes: newNodes }, senderNodeId, peerTombstone, peerId); newNodes = new Map(result.nodes); } else { // Keep tombstone with merged data const currentSender = newNodes.get(senderNodeId)!; const newSenderTombstones = new Map(currentSender.tombstones); newSenderTombstones.set(recordId, updatedSenderTombstone); newNodes.set(senderNodeId, { ...currentSender, tombstones: newSenderTombstones }); } } } return { nodes: newNodes }; }; const gossipRounds = (network: NetworkState, recordId: string, rounds: number): NetworkState => { let state = network; for (let round = 0; round < rounds; round++) { for (const [nodeId, node] of state.nodes) { if (node.records.has(recordId) || node.tombstones.has(recordId)) { state = gossipOnce(state, nodeId, recordId); } } } return state; }; interface ClusterStats { name: string; nodeCount: number; recordCount: number; tombstoneCount: number; } interface SimulationResult { testName: string; recordsDeleted: boolean; roundsToDeleteRecords: number; totalRounds: number; clusters: ClusterStats[]; } const getClusterStats = ( network: NetworkState, recordId: string, clusterPrefix?: string ): ClusterStats => { let recordCount = 0; let tombstoneCount = 0; let nodeCount = 0; for (const [nodeId, node] of network.nodes) { if (clusterPrefix && !nodeId.startsWith(clusterPrefix)) continue; nodeCount++; if (node.records.has(recordId)) recordCount++; if (node.tombstones.has(recordId)) tombstoneCount++; } return { name: clusterPrefix ?? 'all', nodeCount, recordCount, tombstoneCount, }; }; const printSimulationResult = (result: SimulationResult): void => { console.log(`\n== ${result.testName} ==`); if (result.recordsDeleted) { console.log(` Records deleted: YES (${result.roundsToDeleteRecords} rounds)`); } else { console.log(` Records deleted: NO`); } console.log(` Total rounds run: ${result.totalRounds}`); console.log(` Final State:`); for (const cluster of result.clusters) { const clusterLabel = cluster.name === 'all' ? 'Network' : `Cluster ${cluster.name}`; console.log(` ${clusterLabel} (${cluster.nodeCount} nodes):`); console.log(` Records: ${cluster.recordCount}`); console.log(` Tombstones: ${cluster.tombstoneCount}`); } }; interface ConvergenceResult { network: NetworkState; recordsDeleted: boolean; roundsToDeleteRecords: number; totalRounds: number; } const runToConvergence = ( network: NetworkState, recordId: string, maxRounds: number, extraRoundsAfterDeletion: number = 100 ): ConvergenceResult => { let rounds = 0; let state = network; let recordsDeleted = false; let roundsToDeleteRecords = 0; // Phase 1: Run until records are deleted while (rounds < maxRounds && !recordsDeleted) { const stats = getClusterStats(state, recordId); if (stats.recordCount === 0) { recordsDeleted = true; roundsToDeleteRecords = rounds; } state = gossipRounds(state, recordId, 10); rounds += 10; } // Phase 2: Continue running to let tombstones converge let extraRounds = 0; while (extraRounds < extraRoundsAfterDeletion) { state = gossipRounds(state, recordId, 10); extraRounds += 10; rounds += 10; } return { network: state, recordsDeleted, roundsToDeleteRecords, totalRounds: rounds, }; }; const addRecordToNetwork = (network: NetworkState, nodeId: string, recordId: string, data: Data): NetworkState => { const node = network.nodes.get(nodeId); if (!node) return network; const newRecords = new Map(node.records); newRecords.set(recordId, createRecord(recordId, data, nodeId)); const newNodes = new Map(network.nodes); newNodes.set(nodeId, { ...node, records: newRecords }); return { nodes: newNodes }; }; const addTombstoneToNetwork = (network: NetworkState, nodeId: string, recordId: string): NetworkState => { const node = network.nodes.get(nodeId); if (!node) return network; const record = node.records.get(recordId); if (!record) return network; const newTombstones = new Map(node.tombstones); newTombstones.set(recordId, createTombstone(record, nodeId)); const newNodes = new Map(network.nodes); newNodes.set(nodeId, { ...node, tombstones: newTombstones }); return { nodes: newNodes }; }; const testSingleNodeDeletion = (): void => { const trials = 50; const maxRounds = 99999; let deletedCount = 0; let totalDeletionRounds = 0; let totalRounds = 0; let finalRecords = 0; let finalTombstones = 0; for (let trial = 0; trial < trials; trial++) { let network = createNetwork(15, 0.4); const recordId = `test-${trial}`; network = addRecordToNetwork(network, "node-0", recordId, "Test Data"); network = gossipRounds(network, recordId, 20); network = addTombstoneToNetwork(network, "node-0", recordId); const result = runToConvergence(network, recordId, maxRounds); if (result.recordsDeleted) { deletedCount++; totalDeletionRounds += result.roundsToDeleteRecords; } totalRounds += result.totalRounds; const stats = getClusterStats(result.network, recordId); finalRecords += stats.recordCount; finalTombstones += stats.tombstoneCount; } printSimulationResult({ testName: `Single Node Deletion (${trials} trials)`, recordsDeleted: deletedCount === trials, roundsToDeleteRecords: deletedCount > 0 ? Math.round(totalDeletionRounds / deletedCount) : 0, totalRounds: Math.round(totalRounds / trials), clusters: [{ name: 'all', nodeCount: 15 * trials, recordCount: finalRecords, tombstoneCount: finalTombstones, }], }); }; const testEarlyTombstoneCreation = (): void => { const trials = 50; const maxRounds = 99999; let deletedCount = 0; let totalDeletionRounds = 0; let totalRounds = 0; let finalRecords = 0; let finalTombstones = 0; for (let trial = 0; trial < trials; trial++) { let network = createNetwork(20, 0.4); const recordId = `early-tombstone-${trial}`; // Only propagate record for 3 rounds before creating tombstone network = addRecordToNetwork(network, "node-0", recordId, "Test"); network = gossipRounds(network, recordId, 3); network = addTombstoneToNetwork(network, "node-0", recordId); const result = runToConvergence(network, recordId, maxRounds); if (result.recordsDeleted) { deletedCount++; totalDeletionRounds += result.roundsToDeleteRecords; } totalRounds += result.totalRounds; const stats = getClusterStats(result.network, recordId); finalRecords += stats.recordCount; finalTombstones += stats.tombstoneCount; } printSimulationResult({ testName: `Early Tombstone (${trials} trials, record partially propagated)`, recordsDeleted: deletedCount === trials, roundsToDeleteRecords: deletedCount > 0 ? Math.round(totalDeletionRounds / deletedCount) : 0, totalRounds: Math.round(totalRounds / trials), clusters: [{ name: 'all', nodeCount: 20 * trials, recordCount: finalRecords, tombstoneCount: finalTombstones, }], }); }; const testBridgedNetwork = (): void => { const trials = 50; const maxRounds = 99999; const clusterSize = 15; let deletedCount = 0; let totalDeletionRounds = 0; let totalRounds = 0; let finalRecordsA = 0; let finalTombstonesA = 0; let finalRecordsB = 0; let finalTombstonesB = 0; for (let trial = 0; trial < trials; trial++) { let network = createBridgedNetwork(clusterSize, 0.5); const recordId = `bridged-record-${trial}`; network = addRecordToNetwork(network, "cluster-a-0", recordId, "Test Data"); network = gossipRounds(network, recordId, 20); network = addTombstoneToNetwork(network, "cluster-a-0", recordId); const result = runToConvergence(network, recordId, maxRounds); if (result.recordsDeleted) { deletedCount++; totalDeletionRounds += result.roundsToDeleteRecords; } totalRounds += result.totalRounds; const statsA = getClusterStats(result.network, recordId, "cluster-a"); const statsB = getClusterStats(result.network, recordId, "cluster-b"); finalRecordsA += statsA.recordCount; finalTombstonesA += statsA.tombstoneCount; finalRecordsB += statsB.recordCount; finalTombstonesB += statsB.tombstoneCount; } printSimulationResult({ testName: `Bridged Network (${trials} trials, two clusters)`, recordsDeleted: deletedCount === trials, roundsToDeleteRecords: deletedCount > 0 ? Math.round(totalDeletionRounds / deletedCount) : 0, totalRounds: Math.round(totalRounds / trials), clusters: [ { name: 'cluster-a', nodeCount: clusterSize * trials, recordCount: finalRecordsA, tombstoneCount: finalTombstonesA }, { name: 'cluster-b', nodeCount: clusterSize * trials, recordCount: finalRecordsB, tombstoneCount: finalTombstonesB }, ], }); }; const testConcurrentTombstones = (): void => { const trials = 50; const maxRounds = 99999; let deletedCount = 0; let totalDeletionRounds = 0; let totalRounds = 0; let finalRecords = 0; let finalTombstones = 0; for (let trial = 0; trial < trials; trial++) { let network = createNetwork(20, 0.4); const recordId = `concurrent-delete-${trial}`; network = addRecordToNetwork(network, "node-0", recordId, "Test Data"); network = gossipRounds(network, recordId, 30); network = addTombstoneToNetwork(network, "node-0", recordId); network = addTombstoneToNetwork(network, "node-5", recordId); network = addTombstoneToNetwork(network, "node-10", recordId); const result = runToConvergence(network, recordId, maxRounds); if (result.recordsDeleted) { deletedCount++; totalDeletionRounds += result.roundsToDeleteRecords; } totalRounds += result.totalRounds; const stats = getClusterStats(result.network, recordId); finalRecords += stats.recordCount; finalTombstones += stats.tombstoneCount; } printSimulationResult({ testName: `Concurrent Tombstones (${trials} trials, 3 nodes delete)`, recordsDeleted: deletedCount === trials, roundsToDeleteRecords: deletedCount > 0 ? Math.round(totalDeletionRounds / deletedCount) : 0, totalRounds: Math.round(totalRounds / trials), clusters: [{ name: 'all', nodeCount: 20 * trials, recordCount: finalRecords, tombstoneCount: finalTombstones, }], }); }; const testNetworkPartitionHeal = (): void => { const trials = 50; const maxRounds = 99999; const clusterSize = 10; let deletedCount = 0; let totalDeletionRounds = 0; let totalRounds = 0; let finalRecordsA = 0; let finalTombstonesA = 0; let finalRecordsB = 0; let finalTombstonesB = 0; for (let trial = 0; trial < trials; trial++) { let network = createBridgedNetwork(clusterSize, 0.5); const recordId = `partition-test-${trial}`; network = addRecordToNetwork(network, "cluster-a-0", recordId, "Test Data"); network = gossipRounds(network, recordId, 30); // Partition the network const bridgeA = network.nodes.get("cluster-a-0")!; const bridgeB = network.nodes.get("cluster-b-0")!; const newBridgeAPeers = bridgeA.peerIds.filter(p => p !== "cluster-b-0"); const newBridgeBPeers = bridgeB.peerIds.filter(p => p !== "cluster-a-0"); let partitionedNodes = new Map(network.nodes); partitionedNodes.set("cluster-a-0", { ...bridgeA, peerIds: newBridgeAPeers }); partitionedNodes.set("cluster-b-0", { ...bridgeB, peerIds: newBridgeBPeers }); network = { nodes: partitionedNodes }; network = addTombstoneToNetwork(network, "cluster-a-0", recordId); // Run during partition const partitionResult = runToConvergence(network, recordId, 500); network = partitionResult.network; // Heal the network const healedBridgeA = network.nodes.get("cluster-a-0")!; const healedBridgeB = network.nodes.get("cluster-b-0")!; let healedNodes = new Map(network.nodes); healedNodes.set("cluster-a-0", addPeerToNode(healedBridgeA, "cluster-b-0")); healedNodes.set("cluster-b-0", addPeerToNode(healedBridgeB, "cluster-a-0")); network = { nodes: healedNodes }; const result = runToConvergence(network, recordId, maxRounds); if (result.recordsDeleted) { deletedCount++; totalDeletionRounds += partitionResult.roundsToDeleteRecords + result.roundsToDeleteRecords; } totalRounds += partitionResult.totalRounds + result.totalRounds; const statsA = getClusterStats(result.network, recordId, "cluster-a"); const statsB = getClusterStats(result.network, recordId, "cluster-b"); finalRecordsA += statsA.recordCount; finalTombstonesA += statsA.tombstoneCount; finalRecordsB += statsB.recordCount; finalTombstonesB += statsB.tombstoneCount; } printSimulationResult({ testName: `Network Partition and Heal (${trials} trials)`, recordsDeleted: deletedCount === trials, roundsToDeleteRecords: deletedCount > 0 ? Math.round(totalDeletionRounds / deletedCount) : 0, totalRounds: Math.round(totalRounds / trials), clusters: [ { name: 'cluster-a', nodeCount: clusterSize * trials, recordCount: finalRecordsA, tombstoneCount: finalTombstonesA }, { name: 'cluster-b', nodeCount: clusterSize * trials, recordCount: finalRecordsB, tombstoneCount: finalTombstonesB }, ], }); }; const applyDynamicTopologyChanges = (network: NetworkState): NetworkState => { const nodeIds = Array.from(network.nodes.keys()); const changeCount = Math.floor(Math.random() * 5) + 1; let result = network; for (let c = 0; c < changeCount; c++) { const nodeA = nodeIds[Math.floor(Math.random() * nodeIds.length)]; const nodeB = nodeIds[Math.floor(Math.random() * nodeIds.length)]; if (nodeA === nodeB) continue; const nodeAState = result.nodes.get(nodeA)!; const nodeBState = result.nodes.get(nodeB)!; // 50% chance to add connection, 50% to remove if (Math.random() < 0.5) { // Add connection if not already connected if (!nodeAState.peerIds.includes(nodeB)) { const newNodes = new Map(result.nodes); newNodes.set(nodeA, addPeerToNode(nodeAState, nodeB)); newNodes.set(nodeB, addPeerToNode(nodeBState, nodeA)); result = { nodes: newNodes }; } } else { // Remove connection if connected and both have more than 1 peer if (nodeAState.peerIds.includes(nodeB) && nodeAState.peerIds.length > 1 && nodeBState.peerIds.length > 1) { const newNodes = new Map(result.nodes); newNodes.set(nodeA, { ...nodeAState, peerIds: nodeAState.peerIds.filter(p => p !== nodeB), }); newNodes.set(nodeB, { ...nodeBState, peerIds: nodeBState.peerIds.filter(p => p !== nodeA), }); result = { nodes: newNodes }; } } } return result; }; const testDynamicTopology = (): void => { const trials = 50; const maxRounds = 99999; let deletedCount = 0; let totalDeletionRounds = 0; let totalRounds = 0; let finalRecords = 0; let finalTombstones = 0; for (let trial = 0; trial < trials; trial++) { let network = createNetwork(20, 0.3); const recordId = `dynamic-${trial}`; // Create and propagate record network = addRecordToNetwork(network, "node-0", recordId, "Test Data"); network = gossipRounds(network, recordId, 10); // Create tombstone network = addTombstoneToNetwork(network, "node-0", recordId); // Simulate dynamic topology changes during gossip let rounds = 0; let recordsDeleted = false; let roundsToDeleteRecords = 0; while (rounds < maxRounds && !recordsDeleted) { // Random topology changes every 5 rounds if (rounds % 5 === 0) { network = applyDynamicTopologyChanges(network); } const stats = getClusterStats(network, recordId); if (stats.recordCount === 0) { recordsDeleted = true; roundsToDeleteRecords = rounds; } network = gossipRounds(network, recordId, 5); rounds += 5; } // Continue for convergence with dynamic topology still active let extraRounds = 0; while (extraRounds < 100) { if (extraRounds % 5 === 0) { network = applyDynamicTopologyChanges(network); } network = gossipRounds(network, recordId, 5); extraRounds += 5; rounds += 5; } if (recordsDeleted) { deletedCount++; totalDeletionRounds += roundsToDeleteRecords; } totalRounds += rounds; const stats = getClusterStats(network, recordId); finalRecords += stats.recordCount; finalTombstones += stats.tombstoneCount; } printSimulationResult({ testName: `Dynamic Topology (${trials} trials, connections changing)`, recordsDeleted: deletedCount === trials, roundsToDeleteRecords: deletedCount > 0 ? Math.round(totalDeletionRounds / deletedCount) : 0, totalRounds: Math.round(totalRounds / trials), clusters: [{ name: 'all', nodeCount: 20 * trials, recordCount: finalRecords, tombstoneCount: finalTombstones, }], }); }; const applyNodeChurn = ( network: NetworkState, nodeCounter: { value: number } ): NetworkState => { let result = network; const nodeIds = Array.from(result.nodes.keys()); // Remove 1-2 random nodes (not node-0 which has the tombstone) const removeCount = Math.floor(Math.random() * 2) + 1; for (let r = 0; r < removeCount; r++) { const candidateNodes = nodeIds.filter(id => id !== "node-0" && result.nodes.has(id)); if (candidateNodes.length <= 5) break; // Keep minimum network size const nodeToRemove = candidateNodes[Math.floor(Math.random() * candidateNodes.length)]; const nodeState = result.nodes.get(nodeToRemove); if (!nodeState) continue; // Remove node and all its peer connections const newNodes = new Map(result.nodes); newNodes.delete(nodeToRemove); for (const peerId of nodeState.peerIds) { const peer = newNodes.get(peerId); if (peer) { newNodes.set(peerId, { ...peer, peerIds: peer.peerIds.filter(p => p !== nodeToRemove), }); } } result = { nodes: newNodes }; } // Add 1-2 new nodes const addCount = Math.floor(Math.random() * 2) + 1; for (let a = 0; a < addCount; a++) { const newNodeId = `node-${nodeCounter.value++}`; const newNode = createNode(newNodeId); // Connect to 2-4 random existing nodes const existingNodes = Array.from(result.nodes.keys()); const connectionCount = Math.min(existingNodes.length, Math.floor(Math.random() * 3) + 2); const shuffled = existingNodes.sort(() => Math.random() - 0.5); const peersToConnect = shuffled.slice(0, connectionCount); let newNodes = new Map(result.nodes); let updatedNewNode = newNode; for (const peerId of peersToConnect) { const peer = newNodes.get(peerId)!; updatedNewNode = addPeerToNode(updatedNewNode, peerId); newNodes.set(peerId, addPeerToNode(peer, newNodeId)); } newNodes.set(newNodeId, updatedNewNode); result = { nodes: newNodes }; } return result; }; const testNodeChurn = (): void => { const trials = 50; const maxRounds = 99999; let deletedCount = 0; let totalDeletionRounds = 0; let totalRounds = 0; let finalRecords = 0; let finalTombstones = 0; for (let trial = 0; trial < trials; trial++) { let network = createNetwork(20, 0.4); const recordId = `churn-${trial}`; const nodeCounter = { value: 20 }; // Create and propagate record network = addRecordToNetwork(network, "node-0", recordId, "Test Data"); network = gossipRounds(network, recordId, 15); // Create tombstone network = addTombstoneToNetwork(network, "node-0", recordId); // Simulate node churn during gossip let rounds = 0; let recordsDeleted = false; let roundsToDeleteRecords = 0; while (rounds < maxRounds && !recordsDeleted) { // Node churn every 10 rounds if (rounds % 10 === 0 && rounds > 0) { network = applyNodeChurn(network, nodeCounter); } const stats = getClusterStats(network, recordId); if (stats.recordCount === 0) { recordsDeleted = true; roundsToDeleteRecords = rounds; } network = gossipRounds(network, recordId, 5); rounds += 5; } // Continue for convergence with node churn still active let extraRounds = 0; while (extraRounds < 100) { if (extraRounds % 10 === 0) { network = applyNodeChurn(network, nodeCounter); } network = gossipRounds(network, recordId, 5); extraRounds += 5; rounds += 5; } if (recordsDeleted) { deletedCount++; totalDeletionRounds += roundsToDeleteRecords; } totalRounds += rounds; const stats = getClusterStats(network, recordId); finalRecords += stats.recordCount; finalTombstones += stats.tombstoneCount; } printSimulationResult({ testName: `Node Churn (${trials} trials, nodes joining/leaving)`, recordsDeleted: deletedCount === trials, roundsToDeleteRecords: deletedCount > 0 ? Math.round(totalDeletionRounds / deletedCount) : 0, totalRounds: Math.round(totalRounds / trials), clusters: [{ name: 'all', nodeCount: 20 * trials, recordCount: finalRecords, tombstoneCount: finalTombstones, }], }); }; const applyRandomConfigChanges = ( network: NetworkState, trial: number, recordCounter: { value: number } ): NetworkState => { let result = network; const nodeIds = Array.from(result.nodes.keys()); const changeCount = Math.floor(Math.random() * 4) + 1; for (let c = 0; c < changeCount; c++) { const nodeId = nodeIds[Math.floor(Math.random() * nodeIds.length)]; const action = Math.random(); if (action < 0.3) { // Add a new unrelated record to this node (simulating config change) const newRecordId = `config-extra-${trial}-${recordCounter.value++}`; result = addRecordToNetwork(result, nodeId, newRecordId, "Extra Data" as Data); } else if (action < 0.6) { // Modify peer list randomly (add a new peer) const otherNodes = nodeIds.filter(id => { const node = result.nodes.get(nodeId); return id !== nodeId && node && !node.peerIds.includes(id); }); if (otherNodes.length > 0) { const newPeer = otherNodes[Math.floor(Math.random() * otherNodes.length)]; const nodeState = result.nodes.get(nodeId)!; const peerState = result.nodes.get(newPeer)!; const newNodes = new Map(result.nodes); newNodes.set(nodeId, addPeerToNode(nodeState, newPeer)); newNodes.set(newPeer, addPeerToNode(peerState, nodeId)); result = { nodes: newNodes }; } } else { // Remove a random peer (if we have more than 1) const nodeState = result.nodes.get(nodeId)!; if (nodeState.peerIds.length > 1) { const peerToRemove = nodeState.peerIds[Math.floor(Math.random() * nodeState.peerIds.length)]; const peerState = result.nodes.get(peerToRemove)!; // Only remove if peer also has more than 1 connection if (peerState.peerIds.length > 1) { const newNodes = new Map(result.nodes); newNodes.set(nodeId, { ...nodeState, peerIds: nodeState.peerIds.filter(p => p !== peerToRemove), }); newNodes.set(peerToRemove, { ...peerState, peerIds: peerState.peerIds.filter(p => p !== nodeId), }); result = { nodes: newNodes }; } } } } return result; }; const testRandomConfigurationChanges = (): void => { const trials = 50; const maxRounds = 99999; let deletedCount = 0; let totalDeletionRounds = 0; let totalRounds = 0; let finalRecords = 0; let finalTombstones = 0; for (let trial = 0; trial < trials; trial++) { let network = createNetwork(20, 0.4); const primaryRecordId = `config-primary-${trial}`; const recordCounter = { value: 0 }; // Create and propagate primary record network = addRecordToNetwork(network, "node-0", primaryRecordId, "Primary Data"); network = gossipRounds(network, primaryRecordId, 15); // Create tombstone for primary record network = addTombstoneToNetwork(network, "node-0", primaryRecordId); // Simulate random configuration changes during gossip let rounds = 0; let recordsDeleted = false; let roundsToDeleteRecords = 0; while (rounds < maxRounds && !recordsDeleted) { // Random configuration changes every 8 rounds if (rounds % 8 === 0 && rounds > 0) { network = applyRandomConfigChanges(network, trial, recordCounter); } const stats = getClusterStats(network, primaryRecordId); if (stats.recordCount === 0) { recordsDeleted = true; roundsToDeleteRecords = rounds; } network = gossipRounds(network, primaryRecordId, 5); rounds += 5; } // Continue for convergence with config changes still active let extraRounds = 0; while (extraRounds < 100) { if (extraRounds % 8 === 0) { network = applyRandomConfigChanges(network, trial, recordCounter); } network = gossipRounds(network, primaryRecordId, 5); extraRounds += 5; rounds += 5; } if (recordsDeleted) { deletedCount++; totalDeletionRounds += roundsToDeleteRecords; } totalRounds += rounds; const stats = getClusterStats(network, primaryRecordId); finalRecords += stats.recordCount; finalTombstones += stats.tombstoneCount; } printSimulationResult({ testName: `Random Config Changes (${trials} trials, mixed changes)`, recordsDeleted: deletedCount === trials, roundsToDeleteRecords: deletedCount > 0 ? Math.round(totalDeletionRounds / deletedCount) : 0, totalRounds: Math.round(totalRounds / trials), clusters: [{ name: 'all', nodeCount: 20 * trials, recordCount: finalRecords, tombstoneCount: finalTombstones, }], }); }; const disconnectNode = ( network: NetworkState, nodeId: string ): { network: NetworkState; savedPeers: readonly string[] } => { const node = network.nodes.get(nodeId); if (!node) return { network, savedPeers: [] }; const savedPeers = node.peerIds; let newNodes = new Map(network.nodes); // Remove this node from all its peers' peer lists for (const peerId of savedPeers) { const peer = newNodes.get(peerId); if (peer) { newNodes.set(peerId, { ...peer, peerIds: peer.peerIds.filter(p => p !== nodeId), }); } } // Clear this node's peer list newNodes.set(nodeId, { ...node, peerIds: [] }); return { network: { nodes: newNodes }, savedPeers }; }; const reconnectNode = ( network: NetworkState, nodeId: string, peers: readonly string[] ): NetworkState => { const node = network.nodes.get(nodeId); if (!node) return network; let newNodes = new Map(network.nodes); // Restore this node's peer list (only peers that still exist) const validPeers = peers.filter(p => newNodes.has(p)); newNodes.set(nodeId, { ...node, peerIds: validPeers }); // Add this node back to each peer's peer list for (const peerId of validPeers) { const peer = newNodes.get(peerId); if (peer && !peer.peerIds.includes(nodeId)) { newNodes.set(peerId, { ...peer, peerIds: [...peer.peerIds, nodeId], }); } } return { nodes: newNodes }; }; const testNodeDropoutAndReconnect = (): void => { const trials = 50; const maxRounds = 99999; const dropoutRounds = 100; let deletedCount = 0; let totalDeletionRounds = 0; let totalRounds = 0; let finalRecords = 0; let finalTombstones = 0; for (let trial = 0; trial < trials; trial++) { let network = createNetwork(15, 0.4); const recordId = `dropout-${trial}`; const dropoutNodeId = "node-5"; // Node that will drop out // Create and propagate record to all nodes including the dropout node network = addRecordToNetwork(network, "node-0", recordId, "Test Data"); network = gossipRounds(network, recordId, 20); // Verify the dropout node has received the record const dropoutNode = network.nodes.get(dropoutNodeId)!; if (!dropoutNode.records.has(recordId)) { // Force propagation to ensure it has the record network = gossipRounds(network, recordId, 10); } // Create tombstone at origin node network = addTombstoneToNetwork(network, "node-0", recordId); // Disconnect the dropout node (simulating it going offline) const { network: disconnectedNetwork, savedPeers } = disconnectNode(network, dropoutNodeId); network = disconnectedNetwork; // Run gossip for 100 rounds while the node is disconnected // The tombstone should propagate to all other nodes for (let r = 0; r < dropoutRounds; r += 10) { network = gossipRounds(network, recordId, 10); } // Reconnect the dropout node network = reconnectNode(network, dropoutNodeId, savedPeers); // Continue running to see if the system converges properly const result = runToConvergence(network, recordId, maxRounds); if (result.recordsDeleted) { deletedCount++; totalDeletionRounds += result.roundsToDeleteRecords + dropoutRounds; } totalRounds += result.totalRounds + dropoutRounds; const stats = getClusterStats(result.network, recordId); finalRecords += stats.recordCount; finalTombstones += stats.tombstoneCount; } printSimulationResult({ testName: `Node Dropout & Reconnect (${trials} trials, ${dropoutRounds} rounds offline)`, recordsDeleted: deletedCount === trials, roundsToDeleteRecords: deletedCount > 0 ? Math.round(totalDeletionRounds / deletedCount) : 0, totalRounds: Math.round(totalRounds / trials), clusters: [{ name: 'all', nodeCount: 15 * trials, recordCount: finalRecords, tombstoneCount: finalTombstones, }], }); }; const testSparseNetwork = (): void => { const trials = 50; const maxRounds = 99999; let deletedCount = 0; let totalDeletionRounds = 0; let totalRounds = 0; let finalRecords = 0; let finalTombstones = 0; for (let trial = 0; trial < trials; trial++) { let network = createNetwork(25, 0.15); const recordId = `sparse-${trial}`; network = addRecordToNetwork(network, "node-0", recordId, "Test"); network = gossipRounds(network, recordId, 50); network = addTombstoneToNetwork(network, "node-0", recordId); const result = runToConvergence(network, recordId, maxRounds); if (result.recordsDeleted) { deletedCount++; totalDeletionRounds += result.roundsToDeleteRecords; } totalRounds += result.totalRounds; const stats = getClusterStats(result.network, recordId); finalRecords += stats.recordCount; finalTombstones += stats.tombstoneCount; } printSimulationResult({ testName: `Sparse Network (${trials} trials, 15% connectivity)`, recordsDeleted: deletedCount === trials, roundsToDeleteRecords: deletedCount > 0 ? Math.round(totalDeletionRounds / deletedCount) : 0, totalRounds: Math.round(totalRounds / trials), clusters: [{ name: 'all', nodeCount: 25 * trials, recordCount: finalRecords, tombstoneCount: finalTombstones, }], }); }; const runAllTests = (): void => { console.log("=== HyperLogLog Tombstone Simulation ==="); testSingleNodeDeletion(); testEarlyTombstoneCreation(); testBridgedNetwork(); testConcurrentTombstones(); testNetworkPartitionHeal(); testSparseNetwork(); testDynamicTopology(); testNodeChurn(); testRandomConfigurationChanges(); testNodeDropoutAndReconnect(); console.log("\n=== Simulation Complete ==="); }; runAllTests();