A float64 comparator returns 0 whenever either score is NaN; ordering the same leaderboard twice yields different results. Why?
answer
- both branches fail, so it says equal
- equal to two values that differ
- equality has to survive a third element
- not every pair is actually compared
- shuffle the input and compare the runs
basics
~20 sReturning 0 for NaN pairs claims a NaN equals every score, so two differing scores both equal it. Equality is no longer transitive, the comparison is not a valid ordering, and the result depends on input order.
solid answer
~50 sThe comparator falls through both `<` and `>` for any pair containing a NaN, because every IEEE comparison with NaN is false, and returns `0` — "equal". That makes a NaN equal to 12.5 and equal to 3.0 while those two are not equal to each other, so equality is not transitive and the function is not a valid ordering. An ordering routine performs only a subset of the possible pairwise comparisons, and which subset depends on the input permutation, so the same rows yield different results and callers see phantom ties. The fix is `cmp.Compare`, which puts NaN below every number and equal only to NaN. Prove it with a property test that shuffles a corpus containing NaN, negative zero and unset zeros and asserts every run matches — then reject NaN at ingestion with `math.IsNaN`.
code
go · 12 linesfunc byScore(a, b float64) int {
if a < b {
return -1
}
if a > b {
return 1
}
return 0 // NaN lands here: "equal" to 12.5 and to 3.0, which differ
}
// The fix is the whole body:
// func byScore(a, b float64) int { return cmp.Compare(a, b) }go deeper
Take away one fact: with floating point, both the less-than and greater-than tests are false when a NaN is involved, so a comparison written from those two tests silently calls them equal.
Explain the transitivity break concretely — a NaN reported equal to two scores that are not equal to each other — and name cmp.Compare as the function that defines the missing cases.
Drive the diagnosis: reach for a property test that shuffles the corpus and asserts an identical result, rather than a fixed expected slice, and separate the comparator defect from the data-quality defect that produced the NaN.
Decide where this class of bug is prevented for good — a single ordering entry point that every team calls, a documented NaN policy, and validation at ingestion — rather than leaving each service to write its own comparator.
## What the comparator actually claims The body under suspicion is the one nearly everybody writes first: ```go func byScore(a, b float64) int { if a < b { return -1 } if a > b { return 1 } return 0 } ``` For ordinary numbers this is correct. For a pair containing a NaN it is not, because IEEE-754 says every comparison against a NaN is false: `NaN < 12.5` is false, `NaN > 12.5` is false. Both tests fail, control reaches the last line, and the function reports `0`. `0` is not a neutral answer. In a three-way comparison contract it is a positive claim: *these two elements are equal, put them in either order.* So the function is asserting that a NaN equals 12.5, that the same NaN equals 3.0, and — since it is symmetric — nothing more. Meanwhile `byScore(12.5, 3.0)` returns `+1`. Equality is now non-transitive: `a == b` and `a == c` but `b != c`. ## Why non-transitivity produces different results on the same data An ordering routine does not compare every pair. It compares a subset chosen by its own strategy — partitioning around a pivot, small-range fallbacks, merge boundaries — and the subset it chooses depends on where elements start out. When the comparator is consistent, every valid subset leads to the same final arrangement, which is why an unstable algorithm still gives one right answer for distinct keys. When the comparator contradicts itself, that guarantee is gone. A NaN compared against the pivot says "equal, leave it", so it settles wherever it was first encountered — and where it was first encountered depends on the input order. Two shuffles of the same rows therefore produce two different outputs, and the real scores around the NaN can be dragged along, so the corruption is not confined to the bad rows. What the caller sees is *phantom ties*: rows that appear interchangeable in one run and firmly ordered in the next. The cousin symptom is worse: with a genuinely inconsistent comparator, some sorting implementations can read outside the range they were given or loop, because their invariants assumed a valid ordering. Go's implementation is defensive, but nothing can rescue a comparator that disagrees with itself. ## Confirming it rather than guessing The diagnostic that settles this in one commit is a property test: 1. Build a corpus that contains the values that break comparators: at least one `math.NaN()`, a negative zero from `math.Copysign(0, -1)`, several unset zeros, and ordinary scores including duplicates. 2. Order a copy and keep the result as the reference. 3. In a loop, shuffle a fresh copy, order it, and assert the result matches the reference — comparing element by element with `cmp.Compare(x, y) == 0`, so that a NaN in the reference matches a NaN in the run. The `==` operator would report those as different and give a false failure. A fixed-input test with a hand-written expected slice cannot find this bug: it passes for the one permutation you happened to write down. Order-independence is the property that is actually violated, so it is the property the test must assert. ## The fix Delete the hand-written body and call `cmp.Compare`. It defines exactly the two cases the operators leave open — a NaN is less than every non-NaN, and a NaN equals a NaN — and it treats negative zero as equal to positive zero. That restores reflexivity and transitivity, so the ordering becomes a total order and every permutation of the same multiset produces the same result. ```go func byScore(a, b float64) int { return cmp.Compare(a, b) } ``` If the ordering has more than one key, fold the comparisons with `cmp.Or`, whose first non-zero argument wins: a `0` from the first key means "tied, consult the next". ## The part the fix does not cover `cmp.Compare` guarantees a *defined* order, not a *correct* leaderboard. NaNs will now reliably occupy the top of the list, which is still wrong for the product. So the review should ask two further questions: - **Where did the NaN come from?** Usually a division by a zero count, a parse that silently produced a NaN, or an aggregate over an empty window. Fixing that removes the value rather than ordering it. - **What is the policy at the boundary?** Filter with `math.IsNaN` when the scores are ingested, count the rejects, and log or surface them. Data quality belongs at the edge; `cmp.Compare` is the safety net that stops one bad row from making the whole result non-deterministic. And note the near-miss: unset scores that are plain `0` are perfectly orderable and will sort at the bottom of an ascending order. They are a modelling problem, not a comparator problem, and conflating the two sends the investigation to the wrong place.
- Why does a test with one fixed input and a hand-written expected order miss this?Because it pins one permutation, and that permutation happens to produce one arrangement every time. The violated property is order-independence, so the test has to shuffle the same corpus repeatedly and assert the results agree. Only then does the inconsistency have a chance to show.
- Once you switch to cmp.Compare, is the leaderboard correct?It is deterministic, not correct. NaNs now reliably sort to the front, which is still wrong for a leaderboard. Find where the NaN was produced — usually a divide by a zero count or an empty aggregate — and reject or repair those rows with `math.IsNaN` at ingestion.
- Do unset scores stored as 0 cause the same class of problem?No. Zero is an ordinary orderable value, so the comparison stays consistent and the result is repeatable; the rows simply sit at the bottom of an ascending order. That is a data-modelling decision about how absence is represented, not a defect in the comparison.
- What makes a comparison function valid for ordering in the first place?It must be consistent for every pair regardless of surrounding elements, report a value equal to itself, be antisymmetric, and be transitive in both equality and less-than. The NaN case breaks reflexivity and transitivity at once, which is why nothing built on it can be depended on.
It is like a tournament rule that says an unrated player draws against everyone. Two rated players who beat each other both tie with the unrated one, so the final table depends on who happened to be paired first.
saying these in an interview costs you the question
- Blames sort instability rather than the comparator
- Says NaN can safely be treated as equal to everything
- Proposes retrying the sort or sorting twice
- Thinks a not-less and not-greater pair must be equal
- Only fixes the comparator and ignores where NaN came from