#!/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 }