sotashimozono_obsidian-remo.../plugin/tests/PerfAggregator.test.ts
Souta 6772f60b65 test(plugin): Phase C.M4 — PerfAggregator + NDJSON / Markdown writers (0.4.41)
Pure-module aggregator that turns the span-duration samples emitted by
PerfTracer (M0) + the M2 instrumentation into per-bucket percentile
stats. M6 (microbench) will feed it; M10's CI gate will diff its
NDJSON against the `perf-baseline` branch and post the Markdown table
as a PR comment.

Why now: M4 is purely additive (no production code touched, no
existing tests modified) and is the prerequisite for M6 — without it
the bench has no way to turn raw span records into the p50/p95/p99
numbers the regression detector compares against. Lands in parallel
with M3 (Go daemon spans + cid correlator) since the two are
orthogonal: M3 is Go-only, M4 is TS-only and consumer-only.

Surface (`plugin/tests/integration/helpers/perfAggregator.ts`):

- Keyed by the bench-matrix tuple `(span, transport, sizeBytes)`:
    * span      — 'S.adp' | 'S.rpc' | 'S.app' | 'S.e2e' | …
    * transport — 'rpc' | 'sftp'
    * sizeBytes — fixture file size (0 when N/A, e.g. remove/rename)

- `record(span, transport, sizeBytes, durMs)` — push one sample.
  NaN / Infinity / negative values are silently dropped (a perf
  collector should never poison its own dataset on bad input). 0 is
  allowed (sub-resolution timer, not invalid).

- `percentiles(span, transport, sizeBytes, opts?) → PerfStats | null`
  — null when the bucket was never seen. Returns p50/p95/p99/mean/
  stddev/n, plus a `filtered` count when `opts.filterOutliers` is on.
  p* are linear-interpolated (R-7 / numpy.percentile default).

- `toNDJSON(opts?) → string` — one JSON object per bucket, trailing
  newline. Matches the format `PerfTracer.flushNDJSON` writes so the
  two streams can be `cat`'d into one file for offline analysis.

- `toMarkdownTable(opts?) → string` — header + separator + per-bucket
  row with width-aware numeric formatting (3 dec for sub-ms, 2 dec
  for sub-100ms, 1 dec otherwise). For PR comments / CI logs.

- `buckets() → PerfBucket[]` — introspection, lists every (span,
  transport, sizeBytes) seen with its raw count.

- `size()` — distinct-bucket count.

Tukey 1.5×IQR outlier filter is opt-in per call so a single dataset
can be reported both raw (transparency in CI logs) and filtered (the
gate that compares against baseline). Skipped automatically for
series with fewer than 4 samples — IQR isn't meaningful below that
and dropping points would zero out short bench runs.

Tests (13, all green; `tests/PerfAggregator.test.ts`):

- Empty aggregator: size=0, percentiles()=null, NDJSON/Markdown empty.
- Bucket isolation across (span, transport, sizeBytes).
- record() drops NaN / -1 / Infinity but keeps 0.
- Single-sample bucket: every percentile equals the sample, stddev=0.
- 1..10 dataset: p50=5.5, p95=9.55, p99=9.91 (numpy reference values),
  population stddev ≈ 2.872.
- Constant series: stddev=0.
- Tukey filter: 1..10 + 1000 → outlier dropped, mean returns to 5.5,
  filtered=1.
- Tukey skipped for n<4 (no points dropped from a 3-sample series).
- toNDJSON: one JSON per line, trailing newline, parseable, includes
  the `filtered` count when filtering is on.
- toMarkdownTable: header + separator + per-bucket row, numeric
  formatting widens by magnitude.
- buckets(): listing matches insertion order with raw counts.

Suite results:

- `npx vitest run` — 34 files / 454 tests green (was 441, +13 new)
- `npx tsc --noEmit -p tsconfig.json` — clean

Pure module: zero production-side code modified. Path is under
`tests/integration/helpers/` per the plan because the only consumer
is the M6 bench, but the module is integration-free so its unit
tests live in the standard `tests/` directory and run in the unit
suite (where the project's coverage and PR-gate workflows pick them
up).

Roadmap context: M4 + M3 are the two parallel streams that close out
the producer ↔ consumer contract for Phase C. After both land, M6
wires PerfTracer + M3 cid threading + PerfAggregator into a single
bench file and writes NDJSON results to disk, completing the MVP
slice (Phase C.0–6 of the plan).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:54:29 +09:00

177 lines
6.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect } from 'vitest';
import { PerfAggregator } from './integration/helpers/perfAggregator';
/**
* Pure-module unit coverage for the bench-side aggregator. The path
* lives under `tests/integration/helpers/` (consumer is the M6 bench)
* but the module itself is integration-free, so it gets unit tests
* here in the standard `tests/` directory.
*/
describe('PerfAggregator — keying & isolation', () => {
it('starts empty', () => {
const a = new PerfAggregator();
expect(a.size()).toBe(0);
expect(a.percentiles('S.adp', 'rpc', 1024)).toBeNull();
expect(a.toNDJSON()).toBe('');
expect(a.toMarkdownTable()).toBe('');
});
it('isolates samples across (span, transport, sizeBytes) tuples', () => {
const a = new PerfAggregator();
a.record('S.adp', 'rpc', 1024, 1.0);
a.record('S.adp', 'rpc', 1024, 2.0);
a.record('S.adp', 'rpc', 100_000, 5.0); // different size → different bucket
a.record('S.adp', 'sftp', 1024, 7.0); // different transport
a.record('S.rpc', 'rpc', 1024, 0.5); // different span
expect(a.size()).toBe(4);
expect(a.percentiles('S.adp', 'rpc', 1024)?.n).toBe(2);
expect(a.percentiles('S.adp', 'rpc', 100_000)?.n).toBe(1);
expect(a.percentiles('S.adp', 'sftp', 1024)?.n).toBe(1);
expect(a.percentiles('S.rpc', 'rpc', 1024)?.n).toBe(1);
});
it('drops non-finite or negative samples', () => {
const a = new PerfAggregator();
a.record('S.adp', 'rpc', 1024, NaN);
a.record('S.adp', 'rpc', 1024, -1);
a.record('S.adp', 'rpc', 1024, Infinity);
expect(a.size()).toBe(0);
a.record('S.adp', 'rpc', 1024, 0); // 0 is allowed (sub-resolution)
expect(a.percentiles('S.adp', 'rpc', 1024)?.n).toBe(1);
});
});
describe('PerfAggregator — percentile math', () => {
it('single-sample bucket: every percentile equals that sample', () => {
const a = new PerfAggregator();
a.record('S.adp', 'rpc', 1024, 7.0);
const s = a.percentiles('S.adp', 'rpc', 1024);
expect(s).not.toBeNull();
expect(s!.p50).toBe(7);
expect(s!.p95).toBe(7);
expect(s!.p99).toBe(7);
expect(s!.mean).toBe(7);
expect(s!.stddev).toBe(0);
expect(s!.n).toBe(1);
});
it('matches the R-7 / numpy linear-interpolated percentile on 1..10', () => {
const a = new PerfAggregator();
for (let i = 1; i <= 10; i++) a.record('S.adp', 'rpc', 1024, i);
const s = a.percentiles('S.adp', 'rpc', 1024)!;
// numpy: percentile([1..10], [50, 95, 99]) → [5.5, 9.55, 9.91]
expect(s.p50).toBeCloseTo(5.5, 5);
expect(s.p95).toBeCloseTo(9.55, 5);
expect(s.p99).toBeCloseTo(9.91, 5);
expect(s.mean).toBeCloseTo(5.5, 5);
// population stddev of 1..10 ≈ 2.872
expect(s.stddev).toBeCloseTo(2.8722813, 5);
expect(s.n).toBe(10);
expect(s.filtered).toBe(0); // no outlier filter applied
});
it('mean / stddev / n on a constant series', () => {
const a = new PerfAggregator();
for (let i = 0; i < 5; i++) a.record('S.adp', 'rpc', 1024, 4.0);
const s = a.percentiles('S.adp', 'rpc', 1024)!;
expect(s.mean).toBe(4);
expect(s.stddev).toBe(0);
expect(s.n).toBe(5);
});
});
describe('PerfAggregator — Tukey 1.5×IQR outlier filtering', () => {
it('removes the obvious outlier from 1..10 + 1000', () => {
const a = new PerfAggregator();
for (let i = 1; i <= 10; i++) a.record('S.adp', 'rpc', 1024, i);
a.record('S.adp', 'rpc', 1024, 1000);
const raw = a.percentiles('S.adp', 'rpc', 1024)!;
expect(raw.n).toBe(11);
// mean of 1..10 + 1000 = 95.45..., dragged way up by the outlier
expect(raw.mean).toBeGreaterThan(90);
const filt = a.percentiles('S.adp', 'rpc', 1024, { filterOutliers: true })!;
// Q1≈3.25, Q3≈8.5 (after sort), IQR≈5.25, hi-fence ≈ 16.4 → 1000 dropped
expect(filt.n).toBe(10);
expect(filt.filtered).toBe(1);
expect(filt.mean).toBeCloseTo(5.5, 5);
});
it('skips the filter on series with fewer than 4 samples (IQR is meaningless)', () => {
const a = new PerfAggregator();
a.record('S.adp', 'rpc', 1024, 1);
a.record('S.adp', 'rpc', 1024, 1);
a.record('S.adp', 'rpc', 1024, 1000); // would be a clear outlier with more samples
const filt = a.percentiles('S.adp', 'rpc', 1024, { filterOutliers: true })!;
expect(filt.n).toBe(3);
expect(filt.filtered).toBe(0);
});
});
describe('PerfAggregator — output formats', () => {
it('toNDJSON: one JSON object per bucket, trailing newline', () => {
const a = new PerfAggregator();
a.record('S.adp', 'rpc', 1024, 1.0);
a.record('S.adp', 'rpc', 1024, 3.0);
a.record('S.rpc', 'rpc', 1024, 0.5);
const text = a.toNDJSON();
expect(text.endsWith('\n')).toBe(true);
const lines = text.trimEnd().split('\n');
expect(lines).toHaveLength(2);
const parsed = lines.map((l) => JSON.parse(l));
expect(parsed[0]).toMatchObject({ span: 'S.adp', transport: 'rpc', sizeBytes: 1024, n: 2 });
expect(parsed[0].p50).toBeCloseTo(2.0, 5);
expect(parsed[1]).toMatchObject({ span: 'S.rpc', transport: 'rpc', sizeBytes: 1024, n: 1 });
});
it('toNDJSON honours filterOutliers and reports the dropped count', () => {
const a = new PerfAggregator();
for (let i = 1; i <= 10; i++) a.record('S.adp', 'rpc', 1024, i);
a.record('S.adp', 'rpc', 1024, 1000);
const filtered = JSON.parse(a.toNDJSON({ filterOutliers: true }).trim());
expect(filtered.n).toBe(10);
expect(filtered.filtered).toBe(1);
});
it('toMarkdownTable: header + separator + per-bucket row', () => {
const a = new PerfAggregator();
a.record('S.adp', 'rpc', 1024, 1.0);
a.record('S.adp', 'rpc', 1024, 3.0);
const md = a.toMarkdownTable();
const lines = md.split('\n');
expect(lines[0]).toContain('| span | transport |');
expect(lines[1]).toMatch(/\|---/); // separator
expect(lines[2]).toContain('| S.adp | rpc | 1024 | 2 |');
});
it('toMarkdownTable: numeric formatting widens by magnitude', () => {
const a = new PerfAggregator();
a.record('A', 'rpc', 0, 0.123456); // sub-millisecond → 3 decimals
a.record('B', 'rpc', 0, 12.345); // sub-100ms → 2 decimals
a.record('C', 'rpc', 0, 1234.5); // ≥100ms → 1 decimal
const md = a.toMarkdownTable();
expect(md).toContain('0.123');
expect(md).toContain('12.35');
expect(md).toContain('1234.5');
});
});
describe('PerfAggregator — buckets() introspection', () => {
it('lists every (span, transport, sizeBytes) seen, with raw counts', () => {
const a = new PerfAggregator();
a.record('S.adp', 'rpc', 1024, 1);
a.record('S.adp', 'rpc', 1024, 2);
a.record('S.rpc', 'sftp', 0, 5);
const bs = a.buckets();
expect(bs).toEqual([
{ span: 'S.adp', transport: 'rpc', sizeBytes: 1024, n: 2 },
{ span: 'S.rpc', transport: 'sftp', sizeBytes: 0, n: 1 },
]);
});
});