feat: a meta-benchmark over the externally produced benchmarks 0.15.0 used - #875
feat: a meta-benchmark over the externally produced benchmarks 0.15.0 used#875raeq wants to merge 14 commits into
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
There are a few correctness and reliability issues (notably deterministic skip-path tests and a couple of reporting/suite logic problems) that should be addressed before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds a new Python “meta-benchmark” harness (benchmarks/meta) that consolidates the externally produced benchmarks used during the 0.15.0 cycle into a single registry-driven runner with provenance, skip semantics, reporting (Markdown/JSON), and drift comparison against a committed baseline.
Changes:
- Introduces
benchmarks/metaCLI + library: suite registry/selection (nofm), runner, reporting, and baseline drift comparison. - Adds a broad suite catalog across normative tables, CVEs, academic corpora, datasets, model artifacts, comparators, plus an introspective tier excluded by default.
- Adds tests for registry invariants, selection behavior, skip/error handling, baseline serialization, and report rendering; plus a typing fix in
benchmarks/adversarial_eval.
File summaries
| File | Description |
|---|---|
benchmarks/meta/__init__.py |
Public package surface and entry-point exports for the meta-benchmark. |
benchmarks/meta/__main__.py |
CLI for listing/selecting/running suites and emitting reports/baseline updates. |
benchmarks/meta/base.py |
Shared suite helpers: artifact location, stride-based thinning, surface/detector enumeration, SuiteBase behavior. |
benchmarks/meta/baseline.py |
Baseline snapshotting, persistence, and drift comparison logic. |
benchmarks/meta/baselines/default.json |
Initial committed baseline snapshot (keyed by suite + population). |
benchmarks/meta/protocol.py |
Core protocol/enums and dataclasses (provenance, outcomes, measurements, suite interface). |
benchmarks/meta/registry.py |
Registry assembly and deterministic filter/sample selection (“n of m”). |
benchmarks/meta/report.py |
Markdown/JSON rendering, including family grouping and drift/skips sections. |
benchmarks/meta/runner.py |
Run orchestration with per-suite isolation and version stamping. |
benchmarks/meta/README.md |
Usage and rationale documentation for the meta-benchmark harness. |
benchmarks/meta/suites/__init__.py |
Suite module aggregation and exports. |
benchmarks/meta/suites/normative.py |
Normative-table suites (UTS/UAX/UCD/ICANN/CLDR-derived measurements). |
benchmarks/meta/suites/cve.py |
CVE-anchored suites (stringprep delta; Trojan Source PoC files). |
benchmarks/meta/suites/academic.py |
Academic corpus adapter suites (manual artifact placement; detection + recovery scoring). |
benchmarks/meta/suites/datasets.py |
Public dataset suites; reuses benchmarks.adversarial_eval adapters for consistency. |
benchmarks/meta/suites/model_artifacts.py |
Model-artifact suite parsing tokenizer JSONs to derive delimiter probes. |
benchmarks/meta/suites/comparators.py |
Comparator suites (labelled benchmarks/rival tools) including precision/recall scoring. |
benchmarks/meta/suites/introspective.py |
Self-referential sweeps registered as non-external and excluded by default. |
tests/test_meta_benchmark.py |
Test coverage for suite invariants, selection/thinning, runner behavior, baseline, and reporting. |
benchmarks/adversarial_eval/corpora.py |
Tightens _guess_text_column typing to match actual call sites (Sequence[str]). |
CHANGELOG.md |
Documents the new meta-benchmark harness and its core guarantees/semantics. |
Review details
Suppressed comments (2)
benchmarks/meta/suites/model_artifacts.py:107
manufactured_anyis incremented based onper_surface_manufacture(a cumulative dict across all tokens), so it would overcount after the first manufactured delimiter. Since it’s currently unused, it’s best to remove this block to avoid a latent logic bug.
if survived_here == len(surface_map):
survives_all += 1
if any(per_surface_manufacture.values()):
manufactured_any += 1
tests/test_meta_benchmark.py:289
- Like
test_a_missing_artifact_skips..., this relies on the local machine having at least one missing registry artifact. If a developer has all artifacts in cache, the test won’t exercise the skipped path and can fail due toabsent[:3]being empty. Use a deterministic missing-artifact suite here too so the markdown “Not run” section is always exercised.
def test_markdown_names_every_skipped_suite_and_why():
absent = [s for s in registry.all_suites() if not s.available()[0]]
report = run(absent[:3], registered=len(registry.all_suites()))
md = render_markdown(report)
- Files reviewed: 21/21 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…ith its cost Four changes, each closing a way the harness could report a number that did not mean what it appeared to. **The suites did not reproduce the measurements they cited.** The report's headline is a finding/now pair, and nothing checked that the two halves answered the same question. `ascii-producing-steps` cited #719's 261-via-NFKC and 232-via-fold and measured 266 and 253, because it used a looser punctuation set, a bare detection probe and an assigned-only domain. A reader would have read a 21-code-point change in behaviour off a change in method. Each suite now recomputes its published script's exact quantity alongside its own sweep, pinned to what that script printed at v0.14.1. Eighteen pins across six suites, all verified against a real v0.14.1 build in its own worktree; #719 now reproduces 493/261/232/174/76 exactly, and #713 lands on 684. Pins are taken from *executing* each gist, not from its docstring — the segmentation census header says `total=36` and running it prints `37`. `Reproduction.matches` is the only thing that licenses reading a finding and a measurement as a before/after, and the report says so in both directions. **Only disarm was ever scored.** `--subject` runs the suites against the pinned comparator environment in requirements/bench.txt (ftfy, unidecode, text-unidecode, anyascii, decancer) plus CPython's normalization as the floor, so an absolute figure becomes a column. A suite measuring a disarm-specific surface skips other subjects rather than scoring them zero. The first cross-tool result was itself a bad metric: scoring "the tool changed the code point" put unidecode at 99.9%, because a transliterator rewrites everything. Coverage is now "source and its UTS #39 target land on one form". **A tool that deleted everything was the best tool in the registry.** Both sides of every comparison became identical, so it folded 100% of UTS #39 onto target and closed every equivalence class. Collisions now require a non-empty shared form, and XMR requires something to survive. `null-baseline` and `identity` are registered as permanent controls: a control that is meant to fail is the only thing that proves a metric can fail, and a test asserts both score zero. **Coverage had no paired cost.** `corruption-cost` measures the other direction — code points destroyed, characters retained, injectivity, and alteration of pure ASCII that has nothing to fix. Labelled corpora get the same axis free from their own `clean` column, which is by the corpus author's definition text needing no repair. Both ends are reported with the surface names at each end, and key builders are separated: `sort_key` and `catalog_key` are many-to-one by contract, and counting that as damage would rank a tool with no key builder as safer. Every run now records its method — subject, domain, predicates invoked, parameters, the sha256 of the artifact read, and the Unicode/UCD versions. The baseline is keyed by subject as well as suite and carries the table versions, so a UCD bump no longer reads as a regression in the tool. Review findings from #875, each with a test that fails without the fix: - `report.fmt()` treated `of=0` as "no denominator", hiding exactly the empty-population case the report exists to show. - Two tests depended on some registry suite happening to be unavailable, so a populated `$DISARM_META_CACHE` would fail them. They now use an explicit always-missing double. - `manufactured_from_fullwidth` read a cumulative counter inside the row loop, so once one row fired every later row counted too. It was also never reported. Also fixed: `ucd-scripts` counted unassigned code points in denominators that excluded them, so agree + contradict + silent did not sum to the population. Refs #732, #736, #759 Signed-off-by: Richard Quinn <quinn.richard@gmail.com> Assisted-by: Claude:claude-opus-5[1m]
58373ad to
2b63c16
Compare
…ith its cost Four changes, each closing a way the harness could report a number that did not mean what it appeared to. **The suites did not reproduce the measurements they cited.** The report's headline is a finding/now pair, and nothing checked that the two halves answered the same question. `ascii-producing-steps` cited #719's 261-via-NFKC and 232-via-fold and measured 266 and 253, because it used a looser punctuation set, a bare detection probe and an assigned-only domain. A reader would have read a 21-code-point change in behaviour off a change in method. Each suite now recomputes its published script's exact quantity alongside its own sweep, pinned to what that script printed at v0.14.1. Eighteen pins across six suites, all verified against a real v0.14.1 build in its own worktree; #719 now reproduces 493/261/232/174/76 exactly, and #713 lands on 684. Pins are taken from *executing* each gist, not from its docstring — the segmentation census header says `total=36` and running it prints `37`. `Reproduction.matches` is the only thing that licenses reading a finding and a measurement as a before/after, and the report says so in both directions. **Only disarm was ever scored.** `--subject` runs the suites against the pinned comparator environment in requirements/bench.txt (ftfy, unidecode, text-unidecode, anyascii, decancer) plus CPython's normalization as the floor, so an absolute figure becomes a column. A suite measuring a disarm-specific surface skips other subjects rather than scoring them zero. The first cross-tool result was itself a bad metric: scoring "the tool changed the code point" put unidecode at 99.9%, because a transliterator rewrites everything. Coverage is now "source and its UTS #39 target land on one form". **A tool that deleted everything was the best tool in the registry.** Both sides of every comparison became identical, so it folded 100% of UTS #39 onto target and closed every equivalence class. Collisions now require a non-empty shared form, and XMR requires something to survive. `null-baseline` and `identity` are registered as permanent controls: a control that is meant to fail is the only thing that proves a metric can fail, and a test asserts both score zero. **Coverage had no paired cost.** `corruption-cost` measures the other direction — code points destroyed, characters retained, injectivity, and alteration of pure ASCII that has nothing to fix. Labelled corpora get the same axis free from their own `clean` column, which is by the corpus author's definition text needing no repair. Both ends are reported with the surface names at each end, and key builders are separated: `sort_key` and `catalog_key` are many-to-one by contract, and counting that as damage would rank a tool with no key builder as safer. Every run now records its method — subject, domain, predicates invoked, parameters, the sha256 of the artifact read, and the Unicode/UCD versions. The baseline is keyed by subject as well as suite and carries the table versions, so a UCD bump no longer reads as a regression in the tool. Review findings from #875, each with a test that fails without the fix: - `report.fmt()` treated `of=0` as "no denominator", hiding exactly the empty-population case the report exists to show. - Two tests depended on some registry suite happening to be unavailable, so a populated `$DISARM_META_CACHE` would fail them. They now use an explicit always-missing double. - `manufactured_from_fullwidth` read a cumulative counter inside the row loop, so once one row fired every later row counted too. It was also never reported. Also fixed: `ucd-scripts` counted unassigned code points in denominators that excluded them, so agree + contradict + silent did not sum to the population. Refs #732, #736, #759 Signed-off-by: Richard Quinn <quinn.richard@gmail.com> Assisted-by: Claude:claude-opus-5[1m]
2b3e3fc to
7440af0
Compare
…ith its cost Four changes, each closing a way the harness could report a number that did not mean what it appeared to. **The suites did not reproduce the measurements they cited.** The report's headline is a finding/now pair, and nothing checked that the two halves answered the same question. `ascii-producing-steps` cited #719's 261-via-NFKC and 232-via-fold and measured 266 and 253, because it used a looser punctuation set, a bare detection probe and an assigned-only domain. A reader would have read a 21-code-point change in behaviour off a change in method. Each suite now recomputes its published script's exact quantity alongside its own sweep, pinned to what that script printed at v0.14.1. Eighteen pins across six suites, all verified against a real v0.14.1 build in its own worktree; #719 now reproduces 493/261/232/174/76 exactly, and #713 lands on 684. Pins are taken from *executing* each gist, not from its docstring — the segmentation census header says `total=36` and running it prints `37`. `Reproduction.matches` is the only thing that licenses reading a finding and a measurement as a before/after, and the report says so in both directions. **Only disarm was ever scored.** `--subject` runs the suites against the pinned comparator environment in requirements/bench.txt (ftfy, unidecode, text-unidecode, anyascii, decancer) plus CPython's normalization as the floor, so an absolute figure becomes a column. A suite measuring a disarm-specific surface skips other subjects rather than scoring them zero. The first cross-tool result was itself a bad metric: scoring "the tool changed the code point" put unidecode at 99.9%, because a transliterator rewrites everything. Coverage is now "source and its UTS #39 target land on one form". **A tool that deleted everything was the best tool in the registry.** Both sides of every comparison became identical, so it folded 100% of UTS #39 onto target and closed every equivalence class. Collisions now require a non-empty shared form, and XMR requires something to survive. `null-baseline` and `identity` are registered as permanent controls: a control that is meant to fail is the only thing that proves a metric can fail, and a test asserts both score zero. **Coverage had no paired cost.** `corruption-cost` measures the other direction — code points destroyed, characters retained, injectivity, and alteration of pure ASCII that has nothing to fix. Labelled corpora get the same axis free from their own `clean` column, which is by the corpus author's definition text needing no repair. Both ends are reported with the surface names at each end, and key builders are separated: `sort_key` and `catalog_key` are many-to-one by contract, and counting that as damage would rank a tool with no key builder as safer. Every run now records its method — subject, domain, predicates invoked, parameters, the sha256 of the artifact read, and the Unicode/UCD versions. The baseline is keyed by subject as well as suite and carries the table versions, so a UCD bump no longer reads as a regression in the tool. Review findings from #875, each with a test that fails without the fix: - `report.fmt()` treated `of=0` as "no denominator", hiding exactly the empty-population case the report exists to show. - Two tests depended on some registry suite happening to be unavailable, so a populated `$DISARM_META_CACHE` would fail them. They now use an explicit always-missing double. - `manufactured_from_fullwidth` read a cumulative counter inside the row loop, so once one row fired every later row counted too. It was also never reported. Also fixed: `ucd-scripts` counted unassigned code points in denominators that excluded them, so agree + contradict + silent did not sum to the population. Refs #732, #736, #759 Signed-off-by: Richard Quinn <quinn.richard@gmail.com> Assisted-by: Claude:claude-opus-5[1m]
| target = (root / member.name).resolve() | ||
| if not target.is_relative_to(root): | ||
| raise ValueError(f"archive member escapes the destination: {member.name}") | ||
| tar.extractall(dest) # noqa: S202 - every member checked above |
… used The 0.15.0 cycle found and verified defects against roughly thirty artifacts produced outside this repository — corpora released with papers, public spam and phishing datasets, normative Unicode/IETF/ICANN tables, published CVEs, released tokenizers and a third-party labelled benchmark. Each measurement lived in its own script, recorded as a gist on the issue it produced, and nothing re-ran any of them. #732 states the consequence directly: one public corpus produced five findings and nothing in the repo will notice when any of them moves. `benchmarks/meta` consolidates the selection, the provenance record, the scoring protocol and the report into one harness that runs n of m: python -m benchmarks.meta --list python -m benchmarks.meta --run --only-available python -m benchmarks.meta --run --select 'uts39-*' --report out.md 35 benchmarks are registered across six tiers: normative tables, published CVEs, academic corpora, public datasets, released model artifacts, and third-party labelled benchmarks. The harness supplies the runner and supplies no vectors — a library that writes its own benchmark grades its own homework. The bias boundary is machine-checkable rather than promised. `Provenance.external` is a field, the runner tags every outcome with it, the report renders external and introspective results in separate sections, and a test asserts that no external suite is scored against a file disarm generated. That last one is not hypothetical: `data/confusables_lgr.tsv` is an ideal-looking local oracle for the ICANN suite, and it is also the file the shipped fold was built from, so scoring the fold against it returns 100% by construction. The suite requires ICANN's published LGR or reports nothing. A seventh tier, `introspective`, holds the sweeps whose right answer only disarm can supply (#719, #723/#751, #805/#806/#807, #834). They are registered so the 0.15.0 record has no hole, marked `external=False`, excluded unless `--include-introspective` is passed, and never folded into an external total. Three things are reported per suite and kept apart: what the benchmark found during the cycle (historical, quoted from the issue, never edited to match a fresh run), how it is measured (methodology), and what it measures now. The gap between the first and the third is where a landed fix shows up as a number, and several already do — UTS #39 §5.3 was unimplemented under #777 and now reports 75 of 75 numbering systems; `has_bidi_conflict` was neutral to 1,786 of 3,018 strong-RTL code points under #773 and now reaches all 3,018. A suite whose artifact is absent reports SKIPPED with the variable to set. An absent corpus is not a passing corpus, and most academic corpora are deliberately not vendored: copying an attack corpus into this repository would make it disarm's corpus, and a corpus disarm owns is a corpus disarm can be tuned against. The #39/#40 guardrail carries over unchanged — these are measuring instruments, never optimization targets. Drift is reported and never gated. Baselines are keyed by suite *and* population, because a ratio over 4,000 code points and one over 150,000 are not comparable; the report marks such a row rather than subtracting it. `--limit` samples by stride rather than truncating — the first N code points of any sorted domain are Latin, Greek and Cyrillic, the best-covered part of every table here, so a truncating quick pass would disagree with a full one for the wrong reason. Also fixed while here, both found by the harness against its own results: - `ucd-scripts` counted unassigned code points in its denominators, so agree + contradict + silent did not sum to the population. - `benchmarks/adversarial_eval/corpora.py` typed `_guess_text_column` as taking `list[str]` while both call sites pass `csv.DictReader.fieldnames`, a `Sequence[str]`. Refs #732, #736, #759 Signed-off-by: Richard Quinn <quinn.richard@gmail.com> Assisted-by: Claude:claude-opus-5[1m]
…ith its cost Four changes, each closing a way the harness could report a number that did not mean what it appeared to. **The suites did not reproduce the measurements they cited.** The report's headline is a finding/now pair, and nothing checked that the two halves answered the same question. `ascii-producing-steps` cited #719's 261-via-NFKC and 232-via-fold and measured 266 and 253, because it used a looser punctuation set, a bare detection probe and an assigned-only domain. A reader would have read a 21-code-point change in behaviour off a change in method. Each suite now recomputes its published script's exact quantity alongside its own sweep, pinned to what that script printed at v0.14.1. Eighteen pins across six suites, all verified against a real v0.14.1 build in its own worktree; #719 now reproduces 493/261/232/174/76 exactly, and #713 lands on 684. Pins are taken from *executing* each gist, not from its docstring — the segmentation census header says `total=36` and running it prints `37`. `Reproduction.matches` is the only thing that licenses reading a finding and a measurement as a before/after, and the report says so in both directions. **Only disarm was ever scored.** `--subject` runs the suites against the pinned comparator environment in requirements/bench.txt (ftfy, unidecode, text-unidecode, anyascii, decancer) plus CPython's normalization as the floor, so an absolute figure becomes a column. A suite measuring a disarm-specific surface skips other subjects rather than scoring them zero. The first cross-tool result was itself a bad metric: scoring "the tool changed the code point" put unidecode at 99.9%, because a transliterator rewrites everything. Coverage is now "source and its UTS #39 target land on one form". **A tool that deleted everything was the best tool in the registry.** Both sides of every comparison became identical, so it folded 100% of UTS #39 onto target and closed every equivalence class. Collisions now require a non-empty shared form, and XMR requires something to survive. `null-baseline` and `identity` are registered as permanent controls: a control that is meant to fail is the only thing that proves a metric can fail, and a test asserts both score zero. **Coverage had no paired cost.** `corruption-cost` measures the other direction — code points destroyed, characters retained, injectivity, and alteration of pure ASCII that has nothing to fix. Labelled corpora get the same axis free from their own `clean` column, which is by the corpus author's definition text needing no repair. Both ends are reported with the surface names at each end, and key builders are separated: `sort_key` and `catalog_key` are many-to-one by contract, and counting that as damage would rank a tool with no key builder as safer. Every run now records its method — subject, domain, predicates invoked, parameters, the sha256 of the artifact read, and the Unicode/UCD versions. The baseline is keyed by subject as well as suite and carries the table versions, so a UCD bump no longer reads as a regression in the tool. Review findings from #875, each with a test that fails without the fix: - `report.fmt()` treated `of=0` as "no denominator", hiding exactly the empty-population case the report exists to show. - Two tests depended on some registry suite happening to be unavailable, so a populated `$DISARM_META_CACHE` would fail them. They now use an explicit always-missing double. - `manufactured_from_fullwidth` read a cumulative counter inside the row loop, so once one row fired every later row counted too. It was also never reported. Also fixed: `ucd-scripts` counted unassigned code points in denominators that excluded them, so agree + contradict + silent did not sum to the population. Refs #732, #736, #759 Signed-off-by: Richard Quinn <quinn.richard@gmail.com> Assisted-by: Claude:claude-opus-5[1m]
#873 changed which fold targets are themselves sources, which moves five measurements across the confusable suites. All five are inside the noise floor, so the drift table reported them and flagged none — but the baseline should come from the tree it ships with rather than from the one before the rebase. Signed-off-by: Richard Quinn <quinn.richard@gmail.com> Assisted-by: Claude:claude-opus-5[1m]
…e battery can carry a rank **Provisioning.** Twenty-one suites were unrunnable because every academic corpus was declared a manual download. The argument for that — copying an attack corpus into this repository would make it disarm's corpus — is about *vendoring* and does not reach *caching*. A file pulled from its upstream URL into a scratch directory is still the upstream's corpus. The conflation cost twenty-one suites for no benefit. A run now provisions what the selection needs before any suite executes, and leaves untouched anything already on disk, so an operator who placed a specific revision keeps it. `--offline` never reaches the network; `--refresh` forces a re-fetch. Every download is recorded in a manifest with URL, sha256, size and licence, so a figure can be traced to the bytes it came from. Nothing fetched is ever committed. Six upstreams were verified to exist before being wired, rather than assumed: - confusable-bench.v1 (namespace-guard, MIT) — 140 rows, 120/20, matching #736 - confusable-vision weights v2 (CC-BY-4.0) — 4,174 pairs, matching its own meta - Trojan Source PoC files (MIT) — 311 real source files, not one-line fragments - untrace testdata (MIT) — 64 techniques under the rival's own taxonomy - UCD DerivedCoreProperties.txt — 405 Default_Ignorable, matching #770 - ICANN Latin second-level LGR Suites with no *verified* upstream stay manual and say so. Inventing a URL would imply a download nobody has shown to exist. **An empty parse is now an error.** `icann-lgr-latin` reported `blocked_pairs: 0` while its two-table join was wrong, and 0/0 reads as perfect agreement rather than as a fault. A present artifact yielding nothing is a parser fault. That join is still unfinished, so the suite errors honestly instead of scoring. **Leaderboard.** Composite of discrimination-weighted z-scores, every step a named method: corrected item-total correlation for the weights (classical test theory — Crocker & Algina ch. 14), item parcelling within a suite (Little et al. 2002), Bradley-Terry by Hunter's MM algorithm (2004) for a rank that uses only pairwise order, Cronbach's alpha (1951) and Kendall's W for whether the battery is coherent at all, and bootstrap intervals over the benchmark set. IRT is the method of record for discrimination weighting (Rodriguez et al., ACL 2021) and is deliberately not fitted: single-digit respondents cannot support 2PL estimates, and fitting one would look more rigorous while being less so. Two distortions found and fixed while building it. `corruption-cost` supplied seven near-duplicate items at r≈0.945 and took seven times the weight — hence parcelling. And letting `null-baseline` into the standardisation compressed every real tool toward the mean, so the scale is fitted on the tools and the controls are placed on it. **The leaderboard refuses to publish.** On the current battery: 3 directed benchmarks against a floor of 5, Cronbach's alpha 0.05 against the conventional 0.70 (Nunnally 1978), and no two subjects with non-overlapping intervals. All three interlocks fire, no ranking is published, and the composites are printed only so the shortfall is auditable. A leaderboard that cannot fail is not a measurement. Refs #732, #736, #759 Signed-off-by: Richard Quinn <quinn.richard@gmail.com> Assisted-by: Claude:claude-opus-5[1m]
Subject identity was the bare tool name, so two builds of one tool would have collided everywhere it mattered: overwriting each other in the baseline, sharing a column in the comparison table, and being averaged together in the leaderboard. `disarm@0.14.1` against `disarm@0.15.0` is the comparison most worth making here, and it was the one the harness could not express. Identity is now `name@version` throughout — baseline keys, leaderboard subjects, comparison columns, drift rows, the method record and every rendered heading. A test asserts no report can name a tool without its version. A compiled extension cannot be imported twice in one process, so two builds cannot both be live. `--merge` folds earlier JSON runs into the comparison and the leaderboard, which is how each version is measured in its own worktree and then ranked against the other. Also in this change: the ICANN LGR suite now parses. Its pairs live in per-set Variant Set tables that the repertoire table only references by name, so it is a join and not a scan, and the column layout differs between sets. It recovers 21 of the 23 pairs #831 counts. The shortfall is reported as its own measurement rather than tuned away — a denominator that quietly disagrees with the issue it cites is the exact failure this harness exists to catch, and the two missing rows are most likely continuation rows where a mapping spans two `<tr>`s. Signed-off-by: Richard Quinn <quinn.richard@gmail.com> Assisted-by: Claude:claude-opus-5[1m]
`disarm` was the only subject with a detect capability, so every detector suite was locked to it and no detection question had a second column. A benchmark with one participant is a description, not a comparison. - `confusable-homoglyphs` (MIT) — detects confusable and mixed-script identifiers from UTS #39 data. The second detector in the registry. - `pyunormalize` (MIT) — NFC/NFD/NFKC/NFKD against its own bundled UCD 17.0.0, while the interpreter's `unicodedata` is 16.0.0. It isolates *table version* from *algorithm*, the one variable the stdlib column cannot vary. - `icu` (PyICU) — `SpoofChecker` implements UTS #39 directly and `Transliterator` covers romanization, which makes it the most informative column available: not another tool but the standard's own implementation. It needs the ICU C++ headers and is not installed here, so it registers as unavailable with the install hint. A missing reference implementation should be visible as missing rather than absent from the list. `uax29-word-joiners` becomes multi-subject on the back of that, taking the battery from three benchmarks to four. It asks two separable questions, so capability handling gained "at least one of" semantics: a subject answers the half it has and the other half is *omitted*, never reported as zero. `confusable-homoglyphs` detects 100% of fragmented words where disarm detects 59.5%, and shows no recovery column at all rather than a misleading 0%. The leaderboard still refuses: four benchmarks against a floor of five, alpha 0.06. Adding subjects cannot fix that — two of the three blockers are about the number and coherence of *benchmarks*, not the number of tools. Signed-off-by: Richard Quinn <quinn.richard@gmail.com> Assisted-by: Claude:claude-opus-5[1m]
…f the composite Two ranking problems, both visible the moment more subjects existed. **A subject measured on one benchmark ranked above subjects measured on four.** `confusable-homoglyphs` detects and does not transform, so it participates in a single suite — and came first, because answering fewer questions is not the same as answering them better. Subjects covering less than 75% of the battery are now listed with their coverage and kept out of the ordering entirely. **Every benchmark now carries its own ranking.** The composite needs the benchmarks to measure one construct before averaging them, which is exactly the assumption Cronbach's alpha says this battery fails. Ranking within a single benchmark carries no such assumption, so those tables stand whether or not the composite does — and while the composite is blocked, they are the result. Equal scores share a rank, and a subject absent from a table was not asked that question rather than having scored zero. The composite still refuses: four benchmarks against a floor of five, alpha 0.06, no separated pairs. Signed-off-by: Richard Quinn <quinn.richard@gmail.com> Assisted-by: Claude:claude-opus-5[1m]
**`retention` could exceed 100%.** It was `chars_out / chars_in`, so a transliterator mapping one code point to several ASCII characters — `¼` to `1/4`, a CJK ideograph to a syllable — scored 102.6% "retention", which is not a thing. Split into three honest numbers: `length_ratio` (out over in, may exceed 1 and named so it cannot be read as retention), a true `retention` (multiset intersection, bounded at 1, so characters a tool *adds* cannot inflate it), and `max_expansion`. Expansion earns its own measurement because it is a finding here, not a curiosity: #768 measured 18x amplification with no ceiling and #747 found presets manufacturing delimiters the input never contained. `anyascii` reads 102.5% length against 90.7% actual retention, peaking at 3x. **A parcel averaged whatever measurements a subject happened to have.** `unidecode` outranked `disarm` on the word-joiner benchmark while recovering 24.3% to disarm's 43.2% — disarm's average also carried a detection score, and `unidecode` has no detector to be scored on. Subjects answering only part of a benchmark are now listed after its ordering rather than inside it, the same rule already applied one level up for partial battery coverage. **A lower-is-better row read backwards.** The comparison table printed bare percentages, so `unreached` 34.1% next to 44.5% looked like a loss when it is the best score on the row. Directed rows now carry ↑ or ↓, the winning cell is bold, and a census row carries no arrow because it has no better end. Also: `disarm`'s subject identity now carries the build commit — `0.14.1+g5ff5582` — with `.dirty` for an uncommitted tree. `__version__` only moves at release, so every build between two releases reports the older number, and a row reading `disarm@0.14.1` while the extension carries post-0.14.1 code is exactly the mislabelling the versioned-identity rule exists to prevent. Signed-off-by: Richard Quinn <quinn.richard@gmail.com> Assisted-by: Claude:claude-opus-5[1m]
`identity` was marked as the best value on `altered_but_not_onto_target` with 0.0%, because a tool that never alters anything trivially wins a row scored on altering wrongly. `null-baseline` would take any row scored on leaving things unfolded, by leaving nothing at all. Presenting either as the winner puts the degenerate answer forward as the target — the same failure the non-empty collision rule fixed, resurfacing one layer up in the report. Controls are now excluded from the best-cell calculation, from the composite ordering and from every per-benchmark ranking. Their values stay visible, because a reference line is the point of having them: `identity` sitting above `disarm` on corruption cost is exactly the comparison that axis exists to make. They just cannot occupy a position that asserts they beat something. Two related corrections in the same pass. Partially-measured subjects were already kept out of the ordering but still printed a numeric rank; they now print an em-dash like controls. And a benchmark with fewer than two fully-answered subjects no longer prints an ordering at all — `uax29-word-joiners` is answered in full only by `disarm`, and "1st of 1" dresses up a benchmark nobody else could be asked. With controls out of the ordering, the composite reads disarm first of eight ranked tools. The battery still refuses to publish it: four benchmarks against a floor of five, alpha 0.06, no separated pairs. Signed-off-by: Richard Quinn <quinn.richard@gmail.com> Assisted-by: Claude:claude-opus-5[1m]
…arks agree The composite was gated on Cronbach's alpha, which asks whether the benchmarks measure one construct. This battery is not built to: coverage and cost are deliberately opposed axes, so a tool that folds more will alter more, and alpha *should* be low. A unidimensional psychometric gate was being applied to a multidimensional measurement problem. Changing the test does not rescue it. Friedman's test — the right diagnostic for a rank aggregation, and one that assumes no common construct — refuses too: chi-square 4.75 against a 0.05 critical value of 14.07 at 7 degrees of freedom, Kendall's W 0.226. The benchmarks genuinely disagree. `ftfy` places 1st, 1st, 7th; `anyascii` places 8th, 6th, 2nd. Tools that preserve text win on cost and lose on class closure; tools that fold aggressively do the reverse. That disagreement is the finding, not a defect, and it has a standard answer. **Pareto dominance** ranks without weighting and without assuming one construct: a tool is on the frontier when nothing beats it on every axis at once. On the current battery four tools are non-dominated — `decancer`, `disarm`, `ftfy`, `stdlib` — and four are strictly beaten: `anyascii` and `unidecode` by `disarm`, `pyunormalize` by `ftfy`, `text-unidecode` by both. It is a partial order rather than a league table, which is the honest shape of the result. The report now also prints how many benchmarks the observed agreement would need to reach significance: **9**, against the 3 available. Friedman's chi-square is k(n-1)W, so this is linear in the benchmark count — and more *tools* make it harder, not easier, by raising the degrees of freedom. Ten attack-corpus suites are already multi-subject and waiting only on corpus data, so the route to a significant overall ranking is provisioning them rather than adding comparators. Signed-off-by: Richard Quinn <quinn.richard@gmail.com> Assisted-by: Claude:claude-opus-5[1m]
Two accounting biases on the two axes disarm scores highest on, both in its own favour, on its own benchmark. **The surfaces that earned the coverage were exempt from the cost.** `search_key`, `catalog_key` and `sort_key` are inside `PRESETS`, so `transforms()` returned them and the coverage axis scored with them — while the cost axis removed exactly those three via `split_by_intent`. A library that ships key builders collected their coverage for free. The comment there reasoned about the opposite bias, which is also real, and the fix applied produced the inverse. Measured: 1.2 points of the confusable headline was coverage only a key builder earned. **Coverage was a union over every surface a subject happens to expose.** The existential asked "did any of your N entry points get this pair", which rewards shipping many rather than shipping good. disarm exposes 19 transforms against one to five for every other tool, and gained 4.9 points from the union that no other subject could earn — none of them has enough surfaces for a union to differ from its best one. Both axes now score the best *single* non-key surface, which also makes coverage symmetric with cost — the cost side was already per-surface, and that asymmetry is what let the two sets diverge. Key builders are scored in their own role, in their own measurement, where merging is the contract rather than a cost. The winning surface is named and the number of surfaces each subject was allowed is reported, so a reader can see that disarm's score came from one entry point out of thirteen while ftfy's came from one out of two. disarm's confusable coverage moves 65.9% to 61.2% and its class closure 60.7% to 56.6%. It stays on the Pareto frontier; `ftfy` and `pyunormalize` now edge it on confusables. Two more found on the way: - `library_catalog_key_eu`, `search_index` and `scholarly_cyrillic_iso9` are key builders that live among the profiles, so excluding only the three top-level key functions left them scored as text surfaces. `library_catalog_key_eu` was the single most destructive "text" surface in the corruption census, which is precisely what a catalog key should look like. - Raw retention charged a sanitizer for removing private-use, format and control code points, which is the one thing it exists to do. 93.5% of disarm's measured "damage" was Private Use Area removal. The scored measure is now identity retention — letters and symbols only — with raw retention kept as a census. - A surface count of 1 rendered as "100.0%" in the comparison table: the cell formatter could not tell a proportion from a small integer. Signed-off-by: Richard Quinn <quinn.richard@gmail.com> Assisted-by: Claude:claude-opus-5[1m]
Turning the union into a max was the same effect, quieter. Measured over 938 UTS #39 pairs, disarm's thirteen non-key surfaces run: profile:llm_guardrail 58.0% canonicalize 53.4% strip_obfuscation 57.1% profile:rag_ingest 45.7% canonicalize_strict 53.5% ml_normalize 39.1% normalize_user_input 53.5% strip_format 0.0% security_clean 53.4% profile:code_context 0.0% Removing the union took off 4.7 points. A further 4.6 remained: the gap between "your best of thirteen" and the one a reader would actually reach for. Every other tool draws from one or two. Three things the spread showed. The winner was `llm_guardrail` — a ten-step application pipeline nobody reaches for to clean a username, scoring a general confusable-coverage axis. Thirteen surfaces are not thirteen capabilities: five score identically because they share one fold and three do not fold confusables at all, so there are about four distinct behaviours. And the asymmetry survived one level down — coverage was a max over surfaces while cost averaged a *worst* and a *gentlest*, so the surface earning the coverage never paid its own cost. Coverage came from `llm_guardrail` while cost averaged `rag_ingest` and `code_context`. The published point described a configuration nobody could deploy. Each subject now declares one surface per role before the run, and coverage and cost are both measured on it: role disarm ftfy unidecode stdlib sanitizer canonicalize fix_text unidecode NFKC key search_key — — — detector is_confusable — — — `canonicalize` because it is the documented general-purpose comparison form — the entry point a reader arrives at, not the one that wins. What best-of-N would have added is now a reported census per subject rather than a disclosed surface count, so the selection effect is a measured line like the other three biases: +3.7 points for disarm, +28.5 for ftfy, whose coverage was coming from `fix_text_NFKC` rather than its documented `fix_text`. Best-of-N does answer a real question — the most a library can do for you — but it is not the question this page asks, and it assumes a reader who already knows which of thirteen surfaces to pick, which is the problem the library exists to solve. The worst and gentlest surfaces are still reported as censuses, because the range a library offers is real information; they are simply not the score. Signed-off-by: Richard Quinn <quinn.richard@gmail.com> Assisted-by: Claude:claude-opus-5[1m]
b64d7e2 to
cfbb6d2
Compare
disarm's confusable resolution takes two parameters and I chose neither. Both change the result, both were left at their defaults, and neither was recorded. **target_script defaults to "latin".** Of the 6,565 single-source UTS #39 pairs, only 1,968 (30.0%) have a Latin target: 21.2% target CJK, 14.2% Arabic, 6.1% Hangul. So 70% of the denominator asks a Latin-targeting fold to produce a target it does not aim at, and whatever coverage it gets there comes from the NFKC step rather than the fold. `folded_latin_target` is now reported beside the whole table, and it changes the reading: on the subset a Latin fold is actually aimed at, `decancer` leads at 79.9% and disarm is second at 69.4%, where on the full table disarm leads at 57.5%. disarm accepts latin, cyrillic, arabic and hebrew, and rejects greek — though 159 pairs in the table target Greek. **digit_policy defaults to "numeric"** and differs from "tr39" on 45 code points, in opposite directions: U+0660 ARABIC-INDIC DIGIT ZERO folds to `0` under numeric and to `.` under tr39. Scoring against the TR39 table with disarm's own policy costs it 0.7 points (27.1% against 27.8%), so this inherited default understates it rather than flattering it. The finding underneath both: **neither knob is reachable from the surface being scored.** `canonicalize()` takes no arguments, so the configurable fold lives on `normalize_confusables()`, which is not the entry point a reader arrives at. The method record now carries the whole configuration — target script, digit policy, whether the scored surface exposes them, and where the alternatives live. Signed-off-by: Richard Quinn <quinn.richard@gmail.com> Assisted-by: Claude:claude-opus-5[1m]
Every other measurement of the fold scores one target script against the whole UTS #39 table, where 70% of the pairs resolve somewhere it does not aim — so what it measures there is the NFKC step, not the fold. This asks the fair question instead: of the pairs that resolve TO Arabic, how many does the Arabic target reach? target pairs in table resolved by its own profile latin 1,968 (30.0%) 1,373 = 69.8% cyrillic 36 ( 0.5%) 22 = 61.1% arabic 935 (14.2%) 136 = 14.5% hebrew 24 ( 0.4%) 4 = 16.7% greek 159 ( 2.4%) REJECTED #792 added the Arabic and Hebrew targets because intra-RTL confusables had no representation in either shipped table. Measured on their own terms they reach 14.5% and 16.7% — which corroborates #791 (whole equivalence classes dropped when no member is in the target script, 948 of 1,007 strong-RTL sources among them) and #848 (a class whose members are all in the target script is discarded by construction, the keheh/kaf case). Greek is rejected while carrying 159 pairs — more than Cyrillic and Hebrew combined, both of which are supported. The rejection message is also stale: it reads "target_script must be 'latin' or 'cyrillic'" and does not name the two targets #792 added. Pairs are partitioned by the UCD name of the target's first character, which is external. Partitioning with `detect_scripts` would use disarm's own table to decide what disarm's own table should cover. The suite is disarm-locked and says so: no other tool in the registry has a target-script parameter, so this scores four configurations of one library rather than comparing several. Signed-off-by: Richard Quinn <quinn.richard@gmail.com> Assisted-by: Claude:claude-opus-5[1m]
Summary
The 0.15.0 cycle found and verified defects against roughly thirty artifacts produced
outside this repository. Each measurement lived in its own script, recorded as a gist on
the issue it produced, and nothing re-ran any of them. #732 states the consequence
directly: one public corpus produced five findings and nothing in the repo will notice
when any of them moves.
benchmarks/metaconsolidates the selection, the provenance record, the scoring protocoland the report into one harness that runs n of m.
35 benchmarks across six external tiers, plus a seventh that is marked as not external.
normativecveacademicdatasetmodeltokenizer.jsonfilescomparatorconfusable-bench.v1, confusable-vision, untraceWhy every benchmark is somebody else's
A library that writes its own benchmark grades its own homework. The harness supplies the
runner, the selection, the provenance record and the report. It supplies no vectors.
That boundary is checked rather than promised.
Provenance.externalis a field, therunner tags every outcome with it, the report renders external and introspective results
in separate sections, and a test asserts that no external suite is scored against a file
disarm generated.
The last one is not hypothetical.
data/confusables_lgr.tsvlooks like an ideal localoracle for the ICANN suite — an extract of ICANN's LGR, already in the repo, no network
needed. It is also the file the shipped fold was built from, so scoring the fold against
it returns 100% by construction. It read exactly that way before the fallback was removed.
The suite now requires ICANN's published LGR or reports nothing.
The #39/#40 guardrail carries over unchanged: these corpora are measuring instruments,
never optimization targets.
The introspective tier
Four sweeps reproduce real findings (#719, #723/#751, #805/#806/#807, #834) and are not
benchmarks. The distinction is not where the input comes from — the code-point domain is
the UCD — but whether an outside authority decides the right answer. For these, disarm is
both the thing measured and the only oracle, so a number moving proves nothing on its own.
They are registered so the 0.15.0 record has no hole, marked
external=False, excludedunless
--include-introspectiveis passed, and never folded into an external total.What a run reports
Three things per suite, kept apart on purpose:
fresh run.
The gap between the first and the third is the most useful column, and several already
show one. UTS #39 §5.3 was unimplemented when #777 was filed and now reports 75 of 75
numbering systems.
has_bidi_conflictwas neutral to 1,786 of 3,018 strong-RTL codepoints under #773 and now reaches all 3,018.
Skips, and drift
A suite that cannot find its artifact returns
SKIPPEDwith the variable to set, and thereport lists every one. An absent corpus is not a passing corpus.
Most academic corpora are deliberately not vendored: copying an attack corpus into this
repository would make it disarm's corpus, and a corpus disarm owns is a corpus disarm can
be tuned against.
Drift is reported and never gated — a moved number never fails a run; only a suite that
threw does, because that is a harness defect rather than a result. Baselines are keyed by
suite and population, because a ratio over 4,000 code points and one over 150,000 are
not comparable; the report marks such a row rather than subtracting it.
--limitsamplesby stride rather than truncating, since the first N code points of any sorted domain are
Latin, Greek and Cyrillic — the best-covered part of every table here.
Also fixed
Both found by the harness against its own output:
ucd-scriptscounted unassigned code points in its denominators, so agree + contradictbenchmarks/adversarial_eval/corpora.pytyped_guess_text_columnas takinglist[str]while both call sites passcsv.DictReader.fieldnames, aSequence[str].Verification
pytest tests/test_meta_benchmark.py tests/test_adversarial_eval.py— 37 passedruff check ./ruff format --check .— cleanmypy benchmarks/meta --ignore-missing-imports— clean (16 files)python -m benchmarks.meta --run --only-available --include-introspective— 14 suitesran, 0 errors, exit 0; introspective sweeps over all 294,579 assigned code points
perturbed baseline produces the expected row with the right direction
Refs #732, #736, #759
🤖 Generated with Claude Code