jeffmay / vapors   1.0.0-beta8

Apache License 2.0 GitHub

Construct expressions over fact types, then turn those expressions into a result given a set of source facts.

Scala versions: 2.13

Vapor Logo

Table of Contents

Table of contents generated with markdown-toc

GitHub tag (latest SemVer) codecov

Vapors

The Vapors library provides an embedded-DSL for writing expressions that look similar to the Scala collections library and can be interpreted to produce a function that computes the result given a set of source facts.

These expressions are descriptions of computations that can be optimized, serialized, and interpreted in various ways using a tagless final recursive visitor (see Expr.Visitor).

This library is built on top of Scala's cats library for typeclass definitions. See the Implementation section for more details.

You might want to also check out these helpful slides on the library design for more details. (The slides are a little bit out of date, but the basic ideas are the same)

Getting Started

Setup

  1. Add it to your build.sbt using version=GitHub tag (latest SemVer)

    libraryDependencies += "com.rallyhealth" %% "vapors-v1" % "version"
  2. Define your fact types.

    Every fact type must have a unique name as well as a Scala type. You can define a FactType with any Scala type you want, so long as it defines an Order. This is so the facts can be pulled from the FactTable in a reasonable order.

    object FactTypes {
      val DateOfBirth = FactType[LocalDate]("DateOfBirth")
      val Role = FactType[Role]("Role")
    }
    
    sealed trait Role
    object Role {
      case object Admin extends Role
      case object User extends Role
    
      implicit val order: Order[Role] = Order.reverse[Role] {
        Order.by {
          case Admin => 1
          case User => 2
        }
      }
    }

    Typically, if you have it, you'll order things by timestamp. Stay tuned for a data type that encapsulates Timestamped facts.

  3. Import the DSL.

    You get different behavior and add support for different interpreters based on which DSL you use.

    If you just want to compute an expression quickly, with no justification wrapper, then you can use:

    import com.rallyhealth.vapors.v1.dsl.uncached._
    

    If you want your expression to produce a Justified wrapped value, then you can use:

    import com.rallyhealth.vapors.v1.dsl.uncached.justified._
    

    Now when you call .run() or .runWith() on your expression, you can traverse the tree of justification for what operations produced that value.

  4. Craft your expressions.

    Typically, you will start your expression using a value from the FactTable. You can use the valuesOfType operation to do so.

    Since every FactType can have multiple instances defined in the FactTable, they must also define an Order, so that the facts can come out of the FactTable in some natural ordering:

    import com.rallyhealth.vapors.v1.dsl.uncached._
    
    val isAdmin = {
      valuesOfType(FactTypes.Role).exists(_ === Role.Admin)
    }
    val isOver18 = {
      valuesOfType(FactTypes.DateOfBirth).exists { fact =>
        dateDiff(fact, today, ChronoUnit.YEARS.const) >= 18.const
      }
    }
  5. Run your expression with a set of facts.

    Assuming you have these facts:

    import FactTypes._
    
    val dob = FactTypes.DateOfBirth(LocalDate.of(1980, 1, 1))
    val adminRole = FactTypes.Role(Role.Admin)
    val userRole = FactTypes.Role(Role.User)
    
    val facts = FactTable(
      dob,
      adminRole,
      userRole
    )

    If you use the justified DSL, then you can get the list of facts (i.e. the Evidence) used to produce the output.

    import com.rallyhealth.vapors.v1.dsl.uncached.justified._
    
    val isAdminResult = isAdmin.run(facts)
    assert(isAdminResult.value)
    assertEquals(isAdminResult.evidence, Evidence(adminRole))
    
    val combinedResult = and(isAdmin, isOver18).run(facts)
    assert(combinedResult.value)
    assertEquals(combinedResult.evidence, Evidence(adminRole, dob))

    If your expression requires anything other than Any, then you can only call .runWith() to provide the required input of the expected type.

    val isGt3 = ident[Int] > 3.const
    assert(isGt3.runWith(5))
    compileErrors("isGt3.run()")

Complex FactTypes

