OverviewFilesCommitsIssuesDocsReleasesPeers

knowledge-ratchet / atomspace / graph-export.js

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

/**
 * graph-export.js — Export a slice of AtomSpace as D3-compatible JSON
 *
 * Queries AtomSpace for a filtered subgraph and returns {nodes, edges}
 * in the format expected by D3 force-directed layout.
 *
 * Filters (via query params or options):
 *   - module: only include atoms related to this module
 *   - minWisdom: filter atoms below this wisdom score
 *   - nodeTypes: which prefixes to include (file, mod, rule, loop, meta, pkg)
 *   - limit: max nodes to return (default 100)
 *
 * Usage:
 *   const { exportGraph } = require('./graph-export')
 *   const graph = await exportGraph({ minWisdom: 0.5, limit: 150 })
 *   // graph = { nodes: [...], edges: [...] }
 *
 * CLI:
 *   node graph-export.js                         # default slice
 *   node graph-export.js --module=pear-git       # focused
 *   node graph-export.js --min-wisdom=1.0        # only high-wisdom
 */

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

// Node type classification by prefix
const NODE_TYPES = {
  'file:': 'file',
  'mod:': 'module',
  'pkg:': 'package',
  'fn:': 'function',
  'rule:': 'rule',
  'loop:': 'loop',
  'meta:pattern:': 'pattern',
  'meta:loop:': 'meta-loop',
  'session:': 'session',
  'live:correction:': 'correction',
  'live:approval:': 'approval',
  'live:edit:': 'edit'
}

function classifyAtom (name) {
  for (const [prefix, type] of Object.entries(NODE_TYPES)) {
    if (name.startsWith(prefix)) return type
  }
  return 'other'
}

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
}

/**
 * Get wisdom score for an atom (0 if not scored).
 */
async function getWisdom (name) {
  const result = await evalScheme(
    `(let ((v (cog-value (ConceptNode "${name.replace(/"/g, '\\"')}") (Predicate "wisdom")))) (if v (cog-value-ref v 0) 0))`)
  return parseFloat(result) || 0
}

/**
 * Collect candidate atoms based on filter criteria.
 */
async function collectCandidates (opts) {
  await evalScheme('(use-modules (srfi srfi-1))')

  // If a specific module requested, start from there and expand
  if (opts.module) {
    return await collectModuleSlice(opts.module, opts.limit || 100)
  }

  // Otherwise: top wisdom atoms + their connections
  return await collectTopWisdomSlice(opts.minWisdom || 0.5, opts.limit || 100)
}

/**
 * Collect a slice centered on one module.
 */
async function collectModuleSlice (moduleName, limit) {
  const modNode = `(ConceptNode "mod:${moduleName.replace(/"/g, '\\"')}")`
  const atoms = new Set([`mod:${moduleName}`])

  // Files contained in this module
  const filesStr = await evalScheme(
    `(cog-execute! (Meet (TypedVariable (Variable "$f") (Type (quote ConceptNode))) (Present (EdgeLink (BondNode "contains") (ListLink ${modNode} (Variable "$f"))))))`)
  for (const n of parseAtomNames(filesStr)) atoms.add(n)

  // Files this module depends on / imports
  const depsStr = await evalScheme(
    `(cog-execute! (Meet (TypedVariable (Variable "$d") (Type (quote ConceptNode))) (Present (EdgeLink (BondNode "depends-on") (ListLink ${modNode} (Variable "$d"))))))`)
  for (const n of parseAtomNames(depsStr)) atoms.add(n)

  // Rules and meta atoms for the files in this module
  const files = [...atoms].filter(n => n.startsWith('file:'))
  for (const file of files.slice(0, 30)) {
    const fileAtom = `(ConceptNode "${file.replace(/"/g, '\\"')}")`
    const rulesStr = await evalScheme(
      `(cog-execute! (Meet (TypedVariable (Variable "$r") (Type (quote ConceptNode))) (Present (EdgeLink (BondNode "rule-for-file") (ListLink (Variable "$r") ${fileAtom})))))`)
    for (const n of parseAtomNames(rulesStr)) atoms.add(n)

    const loopsStr = await evalScheme(
      `(cog-execute! (Meet (TypedVariable (Variable "$l") (Type (quote ConceptNode))) (Present (EdgeLink (BondNode "loop-in-file") (ListLink (Variable "$l") ${fileAtom})))))`)
    for (const n of parseAtomNames(loopsStr)) atoms.add(n)

    if (atoms.size >= limit) break
  }

  return [...atoms].slice(0, limit)
}

/**
 * Collect top-wisdom atoms plus their immediate neighbors.
 */
