OverviewFilesCommitsIssuesDocsReleasesPeers

knowledge-ratchet / coverage-map.js

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

// Coverage Map — Knowledge Ratchet Phase 2
//
// Maps the knowledge space and identifies gaps (unknown unknowns).
// Topics with zero observations have uncertainty = 1.0 — we can't distinguish
// knowledge from ignorance below the observation threshold (event horizon).
//
// Usage:
//   node coverage-map.js              # full analysis
//   node coverage-map.js --summary    # just the summary stats

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

const DATA_DIR = path.join(__dirname, 'data')
const OUTPUT_FILE = path.join(DATA_DIR, 'coverage-map.json')
const CORRECTIONS_LOG = path.join(__dirname, 'corrections.log')
const FUSED_SCORES_FILE = path.join(DATA_DIR, 'fused-scores.json')
const SKILLS_DIR = path.join(__dirname, '..', '.claude', 'skills')

// ─── Knowledge Space Definition ───
// Three levels of topic taxonomy:
//   Level 1: Project areas (from working directories and known projects)
//   Level 2: Module areas (from skills directory)
//   Level 3: Concept areas (derived from correction/approval text)

// Level 1: Projects — manually defined, maps cwds to topic names
const PROJECT_TOPICS = {
  'hypernote':       { keywords: ['hypernote', 'note', 'notebook', 'hn'] },
  'pear-git':        { keywords: ['pear-git', 'pgh', 'git-remote', 'punch://'] },
  'dc-terrain':      { keywords: ['dc-terrain', 'terrain', 'dual contouring', 'voxel', 'mesh', 'sdf', 'density'] },
  'nvidia-rtx':      { keywords: ['rtx', 'nvidia', 'ray tracing', 'dlss', 'blas', 'tlas', 'rt pipeline'] },
  'blender-bridge':  { keywords: ['blender', 'geonode', 'blender-bridge', 'addon'] },
  'proc-nodes':      { keywords: ['proc-nodes', 'procedural', 'fbm', 'erosion', 'gradient noise'] },
  'hyper-tps':       { keywords: ['hyper-tps', 'tps', 'multiplayer', 'third person'] },
  'hyper-social':    { keywords: ['hyper-social', 'social', 'social-web', 'identity'] },
  'hyper-booking':   { keywords: ['hyper-booking', 'booking', 'calendar'] },
  'hyper-overlay':   { keywords: ['hyper-overlay', 'overlay', 'content-addressed', 'chunker'] },
  'hyper-identity':  { keywords: ['hyper-identity', 'identity', 'keypair', 'anproto'] },
  'hyper-wallet':    { keywords: ['hyper-wallet', 'wallet', 'bitcoin', 'wdk'] },
  'wirecore':        { keywords: ['wirecore', 'wiredove', 'social network'] },
  'hyper-lab':       { keywords: ['hyper-lab', 'multi-device', 'test orchestration'] },
  'hyperident':      { keywords: ['hyperident', 'vpn', 'gateway', 'tun'] },
  'nubis-clouds':    { keywords: ['nubis', 'clouds', 'volumetric', 'compositor', 'atmosphere'] },
  'godot-engine':    { keywords: ['godot', 'gdscript', 'gdextension', 'shader'] },
  'fleekyflux':      { keywords: ['fleekyflux', 'website', '7rnx'] },
}

