Skip to content

Fix encode/decode correctness bugs and polish for 1.0 release - #2

Merged
quinnj merged 5 commits into
mainfrom
release-1.0-readiness
Aug 6, 2026
Merged

Fix encode/decode correctness bugs and polish for 1.0 release#2
quinnj merged 5 commits into
mainfrom
release-1.0-readiness

Conversation

@quinnj

@quinnj quinnj commented Aug 6, 2026

Copy link
Copy Markdown
Member

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)

Bug Before After
UID(...; uid_type=UID24) MethodError — UID24 unusable via the constructor works
UID24 reinterpret carrier Tuple{UInt128, UInt64} has 8 bytes alignment padding (sizeof 32 ≠ 24) NTuple{3, UInt64} (sizeof 24)
string(UID24) encoded 32 bytes incl. padding — identical string to a UID32 with the same words exactly 24 bytes
Negative sub-64-bit ints UID(Int8(-5))[1] == 251 === Int8(-5) (typed decode + sign extension)
Unsigned above typemax(Int64) UID(typemax(UInt64))[1] == -1 === typemax(UInt64)
128-bit integers encoded but could not decode full 128-bit support
typemin(Int64) InexactError on a valid value encodes correctly
Float32 / Float16 bitcast crash encode at exact type width
Base58.decode("111") returned [] — zero-valued UIDs couldn't round-trip returns zero bytes
UInt128(::UID24/32/64) OR'd overlapping words → distinct UIDs with guaranteed equal hashes removed; hash covers all bits
Non-ASCII strings raw StringIndexError crash StringN/SymbolN are byte-based (N = codeunits) — full UTF-8 round-trips
Values wider than 128 bits (strings/symbols > 16 bytes) silently encoded as zeros past byte 16 (found by fuzzing; pre-existing) clear ArgumentError; limit documented

Silent-truncation paths in encode_value (out-of-range integers, wrong-width floats, oversized chars/strings) now throw ArgumentError instead of corrupting data. Unsupported argument types get a descriptive error naming the supported types instead of a raw MethodError.

API decisions locked in for 1.0

  • Primitive UID types are no longer <: Integer. They are opaque identifiers: == (cross-type/cross-domain compares false instead of throwing promotion errors), isless/sort (unsigned, most-significant word first), hash, and string round-trips — but no arithmetic, and generic ::Integer code no longer accepts them only to fail downstream.
  • Base.parse and Base.tryparse for all primitive UID types and UID{T, U}, inverting string(uid) — previously values could only be decoded from the live object, never from the ID string the package exists to produce. tryparse returns nothing on bad input (it previously fell into Base's generic Integer machinery and crashed).
  • print/interpolation now matches string(uid) (bare Base58); show keeps the UID8"..." literal form, which is now valid, pasteable syntax via exported @UIDn_str string macros.
  • The juliac --trim=safe constructor surface is unchanged (trim workload still compiles with 0 errors / 0 warnings and the executable passes). The single-string positional form UID(UID8, "EVT") remains the documented non-decodable trim fast path from fix: trim-resolvable UID construction #1.

Docs, tests, hygiene

  • Docstrings for the module and every exported name; README with badges, installation, parse examples, per-value limits, and an explicit "not cryptographically secure" caveat replacing the "unpredictable" claim.
  • Tests: 13 → 337 assertions plus 7 trim-compile tests and Aqua.jl QA (ambiguities, piracy, compat bounds, etc.). Beyond the suite: 4000-trial randomized round-trip fuzz over all UID sizes and value types, word-boundary straddle probes, and hostile-input probes all pass.
  • invalidations.yml made report-only: its fail-on-any-increase gate blocks any PR adding a Base method 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.
  • Versioned Manifests gitignored; stale action versions bumped in CompatHelper/invalidations workflows.

Known-and-accepted (called out for the record)

  • string(UID24) output changes vs main (it was previously indistinguishable from UID32's) — pre-1.0 breaking fix.
  • LICENSE.md says "Copyright (c) 2022" while repo history starts 2026 — left untouched; update if you want.
  • The jldoctest in the UID docstring 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
  • Fast suite + Aqua on Julia 1.10.11 (minimum supported)
  • 4000-trial randomized round-trip fuzz + hostile-input probes
  • CI matrix green (1.10 min / 1 stable / pre, Linux + Windows + macOS aarch64)

🤖 Generated with Claude Code

quinnj and others added 5 commits August 5, 2026 20:49
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>
@quinnj
quinnj merged commit 4c3ac62 into main Aug 6, 2026
8 checks passed
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.

1 participant