Skip to content

Symbolic evaluator overhaul - #27

Merged
RobinWebbers merged 35 commits into
mainfrom
expr-no-eff
Oct 16, 2026
Merged

RobinWebbers merged 35 commits into
mainfrom
expr-no-eff

Conversation

@RobinWebbers

Copy link
Copy Markdown
Member

This is a big adjustment, where we completely rebuilt the symbolic evaluator.

Symbolic evaluator

At a high level, the evaluator is now much closer to the actual evaluator scheme of Haskell. Allocated thunks are managed through IORef. This way we can apply the evaluation on such a Thunk while applying a side-effect. Previously we would keep arguments as actual Haskell thunks. This got very hairy... In any case, this closes #9.

With us now being able to inspect our runtime better through a Thunk, we can now print the (reachable) allocations from any expression. This allows us to inspect the RTS at any point of evaluation!

Instead of keeping an adjusted form of Expr (as we did previously), we define a WHNF value that is symbolic. This one contains closures, primitives, data constructors and literals. The merging of these is straightforward and does not propagate into its arguments. We also chose to deal with types and coercions in the same way Haskell does: types can be elided and coercions are unit values.

A Thunk comes in three shapes:

  • It is a WHNF value (it was already evaluated)
  • It is a Thunk containing the environment and expression to evaluate
  • It is a Ite, i.e. a branch of two Thunks. This last one is used when merging e.g. a DataCon WHNF.

We leave an Ite thunk as non-merged to accommodate for when we want to add in pruning #7: this way we can decide to drop a Thunk before we actually evaluate it.

We also improved branch operations. On a case-of, we now first accumulate all of the patterns that can only match the default case. This way, we can run the default right-hand side once using this accumulate value!

Fresh variable generation

We also did some initial work for the new API as proposed by #6. This one is not fully done however, so we keep it open. The main change is that we now have a Sym monad in which symbolic statements are run. For now, we only support fresh, which constructs a fresh value. As we haven't implemented checking primitives, the expected type for checking a proposition is Sym Bool.

We do close #3 as we created a fresh variable generation API. It works through the Gen monad. It is a little specific so I'll explain it here. Fresh variable generation should be lazy. For example, we should be able to generate a infinite list of fresh values in a list (without looping forever).

There are two ways to achieve this:

  • The build should pass a fresh identifier to every nested call. This has the downside however of very quickly growing the identifier size.
  • We generate the fresh identifier when a values is demanded. This is a little bit more involved, as for example sharing an expression can create different identifiers if we are not careful. Still, we took this approach and we solved it by following the approach of lazy IO.

Lazy IO gets a very bad name in Haskell (for good reason), but in this case it is very much justified! The side effect we provide is about the same as an oracle that assigns a random unique identifier (which a user cannot actually inspect). Hence, this side-effect is completely harmless.

We use the exact same mechanism as unsafeInterleaveIO, which is used to implement lazy IO. It works by returning a SymWorld token on which the next computation depends. The token is not forced, but lifting of the inner value is blocked by marking the function NOINLINE (thereby no sharing happens, so we ensure that we do not get two fresh variables for the same call).

In the end, the primitive call symbolic# is of type Primitive a => Gen a, where Gen :: SymWorld -> a -> Gen a. Notice how we don't need to return a SymWorld as we never need non-lazy values in Gen. The monadic bind makes sure that we get the lazy behaviour by calling unsafeDefer (unsafeInterleaveIO for SymWorld) on every bind.

The Gen monad can be lifted into the Sym monad (but not vice versa). Note that we use separate monads because indeed Sym should not have these lazy semantics.

…lly for its lambda. This removes the need for effects from the data type. Working on translation from GHC Core. Afterwards will do the big-step semantic for it! Still a lot of open questions remain about how to do eval symbolic eval with this one!
…' is something you pretty much never want to do...). Adjusted files with type applications to remove the ambiguity this introduced. Also added 'effectful' package
…re direct style sharing using 'IORef'. This makes all the 'Defer' code unecessary and is much cleaner in general really. The main reason for doing it is to allow pruning to happen. We also adjusted the evaluator by treating types and coercions more faithfully to a real evaluator: we disregard types and coercions are zero sized values. This is a lot easier and should also help with evaluation speed!
…from the base package, not necessarily from the prelude.
…e function into the shape required for the call.
…sed them to implement a larger number of primitives. Added support for literal values (TODO: case split on 'LitAlt'). Added a global 'Env' which contains embeddings and used unfoldings (to simulate a CAF). Added 'Symbolic' typeclass to builtins (TODO: enable primitive support for the new operation).
…d issue where the 'symbolicP' function was not marked as OPAQUE. Small comment adjustment on 'lookupClass'.
…o implement 'symbolicP' primitive. Added support for some bitvector primitives. Added support for if-then-else primitive. WIP: Adjustments to pretty printer. We have a dedicated collection of thunks, which we can use to purely print the relevant expressions. This way, we also do not duplicate prints by recursing directly (i.e. no duplicate prints of any thunks, which *does not scale at all*).
… need type information that we don't really track elsewhere. It is slightly hacky (as we need to special case application) otherwise it is nice as we create an evidence object that we can actually use.

* Fixed issue where the if-then-else on alternatives was too strict on its accumulator (we used foldl'), causing infinite loops. Adjusted to using foldr.
* Fixed an issue where the 'force' function would not write the merged result of forcing an 'Ite' 'Thunk'.
…ction (we were missing the '1 <= n' coercion slot).

* Force the scrutinee using a bang pattern in 'litAlts' instead of a case expression (is slightly cleaner).
…thunks were created for variables that were actually already forced.

* Adjusted primitive operation resolution. Instead of tracking a separate 'PrimOps' mapping, we now extend the global environment with them (we added the same for 'DataCon' now actually, but this is on the fly).
…e it should be a data constructor wrapping the coercion.

* Fixed a bug we introduced by removing a separate 'PrimOps' mapping, where arity 0 operations were left unevaluated as the arity check only reduces when adding a value. Now we add constants to the global map for arity 0 operations. In the future, we might want to remove arity 0 operations from 'PrimOp' so we don't have to call the evaluator when adding these to the 'Global' environment. This way, we get rid of some effects that will never be called.
…he Sym monad.

* Adjusted symbolic variable generation to do a lazy IO-like identifier generation through the 'Gen' monad.
…ser can for most data types derive the implementation through 'Generically' (or use the default signature).

* Added primitives for 'unreachable' and 'undefinedBehaviour', the former is used for vacuous types in the 'GHC.Generics' implementation.
…nctor in 'CollectThunks' instead of defining custom functor PairF.
…ing an error throw in its inner body (even though it had 'noinline'). Now, I've set it to diverge through a self-recursive call with 'noinline', which seems to not trigger the bug. Not sure why it also didn't end up happening everywhere...
…olve type families that get a reduction due to the unification. E.g. if we instantiate '1 <= n' to '1 <= 4', then we can reduce the type family '<=' such that we can satisfy the constraint.
…hing an unfolding into the global environment. With this fixed, the runtime prints are a lot nicer as we do not pull in arbitrary environments that are unused!
…ir environments: this is space waste as they are already contained in the global environment!
…sed 'KnownNat' conversion back to the Haskell one.
… recursive calls blow through memory. Already added an issue to implement this in a better way. For now, we accept bad call stacks...

* Scrutinee thunk in literal cases now are attempted to be resolve as a variable before creating a closure. This is similar to a patch we made for arguments of applications and it really matters a lot for both constraint size and evaluation time.

* Also added some exports to WHNF. Not sure if we want to keep those though...
… put the 'NoStrategy' on the 'Tsil' instead of inner value of the 'Union'. The improvement this has on the reduction is massive!

* Fixed issue where the 'ThunkKind' 'Ite' constructor would pack its values. This caused their 'StableName' to be different every time it was packed/unpacked. For now, we explicitly annotate that they should not be packed in the data type. We can see in the future if there is a way to have them packed.
… cannot really print it as is. For now, we simply do this to minimize the regression in this aspect.
* Removed the old solver call from 'Pantomime.Solve'.

* Removed the old evaluator itself, this accounts for all file removals in here.

* Removed some now obsolete code from 'Pantomime.Symbolise'.

* Moved the one function we still used from 'Pantomime.Grisette.Mergeable' to 'Pantomime.Util'.
@RobinWebbers RobinWebbers self-assigned this Oct 16, 2026
@RobinWebbers RobinWebbers added the enhancement New feature or request label Oct 16, 2026
@RobinWebbers RobinWebbers linked an issue Oct 16, 2026 that may be closed by this pull request
@RobinWebbers
RobinWebbers merged commit d4f72b6 into main Oct 16, 2026
1 check passed
@RobinWebbers
RobinWebbers deleted the expr-no-eff branch October 16, 2026 08:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Remove effect monad from expressions Fresh variable generation API

1 participant