10

I'm trying to implement Conway's surreal numbers in Scala. A surreal number is defined recursively – as a pair of sets of surreal numbers, called left and right, such that no element in the right set is less than or equal to any element in the left set. Here the relation "less than or equal to" between surreal numbers is also defined recursively: we say that xy if

  • there is no element a in the left set of x such that ya, and
  • there is no element b in the right set of y such that bx.

We start with defining zero as a pair of empty sets, then use zero to define 1 and -1, and so on.

I cannot figure out how to enforce the definition of a surreal number at compile time. This is what I have now:

case class SurrealNumber(left: Set[SurrealNumber], right: Set[SurrealNumber]) {
  if ((for { a <- left; b <- right; if b <= a } yield (a, b)).nonEmpty)
    throw new Exception
  def <=(other: SurrealNumber): Boolean =
    !this.left.exists(other <= _) && !other.right.exists(_ <= this)
}

val zero = SurrealNumber(Set.empty, Set.empty)
val one = SurrealNumber(Set(zero), Set.empty)
val minusOne = SurrealNumber(Set.empty, Set(zero))

assert(zero <= zero)
assert((zero <= one) && !(one <= zero))
assert((minusOne <= zero) && !(zero <= minusOne))

When the arguments are invalid, as in SurrealNumber(Set(one), Set(zero)), this code would throw a runtime exception. Is it possible to express the validity check as a type constraint, so that SurrealNumber(Set(one), Set(zero)) wouldn't compile?

Dmytro Mitin
  • 48,194
  • 3
  • 28
  • 66
siarhei
  • 103
  • 4
  • I don't think the compiler has enough information about SurrealNumber. Maybe it is possible to write a macro to do this assertion at compile time. – Terry Dactyl Jun 26 '20 at 09:31
  • 2
    @TerryDactyl The thing is that for a macro compiler doesn't have enough information either. `SurrealNumber(..., ...)` are runtime values of `zero`, `one`, ... `SurrealNumber` has to be redesigned to be more type-level. – Dmytro Mitin Jun 26 '20 at 10:12
  • 1
    I doubt that restricting the construction to finite sets would preserve any of the interesting properties. – Andrey Tyukin Jun 26 '20 at 11:20
  • 1
    @AndreyTyukin That's true, with finite sets we can only represent a subset of rationals. The point of my question is that even in this situation it isn't trivial to validate the arguments at compile time. – siarhei Jun 26 '20 at 12:02

1 Answers1

7

You could define a macro in order to execute calculations at compile time

case class SurrealNumber private(left: Set[SurrealNumber], right: Set[SurrealNumber]) {
  def <=(other: SurrealNumber): Boolean =
    !this.left.exists(other <= _) && !other.right.exists(_ <= this)
}

object SurrealNumber {
  def unsafeApply(left: Set[SurrealNumber], right: Set[SurrealNumber]): SurrealNumber =
    new SurrealNumber(left, right)

  def apply(left: Set[SurrealNumber], right: Set[SurrealNumber]): SurrealNumber =
    macro applyImpl

  def applyImpl(c: blackbox.Context)(left: c.Tree, right: c.Tree): c.Tree = {
    import c.universe._
    def eval[A](t: Tree): A = c.eval(c.Expr[A](c.untypecheck(t)))
    val l = eval[Set[SurrealNumber]](left)
    val r = eval[Set[SurrealNumber]](right)
    if ((for { a <- l; b <- r; if b <= a } yield (a, b)).nonEmpty)
      c.abort(c.enclosingPosition, "invalid surreal number")
    else q"SurrealNumber.unsafeApply($left, $right)"
  }
}

but the thing is that although

SurrealNumber(Set.empty, Set.empty)

is a compile-time value of zero but

SurrealNumber(Set(zero), Set.empty)
SurrealNumber(Set.empty, Set(zero))

are runtime values of one, minusOne and compiler doesn't have access to them. So

SurrealNumber(Set(SurrealNumber(Set.empty, Set.empty)), Set.empty)
SurrealNumber(Set.empty, Set(SurrealNumber(Set.empty, Set.empty)))

