Surreal Numbers In Python 3

Ratios and Rationalizations

Generating Day 2

So far, we’ve generated 3 forms that we’re familiar with: 0, 1, and -1, represented by {|}, {0|} and {|0} respectively. We also have a batch of new numbers, that we’re not quite sure of:

{1|}{−1|} {0, 1|} {0, −1|} {−1, 1|}
{−1, 0, 1|} {|1} {| − 1} {|0, 1}
{|0, −1} {| − 1, 1} {| − 1, 0, 1} {−1|0}
{−1|1} {−1|0, 1} {0|1} {−1, 0|1}

Moving beyond Day 1, the numbers begin to follow a more intuitive pattern. We can read Surreal numbers as follows:

If S is a surreal number consisting of {a|b}, then S is the number *in-between* a and b. I’m going to start using a physical “number line” as an analog for the Real Numbers, as it’s easier to concieve of the | in the surreal-notation to be akin to a marker on an analog scale, where the number can be read by looking at the numbers surrounding the marker-line. Therefore, I will be using “left” and “right” as analogs for “less-than” and “greater-than” respectively.

We can then conceptualize previous numbers, such as {0|} is the number to the right of 0, which is 1. We can then carry this rule further on. If S = {1|}, then S is the number to the right of 1, which is 2. We’ve now added 2 to our list of forms. Furthermore, we can take the negative of the statement. If S = {|-1}, then S is the number to the left of -1, which is negative 2. Another form for our collection, -2.

By this point, you may have noticed that using entire sets for the left and right values of a surreal is a bit redundant. Equivalent forms yield equivalent numbers, so something like:

{-1, 0, 1|} === {1|} = 2 and {|0, 1} === {|0} = -1 and finally {-1, 0| 2} === {0|2} = 1 Essentially, we only care about max(left) and min(right) to define a number.

We can add these shortcuts to our Surreal class as follows

    @property
    def xl(self):
        return max(self.left_set or (None,))

    @property
    def xr(self):
        return min(self.right_set or (None,))

Rationals

Great, so we can generate all the integers. But even grade-schoolers know about decimals/fractions. Where do those numbers come from in our system? If we remember the bathroom-scale analog, we can think of the | (pipe) as a sort of arrow/indicator, where we can see surrounding numbers to generate further ones. We can start to make some rational numbers by looking “where they live”, in-between integers.

Thus, we can think of the following surreal, {0|1}, as “the number between 0 and 1”, or 1/2. Similarly, -1/2 is represented by {-1|0}. We can now add these rationals to our forms, to look even “further” in.

{1/2|1} = 3/4, born on day 3. {1/2|3/4} = 5/8. If you notice, our denominator will always be a power-of-2, so we call these the Dyadic Rationals.

Actually coding this

Conway uses a recursive function to define his conversion. It follows the common logic of splitting the problem into the base-cases and recursive-case. With surreals, the base cases are -1, 0, and 1. As such, we’ll include special classmethods to act as constructors for these special cases.

    @classmethod
    def zero(cls):
        return cls(0, nothing, nothing)

    @classmethod
    def one(cls):
        return cls(1, (0,), nothing)

    @classmethod
    def neg_one(cls):
        return cls(1, nothing, (0,))

We can then start to write a __float__ function, allowing for easy conversion of Surreals to their Real Number counterparts. We’ll start by coding the base-cases:

    def __float__(self):
        if self == Surreal.zero():
            return 0.
        elif self == Surreal.one():
            return 1.
        elif self == Surreal.neg_one():
            return -1.
        else:
        	raise NotImplementedError()

In order to cover the rest of the number-line, we need to come up with a general-purpose algorithm. Remember previously, we defined 3 cases where we could make a “form” for our collection: left-number, right-number, and in-between-number, where the first 2 increment/decrement the number, and the last finds the number in-between the two numbers given.

		...
        else:
            if len(self.left_set) == 0:  # Left number
                return min(self.right_set) - 1
            elif len(self.right_set) == 0:  # Right number
                return max(self.left_set) + 1
            else:  # In-between
                return (min(self.right_set) + max(self.left_set)) / 2

Going back

We should be able to do the inverse of float-conversion, where we can pass a number to our class, and it converts it to a valid Surreal. However, we can’t simply go from a decimal number to a Surreal; we’re only able to generate numbers from a certain domain, so it stands to reason that using the same rules, we can only convert certain numbers back into Surreals. We can generate all integers (k(n+1) = k(n) + 1) and all dyadic rationals (k(n+1) = (2(k(n) + 1) / 2^n).

To start, we’ll use a simpler example that follows an easy pattern- the integers. We know that a positive integer-number i can be expressed in Surreal form as {i-1|}. Negative integers simply flip which set is used, as i === {|i+1}. Encapsulating our base-case of zero, we get the following code:


    @classmethod
    def from_int(cls, i: int):
        if i == 0:
            return Surreal.zero()
        elif i > 0:
            return Surreal(abs(i), (i-1,), nothing)
        elif i < 0:
            return Surreal(abs(i), nothing,(i+1,))
        else:
            raise Exception("NaN")

Dyadics are a bit harder than the plain integers, as one needs to find an odd-integer numerator N, and a denominator that’s a power-of-2. Without going through the details of solving the equations, we find that the surreal form of dyadic x is { x - 1/2^{k} | x + 1/2^{k} }. This will produce a number whose numeric-average is equal to x.

(x - 1/2^{k}) + (x + 1/2^{k})/2 = 2x/2 = x

We can use Python’s built-in Fraction class to help conver to rational-fractions, and store the numerator and denominator separately. We can also use math.log2 to help us find k.


    @classmethod
    def from_dyadic(cls, f: Union[Fraction, float]):
        if isinstance(f, float):
            f = Fraction(Decimal(f))
        k = log2(f.denominator)
        n = abs(f.numerator//2)+1
        return Surreal(n, (f - (1/2)**k,), (f + (1/2)**k,))

To check, we can assert some quick tests:

With all this in place, we can finally create a Surreal generation-function that includes the dyadic-rationals:


Full Jupyter notebook here

Written on June 4, 2021