| … | … | @@ -431,16 +431,32 @@ function benchmarkR3 () { |
| 431 | 431 | // R2: Novel vs Repeat Corrections |
| 432 | 432 | // ═══════════════════════════════════════════════════════════ |
| 433 | 433 | |
| 434 | // Infer domain from a cwd path. | |
| 435 | function cwdToDomain (cwd) { | |
| 436 | if (!cwd) return 'unknown' | |
| 437 | if (cwd.includes('/code/godot/')) return 'godot' | |
| 438 | if (cwd.includes('/hyper-fleeky/')) return 'p2p-stack' | |
| 439 | if (cwd.includes('knowledge-ratchet')) return 'ratchet' | |
| 440 | if (cwd.includes('hypercore-kb')) return 'p2p-stack' | |
| 441 | return 'unknown' | |
| 442 | } | |
| 443 | ||
| 434 | 444 | function benchmarkR2 () { |
| 435 | 445 | const detail = loadJSON(path.join(DATA_DIR, 'fused-turns-detail.json')) |
| 436 | 446 | if (!detail) { console.log(' No detail data.'); return null } |
| 437 | 447 | |
| 438 | // Collect all correction texts chronologically | |
| 448 | // Collect all correction texts chronologically, tagged with domain | |
| 439 | 449 | const allCorrections = [] |
| 440 | 450 | for (const sess of detail.sessions) { |
| 451 | const domain = cwdToDomain(sess.cwd) | |
| 441 | 452 | for (const turn of (sess.turns || [])) { |
| 442 | 453 | if (turn.isCorrection && turn.text) { |
| 443 | allCorrections.push({ text: turn.text, date: sess.date, session: sess.sessionId }) | |
| 454 | allCorrections.push({ | |
| 455 | text: turn.text, | |
| 456 | date: sess.date, | |
| 457 | session: sess.sessionId, | |
| 458 | domain | |
| 459 | }) | |
| 444 | 460 | } |
| 445 | 461 | } |
| 446 | 462 | } |
| … | … | @@ -464,6 +480,8 @@ function benchmarkR2 () { |
| 464 | 480 | const novelByWeek = {} |
| 465 | 481 | const repeatByWeek = {} |
| 466 | 482 | const pastSets = [] |
| 483 | // Per-domain tracking | |
| 484 | const byDomain = {} | |
| 467 | 485 | |
| 468 | 486 | for (const corr of allCorrections) { |
| 469 | 487 | const words = wordSet(corr.text) |
| … | … | @@ -486,6 +504,23 @@ function benchmarkR2 () { |
| 486 | 504 | novelByWeek[week] = (novelByWeek[week] || 0) + 1 |
| 487 | 505 | } |
| 488 | 506 | pastSets.push(words) |
| 507 | ||
| 508 | // Per-domain aggregation | |
| 509 | if (!byDomain[corr.domain]) byDomain[corr.domain] = { novel: 0, repeat: 0 } | |
| 510 | if (isRepeat) byDomain[corr.domain].repeat++ | |
| 511 | else byDomain[corr.domain].novel++ | |
| 512 | } | |
| 513 | ||
| 514 | // Compute per-domain novel rate | |
| 515 | const perDomain = {} | |
| 516 | for (const [d, stats] of Object.entries(byDomain)) { | |
| 517 | const total = stats.novel + stats.repeat | |
| 518 | perDomain[d] = { | |
| 519 | novel: stats.novel, | |
| 520 | repeat: stats.repeat, | |
| 521 | total, | |
| 522 | novelRate: total > 0 ? stats.novel / total : null | |
| 523 | } | |
| 489 | 524 | } |
| 490 | 525 | |
| 491 | 526 | const allWeeks = [...new Set([...Object.keys(novelByWeek), ...Object.keys(repeatByWeek)])].sort() |
| … | … | @@ -536,7 +571,16 @@ function benchmarkR2 () { |
| 536 | 571 | console.log(` Delta: ${delta >= 0 ? '+' : ''}${(delta * 100).toFixed(0)}pp`) |
| 537 | 572 | } |
| 538 | 573 | |
| 539 | return { novel, repeat, novelRate, weekCount: allWeeks.length } | |
| 574 | // Print per-domain breakdown | |
| 575 | console.log('\n BY DOMAIN (novel rate — higher means ratchet preventing repeats)') | |
| 576 | console.log(' ' + '-'.repeat(60)) | |
| 577 | for (const [d, stats] of Object.entries(perDomain).sort((a, b) => b[1].total - a[1].total)) { | |
| 578 | if (stats.total < 5) continue | |
| 579 | const pct = (stats.novelRate * 100).toFixed(1) | |
| 580 | console.log(` ${d.padEnd(15)} ${pct}% novel (${stats.novel}/${stats.total})`) | |
| 581 | } | |
| 582 | ||
| 583 | return { novel, repeat, novelRate, weekCount: allWeeks.length, perDomain } | |
| 540 | 584 | } |
| 541 | 585 | |
| 542 | 586 | // ═══════════════════════════════════════════════════════════ |
| … | … | @@ -547,9 +591,20 @@ function benchmarkR4 () { |
| 547 | 591 | const loops = loadJSON(path.join(DATA_DIR, 'loops.json')) |
| 548 | 592 | if (!loops) { console.log(' No loops data.'); return null } |
| 549 | 593 | |
| 550 | const resolved = loops.loops.filter(l => l.resolved && l.date) | |
| 594 | // Compute resolution RATE — resolved vs total (catches stuck loops) | |
| 595 | const allLoops = loops.loops.filter(l => l.date) | |
| 596 | const resolved = allLoops.filter(l => l.resolved) | |
| 597 | const unresolvedCount = allLoops.length - resolved.length | |
| 598 | const resolutionRate = allLoops.length > 0 ? resolved.length / allLoops.length : 0 | |
| 599 | ||
| 551 | 600 | if (resolved.length < 5) { console.log(' Not enough resolved loops.'); return null } |
| 552 | 601 | |
| 602 | // Duration percentiles (quick fixes vs sagas) | |
| 603 | const sortedDurations = resolved.map(l => l.duration).sort((a, b) => a - b) | |
| 604 | const p50 = sortedDurations[Math.floor(sortedDurations.length * 0.5)] | |
| 605 | const p90 = sortedDurations[Math.floor(sortedDurations.length * 0.9)] | |
| 606 | const longHaulRate = sortedDurations.filter(d => d > 10).length / sortedDurations.length | |
| 607 | ||
| 553 | 608 | // Group by type and week, track avg duration |
| 554 | 609 | const byTypeWeek = {} |
| 555 | 610 | for (const l of resolved) { |
| … | … | @@ -574,10 +629,14 @@ function benchmarkR4 () { |
| 574 | 629 | const allWeeks = Object.keys(overallWeekly).sort() |
| 575 | 630 | |
| 576 | 631 | console.log('═'.repeat(60)) |
| 577 | console.log(' R4: LOOP RESOLUTION SPEED') | |
| 632 | console.log(' R4: LOOP RESOLUTION') | |
| 578 | 633 | console.log('═'.repeat(60)) |
| 579 | console.log(` Resolved loops: ${resolved.length}`) | |
| 580 | console.log(` Avg resolution: ${(resolved.reduce((s, l) => s + l.duration, 0) / resolved.length).toFixed(1)} turns`) | |
| 634 | console.log(` Total loops: ${allLoops.length} (resolved: ${resolved.length}, stuck: ${unresolvedCount})`) | |
| 635 | console.log(` Resolution rate: ${(resolutionRate * 100).toFixed(1)}% ${resolutionRate >= 0.8 ? '✓' : resolutionRate >= 0.6 ? '~' : '✗'}`) | |
| 636 | console.log(` Avg duration: ${(resolved.reduce((s, l) => s + l.duration, 0) / resolved.length).toFixed(1)} turns`) | |
| 637 | console.log(` P50 duration: ${p50} turns (half resolved this fast or faster)`) | |
| 638 | console.log(` P90 duration: ${p90} turns (tail is here — long sagas)`) | |
| 639 | console.log(` Long-haul rate: ${(longHaulRate * 100).toFixed(1)}% (fraction of loops >10 turns)`) | |
| 581 | 640 | |
| 582 | 641 | console.log('\n WEEKLY AVG RESOLUTION (turns to fix)') |
| 583 | 642 | console.log(' ' + '-'.repeat(60)) |
| … | … | @@ -629,7 +688,17 @@ function benchmarkR4 () { |
| 629 | 688 | console.log(` ${arrow} ${type.padEnd(20)} ${firstAvg.toFixed(1)} → ${secondAvg.toFixed(1)} turns (${delta >= 0 ? '+' : ''}${delta.toFixed(1)})`) |
| 630 | 689 | } |
| 631 | 690 | |
| 632 | return { avgResolution: resolved.reduce((s, l) => s + l.duration, 0) / resolved.length, weekCount: allWeeks.length } | |
| 691 | return { | |
| 692 | avgResolution: resolved.reduce((s, l) => s + l.duration, 0) / resolved.length, | |
| 693 | resolutionRate, | |
| 694 | p50, | |
| 695 | p90, | |
| 696 | longHaulRate, | |
| 697 | totalLoops: allLoops.length, | |
| 698 | resolvedCount: resolved.length, | |
| 699 | unresolvedCount, | |
| 700 | weekCount: allWeeks.length | |
| 701 | } | |
| 633 | 702 | } |
| 634 | 703 | |
| 635 | 704 | // ─── Main ─── |
| … | … | @@ -688,8 +757,23 @@ function main () { |
| 688 | 757 | timestamp: new Date().toISOString(), |
| 689 | 758 | novelRate: results.r2?.novelRate ?? null, |
| 690 | 759 | repeatRate: results.r2 ? 1 - results.r2.novelRate : null, |
| 760 | // Per-domain novel rates (R2 broken down by domain) | |
| 761 | novelRateByDomain: results.r2?.perDomain ?? null, | |
| 691 | 762 | density: results.r3?.overallDensity ?? null, |
| 763 | // R4 enhanced fields | |
| 692 | 764 | avgResolution: results.r4?.avgResolution ?? null, |
| 765 | resolutionRate: results.r4?.resolutionRate ?? null, | |
| 766 | p50Duration: results.r4?.p50 ?? null, | |
| 767 | p90Duration: results.r4?.p90 ?? null, | |
| 768 | longHaulRate: results.r4?.longHaulRate ?? null, | |
| 769 | totalLoopsAll: results.r4?.totalLoops ?? null, | |
| 770 | stuckLoops: results.r4?.unresolvedCount ?? null, | |
| 771 | // Trusted quality (from fused-scores latest) | |
| 772 | trustedScore: null, | |
| 773 | perTurnScore: null, | |
| 774 | avgTurns: null, | |
| 775 | saturationLevels: null, | |
| 776 | // Legacy fields | |
| 693 | 777 | totalLoops: results.r4?.weekCount ?? null, |
| 694 | 778 | sessions: results.r3?.weekCount ?? null, |
| 695 | 779 | rulesCount: null, |
| … | … | @@ -701,6 +785,30 @@ function main () { |
| 701 | 785 | try { snapshot.rulesCount = JSON.parse(fs.readFileSync(rulesFile, 'utf8')).totalRules } catch {} |
| 702 | 786 | } |
| 703 | 787 | |
| 788 | // Load latest trustedScore/perTurnScore from fused-scores weekly | |
| 789 | const fusedFile = path.join(DATA_DIR, 'fused-scores.json') | |
| 790 | if (fs.existsSync(fusedFile)) { | |
| 791 | try { | |
| 792 | const fused = JSON.parse(fs.readFileSync(fusedFile, 'utf8')) | |
| 793 | const weeks = Object.entries(fused.weekly || {}).sort() | |
| 794 | if (weeks.length > 0) { | |
| 795 | const latest = weeks[weeks.length - 1][1] | |
| 796 | snapshot.trustedScore = latest.avgTrustedScore ?? null | |
| 797 | snapshot.perTurnScore = latest.avgPerTurnScore ?? null | |
| 798 | snapshot.avgTurns = latest.avgTurns ?? null | |
| 799 | } | |
| 800 | } catch {} | |
| 801 | } | |
| 802 | ||
| 803 | // Load saturation distribution from advanced metrics | |
| 804 | const advFile = path.join(DATA_DIR, 'advanced-metrics.json') | |
| 805 | if (fs.existsSync(advFile)) { | |
| 806 | try { | |
| 807 | const adv = JSON.parse(fs.readFileSync(advFile, 'utf8')) | |
| 808 | snapshot.saturationLevels = adv.aggregate?.saturation ?? null | |
| 809 | } catch {} | |
| 810 | } | |
| 811 | ||
| 704 | 812 | // Dedup by date — only one entry per day |
| 705 | 813 | history = history.filter(h => h.date !== snapshot.date) |
| 706 | 814 | history.push(snapshot) |
| … | … | @@ -61,9 +61,32 @@ function generate () { |
| 61 | 61 | return acc |
| 62 | 62 | }, { turns: 0, corr: 0 }) |
| 63 | 63 | |
| 64 | // Find last session date in this week for range labels | |
| 65 | const weekSessions = (fused.sessions || []).filter(s => { | |
| 66 | const d = new Date(s.date) | |
| 67 | d.setDate(d.getDate() - d.getDay()) | |
| 68 | return d.toISOString().slice(0, 10) === week | |
| 69 | }) | |
| 70 | const lastDate = weekSessions.length > 0 | |
| 71 | ? weekSessions.sort((a, b) => (a.date || '').localeCompare(b.date || ''))[weekSessions.length - 1].date | |
| 72 | : week | |
| 73 | const weekLabel = lastDate !== week | |
| 74 | ? `${week.slice(5)}…${lastDate.slice(8)}` | |
| 75 | : week.slice(5) | |
| 76 | ||
| 64 | 77 | weeklyData.push({ |
| 65 | 78 | week, |
| 79 | weekLabel, | |
| 66 | 80 | score: data.avgScore, |
| 81 | // Per-turn score: quality independent of session length | |
| 82 | perTurnScore: data.avgPerTurnScore || data.avgScore, | |
| 83 | // Trusted score: top-5 sensors only — PRIMARY quality signal | |
| 84 | trustedScore: data.avgTrustedScore, | |
| 85 | // Correction burst: longest streak of consecutive corrections (lower = healthier) | |
| 86 | avgCorrectionBurst: data.avgCorrectionBurst, | |
| 87 | maxCorrectionBurst: data.maxCorrectionBurst || 0, | |
| 88 | avgTurns: data.avgTurns || 0, | |
| 89 | avgDensity: data.avgDensity || null, | |
| 67 | 90 | sessions: data.sessions, |
| 68 | 91 | corrections: data.corrections, |
| 69 | 92 | approvals: data.approvals, |
| … | … | @@ -234,8 +257,9 @@ function generate () { |
| 234 | 257 | </style> |
| 235 | 258 | </head> |
| 236 | 259 | <body> |
| 237 | <h1>Knowledge Ratchet</h1> | |
| 238 | <h2>Self-improving AI quality system — tracks corrections, detects failure loops, auto-generates prevention rules</h2> | |
| 260 | <h1>Knowledge Ratchet <a href="graph" style="font-size: 14px; color: #58a6ff; text-decoration: none; margin-left: 16px;">→ AtomSpace Graph</a></h1> | |
| 261 | <h2>Self-improving AI quality system — tracks corrections, detects failure loops, auto-generates prevention rules | |
| 262 | <br>Data through: ${benchmarkHistory.length > 0 ? benchmarkHistory[benchmarkHistory.length - 1].date : 'n/a'} | ${sessionData.length} sessions</h2> | |
| 239 | 263 | |
| 240 | 264 | ${liveSession && liveSession.turns > 0 ? ` |
| 241 | 265 | <div class="card" style="border-color: ${liveBorder}; margin-bottom: 16px;"> |
| … | … | @@ -274,10 +298,97 @@ ${liveSession && liveSession.turns > 0 ? ` |
| 274 | 298 | </div> |
| 275 | 299 | </div> |
| 276 | 300 | |
| 277 | <!-- Weekly Fused Score Trend --> | |
| 301 | <!-- Daily Ratchet Trends (primary) --> | |
| 302 | ${benchmarkHistory.length >= 1 ? ` | |
| 303 | <h3>Daily Ratchet Trends</h3> | |
| 304 | <div class="grid"> | |
| 305 | <div class="card"> | |
| 306 | <div class="card-title">Novel Correction Rate (higher = better)</div> | |
| 307 | ${renderLineChart( | |
| 308 | benchmarkHistory.filter(h => h.novelRate != null).map(h => h.novelRate), | |
| 309 | benchmarkHistory.filter(h => h.novelRate != null).map(h => h.date), | |
| 310 | { height: 120, color: '#3fb950', label: 'Novel Rate', format: 'pct' } | |
| 311 | )} | |
| 312 | </div> | |
| 313 | <div class="card"> | |
| 314 | <div class="card-title">Correction Density (lower = better)</div> | |
| 315 | ${renderLineChart( | |
| 316 | benchmarkHistory.filter(h => h.density != null).map(h => h.density), | |
| 317 | benchmarkHistory.filter(h => h.density != null).map(h => h.date), | |
| 318 | { height: 120, color: '#f85149', label: 'Density', format: 'pct' } | |
| 319 | )} | |
| 320 | </div> | |
| 321 | <div class="card"> | |
| 322 | <div class="card-title">Loop Resolution Speed (lower = better)</div> | |
| 323 | ${renderLineChart( | |
| 324 | benchmarkHistory.filter(h => h.avgResolution != null).map(h => h.avgResolution), | |
| 325 | benchmarkHistory.filter(h => h.avgResolution != null).map(h => h.date), | |
| 326 | { height: 120, color: '#d29922', label: 'Turns to Resolve', format: 'auto' } | |
| 327 | )} | |
| 328 | </div> | |
| 329 | <div class="card"> | |
| 330 | <div class="card-title">Learned Rules (higher = more knowledge)</div> | |
| 331 | ${renderLineChart( | |
| 332 | benchmarkHistory.filter(h => h.rulesCount != null).map(h => h.rulesCount), | |
| 333 | benchmarkHistory.filter(h => h.rulesCount != null).map(h => h.date), | |
| 334 | { height: 120, color: '#58a6ff', label: 'Rules', format: 'int' } | |
| 335 | )} | |
| 336 | </div> | |
| 337 | </div> | |
| 338 | ||
| 339 | <div class="card" style="margin-top:16px; margin-bottom:16px"> | |
| 340 | <div class="card-title">Ratchet Scorecard</div> | |
| 341 | <table> | |
| 342 | <tr><th>Metric</th><th>Current</th><th>Target</th><th>Status</th></tr> | |
| 343 | <tr> | |
| 344 | <td>Novel correction rate</td> | |
| 345 | <td>${benchmarkHistory.length > 0 && benchmarkHistory[benchmarkHistory.length-1].novelRate != null ? (benchmarkHistory[benchmarkHistory.length-1].novelRate * 100).toFixed(0) + '%' : 'n/a'}</td> | |
| 346 | <td>>90%</td> | |
| 347 | <td>${benchmarkHistory.length > 0 && benchmarkHistory[benchmarkHistory.length-1].novelRate > 0.9 ? '<span class="green">✓</span>' : benchmarkHistory[benchmarkHistory.length-1]?.novelRate > 0.8 ? '<span class="yellow">~</span>' : '<span class="red">✗</span>'}</td> | |
| 348 | </tr> | |
| 349 | <tr> | |
| 350 | <td>Correction density</td> | |
| 351 | <td>${benchmarkHistory.length > 0 && benchmarkHistory[benchmarkHistory.length-1].density != null ? (benchmarkHistory[benchmarkHistory.length-1].density * 100).toFixed(1) + '%' : 'n/a'}</td> | |
| 352 | <td><15%</td> | |
| 353 | <td>${benchmarkHistory.length > 0 && benchmarkHistory[benchmarkHistory.length-1].density < 0.15 ? '<span class="green">✓</span>' : benchmarkHistory[benchmarkHistory.length-1]?.density < 0.25 ? '<span class="yellow">~</span>' : '<span class="red">✗</span>'}</td> | |
| 354 | </tr> | |
| 355 | <tr> | |
| 356 | <td>Avg loop resolution</td> | |
| 357 | <td>${benchmarkHistory.length > 0 && benchmarkHistory[benchmarkHistory.length-1].avgResolution != null ? benchmarkHistory[benchmarkHistory.length-1].avgResolution.toFixed(1) + ' turns' : 'n/a'}</td> | |
| 358 | <td><6 turns</td> | |
| 359 | <td>${benchmarkHistory.length > 0 && benchmarkHistory[benchmarkHistory.length-1].avgResolution < 6 ? '<span class="green">✓</span>' : benchmarkHistory[benchmarkHistory.length-1]?.avgResolution < 10 ? '<span class="yellow">~</span>' : '<span class="red">✗</span>'}</td> | |
| 360 | </tr> | |
| 361 | <tr> | |
| 362 | <td>Learned rules</td> | |
| 363 | <td>${learnedRules?.totalRules || 0}</td> | |
| 364 | <td>growing</td> | |
| 365 | <td><span class="blue">${learnedRules?.totalRules > 0 ? '✓' : '~'}</span></td> | |
| 366 | </tr> | |
| 367 | </table> | |
| 368 | </div> | |
| 369 | ` : ''} | |
| 370 | ||
| 371 | <!-- Primary quality metric: Trusted score (top-5 sensors, noise-free) --> | |
| 372 | <div class="chart-container"> | |
| 373 | <div class="card-title">★ Trusted Score (PRIMARY) — top-5 sensors only, ratchet's real quality signal</div> | |
| 374 | ${renderLineChart(weeklyData.map(w => w.trustedScore || 0), weeklyData.map(w => w.weekLabel), { height: 140, color: '#3fb950', label: 'Trusted Score', format: 'auto' })} | |
| 375 | </div> | |
| 376 | ||
| 377 | <!-- Secondary metrics for debugging --> | |
| 378 | <div class="chart-container"> | |
| 379 | <div class="card-title">Per-Turn Score (all 16 sensors) — includes noise; see audit for caveats</div> | |
| 380 | ${renderLineChart(weeklyData.map(w => w.perTurnScore), weeklyData.map(w => w.weekLabel), { height: 100, color: '#8b949e', label: 'Per-Turn Score', format: 'auto' })} | |
| 381 | </div> | |
| 382 | ||
| 278 | 383 | <div class="chart-container"> |
| 279 | <div class="card-title">Weekly Fused Score (higher = better)</div> | |
| 280 | ${renderLineChart(weeklyData.map(w => w.score), weeklyData.map(w => w.week), { height: 140, color: '#58a6ff', label: 'Fused Score', format: 'auto' })} | |
| 384 | <div class="card-title">Avg Correction Burst — longest run of consecutive corrections per session (lower = healthier; 1 = isolated mistakes, 2+ = stuck patterns)</div> | |
| 385 | ${renderLineChart(weeklyData.map(w => w.avgCorrectionBurst || 0), weeklyData.map(w => w.weekLabel), { height: 100, color: '#f0883e', label: 'Avg Burst', format: 'auto' })} | |
| 386 | </div> | |
| 387 | ||
| 388 | <!-- Session Length: ambition, not quality --> | |
| 389 | <div class="chart-container"> | |
| 390 | <div class="card-title">Avg Turns Per Session — ambition level (not a quality metric)</div> | |
| 391 | ${renderLineChart(weeklyData.map(w => w.avgTurns), weeklyData.map(w => w.weekLabel), { height: 100, color: '#bc8cff', label: 'Avg Turns', format: 'int' })} | |
| 281 | 392 | </div> |
| 282 | 393 | |
| 283 | 394 | <!-- Weekly Correction Density --> |
| … | … | @@ -334,7 +445,7 @@ ${Object.entries(dangerMap).map(([proj, subs]) => { |
| 334 | 445 | |
| 335 | 446 | <!-- Sensor Weights --> |
| 336 | 447 | <h3>Sensor Weights</h3> |
| 337 | <p class="dim" style="margin-bottom:8px">The system uses 16 sensors to score each turn. Weights auto-adjust based on which sensors best predict corrections. Higher weight = more influence on the fused score.</p> | |
| 448 | <p class="dim" style="margin-bottom:8px">The system uses 16 sensors to score each turn. Weights auto-adjust based on which sensors best predict corrections. Higher weight = more influence on per-turn scoring.</p> | |
| 338 | 449 | <table> |
| 339 | 450 | <tr><th>Sensor</th><th>Weight</th><th>Bar</th></tr> |
| 340 | 451 | ${fused?.sensorWeights ? Object.entries(fused.sensorWeights).sort((a, b) => b[1] - a[1]).map(([name, weight]) => { |
| … | … | @@ -347,76 +458,8 @@ ${Object.entries(dangerMap).map(([proj, subs]) => { |
| 347 | 458 | }).join('\n ') : '<tr><td colspan="3" class="dim">No weight data</td></tr>'} |
| 348 | 459 | </table> |
| 349 | 460 | |
| 350 | <!-- Ratchet Intelligence Trends --> | |
| 351 | <h3>Intelligence Ratchet Trends</h3> | |
| 352 | <p class="dim" style="margin-bottom:8px">These lines should trend in the right direction over time. If they don't, the ratchet isn't working.</p> | |
| 353 | ${benchmarkHistory.length >= 1 ? ` | |
| 354 | <div class="grid"> | |
| 355 | <div class="card"> | |
| 356 | <div class="card-title">Novel Correction Rate (higher = better)</div> | |
| 357 | ${renderLineChart( | |
| 358 | benchmarkHistory.filter(h => h.novelRate != null).map(h => h.novelRate), | |
| 359 | benchmarkHistory.filter(h => h.novelRate != null).map(h => h.date), | |
| 360 | { height: 120, color: '#3fb950', label: 'Novel Rate', format: 'pct' } | |
| 361 | )} | |
| 362 | </div> | |
| 363 | <div class="card"> | |
| 364 | <div class="card-title">Correction Density (lower = better)</div> | |
| 365 | ${renderLineChart( | |
| 366 | benchmarkHistory.filter(h => h.density != null).map(h => h.density), | |
| 367 | benchmarkHistory.filter(h => h.density != null).map(h => h.date), | |
| 368 | { height: 120, color: '#f85149', label: 'Density', format: 'pct' } | |
| 369 | )} | |
| 370 | </div> | |
| 371 | <div class="card"> | |
| 372 | <div class="card-title">Loop Resolution Speed (lower = better)</div> | |
| 373 | ${renderLineChart( | |
| 374 | benchmarkHistory.filter(h => h.avgResolution != null).map(h => h.avgResolution), | |
| 375 | benchmarkHistory.filter(h => h.avgResolution != null).map(h => h.date), | |
| 376 | { height: 120, color: '#d29922', label: 'Turns to Resolve', format: 'auto' } | |
| 377 | )} | |
| 378 | </div> | |
| 379 | <div class="card"> | |
| 380 | <div class="card-title">Learned Rules (higher = more knowledge)</div> | |
| 381 | ${renderLineChart( | |
| 382 | benchmarkHistory.filter(h => h.rulesCount != null).map(h => h.rulesCount), | |
| 383 | benchmarkHistory.filter(h => h.rulesCount != null).map(h => h.date), | |
| 384 | { height: 120, color: '#58a6ff', label: 'Rules', format: 'int' } | |
| 385 | )} | |
| 386 | </div> | |
| 387 | </div> | |
| 388 | ||
| 389 | <div class="card" style="margin-top:16px"> | |
| 390 | <div class="card-title">Ratchet Scorecard — Green = target met, Yellow = working on it, Red = failing</div> | |
| 391 | <table> | |
| 392 | <tr><th>Metric</th><th>Current</th><th>Target</th><th>Status</th></tr> | |
| 393 | <tr> | |
| 394 | <td>Novel correction rate</td> | |
| 395 | <td>${benchmarkHistory.length > 0 && benchmarkHistory[benchmarkHistory.length-1].novelRate != null ? (benchmarkHistory[benchmarkHistory.length-1].novelRate * 100).toFixed(0) + '%' : 'n/a'}</td> | |
| 396 | <td>>90%</td> | |
| 397 | <td>${benchmarkHistory.length > 0 && benchmarkHistory[benchmarkHistory.length-1].novelRate > 0.9 ? '<span class="green">✓</span>' : benchmarkHistory[benchmarkHistory.length-1]?.novelRate > 0.8 ? '<span class="yellow">~</span>' : '<span class="red">✗</span>'}</td> | |
| 398 | </tr> | |
| 399 | <tr> | |
| 400 | <td>Correction density</td> | |
| 401 | <td>${benchmarkHistory.length > 0 && benchmarkHistory[benchmarkHistory.length-1].density != null ? (benchmarkHistory[benchmarkHistory.length-1].density * 100).toFixed(1) + '%' : 'n/a'}</td> | |
| 402 | <td><15%</td> | |
| 403 | <td>${benchmarkHistory.length > 0 && benchmarkHistory[benchmarkHistory.length-1].density < 0.15 ? '<span class="green">✓</span>' : benchmarkHistory[benchmarkHistory.length-1]?.density < 0.25 ? '<span class="yellow">~</span>' : '<span class="red">✗</span>'}</td> | |
| 404 | </tr> | |
| 405 | <tr> | |
| 406 | <td>Avg loop resolution</td> | |
| 407 | <td>${benchmarkHistory.length > 0 && benchmarkHistory[benchmarkHistory.length-1].avgResolution != null ? benchmarkHistory[benchmarkHistory.length-1].avgResolution.toFixed(1) + ' turns' : 'n/a'}</td> | |
| 408 | <td><6 turns</td> | |
| 409 | <td>${benchmarkHistory.length > 0 && benchmarkHistory[benchmarkHistory.length-1].avgResolution < 6 ? '<span class="green">✓</span>' : benchmarkHistory[benchmarkHistory.length-1]?.avgResolution < 10 ? '<span class="yellow">~</span>' : '<span class="red">✗</span>'}</td> | |
| 410 | </tr> | |
| 411 | <tr> | |
| 412 | <td>Learned rules</td> | |
| 413 | <td>${learnedRules?.totalRules || 0}</td> | |
| 414 | <td>growing</td> | |
| 415 | <td><span class="blue">${learnedRules?.totalRules > 0 ? '✓' : '~'}</span></td> | |
| 416 | </tr> | |
| 417 | </table> | |
| 418 | </div> | |
| 419 | ` : '<div class="card"><div class="dim">Not enough benchmark history yet. Run a few sessions to see trends.</div></div>'} | |
| 461 | <!-- Ratchet Intelligence Trends (formerly buried at bottom) --> | |
| 462 | <div class="dim" style="margin-bottom: 4px; font-size: 11px;">Remaining sections: sensor weights, loops by project, subsystem danger map are below.</div> | |
| 420 | 463 | |
| 421 | 464 | <div class="timestamp"> |
| 422 | 465 | Dashboard generated: ${new Date().toISOString()}<br> |
| … | … | @@ -432,17 +475,55 @@ ${benchmarkHistory.length >= 1 ? ` |
| 432 | 475 | |
| 433 | 476 | function serve (port) { |
| 434 | 477 | const http = require('http') |
| 478 | const url = require('url') | |
| 435 | 479 | generate() // initial generation |
| 436 | http.createServer((req, res) => { | |
| 480 | http.createServer(async (req, res) => { | |
| 481 | const parsed = url.parse(req.url, true) | |
| 437 | 482 | try { |
| 483 | // /graph — render the D3 graph visualization page | |
| 484 | if (parsed.pathname === '/graph' || parsed.pathname === '/graph.html') { | |
| 485 | const graphHtml = fs.readFileSync(path.join(__dirname, 'atomspace/graph.html'), 'utf8') | |
| 486 | res.writeHead(200, { 'Content-Type': 'text/html' }) | |
| 487 | res.end(graphHtml) | |
| 488 | return | |
| 489 | } | |
| 490 | ||
| 491 | // /graph.json — live AtomSpace slice as JSON | |
| 492 | if (parsed.pathname === '/graph.json') { | |
| 493 | const { exportGraph } = require('./atomspace/graph-export') | |
| 494 | const opts = {} | |
| 495 | if (parsed.query.module) opts.module = parsed.query.module | |
| 496 | if (parsed.query.minWisdom) opts.minWisdom = parseFloat(parsed.query.minWisdom) | |
| 497 | if (parsed.query.limit) opts.limit = parseInt(parsed.query.limit) | |
| 498 | const data = await exportGraph(opts) | |
| 499 | res.writeHead(200, { 'Content-Type': 'application/json' }) | |
| 500 | res.end(JSON.stringify(data)) | |
| 501 | return | |
| 502 | } | |
| 503 | ||
| 504 | // /fires.json — recent rule firings for live dashboard | |
| 505 | if (parsed.pathname === '/fires.json') { | |
| 506 | const { readRecent, readSince } = require('./atomspace/fire-log') | |
| 507 | const since = parsed.query.since ? parseInt(parsed.query.since) : null | |
| 508 | const limit = parsed.query.limit ? parseInt(parsed.query.limit) : 50 | |
| 509 | const fires = since ? readSince(since) : readRecent(limit) | |
| 510 | res.writeHead(200, { 'Content-Type': 'application/json' }) | |
| 511 | res.end(JSON.stringify({ fires, count: fires.length, at: Date.now() })) | |
| 512 | return | |
| 513 | } | |
| 514 | ||
| 515 | // Default: serve the regenerated dashboard | |
| 438 | 516 | generate() // regenerate on each request — always fresh data |
| 439 | 517 | res.writeHead(200, { 'Content-Type': 'text/html' }) |
| 440 | 518 | res.end(fs.readFileSync(OUTPUT_FILE, 'utf8')) |
| 441 | 519 | } catch (err) { |
| 442 | 520 | res.writeHead(500, { 'Content-Type': 'text/plain' }) |
| 443 | res.end('Dashboard generation failed: ' + err.message) | |
| 521 | res.end('Dashboard error: ' + err.message) | |
| 444 | 522 | } |
| 445 | }).listen(port, () => console.log(`Ratchet dashboard: http://localhost:${port}`)) | |
| 523 | }).listen(port, () => { | |
| 524 | console.log(`Ratchet dashboard: http://localhost:${port}`) | |
| 525 | console.log(`AtomSpace graph: http://localhost:${port}/graph`) | |
| 526 | }) | |
| 446 | 527 | } |
| 447 | 528 | |
| 448 | 529 | // ─── CLI ─── |
| … | … | @@ -558,7 +639,7 @@ function renderBarChart (weeklyData, opts = {}) { |
| 558 | 639 | <rect x="${x}" y="${h - padB - apprH}" width="${barW}" height="${apprH}" fill="#238636" opacity="0.7"/> |
| 559 | 640 | <text x="${x - barW/2}" y="${h - padB - corrH - 3}" fill="#da3633" font-size="8" text-anchor="middle">${d.corrections}</text> |
| 560 | 641 | <text x="${x + barW/2}" y="${h - padB - apprH - 3}" fill="#238636" font-size="8" text-anchor="middle">${d.approvals}</text> |
| 561 | <text x="${x}" y="${h - 5}" fill="#484f58" font-size="9" text-anchor="middle">${d.week.slice(5)}</text> | |
| 642 | <text x="${x}" y="${h - 5}" fill="#484f58" font-size="9" text-anchor="middle">${d.weekLabel || d.week.slice(5)}</text> | |
| 562 | 643 | ` |
| 563 | 644 | }).join('')} |
| 564 | 645 | <text x="${padL}" y="14" fill="#da3633" font-size="11">■ corrections</text> |
| … | … | @@ -77,7 +77,7 @@ |
| 77 | 77 | "During the pear-git-v2 session (2026-03-30), wrote 30+ commits of code without ever successfully completing a Sign In in a browser. Each \"fix\" added complexity on top of unverified changes, creating a compounding mess. curl testing is not sufficient for browser code.", |
| 78 | 78 | "During the skinned BLAS session, we checked out commit `470ee20948` but scons said \"up to date\" and the binary still contained `6e725d14c` (a different commit). The user tested the wrong binary multiple times, wasting significant time." |
| 79 | 79 | ], |
| 80 | "extractedAt": "2026-04-07T01:40:34.854Z" | |
| 80 | "extractedAt": "2026-04-07T03:11:31.383Z" | |
| 81 | 81 | }, |
| 82 | 82 | { |
| 83 | 83 | "topic": "deployment", |
| … | … | @@ -163,7 +163,7 @@ |
| 163 | 163 | "During the dc-terrain mountain generator work (2026-03-19), 8+ \"fix\" commits were made without understanding the root cause. Each guess introduced new problems. The actual bug (missing mountain_pipelines_ declaration + wrong shader binding layout) could have been found in 10 minutes by diffing the working noise shader composition vs the broken mountain composition.", |
| 164 | 164 | "User has startup scripts (`/home/fleeky/scrips/` on stortron) that set the correct port, prefix, tmux window, and flags. Starting manually with wrong args breaks the reverse proxy (503 Service Unavailable) and wastes time debugging." |
| 165 | 165 | ], |
| 166 | "extractedAt": "2026-04-07T01:40:34.854Z" | |
| 166 | "extractedAt": "2026-04-07T03:11:31.383Z" | |
| 167 | 167 | }, |
| 168 | 168 | { |
| 169 | 169 | "topic": "discipline", |
| … | … | @@ -249,7 +249,7 @@ |
| 249 | 249 | "User has startup scripts (`/home/fleeky/scrips/` on stortron) that set the correct port, prefix, tmux window, and flags. Starting manually with wrong args breaks the reverse proxy (503 Service Unavailable) and wastes time debugging.", |
| 250 | 250 | "During the skinned BLAS session, we checked out commit `470ee20948` but scons said \"up to date\" and the binary still contained `6e725d14c` (a different commit). The user tested the wrong binary multiple times, wasting significant time." |
| 251 | 251 | ], |
| 252 | "extractedAt": "2026-04-07T01:40:34.854Z" | |
| 252 | "extractedAt": "2026-04-07T03:11:31.383Z" | |
| 253 | 253 | }, |
| 254 | 254 | { |
| 255 | 255 | "topic": "system-knowledge", |
| … | … | @@ -323,7 +323,7 @@ |
| 323 | 323 | "User has startup scripts (`/home/fleeky/scrips/` on stortron) that set the correct port, prefix, tmux window, and flags. Starting manually with wrong args breaks the reverse proxy (503 Service Unavailable) and wastes time debugging.", |
| 324 | 324 | "During the skinned BLAS session, we checked out commit `470ee20948` but scons said \"up to date\" and the binary still contained `6e725d14c` (a different commit). The user tested the wrong binary multiple times, wasting significant time." |
| 325 | 325 | ], |
| 326 | "extractedAt": "2026-04-07T01:40:34.854Z" | |
| 326 | "extractedAt": "2026-04-07T03:11:31.383Z" | |
| 327 | 327 | }, |
| 328 | 328 | { |
| 329 | 329 | "topic": "persistence", |
| … | … | @@ -361,6 +361,6 @@ |
| 361 | 361 | "rsync + git clean deleted .pear-git-updater.json on stortron, breaking the entire self-update chain. Manual copies also pollute the git working tree.", |
| 362 | 362 | "During the skinned BLAS session, we checked out commit `470ee20948` but scons said \"up to date\" and the binary still contained `6e725d14c` (a different commit). The user tested the wrong binary multiple times, wasting significant time." |
| 363 | 363 | ], |
| 364 | "extractedAt": "2026-04-07T01:40:34.854Z" | |
| 364 | "extractedAt": "2026-04-07T03:11:31.383Z" | |
| 365 | 365 | } |
| 366 | 366 | ] |
| … | … | @@ -31,7 +31,7 @@ |
| 31 | 31 | "verify-binary-matches-source" |
| 32 | 32 | ], |
| 33 | 33 | "factCount": 23, |
| 34 | "validatedAt": "2026-04-07T01:40:34.855Z", | |
| 34 | "validatedAt": "2026-04-07T03:11:31.384Z", | |
| 35 | 35 | "contradictions": [], |
| 36 | 36 | "overrideCount": 0, |
| 37 | 37 | "status": "active" |
| … | … | @@ -70,7 +70,7 @@ |
| 70 | 70 | "use-service-scripts" |
| 71 | 71 | ], |
| 72 | 72 | "factCount": 25, |
| 73 | "validatedAt": "2026-04-07T01:40:34.855Z", | |
| 73 | "validatedAt": "2026-04-07T03:11:31.384Z", | |
| 74 | 74 | "contradictions": [], |
| 75 | 75 | "overrideCount": 0, |
| 76 | 76 | "status": "active" |
| … | … | @@ -109,7 +109,7 @@ |
| 109 | 109 | "verify-binary-matches-source" |
| 110 | 110 | ], |
| 111 | 111 | "factCount": 25, |
| 112 | "validatedAt": "2026-04-07T01:40:34.855Z", | |
| 112 | "validatedAt": "2026-04-07T03:11:31.384Z", | |
| 113 | 113 | "contradictions": [], |
| 114 | 114 | "overrideCount": 0, |
| 115 | 115 | "status": "active" |
| … | … | @@ -144,7 +144,7 @@ |
| 144 | 144 | "verify-binary-matches-source" |
| 145 | 145 | ], |
| 146 | 146 | "factCount": 21, |
| 147 | "validatedAt": "2026-04-07T01:40:34.855Z", | |
| 147 | "validatedAt": "2026-04-07T03:11:31.384Z", | |
| 148 | 148 | "contradictions": [], |
| 149 | 149 | "overrideCount": 0, |
| 150 | 150 | "status": "active" |
| … | … | @@ -167,7 +167,7 @@ |
| 167 | 167 | "verify-binary-matches-source" |
| 168 | 168 | ], |
| 169 | 169 | "factCount": 9, |
| 170 | "validatedAt": "2026-04-07T01:40:34.855Z", | |
| 170 | "validatedAt": "2026-04-07T03:11:31.384Z", | |
| 171 | 171 | "contradictions": [], |
| 172 | 172 | "overrideCount": 0, |
| 173 | 173 | "status": "active" |
| … | … | @@ -1,5 +1,5 @@ |
| 1 | 1 | { |
| 2 | "timestamp": "2026-04-07T01:40:34.893Z", | |
| 2 | "timestamp": "2026-04-07T03:11:31.421Z", | |
| 3 | 3 | "trainingSize": 21, |
| 4 | 4 | "testSize": 9, |
| 5 | 5 | "principleCount": 5, |
| … | … | @@ -625,6 +625,27 @@ function analyzeSession (scannedSession, weights, llmIndex) { |
| 625 | 625 | const gitBranch = mode(branches) || null |
| 626 | 626 | const cwd = mode(cwds) || null |
| 627 | 627 | |
| 628 | // Derived metrics for clearer observability | |
| 629 | const density = turns.length > 0 ? corrections / turns.length : 0 | |
| 630 | const approvalDensity = turns.length > 0 ? approvals / turns.length : 0 | |
| 631 | ||
| 632 | // Trusted-sensor-only per-turn score (top 5 sensors from audit) | |
| 633 | // High signal-to-noise; not polluted by rare-event sensors | |
| 634 | const TRUSTED_SENSORS = ['correction', 'approval', 'userProvidesAnswer', 'buildErrors', 'editSurvival'] | |
| 635 | let trustedSum = 0, trustedTurnCount = 0 | |
| 636 | for (const t of turnScores) { | |
| 637 | const sensors = t.sensors | |
| 638 | if (!sensors) continue | |
| 639 | let sum = 0, count = 0 | |
| 640 | for (const k of TRUSTED_SENSORS) { | |
| 641 | if (sensors[k] != null) { sum += sensors[k]; count++ } | |
| 642 | } | |
| 643 | if (count > 0) { trustedSum += (sum / count); trustedTurnCount++ } | |
| 644 | } | |
| 645 | const trustedScore = trustedTurnCount > 0 | |
| 646 | ? Math.round((trustedSum / trustedTurnCount) * 1000) / 1000 | |
| 647 | : null | |
| 648 | ||
| 628 | 649 | return { |
| 629 | 650 | sessionId, |
| 630 | 651 | date: sessionDate?.slice(0, 10), |
| … | … | @@ -633,9 +654,27 @@ function analyzeSession (scannedSession, weights, llmIndex) { |
| 633 | 654 | cwd, |
| 634 | 655 | turns: turns.length, |
| 635 | 656 | score: Math.round(avgScore * 1000) / 1000, |
| 657 | // Per-turn score: quality independent of session length | |
| 658 | perTurnScore: Math.round(turnAvg * 1000) / 1000, | |
| 659 | // Trusted score (top-5 sensors only — PRIMARY quality signal) | |
| 660 | trustedScore, | |
| 661 | // Session-level aggregate (what penalizes long sessions) | |
| 662 | sessionSignalScore: Math.round(sessionSignalAvg * 1000) / 1000, | |
| 663 | // Derived densities | |
| 664 | density: Math.round(density * 1000) / 1000, | |
| 665 | approvalDensity: Math.round(approvalDensity * 1000) / 1000, | |
| 666 | // Correction burst — first-class metric. Length of longest run of consecutive | |
| 667 | // corrections. 1 = healthy (isolated mistakes); 2+ = stuck pattern. | |
| 668 | correctionBurst: maxCorrectionStreak, | |
| 636 | 669 | corrections, |
| 637 | 670 | approvals, |
| 638 | 671 | totalToolCalls, |
| 672 | // Session length as first-class metric — longer = more ambitious work | |
| 673 | sessionLength: { | |
| 674 | turns: turns.length, | |
| 675 | toolCalls: totalToolCalls, | |
| 676 | toolsPerTurn: turns.length > 0 ? Math.round((totalToolCalls / turns.length) * 10) / 10 : 0 | |
| 677 | }, | |
| 639 | 678 | trajectory, |
| 640 | 679 | sessionSignals, |
| 641 | 680 | sensorAverages, |
| … | … | @@ -691,16 +730,53 @@ function computeWeeklyScores (sessions) { |
| 691 | 730 | d.setDate(d.getDate() - d.getDay()) |
| 692 | 731 | const weekKey = d.toISOString().slice(0, 10) |
| 693 | 732 | if (!weeks[weekKey]) { |
| 694 | weeks[weekKey] = { sessions: 0, totalScore: 0, corrections: 0, approvals: 0, toolCalls: 0 } | |
| 733 | weeks[weekKey] = { | |
| 734 | sessions: 0, | |
| 735 | totalScore: 0, | |
| 736 | totalPerTurnScore: 0, | |
| 737 | totalTrustedScore: 0, | |
| 738 | trustedScoreSessions: 0, | |
| 739 | totalTurns: 0, | |
| 740 | totalDensity: 0, | |
| 741 | totalCorrectionBurst: 0, | |
| 742 | burstSessions: 0, | |
| 743 | maxCorrectionBurst: 0, | |
| 744 | corrections: 0, | |
| 745 | approvals: 0, | |
| 746 | toolCalls: 0 | |
| 747 | } | |
| 695 | 748 | } |
| 696 | 749 | weeks[weekKey].sessions++ |
| 697 | 750 | weeks[weekKey].totalScore += s.score |
| 751 | weeks[weekKey].totalPerTurnScore += (s.perTurnScore != null ? s.perTurnScore : s.score) | |
| 752 | // Trusted score aggregate (NEW primary metric) | |
| 753 | if (s.trustedScore != null) { | |
| 754 | weeks[weekKey].totalTrustedScore += s.trustedScore | |
| 755 | weeks[weekKey].trustedScoreSessions++ | |
| 756 | } | |
| 757 | // Correction-burst aggregate — longest streak of consecutive corrections | |
| 758 | if (s.correctionBurst != null) { | |
| 759 | weeks[weekKey].totalCorrectionBurst += s.correctionBurst | |
| 760 | weeks[weekKey].burstSessions++ | |
| 761 | weeks[weekKey].maxCorrectionBurst = Math.max(weeks[weekKey].maxCorrectionBurst, s.correctionBurst) | |
| 762 | } | |
| 763 | weeks[weekKey].totalTurns += s.turns | |
| 764 | weeks[weekKey].totalDensity += (s.density != null ? s.density : (s.corrections / Math.max(1, s.turns))) | |
| 698 | 765 | weeks[weekKey].corrections += s.corrections |
| 699 | 766 | weeks[weekKey].approvals += s.approvals |
| 700 | 767 | weeks[weekKey].toolCalls += s.totalToolCalls |
| 701 | 768 | } |
| 702 | 769 | for (const w of Object.values(weeks)) { |
| 703 | 770 | w.avgScore = Math.round((w.totalScore / w.sessions) * 1000) / 1000 |
| 771 | w.avgPerTurnScore = Math.round((w.totalPerTurnScore / w.sessions) * 1000) / 1000 | |
| 772 | w.avgTrustedScore = w.trustedScoreSessions > 0 | |
| 773 | ? Math.round((w.totalTrustedScore / w.trustedScoreSessions) * 1000) / 1000 | |
| 774 | : null | |
| 775 | w.avgCorrectionBurst = w.burstSessions > 0 | |
| 776 | ? Math.round((w.totalCorrectionBurst / w.burstSessions) * 100) / 100 | |
| 777 | : null | |
| 778 | w.avgTurns = Math.round(w.totalTurns / w.sessions) | |
| 779 | w.avgDensity = Math.round((w.totalDensity / w.sessions) * 1000) / 1000 | |
| 704 | 780 | } |
| 705 | 781 | return weeks |
| 706 | 782 | } |
| … | … | @@ -817,6 +893,16 @@ async function main () { |
| 817 | 893 | cwd: s.cwd, |
| 818 | 894 | turns: s.turns, |
| 819 | 895 | score: s.score, |
| 896 | // Per-turn score (length-independent) | |
| 897 | perTurnScore: s.perTurnScore, | |
| 898 | // Trusted-score (top-5 sensors only) — PRIMARY quality metric | |
| 899 | trustedScore: s.trustedScore, | |
| 900 | sessionSignalScore: s.sessionSignalScore, | |
| 901 | density: s.density, | |
| 902 | approvalDensity: s.approvalDensity, | |
| 903 | // Correction-burst: longest run of consecutive corrections (added 2026-04-13) | |
| 904 | correctionBurst: s.correctionBurst, | |
| 905 | sessionLength: s.sessionLength, | |
| 820 | 906 | corrections: s.corrections, |
| 821 | 907 | approvals: s.approvals, |
| 822 | 908 | totalToolCalls: s.totalToolCalls, |
| … | … | @@ -64,6 +64,19 @@ async function main () { |
| 64 | 64 | // Step 9: Dashboard |
| 65 | 65 | run('Dashboard', `node ${path.join(DIR, 'dashboard.js')} --no-open`) |
| 66 | 66 | |
| 67 | // Step 10: AtomSpace ingest (optional — skips if CogServer not running) | |
| 68 | try { | |
| 69 | const { isCogServerUp } = require('./atomspace/cogserver') | |
| 70 | const up = await isCogServerUp() | |
| 71 | if (up) { | |
| 72 | run('AtomSpace Ingest', `node ${path.join(DIR, 'atomspace', 'ingest.js')}`) | |
| 73 | } else { | |
| 74 | console.log(' AtomSpace: CogServer not running, skipping ingest') | |
| 75 | } | |
| 76 | } catch { | |
| 77 | // atomspace module not available — skip silently | |
| 78 | } | |
| 79 | ||
| 67 | 80 | const total = Date.now() - start |
| 68 | 81 | console.log(`\nFast pipeline: ${(total / 1000).toFixed(1)}s`) |
| 69 | 82 | } |
| … | … | @@ -13,6 +13,12 @@ const PRINCIPLES_FILE = path.join(__dirname, 'active-principles.md') |
| 13 | 13 | const CORRECTIONS_LOG = path.join(__dirname, 'corrections.log') |
| 14 | 14 | const PROJECT_MAPS_FILE = path.join(__dirname, 'data', 'project-maps.json') |
| 15 | 15 | |
| 16 | // AtomSpace structural queries (optional — graceful fallback if CogServer down) | |
| 17 | let atomspaceQuery = null | |
| 18 | let fireLog = null | |
| 19 | try { atomspaceQuery = require('./atomspace/query') } catch {} | |
| 20 | try { fireLog = require('./atomspace/fire-log') } catch {} | |
| 21 | ||
| 16 | 22 | // Shared modules and their downstream consumers |
| 17 | 23 | // This is the map of files where edits need pre-flight review |
| 18 | 24 | const SHARED_MODULE_MAP = { |
| … | … | @@ -106,7 +112,7 @@ function loadRecentCorrections (filePath) { |
| 106 | 112 | } |
| 107 | 113 | |
| 108 | 114 | // Check which shared module pattern matches this file |
| 109 | function findSharedModule (filePath) { | |
| 115 | async function findSharedModule (filePath) { | |
| 110 | 116 | // First check hardcoded map (manually curated danger files) |
| 111 | 117 | for (const [pattern, info] of Object.entries(SHARED_MODULE_MAP)) { |
| 112 | 118 | if (filePath.includes(pattern)) { |
| … | … | @@ -114,7 +120,14 @@ function findSharedModule (filePath) { |
| 114 | 120 | } |
| 115 | 121 | } |
| 116 | 122 | |
| 117 | // Fallback: check autodiscovered project map for high-danger subsystems | |
| 123 | // Skip non-production paths — tests, examples, demos aren't shared module risk | |
| 124 | const lowerPath = filePath.toLowerCase() | |
| 125 | if (/\/(tests?|examples?|demos?|fixtures?|__tests__|__mocks__)\//.test(lowerPath)) { | |
| 126 | return null | |
| 127 | } | |
| 128 | ||
| 129 | // Fallback: check autodiscovered project map for HIGH-danger subsystems only. | |
| 130 | // Medium is too common (18/28 subsystems) and blocks routine development. | |
| 118 | 131 | if (fs.existsSync(PROJECT_MAPS_FILE)) { |
| 119 | 132 | try { |
| 120 | 133 | const maps = JSON.parse(fs.readFileSync(PROJECT_MAPS_FILE, 'utf8')) |
| … | … | @@ -126,7 +139,7 @@ function findSharedModule (filePath) { |
| 126 | 139 | const matchesFile = (subData.files || []).some(f => |
| 127 | 140 | filePath.endsWith(f) || filePath.endsWith('/' + f) |
| 128 | 141 | ) |
| 129 | if (matchesFile && subData.dangerLevel !== 'low') { | |
| 142 | if (matchesFile && subData.dangerLevel === 'high') { | |
| 130 | 143 | return { |
| 131 | 144 | pattern: `${projectName}/${subName}`, |
| 132 | 145 | description: `Auto-detected: ${projectName}/${subName} subsystem (${subData.fileCount} files, ${subData.loopCount} past loops, danger=${subData.dangerLevel})`, |
| … | … | @@ -141,6 +154,28 @@ function findSharedModule (filePath) { |
| 141 | 154 | } catch {} |
| 142 | 155 | } |
| 143 | 156 | |
| 157 | // Final fallback: query AtomSpace for structural risk. | |
| 158 | // Require genuine danger signal: 3+ past loops or high danger with downstream consumers. | |
| 159 | // A single past loop is routine development, not a blocking signal. | |
| 160 | if (atomspaceQuery) { | |
| 161 | try { | |
| 162 | const br = await atomspaceQuery.blastRadius(filePath) | |
| 163 | if (br) { | |
| 164 | const isGenuineRisk = br.loops.length >= 3 || | |
| 165 | (br.dangerLevel === 'high' && br.consumers.length > 0) || | |
| 166 | (br.loops.length >= 1 && br.consumers.length >= 3) | |
| 167 | if (isGenuineRisk) { | |
| 168 | return { | |
| 169 | pattern: br.file, | |
| 170 | description: `AtomSpace: ${br.loops.length} past loops, danger=${br.dangerLevel}, ${br.consumers.length} downstream consumers`, | |
| 171 | downstream: br.consumers.slice(0, 5).map(c => c.replace('file:', '')), | |
| 172 | loopHistory: br.loops.length > 0 ? `${br.loops.length} loops linked in AtomSpace` : null | |
| 173 | } | |
| 174 | } | |
| 175 | } | |
| 176 | } catch {} | |
| 177 | } | |
| 178 | ||
| 144 | 179 | return null |
| 145 | 180 | } |
| 146 | 181 | |
| … | … | @@ -175,7 +210,7 @@ async function main () { |
| 175 | 210 | process.exit(0) |
| 176 | 211 | } |
| 177 | 212 | |
| 178 | const sharedModule = findSharedModule(filePath) | |
| 213 | const sharedModule = await findSharedModule(filePath) | |
| 179 | 214 | if (!sharedModule) { |
| 180 | 215 | process.exit(0) // not a shared module, allow |
| 181 | 216 | } |
| … | … | @@ -191,6 +226,32 @@ async function main () { |
| 191 | 226 | const corrections = loadRecentCorrections(filePath) |
| 192 | 227 | const downstream = sharedModule.downstream |
| 193 | 228 | |
| 229 | // AtomSpace structural + semantic + contrast context (optional, non-blocking) | |
| 230 | let structural = null | |
| 231 | let fileCtx = null | |
| 232 | let unfamiliarity = null | |
| 233 | let resolutions = null | |
| 234 | let contrast = null | |
| 235 | if (atomspaceQuery) { | |
| 236 | try { structural = await atomspaceQuery.blastRadiusEnhanced(filePath) } catch {} | |
| 237 | try { fileCtx = await atomspaceQuery.fileContext(filePath) } catch {} | |
| 238 | try { unfamiliarity = await atomspaceQuery.unfamiliarityScore(filePath) } catch {} | |
| 239 | if (structural && structural.loops.length > 0) { | |
| 240 | try { resolutions = await atomspaceQuery.findResolution(filePath) } catch {} | |
| 241 | } | |
| 242 | // Contrast-derived meta-cognition: "last time this was the fix / last time this was wasted effort" | |
| 243 | try { contrast = await atomspaceQuery.contrastGuidance(filePath) } catch {} | |
| 244 | } | |
| 245 | ||
| 246 | // Live session context (streaming observability) | |
| 247 | let liveSession = null | |
| 248 | let preferredApproaches = null | |
| 249 | if (atomspaceQuery) { | |
| 250 | try { liveSession = await atomspaceQuery.liveSessionContext(filePath) } catch {} | |
| 251 | // Preferred-approach from success memory (Phase 2.3) | |
| 252 | try { preferredApproaches = await atomspaceQuery.preferredApproachFor(filePath) } catch {} | |
| 253 | } | |
| 254 | ||
| 194 | 255 | // Build the briefing |
| 195 | 256 | const briefing = [] |
| 196 | 257 | briefing.push('PRE-FLIGHT REVIEW — SHARED MODULE EDIT') |
| … | … | @@ -206,6 +267,120 @@ async function main () { |
| 206 | 267 | briefing.push(` → ${dep}`) |
| 207 | 268 | } |
| 208 | 269 | briefing.push(` ${downstream.length} project(s) share this pattern`) |
| 270 | ||
| 271 | // Add AtomSpace context if available | |
| 272 | if (fileCtx || structural || unfamiliarity) { | |
| 273 | briefing.push('') | |
| 274 | briefing.push('ATOMSPACE CONTEXT') | |
| 275 | briefing.push('─'.repeat(30)) | |
| 276 | ||
| 277 | // File classification — language/domain/role | |
| 278 | if (fileCtx) { | |
| 279 | const parts = [] | |
| 280 | if (fileCtx.language !== 'unknown') parts.push(`language=${fileCtx.language}`) | |
| 281 | if (fileCtx.domain !== 'unknown') parts.push(`domain=${fileCtx.domain}`) | |
| 282 | if (fileCtx.role !== 'unknown') parts.push(`role=${fileCtx.role}`) | |
| 283 | if (fileCtx.isExportedAPI) parts.push(`exports=${fileCtx.exports} (public API)`) | |
| 284 | if (parts.length) briefing.push(` File: ${parts.join(', ')}`) | |
| 285 | } | |
| 286 | ||
| 287 | // Unfamiliarity signal | |
| 288 | if (unfamiliarity && unfamiliarity.familiarity !== 'unknown') { | |
| 289 | const warn = (unfamiliarity.familiarity === 'unfamiliar' || unfamiliarity.familiarity === 'never-visited') | |
| 290 | briefing.push(` Module: ${unfamiliarity.module} (${unfamiliarity.sessions} sessions — ${unfamiliarity.familiarity})${warn ? ' ⚠' : ''}`) | |
| 291 | } | |
| 292 | ||
| 293 | // Structural blast radius | |
| 294 | if (structural) { | |
| 295 | if (structural.consumers.length) { | |
| 296 | briefing.push(` ${structural.consumers.length} files import from this file`) | |
| 297 | for (const c of structural.consumers.slice(0, 3)) { | |
| 298 | briefing.push(` ← ${c.replace('file:', '')}`) | |
| 299 | } | |
| 300 | } | |
| 301 | if (structural.loops.length) { | |
| 302 | briefing.push(` ${structural.loops.length} past correction loops on this file`) | |
| 303 | } | |
| 304 | if (structural.rules.length) { | |
| 305 | const hw = structural.rulesHighWisdom || 0 | |
| 306 | const lw = structural.rulesLowWisdom || 0 | |
| 307 | const wisdomNote = hw > 0 ? ` (${hw} high-wisdom)` : (lw > 0 ? ` (${lw} stale-referents)` : '') | |
| 308 | briefing.push(` ${structural.rules.length} learned rules apply${wisdomNote}`) | |
| 309 | } | |
| 310 | if (typeof structural.riskScore === 'number') { | |
| 311 | briefing.push(` Combined risk score: ${structural.riskScore}`) | |
| 312 | } | |
| 313 | } | |
| 314 | ||
| 315 | // Past resolutions for similar loops | |
| 316 | if (resolutions && resolutions.matches.length > 0) { | |
| 317 | briefing.push('') | |
| 318 | briefing.push(' SIMILAR PAST RESOLUTIONS') | |
| 319 | for (const m of resolutions.matches.slice(0, 3)) { | |
| 320 | briefing.push(` [${m.type || '?'}] fix was in: ${m.resolutionFiles.slice(0, 2).join(', ')}`) | |
| 321 | } | |
| 322 | } | |
| 323 | ||
| 324 | // Live session observability — what happened THIS session | |
| 325 | if (liveSession && liveSession.sessionId) { | |
| 326 | const hasLiveSignal = liveSession.editsForThisFile > 0 || | |
| 327 | liveSession.correctionsNearThisFile > 0 || | |
| 328 | liveSession.totalSessionCorrections > 2 | |
| 329 | if (hasLiveSignal) { | |
| 330 | briefing.push('') | |
| 331 | briefing.push(' THIS SESSION (live)') | |
| 332 | if (liveSession.editsForThisFile > 0) { | |
| 333 | briefing.push(` You've edited this file ${liveSession.editsForThisFile}x this session`) | |
| 334 | } | |
| 335 | if (liveSession.correctionsNearThisFile > 0) { | |
| 336 | briefing.push(` ⚠ ${liveSession.correctionsNearThisFile} corrections occurred while editing this file`) | |
| 337 | for (const c of liveSession.recentCorrections.slice(0, 2)) { | |
| 338 | briefing.push(` turn ${c.turn}: "${c.text.slice(0, 80)}..."`) | |
| 339 | } | |
| 340 | } | |
| 341 | if (liveSession.totalSessionCorrections > 2) { | |
| 342 | briefing.push(` Session has ${liveSession.totalSessionCorrections} corrections total — slow down`) | |
| 343 | } | |
| 344 | } | |
| 345 | } | |
| 346 | ||
| 347 | // Preferred-approach from past successes (Phase 2.3) | |
| 348 | if (preferredApproaches && preferredApproaches.length > 0) { | |
| 349 | briefing.push('') | |
| 350 | briefing.push(' ★ PAST SUCCESSFUL APPROACHES') | |
| 351 | for (const p of preferredApproaches.slice(0, 2)) { | |
| 352 | briefing.push(` [${p.shape} ×${p.successCount}] ${p.id.replace('preferred:', '').split('::')[0]}`) | |
| 353 | if (p.sampleApproval) briefing.push(` e.g. "${p.sampleApproval}"`) | |
| 354 | } | |
| 355 | } | |
| 356 | ||
| 357 | // Contrast-derived meta-cognition (highest-value signal) | |
| 358 | if (contrast) { | |
| 359 | const hasSignal = (contrast.worksFor && contrast.worksFor.length > 0) || | |
| 360 | (contrast.failedFor && contrast.failedFor.length > 0) || | |
| 361 | (contrast.patterns && contrast.patterns.length > 0) | |
| 362 | if (hasSignal) { | |
| 363 | briefing.push('') | |
| 364 | briefing.push(' META-COGNITION (contrast-derived)') | |
| 365 | if (contrast.worksFor.length > 0) { | |
| 366 | briefing.push(` ✓ This file FIXED ${contrast.worksFor.length} past loops — it's usually the right place`) | |
| 367 | } | |
| 368 | if (contrast.failedFor.length > 0) { | |
| 369 | briefing.push(` ✗ This file was WASTED EFFORT in ${contrast.failedFor.length} past loops — fix was elsewhere`) | |
| 370 | } | |
| 371 | // Cross-loop patterns | |
| 372 | for (const p of (contrast.patterns || []).slice(0, 1)) { | |
| 373 | briefing.push(` Pattern [${p.context}] (${p.loopCount} loops):`) | |
| 374 | if (p.tryFirst.length) { | |
| 375 | briefing.push(` TRY: ${p.tryFirst.slice(0, 3).join(', ')}`) | |
| 376 | } | |
| 377 | if (p.avoidFirst.length) { | |
| 378 | briefing.push(` AVOID: ${p.avoidFirst.slice(0, 3).join(', ')}`) | |
| 379 | } | |
| 380 | } | |
| 381 | } | |
| 382 | } | |
| 383 | } | |
| 209 | 384 | briefing.push('') |
| 210 | 385 | |
| 211 | 386 | if (principles.length > 0) { |
| … | … | @@ -242,6 +417,51 @@ async function main () { |
| 242 | 417 | briefing.push('confirm. Tell Claude to proceed or adjust the approach.') |
| 243 | 418 | briefing.push('─'.repeat(50)) |
| 244 | 419 | |
| 420 | // Log fired rules/meta atoms for utility tracking + live dashboard | |
| 421 | if (fireLog) { | |
| 422 | try { | |
| 423 | const sessionId = (() => { | |
| 424 | try { return require('fs').readFileSync('/tmp/claude-atomspace-session', 'utf8').trim() } catch { return null } | |
| 425 | })() | |
| 426 | const contextExcerpt = `edit ${filePath}` | |
| 427 | // Log rules that would be shown | |
| 428 | if (structural && structural.rules) { | |
| 429 | for (const r of structural.rules.slice(0, 5)) { | |
| 430 | fireLog.logFire({ | |
| 431 | session: sessionId, | |
| 432 | hook: 'preflight', | |
| 433 | atom: r, | |
| 434 | context: contextExcerpt, | |
| 435 | trigger: filePath | |
| 436 | }) | |
| 437 | } | |
| 438 | } | |
| 439 | // Log meta atoms from contrast guidance | |
| 440 | if (contrast) { | |
| 441 | for (const m of (contrast.worksFor || []).slice(0, 3)) { | |
| 442 | fireLog.logFire({ | |
| 443 | session: sessionId, | |
| 444 | hook: 'preflight', | |
| 445 | atom: `meta:loop:${m.loopId}`, | |
| 446 | atomType: 'meta-loop', | |
| 447 | context: contextExcerpt, | |
| 448 | trigger: filePath | |
| 449 | }) | |
| 450 | } | |
| 451 | for (const p of (contrast.patterns || []).slice(0, 2)) { | |
| 452 | fireLog.logFire({ | |
| 453 | session: sessionId, | |
| 454 | hook: 'preflight', | |
| 455 | atom: `meta:pattern:${p.context}`, | |
| 456 | atomType: 'pattern', | |
| 457 | context: contextExcerpt, | |
| 458 | trigger: filePath | |
| 459 | }) | |
| 460 | } | |
| 461 | } | |
| 462 | } catch {} | |
| 463 | } | |
| 464 | ||
| 245 | 465 | // Mark as reviewed for this session (won't block again on same file) |
| 246 | 466 | // The next edit to this file will pass through — if the user overrides, |
| 247 | 467 | // that's tracked by the override-tracker for principle demotion |
| … | … | @@ -62,6 +62,58 @@ const APPROVAL_PATTERNS = _tuned?.approval || [ |
| 62 | 62 | /\b(thanks|thank you)\b/i, |
| 63 | 63 | ] |
| 64 | 64 | |
| 65 | // Weak-positive patterns — not full approval but proceed signal. | |
| 66 | const PROCEED_PATTERNS = [ | |
| 67 | /\b(continue|proceed|go ahead|go on|keep going|next)\b/i, | |
| 68 | /^\s*(yes|yep|yeah|sure|ok|okay)\b/i, | |
| 69 | /\b(do it|do that|try it|let'?s try)\b/i | |
| 70 | ] | |
| 71 | ||
| 72 | // Direction / new-problem signals — user giving a new task or pivoting. | |
| 73 | // These reset the stuck-loop counter because we're starting fresh. | |
| 74 | const NEW_PROBLEM_PATTERNS = [ | |
| 75 | // Directives that start new work | |
| 76 | /^\s*(now|next|also|and|then)\b/i, | |
| 77 | /\b(let'?s|lets)\s+(add|build|make|create|try|do|check|write|fix|update|run|test|deploy|restart)\b/i, | |
| 78 | // Imperative commands (usually building on prior state) | |
| 79 | /^\s*(add|build|make|create|fix|update|run|test|deploy|restart|remove|delete|show|tell)\b/i, | |
| 80 | // Explicit pivots | |
| 81 | /\b(actually|instead|wait|hold on)\b.*\b(let'?s|try|do)\b/i, | |
| 82 | /^\s*i (want|need|would like) (you|to|us)\b/i | |
| 83 | ] | |
| 84 | ||
| 85 | // Simple commands — one-word or short imperative that's not a correction | |
| 86 | const COMMAND_PATTERNS = [ | |
| 87 | /^\s*(restart|run|test|build|commit|push|deploy|check|show|list|help)\s*\??\s*$/i | |
| 88 | ] | |
| 89 | ||
| 90 | /** | |
| 91 | * Classify a user prompt semantically. | |
| 92 | * Returns: 'correction' | 'approval' | 'proceed' | 'new-problem' | 'command' | 'question' | 'neutral' | |
| 93 | * | |
| 94 | * Categories affect state differently: | |
| 95 | * correction → corrections++, keeps problem-turns running | |
| 96 | * approval → full completion, resets problem, increments problemCount | |
| 97 | * proceed → weak reset on stuck-loop if stuck, else continues problem | |
| 98 | * new-problem → full reset (new work starting) | |
| 99 | * command → weak reset (using prior state for a new action) | |
| 100 | * question → neutral (info-seeking, doesn't advance or block) | |
| 101 | * neutral → no signal | |
| 102 | */ | |
| 103 | function classifyPrompt (text) { | |
| 104 | if (!text) return 'neutral' | |
| 105 | const t = text.trim() | |
| 106 | // Order matters — check corrections first (strongest negative) | |
| 107 | if (CORRECTION_PATTERNS.some(p => p.test(t))) return 'correction' | |
| 108 | if (APPROVAL_PATTERNS.some(p => p.test(t))) return 'approval' | |
| 109 | if (COMMAND_PATTERNS.some(p => p.test(t))) return 'command' | |
| 110 | if (NEW_PROBLEM_PATTERNS.some(p => p.test(t))) return 'new-problem' | |
| 111 | if (PROCEED_PATTERNS.some(p => p.test(t))) return 'proceed' | |
| 112 | // Question heuristic | |
| 113 | if (/\?/.test(t) && /\b(why|how|what|when|where|which|can you|do you|is there)\b/i.test(t)) return 'question' | |
| 114 | return 'neutral' | |
| 115 | } | |
| 116 | ||
| 65 | 117 | // Load skip patterns from tuned config, fall back to defaults |
| 66 | 118 | const _skipFromConfig = (() => { |
| 67 | 119 | if (fs.existsSync(TUNED_PATTERNS_FILE)) { |
| … | … | @@ -97,7 +149,15 @@ const INTERRUPTION_PATTERN = /^\[Request interrupted/ |
| 97 | 149 | function loadState () { |
| 98 | 150 | if (!fs.existsSync(STATE_FILE)) return createFreshState() |
| 99 | 151 | try { |
| 100 | return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) | |
| 152 | const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) | |
| 153 | // Migrate old state: ensure new problem-tracking fields exist | |
| 154 | // Seed from existing counters so context-saturation etc work immediately | |
| 155 | if (state._problemStartTurn == null) state._problemStartTurn = 0 | |
| 156 | if (state._problemTurns == null) state._problemTurns = state.turns || 0 | |
| 157 | if (state._problemCount == null) state._problemCount = (state.approvals || 0) + 1 | |
| 158 | if (state._lastApprovalTurn == null) state._lastApprovalTurn = 0 | |
| 159 | if (state._lastProceedTurn == null) state._lastProceedTurn = 0 | |
| 160 | return state | |
| 101 | 161 | } catch { |
| 102 | 162 | return createFreshState() |
| 103 | 163 | } |
| … | … | @@ -129,6 +189,14 @@ function createFreshState () { |
| 129 | 189 | _recentCorrections: [], // last 5 correction texts |
| 130 | 190 | _loopActive: false, |
| 131 | 191 | _loopMatches: null, |
| 192 | // Problem-boundary tracking (NEW) | |
| 193 | // "Problem turns" = turns since last approval/proceed (or session start). | |
| 194 | // Resets on approval, proceed signal, or topic shift. | |
| 195 | _problemStartTurn: 0, // turn # when current problem started | |
| 196 | _problemTurns: 0, // turns spent on current problem | |
| 197 | _problemCount: 1, // # of problems tackled this session | |
| 198 | _lastApprovalTurn: 0, // last turn with explicit approval | |
| 199 | _lastProceedTurn: 0 // last turn with weak-positive signal | |
| 132 | 200 | } |
| 133 | 201 | } |
| 134 | 202 | |
| … | … | @@ -308,15 +376,55 @@ async function handlePrompt () { |
| 308 | 376 | |
| 309 | 377 | const state = loadState() |
| 310 | 378 | |
| 311 | // Check for correction/approval | |
| 312 | const isCorrection = CORRECTION_PATTERNS.some(p => p.test(userText)) | |
| 313 | const isApproval = APPROVAL_PATTERNS.some(p => p.test(userText)) | |
| 379 | // Semantic prompt classification | |
| 380 | const category = classifyPrompt(userText) | |
| 381 | const isCorrection = category === 'correction' | |
| 382 | const isApproval = category === 'approval' | |
| 314 | 383 | const isInterruption = INTERRUPTION_PATTERN.test(userText) |
| 315 | 384 | |
| 316 | 385 | if (isCorrection) state.corrections++ |
| 317 | 386 | if (isApproval) state.approvals++ |
| 318 | 387 | if (isInterruption) state.interruptions++ |
| 319 | 388 | |
| 389 | // Problem-boundary tracking based on semantic category | |
| 390 | // Each category has a distinct effect on stuck-loop counter: | |
| 391 | switch (category) { | |
| 392 | case 'approval': | |
| 393 | // Full completion — new problem begins | |
| 394 | state._lastApprovalTurn = state.turns | |
| 395 | state._problemStartTurn = state.turns | |
| 396 | state._problemTurns = 0 | |
| 397 | state._problemCount = (state._problemCount || 1) + 1 | |
| 398 | break | |
| 399 | case 'new-problem': | |
| 400 | case 'command': | |
| 401 | // Fresh work starting — reset problem-turns, count as weak completion | |
| 402 | state._problemStartTurn = state.turns | |
| 403 | state._problemTurns = 0 | |
| 404 | state._problemCount = (state._problemCount || 1) + 1 | |
| 405 | state._lastProceedTurn = state.turns | |
| 406 | break | |
| 407 | case 'proceed': | |
| 408 | // Weak reset — resets stuck-loop if currently stuck, else continues problem | |
| 409 | state._lastProceedTurn = state.turns | |
| 410 | if (state._problemTurns > 4) { | |
| 411 | state._problemStartTurn = state.turns | |
| 412 | state._problemTurns = 0 | |
| 413 | } | |
| 414 | break | |
| 415 | case 'correction': | |
| 416 | // Stays in current problem; counter advances as normal | |
| 417 | break | |
| 418 | case 'question': | |
| 419 | case 'neutral': | |
| 420 | // No categorical effect — fall through to counter update | |
| 421 | break | |
| 422 | } | |
| 423 | ||
| 424 | // Always update problem-turns (resets above take effect) | |
| 425 | state._problemTurns = state.turns - (state._problemStartTurn || 0) | |
| 426 | state._lastPromptCategory = category | |
| 427 | ||
| 320 | 428 | // Check edit survival — if this turn is a correction and last turn had edits, |
| 321 | 429 | // those edits likely didn't survive |
| 322 | 430 | if (isCorrection && state._lastTurnEdits.length > 0) { |
| … | … | @@ -471,15 +579,24 @@ function handleStatus () { |
| 471 | 579 | : state.trajectory === 'declining' ? '▼' |
| 472 | 580 | : '▬' |
| 473 | 581 | |
| 474 | // Compute learning rate: what % of corrections led to clean turns? | |
| 582 | // Compute learning rate: what % of correction BURSTS led to clean turns? | |
| 583 | // A burst = consecutive corrections collapsed into one event. | |
| 584 | // Learned = burst followed by 3+ non-correction turns. | |
| 585 | // Failed = burst followed by another burst within 3 turns. | |
| 475 | 586 | const scores = state.turnScores || [] |
| 476 | 587 | let corrLearned = 0 |
| 477 | 588 | let corrFailed = 0 |
| 478 | for (let i = 0; i < scores.length; i++) { | |
| 589 | let i = 0 | |
| 590 | while (i < scores.length) { | |
| 479 | 591 | if (scores[i] === 0.2) { |
| 480 | const after = scores.slice(i + 1, i + 4) | |
| 592 | // Found a correction burst — skip consecutive corrections | |
| 593 | while (i < scores.length && scores[i] === 0.2) i++ | |
| 594 | // Now check what follows | |
| 595 | const after = scores.slice(i, i + 3) | |
| 481 | 596 | if (after.includes(0.2)) corrFailed++ |
| 482 | 597 | else corrLearned++ |
| 598 | } else { | |
| 599 | i++ | |
| 483 | 600 | } |
| 484 | 601 | } |
| 485 | 602 | const totalCorr = corrLearned + corrFailed |