compile but

SurrealNumber(Set(zero), Set.empty)
SurrealNumber(Set.empty, Set(zero))

don't.


So you should redesign SurrealNumber to be more type-level. For example

import shapeless.{::, HList, HNil, IsDistinctConstraint, OrElse, Poly1, Poly2, Refute, poly}
import shapeless.ops.hlist.{CollectFirst, LeftReducer}
import shapeless.test.illTyped

class SurrealNumber[L <: HList : IsDistinctConstraint : IsSorted, 
                    R <: HList : IsDistinctConstraint : IsSorted](implicit
  notExist: Refute[CollectFirst[L, CollectPoly[R]]]
)

trait LEq[S, S1]
object LEq {
  implicit def mkLEq[S,  L  <: HList,  R <: HList, 
                     S1, L1 <: HList, R1 <: HList](implicit
    ev:        S  <:< SurrealNumber[L, R],
    ev1:       S1 <:< SurrealNumber[L1, R1],
    notExist:  Refute[CollectFirst[L, FlippedLEqPoly[S1]]],
    notExist1: Refute[CollectFirst[R1, LEqPoly[S]]]
  ): S LEq S1 = null
}

trait CollectPoly[R <: HList] extends Poly1
object CollectPoly {
  implicit def cse[R <: HList, LElem](implicit 
    exist: CollectFirst[R, LEqPoly[LElem]]
  ): poly.Case1.Aux[CollectPoly[R], LElem, Unit] = null
}

trait LEqPoly[FixedElem] extends Poly1
object LEqPoly {
  implicit def cse[FixedElem, Elem](implicit 
    leq: Elem LEq FixedElem
  ): poly.Case1.Aux[LEqPoly[FixedElem], Elem, Unit] = null
}

trait FlippedLEqPoly[FixedElem] extends Poly1
object FlippedLEqPoly {
  implicit def cse[FixedElem, Elem](implicit 
    leq: FixedElem LEq Elem
  ): poly.Case1.Aux[FlippedLEqPoly[FixedElem], Elem, Unit] = null
}

object isSortedPoly extends Poly2 {
  implicit def cse[Elem, Elem1](implicit 
    leq: Elem LEq Elem1
  ): Case.Aux[Elem, Elem1, Elem1] = null
}
type IsSorted[L <: HList] = (L <:< HNil) OrElse LeftReducer[L, isSortedPoly.type]

val zero = new SurrealNumber[HNil, HNil]
val one = new SurrealNumber[zero.type :: HNil, HNil]
val minusOne = new SurrealNumber[HNil, zero.type :: HNil]
illTyped("new SurrealNumber[one.type :: HNil, zero.type :: HNil]")
new SurrealNumber[zero.type :: HNil, one.type :: HNil]
Dmytro Mitin
  • 48,194
  • 3
  • 28
  • 66
  • 1
    Thanks! Just to make sure I understand: in the type-level solution, if we declare `new SurrealNumber[zero.type :: one.type :: HNil, HNil]` and `new SurrealNumber[one.type :: zero.type :: HNil, HNil]`, these would be two different types, right? Wondering if we could make the parameters `L` and `R` behave more like unordered sets. – siarhei Jun 26 '20 at 11:05
  • Nice - I had a feeling that something like HList with an implicit typeclass was the solution. – Terry Dactyl Jun 26 '20 at 11:16
  • @siarhei Yeah, you'll have to watch that manually. I added constaint `IsSorted`. – Dmytro Mitin Jun 26 '20 at 12:47
  • @siarhei So although both **types** `SurrealNumber[zero.type :: one.type :: HNil, HNil]` and `SurrealNumber[one.type :: zero.type :: HNil, HNil]` are allowed (and are different) but `new SurrealNumber[zero.type :: one.type :: HNil, HNil]` is allowed and `new SurrealNumber[one.type :: zero.type :: HNil, HNil]` is NOT allowed. – Dmytro Mitin Jun 26 '20 at 12:56