Project P061

Roman numerals

Numbers from 1 to 3999 to Roman numerals and back, each split into its parts; a check that a numeral is written the standard way - IIII and IC read, but are not - with the right way given; and adding and subtracting Roman numerals.

2 modules · 2 recorded sessionstext-menu UI in the terminalupdated 2026-10-14

Every screen below was recorded under CPython. When this page was built, the EML interpreter replayed each session from the same input and printed the same bytes.

About

Numbers from 1 to 3999 to Roman numerals and back, each numeral split into its parts. A numeral typed in is checked for the standard way of writing it; when it reads but is not standard, the right way is given. Roman numerals can also be added and subtracted.

  • main.eml - the menu, reading numbers and numerals, and the messages
  • roman.eml - both directions, the standard-form check, and splitting a numeral into parts

How each part works:

  • To a numeral: walk a table of values from 1000 (M) down to 1 (I) and take the largest that still fits, again and again. The subtractive pairs - CM, CD, XC, XL, IX, IV - are entries of the table, as in the corpus case roman-numeral-converter, so no special case is needed.
  • To a number: add each letter's value, except subtract a letter smaller than the one after it, the reading rule of the corpus case roman-to-integer.
  • That rule also reads numerals that are not written the standard way: IIII as 4, IC as 99, VX as 5. So a numeral counts as standard only when turning its value back into a numeral gives the same letters - the round trip the two corpus cases make between them. Lower case is read as upper case.
  • Roman numerals have no zero and no negatives, and past 3999 they need a bar over the letters, which plain text cannot show. So sums and differences outside 1 to 3999 are given as numbers only.

What is checked: menu choices 1 to 5; numbers from 1 to 3999; numerals using only I V X L C D M, in the standard form, from I to MMMCMXCIX. An empty answer cancels.

Sessions: sessions/basic.in gives: - 1994, 2026 and 3999 to numerals, with their parts; - mmxxvi, in lower case, and XLII back to numbers; - XIV + XXVIII = XLII, and MMXXVI - MCMXCIV = XXXII.

sessions/bad-input.in gives: - menu choices 0 and x; - the numbers 0, 4000, -5 and abc; - the numerals IIII, IC, VX and IIX, which read but are not standard, MMMM (4000), and ABC; - MMM + M, which passes 3999; - V - V and X - L, which have no numeral.

Built on the verified corpus cases roman-numeral-converter (a greedy walk down a table that includes the subtractive pairs) and roman-to-integer (the reverse reading rule, checked as a round trip).

Recorded sessions

What the screen shows while someone uses the program. Each typed line appears after its prompt, the way a terminal shows it.

bad-input

interpreter: byte-equal
== Roman numerals ==
Numbers from 1 to 3999 to Roman numerals and back, checked for the standard way of writing them.

1) to Roman  2) from Roman  3) add  4) subtract  5) quit
choice> 0
Pick a number from 1 to 5.

1) to Roman  2) from Roman  3) add  4) subtract  5) quit
choice> x
Pick a number from 1 to 5.

1) to Roman  2) from Roman  3) add  4) subtract  5) quit
choice> 1
number (1 to 3999)> 0
Type a whole number from 1 to 3999 - Roman numerals have no zero, and larger numbers need a bar over the letters.
number (1 to 3999)> 4000
Type a whole number from 1 to 3999 - Roman numerals have no zero, and larger numbers need a bar over the letters.
number (1 to 3999)> -5
Type a whole number from 1 to 3999 - Roman numerals have no zero, and larger numbers need a bar over the letters.
number (1 to 3999)> abc
Type a whole number from 1 to 3999 - Roman numerals have no zero, and larger numbers need a bar over the letters.
number (1 to 3999)> 
Cancelled.

