feat: on_empty, so absence and a stripped value stop sharing a key (#728) - #943
Conversation
1fbb4f3 to
80a740c
Compare
|
📄 Docs preview: https://a79800df.disarm-docs.pages.dev |
There was a problem hiding this comment.
🟡 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 | Nonetosearch_key,catalog_key,sort_key, andskeleton_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=SENTINELis passed (by design). Reword this to make it clear the claim is only about the default output space (withouton_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.
…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>
|
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 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. |
|
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; 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. |
) 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>
21b4e5f to
d505315
Compare
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_filenamewas the only surface that guarded it, with the_sentinel from #485.Re-measured over the 292,531 assigned code points (unchanged by #937):
""slugifystrip_obfuscationsearch_keycatalog_keysort_keycanonicalize/_strict/skeleton_keyml_normalizesanitize_filenameA 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 —
аdminandadminshould 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_emptyapplies only when the input was non-emptyThat 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.
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")equalssearch_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.pymeasures all ten surfaces over the full assigned codespace and fails with a message naming what to update.Scope
Python-only for now, as
digit_policywas 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 likehas_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— cleanpytest -n 8 --dist loadfile— 7,179 passed, 42 skipped (31 new)mypy,ruff,mkdocs build --strict,run_doc_tests.py41/41 — cleanCloses #728