Literal type information is lost when inlining

I have code that successfully detects illegal behavior and refuses to compile when I give incompatible combinations of string-literal encoded types, but only when those types are predefined as variables. When they are inlined at the same site, the code compiles and no errors are reported.

I’m attempting to model propositional logic on the type level. Scastie. Scastie - An interactive playground for Scala.

We model propositions with an OO hierarchy

Proposition
├── Atomic(String)
├── Negation(Proposition)
└── Compound(Proposition, Proposition)
      ├── Implication
      ├── Disjunction
      └── Conjunction
abstract class Proposition:
   val expression: String
   
case class Atomic(expression: String) extends Proposition

abstract class Compound extends Proposition:
   val left: Proposition
   val right: Proposition
   val operation: "∧" | "⋁" | "->"
   
   val expression = s"${left.expression} ${operation} ${right.expression}"
   
case class Implication(left: Proposition, right: Proposition) extends Compound:
   val operation = "->"
case class Disjunction(left: Proposition, right: Proposition) extends Compound:
   val operation = "⋁"
case class Conjunction(left: Proposition, right: Proposition) extends Compound:
   val operation = "∧"

case class Negation(proposition: Proposition) extends Proposition:
   override val expression = s"¬(${proposition.expression})"

And inference rules as functions of (Proposition, Proposition) -> Proposition.

Previously, I was implementing these inference rules through runtime checks, as most simple logic solvers do – they consisted of if statements that would optionally return some derived conclusion or none if it was being misapplied

def modusponens(p1: Proposition, p2: Proposition): Option[Proposition] =
   p1 match
      case p1: Implication =>
         if p2 == p1.left then
            Some(p1.right)
         else None
      case _ => None

While this does make brute-forcing all possible conclusions easier (you can throw any two propositions into modusponens and see if it returns something) I felt it was less correct definitionally. This approach allows you to call modusponens in nonsensical scenarios.

Instead, I want the domain and range of inference rules to be dependently typed, such that they are guaranteed by the compiler to be correct before they are allowed to be called, removing any need for runtime checks. Once you have written p -> q for the first proposition, the only acceptable second proposition - per the definition of modus ponens - is p, and the return type is guaranteed to be q.

I can successfully do this for the most part. I encode the string literal that each Atomic proposition represents as part of the type, using String & Singleton, such that every wrapped expression is considered it’s own distinct type, which is then also captured by all Compound propositions upwards as Left and Right generic arguments.

case class Atomic[S <: String & Singleton](expression: S) extends Proposition

val p = Atomic("p")
//  p: Atomic["p"]
val q = Atomic("q")
// q: Atomic["q"]

case class Implication[L <: Proposition, R <: Proposition](
    left: L, right: R
) extends Compound

val p1 = Implication(p, q)
// p1: Implication[Atomic["p"], Atomic["q"]]
val p2 = Atomic("p")
// p2: Atomic["p"]

Now the inference rules no longer have any need for runtime checks, as the types contain all the information needed. They can be defined simply in terms of the form (shuffling leftside/rightside elements) as pure transformations, which I think is a more faithful implementation.

def modusponens[L <: Proposition, R <: Proposition](
    p1: Implication[L, R], p2: L
): R =
    p1.right

val p1 = Implication(Atomic("p"), Atomic("q"))
val p2 = Atomic("p")

val conclusion = modusponens(
    p1,
    p2
)
//  conclusion: Atomic["q"]

A neat result of this is that the compiler and IDE will stop you if you even attempt to apply modusponens incorrectly, real-time feedback as you write.

val p1 = Implication(Atomic("p"), Atomic("q"))
val p2 = Atomic("q") // <- now q, fallacy of affirming the consequent

val conclusion = modusponens(
    p1,
    p2 //  Error! Expected Atomic("p"), not Atomic("q")
)                                

However, the problem I’ve found is that this behavior stops working if you attempt to inline the arguments

// inline
val conclusion = modusponens(
    Implication(Atomic("p"), Atomic("q")),
    Atomic("q") // should error. does not. Code compiles
)

// predefined
val a = Implication(Atomic("p"), Atomic("q")),
val b = Atomic("q")
val conclusion_ = modusponens(
    a,
    b // error as expected.
)

And I’m not sure why. It seems like the literal type resolution is only able to occur when they are split up into separate toplevel value definitions which is disappointing.

Is it a limitation of Scala, or intended behavior? Is it specific to how I’m working with string literals & Singleton? Is it possible to fix this to get my desired behavior?

Welcome to the Scala community, @Norm_2 :wave:

