Skip to content

feat(repo)!: time every PR's mutation phases against its base - #274

Merged
ryanleecode merged 24 commits into
mainfrom
stryker/exec-bench-lane
Oct 10, 2026
Merged

ryanleecode merged 24 commits into
mainfrom
stryker/exec-bench-lane

Conversation

@systemfsoftware-maker

@systemfsoftware-maker systemfsoftware-maker commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

Every pull request that changes more than documentation now gets a Bench workflow that times a real mutation run on the PR head against the same run on its merge-base, per project and per phase, each project on its own runner. Before this there was no CI evidence of whether a change made a mutation run faster or slower, or which phase the time goes to.

First PR of Stream G (execution engine). The later optimisations in the stream (per-test selection, infection filtering, kill-likelihood ordering, warm module swap) each cite this lane's table as their speed evidence; the instrument share also decides whether Stream F's native schemata emitter goes ahead.

What the lane measures

.github/workflows/bench.yml has a plan job that reads test/bench/corpus.json and starts one bench job per entry. Each job builds side A (the merge-base with the base branch) and side B (the PR head) on its runner, with each side on its own released-tarball pin, and runs both interleaved in the order A B B A B A A B with --full over that entry:

  • three repo entries in test/bench/corpus.json, one dogfood file each from three packages, chosen so that every phase does real work and one entry has no checker. Each entry is bounded so its eight runs fit the job budget: the vitest-runner entry mutates lines 85-160 of its file, and the vitest-runner and TypeScript-checker entries run only their workflow's property test (testFiles). Both sides get the same bound, and it shapes timing only, never a verdict;
  • the enterprise-monorepo e2e fixture.

