Encoding inequalities in an ADT

I have a typeclass that looks like this:

module Observation where

import Prelude
import Prelude qualified as P ((+))

class Observation f a where
  konst : a -> f a
  (+) : f a -> f a -> f a
  (===): f a -> f a -> f Bool

This generates an expression tree, which I would like to be able to serialize to the ledger. I can use it to write expression such as:

(kons 1 + kons 2) === konst 3

To achieve this, I’ve taken the approach similar to that described in Functions - Can we pass them as arguments in a choice? , of encoding it as a data type Obs:

instance Observation Obs a where
  konst = Const
  (+) = Add
  (===) = Equals  -- FAILS TO COMPILE

data Obs a
  = Const a
  | Add with lhs: Obs a, rhs: Obs a
  | Equals with lhs: Obs a, rhs: Obs a

Now this doesn’t work - error on the implementation of (===) = Equals. The reason is that in the typeclass, this requires a return type of Obs Bool, but the ADT constructor Equals infers an Obs a.

This is practically the motivating problem described for GADTs in the Haskell wiki. This language feature doesn’t quite work in DAML unfortunately.

@cocreature Also suggested I look at using a different encoding using the Yoneda lemma. An example is given in this blog post, about half way down the page. But you’ll notice the transformation embeds a function into the ADT, which is not serializable, so that doesn’t help.

I also considered splitting up the algebra in two, with boolean algebra looking something like this:

data Boolean
  = Equals : forall a . (Obs a, Obs a)
   ...

You’ll notice this data type doesn’t have a type parameter a so it’s not inferred. But this also requires a language feature that isn’t supported.

Finally, I should mention that there are a couple of not-so-great solutions, such as ‘tagging’ the type a, by using e.g. Either a Bool instead. This has the major disadvantage that writing expressions will no longer warn of type errors at compile-time. For example, the expression

Left 1 === Right false

would compile fine (although it would throw an error at runtime).

I’m fresh out of ideas. Could anybody suggest an alternative approach?

1 Like

Do you need the genericity afforded by type classes and type parameters here? If not, here is what I would consider a simpler approach, which seems just as type-safe to me (disclaimer: I like dynamic languages, I may not be qualified to judge type safety).

module Main where

import Daml.Script

-- Note: using dummy names for fields as they (the names) won't be used.
data BoolOp
  = ArithEqual { _a1: Op, _a2: Op }
  | BoolEqual { _b1: BoolOp, _b2: BoolOp }
  | GreaterThan { _c1: Op, _c2: Op }

data Op
  = Add { _d1: Op, _d2: Op }
  | Constant Int

eval: BoolOp -> Bool
eval = \case
  ArithEqual left right -> evalOp left == evalOp right
  BoolEqual left right -> eval left == eval right
  GreaterThan left right -> evalOp left > evalOp right

evalOp: Op -> Int
evalOp = \case
  Add left right -> evalOp left + evalOp right
  Constant i -> i

setup : Script ()
setup = script do
  assert(eval (BoolEqual (ArithEqual (Constant 1) (Constant 1))
                         (GreaterThan (Add (Constant 1) (Constant 10))
                                      (Constant 2))))
1 Like

You can’t express a syntax tree consisting of an unbounded set of types in a single DAML type. Every type you can get your hands on using pattern matching has to be contained in your type somehow. Ie since you want to be able to get your hands on a value of type a by traversing the syntax tree of the result of ===, the type of that result has to contain a somehow.

If you don’t need to be serializable, you can just compose functions, swallowing types as you go, but I suspect you want something serializable. In that case, @Gary_Verhaegen’s suggestion is the way to go. Suppose I wanted such a tree where I can store Int, Text or Bool :

module Main where

import Daml.Script

class Observation f a where
  konst : a -> f a
  add : f a -> f a -> f a
  (===): f a -> f a -> f Bool

data Obs a b c d
  = Const d
  | Add (Obs a b c d, Obs a b c d)
  | EqualsA (Obs a b c a, Obs a b c a)
  | EqualsB (Obs a b c b, Obs a b c b)
  | EqualsC (Obs a b c c, Obs a b c c)

instance Observation (Obs a b c) a where
  konst = Const
  add = curry Add
  (===) = curry EqualsA

instance Observation (Obs a b c) b where
  konst = Const
  add = curry Add
  (===) = curry EqualsB

instance Observation (Obs a b c) c where
  konst = Const
  add = curry Add
  (===) = curry EqualsC

