Skip to main content

Proposal: making the canonical form's states member order-free

Status: proposed, not normative, not implemented. This document resolves #95. Unlike the other two proposals in this directory it changes no specification text: the canonical JSON is a property of the conformance reference (tests/conformance/canonical.py) and its five ports, not of the format. §5 says where the text goes instead, and §5.4 says why none of it belongs in the specification.

The canonical form promises that nothing it emits depends on decoded order. Its states member — the post-track reconstruction the object layer exists to prove — breaks that promise in two places, and this proposal recommends a portable fix for each.


1. What the reference says today

The promise is stated at the top of the module (tests/conformance/canonical.py:17-21):

Nothing here may depend on decoded order. Gaussians may be reordered freely by an encoder and readers must not rely on their order, so a summary that did would be asking two correct decoders to disagree. Everything per-gaussian — the sample, the aggregates, the spherical harmonic digest — is taken in the content order defined by _stable_order, which is derived from decoded values alone.

It is a restatement of the format's own rule, §6.1: "Gaussians within a chunk MAY be reordered freely by the encoder; nothing in the format depends on their order, and readers MUST NOT rely on it."

The mechanism is _stable_order (canonical.py:506-540), which builds a key per gaussian from its whole decoded state, rounded to FLOAT_DECIMALS = 6 by _sortable (:543-551), with the spherical harmonic coefficients and object_id appended, and sorts. Its docstring makes the argument that justifies the rounding:

The key is the gaussian's whole decoded state, rounded exactly as the summary rounds it, with its spherical harmonic coefficients last. Two gaussians that tie on all of it are identical in every value this summary emits, so their relative order cannot change the output.

Ties are broken by decode order, deliberately and identically in every port: Python sorts (row, i) pairs on k[0] alone (canonical.py:538-540) and Python's sort is stable; Rust says so in a comment (rust/conformance/src/lib.rs:1014-1015, "A stable sort, so gaussians that tie on every rounded value keep decode order — the same tiebreak every other implementation's sort makes"); TypeScript pushes the decode index into the key explicitly as a final component (typescript/conformance/src/canonical.ts:697).

Rounding is not an accident either, and any fix has to respect why it is there: two implementations computing the same decoded value can differ in the last unit in the last place, so an ordering keyed on exact values would put the same file's gaussians in different orders in different languages. The rounding is what makes the key portable.


2. What breaks

2.1 The tie argument holds for sample and fails for states

_stable_order's claim — "identical in every value this summary emits" — is true of the root sample block (canonical.py:189-204), whose emitted values are num(...) of exactly the fields the key rounds. Two gaussians that tie therefore emit identical rows and swapping them is invisible.

It is not true of states (canonical.py:214-300). Those rows are emitted after two operations the key never sees:

row_for_index = {int(index): row for row, index in enumerate(base["indices"])}
sample_indices = [index for index in order if index in row_for_index][:SAMPLE] # canonical.py:250

and the values are the composed centre and orientation at probe time t:

base_center = position + motion * (t - mu_t) # §3
center = R(t) * base_center + T(t) # §3, object track

The mechanism by which a tie becomes visible is amplification, and it is worth naming precisely because "rounded values tie so the difference is at most 5e-7" is the intuition that makes this look harmless. Two gaussians can tie on motion to six decimals and differ in exact motion by up to 1e-6; over a probe with t - mu_t of ten seconds that difference becomes 1e-5 in the composed centre, an order of magnitude larger than the sixth decimal the summary prints. The rotation in R(t) * base_center multiplies rather than adds, so the same argument applies to orientation.

So a legal reordering of gaussians — the reordering §6.1 explicitly permits an encoder — can change which of two tied rows lands in states[*].sample, and the two rows are not interchangeable. That is precisely the failure the module docstring forbids.

2.2 The states aggregate sums in resident order

"positionSum": [num(sum(float(row[axis]) for row in centers)) for axis in range(3)], # canonical.py:262
"opacitySum": num(sum(float(value) for value in base["opacity"])), # canonical.py:263

centers and base["opacity"] are indexed by row, which is position among the live gaussians in decode order (base["indices"]), not content order. The root aggregate does the opposite, iterating for i in order (canonical.py:104-116). Floating-point addition is not associative, so two files that differ only by a legal reordering can produce different totals.