// Level 3: Concept areas — cross-cutting concerns
const CONCEPT_TOPICS = {
  'deployment':     { keywords: ['deploy', 'stage', 'release', 'seed', 'pear-git', 'ci/cd', 'pear run'] },
  'testing':        { keywords: ['test', 'brittle', 'assertion', 'spec', 'verify'] },
  'storage':        { keywords: ['storage', 'rocksdb', 'hyperbee', 'corestore', 'database', 'persist'] },
  'auth':           { keywords: ['auth', 'identity', 'challenge', 'bearer', 'token', 'login'] },
  'replication':    { keywords: ['replicate', 'replication', 'hyperswarm', 'peer', 'sync', 'swarm'] },
  'multiwriter':    { keywords: ['multiwriter', 'autobase', 'writer', 'linearize', 'apply'] },
  'ui-frontend':    { keywords: ['frontend', 'html', 'css', 'javascript', 'dom', 'ui', 'browser'] },
  'cli':            { keywords: ['cli', 'command', 'argv', 'terminal', 'prompt'] },
  'networking':     { keywords: ['network', 'tcp', 'udp', 'dht', 'nat', 'hole-punch'] },
  'crypto':         { keywords: ['crypto', 'encrypt', 'sodium', 'noise', 'keypair', 'sign'] },
  'streaming':      { keywords: ['stream', 'hls', 'video', 'media', 'ffmpeg'] },
  'git-operations': { keywords: ['git', 'commit', 'branch', 'merge', 'push', 'pull'] },
  'module-system':  { keywords: ['module', 'require', 'import', 'esm', 'bare-pack', 'bundle'] },
  'performance':    { keywords: ['performance', 'optimize', 'benchmark', 'profile', 'cache', 'speed'] },
  'debugging':      { keywords: ['debug', 'error', 'crash', 'fix', 'bug', 'issue', 'broken'] },
  'gpu-compute':    { keywords: ['gpu', 'compute', 'shader', 'glsl', 'vulkan', 'rendering'] },
  'tmux-process':   { keywords: ['tmux', 'session', 'process', 'systemd', 'service', 'daemon'] },
}

// ─── Data Loading ───

function loadCorrections () {
  if (!fs.existsSync(CORRECTIONS_LOG)) return []
  const raw = fs.readFileSync(CORRECTIONS_LOG, 'utf8')
  return raw.split('\n---\n')
    .filter(entry => entry.trim())
    .map(entry => {
      const lines = entry.trim().split('\n')
      const timestampMatch = lines[0]?.match(/^(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)/)
      const typeMatch = lines[0]?.match(/\[(correction|approval)\]/)
      const cwdLine = lines.find(l => l.startsWith('cwd:'))
      const text = lines.filter(l => !l.startsWith('cwd:') && !l.startsWith('session:') && l !== lines[0])
        .join(' ').toLowerCase()

      return {
        timestamp: timestampMatch?.[1] || null,
        type: typeMatch?.[1] || null,
        text,
        cwd: cwdLine?.replace('cwd:', '').trim() || '',
      }
    })
    .filter(c => c.timestamp && c.type)
}

function loadFusedScores () {
  if (!fs.existsSync(FUSED_SCORES_FILE)) return null
  try { return JSON.parse(fs.readFileSync(FUSED_SCORES_FILE, 'utf8')) }
  catch { return null }
}

function loadSkillTopics () {
  // Level 2: Module areas from skills directory
  const topics = {}
  if (!fs.existsSync(SKILLS_DIR)) return topics

  for (const dir of fs.readdirSync(SKILLS_DIR)) {
    const skillDir = path.join(SKILLS_DIR, dir)
    try {
      if (!fs.statSync(skillDir).isDirectory()) continue
    } catch {
      continue // broken symlink or inaccessible
    }

    // Use directory name as topic
    // Only use the full hyphenated name as keyword — splitting creates
    // false matches (e.g. "bits-to-bytes" → "to" matches everything)
    const name = dir
    const keywords = [name]

    topics[`module:${name}`] = { keywords, source: 'skill' }
  }

  return topics
}

// ─── Topic Matching ───

function matchTopics (text, topics) {
  const matched = []
  const lowerText = text.toLowerCase()

  for (const [topicName, topicDef] of Object.entries(topics)) {
    for (const keyword of topicDef.keywords) {
      if (lowerText.includes(keyword.toLowerCase())) {
        matched.push(topicName)
        break
      }
    }
  }

  return matched
}

function matchTopicFromCwd (cwd, topics) {
  const matched = []
  const lowerCwd = cwd.toLowerCase()

  for (const [topicName, topicDef] of Object.entries(topics)) {
    for (const keyword of topicDef.keywords) {
      if (lowerCwd.includes(keyword.toLowerCase())) {
        matched.push(topicName)
        break
      }
    }
  }

  return matched
}

// ─── Main Analysis ───

