packages feed

ychr-0.1.0.0: src/YCHR/Internal/Runtime/Interpreter.hs

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}

-- | Haskell interpreter for CHR VM programs.
--
-- Executes VM programs directly using the 'Chr' runtime monad. The
-- 'Chr' monad reads a 'SessionEnv' that bundles the constraint store,
-- propagation history, reactivation queue, unification-variable
-- counter, interpreter call stack, the procedure map and the host-call
-- registry. Per-procedure-call local variables ('Env') live in a
-- mutable 'IORef' read through a thin 'ReaderT' layer so exception-driven
-- non-local jumps preserve state across catches.
--
-- Non-local control flow ('Return', labelled 'Continue', 'Break') is
-- implemented with real 'Control.Exception' exceptions thrown in 'IO':
-- 'try' delimits each procedure invocation and each 'Foreach' body.
module YCHR.Internal.Runtime.Interpreter
  ( -- * Public API
    interpret,
    HostCallFn (..),
    HostCallRegistry,
    baseHostCallRegistry,

    -- * Deep-eval walker (shared with the query-time evaluator)
    deepEvalValue,

    -- * Tracing helpers (shared with the query-time driver)
    emitTrace,
    snapshotValue,
    snapshotValues,
    suspensionView,
    constraintTypeLabel,
    lookupRuleName,

    -- * Internal (for testing)
    callProc,
    bindParams,
    unit,
  )
where

import Control.Exception
  ( Exception,
    SomeAsyncException,
    SomeException,
    bracket,
    displayException,
    fromException,
    throwIO,
    try,
  )
import Control.Monad (unless)
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.Reader (ReaderT, ask, runReaderT)
import Data.Foldable (toList, traverse_)
import Data.IORef
  ( IORef,
    atomicModifyIORef',
    modifyIORef',
    newIORef,
    readIORef,
    writeIORef,
  )
