Powered by AppSignal & Oban Pro

Debugging and tracing

livebooks/debugging_and_tracing.livemd

Debugging and tracing

System.put_env("AL_MNESIA_DISTRIBUTED", "false")

System.put_env(
  "AL_MNESIA_DIR",
  Path.join(System.tmp_dir!(), "al_livebook_#{:erlang.unique_integer([:positive])}")
)

Mix.install(
  [{:al, github: "anoma/AL-Ex"}],
  config: [al: [transaction_programs: [AL.TransactionProgram.Bootstrap], natives: []]]
)

Setup

This notebook is self-contained. The cell above installs AL directly, no separate node, no cookie, nothing to attach to. The first run compiles AL and its dependencies from scratch, which takes a minute or two. Reopening this notebook later is fast, since Mix.install caches by the exact dependency and config combination. State here is throwaway. It lives only for this notebook's session and disappears when the session ends.

If you already have a real AL node running and want the persistent, shared store instead, see the intro for how to attach to it instead.

Mix.install has to finish running before AL's macros (run, defclass, and the rest) can be brought into scope, so that happens in its own cell here.

use AL

AL.Branch.head()

Reading a failure

An aborted run comes back with a structured reason naming exactly what went wrong. Sending a selector a method it does not understand names the receiver, the selector, its arity, and a ranked list of suggestions.

result =
  run do
    defclass :failgreeter, super: :value do
      defmethod(:init, [self, _, self])
      defmethod(:greet, [self, _name])
    end

    new(:failgreeter, g)
    greett(g, :world)
  end

{:aborted, reason} = result
{reason.reason, reason.message}

AL's reporter can also report domain violations.

run do
  defclass :failure_domain_probe,
    super: :object,
    ivars: [%{name: :state, domain: ["on", "off"]}] do
  end

  new(:failure_domain_probe, %{name: :failure_domain_instance, state: "on"}, _)
end

A custom does_not_understand suppresses the failure entirely

If a class handles its own misses, sending it something unknown doesn't abort. This allows for custom fallback behaviour.

run do
  defclass :failquiet, super: :value do
    defmethod(:init, [self, _, self])
    defmethod(:does_not_understand, [self, _m, _a])
  end

  new(:failquiet, q)
  anything(q, :x)
end

The domino tracing model

AL's call-tree trace family is called "Domino" and is built on the classic Prolog tracer model: a "Byrd box" for every call, with four ports — Call, Exit, Redo, Fail. Plain Prolog needs one box per predicate call. AL needs two, stacked: a method box (dispatch choosing which provider answers a selector), which wraps a clause box (which clause of the chosen method actually runs). Every send opens because AL has dispatch sitting on top of clause selection, whereas Prolog only has the latter.

Retained traces are opt-in and composable. Use trace: [:domino] when you need the structured evidence stream; ordinary runs retain no trace.

{:atomic, {bindings, state}} =
  run trace: [:domino] do
    fibonacci(3, x)
  end

state.trace.events
|> Enum.reverse()
|> Enum.map(&AL.Trace.payload/1)
|> Enum.map(fn
  entry when is_tuple(entry) -> elem(entry, 0)
  entry -> entry
end)
|> Enum.filter(&(&1 in [:method_call, :method_exit, :clause_call, :clause_exit]))

Each of the three recursive fibonacci calls opens its own method box immediately wrapping a clause box, and closes them in the same nested order. method_call, clause_call, ..., clause_exit, method_exit, one pair of boxes per call, exactly as deep as the recursion goes.

Turning on tracing for a selector

AL.trace(selector) prints every Call/Exit/Redo/Fail port as that selector runs, until AL.notrace() turns it back off. Since this notebook runs AL in its own process, the output shows up right below the cell. (If you're instead attached to a separate, persistent AL node, the trace prints to that node's own terminal, not the notebook.)

AL.trace(:cell)

run do
  new(:cell, %{name: :traced}, c)
end

AL.notrace()

Fail shows up the same way when a call doesn't pan out — here member tries :a against :z, fails, then :b against :z, fails too.

AL.trace(:list_member)

run do
  member([:a, :b], :z)
end

AL.notrace()