It’s how the type inference is designed, I don’t think it’s specific to how you are using Singleton and String.

The type is widened to make the call possible (which I think is a general principle, not limited to Singleton):

Implication[Atomic["q" | "p"], Atomic["q"]]

is what you get instead of

Implication[Atomic["p"], Atomic["q"]]

If you spell out the type annotation manually to prevent widening, then it fails as you’d like

Of course this is not a desirable solution.

Scala seems to know the distinction between "p" and "p" | "q" as far as I can tell:

scala> def spam(using ev: "p" =:= ("p" | "q")): Int = 4
def spam(using ev: "p" =:= ("p" | "q")): Int
                                                                                       
scala> spam

-- [E172] Type Error: -----------------------------------------------------------------
1 |spam
  |    ^
  |    Cannot prove that ("p" : String) =:= ("p" : String) | ("q" : String).
1 error found

I see this type of issue from time to time, especially from folks working on this kind of logical application; unfortunately Scala’s widening bites them here. The widening is very useful and desirable in many situations especially more industrial ones; but it’s a limitation in more academic things.

For this type of thing, you need a language like Lean because its type system gives you exactly the literal type of the proposition no matter how long / complicated.

Interestingly there was a SIP to make an exact annotation to avoid type widening but it was not approved.

There are probably solutions to this (other than providing explicit type annotations); but a lot more complicated (redesign your Proposition to be an enum and pattern-match in modusponens, or probably some typeclass solution, maybe even implicit conversions to narrow the type?). But I’m dumb so there might be an easier solution; let’s wait for other ideas!

This might also be related to the Singleton soundness bug:

A <: Singleton and B <: Singleton implies A | B <: Singleton, even when A =/= B

So you can have "p" | "q" <: Singleton

1 Like

It’s how the type inference is designed, … Interestingly there was a SIP to make an exact annotation to avoid type widening but it was not approved.

Ah unfortunate, I had planned to use this type-level verification combined with infix operators like -> on strings to create a logic DSL, turning Scala + VSCode into a sort of jupyter notebook for logic proofs. Wanted to use it for formalizing my own proofs in a language I already know and love. The IDE itself would be the frontend to my logic prover.

prove(
  "p" -> "q", // Implication[Atomic["p"], Atomic["q"]]
  "p" ⋁ "x", // Disjunction[Atomic["p"], Atomic["x"]],
  ¬("x"), // Negation[Atomic["p"]]
  ∴("q") // Therefore[Atomic["Q"]]
)
// result would be an expanded proof from brute-forcing inferences rules

A cool idea. But it looks like not possible until something like that SIP gets approved.
I can make the DSL real, but it would not include the immediate IDE feedback on incorrect steps in logical thinking I wanted. (Don’t want to run the program constantly to make sure I haven’t made a typo etc.)

Curiously in Scala 2 (ignoring that it doesn’t have unions) you can make that work using two parameter lists.
But the improved type inference of Scala 3 breaks that “feature”.

But, what you can do now is to use two different type parameters. And then an implicit evidence for the equality of them.

1 Like

At first I gave that a naive try but ran into the same issue

(Maybe Sporarum is right, it’s due to the Singleton bug?)
Maybe you had something else in mind?

1 Like

I don’t have time to look through the code in detail right now, but compiletime.constValue can be used to detect when a Singleton type is a union rather than an actual single constant. This might help?

scala> inline def cv[L <: (String & Singleton)](l: L): String = compiletime.constValue[l.type]
def cv[L <: String & Singleton](l: L): String
                                                                                
scala> cv("eel")
val res2: String = eel

 scala> var s: String = "eel"
var s: String = eel
                                                                                
scala> cv(s)
-- [E007] Type Mismatch Error: -------------------------------------------------
1 |cv(s)
  |   ^
  |   Found:    (s : String)
  |   Required: String & Singleton
  |
  | longer explanation available when compiling with `-explain`
1 error found
                                                                               
scala> cv("eel": ("eel" | "cod"))
-- [E182] Type Error: ----------------------------------------------------------
1 |cv("eel": ("eel" | "cod"))
  |^^^^^^^^^^^^^^^^^^^^^^^^^^
  |(l$proxy1 : ("eel" : String) | ("cod" : String)) is not a constant type; cannot take constValue
  |-----------------------------------------------------------------------------
  |Inline stack trace
  |- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
  |This location contains code that was inlined from rs$line$6:1
1 |inline def cv[L <: (String & Singleton)](l: L): String = compiletime.constValue[l.type]
  |                                                                                ^^^^^^
   -----------------------------------------------------------------------------
1 error found
1 Like