/** * VerbEmbeddingSignal - Neural semantic similarity for relationship classification * * WEIGHT: 35% (second highest after exact match) * * Uses: * 1. 40 pre-computed verb type embeddings (384 dimensions) * 2. Cosine similarity against context text * 3. Semantic understanding of relationship intent * */ import type { Brainy } from '../../brainy.js' import { VerbType } from '../../types/graphTypes.js' import type { Vector } from '../../coreTypes.js' import { getVerbTypeEmbeddings } from '../embeddedTypeEmbeddings.js' import { cosineDistance } from '../../utils/distance.js' /** * Signal result with classification details */ export interface VerbSignal { type: VerbType confidence: number evidence: string metadata?: { similarity?: number allScores?: Array<{ type: VerbType; similarity: number }> } } /** * Options for verb embedding signal */ export interface VerbEmbeddingSignalOptions { minConfidence?: number // Minimum confidence threshold (default: 0.60) minSimilarity?: number // Minimum cosine similarity (default: 0.55) topK?: number // Number of top candidates to consider (default: 3) cacheSize?: number // LRU cache size (default: 2000) enableTemporalBoosting?: boolean // Boost recently seen relationships (default: true) } /** * Historical relationship entry for temporal boosting */ interface HistoricalEntry { text: string type: VerbType vector: Vector timestamp: number uses: number } /** * VerbEmbeddingSignal - Neural relationship type classification * * Production features: * - Uses 40 pre-computed verb type embeddings (zero runtime cost) * - Cosine similarity for semantic matching * - Temporal boosting for recently seen patterns * - LRU cache for hot paths * - Confidence calibration based on similarity distribution */ export class VerbEmbeddingSignal { private brain: Brainy private distanceFn: (a: Vector, b: Vector) => number private options: Required // Pre-computed verb type embeddings (loaded once at startup) private verbTypeEmbeddings: Map // Historical data for temporal boosting private history: HistoricalEntry[] = [] private readonly MAX_HISTORY = 1000 // LRU cache private cache: Map = new Map() private cacheOrder: string[] = [] // Statistics private stats = { calls: 0, cacheHits: 0, matches: 0, temporalBoosts: 0, averageSimilarity: 0 } constructor(brain: Brainy, options?: VerbEmbeddingSignalOptions) { this.brain = brain // Visibility boundary: Brainy keeps its distance function in a private // field. The signal reuses it when present so verb classification matches // the index geometry, falling back to cosine distance otherwise. this.distanceFn = (brain as unknown as { distance?: (a: Vector, b: Vector) => number }).distance || cosineDistance this.options = { minConfidence: options?.minConfidence ?? 0.60, minSimilarity: options?.minSimilarity ?? 0.55, topK: options?.topK ?? 3, cacheSize: options?.cacheSize ?? 2000, enableTemporalBoosting: options?.enableTemporalBoosting ?? true } // Load pre-computed verb type embeddings this.verbTypeEmbeddings = getVerbTypeEmbeddings() // Verify embeddings loaded if (this.verbTypeEmbeddings.size === 0) { throw new Error('VerbEmbeddingSignal: Failed to load verb type embeddings') } } /** * Classify relationship type using semantic similarity * * @param context Full context text (sentence or paragraph) * @param contextVector Optional pre-computed embedding (performance optimization) * @returns VerbSignal with classified type or null */ async classify( context: string, contextVector?: Vector ): Promise { this.stats.calls++ if (!context || context.trim().length === 0) { return null } // Check cache const cacheKey = this.getCacheKey(context) const cached = this.getFromCache(cacheKey) if (cached !== undefined) { this.stats.cacheHits++ return cached } try { // Get context embedding const embedding = contextVector ?? await this.getEmbedding(context) if (!embedding || embedding.length === 0) { return null } // Compute similarities against all verb types const similarities: Array<{ type: VerbType; similarity: number }> = [] for (const [verbType, typeEmbedding] of this.verbTypeEmbeddings) { const distance = this.distanceFn(embedding, typeEmbedding) const similarity = 1 - distance // Convert distance to similarity similarities.push({ type: verbType, similarity }) } // Sort by similarity (descending) similarities.sort((a, b) => b.similarity - a.similarity) // Get top K candidates const topCandidates = similarities.slice(0, this.options.topK) // Check if best candidate meets threshold const best = topCandidates[0] if (!best || best.similarity < this.options.minSimilarity) { const result = null this.addToCache(cacheKey, result) return result } // Apply temporal boosting if enabled let boostedSimilarity = best.similarity let temporalBoost = 0 if (this.options.enableTemporalBoosting) { const boost = this.getTemporalBoost(context, best.type) if (boost > 0) { temporalBoost = boost boostedSimilarity = Math.min(1.0, best.similarity + boost) this.stats.temporalBoosts++ } } // Calibrate confidence based on similarity distribution const confidence = this.calibrateConfidence(boostedSimilarity, topCandidates) if (confidence < this.options.minConfidence) { const result = null this.addToCache(cacheKey, result) return result } // Update rolling average similarity this.stats.averageSimilarity = (this.stats.averageSimilarity * (this.stats.calls - 1) + best.similarity) / this.stats.calls this.stats.matches++ const result: VerbSignal = { type: best.type, confidence, evidence: `Semantic similarity: ${(best.similarity * 100).toFixed(1)}%${temporalBoost > 0 ? ` (temporal boost: +${(temporalBoost * 100).toFixed(1)}%)` : ''}`, metadata: { similarity: best.similarity, allScores: topCandidates } } this.addToCache(cacheKey, result) return result } catch (error) { return null } } /** * Get embedding for context text */ private async getEmbedding(text: string): Promise { try { // Use brain's embedding service const embedding = await this.brain.embed(text) return embedding } catch (error) { return null } } /** * Calibrate confidence based on similarity distribution * * Higher confidence when: * - Top similarity is high * - Clear gap between top and second-best * - Top K candidates agree on same type */ private calibrateConfidence( topSimilarity: number, topCandidates: Array<{ type: VerbType; similarity: number }> ): number { let confidence = topSimilarity // Boost confidence if there's a clear gap to second-best if (topCandidates.length >= 2) { const gap = topSimilarity - topCandidates[1].similarity if (gap > 0.15) { confidence = Math.min(1.0, confidence + 0.05) // Clear winner bonus } else if (gap < 0.05) { confidence = Math.max(0.0, confidence - 0.05) // Ambiguous penalty } } // Boost confidence if multiple candidates agree on same type const topType = topCandidates[0].type const agreementCount = topCandidates.filter(c => c.type === topType).length if (agreementCount > 1) { confidence = Math.min(1.0, confidence + 0.03 * (agreementCount - 1)) } return confidence } /** * Get temporal boost for recently seen patterns * * Boosts confidence if similar context was recently classified as the same type */ private getTemporalBoost(context: string, type: VerbType): number { if (this.history.length === 0) { return 0 } const now = Date.now() const recentThreshold = 60000 // 1 minute // Find recent similar patterns with same type for (const entry of this.history) { if (entry.type !== type) continue if (now - entry.timestamp > recentThreshold) continue // Check text similarity (simple substring check for now) const normalized = context.toLowerCase() const histNormalized = entry.text.toLowerCase() if (normalized.includes(histNormalized) || histNormalized.includes(normalized)) { // Boost decays with age const age = now - entry.timestamp const decay = 1 - (age / recentThreshold) return 0.05 * decay // Max 5% boost } } return 0 } /** * Add pattern to history for temporal boosting */ addToHistory(text: string, type: VerbType, vector: Vector): void { // Check if pattern already exists const existing = this.history.find( e => e.text.toLowerCase() === text.toLowerCase() && e.type === type ) if (existing) { existing.timestamp = Date.now() existing.uses++ return } // Add new entry this.history.push({ text, type, vector, timestamp: Date.now(), uses: 1 }) // Evict oldest if over limit if (this.history.length > this.MAX_HISTORY) { this.history.sort((a, b) => b.timestamp - a.timestamp) this.history = this.history.slice(0, this.MAX_HISTORY) } } /** * Clear history */ clearHistory(): void { this.history = [] } /** * Get cache key */ private getCacheKey(context: string): string { return context.toLowerCase().trim().substring(0, 200) } /** * Get from LRU cache */ private getFromCache(key: string): VerbSignal | null | undefined { if (!this.cache.has(key)) { return undefined } const cached = this.cache.get(key) // Move to end (most recently used) this.cacheOrder = this.cacheOrder.filter(k => k !== key) this.cacheOrder.push(key) return cached ?? null } /** * Add to LRU cache with eviction */ private addToCache(key: string, value: VerbSignal | null): void { this.cache.set(key, value) this.cacheOrder.push(key) // Evict oldest if over limit if (this.cache.size > this.options.cacheSize) { const oldest = this.cacheOrder.shift() if (oldest) { this.cache.delete(oldest) } } } /** * Get statistics */ getStats() { return { ...this.stats, verbTypeCount: this.verbTypeEmbeddings.size, historySize: this.history.length, cacheSize: this.cache.size, cacheHitRate: this.stats.calls > 0 ? this.stats.cacheHits / this.stats.calls : 0, matchRate: this.stats.calls > 0 ? this.stats.matches / this.stats.calls : 0 } } /** * Reset statistics */ resetStats(): void { this.stats = { calls: 0, cacheHits: 0, matches: 0, temporalBoosts: 0, averageSimilarity: 0 } } /** * Clear cache */ clearCache(): void { this.cache.clear() this.cacheOrder = [] } }