1) to Roman  2) from Roman  3) add  4) subtract  5) quit
choice> 2
Roman numeral> IIII
IIII reads as 4, but is not the standard way to write it: 4 is IV.
Roman numeral> IC
IC reads as 99, but is not the standard way to write it: 99 is XCIX.
Roman numeral> VX
VX reads as 5, but is not the standard way to write it: 5 is V.
Roman numeral> MMMM
MMMM reads as 4000, but Roman numerals here go from I (1) to MMMCMXCIX (3999).
Roman numeral> ABC
Use only the letters I V X L C D M.
Roman numeral> IIX
IIX reads as 10, but is not the standard way to write it: 10 is X.
Roman numeral> 
Cancelled.

1) to Roman  2) from Roman  3) add  4) subtract  5) quit
choice> 3
first numeral> MMM
second numeral> M
MMM + M is 3000 + 1000 = 4000.
That is past MMMCMXCIX (3999), the largest numeral without a bar over the letters.

1) to Roman  2) from Roman  3) add  4) subtract  5) quit
choice> 4
first numeral> V
second numeral> V
V - V is 5 - 5 = 0.
There is no Roman numeral for that: they start at I, with no zero or negatives.

1) to Roman  2) from Roman  3) add  4) subtract  5) quit
choice> 4
first numeral> X
second numeral> L
X - L is 10 - 50 = -40.
There is no Roman numeral for that: they start at I, with no zero or negatives.

1) to Roman  2) from Roman  3) add  4) subtract  5) quit
choice> 5
Bye.
What was typed (26 lines)
0
x
1
0
4000
-5
abc

2
IIII
IC
VX
MMMM
ABC
IIX

3
MMM
M
4
V
V
4
X
L
5

basic

interpreter: byte-equal
== Roman numerals ==
Numbers from 1 to 3999 to Roman numerals and back, checked for the standard way of writing them.

1) to Roman  2) from Roman  3) add  4) subtract  5) quit
choice> 1
number (1 to 3999)> 1994
1994 is MCMXCIV = M (1000) + CM (900) + XC (90) + IV (4).

1) to Roman  2) from Roman  3) add  4) subtract  5) quit
choice> 1
number (1 to 3999)> 2026
2026 is MMXXVI = M (1000) + M (1000) + X (10) + X (10) + V (5) + I (1).

1) to Roman  2) from Roman  3) add  4) subtract  5) quit
choice> 1
number (1 to 3999)> 3999
3999 is MMMCMXCIX = M (1000) + M (1000) + M (1000) + CM (900) + XC (90) + IX (9).

1) to Roman  2) from Roman  3) add  4) subtract  5) quit
choice> 2
Roman numeral> mmxxvi
MMXXVI is 2026 = M (1000) + M (1000) + X (10) + X (10) + V (5) + I (1).

1) to Roman  2) from Roman  3) add  4) subtract  5) quit
choice> 2
Roman numeral> XLII
XLII is 42 = XL (40) + I (1) + I (1).

1) to Roman  2) from Roman  3) add  4) subtract  5) quit
choice> 3
first numeral> XIV
second numeral> XXVIII
XIV + XXVIII is 14 + 28 = 42.
In Roman numerals: XLII.

1) to Roman  2) from Roman  3) add  4) subtract  5) quit
choice> 4
first numeral> MMXXVI
second numeral> MCMXCIV
MMXXVI - MCMXCIV is 2026 - 1994 = 32.
In Roman numerals: XXXII.

1) to Roman  2) from Roman  3) add  4) subtract  5) quit
choice> 5
Bye.
What was typed (17 lines)
1
1994
1
2026
1
3999
2
mmxxvi
2
XLII
3
XIV
XXVIII
4
MMXXVI
MCMXCIV
5

Modules

The program as written, entry module first. Each module transpiles to its own Python file, which is what eml project run executes.

main.eml(entry)

eml
# P061 Roman numerals: numbers to Roman numerals and back, a check that a
# numeral is written the standard way (and the right way when it is not),
# and adding and subtracting Roman numerals.
import roman

