#!/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()