CODE-0004 · claude-opus-4-8 (max)"""Convert between integers and canonical (subtractive) Roman numerals.
Standard library only. Two public functions:
- ``int_to_roman(n)`` -> str
- ``roman_to_int(s)`` -> int
They are inverses on the inclusive range 1..3999:
``roman_to_int(int_to_roman(n)) == n``.
"""
# Value/symbol pairs in descending order, including the six subtractive
# forms. Greedy descent over this table produces the unique canonical
# numeral for any integer in 1..3999.
_ROMAN_TABLE = (
(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"),
)
# Single-character values, used to parse a candidate numeral before the
# canonical round-trip check.
_VALUES = {
"I": 1,
"V": 5,
"X": 10,
"L": 50,
"C": 100,
"D": 500,
"M": 1000,
}
def int_to_roman(n):
"""Return the canonical Roman numeral for integer ``n`` (1..3999).
Uses subtractive forms (IV, IX, XL, XC, CD, CM). Raises ``ValueError``
if ``n`` is not an integer in the inclusive range 1..3999.
"""
# Reject bools explicitly (bool is a subclass of int) and any non-int.
if isinstance(n, bool) or not isinstance(n, int):
raise ValueError("int_to_roman requires an integer in 1..3999")
if n < 1 or n > 3999:
raise ValueError("int_to_roman requires an integer in 1..3999")
parts = []
remaining = n
for value, symbol in _ROMAN_TABLE:
if remaining == 0:
break
count, remaining = divmod(remaining, value)
if count:
parts.append(symbol * count)
return "".join(parts)
def roman_to_int(s):
"""Return the integer value of a canonical Roman numeral string.
Raises ``ValueError`` for an empty string, a non-string input, or any
malformed or non-canonical numeral (e.g. "IIII", "VX", "ABC", "MMMM").
Canonicality is enforced by parsing the string and then requiring it to
equal the canonical numeral for the parsed value: since each integer in
1..3999 has exactly one canonical Roman form, only that exact spelling
round-trips, so every non-canonical or malformed variant is rejected.
"""
if not isinstance(s, str):
raise ValueError("roman_to_int requires a non-empty Roman numeral string")
if s == "":
raise ValueError("roman_to_int requires a non-empty Roman numeral string")
# Every character must be a valid Roman symbol. Casefold is not applied:
# canonical numerals are uppercase, so lowercase input is rejected.
total = 0
prev_value = 0
for ch in reversed(s):
value = _VALUES.get(ch)
if value is None:
raise ValueError("invalid Roman numeral: {!r}".format(s))
# Standard right-to-left accumulation: subtract when a smaller
# symbol precedes a larger one. This yields the numeric value for
# any well-ordered string; the round-trip below rejects the rest.
if value < prev_value:
total -= value
else:
total += value
prev_value = value
# The parsed value may still be out of range or non-canonical. The
# canonical round-trip is the single source of truth for validity.
if total < 1 or total > 3999:
raise ValueError("invalid Roman numeral: {!r}".format(s))
if int_to_roman(total) != s:
raise ValueError("non-canonical Roman numeral: {!r}".format(s))
return total