407 lines
11 KiB
TypeScript
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 = []
|
|
}
|
|
}
|