function analyze () {
  const fusedData = loadFusedScores()
  if (!fusedData) {
    console.error('No fused-scores.json found. Run fuse-signals.js first.')
    process.exit(1)
  }

  // Build complete topic registry
  const allTopics = {}

  // Level 1: Projects
  for (const [name, def] of Object.entries(PROJECT_TOPICS)) {
    allTopics[`project:${name}`] = { ...def, level: 'project' }
  }

  // Level 2: Modules (from skills)
  const moduleTopics = loadSkillTopics()
  for (const [name, def] of Object.entries(moduleTopics)) {
    allTopics[name] = { ...def, level: 'module' }
  }

  // Level 3: Concepts
  for (const [name, def] of Object.entries(CONCEPT_TOPICS)) {
    allTopics[`concept:${name}`] = { ...def, level: 'concept' }
  }

  // Initialize coverage for all topics
  const coverage = {}
  for (const topicName of Object.keys(allTopics)) {
    coverage[topicName] = {
      observations: 0,
      corrections: 0,
      approvals: 0,
      uncertainty: 1.0,
      status: 'unobserved',
      lastSeen: null,
    }
  }

  // PRIMARY DATA SOURCE: fused-scores.json (LLM-weighted, per-session)
  // This replaces corrections.log which was polluted with 4700+ noisy regex entries.
  // Each session has cwd for topic attribution + LLM-weighted correction/approval counts.
  if (fusedData?.sessions) {
    for (const session of fusedData.sessions) {
      const cwdMatches = session.cwd ? matchTopicFromCwd(session.cwd, allTopics) : []
      for (const topic of cwdMatches) {
        if (!coverage[topic]) continue
        coverage[topic].observations += session.turns
        coverage[topic].corrections += session.corrections
        coverage[topic].approvals += session.approvals
        if (session.date && (!coverage[topic].lastSeen || session.date > coverage[topic].lastSeen)) {
          coverage[topic].lastSeen = session.date
        }
      }
    }
  }

  // Compute failure risk and status for each topic
  // Two metrics:
  //   uncertainty — how confident are we in our assessment? (data volume)
  //   failureRisk — how likely is this topic to produce corrections? (quality signal)
  //
  // The old formula (1/(1+obs)) only measured volume. The new formula combines:
  //   1. Correction rate (corrections / total signals) — raw quality
  //   2. Recency decay — recent corrections weigh more (problems may be fixed)
  //   3. Data confidence — low observation count = high uncertainty
  const now = new Date()

  for (const [topicName, data] of Object.entries(coverage)) {
    const total = data.corrections + data.approvals

    // Data confidence: 1/(1+observations) — how much data do we have?
    data.uncertainty = Math.round((1 / (1 + data.observations)) * 1000) / 1000

    if (data.observations === 0) {
      data.status = 'unobserved'
      data.failureRisk = null // can't compute — event horizon
    } else if (total === 0) {
      data.status = 'observed'
      data.failureRisk = 0.5 // seen but no quality signal
    } else {
      // Correction rate
      const correctionRate = data.corrections / total

      // Recency factor: if lastSeen is recent, data is fresher
      // Decay: halve the weight of data older than 14 days
      let recencyWeight = 1.0
      if (data.lastSeen) {
        const daysSince = (now - new Date(data.lastSeen)) / 86400000
        recencyWeight = daysSince <= 7 ? 1.0
          : daysSince <= 14 ? 0.8
          : daysSince <= 30 ? 0.5
          : 0.3
      }

      // Failure risk = correction rate, boosted by recency and dampened by data volume
      // More data = more confident in the rate
      // Recent data = rate is more relevant
      const dataConfidence = Math.min(1.0, total / 20) // saturates at 20 signals
      data.failureRisk = Math.round(correctionRate * recencyWeight * dataConfidence * 1000) / 1000

      // Status classification
      const approvalRate = data.approvals / total
      if (approvalRate >= 0.7) data.status = 'positive'
      else if (approvalRate >= 0.4) data.status = 'mixed'
      else data.status = 'negative'
    }
  }

  return { allTopics, coverage }
}

// ─── Main ───

