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