Skip to content

feat: on_empty, so absence and a stripped value stop sharing a key (#728) - #943

Merged
raeq merged 4 commits into
mainfrom
feat/728-empty-key
Sep 3, 2026
Merged

feat: on_empty, so absence and a stripped value stop sharing a key (#728)#943
raeq merged 4 commits into
mainfrom
feat/728-empty-key

Conversation

@raeq

@raeq raeq commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Summary

Every preset and key builder maps some non-empty input to "", so a value that was entirely stripped is indistinguishable from one that was never there. sanitize_filename was the only surface that guarded it, with the _ sentinel from #485.

Re-measured over the 292,531 assigned code points (unchanged by #937):

surface "" excluding PUA
slugify 249,175 111,707
strip_obfuscation 140,251 2,783
search_key 139,921 2,453
catalog_key 139,918 2,450
sort_key 138,404 936
canonicalize / _strict / skeleton_key 137,955 487
ml_normalize 4,110 4,110
sanitize_filename 0 0

A caller storing search_key(username) as a uniqueness key has 2,453 non-PUA code points, every string built from them, and "no username" competing for one slot.

This is not the homoglyph collision the key builders exist to produce — аdmin and admin should meet. It collapses absence onto a value, which arXiv:2608.06508v1 §2.2 calls code-side semantic collapse and §7.5 says is fixed by moving the sentinel outside the value range.

on_empty applies only when the input was non-empty

That is the whole point, and my first cut got it wrong: it substituted for an empty input too, which put absence and a stripped value straight back into one slot — the collision this exists to break. Measuring caught it before it shipped.

search_key("", on_empty=NUL)         # ''   — absence keeps its own key
search_key("​", on_empty=NUL)   # NUL  — stripped to nothing

The three stripped values still share a key, and that is correct: they are all input that reduced to nothing, one fact rather than three. What changes is that absence is no longer one of them.

The sentinel is the caller's to choose and disarm cannot check it — search_key("​", on_empty="admin") equals search_key("admin"). Documented and asserted, rather than pretended away.

Item 4: the census is frozen

Nine docstrings now carry a number, and a number nobody re-measures stops being right the first time a strip class widens. tests/test_empty_key.py measures all ten surfaces over the full assigned codespace and fails with a message naming what to update.

Scope

Python-only for now, as digit_policy was in #885. It is a post-pass on the output — the answer is a property of the result, not of any transform — and the Rust and binding half belongs with #896 rather than beside it, where it would collide with that work.

Two house rules I tripped

.. warning:: renders as those exact characters on the site, so the note is bold prose like has_anomalies'. And the test's invisible literals are named escapes (#802).

Verification

  • cargo fmt --check, clippy -D warnings, cargo doc --no-deps, perf_lint.sh — clean
  • pytest -n 8 --dist loadfile7,179 passed, 42 skipped (31 new)
  • mypy, ruff, mkdocs build --strict, run_doc_tests.py 41/41 — clean

Closes #728

Copilot AI lite review requested due to automatic review settings September 3, 2026 13:18
@raeq
raeq force-pushed the feat/728-empty-key branch from 1fbb4f3 to 80a740c Compare September 3, 2026 13:21
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

📄 Docs preview: https://a79800df.disarm-docs.pages.dev

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

A few newly added docs/tests contain inaccurate or misleading wording (notably around the “docstrings claim” census test and sentinel guarantees) that should be corrected before merge.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds a Python-only on_empty option to the key-builder APIs so an absent value ("" input) no longer shares the same key as a non-empty input that strips to empty, addressing the “semantic collapse” reported in #728. This keeps current defaults unchanged while letting callers opt into a sentinel for “stripped-to-nothing” outputs, and it freezes the documented “empty output” counts via a Unicode census test.

Changes:

  • Add on_empty: str | None to search_key, catalog_key, sort_key, and skeleton_key, applied as a post-pass only when the input was non-empty.
  • Document the empty-output case (with frozen counts) across the relevant docstrings and limitations docs.
  • Add a full assigned-codepoint sweep test that asserts the frozen “empty key” census.
File summaries
File Description
tests/test_empty_key.py New census + behavioral tests for empty-key surfaces and on_empty semantics.
tests/test_api_stability.py Updates expected parameter lists to include on_empty on the four key builders.
python/disarm/_presets.py Introduces _EMPTY_KEY_CENSUS, _on_empty() helper, and threads on_empty into key builders; adds empty-output docstring notes.
python/disarm/_api.py Adds empty-output documentation note to slugify.
docs/limitations.md Documents the empty-key pitfall, shows on_empty usage, and adds a warning about sentinel choice.
CHANGELOG.md Changelog entry describing on_empty and the frozen census.
Review details

Suppressed comments (3)

tests/test_empty_key.py:37

  • The comment says the sentinel is something no builder can produce, but these builders will produce it when on_empty=SENTINEL is passed (by design). Reword this to make it clear the claim is only about the default output space (without on_empty), or avoid asserting an unverified property in a comment.
#: A sentinel no builder can produce: it is stripped by all of them.
SENTINEL = "␀"  # SYMBOL FOR NULL

tests/test_empty_key.py:74

  • The failure message says "docstring and census say" but the test does not read the docstrings, so this can mislead when debugging failures. Consider updating the message to reference the census only (it can still instruct updating docstrings as a follow-up action).
    assert (total, nonpua) == (total_want, nonpua_want), (
        f"{name}: measured {total:,}/{nonpua:,}, docstring and census say "
        f"{total_want:,}/{nonpua_want:,}. If this is a deliberate change, update "
        "_EMPTY_KEY_CENSUS and the docstring in the same commit."

docs/limitations.md:643

  • Grammar: this line is missing a word and reads awkwardly ("A value a real input also keys to …").
!!! warning "The sentinel is yours to choose, and disarm cannot check it"
    A value a real input also keys to reintroduces the collision one step over:
    `search_key("\u200b", on_empty="admin")` equals `search_key("admin")`. Pick something
  • Files reviewed: 6/6 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 docs/limitations.md
Comment thread tests/test_empty_key.py Outdated
Comment thread tests/test_empty_key.py Outdated
raeq added a commit that referenced this pull request Sep 3, 2026
…ed for (#728)

Copilot review on #943, three findings, all correct.

The sharpest: `test_the_census_is_what_the_docstrings_claim` read only
`_EMPTY_KEY_CENSUS`, so the nine docstring numbers and the table could drift
with the gate green. The name promised a check the body did not make. It is
now two tests, one per half: the table is measured against the library, and
each docstring is parsed for the two numbers it states and compared to the
table. Both halves together make a docstring number a measurement rather than
a claim. Checked that the parser catches a moved number and a deleted one.

The other two are wording that described the world before this PR. The module
docstring said `sanitize_filename` "is the only surface that guards it", which
stopped being true in the same change; and a code comment called the example
sentinel "outside the range any key builder produces" three lines above a
warning saying disarm cannot check that. It now says what it can honestly say:
a value you have verified none of your inputs keys to.

Refs #728

Assisted-by: Claude Opus 5 (1M context)
Signed-off-by: Richard Quinn <quinn.richard@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@raeq

raeq commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

CI caught a real one: the census was measured on a Unicode 16.0.0 host and CI's 3.12 is 15.1.0, so the three surfaces that reach "" through transliteration were high by exactly the 16.0 additions — 51 on search_key, 63 on ml_normalize. Same skew #815 hit, same fix.

Pinned at 15.1.0 (the version CI runs) with the pin as a named constant. Re-measured under a 15.1.0 interpreter: all ten surfaces match CI's numbers exactly. The gate compares exactly on the pin and asserts a lower bound on any newer host — a newer UCD only adds code points, so a shrinking count still fails — so neither branch is a skip.

The docstring-parse gate from the last round earned its keep immediately: my first re-numbering pass gave docstrings each other's numbers, and it caught that before push.

@raeq

raeq commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

The gate refused a second time, as designed, and the message named the reason: CI's 3.12 ships Unicode 15.0.0, and I had pinned at 15.1.0 from a 3.13 host on the assumption they matched.

Installed 3.12 and measured rather than assumed. Nine surfaces are identical across the two versions; slugify is 627 lower at 15.0.0 — every 15.1 addition it drops. Assuming equality would have failed the gate a third time on that surface alone.

Pinned at 15.0.0, the oldest any CI interpreter runs, with the constant's comment listing what each supported CPython ships. Exact branch verified under 3.12, every surface matching.

raeq and others added 4 commits September 3, 2026 16:45
)

Every preset and key builder maps some non-empty input to `""`, so a value that
was entirely stripped is indistinguishable from one that was never there.
`sanitize_filename` was the only surface that guarded it, with the `_` sentinel
from #485.

Re-measured over the 292,531 assigned code points, unchanged by #937:

    slugify             249,175   (111,707 excluding PUA)
    strip_obfuscation   140,251     (2,783)
    search_key          139,921     (2,453)
    catalog_key         139,918     (2,450)
    sort_key            138,404       (936)
    canonicalize        137,955       (487)   also _strict and skeleton_key
    ml_normalize          4,110     (4,110)
    sanitize_filename         0         (0)   returns `_`

A caller storing `search_key(username)` as a uniqueness key has 2,453 non-PUA
code points, every string built from them, and "no username" competing for one
slot. First writer takes it; everyone after collides with a record that is not a
user.

Not the homoglyph collision the key builders exist to produce — `аdmin` and
`admin` SHOULD meet. This collapses absence onto a value, which
arXiv:2608.06508v1 §2.2 calls code-side semantic collapse and §7.5 says is fixed
by moving the sentinel outside the value range rather than by normalizing harder.

`on_empty` applies ONLY when the input was non-empty, and that is the whole
point. My first cut substituted for an empty input too, which put absence and a
stripped value straight back in one slot — the collision this exists to break.
Measuring caught it. `search_key("")` is `""` with or without the parameter;
`search_key(ZWSP)` is the sentinel. The three stripped values still share a key,
which is correct: they are all input that reduced to nothing, one fact rather
than three.

The sentinel is the caller's to choose and disarm cannot check it — a value a
real input also keys to reintroduces the collision one step over. Documented,
and asserted, rather than pretended away.

Item 4: the census is frozen in `tests/test_empty_key.py`. Nine docstrings now
carry a number, and a number nobody re-measures stops being right the first time
a strip class widens.

Python-only for now, as `digit_policy` was in #885. It is a post-pass on the
output — the answer is a property of the result, not of any transform — and the
Rust and binding half belongs with #896 rather than beside it.

Two house rules I tripped and fixed: `.. warning::` renders as those characters
on the site, so the note is bold prose like `has_anomalies`'; and the test's
invisible literals are named escapes (#802).

Closes #728

Assisted-by: Claude Opus 5 (1M context)
Signed-off-by: Richard Quinn <quinn.richard@gmail.com>
…ed for (#728)

Copilot review on #943, three findings, all correct.

The sharpest: `test_the_census_is_what_the_docstrings_claim` read only
`_EMPTY_KEY_CENSUS`, so the nine docstring numbers and the table could drift
with the gate green. The name promised a check the body did not make. It is
now two tests, one per half: the table is measured against the library, and
each docstring is parsed for the two numbers it states and compared to the
table. Both halves together make a docstring number a measurement rather than
a claim. Checked that the parser catches a moved number and a deleted one.

The other two are wording that described the world before this PR. The module
docstring said `sanitize_filename` "is the only surface that guards it", which
stopped being true in the same change; and a code comment called the example
sentinel "outside the range any key builder produces" three lines above a
warning saying disarm cannot check that. It now says what it can honestly say:
a value you have verified none of your inputs keys to.

Refs #728

Assisted-by: Claude Opus 5 (1M context)
Signed-off-by: Richard Quinn <quinn.richard@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
#728)

CI failed `test_the_census_matches_the_measurement` on three surfaces:

    search_key     measured 139,870/2,402   frozen 139,921/2,453
    catalog_key    measured 139,867/2,399   frozen 139,918/2,450
    ml_normalize   measured   4,047/4,047   frozen   4,110/4,110

Every difference is exactly the code points Unicode 16.0 assigned that the
surface takes to `""`. The gate enumerated "assigned" from the host's
`unicodedata`, and the host was 16.0.0 while CI's CPython 3.12 is 15.1.0 —
the same skew that bit #815's census, and the same fix.

The census is now pinned at 15.1.0, the version CI measures, and the pin is a
named constant beside the table. Re-measured under a 15.1.0 interpreter: every
one of the ten surfaces matches CI's numbers exactly. Surfaces that reach `""`
only by stripping — `canonicalize`, `sort_key`, `skeleton_key` — are identical
across the two versions; the ones that reach it through transliteration and the
confusable fold are not, which is why only three moved.

The gate compares exactly when the host is on the pinned version and asserts a
lower bound otherwise: a newer UCD only ever adds code points, so a count that
shrank means a surface stopped taking something to `""`. Neither branch is a
skip, and a test pins that the version comparison itself can fail.

The nine docstrings and the limitations table carry the 15.1.0 numbers and say
so. The docstring-parse gate from the last review round caught my first
re-numbering pass, which searched the whole file per surface and gave
docstrings each other's numbers — now scoped to each function's own docstring.

Refs #728

Assisted-by: Claude Opus 5 (1M context)
Signed-off-by: Richard Quinn <quinn.richard@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The gate refused, as designed, with a message naming both versions:

    host Unicode 15.0.0 is older than the pin 15.1.0

I pinned at 15.1.0 from a 3.13 host on the belief that 3.12 shipped the same
UCD. It ships 15.0.0. Installed 3.12 and measured rather than assumed: nine of
the ten surfaces are identical across 15.0.0 and 15.1.0, and `slugify` is 627
lower — 243,399 rather than 244,026 — which is every 15.1 addition it drops.
Assuming equality would have failed the gate a third time on that one surface.

Pinned at 15.0.0, the OLDEST version any CI interpreter runs; the constant's
comment lists what each supported CPython ships. Exact branch verified under
3.12 with every surface matching; the bound branch covers 3.13 and 3.14.

Refs #728

Assisted-by: Claude Opus 5 (1M context)
Signed-off-by: Richard Quinn <quinn.richard@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@raeq
raeq force-pushed the feat/728-empty-key branch from 21b4e5f to d505315 Compare September 3, 2026 14:46
@raeq
raeq merged commit 091b477 into main Sep 3, 2026
25 checks passed
@raeq
raeq deleted the feat/728-empty-key branch September 3, 2026 14:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants