Skuld.Comp (skuld v0.1.26)

View Source

Skuld.Comp: Evidence-passing algebraic effects with scoped handlers.

Core Concepts

  • Computation: (env, k -> {result, env}) - a suspended computation
  • Result: Opaque value - framework doesn't impose shape
  • Leave-scope: Continuation chain for scope cleanup/control
  • ExternalSuspend: Sentinel struct that bypasses leave-scope

Auto-Lifting

Non-computation values are automatically lifted to pure(value). This enables ergonomic patterns:

comp do
  x <- State.get()
  _ <- if x > 5, do: Writer.tell(:big)  # nil auto-lifted when false
  x * 2  # final expression auto-lifted (no return needed)
end

Architecture

Unlike Freyja's centralised interpreter, Skuld uses decentralised evidence-passing. Run acts as a control authority - it recognizes the ExternalSuspend sentinel and invokes the leave-scope chain - but treats results as opaque.

Scoped effects (Reader.local, Catch) install leave-scope handlers that can clean up env or redirect control flow.

Summary

Functions

Sequence computations

Call a computation with validation and exception handling.

Call an effect handler with exception handling.

Call a continuation (k or leave_scope) with exception handling.

Cancel a suspended computation, invoking the leave_scope chain for cleanup.

Apply f to each element for side effects, discarding results.

Invoke an effect operation

Flatten nested computations

identity continuation - for initial continuation & default leave-scope

Map over a computation's result

Lift a pure value into a computation

Lift a pure value into a computation.

Run a computation to completion.

Run a computation, extracting just the value (raises on ExternalSuspend/Throw)

Create a scoped computation with a final continuation for cleanup and result transformation.

Sequence a list of computations.

Sequence computations, ignoring first result

Apply f to each element, sequence the resulting computations.

Install a scoped handler for an effect.

Install scoped state for a computation with automatic save/restore.

Functions

bind(comp, f)

Sequence computations

call(comp, env, k)

Call a computation with validation and exception handling.

Raises a helpful error if the value is not a valid computation (2-arity function). This catches common mistakes like forgetting return(value) at the end of a comp block.

Elixir exceptions (raise/throw/exit) are caught and converted to Throw effects, allowing them to be handled uniformly with effect-based errors via catch_error.

Note: InvalidComputation errors (validation failures) are re-raised rather than converted to Throws, since they represent programming bugs that should fail fast.

call_handler(handler, args, env, k)

Call an effect handler with exception handling.

Similar to call/3 but for 3-arity handlers. Exceptions in handler code are caught and converted to Throw effects.

call_k(k, value, env)

Call a continuation (k or leave_scope) with exception handling.

Continuations have signature (value, env) -> {value, env}. Unlike call/3 which handles computations, this handles the simpler continuation case where we just need to catch Elixir exceptions and convert them to Throw effects.

Used in scoped/2 to wrap calls to finally_k.

cancel(external_suspend, env, reason)

Cancel a suspended computation, invoking the leave_scope chain for cleanup.

When a computation yields (returns %ExternalSuspend{}), the caller can either:

  • Resume it with suspend.resume.(input)
  • Cancel it with Comp.cancel(suspend, env, reason)

Cancellation creates a %Cancelled{reason: reason} result and invokes the leave_scope chain, allowing effects to clean up resources.

Example

# Run until suspension
{%ExternalSuspend{} = suspend, env} = Comp.run(my_yielding_comp)

# Decide to cancel instead of resume
{%Cancelled{reason: :timeout}, final_env} =
  Comp.cancel(suspend, env, :timeout)

Effect Cleanup

Effects can detect cancellation in their leave_scope handlers:

my_leave_scope = fn result, env ->
  case result do
    %Cancelled{} -> cleanup_my_resources(env)
    _ -> :ok
  end
  {result, env}
end

each(list, f)

Apply f to each element for side effects, discarding results.

Like traverse/2 but returns :ok instead of collecting results. Useful when you only care about effects (e.g., Writer.tell), not values.

Example

comp do
  _ <- Comp.each(items, &Writer.tell/1)
  return(:done)
end

effect(sig, args \\ nil)

Invoke an effect operation

flatten(comp)

Flatten nested computations

identity_k(val, env)

identity continuation - for initial continuation & default leave-scope

map(comp, f)

Map over a computation's result

pure(value)

@spec pure(term()) :: Skuld.Comp.Types.computation()

Lift a pure value into a computation

return(value)

@spec return(term()) :: Skuld.Comp.Types.computation()

Lift a pure value into a computation.

Alias for pure/1. Provided for ergonomic use both inside and outside comp blocks. Inside comp blocks, use the imported return/1 from Skuld.Comp.BaseOps. Outside comp blocks, use Comp.return/1 directly.