Two corrections to #95 here.

  • opacitySum has the same defect and the issue does not mention it. Line 263 is the same mistake as line 262. A fix that changes only positionSum leaves half of it in place. Every port mirrors both: typescript/conformance/src/canonical.ts:447-453, rust/conformance/src/lib.rs:511-518.
  • "Iterate order rather than centers" is not a valid substitution. order indexes all gaussians, centers is indexed by row over the live ones only, and the two index spaces are different sizes at any probe where a gaussian is outside its window. The fix has to go through row_for_index (canonical.py:249) — which is to say it is the same expression the sample selection at :250 already builds, minus the [:SAMPLE].

2.3 The comment #95 quotes does not exist

#95 attributes this to canonical.py:104:

Summed in content order, not index order: two decoders that visit gaussians differently must reach the same total, and floating-point addition is not associative enough to leave that to chance.

That comment is not in canonical.py, at line 104 or anywhere else, and no string containing "not associative" exists anywhere in the repository. Line 104 is a bare for i in order:. The intent is recorded — in the module docstring (:17-21), in _spherical_harmonics's docstring (:484-490, "Taken in content order so that two decoders which visit gaussians differently still agree") and in tests/conformance/README.md — but the specific sentence, and the specific reasoning about associativity, are not written down anywhere. The issue's diagnosis is right; its evidence is a paraphrase presented as a quotation. Worth saying because "the reference is internally inconsistent" is a stronger charge when the rule is written next to the code that follows it, and here it is not.


3. The options

For the ties

(1a) Order the states rows by what they emit. Sort the live rows by the content key first and by the emitted, rounded composed row second, so that any remaining tie ties on the centre, the orientation and the object_id this block prints. Rows that tie after that emit identical arrays and their relative order provably cannot change the output — which is _stable_order's own argument, applied at the level where the values are actually produced.

The secondary comparison is a portable lexicographic order over the emitted row, not the host language's comparison over decoded JSON values. For every emitted scalar, a finite canonical number sorts before null; finite numbers sort by numeric value after canonical six-decimal rounding; -0.0 and 0.0 compare equal; and null compares equal to null. Equivalently, each numeric slot uses (0, rounded_value) when finite and (1, 0) when num emits null. The exact object_id slot compares as an unsigned integer, never as a decimal string. This covers every result of the canonical non-finite rule: NaN, positive infinity and negative infinity all emit null and are therefore deliberately indistinguishable in the key, just as they are in the row. No port asks its native sort to compare None/null with a number.

  • Cost: _stable_order must expose its keys (today it discards them, canonical.py:538-540), and the states block gains a sort over live rows per probe. Six ports, a few dozen lines each.
  • Keeps the correspondence with the root sample: where the content keys differ, the order is unchanged, so the two sample blocks still describe the same gaussians in the same sequence.

(1b) Extend _stable_order's key to exact decoded values. Then no two distinct gaussians tie at all.

  • Rejected. The rounding in _sortable is there because two implementations can compute the same decoded value to different last bits; keying on exact values makes the order itself implementation-dependent, which turns a rare tie hazard into a routine cross-language ordering disagreement. This trades a defect nothing triggers for one everything would.

(1c) Do nothing and document the exposure. Legitimate, and cheapest. Its cost is that the module docstring's "Nothing here may depend on decoded order" stays false, and the next person to read it will take it at face value — which is how #95 was found and how the next one will be missed.

For the summation order

(2a) Sum in the same order the rows are emitted in. Iterate the content-ordered live rows rather than the resident ones, for positionSum and opacitySum alike. One expression per aggregate per port.

  • Removes the large exposure: today the sum depends on the chunk layout, which an encoder chooses freely.
  • Leaves a residual, and this proposal says so rather than claiming otherwise. Tied rows form a contiguous run in the order, and permuting a run of addends can still change the total, because (s + a) + b and (s + b) + a are not equal in general. Fix (1a) narrows that run to rows whose emitted values are identical; it does not make the unrounded addends identical.

(2b) Sum exact canonical integer units. First apply the canonical form's existing FLOAT_DECIMALS rounding to each addend, convert the result to a signed integer count of 10^-FLOAT_DECIMALS units in an arbitrary-precision signed integer, and add those integers exactly. Convert the final integer back only when serializing the aggregate. The sum then depends only on the multiset of values the canonical form actually emits, not on row order or a language's floating-point addition.

