OverviewFilesCommitsIssuesDocsReleasesPeers

knowledge-ratchet / atomspace / chain-analysis.js

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

/**
 * chain-analysis.js — Analyze continuation chains as "implementations"
 *
 * Each chain = sequence of sessions linked by 'continues' edges.
 * Represents ONE body of work spanning multiple sessions.
 *
 * For each chain:
 *   Speed = approvals per turn across all chain sessions
 *   Complexity = files × correction-density × turns
 *   Productivity = speed / complexity (how fast on hard stuff)
 *   Throughput = approvals per hour (wall-clock)
 *
 * Output: data/chain-analysis.json — per-chain metrics + aggregates
 */

const fs = require('fs')
const path = require('path')

const DATA_DIR = path.join(__dirname, '..', 'data')

function readJSON (name) {
  const file = path.join(DATA_DIR, name)
  if (!fs.existsSync(file)) return null
  return JSON.parse(fs.readFileSync(file, 'utf8'))
}

/**
 * Build chains from sessions.
 * Chain: sequence of sessions sharing gitBranch+cwd, connected by time proximity.
 */
function buildChains (sessions) {
  // Sort by timestamp
  const sorted = [...sessions].sort((a, b) =>
    new Date(a.timestamp || a.date).getTime() - new Date(b.timestamp || b.date).getTime())

  // Group into chains: same gitBranch + cwd, within 7 days gap
  const chains = []
  const sessionToChain = new Map()

  for (const s of sorted) {
    const ts = new Date(s.timestamp || s.date).getTime()
    // Find an existing chain this could belong to (most recent matching)
    let matched = null
    for (let i = chains.length - 1; i >= 0; i--) {
      const chain = chains[i]
      const lastSession = chain.sessions[chain.sessions.length - 1]
      if (lastSession.gitBranch !== s.gitBranch) continue
      if (lastSession.cwd !== s.cwd) continue
      const lastTs = new Date(lastSession.timestamp || lastSession.date).getTime()
      const gapHours = (ts - lastTs) / 1000 / 3600
      if (gapHours <= 24 * 7) {
        matched = chain
        break
      }
    }

    if (matched) {
      matched.sessions.push(s)
    } else {
      chains.push({ sessions: [s] })
    }
    sessionToChain.set(s.sessionId, chains.length - 1)
  }

  return chains
}

/**
 * Compute chain-level metrics.
 */
function analyzeChain (chain, loopsBySession) {
  const sessions = chain.sessions
  const first = sessions[0]
  const last = sessions[sessions.length - 1]

  // Aggregates across all chain sessions
  let totalTurns = 0
  let totalCorrections = 0
  let totalApprovals = 0
  let totalToolCalls = 0
  const uniqueFiles = new Set()
  const uniqueModules = new Set()
  const loopTypes = {}
  let resolvedLoops = 0
  let unresolvedLoops = 0
  const territories = new Set()

  for (const s of sessions) {
    totalTurns += s.turns || 0
    totalCorrections += s.corrections || 0
    totalApprovals += s.approvals || 0
    totalToolCalls += s.totalToolCalls || 0

    // Loops for this session
    const loops = loopsBySession[s.sessionId] || []
    for (const l of loops) {
      if (l.resolved) resolvedLoops++
      else unresolvedLoops++
      loopTypes[l.type] = (loopTypes[l.type] || 0) + 1
      for (const f of (l.filesTried || [])) {
        uniqueFiles.add(f)
        const mod = normalizeToModule(f)
        if (mod) uniqueModules.add(mod)
      }
      for (const f of ((l.resolution || {}).files || [])) {
        uniqueFiles.add(f)
        const mod = normalizeToModule(f)
        if (mod) uniqueModules.add(mod)
      }
    }
  }

  // Duration (wall clock)
  const firstTs = new Date(first.timestamp || first.date).getTime()
  const lastTs = new Date(last.timestamp || last.date).getTime()
  const durationHours = (lastTs - firstTs) / 1000 / 3600

  // Speed metrics
  const approvalsPerTurn = totalTurns > 0 ? totalApprovals / totalTurns : 0
  const approvalsPerHour = durationHours > 0 ? totalApprovals / durationHours : null
  const correctionDensity = totalTurns > 0 ? totalCorrections / totalTurns : 0

  // Complexity: multi-dimensional
  // Higher files = broader surface
  // Higher correction density = harder problems
  // Cross-module = architecturally complex
  const complexity = {
    turns: totalTurns,
    files: uniqueFiles.size,
    modules: uniqueModules.size,
    correctionDensity: Math.round(correctionDensity * 1000) / 1000,
    toolCallsPerTurn: totalTurns > 0 ? Math.round((totalToolCalls / totalTurns) * 10) / 10 : 0,
    // Composite score — normalize each and combine
    score: Math.round(
      (Math.log(1 + uniqueFiles.size) * 2 +
       Math.log(1 + uniqueModules.size) * 3 +
       correctionDensity * 10 +
       Math.log(1 + totalTurns) * 1) * 10
    ) / 10
  }

  // Speed score
  const speed = {
    approvalsPerTurn: Math.round(approvalsPerTurn * 1000) / 1000,
    approvalsPerHour: approvalsPerHour != null ? Math.round(approvalsPerHour * 10) / 10 : null,
    durationHours: Math.round(durationHours * 10) / 10,
    loopResolutionRate: (resolvedLoops + unresolvedLoops) > 0
      ? Math.round((resolvedLoops / (resolvedLoops + unresolvedLoops)) * 100) / 100
      : null
  }

  // Productivity = speed / complexity
  // High productivity = fast progress on hard work
  const productivity = complexity.score > 0
    ? Math.round((approvalsPerTurn * 100 / complexity.score) * 1000) / 1000
    : null

  return {
    chainId: first.sessionId,
    firstDate: first.date,
    lastDate: last.date,
    sessionCount: sessions.length,
    module: inferChainModule(uniqueModules),
    speed,
    complexity,
    productivity,
    loopTypes: Object.keys(loopTypes).length > 0
      ? Object.fromEntries(Object.entries(loopTypes).sort((a, b) => b[1] - a[1]).slice(0, 3))
      : {},
    unresolvedLoops,
    resolvedLoops
  }
}