run accepts a set of trace flags. With no flags it retains no execution history. :domino retains structured method/clause and constraint evidence; :vm interleaves every raw goal into the same chronological event stream. The flags compose, so trace: [:domino, :vm] gives the former full trace. Every event is tagged as either :domino or :vm; when both flags are enabled, Domino owns constraint evidence so it is retained only once. A future query explainer can join this same system as another producer. The old trace_mode: values remain compatibility shorthands.

Dispatch on an open receiver can have several candidate legs- a value/ephemeral leg and a durable leg, at minimum. Tracing shows which legs are being considered before a match is even found.

run do
  defclass :trace_leg_class, super: :value, ivars: [] do
    defmethod(:trace_next, [
      %{class: :trace_leg_class, letter: :a},
      %{class: :trace_leg_class, letter: :b}
    ])
  end
end

AL.trace(:trace_next)

run do
  trace_next(x, %{class: :trace_leg_class, letter: :b})
end

AL.notrace()

The derivation tree: a cleaner picture than raw trace text

AL.Trace.derivation_tree/1 turns a completed run state into an operational derivation tree. Call nodes have successful answer children; answers carry the selected clause and what that call derived, and their children are the ordered nested calls and constraint evidence that produced the answer. Open variables nested inside a receiver or argument are included in a call's derivation. Unsuccessful attempts are pruned.

{:atomic, {bindings, state}} =
  run trace: [:domino] do
    fibonacci(3, x)
  end

[root] = AL.Trace.derivation_tree(state)
[answer] = root.children

{root.kind, root.label, answer.clause, answer.derived, length(answer.children)}

When a method has more than one clause that could structurally match, redo tries the next one in place. The tree keeps only the attempt that actually won, not a trail of every clause that was tried.

run do
  defclass :redo_demo, super: :object, ivars: [] do
  end

  defmethod(:redo_demo, :pick, [self, :first])
  defmethod(:redo_demo, :pick, [self, :second])
end
{:atomic, {bindings, state}} =
  run trace: [:domino] do
    new(:redo_demo, %{}, obj)
    pick(obj, result)
    result = :second
  end

roots = AL.Trace.derivation_tree(state)
pick_node = Enum.find(roots, &match?(%{label: {_, :pick, _}}, &1))

[answer] = pick_node.children
{answer.clause, answer.children, answer.derived}

The first clause (:first) is attempted and then upon fail, redone into the second clause. The derivation tree though, only shows the winner. It prunes the failed branches.

run do
  defclass :probe_box, super: :object, ivars: [] do
    defmethod(:probe_reject, [self, v]) do
      v = 1
      v > 50
    end

    defmethod(:probe_leaf, [self, 100])

    defmethod(:probe_mid, [self, v]) do
      probe_leaf(self, w)
      v = w + 1
    end

    defmethod(:probe_top, [self, v]) do
      probe_mid(self, w)
      v = w + 1
    end

    defmethod(:probe_answer, [self, v]) do
      alternative([probe_reject(self, v)], [probe_top(self, v)])
    end
  end
end
{:atomic, {bindings, state}} =
  run trace: [:domino] do
    new(:probe_box, %{}, obj)
    probe_answer(obj, r)
  end

roots = AL.Trace.derivation_tree(state)
answer = Enum.find(roots, &match?(%{label: {_, :probe_answer, _}}, &1))

[answer_result] = answer.children
[top] = answer_result.children
[top_result] = top.children
[mid] = top_result.children
[mid_result] = mid.children
[leaf] = mid_result.children

{top.label, mid.label, leaf.label, Enum.any?(roots, &match?(%{label: {_, :probe_reject, _}}, &1))}

Constraints show up in the tree too, nested under their own scope

Constraint goals (from logic and constraints) remain explicit derivation steps inside the successful answer that posted them. Each constraint node carries the variable descriptions immediately before and immediately after that constraint ran. A later method or collection answer cannot be mistaken for something the earlier constraint derived.

run do
  defclass :triple_class, super: :object, ivars: [] do
    defmethod(:triple, [self, n, result]) do
      result = n * 3
    end
  end
end
{:atomic, {bindings, state}} =
  run trace: [:domino] do
    new(:triple_class, %{}, obj)
    triple(obj, 4, r)
  end

roots = AL.Trace.derivation_tree(state)

root = Enum.find(roots, &match?(%{label: {_, :triple, _}}, &1))
[answer] = root.children
[constraint_node] = Enum.filter(answer.children, &(&1.kind == :constraint))

{constraint_node.kind, constraint_node.constraints_in, constraint_node.derived}