The type of operators that are available for a type are dependent on the instances of cats typeclasses that are available in implicit scope. So for example, if you want to be able to use <, >=, ===, =!=, etc then you need to define an Order instance for your type.

case class FeetAndInches(feet: Int, inches: Int)
object FeetAndInches {
  implicit val order: Order[FeetAndInches] = Order.fromLessThan { (l, r) =>
    l.feet < r.feet || (l.feet == r.feet && l.inches < r.inches)
  }
}

Terminology and Problem Statement

In essence, every expression (Expr[I, O, OP]) can be optimized and interpreted to produce a function like:

(FactTable, I) => O

NOTE: If the I type is Any, then the framework will substitute ExprState.Nothing for the input and allow you to treat the function like:

FactTable => O

The goal of this library is to provide a way to build serializable and introspectable definitions for facts. These can also be thought of as "rules". Thus making this a rules engine library where the "rules" are built using a strongly-typed, embedded domain-specific language (eDSL) using the Scala compiler.

You might wonder: Why not just use an existing rules engine DSL? Or why not just serialize native Scala code?

  1. Justification

    The limited DSL allows computing a value alongside the chain of inferences that justifies that value from some set of source facts, config values, and / or constants.

    While many rules engines support serialization and ways of representing this capability, AFAICT none explicitly support it.

  2. Type-Safety

    By using an embedded DSL, we take advantage of the Scala compiler to catch errors at compile-time and provide an IDE experience.

    While a goal of this project is to allow parsing expressions from files or strings, it should only be done so in a manner that does not violate strongly-typed schemas. In the meantime, we offer capabilities to operate on algebraic data types using shapeless.HList operations.

  3. Customization

    By using embedding the construct within a fully-featured language, the library user is empowered to interpret and customize the DSL to suit their own needs with the full power of the Scala programming language with an interface that is designed for extension.

Facts

Data Types

Fact

Every Fact -- whether a source fact or derived fact -- is produced from a FactType.

FactType

A FactType[T] is constructed with both a String name and a Scala type parameter. The FactType[T] then acts like a function that produces TypedFact[T] instances given values of type T.

val Age = FactType[Int]("age")
val sourceFact = Age(23)

TypedFact

All TypedFact[_] instances extend Fact which has a dependent type member called Value which is assigned to the type parameter. This allows using these Fact instances inside invariant collections (like Set[Fact]). You could also conceivably match on the .factType of a Fact to determine the Value type from the original FactType[T].

TypedFactSet

In the case that you need to handle multiple facts with the same Scala type, but different names (thus different FactType[T] definitions), you can use a FactTypeSet[T]. This can be useful if you are able to handle a common supertype of multiple FactType[_] definitions in a common way. All operations that handle a single FactType[T] can also handle a FactTypeSet[T]. Just be careful not to combine two different fact types with the same Scala type that have different units of measurement. For example, FactType[Double]("weight_kg") and FactType[Double]("weight_lbs") might have the same Scala type, Double, but you should be careful not to perform calculations that are only valid if you are operating in the metric system.

FactTable

Every expression can be computed by providing a FactTable. This is a map of FactTypes to instances of those TypedFacts that can be retrieved at any point in the computation with O(1) performance.

Terminology

Source Facts

When computing an expression, you will often want to feed the expression some starting set of source facts. These facts are available in all computations using the valuesOfType operation, which can retrieve all instances of these facts from the FactTable that was initially provided.

There is no distinction between source facts and derived facts beyond the understanding of when they were defined. If they are defined as part of the process of evaluating an expression, then they are "derived", otherwise, they must have been provided to the FactTable as "source" facts.

Derived Facts

You can use a define operation to create a definition expression, which can be provided to a using expression to compute some values from the result of that definition expression having added all the values computed by the expression into the FactTable.

import com.rallyhealth.vapors.v1.data.{FactType, FactTable}
import com.rallyhealth.vapors.v1.dsl.uncached._

import java.time.LocalDate
import java.time.temporal.ChronoUnit

