diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml
index fb8cc67..88ef942 100644
--- a/.github/workflows/benchmarks.yml
+++ b/.github/workflows/benchmarks.yml
@@ -155,6 +155,7 @@ jobs:
exit 0
fi
echo "have_head=true" >> "$GITHUB_OUTPUT"
+ echo "head_shards=${#head_files[@]}" >> "$GITHUB_OUTPUT"
# Stitch every shard's joined report back into one: keep shard 0's metadata,
# concatenate all shards' Benchmarks arrays.
@@ -418,3 +419,11 @@ jobs:
git -c user.name="github-actions" -c user.email="github-actions@github.com" \
commit -m "Sync custom dashboard from ${GITHUB_SHA:0:7}"
git push origin gh-pages
+
+ # Deliberately the LAST step: a dashboard-wiring mistake must not cost us the
+ # measurement, so the comment and the gh-pages publish above happen first and this
+ # only reddens the job. Skipped unless every shard reported, since a partial merge
+ # is legitimately missing whole benchmark classes (the job runs with if: always()).
+ - name: Check dashboard coverage of the merged report
+ if: steps.merge.outputs.have_head == 'true' && steps.merge.outputs.head_shards == env.SHARD_TOTAL
+ run: node scripts/check_dashboard_coverage.js /tmp/pr-report-full.json
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5e3bede..c9f28e9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -57,6 +57,21 @@ jobs:
if-no-files-found: warn
retention-days: 7
+ # The benchmark workflow only fires on `src/**`, so a dashboard-only change never
+ # reaches the report-backed run of this same script in benchmarks.yml. These are the
+ # structural checks, which need no measurements and cost seconds.
+ dashboard-coverage:
+ name: dashboard-coverage
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ filter: tree:0
+
+ - name: Check dashboard wiring
+ run: node scripts/check_dashboard_coverage.js
+
aot-publish:
name: aot-publish (linux-x64, ${{ matrix.tfm }})
runs-on: ubuntu-latest
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e5f9934..de9a38a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,8 @@ All notable changes to Celerity are documented here. This project follows [Keep
### Fixed
+- **The `EnumMap` and `EnumSet` cards on the benchmark dashboard rendered empty.** The page required an `(ItemCount: N)` suffix on every result name, and both benchmarks deliberately declare no item-count sweep, so their measurements were published and then discarded at render time. Both cards now chart their real numbers, and unparameterized benchmarks are excluded from the headline speedup stats. Closes [#301](https://github.com/marius-bughiu/Celerity/issues/301).
+- A blank dashboard card is now a red CI check rather than a silent gap: `scripts/check_dashboard_coverage.js` fails when a published result name is unparseable, when a card has no measurements behind it, or when a charted collection is missing from either `COLLECTIONS` array or from the CI benchmark suite. Closes [#301](https://github.com/marius-bughiu/Celerity/issues/301).
- **The coverage gate measured only one of the six shipped packages.** Coverlet's assembly filter is exact-match, so `Celerity.Hashing`, `Celerity.Primitives`, and the three showcase packages had been outside the gate since the 2.0.0 package split — any of them could have dropped to 0% with CI green. All six are now measured, the gaps that exposed are backfilled to **100% line and branch** coverage, and the floor is raised from 95%/90% to match. Closes [#314](https://github.com/marius-bughiu/Celerity/issues/314).
## [2.4.0] - 2026-07-26
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 6ba9b4d..9d0c1d3 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -76,15 +76,35 @@ dotnet run -c Release -- --filter '*' # run everything with the default (slow, h
### CI
-The `benchmarks` job in [`.github/workflows/ci.yml`](.github/workflows/ci.yml) runs the full suite on `ubuntu-latest` after `build-and-test` succeeds. It uses a faster `CiConfig` (3 warmup × 5 measurement iterations) so the whole suite completes in ~5 min.
+[`.github/workflows/benchmarks.yml`](.github/workflows/benchmarks.yml) runs the CI-tracked core suite (the `CoreBenchmarks` array in `Program.cs`) at full BenchmarkDotNet accuracy, sharded across a parallel matrix. On a PR each shard measures its slice of both the PR head and the `main` tip back-to-back on the same runner, so hardware variance cancels; an aggregate job stitches the shard reports back together.
Results are parsed by [`benchmark-action/github-action-benchmark`](https://github.com/benchmark-action/github-action-benchmark) and:
-- **On a PR**: a comment is posted with the comparison vs the last `main` baseline. If any benchmark regresses by more than **200%** (i.e. is 2× slower or worse), the job fails red. The threshold is deliberately loose because GitHub-hosted runners are noisy — we'll tighten it once we have history to calibrate against.
-- **On a push to `main`**: the new measurement is appended to the `gh-pages`-stored history powering the dashboard at `https://marius-bughiu.github.io/Celerity/dev/bench/` (enable Pages on the `gh-pages` branch once the first run creates it).
+- **On a PR**: a comment is posted with the same-runner A/B comparison vs `main`. Rows that move by more than ±10% *and* beyond the combined standard deviation of both measurements are flagged; the flags are advisory, so a noisy row does not fail the job.
+- **On a push to `main`**: the new measurement is appended to the `gh-pages`-stored history powering the dashboard at .
If a change is motivated by performance, include before/after numbers from a local Release run in the PR description — the CI job is a guardrail, not a precision instrument. Numbers without `-c Release` are not useful — BenchmarkDotNet refuses to run in Debug.
+### The dashboard
+
+The dashboard reads BenchmarkDotNet result *names*, so a benchmark's naming is part of its contract with the site. A name that the page's parser does not recognise is dropped silently — the data publishes to `gh-pages` correctly and the card just renders blank, with nothing red anywhere.
+
+Two rules keep a benchmark chartable:
+
+- Methods are named `{TypeName}_{Op}` — `EnumSet_Contains`, `Dictionary_Lookup`. The type name decides whether the row is the Celerity arm or the BCL baseline (`BCL_TYPES` in the dashboard source), and `{Op}` is what the card is titled.
+- A `[Params]` sweep property must be called **`ItemCount`**. A class may declare no sweep at all — `EnumMap` / `EnumSet` are bounded by the enum universe, so a synthetic item count would chart a dimension that does not exist — in which case the dashboard renders a single bucket. Any *other* property name is rejected rather than charted under an "items" label it does not mean.
+
+Adding a collection to the site means updating three lists by hand, since the published data alone does not tell the page what to draw: the ship card in `web/index.html`, and the `COLLECTIONS` array in **both** `web/dev/bench/index.html` and `web/dev/bench/detail.html` (`items: [NO_SWEEP]` for an unparameterized class).
+
+[`scripts/check_dashboard_coverage.js`](scripts/check_dashboard_coverage.js) enforces all of this so the failure mode is a red check rather than a blank card. It lifts the `COLLECTIONS` tables and the name parsers out of the dashboard HTML rather than reimplementing them, so it validates the code that actually ships. Run it any time you touch a benchmark or the dashboard:
+
+```bash
+node scripts/check_dashboard_coverage.js # structural checks
+node scripts/check_dashboard_coverage.js path/to/joined-report-full.json # + verify the data
+```
+
+The structural half — the two `COLLECTIONS` arrays agree, and every charted collection has a `{Key}Benchmark` registered in `CoreBenchmarks` — runs on every PR in `ci.yml`. The full check runs in the aggregate job of `benchmarks.yml`, against the merged report, and additionally asserts that every published name parses and that every card resolves to both a BCL and a Celerity measurement.
+
## Versioning
Celerity uses [MinVer](https://github.com/adamralph/minver) to derive NuGet package versions exclusively from **git tags**. There is no `` or `` property in any `.csproj` file — the single source of truth is the `v`-prefixed annotated tag on the commit that represents a release.
diff --git a/ROADMAP.md b/ROADMAP.md
index 118af84..c8a9f55 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -225,11 +225,12 @@ The 2026-Q3 review surveyed the shipped surface against the current .NET 8/9/10
**A fourth core package: `Celerity.Sorting`.** `Array.Sort` / `MemoryExtensions.Sort` are scalar comparison introsort with no radix, counting, or selection path for primitive keys — and the BCL structurally cannot close it, because `Array.Sort` is contractually in-place while radix needs `O(n)` scratch. That is precisely the flexibility-for-speed trade this project's Vision licenses, against a named BCL counterpart. Would layer on `Celerity.Primitives`, mirroring how `Hashing` and `Collections` layer today. Status: `planned` — see the package-scoping caveat below.
-**Build- and release-pipeline integrity.** Three guards the repo advertises but does not have.
+**Build- and release-pipeline integrity.** Guards the repo advertises but does not have.
- The coverage gate measures one of the six shipping assemblies. `src/coverage.runsettings` filters to `[Celerity]*` with the comment "Measure only the shipping library assembly" — written when there was one. `Celerity.Hashing`, `Celerity.Primitives` and the three showcase packages are unmeasured, while `CONTRIBUTING.md` and `CLAUDE.md` describe the 95%/90% gate as library-wide. Status: `planned`.
- Nothing can fail after the NuGet push. `release.yml` pushes six packages irreversibly, *then* extracts the release notes and creates the GitHub Release — so an over-long release body (a failure this repo has actually hit) leaves a half-published release. The notes check should be hoisted ahead of the push. Status: `planned`.
- No API-compatibility gate. Six packages publish on a tag with no `ApiCompat` / `PackageValidation` / public-API-baseline check anywhere in the repo — in a project that already needed a hand-written `TypeForwarders.cs` to survive one assembly split. Status: `planned`.
+- No guard on the benchmark dashboard. The site parses BenchmarkDotNet result *names*, so a benchmark it cannot parse is dropped at render time — the data publishes correctly and the card just goes blank, with no CI signal. `EnumMap` and `EnumSet` had rendered empty since they shipped (they declare no `[Params]` sweep, by design), and `DisjointSet` blanked for five runs when its params property was briefly named `ElementCount`. Status: `done` — the parser now treats the `ItemCount` suffix as optional and renders an unparameterized class as a single bucket, excluded from the headline stats; `scripts/check_dashboard_coverage.js` fails CI on an unparseable name, a card with no measurements behind it, a collection missing from either `COLLECTIONS` array, or one not registered in the CI benchmark suite. It lifts those tables and the parsers out of the dashboard HTML rather than reimplementing them, so the check cannot drift from the page it guards. Tracked in [#301](https://github.com/marius-bughiu/Celerity/issues/301).
Two areas were judged real but deliberately deferred rather than rostered: a `Celerity.Statistics` package (DDSketch / reservoir sampling / running moments — a coherent fourth axis, but two new packages in one cycle is too much at once), and a batch of fuzz-target and AOT-smoke-coverage gaps (real, but low expected defect yield; better folded into whichever collection PR lands next than pursued on their own).
diff --git a/scripts/check_dashboard_coverage.js b/scripts/check_dashboard_coverage.js
new file mode 100644
index 0000000..4b74ba9
--- /dev/null
+++ b/scripts/check_dashboard_coverage.js
@@ -0,0 +1,203 @@
+#!/usr/bin/env node
+//
+// Fails when the benchmark dashboard would silently render an empty card.
+//
+// The dashboard parses BenchmarkDotNet result names with regexes, and a name that does
+// not match is dropped without a trace: the data publishes to gh-pages correctly and the
+// card just renders blank. That has happened twice — EnumMap / EnumSet declare no
+// [Params] at all, and DisjointSet once named its params property ElementCount — and
+// neither produced any CI signal.
+//
+// This check closes that gap. It lifts the COLLECTIONS tables and the two name parsers
+// straight out of the dashboard HTML rather than reimplementing them, so it validates
+// the parser that actually ships and cannot drift from it.
+//
+// Structural checks, run on every PR (no benchmark run needed):
+// 1. index.html and detail.html agree on the collection keys and their item counts;
+// 2. every charted collection has a matching benchmark class registered in the
+// CoreBenchmarks array of src/Celerity.Benchmarks/Program.cs.
+//
+// Report checks, run in the benchmark job once the sharded suite has been merged:
+// 3. every benchmark name in the report is understood by one of the dashboard parsers;
+// 4. every (collection, op) pair the dashboard draws a card for resolves to both a BCL
+// and a Celerity measurement.
+//
+// Usage:
+// node scripts/check_dashboard_coverage.js # 1-2 only
+// node scripts/check_dashboard_coverage.js # 1-4
+// Run from the repository root.
+
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+
+const INDEX_HTML = path.join('web', 'dev', 'bench', 'index.html');
+const DETAIL_HTML = path.join('web', 'dev', 'bench', 'detail.html');
+const PROGRAM_CS = path.join('src', 'Celerity.Benchmarks', 'Program.cs');
+const SELF = path.join('scripts', 'check_dashboard_coverage.js');
+
+function fail(message) {
+ console.error(`error: ${message}`);
+ process.exit(1);
+}
+
+// ---- Lift declarations out of the dashboard source ----------------------------------
+// Each pattern is anchored on the closing token at its known indentation, so a partial
+// match is impossible: either the whole declaration comes out or extraction fails loudly.
+
+function extract(source, file, label, pattern) {
+ const m = source.match(pattern);
+ if (!m) {
+ fail(
+ `could not extract ${label} from ${file}. The dashboard source was restructured; ` +
+ `update the patterns in ${SELF} to match.`
+ );
+ }
+ return m[0];
+}
+
+function loadDashboard(file) {
+ const source = fs.readFileSync(file, 'utf8');
+ const parts = [
+ extract(source, file, 'NO_SWEEP', /var NO_SWEEP = [^;]+;/),
+ extract(source, file, 'BCL_TYPES', /var BCL_TYPES = new Set\(\[[^\]]*\]\);/),
+ extract(source, file, 'COLLECTIONS', /var COLLECTIONS = \[[\s\S]*?\n {2}\];/),
+ extract(source, file, 'parseName', /function parseName\(name\) \{[\s\S]*?\n {2}\}/),
+ ];
+ // Only index.html carries the key builder and the hasher parser; detail.html is
+ // consulted for its COLLECTIONS table alone.
+ const idxKey = source.match(/function idxKey\([\s\S]*?\n {2}\}/);
+ const parseHasher = source.match(/function parseHasher\(name, value\) \{[\s\S]*?\n {2}\}/);
+ if (idxKey) parts.push(idxKey[0]);
+ if (parseHasher) parts.push(parseHasher[0]);
+ parts.push(
+ 'return { NO_SWEEP: NO_SWEEP, COLLECTIONS: COLLECTIONS, parseName: parseName,' +
+ ' idxKey: typeof idxKey === "function" ? idxKey : null,' +
+ ' parseHasher: typeof parseHasher === "function" ? parseHasher : null };'
+ );
+ return new Function(parts.join('\n'))();
+}
+
+// The CI-tracked suite, as `{Prefix}Benchmark` type names.
+function loadCoreBenchmarks() {
+ const source = fs.readFileSync(PROGRAM_CS, 'utf8');
+ const block = source.match(/CoreBenchmarks\s*=\s*\{([\s\S]*?)\};/);
+ if (!block) {
+ fail(`could not find the CoreBenchmarks array in ${PROGRAM_CS}; update the pattern in ${SELF}.`);
+ }
+ return new Set([...block[1].matchAll(/typeof\((\w+)\)/g)].map((m) => m[1]));
+}
+
+function loadBenchmarkNames(reportPath) {
+ let report;
+ try {
+ report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
+ } catch (e) {
+ fail(`could not read ${reportPath}: ${e.message}`);
+ }
+ if (!report || !Array.isArray(report.Benchmarks)) {
+ fail(`${reportPath} has no Benchmarks array — is it a BenchmarkDotNet *-report-full.json?`);
+ }
+ return report.Benchmarks.map((b) => b.FullName).filter(Boolean);
+}
+
+// ---- Checks -------------------------------------------------------------------------
+
+function main() {
+ const reportPath = process.argv[2] || null;
+
+ const index = loadDashboard(INDEX_HTML);
+ const detail = loadDashboard(DETAIL_HTML);
+ if (!index.idxKey) fail(`could not extract idxKey from ${INDEX_HTML}`);
+ if (!index.parseHasher) fail(`could not extract parseHasher from ${INDEX_HTML}`);
+
+ const problems = [];
+
+ // (1) The two tables must agree, or a card that renders on the grid dead-ends on click.
+ const detailByKey = new Map(detail.COLLECTIONS.map((c) => [c.key, c]));
+ for (const col of index.COLLECTIONS) {
+ const twin = detailByKey.get(col.key);
+ if (!twin) {
+ problems.push(`${col.key} is charted in ${INDEX_HTML} but missing from ${DETAIL_HTML}'s COLLECTIONS — its detail page will show "Unknown benchmark".`);
+ continue;
+ }
+ const a = JSON.stringify(col.items || null);
+ const b = JSON.stringify(twin.items || null);
+ if (a !== b) {
+ problems.push(`${col.key} declares items ${a} in ${INDEX_HTML} but ${b} in ${DETAIL_HTML} — the detail page will reject the item count the grid links to.`);
+ }
+ }
+ for (const col of detail.COLLECTIONS) {
+ if (!index.COLLECTIONS.some((c) => c.key === col.key)) {
+ problems.push(`${col.key} is listed in ${DETAIL_HTML} but missing from ${INDEX_HTML}'s COLLECTIONS — it has no card on the grid.`);
+ }
+ }
+
+ // (2) A charted collection whose class is not in the CI suite can never receive data.
+ const core = loadCoreBenchmarks();
+ for (const col of index.COLLECTIONS) {
+ if (!core.has(`${col.key}Benchmark`)) {
+ problems.push(`${col.key} is charted in ${INDEX_HTML} but ${col.key}Benchmark is not registered in the CoreBenchmarks array of ${PROGRAM_CS} — it will never be measured in CI.`);
+ }
+ }
+
+ if (reportPath) {
+ const names = loadBenchmarkNames(reportPath);
+
+ // (3) Nothing in the report may be silently unrenderable.
+ const unparsed = names.filter((n) => !index.parseName(n) && !index.parseHasher(n, 0));
+ if (unparsed.length > 0) {
+ const classes = [...new Set(unparsed.map((n) => n.split('.')[0]))];
+ problems.push(
+ `${unparsed.length} benchmark name(s) match no dashboard parser and would be dropped at render time, ` +
+ `from: ${classes.join(', ')}. First: "${unparsed[0]}". ` +
+ `The usual cause is a [Params] property not named ItemCount — rename it, or teach both dashboard parsers the new shape.`
+ );
+ }
+
+ // (4) Every card the dashboard draws must have both series behind it.
+ const idx = {};
+ for (const name of names) {
+ const p = index.parseName(name);
+ if (!p) continue;
+ const key = index.idxKey(p.collection, p.op, p.itemCount);
+ if (!idx[key]) idx[key] = {};
+ idx[key][p.isBcl ? 'bcl' : 'celerity'] = true;
+ }
+
+ for (const col of index.COLLECTIONS) {
+ // The primary count, exactly as index.html picks it: a card's ratio text may fall
+ // back to the smaller count, but its chart is always seriesFor(..., primaryN), and
+ // that is what decides between a sparkline and "awaiting data". So the primary
+ // count is the one that has to resolve.
+ const items = col.items || [1000, 100000];
+ const primaryN = items[items.length - 1];
+ for (const op of col.ops) {
+ const pair = idx[index.idxKey(col.key, op, primaryN)];
+ if (!pair || !pair.bcl || !pair.celerity) {
+ const at = primaryN == null ? 'no sweep' : primaryN;
+ problems.push(`${col.key}.${op} has no BCL+Celerity pair at ${at} — that card renders "awaiting data".`);
+ }
+ }
+ }
+ }
+
+ if (problems.length > 0) {
+ console.error(`Dashboard coverage check failed (${problems.length} problem(s)):\n`);
+ for (const p of problems) console.error(` - ${p}`);
+ console.error(
+ `\nEvery collection in the COLLECTIONS arrays of ${INDEX_HTML} and ${DETAIL_HTML} must be measured ` +
+ `by the CI suite and must resolve to real measurements. See CONTRIBUTING.md, "The dashboard".`
+ );
+ process.exit(1);
+ }
+
+ const cards = index.COLLECTIONS.reduce((acc, c) => acc + c.ops.length, 0);
+ console.log(
+ `Dashboard coverage OK: ${cards} cards across ${index.COLLECTIONS.length} collections` +
+ (reportPath ? ` resolve from the joined report.` : ` are wired to registered benchmark classes (structural checks only — pass a joined report to verify the data).`)
+ );
+}
+
+main();
diff --git a/web/dev/bench/detail.html b/web/dev/bench/detail.html
index 683182c..6f9c0e7 100644
--- a/web/dev/bench/detail.html
+++ b/web/dev/bench/detail.html
@@ -105,6 +105,8 @@
overflow: hidden;
background: var(--bg);
}
+ /* An unparameterized benchmark has one bucket and no buttons; hide the empty frame. */
+ .toggle:empty { display: none; }
.toggle button {
background: var(--bg);
border: none;
@@ -348,6 +350,10 @@
'use strict';
// Keep in sync with web/dev/bench/index.html.
+ // `items: [NO_SWEEP]` marks a benchmark class that declares no [Params]: its
+ // measurements carry no parenthesized suffix, so there is a single bucket and
+ // no item-count toggle.
+ var NO_SWEEP = null;
var COLLECTIONS = [
{ key: 'IntDictionary', title: 'IntDictionary', vs: 'Dictionary' },
{ key: 'LongDictionary', title: 'LongDictionary', vs: 'Dictionary' },
@@ -360,7 +366,7 @@
{ key: 'CelerityMultiMap', title: 'CelerityMultiMap', vs: 'Dictionary>' },
{ key: 'CelerityMultiSet', title: 'CelerityMultiSet', vs: 'Dictionary' },
{ key: 'SmallDictionary', title: 'SmallDictionary', vs: 'Dictionary', items: [8, 64] },
- { key: 'EnumMap', title: 'EnumMap', vs: 'Dictionary' },
+ { key: 'EnumMap', title: 'EnumMap', vs: 'Dictionary', items: [NO_SWEEP] },
{ key: 'IntSet', title: 'IntSet', vs: 'HashSet' },
{ key: 'LongSet', title: 'LongSet', vs: 'HashSet' },
{ key: 'CeleritySet', title: 'CeleritySet', vs: 'HashSet' },
@@ -370,7 +376,7 @@
{ key: 'PooledCeleritySet', title: 'PooledCeleritySet', vs: 'HashSet' },
{ key: 'FrozenCeleritySet', title: 'FrozenCeleritySet', vs: 'FrozenSet' },
{ key: 'SmallSet', title: 'SmallSet', vs: 'HashSet', items: [8, 64] },
- { key: 'EnumSet', title: 'EnumSet', vs: 'HashSet' },
+ { key: 'EnumSet', title: 'EnumSet', vs: 'HashSet', items: [NO_SWEEP] },
{ key: 'SparseSet', title: 'SparseSet', vs: 'HashSet' },
{ key: 'BloomFilter', title: 'BloomFilter', vs: 'HashSet' },
{ key: 'CuckooFilter', title: 'CuckooFilter', vs: 'HashSet' },
@@ -420,8 +426,15 @@
return;
}
// Most collections use the default 1k / 100k counts; a small-n collection like
- // SmallDictionary overrides them with collection.items.
+ // SmallDictionary overrides them with collection.items, and an unparameterized one
+ // carries the single [NO_SWEEP] bucket.
var itemCounts = collection.items || SUPPORTED_ITEM_COUNTS;
+ var sweeps = itemCounts.length > 1 || itemCounts[0] != null;
+
+ // Subtitle / notice label for an item count, which is absent on an unparameterized run.
+ function itemCountLabel(n) {
+ return n == null ? 'no item-count sweep' : n.toLocaleString() + ' items';
+ }
if (params.n === null) {
params.n = itemCounts[itemCounts.length - 1];
}
@@ -442,14 +455,18 @@
}
var runs = data.entries['Celerity Benchmarks'];
+ // Keep in sync with the parser in index.html: the ItemCount suffix is optional, so a
+ // class that declares no [Params] parses with itemCount === NO_SWEEP instead of being
+ // dropped. A class whose params property is named something other than ItemCount stays
+ // unparsed by design — scripts/check_dashboard_coverage.js fails CI on it.
function parseName(name) {
- var m = name.match(/^(\w+)Benchmark\.(\w+?)_(\w+)\(ItemCount:\s*(\d+)\)$/);
+ var m = name.match(/^(\w+)Benchmark\.(\w+?)_(\w+)(?:\(ItemCount:\s*(\d+)\))?$/);
if (!m) return null;
return {
collection: m[1],
typeName: m[2],
op: m[3],
- itemCount: parseInt(m[4], 10),
+ itemCount: m[4] != null ? parseInt(m[4], 10) : NO_SWEEP,
isBcl: BCL_TYPES.has(m[2])
};
}
@@ -555,7 +572,9 @@
'' +
'';
- // Build the toggle buttons.
+ // Build the toggle buttons. An unparameterized benchmark has a single bucket and
+ // nothing to toggle between, so the control is left empty (and hidden by CSS).
+ if (!sweeps) return;
var toggle = document.getElementById('n-toggle');
itemCounts.forEach(function (n) {
var btn = document.createElement('button');
@@ -579,7 +598,7 @@
function renderDynamic() {
document.getElementById('op-label').textContent = params.op;
- document.getElementById('n-label').textContent = params.n.toLocaleString() + ' items';
+ document.getElementById('n-label').textContent = itemCountLabel(params.n);
// Active toggle button.
document.querySelectorAll('#n-toggle button').forEach(function (b) {
@@ -593,7 +612,9 @@
// Replace the dynamic region with a notice but keep the header.
var notice = document.createElement('div');
notice.className = 'notice';
- notice.innerHTML = 'No measurements recorded for ' + collection.title + '.' + params.op + ' at ' + params.n.toLocaleString() + ' items yet. Back to dashboard →';
+ // `op` is whatever the query string says, so it is escaped before it reaches innerHTML.
+ notice.innerHTML = 'No measurements recorded for ' + collection.title + '.' + escapeHtml(params.op) + '' +
+ (params.n == null ? '' : ' at ' + params.n.toLocaleString() + ' items') + ' yet. Back to dashboard →';
// Remove headline + chart + table; insert notice in their place.
['headline','chart-wrap','section-head','measurements'].forEach(function () { /* placeholder */ });
var existing = document.querySelector('.headline');
diff --git a/web/dev/bench/index.html b/web/dev/bench/index.html
index 9ebf6bd..bdd1ed7 100644
--- a/web/dev/bench/index.html
+++ b/web/dev/bench/index.html
@@ -411,6 +411,11 @@ Hash function throughput
// Layout: ordered list of collections + the BCL counterpart label for the "vs" subtitle.
// The class prefix in benchmark names is "{prefix}Benchmark", and methods are
// named "{TypeName}_{Op}". BCL types: Dictionary, HashSet. Celerity types: the rest.
+ //
+ // `items` overrides the default 1k / 100k sweep. A benchmark class that declares no
+ // [Params] at all uses `items: [NO_SWEEP]`: its measurements carry no parenthesized
+ // suffix and live in a single bucket rather than a per-item-count series.
+ var NO_SWEEP = null;
var COLLECTIONS = [
{ key: 'IntDictionary', title: 'IntDictionary', vs: 'Dictionary', ops: ['Insert', 'Lookup', 'Remove'] },
{ key: 'LongDictionary', title: 'LongDictionary', vs: 'Dictionary', ops: ['Insert', 'Lookup', 'Remove'] },
@@ -426,7 +431,7 @@ Hash function throughput
// (not the 1k / 100k the hash tables use), so it carries its own item counts.
{ key: 'SmallDictionary', title: 'SmallDictionary', vs: 'Dictionary', ops: ['Insert', 'Lookup', 'Remove'], items: [8, 64] },
// EnumMap is bounded by the enum universe (no item-count sweep); Enumerate is the contiguous-storage sweep win.
- { key: 'EnumMap', title: 'EnumMap', vs: 'Dictionary', ops: ['Add', 'Lookup', 'Remove', 'Enumerate'] },
+ { key: 'EnumMap', title: 'EnumMap', vs: 'Dictionary', ops: ['Add', 'Lookup', 'Remove', 'Enumerate'], items: [NO_SWEEP] },
{ key: 'IntSet', title: 'IntSet', vs: 'HashSet', ops: ['Add', 'Contains', 'Remove'] },
{ key: 'LongSet', title: 'LongSet', vs: 'HashSet', ops: ['Add', 'Contains', 'Remove'] },
{ key: 'CeleritySet', title: 'CeleritySet', vs: 'HashSet', ops: ['Add', 'Contains', 'Remove'] },
@@ -439,7 +444,7 @@ Hash function throughput
// items (not the 1k / 100k the hash sets use), so it carries its own item counts.
{ key: 'SmallSet', title: 'SmallSet', vs: 'HashSet', ops: ['Add', 'Contains', 'Remove'], items: [8, 64] },
// EnumSet is bounded by the enum universe (no item-count sweep); Union is the word-wise set-algebra win.
- { key: 'EnumSet', title: 'EnumSet', vs: 'HashSet', ops: ['Add', 'Contains', 'Remove', 'Union'] },
+ { key: 'EnumSet', title: 'EnumSet', vs: 'HashSet', ops: ['Add', 'Contains', 'Remove', 'Union'], items: [NO_SWEEP] },
// SparseSet is a bounded-universe integer set; ClearRefill is the O(1)-clear headline win.
{ key: 'SparseSet', title: 'SparseSet', vs: 'HashSet', ops: ['Add', 'Contains', 'ClearRefill', 'Remove'] },
{ key: 'BloomFilter', title: 'BloomFilter', vs: 'HashSet', ops: ['Add', 'Contains', 'ContainsMissing'] },
@@ -493,24 +498,36 @@ Hash function throughput
// ---- Parse benchmark name into structured fields ----
function parseName(name) {
- // "CelerityDictionaryBenchmark.CelerityDictionary_Lookup(ItemCount: 1000)"
- var m = name.match(/^(\w+)Benchmark\.(\w+?)_(\w+)\(ItemCount:\s*(\d+)\)$/);
+ // "CelerityDictionaryBenchmark.CelerityDictionary_Lookup(ItemCount: 1000)", or
+ // "EnumMapBenchmark.EnumMap_Lookup" for a class that declares no [Params] sweep.
+ //
+ // The suffix is matched strictly as ItemCount rather than as any parameter name:
+ // a class whose params property is called something else measures a dimension the
+ // cards label "items", so charting it would be wrong. Such a class stays unparsed
+ // here and is caught by scripts/check_dashboard_coverage.js in CI instead.
+ var m = name.match(/^(\w+)Benchmark\.(\w+?)_(\w+)(?:\(ItemCount:\s*(\d+)\))?$/);
if (!m) return null;
var collection = m[1];
var typeName = m[2];
var op = m[3];
- var itemCount = parseInt(m[4], 10);
+ var itemCount = m[4] != null ? parseInt(m[4], 10) : NO_SWEEP;
var isBcl = BCL_TYPES.has(typeName);
return { collection: collection, typeName: typeName, op: op, itemCount: itemCount, isBcl: isBcl };
}
+ // Index key. NO_SWEEP collapses to an empty trailing field, so an unparameterized
+ // benchmark gets its own bucket that can never collide with a real item count.
+ function idxKey(collection, op, itemCount) {
+ return collection + '|' + op + '|' + (itemCount == null ? '' : itemCount);
+ }
+
// ---- Build a quick-lookup index of latest values per (collection, op, itemCount, kind) ----
function indexBenches(benches) {
var idx = {};
benches.forEach(function (b) {
var p = parseName(b.name);
if (!p) return;
- var key = p.collection + '|' + p.op + '|' + p.itemCount;
+ var key = idxKey(p.collection, p.op, p.itemCount);
if (!idx[key]) idx[key] = {};
idx[key][p.isBcl ? 'bcl' : 'celerity'] = b.value;
});
@@ -525,9 +542,11 @@ Hash function throughput
Object.keys(latestIdx).forEach(function (key) {
var parts = key.split('|');
var op = parts[1];
- var n = parseInt(parts[2], 10);
if (opAliases.indexOf(op) === -1) return;
- if (n !== ITEM_COUNT_FOR_HEADLINE) return;
+ // Unparameterized benchmarks have no item count and are excluded outright,
+ // rather than bucketed at 0 and compared against the 100k headline sweep.
+ if (parts[2] === '') return;
+ if (parseInt(parts[2], 10) !== ITEM_COUNT_FOR_HEADLINE) return;
var pair = latestIdx[key];
if (pair.bcl == null || pair.celerity == null) return;
var ratio = pair.bcl / pair.celerity;
@@ -563,8 +582,7 @@ Hash function throughput
// ---- Charts per collection ----
function ratioFor(collection, op, itemCount) {
- var key = collection + '|' + op + '|' + itemCount;
- var pair = latestIdx[key];
+ var pair = latestIdx[idxKey(collection, op, itemCount)];
if (!pair || pair.bcl == null || pair.celerity == null) return null;
return pair.bcl / pair.celerity;
}
@@ -700,20 +718,22 @@ Hash function throughput
}
if (displayRatio != null) {
- var pairPrimary = latestIdx[col.key + '|' + op + '|' + primaryN];
+ var pairPrimary = latestIdx[idxKey(col.key, op, primaryN)];
+ var shownPair, shownN;
if (pairPrimary && pairPrimary.bcl != null && pairPrimary.celerity != null) {
- detail = fmtNs(pairPrimary.celerity).replace('μ', 'µ') + ' vs ' + fmtNs(pairPrimary.bcl).replace('μ', 'µ') + ' @ ' + fmtCount(primaryN) + ' items';
+ shownPair = pairPrimary; shownN = primaryN;
} else {
- var pairFallback = latestIdx[col.key + '|' + op + '|' + fallbackN];
- detail = fmtNs(pairFallback.celerity).replace('μ', 'µ') + ' vs ' + fmtNs(pairFallback.bcl).replace('μ', 'µ') + ' @ ' + fmtCount(fallbackN) + ' items';
+ shownPair = latestIdx[idxKey(col.key, op, fallbackN)]; shownN = fallbackN;
}
+ detail = fmtNs(shownPair.celerity).replace('μ', 'µ') + ' vs ' + fmtNs(shownPair.bcl).replace('μ', 'µ') +
+ (shownN == null ? '' : ' @ ' + fmtCount(shownN) + ' items');
}
var cell = document.createElement('div');
cell.className = 'chart-cell';
cell.dataset.collection = col.key;
cell.dataset.op = op;
- cell.dataset.items = String(primaryN);
+ if (primaryN != null) cell.dataset.items = String(primaryN);
cell.innerHTML =
'' + op + '
' +
'' + ratioStr + '
' +
@@ -742,7 +762,7 @@ Hash function throughput
function navigate() {
var qs = '?c=' + encodeURIComponent(cell.dataset.collection) +
'&op=' + encodeURIComponent(cell.dataset.op) +
- '&n=' + cell.dataset.items;
+ (cell.dataset.items ? '&n=' + cell.dataset.items : '');
window.location.href = 'detail.html' + qs;
}
cell.addEventListener('click', navigate);