#!/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 }