The float-to-unit conversion is textual, not integer(num(value) * 10**FLOAT_DECIMALS): that expression multiplies a binary approximation and makes truncation or a second rounding observable. Render the finite result of num(value) in fixed-point form with exactly FLOAT_DECIMALS fractional digits using the same round-to-nearest, ties-to-even rule as num; parse the sign, whole digits and fractional digits as decimal integers. With scale = 10**FLOAT_DECIMALS, compute sign * (whole * scale + fraction). Thus num(1.000001) renders as 1.000001 and becomes 1000001 units even though the binary product round(1.000001, 6) * 10**6 can be 1000000.9999999999. No port converts the fixed-point text through a binary float again.

Serialization is the inverse decimal operation and likewise performs no floating-point conversion. For an integer sum s, emit its sign, abs(s) // 10**FLOAT_DECIMALS as the whole digits, a decimal point, and the zero-padded fractional remainder with trailing zeroes removed (leaving one 0 after the point for an integral result). This is a JSON number token, even when it is outside binary64's finite range. Such a final sum does not become null; null is reserved for the non-finite addend rule below. JSON places no magnitude limit on its number grammar, so a port whose JSON value type cannot hold the token must write this aggregate token directly rather than narrow the exact sum through that type.

The comparison harness must preserve that grammar-level precision too. tests/conformance/run.py loads actual and expected output with json.loads(..., parse_float=Decimal, parse_int=int), through one shared helper, rather than the default binary64-backed float conversion. The same helper is used by tests/conformance/encode_roundtrip.py for its canonical comparisons. Decimal is constructed directly from the JSON token and is therefore exact; numeric spellings such as 1.0 and 1.00 remain semantically equal, while two different 400-digit totals do not both collapse to infinity. This is part of (2b), not optional harness hygiene: writing an exact token and then comparing a narrowed value would leave the conformance claim untested.

Mismatch diagnostics remain part of that contract. encode_roundtrip.py MUST NOT pass the parsed objects directly to json.dumps, whose standard encoder rejects Decimal. Its shared diagnostic renderer recursively replaces each Decimal with its exact decimal spelling before producing the human-readable diff (quoting that spelling is harmless in a diagnostic). A focused harness test feeds two unequal Decimal-bearing summaries through the real mismatch path and requires a field diff rather than TypeError. Retaining the original JSON text is not sufficient here because the gate removes layout-dependent fields before comparison; the diagnostic must render the normalized objects it actually compared.

  • Cost: one rounding and integer conversion per addend. Each port must use the same already-shared canonical rounding rule and an arbitrary-precision integer representation for both the scaled addend and every partial sum. A declared population is not an overflow proof: one finite decoded value may already exceed a fixed-width scaled range. This is O(n) and needs no sort.
  • This is the only option here that discharges both parts of the promise. Sorting raw floats is not sufficient: equivalent cross-language computations may differ in their last bits, which can change both the sorted order of close addends and a non-associative result.

(2c) Exact binary floating-point summation (math.fsum and equivalents). Order-free for one fixed collection of binary inputs, but not portable when independently computed inputs differ in their last bits. Only Python has it in the standard library, too. Rejected on portability and cost.


4. Recommendation

Adopt (1a) and (2b).

  • (1a) because it is the only fix that restores _stable_order's argument at the level where the values are emitted, and it does so without touching the rounding that makes the key portable.
  • (2b) because summing exact canonical units makes the aggregate a function of the emitted multiset and gives all six languages the same arithmetic. The adversarial tied-row pair in §6 makes the rule observable, so it does not ship as an untested convention.
  • The same exact-unit helper is used for the root aggregate and every states[*].aggregate; repairing only the latter would leave the module-level order-independence promise false.
  • The harness parses JSON number tokens losslessly before comparison; otherwise a wrong out-of-binary64-range total can compare equal to the right one.

What regeneration costs, and what was measured

