defmodule Egglog.EGraph do @moduledoc """ Process-owned mutable native egglog e-graph session. `Egglog.EGraph` is the interactive counterpart to `Egglog.Program`. Use it when you want egglog state to persist across calls, as in a REPL, Livebook, or exploratory workflow: {:ok, eg} = Egglog.EGraph.new() {:ok, _} = Egglog.EGraph.run(eg, "(datatype T (A) (B))") {:ok, _} = Egglog.EGraph.run(eg, "(let x (A))") Each successful `run/3` mutates the native e-graph owned by this session's Elixir process. Concurrent callers are serialized through that owner process. This is intentionally different from `Egglog.Program.run/3`, which runs each query against a native clone of a loaded base program. `push/2` and `pop/2` delegate to native egglog's `(push)` and `(pop)` commands. They are useful for temporary experiments: :ok = Egglog.EGraph.push(eg) {:ok, _} = Egglog.EGraph.run(eg, "(let tmp (A))") :ok = Egglog.EGraph.pop(eg) The result shape of `run/3` matches `Egglog.Program.run/3`: outputs are a list of `%{type: atom(), text: String.t()}` maps and stats are a compact map. """ alias Egglog.{Commands, Common} alias Egglog.EGraph.Server @enforce_keys [:pid] defstruct [:pid, name: nil] @opaque t :: %__MODULE__{pid: pid(), name: String.t() | nil} @snapshot_doc """ Builds a snapshot of the current mutable e-graph. This runs no new egglog commands; it asks native egglog to serialize the session's current state, then optionally renders or writes that serialized graph. Supported options: * `:render` - output format to return. Defaults to `:auto`. Accepted values: * `:auto`, `"auto"`, or `true` - render SVG with Graphviz when the `dot` executable is available on `PATH`; otherwise return DOT text. * `:svg` or `"svg"` - require Graphviz SVG rendering. Returns `{:error, {:graphviz_missing, message}}` if `dot` is unavailable. * `:dot`, `"dot"`, `false`, or `nil` - return Graphviz DOT text. * `:json` or `"json"` - return egglog's native JSON snapshot. * `:snapshot_max_functions` - value passed to native egglog's `SerializeConfig.max_functions`. Defaults to `0`. Values greater than `0` become `Some(value)`; `0` and invalid values become `None`. * `:snapshot_max_calls_per_function` - value passed to native egglog's `SerializeConfig.max_calls_per_function`. Defaults to `0`. Values greater than `0` become `Some(value)`; `0` and invalid values become `None`. * `:snapshot_inline_leaves` - number of native leaf-inlining passes to use on the serialized snapshot before producing DOT or JSON text. Defaults to `0`. Negative or invalid values behave like `0`. * `:snapshot_split_primitive_outputs` - whether the native serialized snapshot should split primitive outputs into separate classes before producing DOT or JSON text. Defaults to `false`. * `:path` - optional file path where the returned `:text` payload is written. Defaults to unset. * `:dot_path` - optional file path where DOT is written when DOT text is available. Defaults to unset. For `render: :json`, this key is recorded as `nil` if provided because no DOT text is produced. * `:svg_path` - optional file path where SVG is written when SVG rendering succeeds. Defaults to unset. For non-SVG renders, this key is recorded as `nil` if provided. * `:json_path` - optional file path where JSON is written when `render: :json` is used. Defaults to unset. For non-JSON renders, this key is recorded as `nil` if provided. The returned snapshot map contains: * `:format` - one of `:dot`, `:svg`, or `:json`. * `:text` - the primary payload for the selected render. * `:dot`, `:svg`, or `:json` - the format-specific payload when present. * `:omitted` - native omission metadata for capped snapshots. * `:stats` - snapshot-specific native stats, currently `:snapshot_nodes` and `:snapshot_classes` when available. * `:result` - the underlying `run/3` result used to obtain the native serialization. * any provided artifact path keys after successful writes. String option keys are also accepted, matching the rest of the wrapper. """ @run_doc """ Runs egglog code against the mutable session. On success, native egglog state is retained for future calls. On error, egglog may have applied commands that appeared before the failing command in the same program; use `push/2` and `pop/2` around speculative work when you need an explicit rollback point. `input` may be: * a raw egglog source string, * a list of raw egglog command strings, or * a parsed `%Egglog.Commands{}` handle returned by `parse/1`. Supported options: * `:output_limit` - maximum number of native command outputs to keep in the returned `:outputs` list. Defaults to unset, meaning all outputs are kept. Values must be positive integers; invalid values are ignored. When outputs are truncated, the returned result has `status: :limit`. * `:snapshot` - request a native snapshot after the input has run. Defaults to `:none`. Accepted values: * `:none`, `"none"`, `false`, or `nil` - do not include a snapshot. * `:dot`, `"dot"`, or `true` - include native Graphviz DOT text. * `:json` or `"json"` - include native JSON snapshot text. `run/3` does not render DOT to SVG. Use `snapshot/2` when you want `render: :auto` or `render: :svg` post-processing. * `:snapshot_max_functions` - value passed to native egglog's `SerializeConfig.max_functions` when `:snapshot` is enabled. Defaults to `0`. Values greater than `0` become `Some(value)`; `0` and invalid values become `None`. * `:snapshot_max_calls_per_function` - value passed to native egglog's `SerializeConfig.max_calls_per_function` when `:snapshot` is enabled. Defaults to `0`. Values greater than `0` become `Some(value)`; `0` and invalid values become `None`. * `:snapshot_inline_leaves` - number of native leaf-inlining passes to use on the serialized snapshot before producing DOT or JSON text. Defaults to `0`. Negative or invalid values behave like `0`. * `:snapshot_split_primitive_outputs` - whether the native serialized snapshot should split primitive outputs into separate classes before producing DOT or JSON text. Defaults to `false`. The returned result map contains: * `:status` - native run status, or `:limit` when `:output_limit` truncated outputs. * `:outputs` - list of `%{type: atom(), text: String.t()}` command outputs. * `:stats` - compact numeric/text stats from native egglog. * `:report` - per-rule/per-ruleset reporting data from native egglog. * `:snapshot` - present only when `:snapshot` is `:dot` or `:json`; contains `%{format: :dot | :json, text: binary(), omitted: term()}`. String option keys are also accepted, matching the rest of the wrapper. """ @check_doc """ Runs a native egglog check against the current mutable session. Supported options are the same as `run/3`; they are passed through to the internal `(check ...)` run. Defaults are therefore the `run/3` defaults. Only the check result is returned. Snapshot and output options may affect the internal run, but their result data is not exposed by this function. """ @extract_doc """ Extracts the cheapest native egglog term for `expr` in the current session. Supported options: * `:variants` - optional value inserted as the second argument to native egglog's `(extract expr variants)` command. Defaults to unset, which uses `(extract expr)`. All `run/3` options are also accepted and passed through to the internal extract run. Defaults are the `run/3` defaults. Snapshot and output options may affect the internal run, but `extract/3` returns only the extracted text. String option keys are also accepted, matching the rest of the wrapper. """ @typedoc """ Raw or parsed egglog commands accepted by `run/3`. """ @type input :: String.t() | [String.t()] | Commands.t() @typedoc """ Decoded value returned by `eval/2` and `lookup/3`. `:value` contains a native egglog e-class value rendered as text. Primitive egglog values are decoded to ordinary Elixir values when possible: integers, floats, booleans, strings, and `nil` for unit. """ @type value :: %{ required(:sort) => String.t(), required(:type) => atom(), required(:value) => term() } @typedoc """ Result returned by `run/3`. """ @type run_result :: %{ required(:status) => atom(), required(:outputs) => [%{required(:type) => atom(), required(:text) => String.t()}], required(:stats) => map(), required(:report) => map(), optional(:snapshot) => map() } @doc """ Parses egglog source into a reusable command handle. """ @spec parse(String.t()) :: {:ok, Commands.t()} | {:error, term()} defdelegate parse(source), to: Commands @doc """ Bang variant of `parse/1`. """ @spec parse!(String.t()) :: Commands.t() defdelegate parse!(source), to: Commands @doc """ Creates a process-owned mutable native e-graph session. `source` is optional initial egglog code. Unlike `Egglog.Program`, this state is not a read-only base: later `run/3` calls mutate it directly through the session owner process. Options: * `:name` - human-readable session name stored in the returned struct. Defaults to `nil`; it is not passed to native egglog. * `:proofs` - create the native e-graph with proof support enabled. Defaults to `false`. Truthy values are `true`, `"true"`, `"1"`, and `1`; all other values are treated as false. String option keys are also accepted, matching the rest of the wrapper. """ @spec new(String.t(), keyword() | map()) :: {:ok, t()} | {:error, term()} def new(source \\ "", opts \\ []) when is_binary(source) do opts = Common.option_map(opts) proofs? = Common.truthy?(Common.get(opts, :proofs, false)) with {:ok, pid} <- Server.start(source: source, proofs?: proofs?) do Process.link(pid) {:ok, %__MODULE__{pid: pid, name: Common.get(opts, :name)}} end end @doc @run_doc @spec run(t(), input(), keyword() | map()) :: {:ok, run_result()} | {:error, term()} def run(egraph, input, opts \\ []) def run(%__MODULE__{} = egraph, %Commands{} = commands, opts) do opts = Common.option_map(opts) snapshot = Common.snapshot_options(opts) Server.call( egraph.pid, {:run_parsed, commands.ref, snapshot, Common.get(opts, :output_limit)} ) end def run(%__MODULE__{pid: pid}, input, opts) do opts = Common.option_map(opts) program = normalize_input(input) snapshot = Common.snapshot_options(opts) Server.call(pid, {:run, program, snapshot, Common.get(opts, :output_limit)}) end @doc """ Bang variant of `run/3`. #{@run_doc} Raises if native egglog rejects the input or any option validation raises. """ @spec run!(t(), input(), keyword() | map()) :: run_result() def run!(%__MODULE__{} = egraph, input, opts \\ []) do egraph |> run(input, opts) |> Common.bang("failed to run egglog program") end @doc @check_doc @spec check(t(), String.t(), keyword() | map()) :: {:ok, boolean()} | {:error, term()} def check(%__MODULE__{} = egraph, fact, opts \\ []) when is_binary(fact) do egraph |> run("(check #{fact})", opts) |> Common.check_result() end @doc """ Boolean variant of `check/3`. #{@check_doc} Raises on non-check errors so syntax/type/native errors are not silently treated as false mathematical assertions. """ @spec check?(t(), String.t(), keyword() | map()) :: boolean() def check?(%__MODULE__{} = egraph, fact, opts \\ []) do egraph |> check(fact, opts) |> Common.bang("failed to check egglog fact") end @doc """ Asserts that a native egglog check fails. Supported options are the same as `run/3`; they are passed through to the internal `(fail (check ...))` run. Defaults are therefore the `run/3` defaults. Only `:ok` or `{:error, reason}` is returned. """ @spec check_fail(t(), String.t(), keyword() | map()) :: :ok | {:error, term()} def check_fail(%__MODULE__{} = egraph, fact, opts \\ []) when is_binary(fact) do egraph |> run("(fail (check #{fact}))", opts) |> Common.ok_only() end @doc @extract_doc @spec extract(t(), String.t(), keyword() | map()) :: {:ok, String.t()} | {:error, term()} def extract(%__MODULE__{} = egraph, expr, opts \\ []) when is_binary(expr) do egraph |> run(Common.extract_request(expr, opts), opts) |> Common.extraction() end @doc """ Bang variant of `extract/3`. #{@extract_doc} Raises if native egglog rejects the input or extraction output is not available. """ @spec extract!(t(), String.t(), keyword() | map()) :: String.t() def extract!(%__MODULE__{} = egraph, expr, opts \\ []) do egraph |> extract(expr, opts) |> Common.bang("failed to extract egglog term") end @doc """ Evaluates an egglog expression in the current mutable session. Primitive values are decoded into Elixir values. Non-primitive e-class values are returned with `type: :value`. """ @spec eval(t(), String.t()) :: {:ok, value()} | {:error, term()} def eval(%__MODULE__{pid: pid}, expr) when is_binary(expr) do Server.call(pid, {:eval, expr}) end @doc """ Bang variant of `eval/2`. """ @spec eval!(t(), String.t()) :: value() def eval!(%__MODULE__{} = egraph, expr) do egraph |> eval(expr) |> Common.bang("failed to evaluate egglog expression") end @doc """ Looks up a function row after evaluating the given argument expressions. Returns `{:ok, nil}` when no row exists for the evaluated key. """ @spec lookup(t(), String.t(), [String.t()]) :: {:ok, value() | nil} | {:error, term()} def lookup(%__MODULE__{pid: pid}, name, arg_exprs) when is_binary(name) and is_list(arg_exprs) do Server.call(pid, {:lookup, name, arg_exprs}) end @doc """ Bang variant of `lookup/3`. """ @spec lookup!(t(), String.t(), [String.t()]) :: value() | nil def lookup!(%__MODULE__{} = egraph, name, arg_exprs) do egraph |> lookup(name, arg_exprs) |> Common.bang("failed to look up egglog function") end @doc """ Pushes a native egglog rollback point. `count` defaults to `1`. This corresponds to `(push)` for `1`, or `(push count)` for larger counts. """ @spec push(t(), pos_integer()) :: :ok | {:error, term()} def push(%__MODULE__{} = egraph, count \\ 1) do stack_command("push", count) |> then(&run(egraph, &1)) |> Common.ok_only() end @doc """ Pops native egglog rollback points. `count` defaults to `1`. Popping too much returns an error from native egglog. """ @spec pop(t(), pos_integer()) :: :ok | {:error, term()} def pop(%__MODULE__{} = egraph, count \\ 1) do stack_command("pop", count) |> then(&run(egraph, &1)) |> Common.ok_only() end @doc @snapshot_doc @spec snapshot(t(), keyword() | map()) :: {:ok, map()} | {:error, term()} def snapshot(%__MODULE__{} = egraph, opts \\ []) do opts = Common.option_map(opts) {render, run_opts} = Common.snapshot_run_opts(opts) with {:ok, result} <- run(egraph, "", run_opts), {:ok, snapshot} <- Common.snapshot_from_result(result, opts, render) do {:ok, snapshot} end end @doc """ Bang variant of `snapshot/2`. #{@snapshot_doc} Raises if snapshot serialization, Graphviz rendering, or artifact writing fails. """ @spec snapshot!(t(), keyword() | map()) :: map() def snapshot!(%__MODULE__{} = egraph, opts \\ []) do egraph |> snapshot(opts) |> Common.bang("failed to snapshot egglog e-graph") end @doc """ Returns native egglog's tuple count for the current mutable session. This wraps Rust egglog's `EGraph::num_tuples()`. It is a coarse database-size counter, not a graph summary. For node/class counts, visualization, or JSON inspection, use `snapshot/2`. """ @spec num_tuples(t()) :: {:ok, non_neg_integer()} | {:error, term()} def num_tuples(%__MODULE__{pid: pid}) do Server.call(pid, :num_tuples) end @doc """ Closes the native mutable e-graph and stops the session process. """ @spec close(t()) :: :ok | {:error, term()} def close(%__MODULE__{pid: pid} = egraph) do result = Server.call(pid, :close) stop(egraph) result end defp stop(%__MODULE__{pid: pid}) do if Process.alive?(pid) do GenServer.stop(pid, :normal, :infinity) end catch :exit, _reason -> :ok end defp stack_command(_name, count) when not is_integer(count) or count < 1 do raise ArgumentError, "push/pop count must be a positive integer" end defp stack_command(name, 1), do: "(#{name})" defp stack_command(name, count), do: "(#{name} #{count})" defp normalize_input(input) when is_binary(input), do: input defp normalize_input(commands) when is_list(commands), do: Common.join_commands(commands) end