val Age = FactType[Int]("age")
val DateOfBirth = FactType[LocalDate]("date_of_birth")
val defineAgeFromDob = define(Age).fromSeq {
  valuesOfType(DateOfBirth).map { dob =>
    dateDiff(today, dob, ChronoUnit.YEARS)
  }
}
val isOver18 = using(defineAgeFromDob).thenReturn {
  _.forall(_ >= 18.const)
}
// This will be true when run any day after 2007-12-31
assert(isOver18.run(FactTable(LocalDate.of(1990, 1, 1))))

Expression Algebra

Expr Signature* Unwrapped DSL Type DSL Example Unwrapped Function** Description
Const[+O] Any ~:> O 1.const _ => 1 Ignores the input and just returns a constant value.
Identity[I] I ~:> I ident[Int] + 1
an expression that adds 1 to its input.
(_: Int) + 1 The identity function. It just returns the input as the output. This has some applications for direct use, but is more commonly used to support the builder-style DSL.
In an ideal world, these would be optimized out of the resulting expressions.
These are not often used directly.
AndThen[-II, +IO, -OI, +O] II ~:> OO ident[Seq[Seq[Int]]] .andThen(Expr.Flatten()) (identity[Seq[Seq[Int]]] _) .andThen(_.flatten) Chain the output the first expression to the input of a second expression to produce the output of the second expression. This is the foundation for most of the DSL. Many operations only operate on a certain shape of input and rely on the `andThen` operation to chain the output of a previous expression into the input.
ValuesOfType[T, +O] Any ~:> Seq[O] valuesOfType(Age)

where Age is some FactType[Int]
(_, ft: FactTable) =>
  ft.getSortedSeq(FactTypeSet.one(Age))
    .map(_.value)
Selects all the facts that have a FactType in the given FactTypeSet. The result of the expression will be an Any ~:> Seq[O] that can be filtered, mapped, etc to produce some typed computation result.
NOTE: The only reason for the difference between `T` and `O` is that some DSLs apply a wrapper to every value in the DSL, so the output type might differ from the `FactType`
Definition[-I]

/

Define[-I, +C[_] : Foldable, T, OP]
I ~:> Seq[TypedFact[T]] define(Age).from(21.const) (age: Int, ft: FactTable) => ft.add(Age(age)) Creates a Definition[I, OP] for a given FactType[T] with a given Expr node. These can be used in the using expression to guarantee that the facts generated for the given `FactType` are available for use in the `FactTable` in subsequent expressions within that scope.
UsingDefinitions[-I, +O] I ~:> O using(defineAge).thenReturn(_.exists(_ > 21))
(i: I, ft: FactTable) =>
  ft.addAll(defineAge(i))
    .getSortedSeq(Age)
    .map(_.value)
    .exists(_ > 21)
Adds the given definitions to the FactTable and makes them available to the expression provided in the following block.
Combine[-I, -LI, +LO, -RI, +RO, +O] I ~:> O 1.const + 2.const Add[Int, Int, Int].add(1, 2) Combine 2 expressions using a pre-defined binary operation. Every operation has a name and a function that combines the left-side input (LI) with the right-side input (RI) that will be provided the output of the two input expressions: left-side output (LO) and right-side output (RO). The result of the binary operation (O) is the final output.
Justified[Boolean] defines all operations using Justified.byInference with the operation name and chains both the left and right side justification.
And[-I, +B : AsBoolean, W[+_]] I ~:> Boolean and(true.const, false.const) Seq(true, false).forall(identity[Boolean]) Combine expressions that return a value for which there is an Conjunction and combines the results using the given definition for conjunction.
Justified[Boolean] defines Conjunction by returning the union of the evidence of all expressions that are truthy if any expressions are truthy, otherwise the union of all the evidence of all the given expressions that are falsy.
Or[-I, +B : AsBoolean, W[+_]] I ~:> Boolean or(true.const, false.const) Seq(true, false).exists(identity[Boolean]) Combine expressions that return a value for which there is an Disjunction and combines the results using the given definition for disjunction.
Justified[Boolean] defines Disjunction by returning the union of the evidence of all expressions that are truthy if any expressions are truthy, otherwise the union of all the evidence of all the given expressions that are falsy.
Not[-I, +B : AsBoolean, W[+_]] I ~:> Boolean not(true.const) !true Convert a falsy expression to a truthy expression (or vice versa) using a provided concept of Negation
The Evidence of a Justified value is not altered when the value is negated.
Match[-I, +S, +B : AsBoolean, +O] I ~:> O
ident[Person].matching(
  Case[Admin] ==> Role.Admin.const,
  Case[Employee].when {
    _.get(_.select(_.role)) >= Role.Editor.const
  } ==> ident[Employee].get(_.role),
)
(_: Person) match {
  case _: Admin => Some(Role.Admin)
  case e: Employee if e.role >= Role.Editor => Some(e.role)
  case _ => None
}
Constructs an expression that matches on one of a series of branches that first matches on a subclass, then applies a filter expression, and finally, if the type is correct and the guard expression returns true, then it returns the output of the expression on the right-hand side of the ==> wrapped a Some. If no branch matches the input, then the expression returns None. See also Expr.MatchCase[-I, S, +B, +O].
When[-I, +B : AsBoolean, +O] I ~:> O
when(true.const)
  .thenReturn(1.const)
  .elseReturn(0.const)