async function collectTopWisdomSlice (minWisdom, limit) {
  // Get all meta atoms and rules with wisdom values
  const allStr = await evalScheme(
    '(string-join (map cog-name (cog-get-atoms (quote ConceptNode))) "|")')
  const allAtoms = (allStr || '').replace(/^"|"$/g, '').split('|').filter(Boolean)

  const metaAndRules = allAtoms.filter(n =>
    n.startsWith('rule:') || n.startsWith('meta:pattern:') || n.startsWith('meta:loop:'))

  const scored = []
  for (const name of metaAndRules) {
    const wisdom = await getWisdom(name)
    if (wisdom >= minWisdom) scored.push({ name, wisdom })
  }

  scored.sort((a, b) => b.wisdom - a.wisdom)
  const atoms = new Set(scored.slice(0, Math.min(50, Math.floor(limit / 2))).map(s => s.name))

  // Expand to include connected files and modules
  for (const atomName of [...atoms]) {
    if (atoms.size >= limit) break

    const atomLiteral = `(ConceptNode "${atomName.replace(/"/g, '\\"')}")`
    const bonds = atomName.startsWith('rule:')
      ? ['rule-for-file']
      : atomName.startsWith('meta:pattern:')
        ? ['try-first', 'avoid-first']
        : ['approach-works', 'approach-fails']

    for (const bond of bonds) {
      if (atoms.size >= limit) break
      const result = await evalScheme(
        `(cog-execute! (Meet (TypedVariable (Variable "$f") (Type (quote ConceptNode))) (Present (EdgeLink (BondNode "${bond}") (ListLink ${atomLiteral} (Variable "$f"))))))`)
      for (const n of parseAtomNames(result)) {
        if (atoms.size >= limit) break
        atoms.add(n)
      }
    }
  }

  // Also add the modules that contain the files
  const files = [...atoms].filter(n => n.startsWith('file:'))
  for (const file of files.slice(0, 30)) {
    if (atoms.size >= limit) break
    const fileAtom = `(ConceptNode "${file.replace(/"/g, '\\"')}")`
    const modStr = await evalScheme(
      `(cog-execute! (Meet (TypedVariable (Variable "$m") (Type (quote ConceptNode))) (Present (EdgeLink (BondNode "contains") (ListLink (Variable "$m") ${fileAtom})))))`)
    for (const n of parseAtomNames(modStr)) {
      if (atoms.size >= limit) break
      atoms.add(n)
    }
  }

  return [...atoms]
}

/**
 * Collect all edges between the given set of atoms.
 */
async function collectEdges (atomSet) {
  const atomNames = new Set(atomSet)
  const edges = []
  const bondTypes = [
    'contains', 'imports', 'depends-on', 'exports',
    'loop-in-file', 'rule-for-file', 'resolved-by',
    'approach-works', 'approach-fails', 'try-first', 'avoid-first'
  ]

  for (const bond of bondTypes) {
    const edgeStr = await evalScheme(
      `(cog-incoming-by-type (BondNode "${bond}") (quote EdgeLink))`)
    if (!edgeStr) continue

    // Parse: each Edge has (Bond X) (List A B)
    const listPattern = /\(List\s+\(Concept "([^"]+)"\)\s+\(Concept "([^"]+)"\)\)/g
    let m
    while ((m = listPattern.exec(edgeStr)) !== null) {
      const [, src, dst] = m
      if (atomNames.has(src) && atomNames.has(dst)) {
        edges.push({ source: src, target: dst, type: bond })
      }
    }
  }

  return edges
}

/**
 * Build a D3-ready graph.
 */
async function exportGraph (opts = {}) {
  if (!await isCogServerUp()) {
    return { nodes: [], edges: [], error: 'CogServer not running' }
  }

  const candidates = await collectCandidates(opts)
  if (candidates.length === 0) {
    return { nodes: [], edges: [], error: 'no atoms matched filters' }
  }

  // Fetch wisdom for all candidates (for sizing)
  const nodes = []
  for (const name of candidates) {
    const type = classifyAtom(name)
    let wisdom = 0
    if (name.startsWith('rule:') || name.startsWith('meta:')) {
      wisdom = await getWisdom(name)
    }

    // Short display label
    let label = name
    if (name.startsWith('file:')) label = name.slice(5).split('/').pop()
    else if (name.startsWith('mod:')) label = name.slice(4)
    else if (name.startsWith('pkg:')) label = name.slice(4)
    else if (name.startsWith('rule:')) label = name.slice(5, 25) + '…'
    else if (name.startsWith('meta:pattern:')) label = name.slice(13)
    else if (name.startsWith('meta:loop:')) label = 'loop:' + name.slice(10, 18)
    else if (name.startsWith('loop:')) label = name.slice(5, 25) + '…'

    nodes.push({
      id: name,
      type,
      label,
      wisdom,
      // Fully-qualified for tooltip
      fullName: name
    })
  }

  const edges = await collectEdges(candidates)

  return {
    nodes,
    edges,
    stats: {
      nodeCount: nodes.length,
      edgeCount: edges.length,
      byType: nodes.reduce((acc, n) => { acc[n.type] = (acc[n.type] || 0) + 1; return acc }, {})
    }
  }
}

// CLI
if (require.main === module) {
  const args = process.argv.slice(2)
  const opts = {}
  for (const a of args) {
    if (a.startsWith('--module=')) opts.module = a.split('=')[1]
    else if (a.startsWith('--min-wisdom=')) opts.minWisdom = parseFloat(a.split('=')[1])
    else if (a.startsWith('--limit=')) opts.limit = parseInt(a.split('=')[1])
  }
  exportGraph(opts).then(result => {
    console.log(JSON.stringify(result, null, 2))
  }).catch(err => { console.error(err.message); process.exit(1) })
}

module.exports = { exportGraph }