<!-- canonical: efficientnewlanguage.org/ai/examples/275-version-string-ordering | ai_layer_version: 0.1.0 | updated: 2026-08-06 -->

# Example 275 — Version string ordering — correct until the tenth release

`version_string_ordering.eml` sorts one version list three ways — as text, by numeric component, and by numeric component with missing parts padded to zero — and counts how many ordered **pairs** the rules disagree on.

## EML

```eml
# Self-authored for the EML case corpus (no external origin). Version 1.10 is
# older than version 1.9, according to every string comparison ever written.
#
# A version is a TUPLE of numbers that happens to be written with dots. Sorting
# it as text compares "10" against "9" one character at a time, decides that
# "1" < "9", and stops. The result is a list that looks sorted, is sorted by
# some order, and is not sorted by the order anyone meant.
#
# It is invisible for a long time because it is CORRECT for every project that
# has not reached a tenth release. 1.1 through 1.9 sort identically under both
# rules. The first disagreement arrives with 1.10, by which point the sort is
# load-bearing.
#
# The measurement sorts one version list three ways - as text, by numeric
# component, and by numeric component with a shorter version padded - and
# reports how many ordered PAIRS the rules disagree on, plus where each rule
# says the newest release is.

def digit_val(c):
    "0123456789" => ds
    for i in [0:9]:
        if ds[i] == c:
            return i
    return 0 - 1

def parse_parts(v):
    # Split on "." by hand; there is no split() here, which is just as well
    # because writing it out shows how few rules it really has.
    [] => parts
    0 => cur
    0 => have
    for i in [0:len(v) - 1]:
        v[i] => c
        if c == ".":
            parts + [cur] => parts
            0 => cur
            0 => have
        else:
            digit_val(c) => d
            if d >= 0:
                cur * 10 + d => cur
                1 => have
    parts + [cur] => parts
    return parts

def cmp_text(a, b):
    # -1, 0, 1 by character, which is what a plain string sort does.
    len(a) => la
    len(b) => lb
    la => m
    if lb < m:
        lb => m
    for i in [0:m - 1]:
        if a[i] < b[i]:
            return 0 - 1
        if a[i] > b[i]:
            return 1
    if la < lb:
        return 0 - 1
    if la > lb:
        return 1
    return 0

def cmp_parts(a, b):
    # Component by component, missing components treated as absent.
    parse_parts(a) => pa
    parse_parts(b) => pb
    len(pa) => la
    len(pb) => lb
    la => m
    if lb < m:
        lb => m
    for i in [0:m - 1]:
        if pa[i] < pb[i]:
            return 0 - 1
        if pa[i] > pb[i]:
            return 1
    if la < lb:
        return 0 - 1
    if la > lb:
        return 1
    return 0

def cmp_padded(a, b):
    # Component by component, missing components treated as ZERO. "1.2" and
    # "1.2.0" are then the same version, which is a real and different choice.
    parse_parts(a) => pa
    parse_parts(b) => pb
    len(pa) => m
    if len(pb) > m:
        len(pb) => m
    for i in [0:m - 1]:
        0 => x
        0 => y
        if i < len(pa):
            pa[i] => x
        if i < len(pb):
            pb[i] => y
        if x < y:
            return 0 - 1
        if x > y:
            return 1
    return 0

def sort_by(vs, rule):
    [] => out
    for v in vs:
        out + [v] => out
    1 => i
    while i < len(out):
        out[i] => cur
        i - 1 => j
        1 => moving
        while moving == 1:
            0 => moving
            if j >= 0:
                0 => c
                if rule == "text":
                    cmp_text(out[j], cur) => c
                elif rule == "parts":
                    cmp_parts(out[j], cur) => c
                else:
                    cmp_padded(out[j], cur) => c
                if c > 0:
                    out[j] => out[j + 1]
                    j - 1 => j
                    1 => moving
        cur => out[j + 1]
        i + 1 => i
    return out

def join(vs):
    "" => s
    for v in vs:
        if len(s) > 0:
            s + " " => s
        s + v => s
    return s


["1.9", "1.10", "1.2", "1.20", "2.0", "1.9.1", "1.10.0", "10.0"] => versions

"rule       sorted"^0
{} => sorted_by
for rule in ["text", "parts", "padded"]:
    sort_by(versions, rule) => s
    s => sorted_by[rule]
    ("%-10s %s" % (rule, join(s)))^0

""^0
("versions: " + str(len(versions)))^0
for rule in ["text", "parts", "padded"]:
    sorted_by[rule] => s
    ("  " + rule + " says the newest is " + s[len(s) - 1])^0

# ------------------------------------------- where the rules disagree
""^0
"ordered pairs the rules disagree on:"^0
0 => text_vs_parts
0 => parts_vs_padded
0 => pairs
0 => shown
for i in [0:len(versions) - 1]:
    for j in [i + 1:len(versions) - 1]:
        versions[i] => a
        versions[j] => b
        pairs + 1 => pairs
        cmp_text(a, b) => ct
        cmp_parts(a, b) => cp
        cmp_padded(a, b) => cd
        if not (ct == cp):
            text_vs_parts + 1 => text_vs_parts
            if shown < 3:
                shown + 1 => shown
                ("  " + a + " vs " + b + ": text says " + str(ct) + ", components say " + str(cp))^0
        if not (cp == cd):
            parts_vs_padded + 1 => parts_vs_padded
("  pairs compared: " + str(pairs))^0
("  text vs components disagree on:   " + str(text_vs_parts))^0
("  components vs padded disagree on: " + str(parts_vs_padded))^0

# ---------------------------------- the range where nothing goes wrong
""^0
["1.1", "1.2", "1.3", "1.4", "1.5", "1.6", "1.7", "1.8", "1.9"] => early
0 => early_disagree
for i in [0:len(early) - 1]:
    for j in [i + 1:len(early) - 1]:
        if not (cmp_text(early[i], early[j]) == cmp_parts(early[i], early[j])):
            early_disagree + 1 => early_disagree
("before a tenth release (1.1 .. 1.9), pairs where the rules disagree: " + str(early_disagree))^0
"...which is every version this project had until it had ten."^0

# ------------------------------------------------------------------ checks
0 => passed
0 => checked

# Text order must put 1.10 before 1.9. That is the whole defect, stated as a
# value rather than as a story.
checked + 1 => checked
if cmp_text("1.10", "1.9") < 0:
    passed + 1 => passed

# Component order must put it after.
checked + 1 => checked
if cmp_parts("1.10", "1.9") > 0:
    passed + 1 => passed

# The two rules must agree on every pair before the tenth release, or the
# claim about why this survives is wrong.
checked + 1 => checked
if early_disagree == 0:
    passed + 1 => passed

# Padding must change something - "1.2" against "1.2.0" is the case where
# treating a missing component as zero is a decision rather than a detail.
checked + 1 => checked
if cmp_parts("1.2", "1.2.0") < 0 and cmp_padded("1.2", "1.2.0") == 0:
    passed + 1 => passed

# And the sorts must actually be sorted under their own rule, or the
# comparison above is measuring a broken sort rather than a rule.
checked + 1 => checked
0 => monotone
for rule in ["text", "parts", "padded"]:
    sorted_by[rule] => s
    1 => ok
    for i in [0:len(s) - 2]:
        0 => c
        if rule == "text":
            cmp_text(s[i], s[i + 1]) => c
        elif rule == "parts":
            cmp_parts(s[i], s[i + 1]) => c
        else:
            cmp_padded(s[i], s[i + 1]) => c
        if c > 0:
            0 => ok
    if ok == 1:
        monotone + 1 => monotone
if monotone == 3:
    passed + 1 => passed

""^0
("checks passed: " + str(passed) + "/" + str(checked))^0
if passed == checked:
    "The sort is correct until the tenth release, and then permanently wrong." => verdict
else:
    "FAILED - a version ordering did not behave as the checks describe." => verdict
verdict^0

""^0
"A version is a tuple written with dots, and the dots are not decoration -" => n1
n1^0
"they are the separator that says where one number ends. Comparing the" => n2
n2^0
"whole thing as text is not a shortcut for comparing the tuple; it is a" => n3
n3^0
"different comparison that happens to agree for the first nine releases," => n4
n4^0
"which is exactly long enough for nobody to be watching when it stops." => n5
n5^0
```

