Skip to content
Youness Aamiri
Go back

BenchmarkGate, Part 3: benchmark-gate validate

benchmark-gate validate

In part two, v0.2.0-alpha.1 added a stability gate and a policy file. Both are good things. Both are also new ways to write a file that’s subtly wrong — a threshold typo, an inverted warning/failure pair, a baseline entry missing its identity — and until now, the only way to find that out was to run check and read whatever exception came back first.

v0.3.0-alpha.1 adds benchmark-gate validate. It doesn’t evaluate anything. It just tells you, exhaustively, what’s wrong with a file — before that file ever gets near a real check run.

The problem with fail-fast

Here’s policy.json with two unrelated mistakes in it:

{
  "schemaVersion": 1,
  "stability": { "minimumMeasurements": 0, "maximumCoefficientOfVariation": 0.1 },
  "metrics": {
    "meanNanoseconds": { "direction": "lower-is-better", "warningPercent": 10, "failurePercent": 10 }
  }
}

minimumMeasurements: 0 is invalid. warningPercent equal to failurePercent is also invalid. Before this release, PolicyFile.Load threw on the first problem it found — you’d fix minimumMeasurements, re-run, and then find out about the threshold. Two mistakes, two round-trips.

validate finds both in one pass:

$ benchmark-gate validate —policy bad-policy.json bad-policy.json ERROR BGV105 /stability/minimumMeasurements: Value must be greater than zero; actual value was 0. ERROR BGV117 /metrics/meanNanoseconds: Metric ‘meanNanoseconds’ has warningPercent (10) >= failurePercent (10). warningPercent must be strictly less than failurePercent for the policy to be meaningful.

$ echo $LASTEXITCODE 12

Both errors, one invocation, no guessing what else might be wrong once you fix the first thing.

One command, three file types, together

benchmark-gate validate --policy policy.json
benchmark-gate validate --baseline baseline.json
benchmark-gate validate --results ./BenchmarkDotNet.Artifacts/results

All three flags are optional individually, but at least one is required, and you can combine them — validate your whole input surface in one call before wiring anything into CI:

$ benchmark-gate validate —policy bad-policy.json —baseline bad-baseline.json bad-policy.json ERROR BGV105 /stability/minimumMeasurements: Value must be greater than zero; actual value was 0. ERROR BGV117 /metrics/meanNanoseconds: Metric ‘meanNanoseconds’ has warningPercent (10) >= failurePercent (10). warningPercent must be strictly less than failurePercent for the policy to be meaningful.

bad-baseline.json ERROR BGV203 /benchmarks/1: Duplicate benchmark identity ‘MyApp.Bench.Sort|job=Default’.

Two independently broken files, two different validators (PolicyValidator and SnapshotValidator), one invocation — each grouped under its own source heading, with a blank line separating artifacts so a multi-file run stays scannable instead of turning into a wall of undifferentiated error text.

Console output groups by source file. Errors print in red when you’re looking at a real terminal; piped or redirected output stays plain, so scripts and CI logs get deterministic text.

Structured output for machines

--json writes a versioned report covering every artifact you validated in one call, instead of just printing to the console. Here’s a baseline with a duplicate benchmark entry:

benchmark-gate validate --baseline bad-baseline.json --json validation-report.json
{
  "schemaVersion": 1,
  "isValid": false,
  "errorCount": 1,
  "warningCount": 0,
  "artifacts": [
    {
      "kind": "Baseline",
      "source": "bad-baseline.json",
      "isValid": false,
      "errorCount": 1,
      "warningCount": 0,
      "diagnostics": [
        {
          "code": "BGV203",
          "severity": "Error",
          "title": "Duplicate benchmark identity",
          "path": "/benchmarks/1",
          "message": "Duplicate benchmark identity 'MyApp.Bench.Sort|job=Default'."
        }
      ]
    }
  ]
}

Top-level isValid/errorCount/warningCount let a CI step decide pass/fail without walking every artifact; each artifact still carries its own counts and full diagnostic list for anything that wants the detail — a future benchmark-gate explain BGV203 command, an IDE annotation, a Markdown summary, whatever ends up consuming this.

Where the checks actually live

This is the part I think is more interesting than the command itself: validate doesn’t contain a single validation rule. Every check it runs already existed — I extracted it from check’s own load path, rather than writing a second implementation next to the first.

PolicyFile.Load used to do this:

if (warningPercent >= failurePercent)
    throw new PolicyFileException(path, "warningPercent must be strictly less than failurePercent...");

Fail fast, one exception, done. That check now lives in PolicyValidator, Core-side, returning a ValidationDiagnostic instead of throwing — and PolicyFile.Load calls that same validator, same as it always did, just collecting every finding instead of stopping at the first one:

public static GatePolicy Load(string path)
{
    var document = Deserialize(path);
    var validation = PolicyValidator.Validate(document);

    if (!validation.IsValid)
        throw PolicyFileException.FromValidationResult(path, validation);

    return PolicyCompiler.CompileValidated(document);
}

validate calls the same PolicyValidator.Validate and just… returns what it finds, without compiling anything:

public static ValidationResult Validate(string path)
{
    var document = Deserialize(path);
    return PolicyValidator.Validate(document);
}

Same rule, same code path, two different callers with two different needs. check needs a compiled, guaranteed-valid GatePolicy or nothing. validate needs the findings themselves — including ones check would never show you, because a file with only warnings still loads fine as far as check is concerned.

The same split applies to baseline files (SnapshotValidator, which is what caught the duplicate identity above) and to BenchmarkDotNet result JSON (ObservationValidator, plus a second ObservationSetValidator for catching the same benchmark identity duplicated across two different result files in a directory — a genuinely different scope from “duplicated within one file,” and worth its own diagnostic).

Diagnostic codes are not implementation detail

Every finding has a stable, documented code — BGV1xx for policy, BGV2xx for baseline, BGV3xx for BenchmarkDotNet input. The codes are numbered by what they mean, not by the order I happened to write the checks in — BGV100/BGV101 are “missing schema version” and “unsupported schema version” as two separate codes, not one, because a missing field and an explicitly-wrong value have different fixes. Once a code ships, it means what it means; a future version can add codes, never repurpose one.

What v0.3.0 didn’t fix

BdnParameterStringParser — the thing that turns "N=1000000,Distribution=Canonical" into a parameter dictionary — still silently drops a malformed fragment ("N1000000", no =) instead of reporting it. I know, because I went looking for exactly this kind of gap while building the observation validator, and found it already documented in a two-year-old code comment as “a future version could surface this as a diagnostic.”

I didn’t fix it here, on purpose. By the time any validator runs, the parser has already thrown the malformed fragment away — there’s no evidence left to build a diagnostic from. Fixing it means changing what the parser returns, not adding a check on top of what it currently discards. That’s real, separate, tracked work, not a validate-command diagnostic I could bolt on this release.

What’s next

v0.4.0benchmark-gate compare. Pulls the delta calculation out of the evaluator: compare describes what changed between a baseline and a current run, independent of any policy; check applies policy to that same comparison instead of computing it inline. Full sequencing is in ROADMAP.md.

dotnet tool install --global Bijecta.BenchmarkGate.Tool --version 0.3.0-alpha.1

Repo, README, and the full roadmap: github.com/Bijecta/BenchmarkGate.


Share this post:

Next Post
BenchmarkGate, Part 2: Why a Baseline Isn't Enough