if (true) 1 else 0 Evaluates the first subexpression of the given sequence of condition branch expressions that evaluates to true. This is less powerful than .matching(...) in that it does not allow downcasting to a subclass before applying the condition expression. However, it does not return an Option and can be simpler and more clear in certain cases (including Case[A].when() conditions).
Select[-I, A, B, +O] I ~:> O ident[Map[String, String]].get(_.at("example") (_: Map[String, String]).get("example") Select a field from a product type, map, or sequence. Returns either an Option or strict value depending on the type of Indexed instance is available for input type and key type combination.
Filter[C[_], A, +B : AsBoolean, D[_]] C[A] ~:> D[A] ident[NonEmptySeq[Int]].filter(_ > 1.const) (_: NonEmptySeq[Int]).filter(_ > 1) Keeps elements of the given collection for which the filter expression returns true and discards the others.
Match[I, S, B : AsBoolean, O] I ~:> O
ident[RoleClass].matching(
  Case[Editor].when(_.get(_.select(_.articles)) > 0.const) ==>
    ident[Editor].get(_.select(_.permissions))
)
(_: RoleClass) match {
  case e: Editor if e.articles > 0 => Some(e.permissions)
  case _ => None
}
Matches the given cases in the order provided. If any branch matches both the expected type and the guard expression, if any, then the result of the expression on the right-hand side of the ==> will be returned inside a Some(_). If no Cases match, then the expression returns None.
MapEvery[C[_] : Functor, A, B] C[A] ~:> C[B] ident[List[Int]].map(_ * 2.const) (_: List[Int]).map(_ * 2) For every value in the given Functor apply the given expression to create a new collection of the same type with the elements produced by the given expression.
Flatten[C[_] : FlatMap, A] C[C[A]] ~:> C[A] ident[List[List[Int]]].flatten (_: List[List[Int]]).flatten Uses the definition of FlatMap[F[_]] to flatten the outer collection (C[C[A]]) into a new collection of all the elements in order (C[A]). This can be used to construct a .flatMap() operation.
GetOrElse[I, O] I ~:> O ident[Option[Int]].getOrElse(0.const) (_: Option[Int]).getOrElse(0) Calls the Option.getOrElse method on the result of an expression that returns an Option[O] and provides the evaluation of a default value expression to be computed if the original optional result is empty.
Sorted[C[_], A] C[A] ~:> C[A] ident[List[Int]].sorted (_: List[Int]).sorted Sort the values using the given ExprSorter -- either a given natural Order[R] of the return type or the Order of a field selected by a given NamedLens).
FoldLeft[-I, +C[_] : Foldable, A, O] I ~:> O ident[List[Int]].foldLeft(0.const) { _ + _ } (_: List[Int]).foldLeft(0) { _ + _ } Folds the output of the given expressions into the result of some initial expression.
Sequence[+C[+_] : Applicative : SemigroupK : Traverse, -I, +O] I ~:> C[O] wrapAll(NonEmptySeq.of(1.const, 2.const)]) NonEmptySeq.of(_ => 1, _ => 2).map(_(())) Wraps the output of a sequence of given expressions into an expression that produces the same type of sequence from the results of every expression given the same input.
ToHList[-I, +L <: HList] I ~:> L wrap(1.const, "two".const).toHList ((_ => 1)(()) :: (_ => "two")(()) :: HNil) Wraps the output of a heterogeneous ExprHList of given expressions into an HList of the return types.
Convert[-I, +O] I ~:> O (1 :: "two" :: HNil).const.as[(Int, String)] Generic[(Int, String)].from(1 :: "two" :: HNil) Converts an HList to any type that can be isomorphically converted using shapeless.Generic
ZipToShortestHList[-I, W[+_], +WL <: HList, +UL <: HList] I ~:> W[UL] wrap(seq(1.const, 2.const), seq("1".const)).zipToShortest (Seq(1, 2) :: Seq("1") :: HNil) Zips a heterogeneous list of expressions into a single HList of the results.
Repeat[-I, +O] I ~:> IterableOnce[O]
wrap(
  Seq(1, 2, 3).const,
  repeatConstForever(0.const)
).zipToShortest
Seq(1, 2, 3)
  .zip(IterableOnce.continually(0))
  .map {
    case (l, r) } => l :: r :: HNil
  }
