8f647a13

Report where a mutation run's time goes

## Why

Mutation checks routinely take hours in nix-ci and there was no way to find out why. The driver already measured each child's wall time (`Mutate.hs:runOneSuite`) but discarded it unless the child timed out; nothing was aggregated or reported. The coverage child measured its own in-process test time and wrote it as `baseline.json`, used only to derive per-mutation timeouts.

One wall-clock number per child would not have been enough anyway — see below.

## The three-way split, and why it is the whole point

Every child process pays three separate costs, and **which one dominates decides what would actually make the run faster**:

| cost | what it is | what to do if it dominates |
|---|---|---|
| **process startup** | exec, RTS init, `readAugmentedManifestFile`, `execTestDefM`, printing output | test several mutations per child |
| **suite setup** | `around`/`aroundAll` resources (tmp-postgres, webdriver), paid once per *child* rather than once per *suite* | share those resources across children |
| **test execution** | the leaf tests themselves | covering-test sets are too big, or the tests are slow |

Only the child can tell these apart, so it now writes its own numbers to a file the parent names with a new hidden `--mutation-timing-output`:

- `childTimingForestNanos` — `timedTime` of the `runSpecForestSynchronously` call
- `childTimingTestNanos` — `testSuiteStatSumTime` from `computeTestSuiteStats`
- `childTimingTestsRun` — leaves that ran (fewer than the covering-test count, under the child's fail-fast)

The parent subtracts: `startup = wall − forest`, `setup = forest − tests`, `tests = tests`. `childCosts` clamps the child's numbers to the parent's wall measurement first, so the parts **always sum to exactly the wall time** even if a child reports something impossible (there is a property test for this). A child that never wrote the file — killed on its timeout, or crashed first — is booked as `unattributed` rather than as three zeroes.

## What this immediately tells us

On the in-repo example project:

```
Timing report: mutation phase
  Wall clock                       4.21s
  Children                           328
  Sum of child wall time          16.57s
  Achieved parallelism        3.94× of 4

Where the child time goes
  process startup         15.94s   96.2%
  suite setup            486.8ms    2.9%
  test execution         140.9ms    0.9%
```

Coverage phase on the same project: **98.8% process startup**. On a toy project that is expected; on nix-ci, where suites spin up tmp-postgres, I would expect suite setup to take a large share instead. Either way the number now exists.

## Data flow

```
child (sydtest)                     parent (driver)                    output
──────────────────────────────────────────────────────────────────────────────
Single.hs         ─┐
SingleCoverage.hs ─┴─ ChildTiming ──> Mutate.hs / Coverage.hs ──> MutationPhaseTiming
                     (timing.json      (adds wall time, outcome,   CoveragePhaseTiming
                      in a temp dir)    covering-test count)              │
                                                                          v
                                                    summariseMutationPhase / ...Coverage
                                                                          │
                                              PhaseTimingSummary ─────────┤
                                                                          │
                              renderPhaseTimingSummary (terminal, limited)─┤─> stdout
                              renderPhaseTimingSummaryHtml ────────────────┴─> timing.html
```

### Where things live

- **`sydtest-mutation-runtime`**
  - `Test/Syd/Mutation/Timing.hs` — `ChildTiming`, what a child measures of itself.
  - `Test/Syd/Mutation/TimingReport.hs` — the recorded phase timings (`MutationPhaseTiming`, `CoveragePhaseTiming`), the pure aggregation (`summariseMutationPhase`, `summariseCoveragePhase` → `PhaseTimingSummary`), and the terminal renderer.
  - `Test/Syd/Mutation/TimingReport/Html.hs` — the same summary as a standalone page.
- **`sydtest`**
  - `OptParse.hs` — `--mutation-timing-output`, on both child settings records.
  - `MutationMode/Common.hs` — `childTimingOf`, `diffMonotonicNanos`.
  - `MutationMode/Single.hs`, `MutationMode/SingleCoverage.hs` — write the file.
- **`sydtest-mutation-driver`**
  - `Driver/Mutate.hs` — records a `MutationChildTiming` per child run, times the phase, writes both files.
  - `Driver/Coverage.hs` — same for coverage; also counts attempts so a flaky test shows up as such. `runCoverageMode` now takes a resolved job count and returns its timings for `Driver.hs:runCoverage` to accumulate across suites.
  - `Driver/Timing.hs` — `emitPhaseTimingReport`: writes the page, prints the abridged copy (only when the phase ran children, so the diff runner on an empty selection stays quiet).
  - `Driver/AssertScore.hs` — links the timing files through to `$out`, names `timing.html` alongside the report paths. Both are optional, so an older report directory does not crash a passing check.

## The page

Self-contained (inline CSS and JS, no external assets), so it opens straight from a Nix store path.

- Headline stacked bar for the phase, then a row per cost with an explanation of what to do about it.
- Grouped views: by suite, outcome, operator, module, and — only when more than one suite ran, since otherwise it repeats the per-child listing row for row — by mutation.
- **Per-child table with every child**, identity split into its own columns (operator, location, suite, outcome, tests run, mutation id). Sortable by any column; numeric columns carry the real nanoseconds in `data-v` so `9.0ms` does not sort after `1m02s`. Filter box with a live row count.
- Bars are stacked and shaded per cost, so a row shows both its size and its composition. Every share on the page is a share of the summed child wall time, so bars in different sections are comparable.
- Light and dark via `prefers-color-scheme`.

Verified in a real browser rather than by reading the markup: both phases screenshotted in light and dark, and the sort and filter driven headlessly (clicking `wall` gives 30s/20s/12s descending, clicking again reverses, filtering to `foo-test` leaves 2 of 3 rows with the count updating).

## How to look at it

```sh
nix build .#checks.x86_64-linux.mutation-sydtest-mutation-example.report.sydtest-mutation-example
xdg-open result/timing.html

nix build .#checks.x86_64-linux.mutation-sydtest-mutation-example.coverage
xdg-open result/<pkg>/augmented/timing.html
```

`timing.json` next to it is the machine-readable form, with every child's raw record.

## Notes for whoever picks this up

- **`.gitignore` had a blanket `*.html`**, which would have left the golden pages untracked and `sydtest-mutation`'s tests failing on missing goldens in a fresh clone. That rule is aimed at haddock and coverage output, so `**/test_resources/*.html` is now excepted. Worth knowing before adding more golden pages.
- **Version bumps**: `sydtest` 0.32.0.0 (breaking — both child settings records gained a field), `sydtest-mutation-driver` 0.3.0.0 (`runCoverageMode` signature), `sydtest-mutation-runtime` 0.1.2.0 (additions only). Changelogs written.
- **Two pre-existing flaky tests in `sydtest-test`** fail under machine load and tripped several of my `nix flake check` runs: `Test.Syd.TimingSpec.long diff timing is bounded` (a 120s wall-clock bound) and `Test.Syd.RunnerSpec.seeds the global pseudorandom generator so a FixedSeed run is reproducible`. Forcing the unmodified-`development` `sydtest-test` derivation to re-run failed 1 in 5 times too, so these are not from this change. The RunnerSpec one is a real latent bug — it asserts a *process-global* `StdGen` is undisturbed across two forest runs while the suite runs its tests in parallel, and `CoverageFailFastSpec` concurrently runs forests that reseed that same global generator. Worth fixing separately.
- The terminal renderer is always row-limited now (`renderPhaseTimingSummary :: Int -> ...`); its only consumer is the build log, and the full detail lives in the page.
- `TimingEntry` carries `timingEntryCells :: [(Text, Text)]` (name, value) so both renderers share one summary; the HTML header row is derived from the first entry's names via `entryColumnNames`, so there is no parallel-list invariant to keep in step.

## Possible next steps

The report exists to be acted on. Given what it says about startup cost, the obvious follow-ups are:

1. **Batch mutations per child** — one child process testing N mutations in sequence amortises exec + RTS init + `execTestDefM` across all of them. Biggest available win if startup keeps dominating.
2. **Share suite resources across children** — matters most for suites with an expensive `aroundAll` (tmp-postgres). The report will say when this is the lever.
3. **Trim covering-test sets** — only if `test execution` turns out to dominate on a real project.

## Feedback loops used

1. `stack build <package> --pedantic`
2. `stack test sydtest-mutation sydtest-mutation-driver-gen sydtest-test --pedantic --test-arguments "--ai-executor"`
3. `pre-commit run -a`
4. `nix flake check` — **passes** (`all checks passed!`)

## Tests

- `sydtest-mutation/test/Test/Syd/TimingReportSpec.hs`: `genValidSpec`/`jsonSpec` and file round-trips for every new type; unit tests for the stacked bar, duration formatting and the cost split (including the property that the split always sums to the wall time); golden terminal reports (full and abridged) and golden pages for both phases; a test that a module name containing `<script>&"'` comes out escaped rather than as markup.
- `sydtest-mutation-driver-gen/.../MutateSpec.hs`: a stand-in suite exe that writes a timing file, asserting `runMutationMode` records a child timing carrying the breakdown the child reported, with the right outcome and covering-test count, and writes `timing.html`.

Total

16m52s

Created Started Finished

Time to Start Worker time Duration Time to finish Idle
Config 8s 1s 1s 9s 8s
Eval 9s 22s 22s 31s 0s
Build 30s 9m45s 16m30s 17m01s 12m55s
Suite 8s 10m08s 16m52s 17m01s 13m03s

Timeline

Config Eval Build Compressed gap
0s20s13m40s14m14m20s14m40s15m15m20s15m40s16m16m20s16m40s17m