/** * Background Deduplicator * * Performs 3-tier entity deduplication in background after imports: * - Tier 1: ID-based (O(1)) - Uses entity metadata for deterministic IDs * - Tier 2: Name-based (O(log n)) - Exact name matching (case-insensitive) * - Tier 3: Similarity-based (O(n log n)) - Vector similarity via TypeAware HNSW * * NO MOCKS - Production-ready implementation using existing indexes */ import { Brainy } from '../brainy.js' import { prodLog } from '../utils/logger.js' import { HNSWNounWithMetadata } from '../coreTypes.js' export interface DeduplicationStats { /** Total entities processed */ totalEntities: number /** Duplicates found by ID matching */ tier1Matches: number /** Duplicates found by name matching */ tier2Matches: number /** Duplicates found by similarity */ tier3Matches: number /** Total entities merged/deleted */ totalMerged: number /** Processing time in milliseconds */ processingTime: number } /** * BackgroundDeduplicator - Auto-runs deduplication 5 minutes after imports * * Architecture: * - Debounced trigger (5 min after last import) * - Import-scoped deduplication (no cross-contamination) * - 3-tier strategy (ID → Name → Similarity) * - Uses existing indexes (EntityIdMapper, MetadataIndexManager, TypeAware HNSW) */ export class BackgroundDeduplicator { private brain: Brainy private debounceTimer?: NodeJS.Timeout private pendingImports = new Set() private isProcessing = false constructor(brain: Brainy) { this.brain = brain } /** * Schedule deduplication for an import (debounced 5 minutes) * Called by ImportCoordinator after each import completes */ scheduleDedup(importId: string): void { prodLog.info(`[BackgroundDedup] Scheduled deduplication for import ${importId}`) // Add to pending queue this.pendingImports.add(importId) // Clear existing timer (debouncing) if (this.debounceTimer) { clearTimeout(this.debounceTimer) } // Schedule for 5 minutes from now this.debounceTimer = setTimeout(() => { this.runBatchDedup().catch(error => { prodLog.error('[BackgroundDedup] Batch dedup failed:', error) }) }, 5 * 60 * 1000) } /** * Run deduplication for all pending imports * @private */ private async runBatchDedup(): Promise { if (this.isProcessing) { prodLog.warn('[BackgroundDedup] Already processing, skipping') return } this.isProcessing = true try { const imports = Array.from(this.pendingImports) prodLog.info(`[BackgroundDedup] Processing ${imports.length} pending import(s)`) for (const importId of imports) { await this.deduplicateImport(importId) } this.pendingImports.clear() prodLog.info('[BackgroundDedup] Batch deduplication complete') } finally { this.isProcessing = false } } /** * Deduplicate entities from a specific import * Uses 3-tier strategy: ID → Name → Similarity */ async deduplicateImport(importId: string): Promise { const startTime = performance.now() prodLog.info(`[BackgroundDedup] Starting deduplication for import ${importId}`) const stats: DeduplicationStats = { totalEntities: 0, tier1Matches: 0, tier2Matches: 0, tier3Matches: 0, totalMerged: 0, processingTime: 0 } try { // Get all entities from this import using brain.find() const results = await this.brain.find({ where: { importId }, limit: 100000 // Large limit to get all entities from import }) const entities = results.map(r => r.entity as unknown as HNSWNounWithMetadata) stats.totalEntities = entities.length if (entities.length === 0) { prodLog.info(`[BackgroundDedup] No entities found for import ${importId}`) return stats } prodLog.info(`[BackgroundDedup] Processing ${entities.length} entities from import ${importId}`) // Tier 1: ID-based deduplication (O(1) per entity) const tier1Merged = await this.tier1_IdBased(entities, importId) stats.tier1Matches = tier1Merged stats.totalMerged += tier1Merged // Re-check which entities still exist after Tier 1 let remainingEntities = entities if (tier1Merged > 0) { remainingEntities = await this.filterExisting(entities) prodLog.info(`[BackgroundDedup] After Tier 1: ${entities.length} → ${remainingEntities.length} entities`) } // Tier 2: Name-based deduplication on reduced set const tier2Merged = await this.tier2_NameBased(remainingEntities, importId) stats.tier2Matches = tier2Merged stats.totalMerged += tier2Merged // Re-check which entities still exist after Tier 2 if (tier2Merged > 0) { remainingEntities = await this.filterExisting(remainingEntities) prodLog.info(`[BackgroundDedup] After Tier 2: ${remainingEntities.length} entities remaining`) } // Tier 3: Similarity-based deduplication on final reduced set const tier3Merged = await this.tier3_SimilarityBased(remainingEntities, importId) stats.tier3Matches = tier3Merged stats.totalMerged += tier3Merged stats.processingTime = performance.now() - startTime prodLog.info( `[BackgroundDedup] Completed for import ${importId}: ` + `${stats.totalMerged} merged (T1: ${stats.tier1Matches}, T2: ${stats.tier2Matches}, T3: ${stats.tier3Matches}) ` + `in ${stats.processingTime.toFixed(0)}ms` ) return stats } catch (error) { prodLog.error(`[BackgroundDedup] Error deduplicating import ${importId}:`, error) stats.processingTime = performance.now() - startTime return stats } } /** * Tier 1: ID-based deduplication * Uses entity metadata sourceId field for deterministic matching * Complexity: O(n) where n = number of entities in import */ private async tier1_IdBased(entities: HNSWNounWithMetadata[], importId: string): Promise { const startTime = performance.now() let merged = 0 // Group entities by sourceId (if available) const sourceIdGroups = new Map() for (const entity of entities) { const sourceId = entity.metadata?.sourceId || entity.metadata?.sourceRow if (sourceId) { const key = `${sourceId}` if (!sourceIdGroups.has(key)) { sourceIdGroups.set(key, []) } sourceIdGroups.get(key)!.push(entity) } } // Merge duplicates with same sourceId for (const [sourceId, group] of sourceIdGroups) { if (group.length > 1) { await this.mergeEntities(group, 'ID') merged += group.length - 1 } } const elapsed = performance.now() - startTime if (merged > 0) { prodLog.info(`[BackgroundDedup] Tier 1 (ID): Merged ${merged} duplicates in ${elapsed.toFixed(0)}ms`) } return merged } /** * Tier 2: Name-based deduplication * Exact name matching (case-insensitive, normalized) * Complexity: O(n) where n = number of entities in import */ private async tier2_NameBased(entities: HNSWNounWithMetadata[], importId: string): Promise { const startTime = performance.now() let merged = 0 // Group entities by normalized name const nameGroups = new Map() for (const entity of entities) { const name = entity.metadata?.name if (name && typeof name === 'string') { const normalized = this.normalizeName(name) if (!nameGroups.has(normalized)) { nameGroups.set(normalized, []) } nameGroups.get(normalized)!.push(entity) } } // Merge duplicates with same normalized name and type for (const [name, group] of nameGroups) { if (group.length > 1) { // Further group by type (only merge same types) const typeGroups = new Map() for (const entity of group) { const type = entity.type || 'unknown' if (!typeGroups.has(type)) { typeGroups.set(type, []) } typeGroups.get(type)!.push(entity) } // Merge within each type group for (const [type, typeGroup] of typeGroups) { if (typeGroup.length > 1) { await this.mergeEntities(typeGroup, 'Name') merged += typeGroup.length - 1 } } } } const elapsed = performance.now() - startTime if (merged > 0) { prodLog.info(`[BackgroundDedup] Tier 2 (Name): Merged ${merged} duplicates in ${elapsed.toFixed(0)}ms`) } return merged } /** * Tier 3: Similarity-based deduplication * Uses TypeAware HNSW for vector similarity matching * Complexity: O(n log n) where n = number of entities in import */ private async tier3_SimilarityBased(entities: HNSWNounWithMetadata[], importId: string): Promise { const startTime = performance.now() let merged = 0 // Process in batches to avoid memory spikes const batchSize = 100 const similarityThreshold = 0.85 for (let i = 0; i < entities.length; i += batchSize) { const batch = entities.slice(i, i + batchSize) // Batch vector searches using brain.find() (uses TypeAware HNSW) const searches = batch.map(entity => { const query = `${entity.metadata?.name || ''} ${entity.metadata?.description || ''}`.trim() if (!query) return Promise.resolve([]) return this.brain.find({ query, limit: 5, where: { type: entity.type } // Type-aware search }) }) const results = await Promise.all(searches) // Process matches for (let j = 0; j < batch.length; j++) { const entity = batch[j] const matches = results[j] for (const match of matches) { // Skip self-matches if (match.id === entity.id) continue // Only merge high-similarity matches from same import if (match.score >= similarityThreshold && match.entity.metadata?.importId === importId) { // Check if not already merged const stillExists = await this.brain.get(entity.id) if (stillExists) { // Typed boundary: bridge the public Entity result shape from // brain.find() to the storage-layer HNSWNounWithMetadata record // (same structural bridge as the results.map above). The merge // path only reads id/type/metadata, present in both shapes. const matchEntity = match.entity as unknown as HNSWNounWithMetadata await this.mergeEntities([entity, matchEntity], 'Similarity') merged++ break // Only merge with first high-similarity match } } } } } const elapsed = performance.now() - startTime if (merged > 0) { prodLog.info(`[BackgroundDedup] Tier 3 (Similarity): Merged ${merged} duplicates in ${elapsed.toFixed(0)}ms`) } return merged } /** * Merge multiple entities into one * Keeps entity with highest confidence, merges metadata, deletes duplicates */ private async mergeEntities(entities: HNSWNounWithMetadata[], reason: string): Promise { if (entities.length < 2) return // Find entity with highest confidence const primary = entities.reduce((best, curr) => { const bestConf = best.metadata?.confidence || 0.5 const currConf = curr.metadata?.confidence || 0.5 return currConf > bestConf ? curr : best }) // Merge metadata from all entities const primaryMeta = primary.metadata || {} const mergedMetadata = { ...primaryMeta, // Merge import IDs importIds: Array.from(new Set([ ...(Array.isArray(primaryMeta.importIds) ? primaryMeta.importIds : []), ...entities.flatMap(e => Array.isArray(e.metadata?.importIds) ? e.metadata.importIds : []) ])), // Merge VFS paths vfsPaths: Array.from(new Set([ ...(Array.isArray(primaryMeta.vfsPaths) ? primaryMeta.vfsPaths : []), ...entities.flatMap(e => Array.isArray(e.metadata?.vfsPaths) ? e.metadata.vfsPaths : []) ])), // Merge concepts concepts: Array.from(new Set([ ...(Array.isArray(primaryMeta.concepts) ? primaryMeta.concepts : []), ...entities.flatMap(e => Array.isArray(e.metadata?.concepts) ? e.metadata.concepts : []) ])), // Track merge mergeCount: (typeof primaryMeta.mergeCount === 'number' ? primaryMeta.mergeCount : 0) + (entities.length - 1), mergedWith: entities.filter(e => e.id !== primary.id).map(e => e.id), lastMerged: Date.now(), mergeReason: reason } // Update primary entity with merged metadata await this.brain.update({ id: primary.id, metadata: mergedMetadata, merge: true }) // Delete duplicate entities for (const entity of entities) { if (entity.id !== primary.id) { try { await this.brain.remove(entity.id) } catch (error) { // Entity might already be deleted, continue prodLog.debug(`[BackgroundDedup] Could not delete ${entity.id}:`, error) } } } } /** * Filter entities to only those that still exist (not deleted) * @private */ private async filterExisting(entities: HNSWNounWithMetadata[]): Promise { const existing: HNSWNounWithMetadata[] = [] for (const entity of entities) { const stillExists = await this.brain.get(entity.id) if (stillExists) { existing.push(entity) } } return existing } /** * Normalize string for comparison * Lowercase, trim, remove special characters */ private normalizeName(str: string): string { return str .toLowerCase() .trim() .replace(/[^a-z0-9\s]/g, '') .replace(/\s+/g, ' ') } /** * Cancel pending deduplication (for cleanup) */ cancelPending(): void { if (this.debounceTimer) { clearTimeout(this.debounceTimer) this.debounceTimer = undefined } this.pendingImports.clear() } }