pytest-benchmem 0.4.2__tar.gz → 0.4.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/.github/dependabot.yml +3 -3
  2. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/.github/workflows/ci.yaml +7 -7
  3. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/.github/workflows/docs.yaml +2 -2
  4. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/.github/workflows/nightly.yaml +1 -1
  5. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/.github/workflows/release.yaml +1 -1
  6. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/.gitignore +0 -3
  7. pytest_benchmem-0.4.3/.release-please-manifest.json +3 -0
  8. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/CHANGELOG.md +7 -0
  9. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/PKG-INFO +1 -1
  10. pytest_benchmem-0.4.3/docs/changelog.md +9 -0
  11. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/mkdocs.yml +5 -0
  12. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/pyproject.toml +2 -1
  13. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/src/pytest_benchmem/cli.py +14 -4
  14. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/src/pytest_benchmem/compare.py +21 -13
  15. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/tests/test_cli.py +17 -0
  16. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/tests/test_compare.py +32 -0
  17. pytest_benchmem-0.4.3/uv.lock +2441 -0
  18. pytest_benchmem-0.4.2/.release-please-manifest.json +0 -3
  19. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/.github/workflows/dependabot-auto-merge.yaml +0 -0
  20. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/.github/workflows/pr-title.yaml +0 -0
  21. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/.pre-commit-config.yaml +0 -0
  22. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/.readthedocs.yaml +0 -0
  23. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/.release-please-config.json +0 -0
  24. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/LICENSE +0 -0
  25. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/README.md +0 -0
  26. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/docs/compare-plot.md +0 -0
  27. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/docs/dims.md +0 -0
  28. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/docs/getting-started.md +0 -0
  29. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/docs/index.md +0 -0
  30. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/docs/javascripts/notebook-prompts.js +0 -0
  31. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/docs/metrics.md +0 -0
  32. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/docs/reference.md +0 -0
  33. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/docs/stylesheets/extra.css +0 -0
  34. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/docs/sweeps.md +0 -0
  35. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/docs_macros.py +0 -0
  36. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/src/pytest_benchmem/__init__.py +0 -0
  37. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/src/pytest_benchmem/combined.py +0 -0
  38. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/src/pytest_benchmem/format.py +0 -0
  39. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/src/pytest_benchmem/memray.py +0 -0
  40. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/src/pytest_benchmem/plotting.py +0 -0
  41. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/src/pytest_benchmem/py.typed +0 -0
  42. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/src/pytest_benchmem/pytest_plugin.py +0 -0
  43. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/src/pytest_benchmem/snapshot.py +0 -0
  44. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/src/pytest_benchmem/sweep.py +0 -0
  45. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/src/pytest_benchmem/tables.py +0 -0
  46. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/tests/__init__.py +0 -0
  47. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/tests/conftest.py +0 -0
  48. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/tests/test_combined.py +0 -0
  49. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/tests/test_distribution.py +0 -0
  50. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/tests/test_inline_compare.py +0 -0
  51. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/tests/test_memray.py +0 -0
  52. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/tests/test_memray_platform.py +0 -0
  53. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/tests/test_plotting.py +0 -0
  54. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/tests/test_plugin.py +0 -0
  55. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/tests/test_snapshot.py +0 -0
  56. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/tests/test_sweep.py +0 -0
  57. {pytest_benchmem-0.4.2 → pytest_benchmem-0.4.3}/tests/test_tables.py +0 -0
@@ -1,10 +1,10 @@
1
1
  version: 2
2
2
 
3
3
  updates:
4
- - package-ecosystem: "pip"
4
+ - package-ecosystem: "uv"
5
5
  directory: "/"
6
6
  schedule:
7
- interval: "weekly"
7
+ interval: "monthly"
8
8
  labels:
9
9
  - "dependencies"
10
10
  commit-message:
@@ -27,7 +27,7 @@ updates:
27
27
  - package-ecosystem: "github-actions"
28
28
  directory: "/"
29
29
  schedule:
30
- interval: "weekly"
30
+ interval: "monthly"
31
31
  labels:
32
32
  - "dependencies"
33
33
  commit-message:
@@ -16,11 +16,11 @@ jobs:
16
16
  runs-on: ubuntu-24.04
