brainy/src/neural/signals/VerbEmbeddingSignal.ts

407 lines
11 KiB
TypeScript

/**
* 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<VerbEmbeddingSignalOptions>
// Pre-computed verb type embeddings (loaded once at startup)
private verbTypeEmbeddings: Map<VerbType, Vector>
// Historical data for temporal boosting
private history: HistoricalEntry[] = []
private readonly MAX_HISTORY = 1000
// LRU cache
private cache: Map<string, VerbSignal | null> = 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<VerbSignal | null> {
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<Vector | null> {
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 = []
}
}