wip debug

This commit is contained in:
Kacper Marzecki 2025-06-20 10:28:53 +02:00
parent f1243084c7
commit a78fe0541a
2 changed files with 422 additions and 111 deletions

View File

@ -2,7 +2,9 @@ i have a compiler Im writing in elixir. I need to trace execution of logic to p
I have many tests I want to debug individually but I can run only the full test suite. I have many tests I want to debug individually but I can run only the full test suite.
I want to create a couple of macros/functions that'd enable me to debug my code. I want to create a couple of macros/functions that'd enable me to debug my code.
the scenario I imagine: the scenario I imagine:
before the test I want to debug I write `Tdd.Debug.enable` before the test I want to debug I write `Tdd.Debug.enable` and then :
And after the test line I add `Tdd.Debug.print_and_disable`. as the code is being executed, it prints out a tree of called functions, their arguments and return values, effectively building a stacktrace as its being executed.
The last line prints a tree of called functions, their arguments and return values. first lets design how we'd print this information and if/what should we store in memory to be able to render it in a shape sufficient for good debugging.
We can modify compiler functions. Then lets design an elixir module that compiler modules would 'use'
e.g. a Tdd.Debug module that'd have and override for elixir `def`.

369
new.exs
View File

@ -1,9 +1,267 @@
defmodule Tdd.Debug do defmodule Tdd.Debug do
@moduledoc "Helpers for debugging TDD structures." @moduledoc "Helpers for debugging TDD structures and tracing function calls."
alias Tdd.Store # alias Tdd.Store # Keep if used by your print functions
@agent_name Tdd.Debug.StateAgent
# --- Agent State Management ---
def init do
case Process.whereis(@agent_name) do
nil -> Agent.start_link(fn -> MapSet.new() end, name: @agent_name)
_pid -> :ok
end
:ok
end
defp add_traced_pid(pid) when is_pid(pid) do
init()
Agent.update(@agent_name, &MapSet.put(&1, pid))
end
defp remove_traced_pid(pid) when is_pid(pid) do
if agent_pid = Agent.whereis(@agent_name) do
Agent.cast(agent_pid, fn state -> MapSet.delete(state, pid) end)
end
end
defp is_pid_traced?(pid) when is_pid(pid) do
case Agent.whereis(@agent_name) do
nil ->
false
agent_pid ->
try do
Agent.get(agent_pid, &MapSet.member?(&1, pid), :infinity)
rescue
_e in [Exit, ArgumentError] -> false
end
end
end
# --- Formatting Helpers ---
@arg_length_limit 80
@total_args_length_limit 200
defp format_args_list(args_list) when is_list(args_list) do
formatted_args = Enum.map(args_list, &format_arg_value/1)
combined = Enum.join(formatted_args, ", ")
if String.length(combined) > @total_args_length_limit do
String.slice(combined, 0, @total_args_length_limit - 3) <> "..."
else
combined
end
end
defp format_arg_value(arg) do
inspected = inspect(arg, limit: :infinity, pretty: false, structs: true)
if String.length(inspected) > @arg_length_limit do
String.slice(inspected, 0, @arg_length_limit - 3) <> "..."
else
inspected
end
end
# --- Tracing Control ---
def enable_tracing do
init()
pid_to_trace = self()
add_traced_pid(pid_to_trace)
Process.flag(:trap_exit, true)
ref = Process.monitor(pid_to_trace)
Process.spawn(fn ->
receive do
{:DOWN, ^ref, :process, ^pid_to_trace, _reason} -> remove_traced_pid(pid_to_trace)
after
3_600_000 -> remove_traced_pid(pid_to_trace)
end
end)
:ok
end
def disable_tracing() do
# Ensure agent is available for removal
init()
remove_traced_pid(self())
end
def run(fun) when is_function(fun, 0) do
enable_tracing()
try do
fun.()
after
disable_tracing()
end
end
# --- Process Dictionary for Depth ---
defp get_depth do
Process.get(:tdd_debug_depth, 0)
end
defp increment_depth do
new_depth = get_depth() + 1
Process.put(:tdd_debug_depth, new_depth)
new_depth
end
defp decrement_depth do
new_depth = max(0, get_depth() - 1)
Process.put(:tdd_debug_depth, new_depth)
new_depth
end
# --- Core Macro Logic ---
defmacro __using__(_opts) do
quote do
# Make Tdd.Debug functions (like do_instrument, format_args_list) callable
# from the macros we are about to define locally in the user's module.
import Kernel, except: [def: 2, def: 1, defp: 2, defp: 1]
require Tdd.Debug
import Tdd.Debug
end
end
# This is an internal macro helper, not intended for direct user call
@doc false
defmacro do_instrument(type, call, clauses, env) do
# CORRECTED: Decompose the function call AST
{function_name, _meta_call, original_args_ast_nodes} = call
# Ensure it's a list for `def foo` vs `def foo()`
original_args_ast_nodes = original_args_ast_nodes || []
# CORRECTED: Generate argument variable ASTs for runtime access.
# These vars will hold the actual values of arguments after pattern matching.
arg_vars_for_runtime_access = original_args_ast_nodes
actual_code_ast =
case clauses do
[do: block_content] -> block_content
kw when is_list(kw) -> Keyword.get(kw, :do)
# `do: :atom` or `do: variable`
_ -> clauses
end
# Keep original line numbers for stacktraces from user code
traced_body =
quote location: :keep do
if Tdd.Debug.is_pid_traced?(self()) do
current_print_depth = Tdd.Debug.increment_depth()
indent = String.duplicate(" ", current_print_depth - 1)
# CORRECTED: Use the generated vars for runtime access to get actual argument values.
# We unquote_splicing them into a list literal.
runtime_arg_values = arg_vars_for_runtime_access
args_string = Tdd.Debug.format_args_list(runtime_arg_values)
# __MODULE__ here refers to the module where `use Tdd.Debug` is called.
caller_module_name = Module.split(__MODULE__) |> Enum.join(".")
IO.puts(
"#{indent}CALL: #{caller_module_name}.#{unquote(function_name)}(#{args_string})"
)
try do
# Execute original function body
result = unquote(actual_code_ast)
_ = Tdd.Debug.decrement_depth()
IO.puts(
"#{indent}RETURN from #{caller_module_name}.#{unquote(function_name)}: #{Tdd.Debug.format_arg_value(result)}"
)
result
rescue
exception_class ->
_ = Tdd.Debug.decrement_depth()
error_instance = __catch__(exception_class)
stacktrace = __STACKTRACE__
IO.puts(
"#{indent}ERROR in #{caller_module_name}.#{unquote(function_name)}: #{Tdd.Debug.format_arg_value(error_instance)}"
)
reraise error_instance, stacktrace
end
else
# Tracing not enabled, execute original code
unquote(actual_code_ast)
end
end
# Construct the final definition (def or defp) using Kernel's versions
quote do
Kernel.unquote(type)(unquote(call), do: unquote(traced_body))
end
end
# Define the overriding def/defp macros directly in the user's module.
# These will take precedence over Kernel.def/defp.
@doc false
defmacro def(call, clauses \\ nil) do
generate_traced_function(:def, call, clauses)
end
defmacro defp(call, clauses \\ nil) do
generate_traced_function(:defp, call, clauses)
end
def generate_traced_function(type, call, clauses) do
{function_name, _meta_call, original_args_ast_nodes} = call
args = original_args_ast_nodes || []
block_ast =
case clauses do
[do: block_content] -> block_content
kw when is_list(kw) -> Keyword.get(kw, :do)
_ -> clauses
end
quote location: :keep do
Kernel.unquote(type)(unquote(call)) do
if Tdd.Debug.is_pid_traced?(self()) do
current_print_depth = Tdd.Debug.increment_depth()
indent = String.duplicate(" ", current_print_depth - 1)
args_string = Tdd.Debug.format_args_list([unquote_splicing(args)])
caller_module_name = Module.split(__MODULE__) |> Enum.join(".")
IO.puts("#{indent}CALL: #{caller_module_name}.#{unquote(function_name)}(#{args_string})")
try do
result = unquote(block_ast)
_ = Tdd.Debug.decrement_depth()
IO.puts("#{indent}RETURN from #{caller_module_name}.#{unquote(function_name)}: #{Tdd.Debug.format_arg_value(result)}")
result
rescue
exception_class ->
_ = Tdd.Debug.decrement_depth()
error_instance = __catch__(exception_class)
stacktrace = __STACKTRACE__
IO.puts("#{indent}ERROR in #{caller_module_name}.#{unquote(function_name)}: #{Tdd.Debug.format_arg_value(error_instance)}")
reraise error_instance, stacktrace
end
else
unquote(block_ast)
end
end
end
end
# REMOVE the defmacro def/2 and defmacro defp/2 from here.
# They are now defined by the __using__ macro.
# --- Your TDD Graph Printing (unrelated to the tracing error, kept for completeness) ---
@doc "Prints a formatted representation of a TDD graph starting from an ID." @doc "Prints a formatted representation of a TDD graph starting from an ID."
def print(id) do def print(id) do
# Assuming Tdd.Store is defined elsewhere and aliased if needed
# Or ensure it's globally available if defined in another file
alias Tdd.Store
IO.puts("--- TDD Graph (ID: #{id}) ---") IO.puts("--- TDD Graph (ID: #{id}) ---")
do_print(id, 0, MapSet.new()) do_print(id, 0, MapSet.new())
IO.puts("------------------------") IO.puts("------------------------")
@ -17,8 +275,8 @@ defmodule Tdd.Debug do
:ok :ok
else else
new_visited = MapSet.put(visited, id) new_visited = MapSet.put(visited, id)
# Assuming Tdd.Store.get_node/1 is available
case Store.get_node(id) do case Tdd.Store.get_node(id) do
{:ok, :true_terminal} -> {:ok, :true_terminal} ->
IO.puts("#{prefix}ID #{id} -> TRUE") IO.puts("#{prefix}ID #{id} -> TRUE")
@ -163,8 +421,7 @@ defmodule Tdd.TypeSpec do
normalized = normalize(member) normalized = normalize(member)
case normalized, case normalized,
do: do: (
(
{:union, sub_members} -> sub_members {:union, sub_members} -> sub_members
_ -> [normalized] _ -> [normalized]
) )
@ -971,8 +1228,7 @@ defmodule Tdd.Consistency.Engine do
end) end)
case result, case result,
do: do: (
(
:invalid -> :error :invalid -> :error
_ -> :ok _ -> :ok
) )
@ -1219,13 +1475,18 @@ defmodule Tdd.Algo do
end end
end end
end end
@doc """ @doc """
Recursively traverses a TDD graph from `root_id`, creating a new graph Recursively traverses a TDD graph from `root_id`, creating a new graph
where all references to `from_id` are replaced with `to_id`. where all references to `from_id` are replaced with `to_id`.
This is a pure function used to "tie the knot" in recursive type compilation. This is a pure function used to "tie the knot" in recursive type compilation.
""" """
@spec substitute(root_id :: non_neg_integer(), from_id :: non_neg_integer(), to_id :: non_neg_integer()) :: @spec substitute(
root_id :: non_neg_integer(),
from_id :: non_neg_integer(),
to_id :: non_neg_integer()
) ::
non_neg_integer() non_neg_integer()
def substitute(root_id, from_id, to_id) do def substitute(root_id, from_id, to_id) do
# Handle the trivial case where the root is the node to be replaced. # Handle the trivial case where the root is the node to be replaced.
@ -1244,8 +1505,11 @@ defmodule Tdd.Algo do
result_id = result_id =
case Store.get_node(root_id) do case Store.get_node(root_id) do
# Terminal nodes are unaffected unless they are the target of substitution. # Terminal nodes are unaffected unless they are the target of substitution.
{:ok, :true_terminal} -> Store.true_node_id() {:ok, :true_terminal} ->
{:ok, :false_terminal} -> Store.false_node_id() Store.true_node_id()
{:ok, :false_terminal} ->
Store.false_node_id()
# For internal nodes, recursively substitute in all children. # For internal nodes, recursively substitute in all children.
{:ok, {var, y, n, d}} -> {:ok, {var, y, n, d}} ->
@ -1263,6 +1527,7 @@ defmodule Tdd.Algo do
result_id result_id
end end
end end
# defp do_simplify(tdd_id, assumptions) do # defp do_simplify(tdd_id, assumptions) do
# IO.inspect([tdd_id, assumptions], label: "do_simplify(tdd_id, assumptions)") # IO.inspect([tdd_id, assumptions], label: "do_simplify(tdd_id, assumptions)")
# # First, check if the current assumption set is already a contradiction. # # First, check if the current assumption set is already a contradiction.
@ -1452,11 +1717,14 @@ defmodule Tdd.TypeReconstructor do
end end
defmodule Tdd.Compiler do defmodule Tdd.Compiler do
use Tdd.Debug
@moduledoc "Compiles a `TypeSpec` into a canonical TDD ID." @moduledoc "Compiles a `TypeSpec` into a canonical TDD ID."
alias Tdd.TypeSpec alias Tdd.TypeSpec
alias Tdd.Variable alias Tdd.Variable
alias Tdd.Store alias Tdd.Store
alias Tdd.Algo alias Tdd.Algo
@doc """ @doc """
The main public entry point. Takes a spec and returns its TDD ID. The main public entry point. Takes a spec and returns its TDD ID.
This now delegates to a private function with a context for recursion. This now delegates to a private function with a context for recursion.
@ -1466,6 +1734,7 @@ defmodule Tdd.Compiler do
# Start with an empty context map. # Start with an empty context map.
spec_to_id(spec, %{}) spec_to_id(spec, %{})
end end
# This is the new core compilation function with a context map. # This is the new core compilation function with a context map.
# The context tracks `{spec => placeholder_id}` for in-progress compilations. # The context tracks `{spec => placeholder_id}` for in-progress compilations.
defp spec_to_id(spec, context) do defp spec_to_id(spec, context) do
@ -1494,6 +1763,7 @@ defp spec_to_id(spec, context) do
end end
end end
end end
defp is_recursive_spec?({:list_of, _}), do: true defp is_recursive_spec?({:list_of, _}), do: true
defp is_recursive_spec?(_), do: false defp is_recursive_spec?(_), do: false
@ -1530,21 +1800,41 @@ defp spec_to_id(spec, context) do
Store.put_op_cache({:spec_to_id, spec}, simplified_id) Store.put_op_cache({:spec_to_id, spec}, simplified_id)
simplified_id simplified_id
end end
# This helper does the raw, structural compilation. # This helper does the raw, structural compilation.
# It now takes and passes down the context. # It now takes and passes down the context.
defp do_spec_to_id(spec, context) do defp do_spec_to_id(spec, context) do
case spec do case spec do
# Pass context on all recursive calls to spec_to_id/2 # Pass context on all recursive calls to spec_to_id/2
:any -> Store.true_node_id() :any ->
:none -> Store.false_node_id() Store.true_node_id()
:atom -> create_base_type_tdd(Variable.v_is_atom())
:integer -> create_base_type_tdd(Variable.v_is_integer()) :none ->
:list -> create_base_type_tdd(Variable.v_is_list()) Store.false_node_id()
:tuple -> create_base_type_tdd(Variable.v_is_tuple())
{:literal, val} when is_atom(val) -> compile_value_equality(:atom, Variable.v_atom_eq(val), context) :atom ->
{:literal, val} when is_integer(val) -> compile_value_equality(:integer, Variable.v_int_eq(val), context) create_base_type_tdd(Variable.v_is_atom())
{:literal, []} -> compile_value_equality(:list, Variable.v_list_is_empty(), context)
{:integer_range, min, max} -> compile_integer_range(min, max, context) :integer ->
create_base_type_tdd(Variable.v_is_integer())
:list ->
create_base_type_tdd(Variable.v_is_list())
:tuple ->
create_base_type_tdd(Variable.v_is_tuple())
{:literal, val} when is_atom(val) ->
compile_value_equality(:atom, Variable.v_atom_eq(val), context)
{:literal, val} when is_integer(val) ->
compile_value_equality(:integer, Variable.v_int_eq(val), context)
{:literal, []} ->
compile_value_equality(:list, Variable.v_list_is_empty(), context)
{:integer_range, min, max} ->
compile_integer_range(min, max, context)
{:union, specs} -> {:union, specs} ->
Enum.map(specs, &spec_to_id(&1, context)) Enum.map(specs, &spec_to_id(&1, context))
@ -1586,7 +1876,14 @@ defp spec_to_id(spec, context) do
# --- Private Helpers --- # --- Private Helpers ---
defp create_base_type_tdd(var), do: Store.find_or_create_node(var, Store.true_node_id(), Store.false_node_id(), Store.false_node_id()) defp create_base_type_tdd(var),
do:
Store.find_or_create_node(
var,
Store.true_node_id(),
Store.false_node_id(),
Store.false_node_id()
)
defp compile_value_equality(base_type_spec, value_var, context) do defp compile_value_equality(base_type_spec, value_var, context) do
eq_node = create_base_type_tdd(value_var) eq_node = create_base_type_tdd(value_var)
@ -1608,15 +1905,20 @@ defp spec_to_id(spec, context) do
end end
end end
defp compile_cons_from_ids(h_id, t_id, context) do # Pass context # Pass context
defp compile_cons_from_ids(h_id, t_id, context) do
# Build `list & !is_empty` manually and safely. # Build `list & !is_empty` manually and safely.
id_list = create_base_type_tdd(Variable.v_is_list()) id_list = create_base_type_tdd(Variable.v_is_list())
id_is_empty = create_base_type_tdd(Variable.v_list_is_empty()) id_is_empty = create_base_type_tdd(Variable.v_list_is_empty())
id_not_is_empty = Algo.negate(id_is_empty) id_not_is_empty = Algo.negate(id_is_empty)
non_empty_list_id = Algo.apply(:intersect, &op_intersect_terminals/2, id_list, id_not_is_empty)
head_checker = sub_problem(&Variable.v_list_head_pred/1, h_id, context) # Pass context non_empty_list_id =
tail_checker = sub_problem(&Variable.v_list_tail_pred/1, t_id, context) # Pass context Algo.apply(:intersect, &op_intersect_terminals/2, id_list, id_not_is_empty)
# Pass context
head_checker = sub_problem(&Variable.v_list_head_pred/1, h_id, context)
# Pass context
tail_checker = sub_problem(&Variable.v_list_tail_pred/1, t_id, context)
[non_empty_list_id, head_checker, tail_checker] [non_empty_list_id, head_checker, tail_checker]
|> Enum.reduce(Store.true_node_id(), fn id, acc -> |> Enum.reduce(Store.true_node_id(), fn id, acc ->
@ -1635,7 +1937,8 @@ defp compile_tuple(elements, context) do
|> Enum.reduce(initial_id, fn {elem_spec, index}, acc_id -> |> Enum.reduce(initial_id, fn {elem_spec, index}, acc_id ->
elem_id = spec_to_id(elem_spec) elem_id = spec_to_id(elem_spec)
elem_key_constructor = &Variable.v_tuple_elem_pred(index, &1) elem_key_constructor = &Variable.v_tuple_elem_pred(index, &1)
elem_checker = sub_problem(elem_key_constructor, elem_id, context) # Pass context # Pass context
elem_checker = sub_problem(elem_key_constructor, elem_id, context)
Algo.apply(:intersect, &op_intersect_terminals/2, acc_id, elem_checker) Algo.apply(:intersect, &op_intersect_terminals/2, acc_id, elem_checker)
end) end)
end end
@ -1739,12 +2042,14 @@ defp loop_until_stable(prev_id, step_function, iteration \\ 0) do
raw_next_id = step_function.(prev_id) raw_next_id = step_function.(prev_id)
IO.inspect(raw_next_id, label: "raw_next_id (after step_function)") IO.inspect(raw_next_id, label: "raw_next_id (after step_function)")
Tdd.Debug.print(raw_next_id) # Let's see the raw graph # Let's see the raw graph
Tdd.Debug.print(raw_next_id)
# Crucially, simplify after each step for canonical comparison. # Crucially, simplify after each step for canonical comparison.
next_id = Algo.simplify(raw_next_id) next_id = Algo.simplify(raw_next_id)
IO.inspect(next_id, label: "next_id (after simplify)") IO.inspect(next_id, label: "next_id (after simplify)")
Tdd.Debug.print(next_id) # Let's see the simplified graph # Let's see the simplified graph
Tdd.Debug.print(next_id)
if next_id == prev_id do if next_id == prev_id do
IO.puts("--- Fixed-Point Reached! ---") IO.puts("--- Fixed-Point Reached! ---")
@ -1752,9 +2057,10 @@ defp loop_until_stable(prev_id, step_function, iteration \\ 0) do
else else
# Add a safety break for debugging # Add a safety break for debugging
if iteration > 2 do if iteration > 2 do
IO.inspect Process.info(self(), :current_stacktrace) IO.inspect(Process.info(self(), :current_stacktrace))
raise "Fixed-point iteration did not converge after 2 steps. Halting." raise "Fixed-point iteration did not converge after 2 steps. Halting."
end end
loop_until_stable(next_id, step_function, iteration + 1) loop_until_stable(next_id, step_function, iteration + 1)
end end
end end
@ -3052,6 +3358,9 @@ defmodule TddCompilerRecursiveTests do
end end
end end
end end
# Ensure the tracing state manager is started
Tdd.Debug.init()
Process.sleep(100) Process.sleep(100)
# To run this new test, add the following to your main test runner script: # To run this new test, add the following to your main test runner script:
# TddCompilerRecursiveTests.run() # TddCompilerRecursiveTests.run()