## Python (deterministic transpilation)

```python
def digit_val(c):
    ds = "0123456789"
    for i in range(0, 10):
        if ds[i] == c:
            return i
    return 0 - 1

def parse_parts(v):
    parts = []
    cur = 0
    have = 0
    for i in range(0, len(v)):
        c = v[i]
        if c == ".":
            parts = parts + [cur]
            cur = 0
            have = 0
        else:
            d = digit_val(c)
            if d >= 0:
                cur = cur * 10 + d
                have = 1
    parts = parts + [cur]
    return parts

def cmp_text(a, b):
    la = len(a)
    lb = len(b)
    m = la
    if lb < m:
        m = lb
    for i in range(0, m):
        if a[i] < b[i]:
            return 0 - 1
        if a[i] > b[i]:
            return 1
    if la < lb:
        return 0 - 1
    if la > lb:
        return 1
    return 0

def cmp_parts(a, b):
    pa = parse_parts(a)
    pb = parse_parts(b)
    la = len(pa)
    lb = len(pb)
    m = la
    if lb < m:
        m = lb
    for i in range(0, m):
        if pa[i] < pb[i]:
            return 0 - 1
        if pa[i] > pb[i]:
            return 1
    if la < lb:
        return 0 - 1
    if la > lb:
        return 1
    return 0

def cmp_padded(a, b):
    pa = parse_parts(a)
    pb = parse_parts(b)
    m = len(pa)
    if len(pb) > m:
        m = len(pb)
    for i in range(0, m):
        x = 0
        y = 0
        if i < len(pa):
            x = pa[i]
        if i < len(pb):
            y = pb[i]
        if x < y:
            return 0 - 1
        if x > y:
            return 1
    return 0

def sort_by(vs, rule):
    out = []
    for v in vs:
        out = out + [v]
    i = 1
    while i < len(out):
        cur = out[i]
        j = i - 1
        moving = 1
        while moving == 1:
            moving = 0
            if j >= 0:
                c = 0
                if rule == "text":
                    c = cmp_text(out[j], cur)
                elif rule == "parts":
                    c = cmp_parts(out[j], cur)
                else:
                    c = cmp_padded(out[j], cur)
                if c > 0:
                    out[j + 1] = out[j]
                    j = j - 1
                    moving = 1
        out[j + 1] = cur
        i = i + 1
    return out

def join(vs):
    s = ""
    for v in vs:
        if len(s) > 0:
            s = s + " "
        s = s + v
    return s

versions = ["1.9", "1.10", "1.2", "1.20", "2.0", "1.9.1", "1.10.0", "10.0"]
print("rule       sorted")
sorted_by = {}
for rule in ["text", "parts", "padded"]:
    s = sort_by(versions, rule)
    sorted_by[rule] = s
    print("%-10s %s" % (rule, join(s)))
print("")
print("versions: " + str(len(versions)))
for rule in ["text", "parts", "padded"]:
    s = sorted_by[rule]
    print("  " + rule + " says the newest is " + s[len(s) - 1])
print("")
print("ordered pairs the rules disagree on:")
text_vs_parts = 0
parts_vs_padded = 0
pairs = 0
shown = 0
for i in range(0, len(versions)):
    for j in range(i + 1, len(versions)):
        a = versions[i]
        b = versions[j]
        pairs = pairs + 1
        ct = cmp_text(a, b)
        cp = cmp_parts(a, b)
        cd = cmp_padded(a, b)
        if not ct == cp:
            text_vs_parts = text_vs_parts + 1
            if shown < 3:
                shown = shown + 1
                print("  " + a + " vs " + b + ": text says " + str(ct) + ", components say " + str(cp))
        if not cp == cd:
            parts_vs_padded = parts_vs_padded + 1
print("  pairs compared: " + str(pairs))
print("  text vs components disagree on:   " + str(text_vs_parts))
print("  components vs padded disagree on: " + str(parts_vs_padded))
print("")
early = ["1.1", "1.2", "1.3", "1.4", "1.5", "1.6", "1.7", "1.8", "1.9"]
early_disagree = 0
for i in range(0, len(early)):
    for j in range(i + 1, len(early)):
        if not cmp_text(early[i], early[j]) == cmp_parts(early[i], early[j]):
            early_disagree = early_disagree + 1
print("before a tenth release (1.1 .. 1.9), pairs where the rules disagree: " + str(early_disagree))
print("...which is every version this project had until it had ten.")
passed = 0
checked = 0
checked = checked + 1
if cmp_text("1.10", "1.9") < 0:
    passed = passed + 1
checked = checked + 1
if cmp_parts("1.10", "1.9") > 0:
    passed = passed + 1
checked = checked + 1
if early_disagree == 0:
    passed = passed + 1
checked = checked + 1
if cmp_parts("1.2", "1.2.0") < 0 and cmp_padded("1.2", "1.2.0") == 0:
    passed = passed + 1
checked = checked + 1
monotone = 0
for rule in ["text", "parts", "padded"]:
    s = sorted_by[rule]
    ok = 1
    for i in range(0, len(s) - 2+1):
        c = 0
        if rule == "text":
            c = cmp_text(s[i], s[i + 1])
        elif rule == "parts":
            c = cmp_parts(s[i], s[i + 1])
        else:
            c = cmp_padded(s[i], s[i + 1])
        if c > 0:
            ok = 0
    if ok == 1:
        monotone = monotone + 1
if monotone == 3:
    passed = passed + 1
print("")
print("checks passed: " + str(passed) + "/" + str(checked))
if passed == checked:
    verdict = "The sort is correct until the tenth release, and then permanently wrong."
else:
    verdict = "FAILED - a version ordering did not behave as the checks describe."
print(verdict)
print("")
n1 = "A version is a tuple written with dots, and the dots are not decoration -"
print(n1)
n2 = "they are the separator that says where one number ends. Comparing the"
print(n2)
n3 = "whole thing as text is not a shortcut for comparing the tuple; it is a"
print(n3)
n4 = "different comparison that happens to agree for the first nine releases,"
print(n4)
n5 = "which is exactly long enough for nobody to be watching when it stops."
print(n5)
```