def trim(s):
    0 => i
    len(s) => j
    while i < j and s[i] == " ":
        i + 1 => i
    while j > i and s[j - 1] == " ":
        j - 1 => j
    return s[i:j]

def number(s):
    if s == "" or len(s) > 6:
        return -1
    0 => n
    for c in s:
        if not (c >= "0" and c <= "9"):
            return -1
        n * 10 + int(c) => n
    return n

def breakdown(s):
    "" => out
    for p in roman.parts(s):
        if out != "":
            out + " + " => out
        out + p[1] + " (" + str(p[0]) + ")" => out
    return out

def ask_numeral(prompt):
    # A standard Roman numeral, as [text, value], or [] when the answer is
    # empty. A numeral that reads but is not standard is explained and
    # asked for again.
    while True:
        roman.upper(trim(input(prompt))) => s
        if s == "":
            return []
        roman.value_of(s) => v
        if v == -1:
            "Use only the letters I V X L C D M." ^0
        elif roman.standard(s):
            return [s, v]
        elif v < 1 or v > 3999:
            (s + " reads as " + str(v) + ", but Roman numerals here go from I (1) to MMMCMXCIX (3999).") ^0
        else:
            (s + " reads as " + str(v) + ", but is not the standard way to write it: " + str(v) + " is " + roman.to_roman(v) + ".") ^0

def to_roman():
    while True:
        trim(input("number (1 to 3999)> ")) => answer
        if answer == "":
            "Cancelled." ^0
            return
        number(answer) => n
        if n >= 1 and n <= 3999:
            break
        "Type a whole number from 1 to 3999 - Roman numerals have no zero, and larger numbers need a bar over the letters." ^0
    roman.to_roman(n) => r
    (str(n) + " is " + r + " = " + breakdown(r) + ".") ^0

def from_roman():
    ask_numeral("Roman numeral> ") => r
    if len(r) == 0:
        "Cancelled." ^0
        return
    (r[0] + " is " + str(r[1]) + " = " + breakdown(r[0]) + ".") ^0

def arithmetic(subtract):
    ask_numeral("first numeral> ") => a
    if len(a) == 0:
        "Cancelled." ^0
        return
    ask_numeral("second numeral> ") => b
    if len(b) == 0:
        "Cancelled." ^0
        return
    a[1] + b[1] => v
    "+" => op
    if subtract:
        a[1] - b[1] => v
        "-" => op
    (a[0] + " " + op + " " + b[0] + " is " + str(a[1]) + " " + op + " " + str(b[1]) + " = " + str(v) + ".") ^0
    if v < 1:
        "There is no Roman numeral for that: they start at I, with no zero or negatives." ^0
    elif v > 3999:
        "That is past MMMCMXCIX (3999), the largest numeral without a bar over the letters." ^0
    else:
        ("In Roman numerals: " + roman.to_roman(v) + ".") ^0

"== Roman numerals ==" ^0
"Numbers from 1 to 3999 to Roman numerals and back, checked for the standard way of writing them." ^0
True => running
while running:
    "" ^0
    "1) to Roman  2) from Roman  3) add  4) subtract  5) quit" ^0
    trim(input("choice> ")) => choice
    if choice == "1":
        to_roman()
    elif choice == "2":
        from_roman()
    elif choice == "3":
        arithmetic(False)
    elif choice == "4":
        arithmetic(True)
    elif choice == "5":
        False => running
    else:
        "Pick a number from 1 to 5." ^0
"Bye." ^0
Python projection (main.py)
import roman

def trim(s):
    i = 0
    j = len(s)
    while i < j and s[i] == " ":
        i = i + 1
    while j > i and s[j - 1] == " ":
        j = j - 1
    return s[i:j]

def number(s):
    if s == "" or len(s) > 6:
        return -1
    n = 0
    for c in s:
        if not (c >= "0" and c <= "9"):
            return -1
        n = n * 10 + int(c)
    return n