Repeats the given expression (either memoizing the result or recomputing it) infinitely (or to some limit) to produce an expression of IterableOnce[O] that can be zipped with other sequences of known or unknown length. This is commonly used to thread a single value produced by a prior expression into the input of a collection operation (like .map(), .forall, etc). This helps to avoid the issue of lacking closures in the applicative expression language.
SizeIs[-I, N : AsInt, B : AsBoolean] I ~:> B ident[Seq[Int]].sizeIs > 2.const (_: Seq[Int]).sizeIs > 2 Returns true if the output of the expression has a size that meets a given SizeComparison.
Slice[C[_] : Traverse, A, D[_]] C[A] ~:> D[A] ident[Seq[Int]].slice(1 <-> -1) (s: Seq[Int]) => s.slice(1, s.size - 1) Selects a range of values from the starting collection to produce an output collection. The C[_] and D[_] types are different because if you take a slice from a NonEmptySeq, you will get a regular Seq that can be empty. There are other collections that can differ when selected. You can even define your own. Check out the CollectInto type-level function.
Exists[-C[_] : Foldable, A, B : AsBoolean] C[A] ~:> Boolean ident[Seq[Int]].exists(_ > 18.const) (_: Seq[Int]).exists(_ > 18) Returns true if there exists an element in the input collection for which the given predicate expression returns true, otherwise returns false (including if the collection is empty).
ForAll[-C[_] : Foldable, A, B : AsBoolean] C[A] ~:> Boolean ident[Seq[Int]].forall(_ > 18.const) (_: Seq[Int]).forall(_ > 18) Returns true if every element in the input collection returns true from the given predicate expression (or the collection is empty), otherwise returns false.
ContainsAny[-I, W[+_] : Extract, C[_] : Foldable, A, +B] I ~:> Boolean 1.const in Set(1, 2, 3).const Set(1, 2, 3).contains(1) Returns true if the output of the expression is found in the given Set[A] of values, otherwise false.
WithinWindow[-I, +V, W[+_]] I ~:> Boolean 1.const <= 2.const 1 <= 2 Returns true if the output of the expression is contained by the given Window[V], otherwise false.
IsEqual[-I, +V, W[+_]] I ~:> Boolean 1.const === 2.const 1 == 2 Returns true if the output of the left expression is equal to the output of the right expression according to the provided definition of EqualComparable[W, V, OP].
CustomFunction[-I, +O] I ~:> O Expr.CustomFunction("average", (xs: Seq[Int]) => xs.sum / xs.size) (xs: Seq[Int]) => xs.sum / xs.size Allows you do define a custom expression node that will evaluate the given function. Note that this will bypass any of the evidence tracking, introspect-ability, and other use cases for using this library in the first place, so you should only use it for simple functions that have well-defined behavior and name. This is mainly to allow calling out to library code to do things like decryption, datetime calculations, statistical calculations, etc.