## stdout (executed)

```text
rule       sorted
text       1.10 1.10.0 1.2 1.20 1.9 1.9.1 10.0 2.0
parts      1.2 1.9 1.9.1 1.10 1.10.0 1.20 2.0 10.0
padded     1.2 1.9 1.9.1 1.10 1.10.0 1.20 2.0 10.0

versions: 8
  text says the newest is 2.0
  parts says the newest is 10.0
  padded says the newest is 10.0

ordered pairs the rules disagree on:
  1.9 vs 1.10: text says 1, components say -1
  1.9 vs 1.20: text says 1, components say -1
  1.9 vs 1.10.0: text says 1, components say -1
  pairs compared: 28
  text vs components disagree on:   9
  components vs padded disagree on: 1

before a tenth release (1.1 .. 1.9), pairs where the rules disagree: 0
...which is every version this project had until it had ten.

checks passed: 5/5
The sort is correct until the tenth release, and then permanently wrong.

A version is a tuple written with dots, and the dots are not decoration -
they are the separator that says where one number ends. Comparing the
whole thing as text is not a shortcut for comparing the tuple; it is a
different comparison that happens to agree for the first nine releases,
which is exactly long enough for nobody to be watching when it stops.
```

## Round-trip

`ok: true` — round-trip fixpoint reached (python1 == python2)

## Trace event types

eml:run:start · eml:def · eml:assign · eml:output · eml:call · eml:return · eml:run:done