def breakdown(s):
    out = ""
    for p in roman.parts(s):
        if out != "":
            out = out + " + "
        out = out + p[1] + " (" + str(p[0]) + ")"
    return out

def ask_numeral(prompt):
    while True:
        s = roman.upper(trim(input(prompt)))
        if s == "":
            return []
        v = roman.value_of(s)
        if v == -1:
            print("Use only the letters I V X L C D M.")
        elif roman.standard(s):
            return [s, v]
        elif v < 1 or v > 3999:
            print(s + " reads as " + str(v) + ", but Roman numerals here go from I (1) to MMMCMXCIX (3999).")
        else:
            print(s + " reads as " + str(v) + ", but is not the standard way to write it: " + str(v) + " is " + roman.to_roman(v) + ".")

def to_roman():
    while True:
        answer = trim(input("number (1 to 3999)> "))
        if answer == "":
            print("Cancelled.")
            return
        n = number(answer)
        if n >= 1 and n <= 3999:
            break
        print("Type a whole number from 1 to 3999 - Roman numerals have no zero, and larger numbers need a bar over the letters.")
    r = roman.to_roman(n)
    print(str(n) + " is " + r + " = " + breakdown(r) + ".")

def from_roman():
    r = ask_numeral("Roman numeral> ")
    if len(r) == 0:
        print("Cancelled.")
        return
    print(r[0] + " is " + str(r[1]) + " = " + breakdown(r[0]) + ".")

def arithmetic(subtract):
    a = ask_numeral("first numeral> ")
    if len(a) == 0:
        print("Cancelled.")
        return
    b = ask_numeral("second numeral> ")
    if len(b) == 0:
        print("Cancelled.")
        return
    v = a[1] + b[1]
    op = "+"
    if subtract:
        v = a[1] - b[1]
        op = "-"
    print(a[0] + " " + op + " " + b[0] + " is " + str(a[1]) + " " + op + " " + str(b[1]) + " = " + str(v) + ".")
    if v < 1:
        print("There is no Roman numeral for that: they start at I, with no zero or negatives.")
    elif v > 3999:
        print("That is past MMMCMXCIX (3999), the largest numeral without a bar over the letters.")
    else:
        print("In Roman numerals: " + roman.to_roman(v) + ".")

print("== Roman numerals ==")
print("Numbers from 1 to 3999 to Roman numerals and back, checked for the standard way of writing them.")
running = True
while running:
    print("")
    print("1) to Roman  2) from Roman  3) add  4) subtract  5) quit")
    choice = trim(input("choice> "))
    if choice == "1":
        to_roman()
    elif choice == "2":
        from_roman()
    elif choice == "3":
        arithmetic(False)
    elif choice == "4":
        arithmetic(True)
    elif choice == "5":
        running = False
    else:
        print("Pick a number from 1 to 5.")
print("Bye.")

roman.eml

eml
# P061 Roman numerals - the two directions and the check between them.

def table():
    # The values and symbols from largest to smallest, the subtractive pairs
    # (CM, CD, XC, XL, IX, IV) as entries of their own, as in the corpus case
    # roman-numeral-converter.
    return [[1000, "M"], [900, "CM"], [500, "D"], [400, "CD"], [100, "C"], [90, "XC"],
            [50, "L"], [40, "XL"], [10, "X"], [9, "IX"], [5, "V"], [4, "IV"], [1, "I"]]

def to_roman(n):
    # 1 to 3999 as a Roman numeral: take the largest value that still fits,
    # again and again.
    "" => out
    n => left
    for row in table():
        while left >= row[0]:
            out + row[1] => out
            left - row[0] => left
    return out

def letter_value(ch):
    # The value of one letter, upper or lower case, or 0 for anything else.
    "IVXLCDM" => big
    "ivxlcdm" => small
    [1, 5, 10, 50, 100, 500, 1000] => values
    for k in [0:6]:
        if ch == big[k] or ch == small[k]:
            return values[k]
    return 0