import Data.IntMap.Strict qualified as IntMap
import Data.List qualified as List
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Sequence qualified as Seq
import Data.Text (Text)
import Data.Text qualified as T
import YCHR.Internal.Meta (valueToTerm)
import YCHR.Internal.Pretty (prettyTerm)
import YCHR.Internal.Runtime.Error (RuntimeErrorThrown, runtimeError', runtimeErrorS)
import YCHR.Internal.Runtime.History (addHistory, notInHistory)
import YCHR.Internal.Runtime.Monad
  ( Chr,
    HostCallFn (..),
    HostCallRegistry,
    SessionEnv (..),
    initSessionEnv,
    runChr,
  )
import YCHR.Internal.Runtime.Reactivation (drainQueue, enqueue)
import YCHR.Internal.Runtime.Registry
  ( baseHostCallRegistry,
    unit,
  )
import YCHR.Internal.Runtime.Store
  ( Suspension (..),
    aliveConstraint,
    createConstraint,
    getConstraintArg,
    getConstraintType,
    getStoreSnapshot,
    idEqual,
    isConstraintType,
    isSuspAlive,
    killConstraint,
    lookupSusp,
    storeConstraint,
    suspArg,
  )
import YCHR.Internal.Runtime.Trace (TraceEvent (..))
import YCHR.Internal.Runtime.Types (CallVal (..), SuspensionId, Value (..))
import YCHR.Internal.Runtime.Var
  ( deref,
    equal,
    getArg,
    makeTerm,
    matchTerm,
    newVar,
    unify,
  )
import YCHR.Internal.Types (Term)
import YCHR.Internal.Types qualified as Types
import YCHR.Internal.VM

-- ---------------------------------------------------------------------------
-- Types
-- ---------------------------------------------------------------------------

-- | Local variable environment for a procedure call. Split by kind:
-- value-bound names live in 'envValues', id-bound names in 'envIds'.
-- The IR ensures each name appears in only one map.
data Env = Env
  { envValues :: !(Map Name Value),
    envIds :: !(Map Name SuspensionId)
  }

emptyEnv :: Env
emptyEnv = Env Map.empty Map.empty

insertVal :: Name -> Value -> Env -> Env
insertVal n v e = e {envValues = Map.insert n v e.envValues}

insertId :: Name -> SuspensionId -> Env -> Env
insertId n s e = e {envIds = Map.insert n s e.envIds}

-- | Maximum number of call stack frames to keep.
maxCallStackDepth :: Int
maxCallStackDepth = 10

-- | Non-local control flow signals. Thrown as exceptions to escape
-- the current procedure or 'Foreach' loop and caught at the matching
-- boundary. 'CFReturn' is caught by 'callProc'; 'CFContinue' / 'CFBreak'
-- by 'execForeach' on its labelled loop.
data ControlFlow
  = CFReturn Value
  | CFContinue Label
  | CFBreak Label

instance Show ControlFlow where
  show (CFReturn _) = "CFReturn <val>"
  show (CFContinue l) = "CFContinue " ++ T.unpack l.unLabel
  show (CFBreak l) = "CFBreak " ++ T.unpack l.unLabel

instance Exception ControlFlow

-- | The interpreter's local stack: an 'IORef Env' threaded above 'Chr'.
-- Using a ref lets exception-driven jumps preserve any state changes
-- made before the jump, matching the original effectful-static-Local
-- 'runError'-with-outer-state semantics.
type InterpM = ReaderT (IORef Env) Chr

-- ---------------------------------------------------------------------------
-- Public API
-- ---------------------------------------------------------------------------

-- | Interpret a VM program by calling a named procedure with the given
-- value arguments. Builds a fresh 'SessionEnv' for the program and
-- runs the call inside it.
interpret :: Program -> HostCallRegistry -> Name -> [Value] -> IO Value
interpret prog hostCalls entryName args = do
  let procMap = Map.fromList [(p.name, p) | p <- prog.procedures]
      evaluableMap = Map.fromList prog.evaluables
  env <-
    initSessionEnv
      prog.typeNames
      prog.ruleNames
      procMap
      hostCalls
      evaluableMap
      Map.empty
      mempty
  runChr (callProc entryName (map CVal args)) env

-- ---------------------------------------------------------------------------
-- Env helpers
-- ---------------------------------------------------------------------------

getEnv :: InterpM Env
getEnv = do
  ref <- ask
  liftIO (readIORef ref)

modifyEnv :: (Env -> Env) -> InterpM ()
modifyEnv f = do
  ref <- ask
  liftIO (modifyIORef' ref f)

withFreshEnv :: Env -> InterpM a -> Chr a
withFreshEnv env action = do
  ref <- liftIO (newIORef env)
  runReaderT action ref

-- ---------------------------------------------------------------------------
-- Session helpers
-- ---------------------------------------------------------------------------

lookupProc :: Name -> Chr (Maybe Procedure)
lookupProc name = do
  SessionEnv {procMap} <- ask
  pm <- liftIO (readIORef procMap)
  pure (Map.lookup name pm)

lookupHostCall :: Name -> Chr (Maybe HostCallFn)
lookupHostCall name = do
  SessionEnv {hostCalls} <- ask
  pure (Map.lookup name hostCalls)

pushFrame :: StackFrame -> Chr ()
pushFrame frame = do
  SessionEnv {callStack} <- ask
  liftIO $
    atomicModifyIORef' callStack $ \stack ->
      (take maxCallStackDepth (frame : stack), ())

-- | Save the call stack, run @action@, and restore the saved frames
-- on the way out — whether @action@ returns normally or throws. The
-- restore-on-throw matters because the REPL's outer @try@ may catch
-- a 'RuntimeErrorThrown' that escapes a procedure call; without
-- bracketing, frames pushed during the failed call would leak into
-- the next operation that observes the same 'SessionEnv'.
withSavedCallStack :: Chr a -> Chr a
withSavedCallStack action = do
  env@SessionEnv {callStack} <- ask
  liftIO $
    bracket
      (readIORef callStack)
      (writeIORef callStack)
      (\_ -> runChr action env)

-- | Catch a 'ControlFlow' exception thrown inside a 'Chr' action.
-- Uses 'try' at the 'IO' layer so the action's state changes (in
-- 'SessionEnv' refs and in any 'InterpM' env ref currently in scope)
-- survive the catch.
tryControlFlow :: Chr a -> Chr (Either ControlFlow a)
tryControlFlow m = do
  env <- ask
  liftIO (try (runChr m env))

-- ---------------------------------------------------------------------------
-- Tracing helpers
-- ---------------------------------------------------------------------------

-- | Emit a 'TraceEvent' if a handler is installed. The event is built
-- lazily — when tracing is off, the action passed in is not run, so
-- callers can place expensive snapshotValues (e.g. 'valueToTerm' walks)
-- inside it without paying the cost when tracing is disabled.
emitTrace :: Chr TraceEvent -> Chr ()
emitTrace mkEv = do
  env <- ask
  mh <- liftIO (readIORef env.traceHandler)
  case mh of
    Nothing -> pure ()
    Just h -> do
      ev <- mkEv
      depth <- liftIO (readIORef env.traceDepth)
      liftIO (h depth ev)
{-# INLINE emitTrace #-}

-- | Run @action@ at depth @depth + 1@, restoring the previous depth
-- on the way out (including on exception). No-op when tracing is off.
withTraceDepth :: Chr a -> Chr a
withTraceDepth action = do
  env <- ask
  mh <- liftIO (readIORef env.traceHandler)
  case mh of
    Nothing -> action
    Just _ -> do
      let depthRef = env.traceDepth
      liftIO $
        bracket
          (atomicModifyIORef' depthRef (\d -> (d + 1, ())))
          (\() -> atomicModifyIORef' depthRef (\d -> (d - 1, ())))
          (\() -> runChr action env)

-- | Snapshot a 'Value' as a 'Term' for inclusion in a trace event.
-- Wraps 'valueToTerm' with an empty alias map — trace events do not
-- need the per-query alias-class machinery; raw variable names are
-- fine for inspection.
snapshotValue :: Value -> Chr Term
snapshotValue = valueToTerm Map.empty

snapshotValues :: [Value] -> Chr [Term]
snapshotValues = traverse snapshotValue

-- | Look up a suspension by id and return its constraint-type and
-- argument values. Used by trace-event builders that need both.
suspensionView :: SuspensionId -> Chr (ConstraintType, [Value])
suspensionView sid = do
  susp <- lookupSusp sid
  pure (susp.suspType, susp.args)

-- | Look up a rule's display name from the session-cached
-- 'ruleNames' table. Falls back to @__rule_N@ on miss.
lookupRuleName :: SessionEnv -> RuleId -> Text
lookupRuleName env (RuleId i) =
  case IntMap.lookup i env.ruleNames of
    Just n -> n
    Nothing -> T.pack ("__rule_" ++ show i)

-- ---------------------------------------------------------------------------
-- Core interpreter
-- ---------------------------------------------------------------------------

-- | Call a procedure. Creates a fresh local 'Env' with parameter
-- bindings, executes the body, and catches 'CFReturn'. Default
-- return: 'VBool False'. Emits trace events at entry (and on return
-- for user functions / lambdas) when tracing is on; uses the
-- procedure's 'procKind' tag to label the event and decide whether
-- to bump the trace indentation.
callProc :: Name -> [CallVal] -> Chr Value
callProc name args = do
  mproc <- lookupProc name
  case mproc of
    Nothing -> runtimeError' "callProc: unknown procedure " name.unName
    Just proc -> do
      env <- case bindParams name proc.params args of
        Right e -> pure e
        Left msg -> runtimeErrorS msg
      traceEntry proc args
      let runBody = withSavedCallStack $ do
            result <- tryControlFlow (withFreshEnv env (execStmts proc.body))
            case result of
              Right () -> pure (VBool False)
              Left (CFReturn v) -> pure v
              Left (CFContinue l) -> runtimeError' "callProc: uncaught Continue " l.unLabel
              Left (CFBreak l) -> runtimeError' "callProc: uncaught Break " l.unLabel
      result <-
        if bumpDepthFor proc.procKind
          then withTraceDepth runBody
          else runBody
      traceExit proc.procKind result
      pure result

-- | Should entering a procedure of this kind increase trace
-- indentation? Tells, activates, occurrences, reactivate-dispatch,
-- and user functions/lambdas do; the @$call@ dispatcher is a thin
-- router and would just add noise.
bumpDepthFor :: ProcKind -> Bool
bumpDepthFor PKTell {} = True
bumpDepthFor PKActivate {} = True
bumpDepthFor PKOccurrence {} = True
bumpDepthFor PKReactivateDispatch = True
bumpDepthFor PKFunction {} = True
bumpDepthFor PKCallDispatch {} = False

-- | Emit the entry-time event for a procedure call, if tracing is on.
-- Reactivation events are emitted at the per-suspension boundary
-- inside 'DrainReactivationQueue' (where the constraint id is in
-- hand), not here.
traceEntry :: Procedure -> [CallVal] -> Chr ()
traceEntry proc args = case proc.procKind of
  PKTell ct -> emitTrace $ do
    ctName <- constraintTypeLabel ct
    ts <- snapshotValues [v | CVal v <- args]
    pure (TETell ctName ts)
  PKActivate _ -> emitTrace $ do
    let sid = activateSuspensionId args
    (ct, vs) <- suspensionView sid
    ctName <- constraintTypeLabel ct
    ts <- snapshotValues vs
    pure (TEActivate ctName sid ts)
  PKOccurrence ct n _ display -> emitTrace $ do
    ctName <- constraintTypeLabel ct
    pure (TETryOccurrence ctName n display)
  PKReactivateDispatch -> pure ()
  PKCallDispatch _ -> pure ()
  PKFunction qn _ -> emitTrace $ do
    let fname = Types.flattenName (Types.qualifiedToName qn)
    ts <- snapshotValues [v | CVal v <- args]
    pure (TECallFunction fname ts)

-- | Emit a 'TEReturn' event for procedures whose return value is
-- user-meaningful: user functions and lambdas. The framework
-- procedures (tell / activate / occurrence) return a boolean control
-- flag that has no surface meaning; the depth dedent alone makes
-- their completion visible.
traceExit :: ProcKind -> Value -> Chr ()
traceExit (PKFunction _ _) v = emitTrace $ do
  t <- snapshotValue v
  pure (TEReturn t)
traceExit _ _ = pure ()

-- | Render a 'ConstraintType' as its source name using the session's
-- 'storeTypeNames' table. Falls back to @c#type_N@ when the type is
-- unknown (which should never happen for compiler-generated code).
constraintTypeLabel :: ConstraintType -> Chr Text
constraintTypeLabel ct = do
  env <- ask
  let i = ct.unConstraintType
  case IntMap.lookup i env.storeTypeNames of
    Just name -> pure (Types.flattenName name)
    Nothing -> pure (T.pack ("c#type_" ++ show i))

-- | Pull the leading suspension id out of an activate / occurrence /
-- reactivate-dispatch procedure's argument list. The compiler always
-- emits the id as the first parameter.
activateSuspensionId :: [CallVal] -> SuspensionId
activateSuspensionId (CId s : _) = s
activateSuspensionId _ = error "activateSuspensionId: expected leading id argument"

-- | Bind procedure parameters into the appropriate environment slot
-- based on the runtime tag of each argument.
bindParams :: Name -> [Name] -> [CallVal] -> Either String Env
bindParams pname params args
  | length params /= length args =
      Left $
        "bindParams: arity mismatch in "
          ++ T.unpack pname.unName
          ++ ": "
          ++ show (length params)
          ++ " params, "
          ++ show (length args)
          ++ " args"
  | otherwise = Right (List.foldl' step emptyEnv (zip params args))
  where
    step e (p, CVal v) = insertVal p v e
    step e (p, CId s) = insertId p s e

-- | Execute a list of statements sequentially.
execStmts :: [Stmt] -> InterpM ()
execStmts = traverse_ execStmt

-- | Execute a single statement. Mutates the local 'Env' for binders,
-- delegates control-flow stmts to the throw-and-catch machinery, and
-- routes store / history / reactivation effects through 'Chr'.
execStmt :: Stmt -> InterpM ()
execStmt (LetVal name expr) = do
  v <- evalValExpr expr
  modifyEnv (insertVal name v)
execStmt (LetId name expr) = do
  s <- evalIdExpr expr
  modifyEnv (insertId name s)
execStmt (AssignVal name expr) = do
  v <- evalValExpr expr
  modifyEnv (insertVal name v)
execStmt (AssignId name expr) = do
  s <- evalIdExpr expr
  modifyEnv (insertId name s)
execStmt (If cond thenBranch elseBranch) = do
  b <- evalBoolExpr cond
  if b then execStmts thenBranch else execStmts elseBranch
execStmt (Foreach lbl cType suspVar conditions body) = do
  snapshot <- lift (getStoreSnapshot cType)
  let susps = toList snapshot
  execForeach lbl suspVar conditions body susps
execStmt (Continue lbl) = liftIO (throwIO (CFContinue lbl))
execStmt (Break lbl) = liftIO (throwIO (CFBreak lbl))
execStmt (Return expr) = do
  v <- evalValExpr expr
  liftIO (throwIO (CFReturn v))
execStmt (ExprStmt expr) = do
  _ <- evalValExpr expr
  pure ()
execStmt (BoolExprStmt expr) = do
  _ <- evalBoolExpr expr
  pure ()
execStmt (Store expr) = do
  sid <- evalIdExpr expr
  lift $ do
    storeConstraint sid
    emitTrace $ do
      (ct, vs) <- suspensionView sid
      ctName <- constraintTypeLabel ct
      ts <- snapshotValues vs
      pure (TEStore sid ctName ts)
execStmt (Kill expr) = do
  sid <- evalIdExpr expr
  lift $ do
    killConstraint sid
    emitTrace (pure (TEKill sid))
execStmt (AddHistory ruleId exprs) = do
  sids <- traverse evalIdExpr exprs
  lift $ do
    emitTrace $ do
      env <- ask
      let rn = lookupRuleName env ruleId
      pure (TEFire rn sids)
    addHistory ruleId sids
execStmt (DrainReactivationQueue suspVar body) = do
  envRef <- ask
  lift $
    drainQueue $ \sid -> do
      alive <- aliveConstraint sid
      if alive
        then do
          emitTrace $ do
            (ct, vs) <- suspensionView sid
            ctName <- constraintTypeLabel ct
            ts <- snapshotValues vs
            pure (TEReactivate sid ctName ts)
          liftIO (modifyIORef' envRef (insertId suspVar sid))
          runReaderT (execStmts body) envRef
        else pure ()
execStmt (PushFrame frame) = lift (pushFrame frame)

-- ---------------------------------------------------------------------------
-- Foreach implementation
-- ---------------------------------------------------------------------------

-- | Iterate the body of a 'Foreach' over a snapshot of candidate
-- suspensions. Dead suspensions and suspensions failing the index
-- conditions are skipped without entering the body. Labelled
-- 'CFContinue' / 'CFBreak' are caught here; non-matching labels and
-- 'CFReturn' propagate to the next outer handler.
execForeach ::
  Label ->
  Name ->
  [(ArgIndex, ValExpr)] ->
  [Stmt] ->
  [Suspension] ->
  InterpM ()
execForeach _ _ _ _ [] = pure ()
execForeach lbl suspVar conditions body (susp : rest) = do
  alive <- lift (isSuspAlive susp)
  if not alive
    then execForeach lbl suspVar conditions body rest
    else do
      ok <- checkConditions susp conditions
      if not ok
        then execForeach lbl suspVar conditions body rest
        else do
          lift $ emitTrace $ do
            ctName <- constraintTypeLabel susp.suspType
            ts <- snapshotValues susp.args
            pure (TEPartner ctName susp.suspId ts)
          modifyEnv (insertId suspVar susp.suspId)
          envRef <- ask
          result <- lift (withTraceDepth (tryControlFlow (runReaderT (execStmts body) envRef)))
          case result of
            Right () -> execForeach lbl suspVar conditions body rest
            Left (CFContinue l)
              | l == lbl -> execForeach lbl suspVar conditions body rest
              | otherwise -> liftIO (throwIO (CFContinue l))
            Left (CFBreak l)
              | l == lbl -> pure ()
              | otherwise -> liftIO (throwIO (CFBreak l))
            Left cf@(CFReturn _) -> liftIO (throwIO cf)

checkConditions :: Suspension -> [(ArgIndex, ValExpr)] -> InterpM Bool
checkConditions _ [] = pure True
checkConditions susp ((ArgIndex i, expr) : rest) = do
  v <- evalValExpr expr
  let argVal = suspArg susp i
  eq <- lift (equal v argVal)
  if eq
    then checkConditions susp rest
    else pure False

-- ---------------------------------------------------------------------------
-- Value-expression evaluator (normal mode)
-- ---------------------------------------------------------------------------

-- | Evaluate a 'ValExpr' in normal (non-deep) mode. Variable references
-- return whatever value is currently bound; chains are not followed.
-- 'EvalDeep' delegates to 'evalValExprDeep'.
evalValExpr :: ValExpr -> InterpM Value
evalValExpr (Var name) = do
  env <- getEnv
  case Map.lookup name env.envValues of
    Just v -> pure v
    Nothing -> lift (runtimeError' "evalValExpr: unbound variable " name.unName)
evalValExpr (Lit (IntLit n)) = pure (VInt n)
evalValExpr (Lit (FloatLit n)) = pure (VFloat n)
evalValExpr (Lit (AtomLit s)) = pure (VAtom s)
evalValExpr (Lit (TextLit s)) = pure (VText s)
evalValExpr (Lit (BoolLit b)) = pure (VBool b)
evalValExpr (Lit WildcardLit) = pure VWildcard
evalValExpr (CallExpr name args) = do
  argVals <- traverse evalCallArg args
  lift (callProc name argVals)
evalValExpr (HostCall name args) = do
  argVals <- traverse evalValExpr args
  derefedVals <- lift (traverse deref argVals)
  lift (invokeHostCall name derefedVals)
evalValExpr NewVar = lift newVar
evalValExpr (MakeTerm functor args) = do
  argVals <- traverse evalValExpr args
  pure $ makeTerm functor.unName argVals
evalValExpr (GetArg expr idx) = do
  v <- evalValExpr expr
  lift (getArg v idx)
evalValExpr (FieldArg expr (ArgIndex i)) = do
  sid <- evalIdExpr expr
  lift (getConstraintArg sid i)
evalValExpr (FieldType expr) = do
  sid <- evalIdExpr expr
  ct <- lift (getConstraintType sid)
  pure (VInt (fromIntegral ct.unConstraintType))
evalValExpr (EvalDeep expr) = evalValExprDeep expr
-- 'EvalIs' is the @is@-with-variable-RHS marker. The compiler only
-- emits it for @R is X@ where @X@ is syntactically a variable; the
-- inner expression is therefore always a 'Var'. We evaluate that
-- 'Var', dereference, and then walk the resulting value with
-- 'deepEvalValue' so a bound compound whose functor is a declared
-- function actually evaluates (matching SWI Prolog's @is@ on a
-- variable). Other 'EvalDeep' use sites (guards, non-variable @is@
-- RHSes) do not invoke the walker.
evalValExpr (EvalIs expr) = do
  v <- evalValExprDeep expr
  lift (deepEvalValue v)

-- ---------------------------------------------------------------------------
-- Bool-expression evaluator (normal mode)
-- ---------------------------------------------------------------------------

-- | Evaluate a 'BoolExpr' in normal (non-deep) mode. Logical connectives
-- short-circuit. 'BEvalDeep' delegates to 'evalBoolExprDeep'.
evalBoolExpr :: BoolExpr -> InterpM Bool
evalBoolExpr (BLit b) = pure b
evalBoolExpr (BNot e) = not <$> evalBoolExpr e
evalBoolExpr (BAnd e1 e2) = do
  b1 <- evalBoolExpr e1
  if b1 then evalBoolExpr e2 else pure False
evalBoolExpr (BOr e1 e2) = do
  b1 <- evalBoolExpr e1
  if b1 then pure True else evalBoolExpr e2
evalBoolExpr (BMatchTerm expr functor arity) = do
  v <- evalValExpr expr
  lift (matchTerm v functor.unName arity)
evalBoolExpr (BEqual e1 e2) = do
  v1 <- evalValExpr e1
  v2 <- evalValExpr e2
  lift (equal v1 v2)
evalBoolExpr (BIdEqual e1 e2) = do
  s1 <- evalIdExpr e1
  s2 <- evalIdExpr e2
  pure (idEqual s1 s2)
evalBoolExpr (BAlive expr) = do
  sid <- evalIdExpr expr
  lift (aliveConstraint sid)
evalBoolExpr (BIsConstraintType expr cType) = do
  sid <- evalIdExpr expr
  lift (isConstraintType sid cType)
evalBoolExpr (BNotInHistory ruleId args) = do
  sids <- traverse evalIdExpr args
  ok <- lift (notInHistory ruleId sids)
  unless ok $
    lift $
      emitTrace $ do
        env <- ask
        let rn = lookupRuleName env ruleId
        pure (TEHistoryHit rn sids)
  pure ok
evalBoolExpr (BUnify e1 e2) = do
  v1 <- evalValExpr e1
  v2 <- evalValExpr e2
  lift $ do
    env <- ask
    mh <- liftIO (readIORef env.traceHandler)
    case mh of
      Nothing -> unifyOrError v1 v2
      Just _ -> do
        t1 <- snapshotValue v1
        t2 <- snapshotValue v2
        beforeLen <- liftIO (Seq.length <$> readIORef env.reactQueue)
        ok <- unifyOrError v1 v2
        afterLen <- liftIO (Seq.length <$> readIORef env.reactQueue)
        emitTrace (pure (TEUnify t1 t2 (afterLen - beforeLen)))
        pure ok
evalBoolExpr (BFromVal expr) = do
  v <- evalValExpr expr
  case v of
    VBool b -> pure b
    _ -> lift (runtimeErrorS "guard did not evaluate to a boolean")
evalBoolExpr (BEvalDeep expr) = evalBoolExprDeep expr

-- ---------------------------------------------------------------------------
-- Id-expression evaluator
-- ---------------------------------------------------------------------------

-- | Evaluate an 'IdExpr' to a 'SuspensionId': either a lookup in the
-- id slot of the local 'Env', or a fresh suspension created from a
-- 'CreateConstraint' (not yet 'Store'd).
evalIdExpr :: IdExpr -> InterpM SuspensionId
evalIdExpr (IdVar name) = do
  env <- getEnv
  case Map.lookup name env.envIds of
    Just s -> pure s
    Nothing -> lift (runtimeError' "evalIdExpr: unbound id variable " name.unName)
evalIdExpr (CreateConstraint cType args) = do
  argVals <- traverse evalValExpr args
  lift (createConstraint cType argVals)

-- ---------------------------------------------------------------------------
-- Call-arg evaluator
-- ---------------------------------------------------------------------------

evalCallArg :: CallArg -> InterpM CallVal
evalCallArg (AVal e) = CVal <$> evalValExpr e
evalCallArg (AId e) = CId <$> evalIdExpr e

-- ---------------------------------------------------------------------------
-- Host call dispatch
-- ---------------------------------------------------------------------------

-- | Dispatch a host call by name. Only synchronous, non-control-flow
-- exceptions thrown by the host body get wrapped as runtime errors:
-- 'ControlFlow' (interpreter-internal: Return/Continue/Break) and
-- async exceptions (Ctrl+C, thread kill) must keep their identity so
-- they reach their intended handler; 'RuntimeErrorThrown' is re-thrown
-- verbatim so nested host calls preserve the original message and
-- stack frames.
invokeHostCall :: Name -> [Value] -> Chr Value
invokeHostCall name argVals = do
  mfn <- lookupHostCall name
  case mfn of
    Just (HostCallFn f) -> do
      env <- ask
      result <- liftIO (try @SomeException (runChr (f argVals) env))
      case result of
        Right v -> do
          emitTrace $ do
            argTs <- snapshotValues argVals
            resT <- snapshotValue v
            pure (TECallHost name.unName argTs resT)
          pure v
        Left exc
          | Just (cf :: ControlFlow) <- fromException exc ->
              liftIO (throwIO cf)
          | Just (ae :: SomeAsyncException) <- fromException exc ->
              liftIO (throwIO ae)
          | Just (rte :: RuntimeErrorThrown) <- fromException exc ->
              liftIO (throwIO rte)
          | otherwise ->
              runtimeErrorS $
                "host call " ++ T.unpack name.unName ++ ": " ++ displayException exc
    Nothing -> runtimeError' "invokeHostCall: unknown host call " name.unName

-- | Unify two already-evaluated values (tell semantics). Enqueues
-- observers of any variables affected by the unification — including
-- on failure, where partial bindings may still have produced
-- observers worth reactivating. Raises a runtime error with both
-- operands pretty-printed when unification fails.
unifyOrError :: Value -> Value -> Chr Bool
unifyOrError v1 v2 = do
  (ok, observers) <- unify v1 v2
  enqueue observers
  if ok
    then pure True
    else do
      t1 <- valueToTerm Map.empty v1
      t2 <- valueToTerm Map.empty v2
      runtimeErrorS $
        "unification failure: cannot unify "
          ++ prettyTerm t1
          ++ " with "
          ++ prettyTerm t2

-- ---------------------------------------------------------------------------
-- Value-expression evaluator (deep deref mode)
-- ---------------------------------------------------------------------------

-- | Evaluate a value expression with automatic dereferencing: variable
-- references follow binding chains before use, and this mode propagates
-- into sub-expressions. Used to implement 'EvalDeep' (guards and @is@ RHS).
--
-- This evaluator dereferences 'Var' references once. The
-- 'EvalDeep' case at 'evalValExpr' is responsible for the additional
-- value-side walk that evaluates a dereferenced compound term —
-- it applies only when the outer 'EvalDeep' is a bare 'Var', so that
-- @R is X@ (RHS is a variable) walks @X@'s bound term but
-- @R is copy_term(quote(X))@ (RHS is a host call) leaves @X@'s bound
-- compound symbolic for the host call's benefit. This intentionally
-- mirrors the type checker's rule: only @R is X@ widens the LHS to
-- @any@.
evalValExprDeep :: ValExpr -> InterpM Value
evalValExprDeep (Var name) = do
  v <- evalValExpr (Var name)
  lift (deref v)
evalValExprDeep (HostCall name args) = do
  argVals <- traverse evalValExprDeep args
  lift (invokeHostCall name argVals)
evalValExprDeep (CallExpr name args) = do
  argVals <- traverse evalCallArgDeep args
  lift (callProc name argVals)
evalValExprDeep (MakeTerm functor args) = do
  argVals <- traverse evalValExprDeep args
  pure $ makeTerm functor.unName argVals
evalValExprDeep expr = evalValExpr expr

-- | Walk a runtime 'Value', evaluating any compound subterm whose
-- @(functor, arity)@ names a declared host call or a compiled user
-- function. Atomic values pass through. Unbound variables are
-- dereferenced once and then re-walked, so a chain of bindings
-- ending in a compound triggers full evaluation. Functors that do
-- not name an evaluable declaration (constructors, undeclared
-- atoms) raise a runtime error — mirroring SWI Prolog's
-- @type_error(evaluable, F\/N)@.
--
-- Caveat: the host-call fallback below looks up @key.functor@ only,
-- discarding the arity, because 'HostCallRegistry' is keyed by name
-- alone. So @X = '-'(1), R is X@ reports the arity mismatch from
-- inside the @-@ primitive rather than as
-- @is: functor is not evaluable: -\/1@. The Scheme runtime keys its
-- equivalent table by @(name, arity)@ and so reports the latter — a
-- known divergence, tracked in @dev-docs\/SCHEME_BACKEND_GAPS.md@.
--
-- 'MakeTerm' callers do /not/ funnel through this walker — the
-- @quote/1@ quoting form must continue to produce the symbolic
-- compound it was asked to build.
deepEvalValue :: Value -> Chr Value
deepEvalValue v = do
  v' <- deref v
  case v' of
    VTerm functor args -> do
      args' <- traverse deepEvalValue args
      let key = EvaluableKey {functor = Name functor, arity = length args}
      invokeByKey key args'
    _ -> pure v'

-- | Dispatch a value-side deep-evaluation step: prefer a user-defined
-- function (resolved through the compiler-emitted 'evaluables' table),
-- fall back to the host-call registry, and raise a runtime error if
-- neither matches. The two-tier order lets a user shadow a prelude
-- host call by declaring a function of the same name and arity.
invokeByKey :: EvaluableKey -> [Value] -> Chr Value
invokeByKey key args = do
  SessionEnv {evaluables, hostCalls} <- ask
  case Map.lookup key evaluables of
    Just procName -> callProc procName (map CVal args)
    Nothing ->
      case Map.lookup key.functor hostCalls of
        Just _ -> invokeHostCall key.functor args
        Nothing ->
          runtimeError'
            "is: functor is not evaluable: "
            (key.functor.unName <> "/" <> T.pack (show key.arity))

evalCallArgDeep :: CallArg -> InterpM CallVal
evalCallArgDeep (AVal e) = CVal <$> evalValExprDeep e
evalCallArgDeep (AId e) = CId <$> evalIdExpr e

-- ---------------------------------------------------------------------------
-- Bool-expression evaluator (deep deref mode)
-- ---------------------------------------------------------------------------

-- | Deep-deref evaluation for 'BoolExpr'. Mirrors 'evalValExprDeep':
-- propagates deep mode into 'ValExpr' and 'IdExpr' payloads.
evalBoolExprDeep :: BoolExpr -> InterpM Bool
evalBoolExprDeep (BNot e) = not <$> evalBoolExprDeep e
evalBoolExprDeep (BAnd e1 e2) = do
  b1 <- evalBoolExprDeep e1
  if b1 then evalBoolExprDeep e2 else pure False
evalBoolExprDeep (BOr e1 e2) = do
  b1 <- evalBoolExprDeep e1
  if b1 then pure True else evalBoolExprDeep e2
evalBoolExprDeep (BMatchTerm expr functor arity) = do
  v <- evalValExprDeep expr
  lift (matchTerm v functor.unName arity)
evalBoolExprDeep (BEqual e1 e2) = do
  v1 <- evalValExprDeep e1
  v2 <- evalValExprDeep e2
  lift (equal v1 v2)
evalBoolExprDeep (BUnify e1 e2) = do
  v1 <- evalValExprDeep e1
  v2 <- evalValExprDeep e2
  lift (unifyOrError v1 v2)
evalBoolExprDeep (BFromVal expr) = do
  v <- evalValExprDeep expr
  case v of
    VBool b -> pure b
    _ -> lift (runtimeErrorS "guard did not evaluate to a boolean")
evalBoolExprDeep (BEvalDeep expr) = evalBoolExprDeep expr
evalBoolExprDeep expr = evalBoolExpr expr