Skip to content

Commit 385ccf7

Browse files
feat(perf): Phase D benchmark harness scaffold (standards#99 / #91 channel) (#12)
Scaffold-only, multi-session work-package. Lands the skeleton for the single-lane HCG channel's Phase D — benchmark formalisation, published latency contract, and CI regression alert — without any real benchmark numbers. Phases D-2..D-4 will fill those in post-merge. What's in: * bench/gateway_latency.exs — Benchee harness with three named scenarios (health endpoint / policy deny 405 fast-path / exact route allow), reporting p50/p95/p99 + JSON output. * bench/compare.exs — comparator that diffs the live run against bench/baseline.json. Reads tolerance ratios from the baseline. Honours a `_status` flag so the gate is non-blocking until a real baseline lands (scaffold-placeholder vs active). * bench/baseline.json — placeholder baseline with TODO markers + the default tolerance ratios (p50≤1.20×, p95≤1.30×, p99≤1.50×). * docs/perf-contract.md — published-latency contract: names the three scenarios, names the percentiles, names the rebaseline ritual, and explicitly lists what's deferred (loopback fixture / mTLS handshake cost / trust-header rewrite cost / real numbers / dashboard). * .github/workflows/perf-regression.yml — runs the harness on PRs + pushes to main, posts a markdown report to the step summary, uploads the JSON artefact. SHA-pinned actions, concurrency-guarded per standards#122. * Justfile — `just bench` and `just bench-collect` recipes that mirror the CI invocation. * mix.exs — adds :benchee and :benchee_json as dev/test deps only (runtime: false, never reaches prod). What's deliberately deferred: * Real benchmark numbers (Phase D-4 collects baseline) * Loopback backend fixture (Phase D-2) * mTLS handshake cost (Phase D-3, reuses Phase B real-CA fixture) * Trust-header rewrite cost on Proxy.build_backend_headers/1 (Phase D-3) * Dashboard publication of historical numbers (Phase E / standards#100) Refs hyperpolymath/standards#99 Refs hyperpolymath/standards#91 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8325561 commit 385ccf7

9 files changed

Lines changed: 534 additions & 0 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
#
4+
# perf-regression.yml — Phase D performance regression gate (standards#99)
5+
#
6+
# Runs the Benchee harness in bench/gateway_latency.exs and diffs the
7+
# resulting percentiles against the checked-in bench/baseline.json via
8+
# bench/compare.exs.
9+
#
10+
# Modes (driven by baseline.json `_status`):
11+
# • scaffold-placeholder — report-only, never fails the build (current).
12+
# • active — fails the PR when p50/p95/p99 regress past
13+
# the tolerance ratios in baseline.json.
14+
#
15+
# This is the regression-alert leg of Phase D — see
16+
# docs/perf-contract.md for the published SLO contract and
17+
# standards#91 for the single-lane HCG channel ordering.
18+
19+
name: Perf Regression
20+
21+
on:
22+
pull_request:
23+
branches: ['**']
24+
push:
25+
branches: [main, master]
26+
workflow_dispatch:
27+
28+
# Estate guardrail (standards#122): cancel superseded runs so re-pushes
29+
# don't pile up against the account-wide Actions concurrency pool. Safe
30+
# here because the workflow is read-only (no publish, no mutation).
31+
concurrency:
32+
group: ${{ github.workflow }}-${{ github.ref }}
33+
cancel-in-progress: true
34+
35+
permissions:
36+
contents: read
37+
pull-requests: write # Required to post the regression-report comment.
38+
39+
jobs:
40+
bench:
41+
name: Gateway latency benchmark
42+
runs-on: ubuntu-latest
43+
timeout-minutes: 15
44+
45+
steps:
46+
- name: Checkout repository
47+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
48+
49+
- name: Setup Elixir/OTP
50+
uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.18.2
51+
with:
52+
# Pinned to match .tool-versions; bump both together.
53+
elixir-version: '1.19'
54+
otp-version: '28'
55+
56+
- name: Cache deps
57+
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
58+
with:
59+
path: |
60+
deps
61+
_build
62+
key: ${{ runner.os }}-perf-${{ hashFiles('mix.lock') }}
63+
restore-keys: |
64+
${{ runner.os }}-perf-
65+
66+
- name: Install deps
67+
run: |
68+
mix local.hex --force
69+
mix local.rebar --force
70+
mix deps.get
71+
72+
- name: Compile
73+
run: mix compile --warnings-as-errors
74+
75+
- name: Run benchmark harness
76+
run: mix run bench/gateway_latency.exs | tee bench/console.log
77+
78+
- name: Compare against baseline
79+
id: compare
80+
run: |
81+
# bench/compare.exs writes the markdown table to stdout and
82+
# exits non-zero on regression (active mode) or zero (scaffold mode).
83+
mix run bench/compare.exs | tee -a "$GITHUB_STEP_SUMMARY"
84+
85+
- name: Upload bench artefacts
86+
if: always()
87+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
88+
with:
89+
name: perf-regression-results
90+
path: |
91+
bench/results.json
92+
bench/console.log
93+
retention-days: 30

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ htmlcov/
7373
*.log
7474
/logs/
7575

76+
# Phase D benchmark run artefacts (the harness regenerates these each run).
77+
# bench/baseline.json IS tracked — only the live run output is ignored.
78+
/bench/results.json
79+
/bench/console.log
80+
7681
# Zig
7782
ffi/zig/zig-out/
7883
**/.zig-cache/

Justfile

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,26 @@ test-verbose:
9292
test-coverage:
9393
mix test --cover
9494

95+
# ═══════════════════════════════════════════════════════════════════════════════
96+
# BENCHMARKS (Phase D — standards#99)
97+
# ═══════════════════════════════════════════════════════════════════════════════
98+
99+
# Run the Phase D benchmark harness and diff against the checked-in baseline.
100+
# Mirrors what the perf-regression CI workflow does on every PR.
101+
# Scaffold-mode (current): always reports; gate is non-blocking until
102+
# bench/baseline.json `_status` flips to `active` (Phase D-4).
103+
bench:
104+
mix run bench/gateway_latency.exs
105+
mix run bench/compare.exs
106+
107+
# Run the harness without the comparator — useful when collecting a new
108+
# baseline (Phase D-4). Result lands in bench/results.json; promote it
109+
# into bench/baseline.json via a dedicated `perf: rebaseline` PR.
110+
bench-collect:
111+
mix run bench/gateway_latency.exs
112+
@echo "Results written to bench/results.json"
113+
@echo "To rebaseline: review numbers, then update bench/baseline.json in a dedicated PR."
114+
95115
# ═══════════════════════════════════════════════════════════════════════════════
96116
# LINT & FORMAT
97117
# ═══════════════════════════════════════════════════════════════════════════════

STATE.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ See `ROADMAP.adoc` for full details. Summary:
4747
* `ROADMAP.adoc` — Current roadmap (canonical)
4848
* `TEST-NEEDS.md` — Test gap analysis
4949
* `PROOFS_NEEDED.md` — Formal proof gap analysis
50+
* `docs/perf-contract.md` — Phase D published-latency contract (scaffold, standards#99)
5051
* `.machine_readable/STATE.a2ml` — Machine-readable state
5152

5253
== Historical Documents

bench/baseline.json

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"_comment": "Phase D scaffold placeholder baseline (standards#99). Numbers are TODO. Real collection in Phase D-4 — see docs/perf-contract.md §Baseline collection. DO NOT trust these numbers; the perf-regression CI gate is intentionally non-blocking until this file is populated by a baseline-collection PR.",
3+
"_schema_version": "0.1.0-scaffold",
4+
"_generated_at": "TODO(Phase D-4): replace with ISO8601 timestamp from collection run",
5+
"_generated_by": "TODO(Phase D-4): replace with `bench/gateway_latency.exs` git SHA + machine fingerprint",
6+
"_status": "scaffold-placeholder",
7+
"scenarios": {
8+
"health endpoint": {
9+
"p50_us": "TODO",
10+
"p95_us": "TODO",
11+
"p99_us": "TODO",
12+
"ips": "TODO"
13+
},
14+
"policy deny (405 fast-path)": {
15+
"p50_us": "TODO",
16+
"p95_us": "TODO",
17+
"p99_us": "TODO",
18+
"ips": "TODO"
19+
},
20+
"exact route allow (proxy short-circuits)": {
21+
"p50_us": "TODO",
22+
"p95_us": "TODO",
23+
"p99_us": "TODO",
24+
"ips": "TODO"
25+
}
26+
},
27+
"tolerance": {
28+
"_comment": "Multiplicative regression tolerance applied to each percentile. 1.20 = a PR is allowed to be up to 20% slower than baseline before the gate fails. Phase D-4 will revisit these once we have signal on intra-run variance.",
29+
"p50_max_ratio": 1.20,
30+
"p95_max_ratio": 1.30,
31+
"p99_max_ratio": 1.50
32+
}
33+
}

bench/compare.exs

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
#
4+
# bench/compare.exs — Phase D regression-gate comparator (standards#99 scaffold)
5+
#
6+
# Reads:
7+
# • bench/results.json (produced by bench/gateway_latency.exs in this run)
8+
# • bench/baseline.json (checked-in baseline)
9+
#
10+
# Emits:
11+
# • A markdown table on stdout (also written to the GitHub step summary by CI)
12+
# • Exit 0 if all scenarios are within tolerance
13+
# • Exit 1 if any scenario regressed past tolerance
14+
#
15+
# SCAFFOLD MODE
16+
# ─────────────
17+
# Until baseline.json contains real numbers (its "_status" is "scaffold-
18+
# placeholder"), this script is INTENTIONALLY non-blocking — it reports
19+
# the live numbers and exits 0 with a clear "scaffold mode" banner.
20+
# Phase D-4 will flip "_status" to "active" once a real baseline lands;
21+
# the gate becomes blocking from that point on with no code change here.
22+
23+
# `mix run` puts deps (including :jason) on the code path; nothing to start.
24+
25+
defmodule Bench.Compare do
26+
@results_path "bench/results.json"
27+
@baseline_path "bench/baseline.json"
28+
29+
def run do
30+
with {:ok, results_raw} <- File.read(@results_path),
31+
{:ok, baseline_raw} <- File.read(@baseline_path),
32+
{:ok, results} <- Jason.decode(results_raw),
33+
{:ok, baseline} <- Jason.decode(baseline_raw) do
34+
compare(results, baseline)
35+
else
36+
{:error, :enoent} ->
37+
IO.puts(:stderr, "ERROR: bench/results.json or bench/baseline.json missing")
38+
IO.puts(:stderr, "Did `mix run bench/gateway_latency.exs` run first?")
39+
System.halt(2)
40+
41+
{:error, reason} ->
42+
IO.puts(:stderr, "ERROR reading baseline/results: #{inspect(reason)}")
43+
System.halt(2)
44+
end
45+
end
46+
47+
defp compare(results, baseline) do
48+
status = Map.get(baseline, "_status", "unknown")
49+
tolerance = Map.get(baseline, "tolerance", %{})
50+
51+
IO.puts("# Phase D — Performance Regression Report")
52+
IO.puts("")
53+
IO.puts("Baseline status: **#{status}**")
54+
IO.puts("")
55+
56+
case status do
57+
"scaffold-placeholder" ->
58+
IO.puts(
59+
"> SCAFFOLD MODE — bench/baseline.json has not been populated yet " <>
60+
"(Phase D-4 collects the real baseline). This run is informational " <>
61+
"only; the gate is **non-blocking** until baseline.json `_status` " <>
62+
"is flipped to `active`."
63+
)
64+
65+
IO.puts("")
66+
emit_table(results, nil, tolerance)
67+
System.halt(0)
68+
69+
"active" ->
70+
emit_table(results, baseline["scenarios"], tolerance)
71+
|> case do
72+
:ok -> System.halt(0)
73+
:regressed -> System.halt(1)
74+
end
75+
76+
other ->
77+
IO.puts(:stderr, "ERROR: unknown baseline _status: #{inspect(other)}")
78+
System.halt(2)
79+
end
80+
end
81+
82+
# ── Pretty-print + regression check ────────────────────────────────────────
83+
84+
defp emit_table(results, baseline_scenarios, tolerance) do
85+
# Benchee JSON shape: top-level "statistics" -> per-scenario map.
86+
stats = Map.get(results, "statistics", %{})
87+
88+
IO.puts("| Scenario | p50 (µs) | p95 (µs) | p99 (µs) | Status |")
89+
IO.puts("|----------|----------|----------|----------|--------|")
90+
91+
Enum.reduce(stats, :ok, fn {name, scenario_stats}, acc ->
92+
p50 = percentile_us(scenario_stats, "50")
93+
p95 = percentile_us(scenario_stats, "95")
94+
p99 = percentile_us(scenario_stats, "99")
95+
96+
status =
97+
case baseline_scenarios do
98+
nil ->
99+
"scaffold"
100+
101+
map ->
102+
base = Map.get(map, name)
103+
check_regression(base, p50, p95, p99, tolerance)
104+
end
105+
106+
IO.puts("| #{name} | #{fmt(p50)} | #{fmt(p95)} | #{fmt(p99)} | #{status} |")
107+
108+
cond do
109+
acc == :regressed -> :regressed
110+
status == "REGRESSED" -> :regressed
111+
true -> acc
112+
end
113+
end)
114+
end
115+
116+
defp percentile_us(scenario_stats, p) do
117+
# Benchee reports nanoseconds in JSON; convert to µs for human readability
118+
# and to match the µs units used in docs/perf-contract.md.
119+
case get_in(scenario_stats, ["percentiles", p]) do
120+
nil -> nil
121+
ns when is_number(ns) -> ns / 1_000.0
122+
end
123+
end
124+
125+
defp fmt(nil), do: "—"
126+
defp fmt(n) when is_float(n), do: :erlang.float_to_binary(n, decimals: 2)
127+
defp fmt(n), do: to_string(n)
128+
129+
defp check_regression(nil, _, _, _, _), do: "no baseline"
130+
131+
defp check_regression(base, p50, p95, p99, tolerance) do
132+
bp50 = num(base["p50_us"])
133+
bp95 = num(base["p95_us"])
134+
bp99 = num(base["p99_us"])
135+
136+
t50 = Map.get(tolerance, "p50_max_ratio", 1.20)
137+
t95 = Map.get(tolerance, "p95_max_ratio", 1.30)
138+
t99 = Map.get(tolerance, "p99_max_ratio", 1.50)
139+
140+
breached =
141+
(bp50 && p50 && p50 > bp50 * t50) or
142+
(bp95 && p95 && p95 > bp95 * t95) or
143+
(bp99 && p99 && p99 > bp99 * t99)
144+
145+
if breached, do: "REGRESSED", else: "ok"
146+
end
147+
148+
defp num(nil), do: nil
149+
defp num(n) when is_number(n), do: n
150+
defp num(_), do: nil
151+
end
152+
153+
Bench.Compare.run()

0 commit comments

Comments
 (0)