def value_of(s):
    # The value by the reading rule of the corpus case roman-to-integer: add
    # each letter, except subtract one that is smaller than the letter after
    # it. Returns -1 when a character is not a Roman letter. The rule reads
    # non-standard numerals too - IIII as 4, IC as 99 - so a value alone does
    # not say the numeral was written right.
    0 => total
    for i in [0:len(s) - 1]:
        letter_value(s[i]) => v
        if v == 0:
            return -1
        0 => next_v
        if i + 1 < len(s):
            letter_value(s[i + 1]) => next_v
        if v < next_v:
            total - v => total
        else:
            total + v => total
    return total

def upper(s):
    "" => out
    for ch in s:
        ch => c
        for k in [0:6]:
            if ch == "ivxlcdm"[k]:
                "IVXLCDM"[k] => c
        out + c => out
    return out

def standard(s):
    # Written the standard way when converting its value back gives the same
    # letters - the round trip the two corpus cases make between them.
    value_of(s) => v
    if v < 1 or v > 3999:
        return False
    return to_roman(v) == upper(s)

def parts(s):
    # A standard numeral split into its table entries, largest first:
    # MCMXCIV is M, CM, XC, IV. Two-letter entries are tried before one.
    [] => out
    upper(s) => t
    0 => i
    while i < len(t):
        False => found
        for row in table():
            if not found and len(row[1]) == 2 and i + 1 < len(t) and t[i:i + 2] == row[1]:
                out + [row] => out
                i + 2 => i
                True => found
        if not found:
            for row in table():
                if not found and len(row[1]) == 1 and t[i] == row[1]:
                    out + [row] => out
                    i + 1 => i
                    True => found
    return out
Python projection (roman.py)
def table():
    return [[1000, "M"], [900, "CM"], [500, "D"], [400, "CD"], [100, "C"], [90, "XC"], [50, "L"], [40, "XL"], [10, "X"], [9, "IX"], [5, "V"], [4, "IV"], [1, "I"]]

def to_roman(n):
    out = ""
    left = n
    for row in table():
        while left >= row[0]:
            out = out + row[1]
            left = left - row[0]
    return out

def letter_value(ch):
    big = "IVXLCDM"
    small = "ivxlcdm"
    values = [1, 5, 10, 50, 100, 500, 1000]
    for k in range(0, 7):
        if ch == big[k] or ch == small[k]:
            return values[k]
    return 0

def value_of(s):
    total = 0
    for i in range(0, len(s)):
        v = letter_value(s[i])
        if v == 0:
            return -1
        next_v = 0
        if i + 1 < len(s):
            next_v = letter_value(s[i + 1])
        if v < next_v:
            total = total - v
        else:
            total = total + v
    return total

def upper(s):
    out = ""
    for ch in s:
        c = ch
        for k in range(0, 7):
            if ch == "ivxlcdm"[k]:
                c = "IVXLCDM"[k]
        out = out + c
    return out

def standard(s):
    v = value_of(s)
    if v < 1 or v > 3999:
        return False
    return to_roman(v) == upper(s)

def parts(s):
    out = []
    t = upper(s)
    i = 0
    while i < len(t):
        found = False
        for row in table():
            if not found and len(row[1]) == 2 and i + 1 < len(t) and t[i:i + 2] == row[1]:
                out = out + [row]
                i = i + 2
                found = True
        if not found:
            for row in table():
                if not found and len(row[1]) == 1 and t[i] == row[1]:
                    out = out + [row]
                    i = i + 1
                    found = True
    return out

Built on these corpus cases

ΣEML 2026

Efficient New Language — a high-density semantic overlay for humans and AI agents.

This site’s demos run on the real EML toolchain, in your browser.

© 2026 EveMissLab (一言諾科技有限公司) / Neo.K. All rights reserved.