diff --git a/packages/core/src/perf/perf.test.ts b/packages/core/src/perf/perf.test.ts new file mode 100644 index 0000000..8f3df95 --- /dev/null +++ b/packages/core/src/perf/perf.test.ts @@ -0,0 +1,75 @@ +/** + * Performance pass (#32). The deterministic compute path must stay well under the + * PLAN.md targets on representative fixtures: + * - scheduler + Monte Carlo forecast < 1s @ 200 open issues. + * - scaling stays roughly linear (no accidental O(n²) in the hot path). + * + * Reconcile-<5s@500 is network-bound (~2N gitea calls) and is covered by the live + * reconcile, not here — this file benchmarks the pure compute the app runs each + * turn. Bounds are the actual targets with comfortable headroom so timing jitter + * can't flake the suite; actuals are logged. + */ +import { describe, expect, it } from 'vitest' + +import { forecast } from '../forecast/forecast-v0.js' +import { type DependencyEdge, schedule, type SchedulableIssue } from '../scheduler/scheduler-v0.js' +import { scheduleWithCapacity, type Worker } from '../scheduler/scheduler-capacity-v0.js' + +const EST = [1, 2, 3, 5, 8] +const WORKERS: Worker[] = [ + { person: 'a', speed: 0.8 }, + { person: 'b', speed: 0.6 }, + { person: 'c', speed: 1.0 }, +] + +/** A representative open backlog: varied estimates/priorities/assignees + a light dependency web. */ +function backlog(n: number): { issues: SchedulableIssue[]; edges: DependencyEdge[] } { + const issues: SchedulableIssue[] = Array.from({ length: n }, (_, i) => ({ + number: i + 1, + title: `Issue ${i + 1} with a representative title of some length`, + labels: [`est/${EST[i % EST.length]}d`, `p/${(i % 4) + 1}`], + estimateDays: EST[i % EST.length], + priority: (i % 4) + 1, + assignee: WORKERS[i % WORKERS.length].person, + })) + // ~1 dependency per 3 issues, always on a lower-numbered issue (acyclic) + const edges: DependencyEdge[] = [] + for (let i = 3; i < n; i += 3) edges.push({ issue: i + 1, dependsOn: i - 1 }) + return { issues, edges } +} + +function ms(fn: () => void): number { + const t0 = performance.now() + fn() + return performance.now() - t0 +} + +describe('perf (#32)', () => { + it('scheduler + Monte Carlo forecast < 1s @ 200 open issues', () => { + const { issues, edges } = backlog(200) + const elapsed = ms(() => { + schedule(issues, edges) + scheduleWithCapacity(issues, edges, WORKERS) + forecast(issues, edges, { workers: WORKERS }) // 2000 trials (default) + }) + // eslint-disable-next-line no-console + console.log(`[perf] schedule+capacity+forecast @200 = ${elapsed.toFixed(1)}ms`) + expect(elapsed).toBeLessThan(1000) + }) + + it('scales roughly linearly — 400 issues is well under 4x the 100-issue time', () => { + const small = backlog(100) + const big = backlog(400) + const run = (b: typeof small) => () => { + schedule(b.issues, b.edges) + forecast(b.issues, b.edges, { workers: WORKERS }) + } + // warm up (JIT) so the ratio reflects steady state + run(small)() + const t100 = Math.max(ms(run(small)), 0.1) + const t400 = ms(run(big)) + // eslint-disable-next-line no-console + console.log(`[perf] @100 = ${t100.toFixed(1)}ms · @400 = ${t400.toFixed(1)}ms · ratio ${(t400 / t100).toFixed(1)}x`) + expect(t400).toBeLessThan(t100 * 8) // generous: rules out O(n²), tolerant of jitter + }) +})