Skip to content

Commit 535f294

Browse files
authored
Merge pull request #175 from LennartvdM/codex/add-human-readable-purity-summary-and-logging
Add human-readable purity analysis
2 parents e99bafb + 5bfc9f2 commit 535f294

1 file changed

Lines changed: 327 additions & 9 deletions

File tree

web/app.js

Lines changed: 327 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3376,6 +3376,40 @@ function accumulateActivityMinutes(items, resolveId, resolveMinutes) {
33763376
* @property {boolean} hasAnyError
33773377
* @property {BatchPurityRunResult[]} runs
33783378
* @property {boolean} [didCheckerError]
3379+
* @property {BatchPurityAnalysis} [analysis]
3380+
*/
3381+
3382+
/**
3383+
* @typedef {Object} BatchPurityActivityAnalysis
3384+
* @property {string} key
3385+
* @property {string} label
3386+
* @property {number} totalRaw
3387+
* @property {number} totalShare
3388+
* @property {number} totalSequence
3389+
* @property {number} avgRawPerRun
3390+
* @property {number} avgSharePerRun
3391+
* @property {number} avgShareDriftMinutes
3392+
* @property {number} avgShareDriftPercent
3393+
*/
3394+
3395+
/**
3396+
* @typedef {Object} BatchPurityRunAnalysis
3397+
* @property {number} runIndex
3398+
* @property {number} totalRaw
3399+
* @property {number} totalShare
3400+
* @property {number} totalSequence
3401+
* @property {number} totalDriftMinutes
3402+
* @property {{ id: string, label: string, minutes: number }} [worstActivityByAbsoluteDrift]
3403+
* @property {{ id: string, label: string, percent: number }} [worstActivityByRelativeDrift]
3404+
*/
3405+
3406+
/**
3407+
* @typedef {Object} BatchPurityAnalysis
3408+
* @property {number} avgRawTotal
3409+
* @property {number} avgShareTotal
3410+
* @property {number} avgSequenceTotal
3411+
* @property {BatchPurityActivityAnalysis[]} activities
3412+
* @property {BatchPurityRunAnalysis[]} runs
33793413
*/
33803414

33813415
function auditBatchRunPurity(truth) {
@@ -3524,6 +3558,7 @@ function computeBatchPuritySummary(batchRunsTruth, cachedResults) {
35243558
hasAnyError: false,
35253559
runs: runResults.slice(),
35263560
didCheckerError: false,
3561+
analysis: null,
35273562
};
35283563

35293564
runResults.forEach((result) => {
@@ -3538,6 +3573,7 @@ function computeBatchPuritySummary(batchRunsTruth, cachedResults) {
35383573
});
35393574

35403575
summary.hasAnyError = summary.impureRuns > 0 || summary.didCheckerError;
3576+
summary.analysis = buildBatchPurityAnalysis(runResults);
35413577
return summary;
35423578
} catch (error) {
35433579
console.error('[Purity] Checker crashed:', error);
@@ -3550,6 +3586,269 @@ function computeBatchPuritySummary(batchRunsTruth, cachedResults) {
35503586
}
35513587
}
35523588

3589+
function buildBatchPurityAnalysis(runResults) {
3590+
const results = Array.isArray(runResults) ? runResults : [];
3591+
const totalRuns = results.length;
3592+
if (!totalRuns) {
3593+
return {
3594+
avgRawTotal: 0,
3595+
avgShareTotal: 0,
3596+
avgSequenceTotal: 0,
3597+
activities: [],
3598+
runs: [],
3599+
};
3600+
}
3601+
3602+
const activityLookup = new Map();
3603+
const runAnalyses = [];
3604+
let rawTotalMinutes = 0;
3605+
let shareTotalMinutes = 0;
3606+
let sequenceTotalMinutes = 0;
3607+
3608+
results.forEach((run, index) => {
3609+
if (!run) {
3610+
return;
3611+
}
3612+
const runIndex = Number.isFinite(run.runIndex) ? run.runIndex : index + 1;
3613+
const totalRaw = Number.isFinite(run.totalRaw) ? run.totalRaw : 0;
3614+
const totalShare = Number.isFinite(run.totalShare) ? run.totalShare : 0;
3615+
const totalSequence = Number.isFinite(run.totalSequence) ? run.totalSequence : 0;
3616+
const activityTotals = Array.isArray(run.activityTotals) ? run.activityTotals : [];
3617+
rawTotalMinutes += totalRaw;
3618+
shareTotalMinutes += totalShare;
3619+
sequenceTotalMinutes += totalSequence;
3620+
3621+
const worst = {
3622+
absolute: null,
3623+
relative: null,
3624+
};
3625+
3626+
activityTotals.forEach((activity, activityIndex) => {
3627+
if (!activity) {
3628+
return;
3629+
}
3630+
const rawValue = Number.isFinite(activity.raw) ? activity.raw : 0;
3631+
const shareValue = Number.isFinite(activity.share) ? activity.share : 0;
3632+
const sequenceValue = Number.isFinite(activity.sequence) ? activity.sequence : 0;
3633+
const label =
3634+
activity.label ||
3635+
activity.key ||
3636+
`activity-${activityIndex + 1}`;
3637+
const lookupKey =
3638+
activity.key ||
3639+
normalizeActivityKey(activity.label) ||
3640+
normalizeActivityKey(label) ||
3641+
label;
3642+
const driftMinutes = shareValue - rawValue;
3643+
const percentDrift = rawValue === 0 ? null : driftMinutes / rawValue;
3644+
const absoluteDiff = Math.abs(driftMinutes);
3645+
if (!worst.absolute || absoluteDiff > worst.absolute.absoluteDiff) {
3646+
worst.absolute = {
3647+
id: lookupKey,
3648+
label,
3649+
minutes: driftMinutes,
3650+
absoluteDiff,
3651+
};
3652+
}
3653+
if (percentDrift !== null) {
3654+
const absolutePercent = Math.abs(percentDrift);
3655+
if (!worst.relative || absolutePercent > worst.relative.absolutePercent) {
3656+
worst.relative = {
3657+
id: lookupKey,
3658+
label,
3659+
percent: percentDrift,
3660+
absolutePercent,
3661+
};
3662+
}
3663+
}
3664+
let bucket = activityLookup.get(lookupKey);
3665+
if (!bucket) {
3666+
bucket = {
3667+
key: lookupKey,
3668+
label,
3669+
totalRaw: 0,
3670+
totalShare: 0,
3671+
totalSequence: 0,
3672+
};
3673+
activityLookup.set(lookupKey, bucket);
3674+
}
3675+
bucket.totalRaw += rawValue;
3676+
bucket.totalShare += shareValue;
3677+
bucket.totalSequence += sequenceValue;
3678+
});
3679+
3680+
runAnalyses.push({
3681+
runIndex,
3682+
totalRaw,
3683+
totalShare,
3684+
totalSequence,
3685+
totalDriftMinutes: totalShare - totalRaw,
3686+
worstActivityByAbsoluteDrift: worst.absolute
3687+
? { id: worst.absolute.id, label: worst.absolute.label, minutes: worst.absolute.minutes }
3688+
: null,
3689+
worstActivityByRelativeDrift: worst.relative
3690+
? { id: worst.relative.id, label: worst.relative.label, percent: worst.relative.percent }
3691+
: null,
3692+
});
3693+
});
3694+
3695+
const activities = Array.from(activityLookup.values()).map((activity) => {
3696+
const avgRawPerRun = activity.totalRaw / totalRuns;
3697+
const avgSharePerRun = activity.totalShare / totalRuns;
3698+
const avgShareDriftMinutes = avgSharePerRun - avgRawPerRun;
3699+
const avgShareDriftPercent = avgRawPerRun === 0 ? null : avgShareDriftMinutes / avgRawPerRun;
3700+
return {
3701+
...activity,
3702+
avgRawPerRun,
3703+
avgSharePerRun,
3704+
avgShareDriftMinutes,
3705+
avgShareDriftPercent,
3706+
};
3707+
});
3708+
3709+
return {
3710+
avgRawTotal: rawTotalMinutes / totalRuns,
3711+
avgShareTotal: shareTotalMinutes / totalRuns,
3712+
avgSequenceTotal: sequenceTotalMinutes / totalRuns,
3713+
activities,
3714+
runs: runAnalyses,
3715+
};
3716+
}
3717+
3718+
function formatPurityMinutes(value) {
3719+
if (!Number.isFinite(value)) {
3720+
return '0';
3721+
}
3722+
const rounded = Math.round(value);
3723+
try {
3724+
return rounded.toLocaleString('en-US');
3725+
} catch (error) {
3726+
return String(rounded);
3727+
}
3728+
}
3729+
3730+
function formatPurityPercent(value) {
3731+
if (!Number.isFinite(value)) {
3732+
return 'n/a';
3733+
}
3734+
const percentValue = value * 100;
3735+
const absPercent = Math.abs(percentValue);
3736+
const rounded = absPercent >= 10 ? Math.round(percentValue) : Math.round(percentValue * 10) / 10;
3737+
const magnitude = Math.abs(rounded);
3738+
const sign = percentValue > 0 ? '+' : percentValue < 0 ? '-' : '';
3739+
return `${sign}${magnitude}%`;
3740+
}
3741+
3742+
function buildPurityObservations(summary) {
3743+
const lines = [];
3744+
if (!summary) {
3745+
return lines;
3746+
}
3747+
const totalRuns = Number.isFinite(summary.totalRuns) ? summary.totalRuns : 0;
3748+
const pureRuns = Number.isFinite(summary.pureRuns) ? summary.pureRuns : 0;
3749+
const impureRuns = Number.isFinite(summary.impureRuns) ? summary.impureRuns : 0;
3750+
const statusLabel = summary.didCheckerError
3751+
? 'ERROR'
3752+
: summary.hasAnyError
3753+
? 'FAILED'
3754+
: 'OK';
3755+
if (totalRuns > 0) {
3756+
const statusParts = [`Purity status: ${statusLabel}.`];
3757+
if (summary.didCheckerError) {
3758+
statusParts.push('Checker encountered errors.');
3759+
} else if (impureRuns > 0) {
3760+
statusParts.push(`${impureRuns}/${totalRuns} runs show drift between raw and share totals.`);
3761+
} else {
3762+
statusParts.push(`All ${pureRuns}/${totalRuns} runs are pure.`);
3763+
}
3764+
lines.push(`Overall: ${statusParts.join(' ')}`);
3765+
}
3766+
3767+
const analysis = summary.analysis;
3768+
if (!analysis || !totalRuns) {
3769+
return lines;
3770+
}
3771+
3772+
if (Number.isFinite(analysis.avgRawTotal)) {
3773+
const avgRaw = formatPurityMinutes(analysis.avgRawTotal);
3774+
const avgShare = formatPurityMinutes(analysis.avgShareTotal);
3775+
const avgSequence = formatPurityMinutes(analysis.avgSequenceTotal);
3776+
lines.push(
3777+
`Overall: Average total minutes per run: raw=${avgRaw}, share=${avgShare}, sequence=${avgSequence}.`
3778+
);
3779+
3780+
const shareDiff = analysis.avgShareTotal - analysis.avgRawTotal;
3781+
const percentDiff = analysis.avgRawTotal === 0 ? null : shareDiff / analysis.avgRawTotal;
3782+
if (Math.abs(shareDiff) >= 1) {
3783+
const direction = shareDiff < 0 ? 'missing' : 'over-reporting';
3784+
const percentText = formatPurityPercent(percentDiff);
3785+
const percentSuffix = percentText === 'n/a' ? '' : ` (≈ ${percentText}).`;
3786+
const driftMinutesText = formatPurityMinutes(Math.abs(shareDiff));
3787+
const baseText = `Overall: On average, share is ${direction} ${driftMinutesText} minutes per run compared to raw`;
3788+
lines.push(percentSuffix ? `${baseText}${percentSuffix}` : `${baseText}.`);
3789+
}
3790+
}
3791+
3792+
const activityThresholdMinutes = 60;
3793+
const activityThresholdPercent = 0.1;
3794+
const activityAnalyses = Array.isArray(analysis.activities) ? analysis.activities.slice() : [];
3795+
const significantActivities = activityAnalyses
3796+
.filter((activity) => {
3797+
if (!activity) {
3798+
return false;
3799+
}
3800+
const driftMinutes = Number(activity.avgShareDriftMinutes) || 0;
3801+
const driftPercent = Number(activity.avgShareDriftPercent) || 0;
3802+
return (
3803+
Math.abs(driftMinutes) >= activityThresholdMinutes ||
3804+
Math.abs(driftPercent) >= activityThresholdPercent
3805+
);
3806+
})
3807+
.sort(
3808+
(a, b) => Math.abs(b.avgShareDriftMinutes || 0) - Math.abs(a.avgShareDriftMinutes || 0)
3809+
)
3810+
.slice(0, 3);
3811+
3812+
significantActivities.forEach((activity) => {
3813+
const driftMinutes = activity.avgShareDriftMinutes || 0;
3814+
const direction = driftMinutes < 0 ? 'undercounted' : 'overcounted';
3815+
const label = activity.label || activity.key || 'activity';
3816+
const percentText = formatPurityPercent(activity.avgShareDriftPercent);
3817+
const percentSuffix = percentText === 'n/a' ? '' : ` (≈ ${percentText} vs raw)`;
3818+
const driftMinutesText = formatPurityMinutes(Math.abs(driftMinutes));
3819+
lines.push(
3820+
`Activity '${label}' is ${direction} in share by an average of ${driftMinutesText} minutes per run${percentSuffix}.`
3821+
);
3822+
});
3823+
3824+
const runThresholdMinutes = 60;
3825+
const runAnalyses = Array.isArray(analysis.runs) ? analysis.runs.slice() : [];
3826+
const worstRuns = runAnalyses
3827+
.filter((run) => run && Math.abs(run.totalDriftMinutes || 0) >= runThresholdMinutes)
3828+
.sort((a, b) => Math.abs(b.totalDriftMinutes || 0) - Math.abs(a.totalDriftMinutes || 0))
3829+
.slice(0, 3);
3830+
if (worstRuns.length > 0) {
3831+
const runLabels = worstRuns.map((run) => `#${run.runIndex || 0}`);
3832+
const minMagnitude = Math.min(
3833+
...worstRuns.map((run) => Math.abs(run.totalDriftMinutes || 0))
3834+
);
3835+
const allNegative = worstRuns.every((run) => (run.totalDriftMinutes || 0) < 0);
3836+
const allPositive = worstRuns.every((run) => (run.totalDriftMinutes || 0) > 0);
3837+
let descriptor = 'mismatching by';
3838+
if (allNegative) {
3839+
descriptor = 'missing';
3840+
} else if (allPositive) {
3841+
descriptor = 'over-reporting';
3842+
}
3843+
const minMagnitudeText = formatPurityMinutes(minMagnitude);
3844+
lines.push(
3845+
`Runs: Largest total drift in runs ${runLabels.join(', ')} (share ${descriptor}${minMagnitudeText} minutes each).`
3846+
);
3847+
}
3848+
3849+
return lines;
3850+
}
3851+
35533852
function cancelBatchFitMeasurement() {
35543853
if (
35553854
batchState.pendingFitFrame &&
@@ -3812,22 +4111,41 @@ function logBatchPurityReport(summary) {
38124111
if (!summary) {
38134112
return;
38144113
}
4114+
const totalRuns = Number.isFinite(summary.totalRuns) ? summary.totalRuns : 0;
4115+
const pureRuns = Number.isFinite(summary.pureRuns) ? summary.pureRuns : 0;
4116+
const impureRuns = Number.isFinite(summary.impureRuns) ? summary.impureRuns : 0;
4117+
const runs = Array.isArray(summary.runs) ? summary.runs : [];
4118+
const errorRuns = runs.filter((run) => run && run.didError).length;
4119+
const logLevel = summary.hasAnyError ? 'warn' : 'info';
4120+
4121+
let didLogAnalysisBlock = false;
38154122
try {
3816-
const totalRuns = Number.isFinite(summary.totalRuns) ? summary.totalRuns : 0;
3817-
const pureRuns = Number.isFinite(summary.pureRuns) ? summary.pureRuns : 0;
3818-
const impureRuns = Number.isFinite(summary.impureRuns) ? summary.impureRuns : 0;
3819-
const runs = Array.isArray(summary.runs) ? summary.runs : [];
3820-
const errorRuns = runs.filter((run) => run && run.didError).length;
3821-
const headerParts = [`[Purity] Batch complete: ${totalRuns} runs`, `pure=${pureRuns}`, `impure=${impureRuns}`];
4123+
const observationLines = buildPurityObservations(summary);
4124+
const headerParts = [`[Purity] Batch analysis: ${totalRuns} runs`, `pure=${pureRuns}`, `impure=${impureRuns}`];
38224125
if (errorRuns > 0 || summary.didCheckerError) {
38234126
const errorCount = errorRuns > 0 ? errorRuns : 1;
38244127
headerParts.push(`checkerErrors=${errorCount}`);
38254128
}
3826-
appendLogEntry({
3827-
level: summary.hasAnyError ? 'warn' : 'info',
3828-
message: headerParts.join(', '),
4129+
appendLogEntry({ level: logLevel, message: headerParts.join(', ') });
4130+
observationLines.forEach((line) => {
4131+
appendLogEntry({ level: logLevel, message: `[Purity] ${line}` });
38294132
});
4133+
didLogAnalysisBlock = true;
4134+
} catch (error) {
4135+
const message = typeof error?.message === 'string' ? error.message : 'Unknown error';
4136+
appendLogEntry({ level: 'error', message: `[Purity] ERROR building summary: ${message}` });
4137+
}
38304138

4139+
if (!didLogAnalysisBlock) {
4140+
const fallbackParts = [`[Purity] Batch complete: ${totalRuns} runs`, `pure=${pureRuns}`, `impure=${impureRuns}`];
4141+
if (errorRuns > 0 || summary.didCheckerError) {
4142+
const errorCount = errorRuns > 0 ? errorRuns : 1;
4143+
fallbackParts.push(`checkerErrors=${errorCount}`);
4144+
}
4145+
appendLogEntry({ level: logLevel, message: fallbackParts.join(', ') });
4146+
}
4147+
4148+
try {
38314149
runs.forEach((run, index) => {
38324150
if (!run) {
38334151
return;

0 commit comments

Comments
 (0)