OverviewFilesCommitsIssuesDocsReleasesPeers

knowledge-ratchet / atomspace / semantic-embed.js

228 lines · javascript ref:
#!/usr/bin/env node

/**
 * semantic-embed.js — Simple text embedding for semantic repeat detection
 *
 * Current R2 uses Jaccard on word sets with threshold 0.4.
 * Misses semantic repeats with different vocabulary:
 *   "stop that's wrong" vs "no, that's incorrect" vs "wait, that's not right"
 *
 * This module adds:
 *   1. Character n-gram fingerprints (catches morphological variation)
 *   2. Conceptual grouping by correction INTENT (regex-classified)
 *   3. Semantic signature = (intent-class, content-fingerprint)
 *
 * Two corrections are semantic-repeats if they share intent-class AND
 * have high content similarity.
 */

// Intent classes — regex-based "what is this correction saying"
const INTENT_PATTERNS = [
  { name: 'stop-wrong-direction', patterns: [
    /\b(stop|wait|halt)\b.*\b(wrong|incorrect|not right|that's not)\b/i,
    /\b(no|not that)\b.*\b(direction|approach|way)\b/i,
    /\bgoing (in the )?wrong\b/i
  ]},
  { name: 'use-existing', patterns: [
    /\b(use|try) .+ instead\b/i,
    /\bwe (already )?have\b/i,
    /\b(there is|there's) (already )?(a|an) \w+/i,
    /\balready (exists?|built|done)\b/i
  ]},
  { name: 'read-first', patterns: [
    /\bread (the|this) (file|docs?|code|source)\b/i,
    /\b(did|have) you (even )?(read|look at)\b/i,
    /\bRTFM\b/i,
    /\bcheck the (docs?|source|file)\b/i
  ]},
  { name: 'verify-first', patterns: [
    /\bverify\b/i,
    /\bdid you (test|check|confirm|verify)\b/i,
    /\bdouble[- ]?check\b/i,
    /\bconfirm\b/i
  ]},
  { name: 'explain-why', patterns: [
    /\bwhy\b.*\?/i,
    /\bexplain\b/i,
    /\bwhat are you (trying to )?do(ing)?\b/i
  ]},
  { name: 'too-much-too-fast', patterns: [
    /\bslow down\b/i,
    /\btoo (fast|much)\b/i,
    /\bone (thing|step) at a time\b/i,
    /\bwait\b/i
  ]},
  { name: 'wrong-file', patterns: [
    /\bwrong file\b/i,
    /\b(not|that's not) the (file|one)\b/i,
    /\bin the other file\b/i
  ]},
  { name: 'frustration', patterns: [
    /\b(again|how many times|already told you|we discussed)\b/i,
    /\b(still|keep)\b.*\b(wrong|broken|not working)\b/i
  ]},
  { name: 'forgot-context', patterns: [
    /\b(you forgot|forgetting|remember)\b/i,
    /\b(the plan|the task|what we're doing)\b/i
  ]},
  { name: 'destructive-warning', patterns: [
    /\bwhat are you doing\b/i,
    /\bchanging production\b/i,
    /\bdon't (delete|remove|rm)\b/i
  ]}
]

/**
 * Classify a correction into one or more intent classes.
 * Returns array of matching intent names (may be empty).
 */
function classifyIntent (text) {
  if (!text) return []
  const matched = []
  for (const { name, patterns } of INTENT_PATTERNS) {
    if (patterns.some(p => p.test(text))) matched.push(name)
  }
  return matched
}

/**
 * Character n-gram fingerprint — a Set of character trigrams from text.
 * Catches morphological variation (stemming handled via overlap).
 */
function ngramFingerprint (text, n = 3) {
  if (!text) return new Set()
  const cleaned = text.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim()
  const grams = new Set()
  for (let i = 0; i <= cleaned.length - n; i++) {
    grams.add(cleaned.slice(i, i + n))
  }
  return grams
}

/**
 * Cosine-like similarity between two ngram sets.
 * |intersection| / sqrt(|a|*|b|)
 */
function ngramSimilarity (a, b) {
  if (a.size === 0 || b.size === 0) return 0
  let intersection = 0
  for (const g of a) if (b.has(g)) intersection++
  return intersection / Math.sqrt(a.size * b.size)
}

/**
 * Semantic repeat detection — two corrections are repeats if:
 *   - They share at least 1 intent class, AND
 *   - ngram similarity > 0.3 (looser than word Jaccard, since n-grams are granular)
 * OR
 *   - ngram similarity > 0.5 regardless of intent
 */
function isSemanticRepeat (a, b) {
  const intentA = classifyIntent(a)
  const intentB = classifyIntent(b)

  const fpA = ngramFingerprint(a)
  const fpB = ngramFingerprint(b)
  const sim = ngramSimilarity(fpA, fpB)

  // Strong content overlap = repeat
  if (sim > 0.5) return true

  // Shared intent + medium content overlap = repeat
  const sharedIntent = intentA.some(i => intentB.includes(i))
  if (sharedIntent && sim > 0.3) return true

  return false
}

/**
 * Compute novel vs repeat over a stream of corrections.
 * Returns: { novel, repeat, novelRate, byIntent, improvedNovelRate }
 *
 * Also returns the "improved" delta — how many corrections are now classified
 * as repeats that the simple Jaccard missed.
 */
function analyzeCorrections (corrections) {
  let novel = 0, repeat = 0
  const seen = []  // array of { text, intents, fingerprint }
  const byIntent = {}

  for (const c of corrections) {
    const text = c.text || c
    const intents = classifyIntent(text)
    const fp = ngramFingerprint(text)

    // Check against all prior
    let isRepeat = false
    for (const past of seen) {
      const sim = ngramSimilarity(fp, past.fingerprint)
      if (sim > 0.5) { isRepeat = true; break }
      const sharedIntent = intents.some(i => past.intents.includes(i))
      if (sharedIntent && sim > 0.3) { isRepeat = true; break }
    }

    if (isRepeat) repeat++
    else novel++

    // Track by primary intent
    const primary = intents[0] || 'uncategorized'
    if (!byIntent[primary]) byIntent[primary] = { novel: 0, repeat: 0 }
    if (isRepeat) byIntent[primary].repeat++
    else byIntent[primary].novel++

    seen.push({ text, intents, fingerprint: fp })
  }

  const total = novel + repeat
  return {
    novel,
    repeat,
    total,
    novelRate: total > 0 ? novel / total : null,
    byIntent
  }
}

// CLI — run against corrections.log
if (require.main === module) {
  const fs = require('fs')
  const path = require('path')

  const LOG = path.join(__dirname, '..', 'corrections.log')
  if (!fs.existsSync(LOG)) {
    console.log('No corrections log')
    process.exit(0)
  }

  const content = fs.readFileSync(LOG, 'utf8')
  const blocks = content.split('\n---\n').filter(Boolean)
  const corrections = []
  for (const block of blocks) {
    const lines = block.trim().split('\n')
    if (lines.length < 2) continue
    const m = lines[0].match(/^(\d{4}-\d{2}-\d{2}T[\d:.]+Z)\s*\[correction\]/)
    if (!m) continue
    const text = lines.slice(1).filter(l => !l.startsWith('cwd:')).join('\n').trim()
    if (text) corrections.push({ text })
  }

  console.log(`Analyzing ${corrections.length} corrections with semantic repeat detection...`)
  const result = analyzeCorrections(corrections)

  console.log('')
  console.log('== SEMANTIC NOVEL-RATE ==')
  console.log(`Total corrections: ${result.total}`)
  console.log(`Novel: ${result.novel}  (${(result.novelRate * 100).toFixed(1)}%)`)
  console.log(`Repeat: ${result.repeat}  (${((1 - result.novelRate) * 100).toFixed(1)}%)`)

  console.log('\n== By Intent (top 10) ==')
  const sorted = Object.entries(result.byIntent).sort((a, b) => (b[1].novel + b[1].repeat) - (a[1].novel + a[1].repeat))
  for (const [intent, s] of sorted.slice(0, 10)) {
    const total = s.novel + s.repeat
    const novelPct = total > 0 ? (s.novel / total * 100).toFixed(0) : '-'
    console.log(`  ${intent.padEnd(25)} ${total} corrections  (${s.novel} novel, ${s.repeat} repeat, ${novelPct}% novel)`)
  }
}

module.exports = { classifyIntent, ngramFingerprint, ngramSimilarity, isSemanticRepeat, analyzeCorrections }