reduceObs : (Semigroup a, Semigroup b, Semigroup c, Semigroup d, Eq a, Eq b, Eq c) => Obs a b c d -> Either d Bool
reduceObs o = case o of
  Const x -> Left x
  Add (x, y) -> case (reduceObs x, reduceObs y) of
    (Left x', Left y') -> Left (x' <> y')
    (Right x', Right y') -> Right (x' <> y')
    _ -> error "IMPOSSIBLE"
  EqualsA (x, y) -> case (reduceObs x, reduceObs y) of
    (Left x', Left y') -> Right (x' == y')
    (Right x', Right y') -> Right (x' == y')
    _ -> error "IMPOSSIBLE"
  EqualsB (x, y) -> case (reduceObs x, reduceObs y) of
    (Left x', Left y') -> Right (x' == y')
    (Right x', Right y') -> Right (x' == y')
    _ -> error "IMPOSSIBLE"
  EqualsC (x, y) -> case (reduceObs x, reduceObs y) of
    (Left x', Left y') -> Right (x' == y')
    (Right x', Right y') -> Right (x' == y')
    _ -> error "IMPOSSIBLE"

instance Semigroup Int where
  (<>) = (+)

instance Semigroup Bool where
  x <> y = (x && not y) || (not x && y)

test = script do
  let
    obs : Obs Int Text Bool Bool = ((konst 1 `add` konst 2) === konst 3) `add` ((konst "1" `add` konst "2") === konst "12")
  return (reduceObs obs)
1 Like

Hi @bernhard,

I must say I am still confused by the type class there. It seems a bit weird to constrain all your types to support the exact same set of operations, and it’s not clear to me what you gain in exchange for that constrain (and the additional complexity introduced by the type parameters).

If you know the types you support in advance, is it not simpler to just encode them directly? I’d go for a variant for each such type, where the variant groups all the operations that return that type.

module Main where

import Daml.Script
import qualified DA.Text

-- Note: using dummy names for fields as they (the names) won't be used.
data BoolOp
  = NumEqual { _bo1: NumOp, _bo2: NumOp }
  | BoolEqual { _bo3: BoolOp, bo4: BoolOp }
  | StringEqual { _bo5: StringOp, bo6: StringOp }
  | GreaterThan { _bo7: NumOp, _bo8: NumOp }
  | StringToBool StringOp

data NumOp
  = Add { _no1: NumOp, _no2: NumOp }
  | Num Int
  | StringToNum StringOp

data StringOp
  = Concat { _so1: StringOp, _so2: StringOp }
  | String Text
  | NumToString NumOp
  | BoolToString BoolOp

evalBool: BoolOp -> Either Text Bool
evalBool = \case
  NumEqual n1 n2 -> do
    n1' <- evalNum n1
    n2' <- evalNum n2
    return $ n1' == n2'
  BoolEqual b1 b2 -> do
    b1' <- evalBool b1
    b2' <- evalBool b2
    return $ b1' == b2'
  StringEqual s1 s2 -> do
    s1' <- evalString s1
    s2' <- evalString s2
    return $ s1' == s2'
  GreaterThan n1 n2 -> do
    n1' <- evalNum n1
    n2' <- evalNum n2
    return $ n1' > n2'
  StringToBool s -> do
    s' <- evalString s
    case s' of
      "true" -> return True
      "false" -> return False
      _ -> Left $ "Invalid conversion from String to Boolean: " <> s'

evalString: StringOp -> Either Text Text
evalString = \case
  Concat s1 s2 -> do
    s1' <- evalString s1
    s2' <- evalString s2
    return $ s1' <> s2'
  String s -> return s
  NumToString n -> do
    n' <- evalNum n
    return $ show n'
  BoolToString b -> do
    b' <- evalBool b
    return $ show b'

evalNum: NumOp -> Either Text Int
evalNum = \case
  Add n1 n2 -> do
    n1' <- evalNum n1
    n2' <- evalNum n2
    return $ n1' + n2'
  Num n -> return n
  StringToNum s -> do
    s' <- evalString s
    case DA.Text.parseInt s' of
      None -> Left $ "cannot parse as Num: " <> s'
      Some i -> return i

setup : Script ()
setup = script do
  assert(Right True == evalBool (BoolEqual (NumEqual (Num 1)
                                                     (Num 1))
                                           (GreaterThan (Add (Num 1)
                                                             (Num 10))
                                                        (Num 2))))
  assert(Right 123 == evalNum (StringToNum (Concat (String "12") (String "3"))))
  assert(Left "cannot parse as Num: hello" == evalNum (StringToNum (String "hello")))

What am I missing?

It depends what you want to do with it. If you want to be able to add and equate without worrying about which type of expression you are in, you need to wrap a bit more machinery around your code. You also lose the genericism that I can switch Int and Text out for Decimal and [Party] without writing terribly much code.

Hi Guys. Thank you for both your suggestions.

@bernhard, I think your solution is what I described at the end of my OP. The disadvantage is that the interpreter is partial - i.e. it throws errors during runtime. You also lose the type safety (consider the example I gave at the end) during compile time.

@Gary_Verhaegen I think your suggestion works, however, as you pointed out, I loose the type parameter, which is something I’d like to keep.

I think that at a very fundamental level, the only way to encode this is using functions (which don’t serialize) , or some language construct that allows us to encode the dependency of the output type based on the input type.