Skip to content

feat: a meta-benchmark over the externally produced benchmarks 0.15.0 used - #875

Open
raeq wants to merge 14 commits into
mainfrom
bench/meta-harness
Open

feat: a meta-benchmark over the externally produced benchmarks 0.15.0 used#875
raeq wants to merge 14 commits into
mainfrom
bench/meta-harness

Conversation

@raeq

@raeq raeq commented Sep 1, 2026

Copy link
Copy Markdown
Owner

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/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                    # show m, and what is runnable
python -m benchmarks.meta --run --only-available    # run what the machine has
python -m benchmarks.meta --run --select 'uts39-*' --report out.md
python -m benchmarks.meta --run --sample 5 --seed 3 # a reproducible partial pass

35 benchmarks across six external tiers, plus a seventh that is marked as not external.

tier anchor suites
normative a table that defines the right answer UTS #39 confusables + §5.1 + §5.3 + equivalence classes, UCD Scripts.txt, UAX #9, UAX #29, DerivedCoreProperties, ICANN LGR, CLDR
cve a published vulnerability's own vector CVE-2026-17084 stringprep B.3 delta, CVE-2021-42574 PoC files
academic a corpus released with a paper Bad Characters, arXiv:2405.14490, special-char-attack, GCG suffixes, GAversary, smishing, XOXO, ESTI, backdoor triggers, canonicalization obligation
dataset a public corpus BitAbuse, YouTube-Spam, TREC-2007, MeAJOR, benign Gutenberg prose
model a released model artifact chat-template delimiters read from published tokenizer.json files
comparator a third-party labelled benchmark or rival tool confusable-bench.v1, confusable-vision, untrace

Why 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.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.

The last one is not hypothetical. data/confusables_lgr.tsv looks like an ideal local
oracle 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, excluded
unless --include-introspective is passed, and never folded into an external total.

What a run reports

Three things per suite, kept apart on purpose:

  • Found during the cycle — historical, quoted from the issue, never edited to match a
    fresh run.
  • How it is measured — methodology, true over time.
  • Measured now — this 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_conflict was neutral to 1,786 of 3,018 strong-RTL code
points under #773 and now reaches all 3,018.

Skips, and drift

A suite that cannot find its artifact returns SKIPPED with the variable to set, and the
report 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. --limit samples
by 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-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].

Verification

  • pytest tests/test_meta_benchmark.py tests/test_adversarial_eval.py — 37 passed
  • ruff check . / ruff format --check . — clean
  • mypy benchmarks/meta --ignore-missing-imports — clean (16 files)
  • python -m benchmarks.meta --run --only-available --include-introspective — 14 suites
    ran, 0 errors, exit 0; introspective sweeps over all 294,579 assigned code points
  • Drift verified in both directions: no rows against the committed baseline, and a
    perturbed baseline produces the expected row with the right direction
  • No Rust changed, so the Rust and bindings gates are not implicated

Refs #732, #736, #759

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings September 1, 2026 16:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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/meta CLI + library: suite registry/selection (n of m), 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_any is incremented based on per_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 to absent[: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.

Comment thread benchmarks/meta/report.py Outdated
Comment thread tests/test_meta_benchmark.py Outdated
Comment thread benchmarks/meta/suites/model_artifacts.py Outdated
github-actions Bot added a commit that referenced this pull request Sep 1, 2026
Signed-off-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
raeq added a commit that referenced this pull request Sep 1, 2026
…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]
@raeq
raeq force-pushed the bench/meta-harness branch from 58373ad to 2b63c16 Compare September 1, 2026 16:36
raeq added a commit that referenced this pull request Sep 1, 2026
…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]
github-actions Bot added a commit that referenced this pull request Sep 1, 2026
Signed-off-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
@raeq
raeq force-pushed the bench/meta-harness branch from 2b3e3fc to 7440af0 Compare September 1, 2026 17:11
raeq added a commit that referenced this pull request Sep 1, 2026
…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]
Comment thread benchmarks/meta/fetch.py
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
github-actions Bot added a commit that referenced this pull request Sep 1, 2026
Signed-off-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
github-actions Bot added a commit that referenced this pull request Sep 1, 2026
Signed-off-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
github-actions Bot added a commit that referenced this pull request Sep 1, 2026
Signed-off-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
raeq added 12 commits September 1, 2026 20:30
… 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]
@raeq
raeq force-pushed the bench/meta-harness branch from b64d7e2 to cfbb6d2 Compare September 1, 2026 18:32
github-actions Bot added a commit that referenced this pull request Sep 1, 2026
Signed-off-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
raeq added 2 commits September 1, 2026 20:54
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]
github-actions Bot added a commit that referenced this pull request Sep 1, 2026
Signed-off-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants