check-safety/renderperf.go
Alex Dunmow 8d844f2d61 feat: check 30 — render benchmark regression gate (WO-RP-013)
Compares cms e2e/render-bench/results/latest.json (written by the dojo
render-bench suite) against the committed baseline.json. FAILs when
home-page p50 exceeds baseline by >25%, with an explicit instruction to
investigate and never bump the baseline without Captain approval. p95
breach (>40%) WARNs only: measured ambient p95 variance on an idle dev
instance exceeds 100% between runs while p50 holds within ~7%, so a hard
tail gate would block unrelated commits on dev-stack noise.

SKIPs (never blocks) when: baseline.json absent (non-cms repo),
latest.json absent (bench not run), latest older than 7 days, or latest
predates the baseline — each with a nudge to run the suite from dojo.
Comparison logic is Reporter-free and unit-tested (renderperf_test.go).

Verified against the live tree in all three states: OK (p50 22.9 vs
27.3ms), FAIL (26% slower via delay proxy, mandated message), SKIP
(latest.json removed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 01:11:00 +08:00

139 lines
5.3 KiB
Go

package main
// Render-performance regression gate (WO-RP-013).
//
// Compares the latest e2e render benchmark result (cms
// e2e/render-bench/results/latest.json, written by the dojo suite
// `render-bench`) against the committed baseline
// (e2e/render-bench/baseline.json) and FAILS when the home page — the gating
// page class — is markedly slower.
//
// Staleness rule: this check must never block unrelated work when no live
// stack is running. It SKIPs (never fails) when:
// - baseline.json is absent (repo is not the cms repo, or gate not set up),
// - results/latest.json is absent (bench not run on this machine),
// - latest.json is older than renderPerfMaxAge (the bench predates recent
// work and proves nothing about it),
// - latest.json predates the committed baseline (stale result from before
// the current baseline was captured).
// In every SKIP case it says how to produce a fresh result (run the
// `render-bench` suite from dojo).
import (
"encoding/json"
"fmt"
"os"
"time"
)
const (
// renderPerfP50Threshold fails the gate when home-page p50 exceeds the
// baseline p50 by more than this fraction. 25% sits above observed
// run-to-run variance (~7%) but well below any real regression worth a
// human look (WO-RP-013 mandated default).
renderPerfP50Threshold = 0.25
// renderPerfP95Threshold is the secondary tail gate. It WARNs rather than
// FAILs: observed p95 on an otherwise-idle dev instance varies >100%
// between runs minutes apart (36ms → 76ms) while p50 stays within ~7%,
// so a hard p95 gate would block commits on ambient dev-stack noise.
// p50 is the blocking metric.
renderPerfP95Threshold = 0.40
// renderPerfMaxAge is how old a latest.json may be and still count as
// evidence about the current tree.
renderPerfMaxAge = 7 * 24 * time.Hour
)
type renderBenchPage struct {
Page string `json:"page"`
Path string `json:"path"`
Gating bool `json:"gating"`
P50Ms float64 `json:"p50_ms"`
P95Ms float64 `json:"p95_ms"`
}
type renderBenchResults struct {
Timestamp string `json:"timestamp"`
Instance string `json:"instance"`
Pages []renderBenchPage `json:"pages"`
}
func loadRenderBench(path string) (*renderBenchResults, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var r renderBenchResults
if err := json.Unmarshal(raw, &r); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return &r, nil
}
func (r *renderBenchResults) gatingPage() *renderBenchPage {
for i := range r.Pages {
if r.Pages[i].Gating {
return &r.Pages[i]
}
}
return nil
}
func (r *renderBenchResults) parsedTime() (time.Time, error) {
return time.Parse(time.RFC3339, r.Timestamp)
}
// renderPerfVerdict is the outcome of one baseline/latest comparison,
// separated from the Reporter so it can be unit-tested.
type renderPerfVerdict struct {
skip bool
warn bool
fail bool
message string
}
func compareRenderPerf(baseline, latest *renderBenchResults, now time.Time) renderPerfVerdict {
base := baseline.gatingPage()
if base == nil {
return renderPerfVerdict{skip: true, message: "baseline.json has no gating page — regenerate it from the render-bench suite"}
}
cur := latest.gatingPage()
if cur == nil {
return renderPerfVerdict{skip: true, message: "latest.json has no gating page — re-run the render-bench suite from dojo"}
}
latestAt, err := latest.parsedTime()
if err != nil {
return renderPerfVerdict{skip: true, message: fmt.Sprintf("latest.json has an unparseable timestamp (%v) — re-run the render-bench suite from dojo", err)}
}
if now.Sub(latestAt) > renderPerfMaxAge {
return renderPerfVerdict{skip: true, message: fmt.Sprintf("latest bench result is %.0fh old (max %.0fh) — run the render-bench suite from dojo for a fresh result", now.Sub(latestAt).Hours(), renderPerfMaxAge.Hours())}
}
if baseAt, err := baseline.parsedTime(); err == nil && latestAt.Before(baseAt) {
return renderPerfVerdict{skip: true, message: "latest bench result predates the committed baseline — run the render-bench suite from dojo"}
}
if base.P50Ms <= 0 {
return renderPerfVerdict{skip: true, message: "baseline gating p50 is zero — regenerate baseline.json from the render-bench suite"}
}
p50Delta := (cur.P50Ms - base.P50Ms) / base.P50Ms
p95Delta := 0.0
if base.P95Ms > 0 {
p95Delta = (cur.P95Ms - base.P95Ms) / base.P95Ms
}
if p50Delta > renderPerfP50Threshold {
return renderPerfVerdict{fail: true, message: fmt.Sprintf(
"Home page render is %.0f%% slower than baseline (p50 %.1fms vs %.1fms). Investigate before proceeding — profile the regression (see docs/works/WO-RP-001 methodology); do NOT raise the baseline to make this pass without Captain approval.",
p50Delta*100, cur.P50Ms, base.P50Ms)}
}
if p95Delta > renderPerfP95Threshold {
return renderPerfVerdict{warn: true, message: fmt.Sprintf(
"Home page render tail is %.0f%% slower than baseline (p95 %.1fms vs %.1fms; p50 healthy at %.1fms vs %.1fms). p95 is noisy on the shared dev stack — re-run the render-bench suite; investigate if it persists.",
p95Delta*100, cur.P95Ms, base.P95Ms, cur.P50Ms, base.P50Ms)}
}
return renderPerfVerdict{message: fmt.Sprintf("home p50 %.1fms vs baseline %.1fms (%+.0f%%), p95 %.1fms vs %.1fms (%+.0f%%)",
cur.P50Ms, base.P50Ms, p50Delta*100, cur.P95Ms, base.P95Ms, p95Delta*100)}
}