/** * 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 stays fast @ 200 open issues', () => { const { issues, edges } = backlog(200) const run = () => ms(() => { schedule(issues, edges) scheduleWithCapacity(issues, edges, WORKERS) forecast(issues, edges, { workers: WORKERS }) // 2000 trials (default) }) run() // warm up (JIT) // Best of several runs: a micro-benchmark's minimum reflects true compute cost; // a single shot flakes when the CI/dev box is momentarily loaded. Nominal is // ~230ms, so 1500ms is a catastrophic-regression guard (>6x) that tolerates // load spikes — the scaling test below is the real O(n²) guard. const best = Math.min(run(), run(), run()) // eslint-disable-next-line no-console console.log(`[perf] schedule+capacity+forecast @200 = ${best.toFixed(1)}ms (best of 3)`) expect(best).toBeLessThan(1500) }) 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 }) })