There are two regeneration scopes, and they must not be conflated. The row-order measurement was made against the four object-layer files that exercise composed states rows — object/SingleObject, object/MultiObject, object/ObjectTrackComposed and the top-level LongLived-UseChunkIndex-UseCrc-WithObjects. (#95 and #134 both say "three"; the top-level WithObjects variant is the fourth and it carries a table, a track and an object_id stream.) For each, at each of the three probe times:

variantgaussiansties in the keycontent order == resident orderrounded positionSum changesrounded opacitySum changes
LongLived-…-WithObjects2560nonono
object/SingleObject60nonono
object/MultiObject80nonono
object/ObjectTrackComposed60nonono

Read the two middle columns together: the summation-order defect is live — content order differs from resident order in every variant at every probe, so today's sums really are taken in an order the format says means nothing — and it is currently harmless, because these values are small and well-conditioned enough that both orders round to the same six decimals. And there are no ties anywhere, so (1a) reorders nothing.

So the measured cost of (1a) and the weaker (2a) is zero existing expectation bytes. That measurement says nothing about the regeneration scope of (2b): canonical.py emits the root aggregate for every ordinary gaussian-birth variant, not only for object files, and the same helper also serves the keyframe-delta and per-state aggregates. At the time of this proposal, 53 committed expectations contain a root aggregate (46 top-level, three under object/, and four under keyframe/), while eight contain states. The bottom implementation PR must regenerate and review every expectation containing either affected aggregate, deriving that set from the generated JSON rather than hard-coding these counts. Limiting review to the four rows above would permit an implementation accidentally scoped to object files and leave the repository's ordinary gaussian-birth expectations stale.

Delivery follows the repository's stacked-PR rule: the corpus, reference canonical and Python runner form the bottom layer; Rust, TypeScript, Dart, C++ and Swift each get their own language-only layer, targeting the branch below. Merge and rebase them bottom-up. The affected ports are tests/conformance/canonical.py, rust/conformance/src/lib.rs, typescript/conformance/src/canonical.ts, dart/conformance/lib/canonical.dart, cpp/conformance/canonical.cpp and swift/conformance/Support/Summary.swift. This is an unusually good moment to make the change, and it will not stay true — the first adversarial scene makes the same fix regenerate its new expectation.

There is no initial (1a)/(2a) release in this rollout. The bottom conformance layer lands (1a), (2b), the lossless comparator and every discriminating variant in §6 together; only then do the language layers claim the guarantee. A weaker (2a) experiment may exist on a development branch, but it is not a mergeable layer and does not update the feature matrix.


5. The text

5.1 canonical.py's module docstring, replacing the "Nothing here may depend on decoded order" paragraph

Nothing here may depend on decoded order. Gaussians may be reordered freely by an encoder and readers must not rely on their order, so a summary that did would be asking two correct decoders to disagree. Per-gaussian rows and the spherical harmonic digest use the content order defined by _stable_order; the root aggregate and every per-state aggregate sum exact arbitrary-precision integer units after the same canonical rounding used for emitted values, so their result is a function of the multiset rather than an iteration order.

That order rounds to FLOAT_DECIMALS before comparing, which is deliberate — two implementations can compute a decoded value to different last bits, and an order keyed on exact values would differ between languages — and it is why two gaussians can tie on the key and still hold different exact values. A tie is harmless wherever the emitted values are the keyed fields, which is true of the root sample. It is not true of states, whose rows are composed at a probe time: a difference below the sixth decimal in motion becomes visible in a centre after multiplication by t - mu_t. So the states block orders its rows by the content key and then by the rounded values it emits, and a tie that survives both ties on every number that block prints.

Compare that secondary row lexicographically with an explicit scalar order: finite canonical numbers first in ascending numeric order (-0.0 equal to 0.0), then null, with null equal to null; compare object_id as an exact unsigned integer. Do not delegate number-versus-null ordering to the host language.

5.2 canonical.py, as a comment on the root and states aggregates

Each addend is rounded by the canonical num rule and converted to an integer count of 10^-FLOAT_DECIMALS units before summation. Both the scaled addend and accumulator are arbitrary-precision signed integers: a population limit alone cannot make a fixed-width addend safe. The integer sum is exact and therefore depends only on the multiset, never resident or decoded order. Sorting and then adding raw floats would still be non-portable: equivalent computations in different languages may differ in their last bits and therefore sort or sum differently.

Apply num before integer conversion. If any addend rounds to null because the decoded or composed value is non-finite, the aggregate for that field is null and no integer conversion or partial sum is attempted. This preserves the canonical form's existing non-finite rule and gives every port one defined result instead of asking it to convert null to integer units.

Convert a finite rounded addend to units by rendering it with exactly FLOAT_DECIMALS fixed-point fractional digits under the canonical ties-to-even rule and parsing those decimal digits into an arbitrary-precision signed integer. Do not multiply its binary float by the scale. Serialize the exact total by inserting the decimal point FLOAT_DECIMALS digits from the right, trimming trailing fractional zeroes but retaining .0; never convert the total to a binary float. A finite-addend total outside binary64's range is therefore still an exact JSON number, not null.

5.3 tests/conformance/README.md, replacing the "nothing depends on decoded order" bullet

  • nothing depends on decoded order. Gaussians may be reordered freely by an encoder, so the sample, the aggregates and the spherical harmonic digest are all taken in a content order derived from decoded values alone. That order rounds before comparing, so two gaussians can tie on it and still hold different exact values — harmless wherever the emitted values are the keyed fields, and not harmless in states, whose rows are composed at a probe time from inputs the key sees only rounded. The states block therefore orders by the content key and then by the rounded values it emits. The root aggregate and every state aggregate round each addend to canonical arbitrary-precision integer units and sum those units exactly, making them independent of both storage order and floating-point addition order.

5.4 Nothing in website/docs/spec/index.md

Stated as a recommendation rather than an omission. The canonical form is how two implementations are diffed; it is not what a conforming decoder must produce, and no decoder emits it outside a conformance runner. Putting the tie rule or the summation rule into the specification would make a testing convention normative for every reader in the world, and would invite the reading that a decoder's own aggregates have to be computed a particular way — which the format has never said and should not start saying.

The format-level statement already exists and is sufficient: §6.1's "Gaussians within a chunk MAY be reordered freely by the encoder; nothing in the format depends on their order, and readers MUST NOT rely on it." Everything in this proposal is the conformance reference being held to that sentence.


6. The conformance variants that pin it

Seven cases, represented by eleven files; all four tie-sensitive cases are deliberately paired encodings.

  • ObjectTiedGaussians and ObjectTiedGaussiansReordered — two encodings of the same object-layer gaussian multiset, containing at least one pair of gaussians that tie on every field of the _stable_order key at six decimals while differing in exact position and motion, with a mu_t far enough from the probe times that the difference amplifies past the sixth decimal in the composed centre. Built by choosing a step_pos and step_motion fine enough that two adjacent bins round identically — a quantization choice, not a hand-written float. The second file permutes the tied rows' physical/resident order while preserving every decoded value; the generator refuses to emit the pair unless their pre-fix resident-order canonicals differ and their content-order canonicals are identical.

    What it asserts: every runner produces identical states[*].sample rows for both encodings. Without fix (1a), the stable sort retains the two different decoded resident orders when its key ties, so at least the reordered file disagrees with the shared expectation. No decoder is assumed to choose its own chunking or order; the two inputs supply both orders explicitly.

  • ObjectTiedNonFiniteRows and ObjectTiedNonFiniteRowsReordered — finite stored positions, motions and probe times whose composed arithmetic overflows in one tied row, so its emitted centre contains null, beside a tied row whose emitted centre remains finite. The second file reverses only those rows. The generator proves the primary content keys tie and that the secondary rows contain both scalar classes before it writes either file.

    What it asserts: every port applies the explicit secondary scalar order — finite canonical numbers before null, null equal to null — and both encodings emit the same sampled rows. A host-language comparison between Python None and float, or a port-specific choice to put null first, cannot pass. Every source value is finite; the null is the canonical representation of a non-finite composed result, not a malformed-file shortcut.

  • ObjectCancellingPositionSum — live centres of large opposing sign spread across at least two chunks, with resident order grouping signs and content order interleaving them. The generator must assert before writing the expectation that the two pre-fix orders produce different values after six-decimal rounding. It asserts only states[*].aggregate.positionSum; opacity is deliberately held constant so a position fix cannot borrow evidence from another field.

  • ObjectOpacityOrder — 64 live gaussians whose decoded opacities contain 32 high values, 31 low values and one boundary value tuned so resident-order and content-order addition land on opposite sides of a six-decimal rounding boundary. Resident order groups high then low values; the content key alternates them. The generator records both sums and refuses to emit the variant unless their rounded opacitySum differs, while all centres are zero. This independently catches a port that fixes positionSum and leaves opacity in resident order.

  • ObjectResidualTieSum and ObjectResidualTieSumReordered — two encodings of the same rows, which tie both on _stable_order and on the rounded secondary row emitted by (1a), while their unrounded composed centres differ. The second encoding reverses the surviving tied run's physical order and changes no decoded value. Their generator preconditions are explicit: secondary emitted-row keys compare equal, and resident-order addition in the two encodings lands on opposite sides of a six-decimal boundary. The generator evaluates the two summary levels independently, because they do not sum the same values: root aggregate.positionSum sums stored positions, while each states[*].aggregate.positionSum sums centres composed at that probe. For the root and for every probe whose aggregate is asserted, it MUST separately prove that sorting and adding the applicable raw floats produces a rounded total that differs from the exact sum of the individually rounded integer-unit addends. A state-level witness is not evidence for the root, and one probe is not evidence for another; an aggregate that does not meet the precondition is omitted rather than claimed. The pair shares the resulting integer-unit expectations, so neither stable resident order nor the explicitly rejected raw-float sort can pass at either summary level. This is the first pair that actually reaches (2a)'s residual and therefore the trigger for implementing (2b); ObjectTiedGaussians cannot serve that role because its emitted centres differ. It therefore lands in the bottom conformance layer of the same recommended (1a)/(2b) stack, before any language layer can claim order-independent aggregates.

  • ObjectResidualTieOpacity and ObjectResidualTieOpacityReordered — the opacity counterpart to the preceding pair. The rows tie on the six-decimal opacity component of the primary key and on the complete rounded emitted-state secondary key, but carry distinct unrounded decoded opacities. The second encoding reverses only that surviving tied run. The generator must prove that resident-order addition crosses a six-decimal opacitySum boundary between the two files. Here too the root and state preconditions are separate: the root sums stored alpha, while each state sums the time-weighted opacity at its probe. For the root and for every asserted probe, the generator MUST independently show that raw-float sorted addition rounds differently from exact integer-unit addition; it cannot use a state witness to claim root coverage or vice versa. The pair shares those integer-unit expectations for root aggregate.opacitySum and every qualifying states[*].aggregate.opacitySum. ObjectOpacityOrder proves that (2a) must use content order, but cannot catch a port that applies (2b)'s exact-unit rule to centres alone; this pair makes both aggregates and both summary levels part of the immediate (2b) contract in that same bottom layer.

  • ObjectWideUnitAggregate — finite centres at the maximum f32 value, preceded in portable content order by 16 small rows. The small rows completely fill every ordinary root/state sample; the max-f32 rows remain live only in the aggregates, whose canonical units require more than 128 signed bits after scaling by 10^6. The generator asserts the wide rows stay outside SAMPLE, the scaled total exceeds signed 128-bit, and root plus all composed-state aggregates emit the same exact token.

    What it asserts: every runner can emit the direct JSON number token required by (2b). The harness also keeps a direct 10×1e308 regression for its raw-token serializer and lossless comparator. The encoded f32 case certifies every SDK; the wider direct test independently proves the Python reference does not narrow an arbitrary-precision JSON number.

All seven belong beside the existing object/ variants. None needs a spec change, a new opcode or a new writer capability — they are ordinary scenes with adversarially chosen numbers. The generator's precondition assertions are part of the design: an adversarial name is not evidence unless the constructed values demonstrably separate the two algorithms.


7. Claims in #95 this proposal could not confirm as written

  • The quoted comment at canonical.py:104. Not present, at that line or anywhere; see §2.3. The substance of the claim — that the root aggregate deliberately sums in content order — is correct and is visible in the code (for i in order) and in the module docstring.
  • "For the sum: iterate order rather than centers." Not a valid substitution as written; the two index different spaces. See §2.2.
  • "The three object-layer conformance variants." Four; see §4.
  • What the issue does not say, and should: opacitySum (canonical.py:263) has the same defect as positionSum and needs independent conformance evidence.

8. Deliberately not decided here

  • Whether FLOAT_DECIMALS should change. Raising it narrows ties and widens cross-language last-bit disagreements; it is a different trade with different evidence and it should not ride along with this one.
  • Whether the keyframe-delta canonical needs the same treatment. It does not, today: keyframe_delta_file.reconstruct_at orders by gaussian_id, which is unique within a state (§11.2) and is a decoded value, so it has no ties to break. That remains true only while that canonical stays keyed on identity, which is worth remembering if it ever grows an object-layer block (#79).