* Every Expr has 3 parameters, -I input, +O output, and OP[_] the output parameter. Since the OP type is repeated throughout every expression and has no real implication on the standard behavior of the language, I have removed it from the signatures. So, for example, if we have a subclass of Expr named Op, the signature would be shown as Op[-I, +O], rather than Op[-I, +O, OP[_]].

** Every Expr[I, O, OP[_]] built by an unwrapped DSL is interpreted as a function (I, FactTable) => O, however to simplify this column, any expression that does not depend on the FactTable will only use the input parameter, I.

These operations have a custom "wrapper" type W[+_]. This is not the ideal state for the expression algebra, as the wrapper type is an artifact of the DSL, and not the underlying logic of the language. Unfortunately, I was not able to figure out how to solve various issues that arose from using a single concrete type parameter (rather than a higher-kinded parameter and a concrete inner parameter).

Expression Type Aliases

Type Description Definition
I ~:> O The super class of all expression nodes. You can call .run() on an Any ~:> O to get a Result[O]. Expr[I, O, OP]
I =~:> O A function from an expression of I ~:> I to produce an expression I ~:> O. Expr[I, I, OP] => Expr[I, O, OP]
AndThen[I, M, O] An alias for the Expr.AndThen expression node where the intermediate type is fixed. Expr.AndThen[I, M, M, O, OP]
I >=< O An alias for the Expr.WithinWindow expression node where the wrapper type and OP type are fixed. Expr.WithinWindow[I, V, W, OP]

Justification

Custom DSLs / Interpreters

Note that the type OP is the "output parameter" that can be used when building an interpreter (i.e. Expr.Visitor) to provide type-bounded context for the output of every expression node. It can also be used when defining mathematic or comparison operations like addition, equality, etc. Lastly, it can be used when interpreting an ExprResult (more details on this later).

In the simple uncached DSL, the result is computed directly without any wrapper. The interpreter produces a stream-lined and efficient function because it can ignore any work required to wrap the results. You can define custom DSLs and interpreters that produce different results, but they will likely be slower.

Example

The following a more complete example of a set of facts and an evaluated query.

import cats.Order
import com.rallyhealth.vapors.v1.dsl.uncached.justified._

case class SemVer(major: Int, minor: Int, patch: Int)
object SemVer {
  implicit val order: Order[SemVer] = Order.fromLessThan { (l, r) =>
    l.major < r.major || l.major == r.major && l.minor < r.minor || l.major == r.major && l.minor == r.minor && l.patch < r.patch
  }
}

case class PredictionModel(
  modelName: String,
  modelVersion: SemVer,
  score: Double
)

object FactTypes {
  val DateOfBirth = FactType[LocalDate]("date_of_birth")
  val HeightFt = FactType[FeetAndInches]("height_ft")
  val WeightLbs = FactType[Int]("weight_lbs")
  val Prediction = FactType[PredictionModel]("prediction")
}

object JoeSchmoe {
  val height = FactTypes.HeightFt(FeetAndInches(5, 8))
  val weight = FactTypes.WeightLbs(260)
  val dateOfBirth = FactTypes.DateOfBirth(LocalDate.of(1980, 1, 1))
  val predictWeightloss = FactTypes.Prediction(PredictionModel("weightloss", SemVer(2, 0, 1), 0.85))
  val facts = FactTable(height, weight, dateOfBirth, predictWeightloss)
}