PRs that change only **/*.md or docs/** skip the workflow. A full-corpus bench belongs on main and is not part of this lane.

Time budget

No bench job may run over 10 minutes, and the whole workflow must finish within 10. Each bench job has timeout-minutes: 9 and plan has 1. Each job also sets BENCH_DEADLINE_MS 7.5 minutes after it starts. The orchestrator clips each run's timeout to that deadline, and when the deadline stops a run or arrives before one starts, it writes an aborted report with code budget-exceeded, the finished runs' times and the next action, emits ::error and exits 1. An overrun therefore fails with a reason before the job ceiling is reached.

Measured on head 714dae7, run 38037113409 (whole workflow 6 min 18 s, created to last job done):

job wall setup inside the bench step per run (8 runs)
plan 0:06
test/e2e-core 4:00 turbo build 38 s + 37 s, installs 10 s + 6 s 15-16 s
packages/stryker-js-typescript-checker 5:15 turbo build 22 s + 21 s, installs 6 s + 4 s 29-30 s
enterprise-monorepo fixture 5:28 closure build and pack 39 s + 37 s, fixture install 17 s + 10 s 18-35 s
packages/stryker-js-vitest-runner 5:41 turbo build 39 s + 36 s, installs 9 s + 7 s 27-30 s

The released tarballs come from the released-tarballs cache that ci.yml already fills, so neither side runs Nix when the two pins match (side A copies side B's tarballs). Before this change one job ran every entry and took 26 minutes (3.3 minutes of tarball builds, 22.5 minutes of runs).

Every timing comes from the run's own NDJSON verdict line, not from wall-clock wrapped around the CLI. The job summary shows one table per project:

phase main (A) PR (B) Δ verdict
prepare median s · share of total median s · share of total B − A improved / regressed / no signal / not measured / workload unverified
instrument … … … …
check … … … …
dry-run … … … …
mutation-test … … … …
reporting … … … …
total … … … …

A cell only counts as a signal when the four samples on each side do not overlap at all and |Δ median| is at least 3% of A. Under the null hypothesis a cell is a false signal in at most 2/70 ≈ 2.9% of orderings, so treat a lone signal as a reason to re-run, not as proof. Each project also reports its workload status (same, changed with the differing entries, or unverified when a digest could not be computed) and both sides' mutant and executed-test counts. A cell with a changed or unverified workload never signals.

Until this PR is on main, side A's verdict lines carry no check or reporting duration. Those cells read "not measured", and so does mutation-test on side A, because the older engine counts reporting time inside it.

Contract change (breaking)

  • The verdict line's phaseDurations gains check (measured {ms} | not-run | not-recorded) and reporting (measured {ms} | not-recorded). check is checker-busy wall-clock, the union of the intervals in which any checker was starting or checking. It overlaps the other phases.
  • RunPhase gains reporting, and mutation-test now stops where reporting starts, so the five sequential phases add up to the elapsed time. Exhaustive matches over RunPhase have to handle the new value.
  • Verdict lines written before this change decode both new keys as { "_tag": "not-recorded" }. The new keys ship in stream schemaVersion 8.0, the version main's unreleased subsumption change already introduced. The changeset bumps stryker-js-cli-contract minor and stryker-js major.

How an agent consumes the result

  1. Artifacts. Download the bench-report-<entry slug> artifacts from the Bench workflow run on the PR head, one per corpus entry. Each holds that entry's bench-report.json.
  2. Schema. Decode it with BenchReportJson from @systemfsoftware/stryker-e2e-core (test/e2e-core/src/bench-report.schema.ts, schemaVersion: '1.0'). outcome is a tagged union:
    • summarized: projects[], each with corpus, entry, workload, counts, and rows[] of { phase, a, b, verdict }. A measured side cell stores every sample (ms, shareOfTotal) in position order, and median, min and max are derived from those samples. verdict is improved | regressed | no-signal | not-measured | workload-unverified, with deltaMs and deltaShareOfA where measured.
    • failed: invalid[], each a run key plus code (stream-undecodable | stream-invalid | timeout), exitCode and a bounded stderrTail.
    • aborted: code, reason and nextAction for any setup or orchestration failure, including entry-unknown and budget-exceeded.
      runs[] holds every run's outer wall-clock and exit code, and setupSteps[] the time of each setup step.
  3. Verdict line. Each bench job's log carries exactly one annotation: ::error title=Bench regression::… when any cell regressed, ::error for a failed or aborted report (the job also exits non-zero), and ::notice otherwise. The markdown table and the annotation are both rendered from the decoded report, so they cannot disagree with the artifact.

The lane is report-only. It is not part of ci.yml, so no required status context changes.

Code the new tests target in main's mutation report

The dogfood mutation lane mutates src/**/*.workflow.ts and src/**/*.schema.ts in each package. The new laws and integration scenarios target:

  • test/e2e-core/src/summarize-bench.workflow.ts: per-phase rule table, sample separation, the 3% floor, workload same / changed / unverified, and suppression of mutation-test when side A's reporting was not recorded.
  • test/e2e-core/src/read-bench-run.workflow.ts: exactly one verdict line, undecodable or invalid streams, and timeouts with their exit code and stderr tail.
  • test/e2e-core/src/render-bench-report.workflow.ts: table, notice or error choice, failed and aborted markdown, and annotation escaping.
  • test/e2e-core/src/bench-summary.schema.ts, bench-report.schema.ts, bench-run.schema.ts: round-trips and abort codes with their next actions.
  • packages/stryker-js/src/phase-durations.workflow.ts: the five sequential phases sum to the elapsed time, a missing mark yields PhaseMarksMissing, and check is not-run without a checker.
  • packages/stryker-js/src/phase-durations.schema.ts: CheckerBusyIntervalSchema refuses endMs < startMs (one clamp, at PhaseClock.recordCheckerBusy), and the checker-busy union is bounded, order-free, split- and duplicate-invariant.
  • packages/stryker-js-cli-contract/src/run-event.schema.ts, through the wire-line and contract-version suites: absent keys decode as not-recorded, and the contract-compat check now reports a removed constrained union branch.

Verification

