OverviewFilesCommitsIssuesDocsReleasesPeers

knowledge-ratchet / atomspace / wisdom-score.js

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

/**
 * wisdom-score.js — Compute and store wisdom scores for rules and meta atoms
 *
 * Three measurable dimensions:
 *   1. referent_health — % of files referenced that still exist
 *   2. breadth         — how many distinct contexts the rule applies to
 *   3. utility         — prevented_fires / (prevented + contradicted)
 *
 * Composite: wisdom = utility × referent_health × log(1 + breadth)
 *
 * Storage (as FloatValues on each rule/meta atom):
 *   (Predicate "wisdom")           composite score 0..1+
 *   (Predicate "referent-health")  0..1
 *   (Predicate "breadth")          integer count
 *   (Predicate "utility")          0..1
 *   (Predicate "utility-fires")    times this rule fired
 *   (Predicate "utility-contradictions") times a correction followed a firing
 *
 * NO ATOMS ARE DELETED. This only ranks.
 *
 * Usage:
 *   node atomspace/wisdom-score.js           # compute + store
 *   node atomspace/wisdom-score.js --report  # just print, don't store
 */

const fs = require('fs')
const path = require('path')
const { evalScheme, isCogServerUp } = require('./cogserver')

const HYPERCORE_KB = path.resolve(__dirname, '../..')
const HYPER_FLEEKY = path.join(HYPERCORE_KB, 'hyper-fleeky')

function esc (s) {
  return String(s || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"')
}

/**
 * Parse a scheme result listing atom names.
 */
function parseAtomNames (resultStr) {
  if (!resultStr) return []
  const names = []
  const re = /(?:ConceptNode|Concept) "([^"]+)"/g
  let m
  while ((m = re.exec(resultStr)) !== null) names.push(m[1])
  return names
}

/**
 * Check if a file exists at its real filesystem location.
 * Paths in AtomSpace are normalized — reconstruct the full path.
 */
function fileExists (normalPath) {
  if (!normalPath) return false
  // Skip non-file paths (memory notes, plans, external)
  if (normalPath.startsWith('/home/') || normalPath.startsWith('/tmp/')) {
    // Memory files — check existence at actual path
    try { return fs.existsSync(normalPath) } catch { return false }
  }
  const candidates = [
    normalPath,  // already absolute
    path.join(HYPER_FLEEKY, normalPath),
    path.join(HYPERCORE_KB, normalPath),
    path.join('/storage/projects/code', normalPath),
    path.join('/storage/projects/code/godot', normalPath)  // Godot projects live here
  ]
  for (const p of candidates) {
    try { if (fs.existsSync(p)) return true } catch {}
  }
  // For Godot paths with module/<file>.cpp pattern, try module/src/<file>.cpp
  // (ratchet's project-mapper sometimes strips the src/ prefix)
  const parts = normalPath.split('/')
  if (parts.length === 2) {
    const withSrc = path.join('/storage/projects/code/godot', parts[0], 'src', parts[1])
    try { if (fs.existsSync(withSrc)) return true } catch {}
  }
  return false
}

/**
 * For each rule/meta atom, get its referenced files.
 * Rules link via 'rule-for-file'. Meta atoms link via 'approach-works',
 * 'approach-fails', 'try-first', 'avoid-first'.
 */
async function getFilesForAtom (atomName, bondTypes) {
  const files = new Set()
  for (const bond of bondTypes) {
    const result = await evalScheme(
      `(cog-execute! (Meet (TypedVariable (Variable "$f") (Type (quote ConceptNode))) (Present (EdgeLink (BondNode "${bond}") (ListLink (ConceptNode "${esc(atomName)}") (Variable "$f"))))))`)
    for (const name of parseAtomNames(result)) {
      if (name.startsWith('file:')) files.add(name.slice(5))
    }
  }
  return [...files]
}

/**
 * Breadth: how many distinct contexts this rule applies to.
 * For meta:loop: atoms — always 1 (single loop).
 * For meta:pattern: atoms — read loop-count Value.
 * For rule: atoms — count distinct files referenced (proxy for scope).
 */
async function computeBreadth (atomName) {
  if (atomName.startsWith('meta:pattern:')) {
    const lc = await evalScheme(
      `(let ((v (cog-value (ConceptNode "${esc(atomName)}") (Predicate "loop-count")))) (if v (cog-value-ref v 0) 1))`)
    return Math.max(1, parseFloat(lc) || 1)
  }
  return 1
}