object Example {
  val query: Any ~:> Boolean = {
    or(
      and(
        valuesOfType(FactTypes.WeightLbs).exists(_ > 250.const),
        valuesOfType(FactTypes.HeightFt).exists(_ < FeetAndInches(6, 0).const)
      ),
      and(
        valuesOfType(FactTypes.WeightLbs).exists(_ > 300.const),
        valuesOfType(FactTypes.HeightFt).exists(_ >= FeetAndInches(6, 0).const)
      ),
      valuesOfType(FactTypes.Prediction).exists { prediction =>
        and(
          prediction.get(_.select(_.modelName)) === "weightloss".const,
          prediction.get(_.select(_.score)) > 0.8.const,
          prediction.get(_.select(_.modelVersion)) >= SemVer(2, 0, 0).const
        )
      }
    )
  }
  val rs = query.run(JoeSchmoe.facts)
  assert(rs.value)
  assert(rs.evidence == Evidence(JoeSchmoe.height, JoeSchmoe.weight, JoeSchmoe.predictWeightloss))
}

Debugging

Since you cannot drop a breakpoint at a specific point in the construction of the expression (as you probably want access to the value when it is running, rather than the mostly visible information available when the expression is being constructed), we provide a mechanism to attach a debug function.

For example, you can call .debug on any Expr to provide a ExprState[?, O] => Unit function that will be evaluated when the expression is run. By attaching this debug function, you also get place where you can drop a breakpoint.

Note: The input type of the ExprState provided to the debug function depends on the Expr subclass. This is the primary reason why DSLs return the specific Expr subclass rather than their widened I ~:> O type. Likewise, the specific Expr type is unaffected by attaching a debug function.

import com.rallyhealth.vapors.v1.dsl.uncached._

object Example {
  val query: Seq[String] ~:> Boolean = ident[Seq[String]].exists {
    _.sizeIs > 3
  }.debug { s =>
    val in = s.input
    val out = s.output
    println(s"INPUT: $in")
    println(s"OUTPUT: $out")
  }
}

Implementation

Core DSLs

Import Status Interpreter Function Type Function Description
dsl.uncached SimpleEngine Expr.State[Any, I] => O Produces the unwrapped value directly
dsl.uncached.justified ☑️ SimpleEngine Expr.State[Any, I] => Justified[O] Produces the Justified wrapped value directly
dsl.standard ◽️ StandardEngine Expr.State[Any, I] => ExprResult[I, O, OP] Produces the unwrapped value inside of an ExprResult, which can be reinterpreted

✅ - Fully implemented and tested. Only minor changes to be expected.

☑️ - Fully implemented but with some inconsistencies. Major changes are plausible.

◽️ - Partially implemented. Major changes are likely.

Interpreters

In order to convert from an Expr into something useful, we must interpret the expression.

The main interpreter is the engine provided by your imported DSL. If you are using an unwrapped DSL, then this is probably the SimpleEngine.Visitor which converts an Expr[I, O, OP] into a function (I, FactTable) => O.

There is also a Justified DSL that uses the same SimpleEngine, but it returns a Justified[O] instead of just the O. This allows you to follow the chain of justification for any produced output.

Lastly, there is a StandardEngine which interprets the Expr as a function that produces an ExprResult, which is another recursive data structure that mirrors the expressions used to construct it. This mirror data structure contains all the information of the original expression combined with the state and the captured output parameters at the point where it was evaluated. This data structure can be re-interpreted with an ExprResult.Visitor to serialize the evaluation, create some kind of visualization, etc. It is much slower to capture all of this data, so this DSL is only recommended if you need any of these capabilities. This is a large part of the reason why the name will likely change from "standard" to something more descrtiptive to its purpose.

This engine requires redesign. The name and implementation are likely to change fairly dramatically.

Custom Interpreters

You can define your own Expr.Visitor to produce something other than a function. For example, we can interpret an Expr as a Json object by providing an interpreter that looks like:

import io.circe.Json
import io.circe.syntax._

object VisitExprAsJson {
  type G[-_, +_] = Json
}

class VisitExprAsJson[OP[a] <: HasEncoder[a]] extends Expr.Visitor[VisitExprAsJson.G, OP] {

  implicit def encodeOutput[O](implicit op: OP[O]): Encoder[O] = op.encoder

  override def visitConst[O : OP](expr: Expr.Const[O, OP]): Json = Json.obj(
    "$type" -> expr.name.asJson,
    "value" -> expr.value.asJson,
  )

  // ... implement all other visitX methods to produce Json
}