function normalizeToModule (f) {
  if (!f) return null
  const normalized = f
    .replace(/^\/storage\/projects\/code\/hypercore-kb\/hyper-fleeky\//, '')
    .replace(/^\/storage\/projects\/code\/hypercore-kb\//, '')
    .replace(/^\/storage\/projects\/code\//, '')
  const parts = normalized.split('/')
  if (parts.length > 0 && !parts[0].startsWith('.') && !parts[0].startsWith('/home')) {
    return parts[0]
  }
  return null
}

function inferChainModule (uniqueModules) {
  // Most common module by count
  const arr = [...uniqueModules]
  if (arr.length === 0) return 'unknown'
  if (arr.length === 1) return arr[0]
  return arr.slice(0, 3).join('+')
}

async function main () {
  const fused = readJSON('fused-scores.json')
  const loops = readJSON('loops.json')
  if (!fused) { console.log('No fused-scores'); process.exit(1) }

  const sessions = fused.sessions || []

  // Index loops by session
  const loopsBySession = {}
  for (const l of (loops?.loops || [])) {
    if (!loopsBySession[l.sessionId]) loopsBySession[l.sessionId] = []
    loopsBySession[l.sessionId].push(l)
  }

  const chains = buildChains(sessions)
  console.log(`Analyzing ${chains.length} chains from ${sessions.length} sessions...`)

  const analyzed = chains.map(c => analyzeChain(c, loopsBySession))

  // Sort by complexity descending
  analyzed.sort((a, b) => b.complexity.score - a.complexity.score)

  const output = {
    generated: new Date().toISOString(),
    totalChains: chains.length,
    chains: analyzed
  }
  fs.writeFileSync(path.join(DATA_DIR, 'chain-analysis.json'), JSON.stringify(output, null, 2))

  // Report
  console.log('\n── Top 10 Most Complex Implementations ──')
  console.log('  complexity  speed     productivity  sessions  module                dates')
  for (const c of analyzed.slice(0, 10)) {
    console.log(
      `  ${String(c.complexity.score).padStart(8)}   ` +
      `${String(c.speed.approvalsPerTurn).padEnd(8)}  ` +
      `${String(c.productivity || '-').padEnd(12)}  ` +
      `${String(c.sessionCount).padStart(3)}       ` +
      `${c.module.padEnd(20).slice(0, 20)}  ` +
      `${c.firstDate}→${c.lastDate}`
    )
  }

  console.log('\n── Top 10 Most Productive (speed/complexity) ──')
  const byProd = [...analyzed]
    .filter(c => c.productivity != null && c.complexity.score > 5)
    .sort((a, b) => b.productivity - a.productivity)
    .slice(0, 10)
  for (const c of byProd) {
    console.log(
      `  prod=${String(c.productivity).padEnd(8)} ` +
      `complexity=${String(c.complexity.score).padStart(6)}  ` +
      `speed=${String(c.speed.approvalsPerTurn).padEnd(7)}  ` +
      `${c.module.padEnd(20).slice(0, 20)}  ` +
      `${c.firstDate}`
    )
  }

  console.log('\n── Aggregates by module ──')
  const byModule = {}
  for (const c of analyzed) {
    const primary = c.module.split('+')[0]
    if (!byModule[primary]) byModule[primary] = { chains: 0, totalSpeed: 0, totalComplexity: 0, totalProductivity: 0, prodCount: 0 }
    byModule[primary].chains++
    byModule[primary].totalSpeed += c.speed.approvalsPerTurn
    byModule[primary].totalComplexity += c.complexity.score
    if (c.productivity != null) {
      byModule[primary].totalProductivity += c.productivity
      byModule[primary].prodCount++
    }
  }
  console.log('  module                chains  avg-speed  avg-complexity  avg-productivity')
  const sortedByModule = Object.entries(byModule).sort((a, b) => b[1].chains - a[1].chains)
  for (const [mod, stats] of sortedByModule.slice(0, 10)) {
    const avgSpeed = stats.totalSpeed / stats.chains
    const avgComp = stats.totalComplexity / stats.chains
    const avgProd = stats.prodCount > 0 ? (stats.totalProductivity / stats.prodCount) : null
    console.log(
      `  ${mod.padEnd(20).slice(0, 20)}  ${String(stats.chains).padStart(3)}     ` +
      `${avgSpeed.toFixed(3)}      ${avgComp.toFixed(1).padStart(6)}          ` +
      `${avgProd != null ? avgProd.toFixed(3) : '-'}`
    )
  }
}

if (require.main === module) {
  main().catch(err => { console.error(err.message); process.exit(1) })
}

module.exports = { buildChains, analyzeChain }