@@ -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
33813415function 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+
35533852function 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