function main () {
  if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true })

  const { allTopics, coverage } = analyze()
  const summaryOnly = process.argv.includes('--summary')

  // Compute aggregate stats
  const topicCount = Object.keys(coverage).length
  const observed = Object.values(coverage).filter(c => c.status !== 'unobserved').length
  const unobserved = topicCount - observed
  const positive = Object.values(coverage).filter(c => c.status === 'positive').length
  const negative = Object.values(coverage).filter(c => c.status === 'negative').length
  const mixed = Object.values(coverage).filter(c => c.status === 'mixed').length

  const unknownScore = unobserved / topicCount
  const avgUncertainty = Object.values(coverage).reduce((s, c) => s + c.uncertainty, 0) / topicCount

  // Group by level
  const byLevel = { project: [], module: [], concept: [] }
  for (const [name, data] of Object.entries(coverage)) {
    const level = allTopics[name]?.level || 'module'
    byLevel[level].push({ name, ...data })
  }

  // ─── Output ───
  const report = {
    generated: new Date().toISOString(),
    summary: {
      totalTopics: topicCount,
      observed,
      unobserved,
      positive,
      negative,
      mixed,
      unknownScore: Math.round(unknownScore * 1000) / 1000,
      avgUncertainty: Math.round(avgUncertainty * 1000) / 1000,
    },
    byLevel: {
      project: byLevel.project.length,
      module: byLevel.module.length,
      concept: byLevel.concept.length,
    },
    coverage,
  }

  fs.writeFileSync(OUTPUT_FILE, JSON.stringify(report, null, 2))

  // ─── Console Report ───
  console.log(`${'═'.repeat(60)}`)
  console.log('  KNOWLEDGE COVERAGE MAP')
  console.log('═'.repeat(60))
  console.log(`  Total topics:    ${topicCount}`)
  console.log(`  Observed:        ${observed} (${(observed/topicCount*100).toFixed(0)}%)`)
  console.log(`  Unobserved:      ${unobserved} (${(unobserved/topicCount*100).toFixed(0)}%) ← event horizon`)
  console.log(`  Unknown score:   ${unknownScore.toFixed(3)} (lower = better coverage)`)
  console.log(`  Avg uncertainty: ${avgUncertainty.toFixed(3)}`)
  console.log('')
  console.log(`  Status breakdown:`)
  console.log(`    positive:   ${positive} (high approval rate)`)
  console.log(`    mixed:      ${mixed} (balanced corrections/approvals)`)
  console.log(`    negative:   ${negative} (high correction rate)`)
  console.log(`    unobserved: ${unobserved} (no data — event horizon)`)

  if (summaryOnly) {
    console.log(`\nFull report: ${OUTPUT_FILE}`)
    return
  }

  // Show topics by status
  for (const level of ['project', 'concept', 'module']) {
    const topics = byLevel[level].sort((a, b) => b.observations - a.observations)

    console.log(`\n  ${level.toUpperCase()} TOPICS (${topics.length})`)
    console.log('  ' + '-'.repeat(56))

    // Observed topics with data
    const withData = topics.filter(t => t.status !== 'unobserved')
    if (withData.length > 0) {
      for (const t of withData) {
        const icon = t.status === 'positive' ? '✓'
          : t.status === 'negative' ? '✗'
          : t.status === 'mixed' ? '~'
          : '?'
        const shortName = t.name.replace(/^(project|module|concept):/, '')
        console.log(`  ${icon} ${shortName.padEnd(25)} obs=${String(t.observations).padStart(4)}  ${t.corrections}✗ ${t.approvals}✓  unc=${t.uncertainty.toFixed(3)}  last=${t.lastSeen?.slice(0, 10) || '?'}`)
      }
    }

    // Unobserved topics (show first 10 for module level, all for others)
    const noData = topics.filter(t => t.status === 'unobserved')
    if (noData.length > 0) {
      const showCount = level === 'module' ? 10 : noData.length
      const shown = noData.slice(0, showCount)
      console.log(`  — unobserved (${noData.length} topics, uncertainty=1.0):`)
      const names = shown.map(t => t.name.replace(/^(project|module|concept):/, ''))
      // Print in rows of 4
      for (let i = 0; i < names.length; i += 4) {
        console.log(`    ${names.slice(i, i + 4).join(', ')}`)
      }
      if (noData.length > showCount) {
        console.log(`    ... and ${noData.length - showCount} more`)
      }
    }
  }

  console.log(`\n${'═'.repeat(60)}`)
  console.log(`Full report: ${OUTPUT_FILE}`)
}

main()