Fix encode/decode correctness bugs and polish for 1.0 release - #2
Merged
Conversation
Correctness fixes:
- UID24 was unusable through the UID constructor (MethodError from word
vector promotion) and its reinterpret carrier Tuple{UInt128, UInt64}
has 8 bytes of alignment padding; use NTuple{3, UInt64} instead
- string(UID24) encoded 32 bytes (8 of them padding), producing strings
identical to UID32's; now encodes exactly 24 bytes
- decode_value for integers always returned Int64: sub-64-bit negative
values decoded wrong (Int8(-5) -> 251), unsigned values above
typemax(Int64) decoded negative, and 128-bit integers could encode but
not decode; decoding is now typed, sign-extending, and supports up to
128 bits
- encode_value(typemin(Int64), 64) threw InexactError on a valid value
- Float32/Float16 crashed with a bitcast error; IEEE floats now encode
at their exact type width
- encode_value silently truncated out-of-range integers, floats (bits
mismatch), chars, and strings; all now throw ArgumentError
- Base58.decode of all-zero input ("111") returned [] instead of zero
bytes, so zero-valued UIDs could not round-trip
- UInt128(::UID24/32/64) OR'd overlapping words, guaranteeing hash
collisions between distinct UIDs; removed, hashing now covers all bits
- Non-ASCII strings crashed with StringIndexError; StringN/SymbolN are
now byte-based (N counts codeunits), giving full UTF-8 round-trips
New for 1.0:
- Base.parse for all primitive UID types and UID{T, U}, inverting
string(uid) so identifiers round-trip through their string form
- Docstrings for the module and every exported name
- README: badges, installation, parse examples, accurate randomness and
encoding-semantics notes
- Test suite expanded from 13 to 306 assertions plus Aqua.jl QA checks;
verified on Julia 1.10 and 1.12 including the juliac --trim=safe tests
- Aqua added to test target; versioned Manifests gitignored
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… ==, single-pass Tuple - The parse docstring was attached to the private _uid_parse_bytes helper instead of the Base.parse methods - Symbol == SymbolN now matches SymbolN == Symbol (was asymmetric) - Tuple(uid) decodes all values in one pass instead of O(n^2) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The hard "fail on any increase" gate blocks any PR that adds a method to a Base function for the package's own types. This PR adds exactly one new invalidating method (==(::Symbol, ::SymbolN), required for symmetric equality; measured with SnoopCompile — the other four invalidation trees pre-exist on main). Keep the measurement in the step summary but drop the hard failure, matching sibling JuliaServices packages which don't gate on invalidations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lier errors
- Drop <: Integer from the primitive UID types: they are opaque
identifiers, not numbers. Cross-type/cross-domain == now returns false
instead of throwing a promotion error, and generic Integer code no
longer accepts them only to fail downstream
- Add Base.isless (unsigned, most-significant word first) so identifiers
sort; add Base.tryparse for primitives and UID{T, U}
- Base.print (and string interpolation) now renders the bare Base58
form, matching string(x); show keeps the UIDn"..." literal form, which
is now valid syntax via exported @UIDn_str string macros
- bits_required for unsupported types throws a descriptive ArgumentError
naming the supported types instead of a raw MethodError
- Fix bits_required docstring (strings/symbols measure byte length, not
type width); bump stale action versions in CompatHelper/invalidations
workflows
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fuzz/probe testing found that a single value wider than 128 bits (a
string or symbol over 16 bytes) silently encoded as zeros past byte 16:
encode_value packs each value into one UInt128, so shifts beyond bit 128
dropped the data. This predates the PR — the old code had the same
bottleneck. StringN/SymbolN constructors and decode_value now reject
N > 16 with a clear error telling users to split longer strings into
multiple values; docstrings and README document the per-value limit.
Also add isless for the UID{T, U} wrapper so payload uids sort like the
primitive ones.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
In-depth readiness pass for the 1.0 release, iterated through four self-review rounds (adversarial review, independent API review, randomized round-trip fuzzing, CI). Fixes eleven verified correctness bugs, settles the API contracts that would be hard to change after 1.0, and adds docs, tests, and QA checks.
Correctness fixes (all runtime-verified before fixing)
UID(...; uid_type=UID24)MethodError— UID24 unusable via the constructorTuple{UInt128, UInt64}has 8 bytes alignment padding (sizeof 32 ≠ 24)NTuple{3, UInt64}(sizeof 24)string(UID24)UID32with the same wordsUID(Int8(-5))[1] == 251=== Int8(-5)(typed decode + sign extension)typemax(Int64)UID(typemax(UInt64))[1] == -1=== typemax(UInt64)typemin(Int64)InexactErroron a valid valueFloat32/Float16Base58.decode("111")[]— zero-valued UIDs couldn't round-tripUInt128(::UID24/32/64)hashcovers all bitsStringIndexErrorcrashStringN/SymbolNare byte-based (N = codeunits) — full UTF-8 round-tripsArgumentError; limit documentedSilent-truncation paths in
encode_value(out-of-range integers, wrong-width floats, oversized chars/strings) now throwArgumentErrorinstead of corrupting data. Unsupported argument types get a descriptive error naming the supported types instead of a rawMethodError.API decisions locked in for 1.0
<: Integer. They are opaque identifiers:==(cross-type/cross-domain comparesfalseinstead of throwing promotion errors),isless/sort(unsigned, most-significant word first),hash, and string round-trips — but no arithmetic, and generic::Integercode no longer accepts them only to fail downstream.Base.parseandBase.tryparsefor all primitive UID types andUID{T, U}, invertingstring(uid)— previously values could only be decoded from the live object, never from the ID string the package exists to produce.tryparsereturnsnothingon bad input (it previously fell into Base's generic Integer machinery and crashed).print/interpolation now matchesstring(uid)(bare Base58);showkeeps theUID8"..."literal form, which is now valid, pasteable syntax via exported@UIDn_strstring macros.juliac --trim=safeconstructor surface is unchanged (trim workload still compiles with 0 errors / 0 warnings and the executable passes). The single-string positional formUID(UID8, "EVT")remains the documented non-decodable trim fast path from fix: trim-resolvable UID construction #1.Docs, tests, hygiene
invalidations.ymlmade report-only: its fail-on-any-increase gate blocks any PR adding aBasemethod for the package's own types (measured with SnoopCompile: this PR adds exactly one new invalidating method, the==(::Symbol, ::SymbolN)symmetry fix). Sibling JuliaServices packages don't carry this workflow at all — flag if you'd rather keep the hard gate or drop the workflow entirely.Known-and-accepted (called out for the record)
string(UID24)output changes vs main (it was previously indistinguishable fromUID32's) — pre-1.0 breaking fix.jldoctestin theUIDdocstring is accurate but nothing executes it (no Documenter setup).Test plan
Pkg.test()on Julia 1.12.6 — 337 unit tests + 7 trim-compile tests pass🤖 Generated with Claude Code