17
17
  timeout-minutes: 5
18
18
  steps:
19
- - uses: actions/checkout@v6
19
+ - uses: actions/checkout@v7
20
20
  - uses: astral-sh/setup-uv@v7
21
21
  with:
22
22
  enable-cache: true
23
- - run: uv sync --group dev
23
+ - run: uv sync --group dev --frozen
24
24
  - run: uv run ruff check --output-format=github
25
25
  - run: uv run ruff format --diff
26
26
 
@@ -29,11 +29,11 @@ jobs:
29
29
  runs-on: ubuntu-24.04
30
30
  timeout-minutes: 5
31
31
  steps:
32
- - uses: actions/checkout@v6
32
+ - uses: actions/checkout@v7
33
33
  - uses: astral-sh/setup-uv@v7
34
34
  with:
35
35
  enable-cache: true
36
- - run: uv sync --group dev
36
+ - run: uv sync --group dev --frozen
37
37
  - run: uv run mypy src/pytest_benchmem tests
38
38
 
39
39
  test:
@@ -49,14 +49,14 @@ jobs:
49
49
  - os: macos-latest
50
50
  python-version: "3.13"
51
51
  steps:
52
- - uses: actions/checkout@v6
52
+ - uses: actions/checkout@v7
53
53
  - uses: astral-sh/setup-uv@v7
54
54
  with:
55
55
  enable-cache: true
56
56
  - uses: actions/setup-python@v6
57
57
  with:
58
58
  python-version: ${{ matrix.python-version }}
59
- - run: uv sync --group dev
59
+ - run: uv sync --group dev --frozen
60
60
  - run: uv run pytest -q
61
61
 
62
62
  smoke-install:
@@ -64,7 +64,7 @@ jobs:
64
64
  runs-on: ubuntu-24.04
65
65
  timeout-minutes: 10
66
66
  steps:
67
- - uses: actions/checkout@v6
67
+ - uses: actions/checkout@v7
68
68
  - uses: astral-sh/setup-uv@v7
69
69
  with:
70
70
  enable-cache: true
@@ -18,11 +18,11 @@ jobs:
18
18
  runs-on: ubuntu-24.04
19
19
  timeout-minutes: 10
20
20
  steps:
21
- - uses: actions/checkout@v6
21
+ - uses: actions/checkout@v7
22
22
  - uses: astral-sh/setup-uv@v7
23
23
  with:
24
24
  enable-cache: true
25
25
  # Build the notebooks from their jupytext sources, then build+execute the site.
26
- - run: uv sync --group docs
26
+ - run: uv sync --group docs --frozen
27
27
  - run: uv run jupytext --to ipynb docs/compare-plot.md
28
28
  - run: uv run mkdocs gh-deploy --force
@@ -31,7 +31,7 @@ jobs:
31
31
  python: "3.13"
32
32
  resolution: "--upgrade"
33
33
  steps:
34
- - uses: actions/checkout@v6
34
+ - uses: actions/checkout@v7
35
35
  - uses: astral-sh/setup-uv@v7
36
36
  - uses: actions/setup-python@v6
37
37
  with:
@@ -49,7 +49,7 @@ jobs:
49
49
  name: pypi
50
50
  url: https://pypi.org/project/pytest-benchmem
51
51
  steps:
52
- - uses: actions/checkout@v6
52
+ - uses: actions/checkout@v7
53
53
  with:
54
54
  ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || needs.release-please.outputs.tag_name }}
55
55
  fetch-depth: 0
@@ -10,9 +10,6 @@ build/
10
10
  .pytest_cache/
11
11
  *.bin
12
12
 
13
- # lockfile not tracked for a library — resolve fresh against current deps
14
- uv.lock
15
-
16
13
  # jupytext-built notebooks (source is the .md) + mkdocs build
17
14
  docs/*.ipynb
18
15
  site/
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.4.3"
3
+ }
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.3](https://github.com/fluxopt/pytest-benchmem/compare/v0.4.2...v0.4.3) (2026-06-29)
4
+
5
+
6
+ ### Features
7
+
8
+ * benchmem compare accepts a single run (plain table) ([#123](https://github.com/fluxopt/pytest-benchmem/issues/123)) ([96275d9](https://github.com/fluxopt/pytest-benchmem/commit/96275d93628323ffd4f3cc74decdebdcb132004e))
9
+
3
10
  ## [0.4.2](https://github.com/fluxopt/pytest-benchmem/compare/v0.4.1...v0.4.2) (2026-06-18)
4
11
 
5
12
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-benchmem
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: The memory companion to pytest-benchmark: a memray peak-memory pass on the same test, plus dims-aware plots and cross-version sweeps.
5
5
  Project-URL: Homepage, https://github.com/fluxopt/pytest-benchmem
6
6
  Project-URL: Documentation, https://fluxopt.github.io/pytest-benchmem/
@@ -0,0 +1,9 @@
1
+ ---
2
+ hide:
3
+ - navigation
4
+ ---
5
+
6
+ <!-- Rendered from the repo-root CHANGELOG.md (release-please source of truth) so the
7
+ published release notes can't drift from the tagged releases. -->
8
+
9
+ --8<-- "CHANGELOG.md"
@@ -39,6 +39,7 @@ nav:
39
39
  - Grouping by dims: dims.md
40
40
  - Cross-version sweeps: sweeps.md
41
41
  - Reference: reference.md
42
+ - Release notes: changelog.md
42
43
 
43
44
  plugins:
44
45
  - search
@@ -93,6 +94,10 @@ markdown_extensions:
93
94
  anchor_linenums: true
94
95
  use_pygments: true
95
96
  - pymdownx.inlinehilite
97
+ - pymdownx.snippets:
98
+ # Pull docs/changelog.md's `--8<-- "CHANGELOG.md"` from the repo root.
99
+ base_path: ["."]
100
+ check_paths: true
96
101
  - pymdownx.superfences
97
102
  - pymdownx.details
98
103
  - pymdownx.tabbed:
@@ -48,7 +48,8 @@ build-backend = "hatchling.build"
48
48
  source = "vcs"
49
49
 
50
50
  [dependency-groups]
51
- dev = ["ruff", "mypy", "pre-commit", "pytest-benchmem[plot]", { include-group = "docs" }]
51
+ # mypy <2.1: 2.1.0 errors on numpy's PEP 695 stub under python_version=3.11. Lift once fixed upstream.
52
+ dev = ["ruff", "mypy<2.2", "pre-commit", "pytest-benchmem[plot]", { include-group = "docs" }]
52
53
  docs = [
53
54
  "jupytext",
54
55
  "ipykernel",
@@ -188,7 +188,10 @@ def plot(
188
188
  def compare(
189
189
  runs: Annotated[
190
190
  list[Path],
191
- typer.Argument(help="Two or more pytest-benchmark runs, oldest → newest (a sweep is N)."),
191
+ typer.Argument(
192
+ help="One or more pytest-benchmark runs, oldest → newest. One prints a plain "
193
+ "table; two or more compare (a sweep is N)."
194
+ ),
192
195
  ],
193
196
  columns: Annotated[
194
197
  str | None,
@@ -233,9 +236,16 @@ def compare(
233
236
  ),
234
237
  ] = None,
235
238
  ) -> None:
236
- """Print a per-id comparison table across two or more runs (and optionally gate CI)."""
237
- if len(runs) < 2: # noqa: PLR2004 — a comparison needs two sides
238
- raise _fail("compare needs at least two runs", 2)
239
+ """Print a per-id table for one run, or compare two or more (and optionally gate CI)."""
240
+ if not runs:
241
+ raise _fail("compare needs at least one run", 2)
242
+ if fail_on and len(runs) < 2: # noqa: PLR2004 — a growth gate needs a before and an after
243
+ raise _fail(
244
+ "compare --fail-on needs at least two runs — it gates growth of the first run vs "
245
+ "the last. For an absolute ceiling on a single run, use the "
246
+ "@pytest.mark.benchmem(max_peak=...) marker instead.",
247
+ 2,
248
+ )
239
249
  _require_runs_exist(runs, suggest=False)
240
250
  from pytest_benchmem.compare import compare_runs, find_regressions, parse_threshold
241
251
 
@@ -1,11 +1,12 @@
1
- """Text comparison of two or more pytest-benchmark runs — keyed on the benchmark id.
1
+ """Text table for one or more pytest-benchmark runs — keyed on the benchmark id.
2
2
 
3
- :func:`compare_runs` prints the comparison table, modelled on pytest-benchmark's own:
3
+ :func:`compare_runs` prints the table, modelled on pytest-benchmark's own:
4
4
  rows are one per ``(benchmark × run)``, columns are ``metric × stat`` (``--columns``
5
5
  picks the metrics, default ``time,peak``; ``--stat`` picks the stat, default ``all`` for
6
- the full min/max/mean/median/stddev spread), every cell carries a relative ``(N.NN)``
7
- multiplier vs its column's best (best green, worst red), and ``group_by`` splits the rows
8
- into sub-tables.
6
+ the full min/max/mean/median/stddev spread), and ``group_by`` splits the rows into
7
+ sub-tables. With **two or more** runs every cell carries a relative ``(N.NN)`` multiplier
8
+ vs its column's best (best green, worst red); a **single** run is a plain readout — there's
9
+ no other run to rank against, so the multiplier and colour are dropped.
9
10
 
10
11
  The rest is the regression gate behind ``benchmem compare --fail-on``: parse a threshold
11
12
  like ``peak:10%`` / ``peak:5MiB`` / ``allocations:5%`` / ``time:5%``, find the ids whose
@@ -227,15 +228,15 @@ def compare_runs(
227
228
  csv: Path | None = None,
228
229
  out: TextIO | None = None,
229
230
  ) -> None:
230
- """Print a per-(benchmark, run) comparison table across two or more runs.
231
+ """Print a per-(benchmark, run) table for one or more runs.
231
232
 
232
233
  Mirrors pytest-benchmark's table model: rows are one per ``(benchmark × run)``,
233
234
  ``columns`` selects which metric columns appear (``time`` / ``peak`` / ``allocated``
234
- / ``allocations``), each cell carries the value plus a relative ``(N.NN)`` multiplier
235
- vs the group's best (best green, worst red), and ``group_by`` splits the rows into
236
- sub-tables (``fullname`` / ``name`` / ``func`` / ``group`` / ``module`` / ``class`` /
237
- ``param:NAME``, comma-composable). With no ``group_by`` the whole comparison is one
238
- table.
235
+ / ``allocations``). With two or more runs each cell also carries a relative ``(N.NN)``
236
+ multiplier vs the group's best (best green, worst red); a single run is a plain readout
237
+ with neither. ``group_by`` splits the rows into sub-tables (``fullname`` / ``name`` /
238
+ ``func`` / ``group`` / ``module`` / ``class`` / ``param:NAME``, comma-composable). With
239
+ no ``group_by`` the whole comparison is one table.
239
240
 
240
241
  Every column is a ``metric × stat`` pair. ``columns`` is a comma list of metric names
241
242
  (default ``time,peak``); ``stat`` is one of ``min`` / ``max`` / ``mean`` / ``median`` /
@@ -254,11 +255,16 @@ def compare_runs(
254
255
  raise ValueError(f"unknown --sort {sort!r}; use one of {', '.join(_SORTS)}")
255
256
  metrics, stats = _resolve_columns(columns), _resolve_stats(stat)
256
257
  labels, values, units, dims = _load_columns(runs, metrics, stats)
257
- if len(labels) < 2: # noqa: PLR2004 — a comparison needs two sides
258
+ if not labels:
259
+ raise ValueError("no benchmarks found in the given run(s)")
260
+ if len(labels) < 2 and len(runs) > 1: # noqa: PLR2004 — distinct paths collapsed to one series
258
261
  raise ValueError(
259
262
  f"compare needs at least two distinct runs; got {labels!r} "
260
263
  f"(the same file more than once?). Pass distinct files."
261
264
  )
265
+ # One run is a plain readout: with no other run to rank against, the (N.NN) multiplier and
266
+ # the best/worst colour are meaningless (best == worst == the value), so they're dropped.
267
+ single = len(labels) == 1
262
268
  with_data = {col_id for col_id, _i, _lab in values}
263
269
  all_cols = [(m, s) for m in metrics for s in stats]
264
270
  cols = [(m, s) for m, s in all_cols if f"{m}:{s}" in with_data] or all_cols # drop empties
@@ -338,7 +344,9 @@ def compare_runs(
338
344
  if unit == ""
339
345
  else f"{v:.4g}"
340
346
  )
341
- cells.append(Text(body + _mult(v, best), style=rank_style(v, best, worst) or ""))
347
+ mult = "" if single else _mult(v, best)
348
+ style = "" if single else (rank_style(v, best, worst) or "")
349
+ cells.append(Text(body + mult, style=style))
342
350
  table.add_row(*cells)
343
351
  console.print(table)
344
352
  console.print() # one blank line between sub-tables
@@ -60,6 +60,23 @@ def test_compare_prints_table(tmp_path):
60
60
  assert "(2.00)" in result.output and "time (s)" in result.output
61
61
 
62
62
 
63
+ def test_compare_single_run_prints_table(tmp_path):
64
+ # one run is allowed now: a plain table, no comparison multiplier
65
+ a = _run(tmp_path, "run.json", [_bm("test_x", t=1.0, peak=10 * 1024**2)])
66
+ result = runner.invoke(app, ["compare", str(a), "--columns", "time,peak"])
67
+ assert result.exit_code == 0, _text(result)
68
+ assert "time (s)" in result.output and "peak (MiB)" in result.output
69
+ assert "(1.0)" not in result.output # nothing to rank against
70
+
71
+
72
+ def test_compare_single_run_with_fail_on_exits_2(tmp_path):
73
+ # --fail-on gates growth of one run vs another, so it needs two; refuse, don't silently pass
74
+ a = _run(tmp_path, "run.json", [_bm("test_x", peak=100)])
75
+ result = runner.invoke(app, ["compare", str(a), "--fail-on", "peak:10%"])
76
+ assert result.exit_code == 2
77
+ assert "at least two runs" in _text(result)
78
+
79
+
63
80
  def test_compare_columns_selects_time_and_peak(tmp_path):
64
81
  a = _run(tmp_path, "base.json", [_bm("test_x", t=1.0, peak=10 * 1024**2)])
65
82
  b = _run(tmp_path, "head.json", [_bm("test_x", t=1.0, peak=20 * 1024**2)])
@@ -57,6 +57,38 @@ def test_same_file_twice_errors_cleanly(tmp_path):
57
57
  compare_runs([a, a], out=StringIO())
58
58
 
59
59
 
60
+ def test_single_run_prints_plain_table(tmp_path):
61
+ # one run is a valid input: a plain readout, no cross-run multiplier or ranking colour
62
+ a = _write(tmp_path / "run.json", [_bm("test_x", t=1.0, peak=10 * 1024**2)])
63
+ out = StringIO()
64
+ compare_runs([a], columns="time,peak", out=out)
65
+ text = out.getvalue()
66
+ assert "time (s)" in text and "peak (MiB)" in text # the columns still render
67
+ rows = _lines(text, "test_x")
68
+ assert "(run)" in rows # the single run's label still names the row
69
+ assert "(1.0)" not in rows # nothing to compare against → no relative multiplier
70
+
71
+
72
+ def test_single_run_multi_id_has_no_within_run_ranking(tmp_path):
73
+ # a single run with several ids in one group is still plain — no (N.NN) ranking the
74
+ # ids against each other (that's reserved for the cross-run comparison).
75
+ a = _write(
76
+ tmp_path / "run.json",
77
+ [_bm("pkg::test_op[n=1]", peak=1024), _bm("pkg::test_op[n=2]", peak=10 * 1024**2)],
78
+ )
79
+ out = StringIO()
80
+ compare_runs([a], columns="peak", group_by="func", out=out)
81
+ text = out.getvalue()
82
+ assert "test_op[n=1]" in text and "test_op[n=2]" in text
83
+ assert "(1.0)" not in text and "(10240.00)" not in text # no within-run multiplier
84
+
85
+
86
+ def test_single_run_empty_file_errors(tmp_path):
87
+ a = _write(tmp_path / "run.json", [])
88
+ with pytest.raises(ValueError, match="no benchmarks"):
89
+ compare_runs([a], out=StringIO())
90
+
91
+
60
92
  def test_distinct_files_same_stem_disambiguate(tmp_path):
61
93
  # two real runs that happen to share a stem (v1/bench.json vs v2/bench.json)
62
94
  # is a legit comparison and must not collapse to one series.