/**
 * Compute wisdom for all rule and meta atoms.
 */
async function computeAll (report = false) {
  if (!await isCogServerUp()) {
    console.log('CogServer not running')
    process.exit(1)
  }

  // Collect all rule: and meta: atoms
  const allAtomsStr = await evalScheme(
    '(string-join (map cog-name (cog-get-atoms (quote ConceptNode))) "|")')
  const allAtoms = (allAtomsStr || '').replace(/^"|"$/g, '').split('|')
  const ruleAtoms = allAtoms.filter(n => n.startsWith('rule:'))
  const metaAtoms = allAtoms.filter(n => n.startsWith('meta:'))

  console.log(`Scoring ${ruleAtoms.length} rule atoms + ${metaAtoms.length} meta atoms`)

  const results = []
  const batch = []

  // Rule atoms: use 'rule-for-file' bond
  for (const atomName of ruleAtoms) {
    const files = await getFilesForAtom(atomName, ['rule-for-file'])
    const existingFiles = files.filter(fileExists)
    const referent_health = files.length > 0 ? existingFiles.length / files.length : 1.0

    // Utility: read existing values, default to 1.0
    const firesStr = await evalScheme(
      `(let ((v (cog-value (ConceptNode "${esc(atomName)}") (Predicate "utility-fires")))) (if v (cog-value-ref v 0) 0))`)
    const contradStr = await evalScheme(
      `(let ((v (cog-value (ConceptNode "${esc(atomName)}") (Predicate "utility-contradictions")))) (if v (cog-value-ref v 0) 0))`)
    const fires = parseFloat(firesStr) || 0
    const contradictions = parseFloat(contradStr) || 0
    let utility = (fires + contradictions) === 0
      ? 1.0  // innocent until proven guilty
      : Math.max(0.05, (fires - contradictions) / (fires + contradictions) * 0.5 + 0.5)

    // Phase 4.2: Time decay — rules that haven't fired in 30+ days decay
    // Does NOT delete — just reduces utility until it fires again
    const lastFiredStr = await evalScheme(
      `(let ((v (cog-value (ConceptNode "${esc(atomName)}") (Predicate "last-fired-at")))) (if v (cog-value-ref v 0) 0))`)
    const lastFired = parseFloat(lastFiredStr) || 0
    if (lastFired > 0) {
      const daysSinceFire = (Date.now() / 1000 - lastFired) / 86400
      if (daysSinceFire > 30) {
        // Gentle decay: -10% utility per 30 days of dormancy, floor 0.3
        const decayFactor = Math.max(0.3, 1 - ((daysSinceFire - 30) / 30) * 0.1)
        utility = utility * decayFactor
      }
    }

    // Breadth: # files referenced (proxy for scope)
    const breadth = Math.max(1, files.length)

    const wisdom = utility * referent_health * Math.log(1 + breadth)

    results.push({
      atom: atomName,
      kind: 'rule',
      files: files.length,
      existing: existingFiles.length,
      referent_health,
      breadth,
      utility,
      fires,
      contradictions,
      lastFiredDaysAgo: lastFired > 0 ? Math.round((Date.now() / 1000 - lastFired) / 86400) : null,
      wisdom
    })

    // Queue for batch ingest
    const node = `(ConceptNode "${esc(atomName)}")`
    batch.push(`(cog-set-value! ${node} (Predicate "referent-health") (FloatValue ${referent_health}))`)
    batch.push(`(cog-set-value! ${node} (Predicate "breadth") (FloatValue ${breadth}))`)
    batch.push(`(cog-set-value! ${node} (Predicate "utility") (FloatValue ${utility}))`)
    batch.push(`(cog-set-value! ${node} (Predicate "wisdom") (FloatValue ${wisdom}))`)
  }

  // Meta atoms: use 'approach-works' + 'approach-fails' or 'try-first' + 'avoid-first'
  for (const atomName of metaAtoms) {
    const bondTypes = atomName.startsWith('meta:pattern:')
      ? ['try-first', 'avoid-first']
      : ['approach-works', 'approach-fails']
    const files = await getFilesForAtom(atomName, bondTypes)
    const existingFiles = files.filter(fileExists)
    const referent_health = files.length > 0 ? existingFiles.length / files.length : 1.0

    const firesStr = await evalScheme(
      `(let ((v (cog-value (ConceptNode "${esc(atomName)}") (Predicate "utility-fires")))) (if v (cog-value-ref v 0) 0))`)
    const contradStr = await evalScheme(
      `(let ((v (cog-value (ConceptNode "${esc(atomName)}") (Predicate "utility-contradictions")))) (if v (cog-value-ref v 0) 0))`)
    const fires = parseFloat(firesStr) || 0
    const contradictions = parseFloat(contradStr) || 0
    const utility = (fires + contradictions) === 0
      ? 1.0
      : Math.max(0.05, (fires - contradictions) / (fires + contradictions) * 0.5 + 0.5)

    const breadth = await computeBreadth(atomName)
    const wisdom = utility * referent_health * Math.log(1 + breadth)

    results.push({
      atom: atomName,
      kind: atomName.startsWith('meta:pattern:') ? 'pattern' : 'per-loop',
      files: files.length,
      existing: existingFiles.length,
      referent_health,
      breadth,
      utility,
      fires,
      contradictions,
      wisdom
    })

    const node = `(ConceptNode "${esc(atomName)}")`
    batch.push(`(cog-set-value! ${node} (Predicate "referent-health") (FloatValue ${referent_health}))`)
    batch.push(`(cog-set-value! ${node} (Predicate "breadth") (FloatValue ${breadth}))`)
    batch.push(`(cog-set-value! ${node} (Predicate "utility") (FloatValue ${utility}))`)
    batch.push(`(cog-set-value! ${node} (Predicate "wisdom") (FloatValue ${wisdom}))`)
  }

  // Report
  const byWisdom = [...results].sort((a, b) => b.wisdom - a.wisdom)

  console.log('\n── Wisdom Distribution ──')
  const bins = { high: 0, med: 0, low: 0, zero: 0 }
  const highThreshold = 1.0   // high-wisdom rules
  const medThreshold = 0.3
  for (const r of results) {
    if (r.wisdom >= highThreshold) bins.high++
    else if (r.wisdom >= medThreshold) bins.med++
    else if (r.wisdom > 0) bins.low++
    else bins.zero++
  }
  console.log(`  high  (wisdom ≥ 1.0): ${bins.high}`)
  console.log(`  med   (0.3 ≤ wisdom < 1.0): ${bins.med}`)
  console.log(`  low   (0 < wisdom < 0.3): ${bins.low}`)
  console.log(`  zero  (wisdom = 0): ${bins.zero}`)

  console.log('\n── Top 10 wisest rules ──')
  for (const r of byWisdom.slice(0, 10)) {
    const label = r.atom.length > 60 ? r.atom.slice(0, 60) + '…' : r.atom
    console.log(`  wisdom=${r.wisdom.toFixed(2)} (u=${r.utility.toFixed(2)} rh=${r.referent_health.toFixed(2)} br=${r.breadth})  ${label}`)
  }

  console.log('\n── Referent health issues (health < 0.5) ──')
  const broken = results.filter(r => r.referent_health < 0.5 && r.files > 0).slice(0, 10)
  for (const r of broken) {
    const label = r.atom.length > 60 ? r.atom.slice(0, 60) + '…' : r.atom
    console.log(`  health=${r.referent_health.toFixed(2)} (${r.existing}/${r.files} files exist)  ${label}`)
  }

  if (report) {
    console.log('\n(--report mode: not storing)')
    return { total: results.length, bins }
  }

  // Store as FloatValues
  console.log(`\nStoring ${batch.length} wisdom values...`)
  const BATCH = 50
  let sent = 0
  for (let i = 0; i < batch.length; i += BATCH) {
    const slice = batch.slice(i, i + BATCH)
    await evalScheme('(begin ' + slice.join(' ') + ')')
    sent += slice.length
  }
  console.log(`Stored: ${sent} values`)

  return { total: results.length, bins, sent }
}

if (require.main === module) {
  const report = process.argv.includes('--report')
  computeAll(report).then(result => {
    console.log('\n' + JSON.stringify(result, null, 2))
  }).catch(err => { console.error(err.message); process.exit(1) })
}

module.exports = { computeAll }