Skip to content

fix(encode): deterministically sort simple value and float map keys#186

Open
spokodev wants to merge 1 commit into
rvagg:masterfrom
spokodev:fix/map-sort-simple-keys
Open

fix(encode): deterministically sort simple value and float map keys#186
spokodev wants to merge 1 commit into
rvagg:masterfrom
spokodev:fix/map-sort-simple-keys

Conversation

@spokodev

@spokodev spokodev commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

The default map key sorter compared key types with keyToken1.type !== keyToken2.type and fell back to Type.compare, which only looks at the major type. false, true, null, undefined and floats all share major type 7, so any pair of them compared as equal and kept their insertion order. A map keyed by these values encoded to different bytes depending on insertion order, and pairs such as true/false came out in the wrong order (true 0xf5 before false 0xf4).

This routes keys that share a major type through the type encoder's compareTokens and gives the major 7 encoder a comparator that orders the simple values by their encoded byte and floats by value, so these keys sort deterministically. Adds tests for simple value and float map keys.

@rvagg

rvagg commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Thanks for picking this up (tbh I haven't cared much about non-string keys), and it's a nice start, but this still isn't RFC 7049 (3.9) ordering for float keys since we're aiming for that. The rule is: encoded length first, then bytewise lexical order; numeric value isn't equivalent. What we have here currently is:

  • [-1.5, 1.5] sorts negative first, but f93e00 (+1.5) must precede f9be00 (-1.5)
  • [1.1, Infinity] sorts 1.1 first, but three-byte f97c00 must precede nine-byte fb3ff199999999999a
  • NaN compares equal to every float because both numeric comparisons are false, so insertion order still changes the output

So we need to make the major-7 comparison equivalent to encoded length followed by encoded bytes, honouring float64, and cover negative floats, mixed widths, and NaN in both insertion orders.

Also we should ! the commit message here when I land it and signal a "breaking" change—very low liklihood of anyone actually getting bitten by this but important to be consistent.

Also fwiw RFC's 8949 restated rules end up having major-7 sorted in the same way, it's just defined differently:

  • RFC 7049 "canonical" order: encoded length first, then bytewise lexical order.
  • RFC 8949 "core deterministic" order: bytewise lexical order only.
  • RFC 8949 4.2.3: separately preserves RFC 7049 compatibility as “length-first core deterministic encoding”

For major-7 we end up in the same place anyway, so nice and consistent and easy in this case.

The default map key sorter compared key types with
`keyToken1.type !== keyToken2.type` and fell back to `Type.compare`, which
only looks at the major type. false, true, null, undefined and floats all
share major type 7, so any pair of them compared as equal and kept their
insertion order — a map keyed by these values encoded to different bytes
depending on insertion order.

Route keys that share a major type through the type encoder's
`compareTokens`, and give major 7 a comparator that orders keys by their
canonical CBOR encoding per RFC 7049 3.9: shorter encoding first, then
bytewise. The four simple values encode to a single byte so they sort
ahead of every float, and comparing the encoded bytes rather than the
numeric value makes negative floats (+1.5 `f93e00` before -1.5 `f9be00`),
mixed encoding widths (3-byte Infinity before a 9-byte float64) and NaN
(no numeric comparison, so previously insertion-order dependent) all
deterministic in both insertion orders.

The comparator honours `options.float64`: `compareTokens` and the default
`mapSorter` now receive the encode options so the sort matches the bytes
that are actually written (a value encoded 3-byte by default but 9-byte
under float64 sorts differently, and now correctly). Options are passed as
a trailing argument, so custom `(e1, e2)` sorters are unaffected.

This changes the encoded output for maps with major 7 keys whose previous
order was insertion-dependent.
@spokodev
spokodev force-pushed the fix/map-sort-simple-keys branch from ff012a9 to c610e95 Compare July 23, 2026 11:48
@spokodev

Copy link
Copy Markdown
Contributor Author

Reworked to order major 7 keys by their canonical encoding rather than by value.

compareTokens now encodes each key to the bytes it would actually be written as (the four simple values to one byte, floats to 3/5/9) and compares length-first then bytewise, i.e. RFC 7049 3.9 for major 7. That covers the three cases you flagged: +1.5 (f93e00) before -1.5 (f9be00), 3-byte Infinity before a 9-byte float64, and NaN deterministic instead of insertion-order dependent.

It honours float64: mapSorter and compareTokens now receive the encode options, so a value that encodes 3-byte by default but 9-byte under float64 sorts to match the bytes actually written. Options ride along as a trailing argument, so custom (e1, e2) sorters are untouched. Left the commit prefix as-is for you to mark breaking at land time.

Tests cover negative floats, mixed widths, NaN and the float64 case in both insertion orders. Full suite green.

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.

2 participants