Files
boc/SIL/metrics.mjs
T

377 lines
14 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
// ═══════════════════════════════════════════════════════════════════════════
// SIL Metrics — Diagnostiska metriker för System Intelligence Layer
// Erik-krav: recall, precision, false negatives, false positives,
// freshness, confidence calibration
// ═══════════════════════════════════════════════════════════════════════════
import { readFileSync, existsSync } from 'fs';
const SHADOW_LOG = '/home/bernt/.openclaw/workspace/SIL/shadow-log.jsonl';
const GOLD_SET = '/home/bernt/.openclaw/workspace/SIL/gold-set.jsonl';
class SILMetrics {
constructor() {
this.entries = this.loadLog(SHADOW_LOG);
this.goldEntries = this.loadLog(GOLD_SET);
}
loadLog(path) {
if (!existsSync(path)) return [];
return readFileSync(path, 'utf8')
.split('\n')
.filter(line => line.trim())
.map(line => {
try { return JSON.parse(line); } catch { return null; }
})
.filter(Boolean);
}
// ── Core Diagnostic Metrics ─────────────────────────────────────────────
/**
* RECALL: Hur många verkligt påverkade komponenter hittade SIL?
* recall = true_positives / (true_positives + false_negatives)
*/
calculateRecall(goldEntry) {
if (!goldEntry || !goldEntry.actual) return null;
const predicted = new Set(goldEntry.predicted || []);
const actual = new Set(goldEntry.actual || []);
let truePositives = 0;
let falseNegatives = 0;
for (const comp of actual) {
if (predicted.has(comp)) {
truePositives++;
} else {
falseNegatives++;
}
}
const total = truePositives + falseNegatives;
return {
truePositives,
falseNegatives,
recall: total > 0 ? truePositives / total : 0,
// Erik: "false negatives är farligare än false positives"
missRate: total > 0 ? falseNegatives / total : 0
};
}
/**
* PRECISION: Hur många av de rapporterade beroendena var relevanta?
* precision = true_positives / (true_positives + false_positives)
*/
calculatePrecision(goldEntry) {
if (!goldEntry || !goldEntry.actual) return null;
const predicted = new Set(goldEntry.predicted || []);
const actual = new Set(goldEntry.actual || []);
let truePositives = 0;
let falsePositives = 0;
for (const comp of predicted) {
if (actual.has(comp)) {
truePositives++;
} else {
falsePositives++;
}
}
const total = truePositives + falsePositives;
return {
truePositives,
falsePositives,
precision: total > 0 ? truePositives / total : 0,
falseDiscoveryRate: total > 0 ? falsePositives / total : 0
};
}
/**
* F1-SCORE: Harmoniskt medelvärde av precision och recall
*/
calculateF1(precision, recall) {
if (precision + recall === 0) return 0;
return 2 * (precision * recall) / (precision + recall);
}
/**
* FRESHNESS: Hur lång tid efter en ändring uppdaterades grafen?
*/
calculateFreshness() {
const now = Date.now();
const freshnessScores = [];
for (const entry of this.entries) {
if (!entry.timestamp) continue;
const entryTime = new Date(entry.timestamp).getTime();
const age = (now - entryTime) / 1000 / 60; // minuter
// Exponential decay: 100% vid 0 min, 50% vid 60 min, ~0% vid 4h
const freshness = Math.exp(-age / 60);
freshnessScores.push({
timestamp: entry.timestamp,
ageMinutes: Math.round(age),
freshness: Math.round(freshness * 100) / 100
});
}
if (freshnessScores.length === 0) return null;
const avgFreshness = freshnessScores.reduce((a, b) => a + b.freshness, 0) / freshnessScores.length;
const maxAge = Math.max(...freshnessScores.map(f => f.ageMinutes));
return {
averageFreshness: Math.round(avgFreshness * 100) / 100,
maxAgeMinutes: maxAge,
entries: freshnessScores.slice(-10) // senaste 10
};
}
/**
* CONFIDENCE CALIBRATION: Stämmer confidence-värdena med verklig träffsäkerhet?
* E.g. om SIL säger 90% confidence, bör ~90% av förutsägelserna vara korrekta
*/
calculateCalibration() {
const bins = {
'0.5-0.6': { predicted: 0, correct: 0 },
'0.6-0.7': { predicted: 0, correct: 0 },
'0.7-0.8': { predicted: 0, correct: 0 },
'0.8-0.9': { predicted: 0, correct: 0 },
'0.9-1.0': { predicted: 0, correct: 0 }
};
for (const gold of this.goldEntries) {
if (!gold.predictions) continue;
for (const pred of gold.predictions) {
const conf = pred.confidence || 0;
const bin = this.confidenceToBin(conf);
if (!bin) continue;
bins[bin].predicted++;
if (pred.verified === true) {
bins[bin].correct++;
}
}
}
const calibration = [];
for (const [bin, data] of Object.entries(bins)) {
if (data.predicted === 0) continue;
const actualAccuracy = data.correct / data.predicted;
const expectedConfidence = this.binToExpected(bin);
const calibrationError = Math.abs(expectedConfidence - actualAccuracy);
calibration.push({
bin,
predicted: data.predicted,
correct: data.correct,
expectedConfidence: Math.round(expectedConfidence * 100) + '%',
actualAccuracy: Math.round(actualAccuracy * 100) + '%',
calibrationError: Math.round(calibrationError * 100) + '%',
wellCalibrated: calibrationError < 0.1
});
}
const avgError = calibration.length > 0
? calibration.reduce((a, c) => a + parseFloat(c.calibrationError), 0) / calibration.length
: 0;
return {
bins: calibration,
averageCalibrationError: Math.round(avgError * 100) / 100 + '%',
wellCalibrated: avgError < 0.1
};
}
confidenceToBin(confidence) {
if (confidence >= 0.9) return '0.9-1.0';
if (confidence >= 0.8) return '0.8-0.9';
if (confidence >= 0.7) return '0.7-0.8';
if (confidence >= 0.6) return '0.6-0.7';
if (confidence >= 0.5) return '0.5-0.6';
return null;
}
binToExpected(bin) {
const map = { '0.5-0.6': 0.55, '0.6-0.7': 0.65, '0.7-0.8': 0.75, '0.8-0.9': 0.85, '0.9-1.0': 0.95 };
return map[bin] || 0.5;
}
// ── Aggregate Metrics ───────────────────────────────────────────────────
calculateAggregate() {
if (this.goldEntries.length === 0) {
return { error: 'Inga gold set-entries hittades. Kör gold-set.mjs först.' };
}
let totalTP = 0, totalFP = 0, totalFN = 0;
const perPREntries = [];
for (const gold of this.goldEntries) {
const recall = this.calculateRecall(gold);
const precision = this.calculatePrecision(gold);
if (recall && precision) {
totalTP += recall.truePositives;
totalFP += precision.falsePositives;
totalFN += recall.falseNegatives;
perPREntries.push({
pr: gold.pr || 'unknown',
recall: Math.round(recall.recall * 100) + '%',
precision: Math.round(precision.precision * 100) + '%',
falseNegatives: recall.falseNegatives,
falsePositives: precision.falsePositives,
f1: Math.round(this.calculateF1(precision.precision, recall.recall) * 100) + '%'
});
}
}
const aggregateRecall = totalTP + totalFN > 0 ? totalTP / (totalTP + totalFN) : 0;
const aggregatePrecision = totalTP + totalFP > 0 ? totalTP / (totalTP + totalFP) : 0;
const aggregateF1 = this.calculateF1(aggregatePrecision, aggregateRecall);
return {
totalPRsAnalyzed: this.goldEntries.length,
aggregate: {
recall: Math.round(aggregateRecall * 100) + '%',
precision: Math.round(aggregatePrecision * 100) + '%',
f1: Math.round(aggregateF1 * 100) + '%',
falseNegativeRate: totalTP + totalFN > 0 ? Math.round(totalFN / (totalTP + totalFN) * 100) + '%' : 'N/A',
falsePositiveRate: totalTP + totalFP > 0 ? Math.round(totalFP / (totalTP + totalFP) * 100) + '%' : 'N/A'
},
perPR: perPREntries.slice(-20), // senaste 20
// Erik: "false negatives är farligare än false positives"
riskAssessment: {
falseNegativeSeverity: totalFN > totalFP ? 'HIGH' : 'LOW',
recommendation: totalFN > totalFP
? 'Förbättra recall: SIL missar kritiska påverkade komponenter'
: 'OK: Fler falska larm än missade komponenter (acceptabelt)'
}
};
}
// ── Main Report ─────────────────────────────────────────────────────────
generateReport() {
const aggregate = this.calculateAggregate();
const freshness = this.calculateFreshness();
const calibration = this.calculateCalibration();
const report = {
timestamp: new Date().toISOString(),
period: {
totalEntries: this.entries.length,
goldSetEntries: this.goldEntries.length,
firstEntry: this.entries[0]?.timestamp || null,
lastEntry: this.entries[this.entries.length - 1]?.timestamp || null
},
diagnostics: aggregate,
freshness,
calibration,
// Erik: experiment-fas
experimentStatus: {
phase: 'EXPERIMENT',
criteria: {
recallTarget: '> 85%',
precisionTarget: '> 80%',
falseNegativeRateTarget: '< 10%',
calibrationErrorTarget: '< 10%'
},
readyForActivation: aggregate.aggregate
&& parseFloat(aggregate.aggregate.recall) > 85
&& parseFloat(aggregate.aggregate.precision) > 80
&& parseFloat(aggregate.aggregate.falseNegativeRate) < 10
}
};
this.printReport(report);
return report;
}
printReport(report) {
console.log('═══════════════════════════════════════════════════════════════');
console.log(' SIL DIAGNOSTIC METRICS');
console.log(' (Experiment-fas — ej aktiverad för merge)');
console.log('═══════════════════════════════════════════════════════════════\n');
if (report.diagnostics.error) {
console.log(`${report.diagnostics.error}\n`);
console.log(' Kör: node SIL/gold-set.mjs --build\n');
return;
}
// Aggregate
console.log('📊 AGGREGERADE METRIKER (alla Gold Set PR:er)\n');
const agg = report.diagnostics.aggregate;
console.log(` Recall: ${agg.recall} (hur många påverkade komponenter hittade vi?)`);
console.log(` Precision: ${agg.precision} (hur många rapporterade var faktiskt relevanta?)`);
console.log(` F1: ${agg.f1} (harmoniskt medelvärde)`);
console.log(` False Negative Rate: ${agg.falseNegativeRate} (missade kritiska påverkan?)`);
console.log(` False Positive Rate: ${agg.falsePositiveRate} (varnade i onödan?)`);
console.log();
// Risk assessment
const risk = report.diagnostics.riskAssessment;
const riskIcon = risk.falseNegativeSeverity === 'HIGH' ? '🔴' : '🟢';
console.log(`${riskIcon} RISKBEDÖMNING`);
console.log(` ${risk.recommendation}\n`);
// Freshness
if (report.freshness) {
console.log('⏱️ FRESHNESS\n');
console.log(` Genomsnittlig: ${report.freshness.averageFreshness}`);
console.log(` Max ålder: ${report.freshness.maxAgeMinutes} minuter`);
console.log(` Status: ${report.freshness.maxAgeMinutes < 60 ? '✅ OK' : '⚠️ För gammal'}\n`);
}
// Calibration
if (report.calibration && report.calibration.bins.length > 0) {
console.log('🎯 CONFIDENCE CALIBRATION\n');
console.log(` Genomsnittligt kalibreringsfel: ${report.calibration.averageCalibrationError}`);
console.log(` Väl kalibrerad: ${report.calibration.wellCalibrated ? '✅ Ja' : '⚠️ Nej'}\n`);
for (const bin of report.calibration.bins) {
const icon = bin.wellCalibrated ? '✅' : '⚠️';
console.log(` ${icon} ${bin.bin}: förväntat ${bin.expectedConfidence}, faktiskt ${bin.actualAccuracy}`);
console.log(` (${bin.correct}/${bin.predicted} korrekta, fel: ${bin.calibrationError})`);
}
console.log();
}
// Experiment status
console.log('🔬 EXPERIMENTSTATUS\n');
console.log(` Fas: ${report.experimentStatus.phase}`);
console.log(` Kriterier för aktivering:`);
console.log(` • Recall > 85%: ${report.experimentStatus.readyForActivation ? '✅' : '❌'}`);
console.log(` • Precision > 80%: ${report.experimentStatus.readyForActivation ? '✅' : '❌'}`);
console.log(` • False Negative Rate < 10%: ${report.experimentStatus.readyForActivation ? '✅' : '❌'}`);
console.log();
console.log(` ${report.experimentStatus.readyForActivation ? '✅ KLAR för aktivering vid merge' : '❌ INTE klar — fortsätt samla data'}\n`);
// Per-PR (senaste)
if (report.diagnostics.perPR.length > 0) {
console.log('📋 SENASTE PR-ANALYSER (Gold Set)\n');
for (const pr of report.diagnostics.perPR.slice(-10)) {
console.log(` ${pr.pr}:`);
console.log(` recall=${pr.recall} precision=${pr.precision} f1=${pr.f1}`);
console.log(` FN=${pr.falseNegatives} FP=${pr.falsePositives}`);
}
console.log();
}
console.log('═══════════════════════════════════════════════════════════════\n');
}
}
// ── Main ──────────────────────────────────────────────────────────────────
const metrics = new SILMetrics();
metrics.generateReport();