Example

# Inside a comp block (return is imported)
comp do
  x <- State.get()
  return(x + 1)
end

# Outside a comp block
fn x -> Comp.return(x * 2) end

run(comp)

Run a computation to completion.

Creates a fresh environment internally - all handler installation should be done via with_handler on the computation.

Uses ISentinel protocol to determine completion behavior:

  • ExternalSuspend: bypasses leave-scope chain
  • Other values: invoke leave-scope chain

Example

{result, _env} =
  my_comp
  |> State.with_handler(0)
  |> Reader.with_handler(:config)
  |> Comp.run()

run!(comp)

@spec run!(Skuld.Comp.Types.computation()) :: term()

Run a computation, extracting just the value (raises on ExternalSuspend/Throw)

scoped(comp, setup)

Create a scoped computation with a final continuation for cleanup and result transformation.

The setup function receives the current env and must return {modified_env, finally_k} where finally_k :: (value, env) -> {value, env} is a continuation that runs when the scope exits.

This enables Koka-style with semantics where handlers can transform computation results (e.g., wrapping with collected state, logs, etc.).

The finally_k continuation is called on both:

  • Normal exit: before continuing to outer computation
  • Abnormal exit: during leave-scope unwinding (e.g., throw)

The previous leave-scope is automatically restored in both paths.

The argument order is pipe-friendly (computation first).

Example - Environment restoration only

def local(modify, comp) do
  comp
  |> Skuld.Comp.scoped(fn env ->
    current = Env.get_state(env, @sig)
    modified_env = Env.put_state(env, @sig, modify.(current))
    finally_k = fn value, e -> {value, Env.put_state(e, @sig, current)} end
    {modified_env, finally_k}
  end)
end

Example - Result transformation (like EffectLogger)

def with_logging(comp) do
  comp
  |> Skuld.Comp.scoped(fn env ->
    env_with_log = Env.put_state(env, :log, [])

    finally_k = fn value, e ->
      log = Env.get_state(e, :log)
      cleaned = Map.delete(e.state, :log)
      {{value, Enum.reverse(log)}, %{e | state: cleaned}}
    end

    {env_with_log, finally_k}
  end)
end

sequence(comps)

Sequence a list of computations.

Runs each computation in order, collecting results into a list. Uses a tail-recursive accumulator to avoid stack overflow on large lists.

then_do(comp1, comp2)

Sequence computations, ignoring first result

traverse(list, f)

Apply f to each element, sequence the resulting computations.

Uses a tail-recursive accumulator to avoid stack overflow on large lists.

with_handler(comp, sig, handler)

Install a scoped handler for an effect.

The handler is installed for the duration of comp and then restored to its previous state (or removed if there was no previous handler).

This allows "shadowing" handlers - an inner computation can have its own handler for an effect while an outer handler exists.

The argument order is pipe-friendly (computation first).

Example

# Create a computation with its own State handler
inner =
  comp do
    x <- State.get()
    _ <- State.put(x + 1)
    return(x)
  end
  |> Comp.with_handler(State, &State.handle/3)

# Use it - inner State is independent of outer State
outer = comp do
  _ <- State.put(100)
  result <- inner        # uses inner's handler
  y <- State.get()       # uses outer's handler, still 100
  return({result, y})
end

with_scoped_state(comp, state_key, initial, opts \\ [])

Install scoped state for a computation with automatic save/restore.

This is a common pattern used by effect handlers to manage state that should be isolated to a computation scope. On entry, saves previous state (if any) and sets initial state. On exit (normal or throw), restores previous state or removes it if there was none.

Options

  • :output - optional function (result, final_state) -> new_result to transform the result using the final state value before returning.
  • :suspend - optional function (ExternalSuspend.t(), env) -> {ExternalSuspend.t(), env} to decorate ExternalSuspend values when yielding. Allows attaching scoped state to suspends.
  • :default - default value when reading final state (default: nil)

Example

# Simple usage - state is saved/restored automatically
comp
|> Comp.with_scoped_state(state_key, initial_value)
|> Comp.with_handler(sig, handler)

# With output transformation - include final state in result
comp
|> Comp.with_scoped_state(state_key, initial, output: fn result, final -> {result, final} end)
|> Comp.with_handler(sig, handler)

# With suspend decoration - attach state to ExternalSuspend.data when yielding
comp
|> Comp.with_scoped_state(state_key, initial,
  suspend: fn s, env ->
    state = Env.get_state(env, state_key)
    data = s.data || %{}
    {%{s | data: Map.put(data, :my_state, state)}, env}
  end
)