#!/usr/bin/env node
/**
* contrast-rules.js — Generate meta-cognition atoms from success/failure contrast
*
* For each resolved loop, contrast filesTried vs resolution.files to find
* "approaches that didn't work" and "approach that did work."
*
* Also clusters loops by (project, language, loop-type) to find patterns that
* hold across multiple loops — stronger evidence than single-loop contrasts.
*
* Output:
* meta: atoms with: context, positive-approach, negative-approaches, evidence-count
* contrast-edges: meta-atom → file edges (approach-works, approach-fails)
*
* Usage:
* node atomspace/contrast-rules.js # generate and ingest
* node atomspace/contrast-rules.js --dry-run # print, don't ingest
*/
const fs = require('fs')
const path = require('path')
const { evalScheme, isCogServerUp } = require('./cogserver')
const S = require('./schema')
const LOOPS_FILE = path.join(__dirname, '..', 'data', 'loops.json')
function esc (s) {
return String(s || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"')
}
/**
* Normalize file path to match codetree conventions.
* Strips /storage/projects/code/hypercore-kb/hyper-fleeky/ etc.
*/
function normalizePath (p) {
if (!p) return ''
return p
.replace(/^\/storage\/projects\/code\/hypercore-kb\/hyper-fleeky\//, '')
.replace(/^\/storage\/projects\/code\/hypercore-kb\//, '')
.replace(/^\/storage\/projects\/code\//, '')
}
/**
* Infer language from file extension.
*/
function langOf (p) {
if (/\.(cpp|cc|h|hpp)$/.test(p)) return 'cpp'
if (/\.gd$/.test(p)) return 'gdscript'
if (/\.(glsl|gdshader|shader)$/.test(p)) return 'glsl'
if (/\.py$/.test(p)) return 'python'
if (/\.js$/.test(p)) return 'javascript'
if (/\.md$/.test(p)) return 'markdown'
if (/\.scm$/.test(p)) return 'scheme'
if (/\.json$/.test(p)) return 'json'
return 'unknown'
}
/**
* Per-loop contrast: files tried that didn't fix it vs files that did.
*/
function contrastPerLoop (loop) {
if (!loop.resolved) return null
if (!loop.filesTried || !loop.resolution || !loop.resolution.files) return null
const tried = loop.filesTried.map(normalizePath).filter(Boolean)
const worked = loop.resolution.files.map(normalizePath).filter(Boolean)
const wasted = tried.filter(f => !worked.includes(f))
if (worked.length === 0 || wasted.length === 0) return null
// Primary language of the resolution (usually same-language files cluster together)
const workedLangs = worked.map(langOf)
const dominantLang = workedLangs[0] || 'unknown'
return {
loopId: loop.fingerprint || loop.id,
project: loop.project,
type: loop.type,
language: dominantLang,
worked, // approach that did work
wasted, // approaches that didn't work
correctionSnippet: (loop.corrections && loop.corrections[0] || '').slice(0, 200),
duration: loop.duration,
correctionCount: loop.correctionCount || 0
}
}
/**
* Cluster per-loop contrasts into cross-loop patterns.
* Group by (project, language, loop-type) — loops in same context.
*
* For each group, compute per-file:
* resolveCount — # of loops where this file was in resolution.files
* wastedCount — # of loops where this file was in filesTried but NOT resolution
*
* Files with high resolveCount = "try this first"
* Files with high wastedCount = "don't try this"
*/
function clusterContrasts (perLoopContrasts) {
const groups = new Map()
for (const c of perLoopContrasts) {
const key = `${c.project}:${c.language}:${c.type}`
if (!groups.has(key)) {
groups.set(key, { project: c.project, language: c.language, type: c.type, loops: [] })
}
groups.get(key).loops.push(c)
}
// Build a broader grouping: also group by (project, language) alone — wider nets.
const broadGroups = new Map()
for (const c of perLoopContrasts) {
const key = `${c.project}:${c.language}:*`
if (!broadGroups.has(key)) {
broadGroups.set(key, { project: c.project, language: c.language, type: '*', loops: [] })
}
broadGroups.get(key).loops.push(c)
}
for (const [key, grp] of broadGroups) {
if (!groups.has(key)) groups.set(key, grp)
}
const patterns = []
for (const [key, grp] of groups) {
if (grp.loops.length < 2) continue // need ≥2 loops for a pattern (research threshold)
const resolveCount = new Map()
const wastedCount = new Map()
for (const c of grp.loops) {
for (const f of c.worked) {
resolveCount.set(f, (resolveCount.get(f) || 0) + 1)
}
for (const f of c.wasted) {
wastedCount.set(f, (wastedCount.get(f) || 0) + 1)
}
}
// Strong signals: file resolved ≥2 loops, or file was wasted ≥3 times
const tryFirst = [...resolveCount.entries()]
.filter(([f, n]) => n >= 2)
.sort((a, b) => b[1] - a[1])
.map(([f, n]) => ({ file: f, evidence: n }))
const avoid = [...wastedCount.entries()]
.filter(([f, n]) => n >= 2 && (resolveCount.get(f) || 0) === 0)
.sort((a, b) => b[1] - a[1])
.map(([f, n]) => ({ file: f, evidence: n }))
if (tryFirst.length > 0 || avoid.length > 0) {
patterns.push({
key,
context: { project: grp.project, language: grp.language, type: grp.type },
loopCount: grp.loops.length,
tryFirst,
avoid
})
}
}
return patterns
}
/**
* Generate Scheme expressions for meta-cognition atoms and ingest them.
*/
async function ingestContrasts (perLoopContrasts, patterns, dryRun = false) {
const exprs = []
// Per-loop contrast atoms (individual evidence)
for (const c of perLoopContrasts) {
const metaId = `meta:loop:${c.loopId}`
const metaNode = `(ConceptNode "${esc(metaId)}")`
exprs.push(metaNode)
exprs.push(`(cog-set-value! ${metaNode} (Predicate "kind") (StringValue "per-loop-contrast"))`)
exprs.push(`(cog-set-value! ${metaNode} (Predicate "project") (StringValue "${esc(c.project)}"))`)
exprs.push(`(cog-set-value! ${metaNode} (Predicate "loop-type") (StringValue "${esc(c.type)}"))`)
exprs.push(`(cog-set-value! ${metaNode} (Predicate "language") (StringValue "${esc(c.language)}"))`)
exprs.push(`(cog-set-value! ${metaNode} (Predicate "correction-snippet") (StringValue "${esc(c.correctionSnippet)}"))`)
exprs.push(`(cog-set-value! ${metaNode} (Predicate "duration") (FloatValue ${c.duration || 0}))`)
// Link meta → worked files (approach-works)
for (const f of c.worked) {
exprs.push(`(EdgeLink (BondNode "approach-works") (ListLink ${metaNode} (ConceptNode "file:${esc(f)}")))`)
}
// Link meta → wasted files (approach-fails)
for (const f of c.wasted) {
exprs.push(`(EdgeLink (BondNode "approach-fails") (ListLink ${metaNode} (ConceptNode "file:${esc(f)}")))`)
}
}
// Cross-loop pattern atoms (aggregate evidence)
for (const p of patterns) {
const metaId = `meta:pattern:${p.key}`
const metaNode = `(ConceptNode "${esc(metaId)}")`
exprs.push(metaNode)
exprs.push(`(cog-set-value! ${metaNode} (Predicate "kind") (StringValue "cross-loop-pattern"))`)
exprs.push(`(cog-set-value! ${metaNode} (Predicate "project") (StringValue "${esc(p.context.project)}"))`)
exprs.push(`(cog-set-value! ${metaNode} (Predicate "language") (StringValue "${esc(p.context.language)}"))`)
exprs.push(`(cog-set-value! ${metaNode} (Predicate "loop-type") (StringValue "${esc(p.context.type)}"))`)
exprs.push(`(cog-set-value! ${metaNode} (Predicate "loop-count") (FloatValue ${p.loopCount}))`)
for (const t of p.tryFirst) {
exprs.push(`(EdgeLink (BondNode "try-first") (ListLink ${metaNode} (ConceptNode "file:${esc(t.file)}")))`)
// Store evidence count as Value on the EdgeLink? Use a separate counter.
}
for (const a of p.avoid) {
exprs.push(`(EdgeLink (BondNode "avoid-first") (ListLink ${metaNode} (ConceptNode "file:${esc(a.file)}")))`)
}
}
console.log(`Generated ${exprs.length} Scheme expressions from:`)
console.log(` ${perLoopContrasts.length} per-loop contrasts`)
console.log(` ${patterns.length} cross-loop patterns`)
if (dryRun) {
console.log('\n--- SAMPLE PATTERNS (top 5 by loop count) ---')
for (const p of patterns.sort((a, b) => b.loopCount - a.loopCount).slice(0, 5)) {
console.log(`\n ${p.key} (${p.loopCount} loops)`)
if (p.tryFirst.length) {
console.log(' try first:')
for (const t of p.tryFirst.slice(0, 3)) console.log(` ${t.file} (resolved ${t.evidence} loops)`)
}
if (p.avoid.length) {
console.log(' avoid first:')
for (const a of p.avoid.slice(0, 3)) console.log(` ${a.file} (wasted ${a.evidence} times)`)
}
}
return { ok: true, dryRun: true, contrasts: perLoopContrasts.length, patterns: patterns.length }
}
if (!await isCogServerUp()) {
console.log('CogServer not running, skipping ingest')
return { ok: false, reason: 'cogserver-down' }
}
// Batch ingest
const BATCH = 50
let sent = 0
for (let i = 0; i < exprs.length; i += BATCH) {
const batch = exprs.slice(i, i + BATCH)
const result = await evalScheme('(begin ' + batch.join(' ') + ')')
if (result === null) {
console.error(`Connection lost at batch ${i}`)
return { ok: false, reason: 'connection-lost', sent }
}
sent += batch.length
}
console.log(`Ingested ${sent} atoms/edges`)
return { ok: true, sent, contrasts: perLoopContrasts.length, patterns: patterns.length }
}
async function main () {
const dryRun = process.argv.includes('--dry-run')
const loopsData = JSON.parse(fs.readFileSync(LOOPS_FILE, 'utf8'))
const loops = loopsData.loops || []
console.log(`Analyzing ${loops.length} loops...`)
// Per-loop contrasts
const contrasts = loops.map(contrastPerLoop).filter(Boolean)
console.log(`Per-loop contrasts: ${contrasts.length}`)
// Cross-loop patterns
const patterns = clusterContrasts(contrasts)
console.log(`Cross-loop patterns: ${patterns.length}`)
const result = await ingestContrasts(contrasts, patterns, dryRun)
console.log('\n' + JSON.stringify(result, null, 2))
}
main().catch(err => { console.error(err.message); process.exit(1) })