import { Logger, MetricAggregate } from './Logger'; import { PERF_TOUCH_RESPONSE_MAX_MS, PERF_JUMP_STATE_TOGGLE_MAX_MS, PERF_COMBO_RECOGNITION_MAX_MS, PERF_PARABOLIC_ANGLE_ACCURACY_TARGET, PERF_AIR_JUMP_BLOCK_RATE_TARGET, MAX_FIRST_PACKAGE_BYTES, MAX_AUDIO_BUNDLE_BYTES, MAX_MEMORY_PEAK_BYTES, } from './Constants'; /** * Performance monitor (task 10.2, req 18 & 20). * * Aggregates all the KPI samples recorded through `Logger.metric(...)` and * reports pass/fail against every threshold listed in the requirements doc. * CI can run `collectReport()` and assert that `allPassing === true`. */ export interface IPerfThreshold { metric: string; /** Budget target (max for latency, min for rates). */ limit: number; comparator: '<=' | '>='; requirementId: string; } export const CORE_PERF_THRESHOLDS: ReadonlyArray = [ { metric: 'input/touchStart', limit: PERF_TOUCH_RESPONSE_MAX_MS, comparator: '<=', requirementId: 'req 20.1' }, { metric: 'jump/state_toggle_ms', limit: PERF_JUMP_STATE_TOGGLE_MAX_MS, comparator: '<=', requirementId: 'req 20.2' }, { metric: 'input/parabolic_accuracy', limit: PERF_PARABOLIC_ANGLE_ACCURACY_TARGET, comparator: '>=', requirementId: 'req 20.3' }, { metric: 'input/combo_recognition_ms', limit: PERF_COMBO_RECOGNITION_MAX_MS, comparator: '<=', requirementId: 'req 20.4' }, { metric: 'jump/air_jump_block_rate', limit: PERF_AIR_JUMP_BLOCK_RATE_TARGET, comparator: '>=', requirementId: 'req 20.5' }, ]; export interface IPerfCheckResult { threshold: IPerfThreshold; aggregate?: MetricAggregate; passing: boolean; reason: string; } export interface IPerfReport { allPassing: boolean; checks: IPerfCheckResult[]; /** Optional build/runtime sizes filled in by CI (bytes). */ firstPackageBytes?: number; audioBundleBytes?: number; memoryPeakBytes?: number; /** Top-level pass/fail for the size budgets. */ sizeBudgetPassing?: boolean; } export class PerfMonitor { constructor( private readonly logger: Logger, private readonly thresholds: ReadonlyArray = CORE_PERF_THRESHOLDS ) {} public collectReport(buildSizes?: { firstPackageBytes?: number; audioBundleBytes?: number; memoryPeakBytes?: number; }): IPerfReport { const checks: IPerfCheckResult[] = this.thresholds.map((t) => this.check(t)); let sizeBudgetPassing: boolean | undefined; if (buildSizes) { sizeBudgetPassing = (buildSizes.firstPackageBytes ?? 0) <= MAX_FIRST_PACKAGE_BYTES && (buildSizes.audioBundleBytes ?? 0) <= MAX_AUDIO_BUNDLE_BYTES && (buildSizes.memoryPeakBytes ?? 0) <= MAX_MEMORY_PEAK_BYTES; } const allPassing = checks.every((c) => c.passing) && (sizeBudgetPassing ?? true); return { allPassing, checks, firstPackageBytes: buildSizes?.firstPackageBytes, audioBundleBytes: buildSizes?.audioBundleBytes, memoryPeakBytes: buildSizes?.memoryPeakBytes, sizeBudgetPassing, }; } private check(t: IPerfThreshold): IPerfCheckResult { const agg = this.logger.aggregate(t.metric); if (!agg) { return { threshold: t, passing: false, reason: `no samples recorded for "${t.metric}"`, }; } // For latency, use p95. For rate metrics, use avg. const isRate = t.comparator === '>='; const observed = isRate ? agg.avg : agg.p95; const passing = isRate ? observed >= t.limit : observed <= t.limit; return { threshold: t, aggregate: agg, passing, reason: `${t.metric} ${isRate ? 'avg' : 'p95'}=${observed.toFixed(2)} vs limit ${t.limit} (${t.comparator})`, }; } }