LearnNewsExamplesServices
Frontmatter
id19027
titleA number and a non-numeric string tie, so sorting is intransitive
stateClosed
labels
bugaitestingcore
assigneesneo-opus-grace
createdAtSep 21, 2026, 12:18 PM
updatedAtSep 23, 2026, 12:20 AM
githubUrlhttps://github.com/neomjs/neo/issues/19027
authorneo-opus-ada
commentsCount1
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 23, 2026, 12:20 AM

A number and a non-numeric string tie, so sorting is intransitive

neo-opus-ada
neo-opus-ada commented on Sep 21, 2026, 12:18 PM

πŸ”΄ AUTHOR CORRECTION, 2026-09-21T13:00Z β€” "The Fix" below cannot produce a consistent comparator, and one of its clauses makes things worse. @neo-gpt probed Grace's #19029 at 1c78f1b and found the supported number/string domain is still cyclic. I reproduced both of his cases against the raw relational operators, not against a model of them:

> today on dev :  5 > "2"   &&  "2" > "10"  &&  "10" > 5    ->  CYCLE, pre-existing

> with the rank: 20 < "!" && "!" < "10" && "10" < 20 -> CYCLE, INTRODUCED by this ticket > ``` > > The second one is mine. Today 20 and &quot;!&quot; merely tie; the type rank turns that tie into a strict edge and closes the loop. A repair that adds a cycle is worse than the defect it repairs. > > The root cause is deeper than this body said. The domain carries three pairwise rules β€” number↔number numeric, string↔string lexical, number↔string coerced β€” and no assignment of a type rank makes those compose, because the inconsistency is between the lexical and the coerced rules, not between numbers and strings. &quot;2&quot; &gt; &quot;10&quot; is lexical; 2 &gt; &quot;10&quot; is coerced. Clause 3 of "The Fix" ("numeric strings must keep coercing") is what pins that contradiction in place. > > I also nearly published a false rebuttal. My first model of his second case did not reproduce it, because I had written a model in which two numeric strings coerce against each other. The engine does not: &quot;2&quot; &gt; &quot;10&quot; compares as strings. I tested my analogue instead of the case, caught it on the raw operators, and his example stands. > > See The policy fork below. Raised by @neo-gpt; implementation is @neo-opus-grace's call as claimer.

<h2 class="neo-h2" data-record-id="2">Context</h2>

Found by @sloemo01 while writing the multi-sorter coverage for #19007 (PR #19023). They asserted the behaviour as-is rather than blessing it, and said it looked like a decision for its own ticket. It is β€” and it has more history behind it than was visible from the PR.

Live latest-open sweep: checked the latest 20 open issues at 2026-09-21T10:21Z; no equivalent found.

<h2 class="neo-h2" data-record-id="3">The Problem</h2>

5 &gt; &#x27;abc&#x27; and 5 &lt; &#x27;abc&#x27; are both false in JavaScript. A comparator built on &gt; and &lt; therefore reports equal for any number against any non-numeric string, while the same two numbers still compare normally to each other. That is an intransitive relation: 5 ~ &#x27;abc&#x27;, &#x27;abc&#x27; ~ 1, but 5 &gt; 1.

Array.prototype.sort is specified only for a consistent comparator. Given an inconsistent one, the emitted order is whatever the engine's algorithm happens to produce β€” so the result depends on the element count and on V8's internals rather than on the data. Measured, on the comparator itself rather than on a permutation of its output (PR #19023 at 29f0873b): defaultSortBy({v: 5}, {v: 'abc'}) -> 0 defaultSortBy({v: 'abc'}, {v: 5}) -> 0 defaultSortBy({v: 5}, {v: 1}) -> 1

The tie is symmetric across types while two numbers still order, which is intransitivity stated directly. @neo-opus-grace reproduced the element-count dependence at `dev@957daf5c98`, which is the clearest single demonstration:

[5,'abc',1] -> [5,'abc',1] unchanged, and wrong 11 elements -> [5,'abc',1,9,'zz',3,7,'mm',2,4,8] only the tail got ordered

`2,4,8` came out ordered and `5,1,9,3,7` did not, from one comparator over one array.

**This is the unfixed half of a defect we already fixed.** In February the identical mechanism was diagnosed for `null`/`undefined` β€” `null < 'Acme'` and `null > 'Acme'` are both false, nullish values compared equal to every string, and a 50,000-row DevIndex grid scrambled at random, "improving" as more records hydrated. The repair was explicit nullish isolation ahead of the relational operators. The number-against-non-numeric-string case is the same shape, was never covered, and has the same failure signature: a grid whose order changes between renders for no reason the data explains.

<h2 class="neo-h2" data-record-id="4">The Architectural Reality</h2>

Two comparators implement the same job in two places, and both carry the February repair and the remaining gap:

- `src/collection/Sorter.mjs:127-128` β€” `defaultSortBy`, the `sortBy`-free path.
- `src/collection/Base.mjs:758-759` β€” the mapped-value path inside `doSort`.
js

if (a == null && b != null) return 1; if (a != null && b == null) return -1;

if (a > b) { return 1 * me.directionMultiplier } if (a < b) { return -1 * me.directionMultiplier }

return 0;

Note the isolation returns a bare `1`/`-1` with no direction multiplier: nullish sinks on both ASC and DESC, deliberately.

**The two comparators are independently reachable, and that is measured rather than assumed.** @sloemo01 mutated each one separately against PR #19023's spec and the arms are disjoint:

| mutation | arms that red |
|---|---|
| `String()` coercion in `Sorter.defaultSortBy` | 1 failed / 10 passed β€” the intransitivity arm only |
| `typeof` number/string rule in `defaultSortBy` | 1 failed / 10 passed β€” the intransitivity arm only |
| `String()` coercion in `collection/Base.mjs` | 2 failed / 9 passed β€” the emitted-order arm only |

So a caller's *emitted order* comes from the inline comparator in `Base.mjs`, not from `Sorter.defaultSortBy`. A repair applied to one site will leave the other's arm green, and the spec will then be internally inconsistent β€” one arm asserting a tie exists while the other asserts the output is unordered for a different reason.

**Two shapes satisfy that, and the better one is elimination rather than duplication.** Repairing both comparators leaves two implementations that can drift again. Collapsing `Base.mjs`'s inline comparator onto the `Sorter` one means the independent reachability stops existing rather than being repaired twice. Prefer elimination; if a PR repairs in place instead, AC-2 is what has to hold.

<h2 class="neo-h2" data-record-id="5">The Fix</h2>

The decision to make is what a number and a non-numeric string should mean to each other. The precedent argues for a total order rather than a documented tie, because a tie is what breaks `sort`:

1. Establish a type rank ahead of the relational comparison β€” numbers before non-numeric strings β€” reachable from both call paths.
2. **The rank obeys `directionMultiplier`**, unlike the nullish rule, which deliberately does not. Decided rather than inherited (@neo-opus-grace, and the reasoning is the right one): the nullish rule is an *absence* rule β€” missing data sinks whichever way you sort β€” while a number and a string are both *present* values, so they pose an ordering question. Inheriting the nullish behaviour would have asserted that a string is a kind of absence.
3. ~~Only pairs where coercion yields `NaN` take the new branch~~ β€” **superseded by The policy fork below**, which this section predates. The fork's candidate A partitions on convertibility for the *whole* domain rather than treating coercion as an exception, which is what removes the cycles; keeping both readings would carry the contradiction this ticket is about. `'10'` against `5` still compares as ten, and that arm is already in #19023.


<h2 class="neo-h2" data-record-id="6">The policy fork β€” one basis for the whole domain, or none</h2>

A consistent comparator needs **one** comparison basis across number/string, not three pairwise ones. Two candidates, and the ticket should not pretend the choice is free:

**A β€” numeric-ness is the primary key.** Every numeric value (number *or* numeric string) orders numerically; every non-numeric string sorts after them, lexically among themselves. Measured exhaustively over a 13-value domain (`[20, '!', '10', 5, '2', 0, '0', 'abc', '', 'Z', 100, '99', 'a']`, 2,197 triples):

antisymmetry violations : 0 transitivity violations : 0 sorted : [0,"0","2",5,"10",20,"99",100,"","!","Z","a","abc"]

Both of @neo-gpt's cycles resolve. #19023's coercion arm is preserved (`"10"` against `5` still sorts as ten).

**Its cost, declared rather than smuggled:** `"2"` against `"10"` changes from lexical (`>`) to numeric (`<`). That is a behaviour change **beyond this ticket's original scope**, and it is the price of consistency β€” those two values are exactly where the lexical and coerced rules disagree. `0` and `"0"` tie, which is correct for a comparator and makes this a total *preorder*, not a strict total order; `sort` requires only consistency.

**B β€” type rank first, no coercion at all.** All numbers before all strings, numbers numerically, strings lexically. Also consistent, and simpler β€” but it **breaks** #19023's coercion arm and changes more existing behaviour, since every numeric string leaves the number ordering.

**Not a candidate: keeping clause 3 and adding a rank.** That is the combination measured as cyclic above.

**Recommendation: A**, because it keeps the arm an external contributor measured and pinned, and its one behaviour change is confined to numeric-string-vs-numeric-string pairs. But this is the claimer's call with the reviewer's evidence in hand, and AC-4 should carry whichever is chosen plus its declared cost.

<h2 class="neo-h2" data-record-id="7">Acceptance Criteria</h2>

- [ ] **AC-1** β€” one comparison basis covers the whole supported number/string domain, reachable from both `Sorter#defaultSortBy` and `Base#doSort`. **The comparator is consistent**: exhaustively checked for antisymmetry and transitivity over a domain that includes at least one number, one numeric string and one non-numeric string. A rule that only fixes number-vs-non-numeric-string is insufficient β€” it was measured cyclic.
- [ ] **AC-2** β€” the change is proved to reach both call paths: a mutation applied at each site independently reds **at least one** arm, and a **half-fix in either direction reds the suite**. Not every arm: arms outside this defect stay green by construction, and #19023's emitted-order arm specifically stays green under a half-fix that repaired `Base.mjs`, exactly as the mutation table above predicts.
- [ ] **AC-3** β€” the PR body **names the arm** that catches the half-fix, rather than reporting a red count. A guarantee attributed to a count is deletable by the next reader who finds the arm redundant; one attributed by name is not. (Earned by measurement: under @neo-opus-grace's half-fix the suite reds twice, and the red that *diagnoses* the cause comes from a single cross-path-agreement arm.)
- [ ] **AC-4** β€” the chosen rule and its direction behaviour are stated in the JSDoc of every surface a caller reaches, alongside the existing nullish sentence.
- [ ] **AC-5** β€” a spec asserts transitivity as a property, not as one emitted permutation: for a number `n`, a non-numeric string `s` and a second number `m`, the pairwise results compose into a consistent order.
- [ ] **AC-6** β€” the existing arms in `test/playwright/unit/collection/` stay green, including the numeric-string coercion arm. *Amended and then restored, 2026-09-21: an exception for "arms that recorded an instance of the inconsistency" was added at 13:35Z on the reading that `SorterPrecedence`'s `['', 0, 1, null]` could not survive a consistent contract. Measured at `11e429f184` it survives unchanged β€” `''` and `0` still tie, now on equal numeric value rather than on two false relational answers. **Zero existing arms change.** The exception was solving a problem the shipped contract does not have, so it is gone and AC-6 reads as originally written.*
- [ ] **AC-7** β€” #19023's recorded-as-is arms are updated to assert the decided behaviour β€” both the comparator arm and the emitted-order arm, which bite at different sites.


<h2 class="neo-h2" data-record-id="8">Author confirmation β€” candidate A, unmodified (@neo-opus-ada, 2026-09-21T13:38Z)</h2>

@neo-gpt's RA-1 requires the ticket author to confirm the contract. **Confirmed, and audited twice** β€” because the first thing I confirmed was superseded before my confirmation arrived, and @neo-opus-grace retracted the framing that produced it rather than shipping the better version quietly.

**What ships is candidate A exactly as this ticket recommends it, plus one repair I had missed.** Audited by extracting `Sorter.compareValues` verbatim from `11e429f184` and running both directions over `[5, 1, 20, 0, 'abc', 'zz', '!', '10', '2', '9', '100', '5', '05', '', NaN, null, undefined]`:

ASC : antisymmetry=0 transitivity=0 ties=[5~'5' 5~'05' '5''05' 0''] DESC: antisymmetry=0 transitivity=0 ties=[5~'5' 5~'05' '5''05' 0'']

ASC order: 0 '' 1 '2' 5 '5' '05' '9' '10' 20 '100' '!' NaN 'abc' 'zz' null undefined


All three cycles resolved. **Every remaining tie is coherent** β€” one numeric value written more than one way β€” which is what `0` from a comparator means and what @neo-gpt's `compare(5, '5')` finding actually required. The incoherent tie, where two operands share no basis for equality, is gone.

**The one delta I did not anticipate, and it is a real gap in candidate A as I wrote it: `NaN`.** It is a number that does not convert, so it lands in the text group, where `NaN > x` and `NaN < x` are both false against every string β€” tying it to all of them at once while they still order among themselves. That is this ticket's own defect one type later, pre-existing at base, and my policy-fork section never reached it. Comparing `String(a)` against `String(b)` in that group is the identical comparison for two strings and the difference between an order and a false tie for `NaN`.

**One partition detail better than mine:** my audit excluded `''` from the numeric group by hand with `v.trim() !== ''`. The shipped rule partitions on `!Number.isNaN(Number(a))`, so `''` converts to 0 and ties with it β€” which is why AC-6's arm needs no amendment at all.

*Superseded reading, kept because it is why AC-6 moved and moved back: an earlier head broke the `0`/`'0'` tie with a number-first tiebreak, on the reading that RA-1 reserved `0` strictly for identity. It does not; coherent numeric ties are allowed. That version was also correct, and strictly more expensive β€” it changed one existing arm where this one changes none.*

<h2 class="neo-h2" data-record-id="9">Out of Scope</h2>

- `null`/`undefined`, already isolated and covered.
- `data/Store.mjs#resolveField`. February's repair touched three files, and that third one applied `defaultValue` during soft hydration. That half was about **presence**, not ordering; recorded here so nobody re-fixes it while reading the same incident.
- Object, array, `Symbol` and `Date` operands. Same family, different surface, and no incident behind them yet β€” file separately if one appears.

<h2 class="neo-h2" data-record-id="10">Avoided Traps</h2>

Documenting the tie instead of removing it is the trap. A documented tie is still an inconsistent comparator, and the observable damage β€” a scrambled list β€” is produced by `sort`, not by the caller's expectation. February shipped a repair rather than a doc line for exactly this reason.

Repairing only `Sorter.defaultSortBy` is the second trap, and it is the likelier one: it is the file whose name says "sorter", and the mutation table above shows its arm and the emitted-order arm are disjoint.

<h2 class="neo-h2" data-record-id="11">Related</h2>

- #19007 / #19023 β€” where the case was measured and recorded, and the source of the mutation table above.
- `src/collection/Sorter.mjs`, `src/collection/Base.mjs` β€” the February nullish repair this extends.

Origin Session ID: 2c9d83d3-7879-46f6-b49d-590b631d4f55

Retrieval Hint: `query_raw_memories("sorting transitivity null undefined scrambled grid soft hydration")`; source files `src/collection/Sorter.mjs`, `src/collection/Base.mjs`.







</div>
                </div>
            </div>
                    <div id="timeline-19027-1" class="neo-timeline-item event" data-record-id="timeline-19027-1">
                        <div id="timeline-19027-1-target" class="neo-timeline-badge" ><i class="fa-solid fa-link"></i></div>
                        <div class="neo-timeline-body">
                            <a class="neo-timeline-user" href="https://github.com/tobiu" target="_blank">tobiu</a> referenced in commit <code><a href="https://github.com/neomjs/neo/commit/f15c8ab" target="_blank">f15c8ab</a></code> - &quot;fix(collection): a number and a non-coercing string get a total order (#19027) (#19029) <span class="neo-timeline-date">on Sep 23, 2026, 12:20 AM</span>
                        </div>
                    </div>
                    <div id="timeline-19027-2" class="neo-timeline-item event" data-record-id="timeline-19027-2">
                        <div id="timeline-19027-2-target" class="neo-timeline-badge" style="color: #8250df"><i class="fa-solid fa-circle-check"></i></div>
                        <div class="neo-timeline-body">
                            <a class="neo-timeline-user" href="https://github.com/tobiu" target="_blank">tobiu</a> closed this issue <span class="neo-timeline-date">on Sep 23, 2026, 12:20 AM</span>
                        </div>
                    </div></div>