Run locally on the head: pnpm typecheck (58/58 tasks), plus the changed and added suites in test/e2e-core (bench-decisions, read-bench-run and render-bench-report), packages/stryker-js (phase-durations, check-cost-record, readmit-subsumed, run-event-stream: 35) and packages/stryker-js-cli-contract (all 160), all passing. The bench smoke covered the environment-incomplete, corpus-unreadable, side-setup-failed, entry-unknown and budget-exceeded aborts: each wrote the aborted report, emitted ::error and exited 1, and no Stryker run started. No bench run or mutation run was done locally. The lane itself runs only in CI, on this PR.

Plan: docs/plans/2026-10-10-0536-perf-execution-engine-bench-lane-plan.md.

Records what origin/main already does for Stream G with citations, the infection losslessness argument, the switch-point contract for Stream F, the per-mutator purity draft, and the implementation plan for PR 1 (report-only same-job A/B bench lane plus a check phase duration). Per REPO-D2 this is the PR's single plan file.
N = 4 in order A B B A B A A B with a non-overlap + 3% signal rule (2/70 null rate per cell), workload digest from mutate files, covering tests from each run's own incremental report and loaded configs/manifests/catalogs, side A = merge-base with the PR's base branch, one build mechanism for both sides, 20-minute target, no artifact upload, incomparable state dropped.
…hase durations

Adds .github/workflows/bench.yml (approved under CONST-E9 by supervisor ruling
1, 2026-10-09): builds the PR's merge-base with its base branch and the PR
head, runs both A B B A B A A B over the repo slice and the enterprise fixture
with --full, and publishes one versioned bench-report.json from which the step
summary table and a single ::notice/::error line derive.

The verdict line's phaseDurations gains check (union of checker-busy
intervals; measured | not-run | not-recorded) and reporting (measured |
not-recorded), and RunPhase gains reporting. Pure bench decisions live in
test/e2e-core; the orchestrator is the new private test/bench package. The
catalog helpers move from test/e2e into test/e2e-core (DEL1: no copy left).
Share SetupStep, BenchCorpusName and the BenchPhase schema instead of
redeclaring them; add BenchRunKey.label; reuse the reporting cells for the
suppress rule; dedupe covering ids before lookup; name the render property
tests after their workflows so test-discipline accepts them.
# Conflicts:
#	packages/stryker-js/src/Checker/checker-pool.handle.ts
# Conflicts:
#	packages/stryker-js-cli-contract/tests/run-event-wire-line.integration.test.ts
Every setup or orchestration failure now writes an aborted report with a
code and next action, emits ::error and exits non-zero. Each spawned CLI
run has its own timeout and a timed-out run is an invalid sample. Failed
runs carry their exit code and a bounded stderr tail; an uncomputable
workload digest is a distinct unverified state that never reports a
speed signal. The phase-split and rendering decisions move into
*.workflow.ts modules (ADR-0002), the per-phase reading is one rule
table, a measured side cell stores its samples so its statistics are
derived, and CheckerBusyIntervalSchema refuses endMs < startMs with one
clamp at the clock. The contract-compat check reports a removed
constrained union branch. The bench shares the e2e-core lint config and
one manifest resolve helper.
@systemfsoftware-maker systemfsoftware-maker changed the title stryker/exec bench lane feat(repo)!: time every PR's mutation phases against its base Oct 10, 2026
The effect tsgo lint (newSchemaClass) fails CI's check on the three
`new` constructions moved into catalog-resolution.ts.
Per the sub-conductor CI budget ruling: one bench job per corpus entry,
emitted by a plan job from test/bench/corpus.json, each with
timeout-minutes 9; a job deadline (BENCH_DEADLINE_MS) clips every run and
aborts the report with code budget-exceeded instead of a silent kill.
Repo entries pin a mutate range and their dedicated testFiles, identical on
both sides. Side B's tarballs come from the released-tarballs cache CI
already fills, side A copies them when the pins match. Docs-only PRs skip
the lane.
# Conflicts:
#	packages/stryker-js/src/run/deferrable-dry-run.cell.ts
#	packages/stryker-js/src/run/mutant-settlement.ts
The report keeps only per-run wall time and counts; the enterprise
fixture's executed-test counts swing between runs of one workload, and
diagnosing that needs every run's own NDJSON and incremental report.
… the temp outDir

The TCE temp program extends the project's tsconfig, so paths aliases pull
real sources in and tsc wrote their .js beside them. Later runs resolved
those files, changing which tests Vitest selected: the bench's enterprise
fixture swung 47-481 executed tests with no edit.

bench: restore each side's project tree before every run (main's released
checker still leaks) and pin the enterprise mutate set to four files.
…ecision

The plan was started, so KTD4b lands in a new dated plan that supersedes
it instead of an in-place rewrite; one plan file stays in the PR.
…cked projects

The checker's TCE temp program extends the project tsconfig, so tsc wrote
pulled-in sources' .js beside them and later runs selected fewer tests.
The plan is superseded to correct KTD4b: side A builds main's checker from
the merge-base, not a released one.
…-out

The enterprise job on d929a6b hung in npm install until the 9-minute job
ceiling cancelled it, with no report: the deadline covered only the runs.
Setup now runs under the same deadline, setup commands force-kill 10 s
after interruption like runs do, and a stall aborts with its own code and
next action.
The enterprise job's setup stalled past the deadline on a7ad3b6 and the
log named no step. Each timed setup step now logs when it starts and ends.
The enterprise install stalled at the deadline on two heads and its
buffered output was discarded on interruption. An interrupted setup
command now prints its output tail, and the enterprise npm installs log
each registry request, so the tail names the request that stalled.
…commit

@effect/opentelemetry and @effect/platform-node 4.0.3 were published with
a peer on effect ^4.0.3, which is not published; npm's resolver looped on
that peer until the job deadline. Both enterprise npm installs now pass
--before the time of the side's last pnpm-lock.yaml commit, so only
releases that lockfile could have seen are admitted and both sides with
the same lockfile resolve the same versions.
…the report

The report said an entry's workload was same or changed, but kept no
digest to check that against. Each measured run now carries its
executed-test count and workload digest, and the report schema moves to
1.1. The plan's KTD11 is updated in a new dated plan that supersedes the
1050 one.

@ryanleecode ryanleecode left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Root verified: all checks green, 0 threads, no suppressions. Over-10m legs inherited (Stream K).

@ryanleecode
ryanleecode merged commit 70aaafd into main Oct 10, 2026
18 checks passed
@ryanleecode
ryanleecode deleted the stryker/exec-bench-lane branch October 10, 2026 17:50
systemfsoftware-maker added a commit that referenced this pull request Oct 10, 2026
…ersion #282 requires

#282 made mainVersion a required field of the version-law input; #274, merged after it, added the nullable-struct and constrained-union scenarios without it, so the package's tests no longer compiled (TS2741) and those cases read as stale-baseline. Both scenarios now declare mainVersion equal to their releasedVersion, as every other 0.4.0 scenario does.
ryanleecode pushed a commit that referenced this pull request Oct 10, 2026
)

* fix(stryker-js): read the ignore reason's code in count-runs

#276 read the decoded IgnoreStatusReason's ruleId while #283 renamed that field to code; each PR compiled alone and main 251a511 fails tsc (TS2339). A property law now encodes a drawn IgnoreStatusReason through the schema and requires countRuns to count exactly that code once.

* fix(stryker-js-cli-contract): give #274's two law scenarios the mainVersion #282 requires

#282 made mainVersion a required field of the version-law input; #274, merged after it, added the nullable-struct and constrained-union scenarios without it, so the package's tests no longer compiled (TS2741) and those cases read as stale-baseline. Both scenarios now declare mainVersion equal to their releasedVersion, as every other 0.4.0 scenario does.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants