Visualizing Recursion Trees
Reflections on Vibe Coding
How difficult would it be to create the above?
Turns out way harder than I thought.
How to reproduce
Here’s the gist that you can Run All and play with the widgets: recursion_viz.ipynb.
There will be 2 files written in the same directory as the notebook, in case you want to clean up.
graph_data.json
visualization.html
This article goes through multiple failed attempts before getting it done, and done well, ending with lessons on the human-LLM relationship.
Hopefully, it inspires ideas on how to better work with LLMs.
Motivation
I wanted to understand how a recursive function works, but VSCode debugger is unsuitable:
- Too linear, forcing users to click the stack frames one at a time
- Too granular, I only want to see the call graph as a start
Existing options
I found nothing satisfactory in the 1st results page of google for “recursion tree visualizer”.
Requirements
- Easy to paste any custom function to visualize
- Easy to setup global variables
- Has a widget (eg. slider) that allows stepping across execution
Results
- https://visualgo.net/en/recursion | Can’t figure out how to setup global variables properly
- https://github.com/brpapa/recursion-tree-visualizer | Forces pasted functions to be named
def fn
, thus requiring function names in recursive body to be edited too. For functions requiring global variables, I get a “The recursion tree is empty” error when the same code works in Jupyter.
The plan
- Get a working visualization with free chatgpt
- See how far I can go with vibe coding
- See how long my patience lasts before I go Thanos mode (fine i’ll do it myself) and delete the crap from LLM.
Maybe changing the LLM could’ve influenced the outcomes of this experience, but I still find chatgpt the easiest UX compared to copilot editing code in places I don’t expect and requiring approvals.
The journey
- Function
- Indented
- Graphviz
- D3.js (CORS issues)
- Ipywidget (plotly)
- ax.plot (not refreshing)
- Simple example
- plt.show
- ax.plot (working)
- Decorators
- Skip to 10 for the impatient
- Skip the first part of 10 too if you understand class-based vs function-based decorators
- Step 6 and 10 contain 1 debugging exercise each for the intrepid.
The Function
def permute(nums):
def backtrack(first=0):
if first == len(nums) - 1:
result.append(nums[:])
return
for i in range(first, len(nums)):
nums[first], nums[i] = nums[i], nums[first] # Swap numbers
backtrack(first + 1)
nums[first], nums[i] = nums[i], nums[first] # Swap back
result = []
backtrack()
return result
print(permute([1, 2, 3]))
# Output: [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 2, 1], [3, 1, 2]]
This function shows a general recursive structure that does work before and after the recursive call.
The recursion gets to the goal by moving first
towards the last position.
The base case is reached when first == len(nums) — 1
(first = 2).
The original code ended at first == len(nums)
(first = 3).
Visualization helped me realize that was 1 unnecessary call, try it yourself!
Indentation to track execution
def permute(nums):
depth = 0
def backtrack(first=0):
nonlocal depth # Use nonlocal to modify the 'depth' variable from the enclosing scope
print(" " * depth + f"backtrack(first={first}, nums={nums})")
if first == len(nums) - 1:
result.append(nums[:])
return
for i in range(first, len(nums)):
print(" " * depth + f"swapping {nums[i]} and {nums[first]}")
nums[first], nums[i] = nums[i], nums[first]
depth += 1
backtrack(first + 1)
depth -= 1
print(" " * depth + f"swapping back {nums[first]} and {nums[i]}")
nums[first], nums[i] = nums[i], nums[first]
result = []
backtrack()
return result
print(permute([1, 2, 3]))
Truncated Output
backtrack(first=0, nums=[1, 2, 3])
swapping 1 and 1
backtrack(first=1, nums=[1, 2, 3])
swapping 2 and 2
backtrack(first=2, nums=[1, 2, 3])
swapping back 2 and 2
swapping 3 and 2
backtrack(first=2, nums=[1, 3, 2])
swapping back 3 and 2
swapping back 1 and 1
---------------------------TRUNCATED--------------------------------
Tracking a depth
variable to control the number of spaces printed on the left of print statements helps visualize the order of calls.
Limitations
It requires much vertical scrolling, and tedious matching of swapping
and swapping back
lines which could be far apart.
It changed the function body to include prints and nonlocal depth
.
That’s fine if it’s a one-off exercise for understanding, but inconvenient compared to solutions that don’t change the implementation, like decorators (we’ll see later).
Graphviz
from graphviz import Digraph
def permute(nums):
graph = Digraph(format="png")
node_id = 0 # Unique ID for each node
def backtrack(first=0, depth=0, parent=None):
nonlocal node_id
node_label = f"{first}: {nums}" # Label for current node
current_id = str(node_id)
graph.node(current_id, label=node_label)
node_id += 1
if parent is not None:
graph.edge(parent, current_id) # Draw edge from parent to current node
if first == len(nums) - 1:
return
for i in range(first, len(nums)):
nums[first], nums[i] = nums[i], nums[first] # Swap numbers
backtrack(first + 1, depth + 1, current_id)
nums[first], nums[i] = nums[i], nums[first] # Swap back
backtrack()
# graph.render("recursion_tree", view=True) # Save and open the tree
return graph
# Run the function
graph = permute([1, 2, 3])
print(graph)
graph
digraph {
0 [label="0: [1, 2, 3]"]
1 [label="1: [1, 2, 3]"]
0 -> 1
2 [label="2: [1, 2, 3]"]
1 -> 2
3 [label="2: [1, 3, 2]"]
1 -> 3
4 [label="1: [2, 1, 3]"]
0 -> 4
5 [label="2: [2, 1, 3]"]
4 -> 5
6 [label="2: [2, 3, 1]"]
4 -> 6
7 [label="1: [3, 2, 1]"]
0 -> 7
8 [label="2: [3, 2, 1]"]
7 -> 8
9 [label="2: [3, 1, 2]"]
7 -> 9
}
Now I can see all calls at once clearly and the hierarchy that was previously expressed with indentation, but not the order of calls.
Annotating each node with a counter helps, but I prefer having colors and interactivity.
Printing the graph object allows a more granular checking of whether the parent-child relationships across the recursive stack are connected correctly. I needed this when debugging the decorator version coming later, where the rendered tree had arrows as wild as Lu Tong from Nezha 2.
Increasing coupling of supporting functionality
Notice how in both def permute
and def backtrack
, there is extra code unrelated to the core backtracking logic, which reduces readability and maintainability.
Here’s where chatgpt hit the limit and becomes increasingly annoying.
Adding interactivity with D3
I asked for a slider, and chatgpt visualized using D3.js instead of graphviz.
I will only explain the main portions due to code length.
with open("graph_data.json", "w") as f:
json.dump(graph_data, f)
f"""
<!DOCTYPE html>
<html>
<!----------------------TRUNCATED SECTION --------------------->
</html>
"""
with open("visualization.html", "w") as f:
f.write(html_code)
This solution did not depend on graphviz at all.
Notice there is no graph.node
or graph.edge
populated into the Digraph.
It created a list of labels for nodes and edges before writing onto disk in graph_data.json
.
After that, the whole block of html_code
is written into visualization.html
which we can open in a browser. At that moment this HTML will look for graph_data.json
to draw the objects.
Getting around CORS error
Initially, you may see a blank page if you double-click visualization.html
from file explorer (windows).
F12 to open developer tools, go to Network tab, then F5 reload to see the CORS error.
My browser URL shows file://wsl.localhost/Ubuntu-20.04/home/hanqi/code/test_search/visualization.html
.
file://
is considered a different origin by the browser compared to http://
or https://
This issue can be worked around by starting a local HTTP server with python3 -m http.server 8000 — bind 0.0.0.0
and going to localhost:8000
to open the file.
Port 8000 is arbitrary, any non-clashing or ephemeral port should work.
The slider works at coloring and discoloring the nodes, but the layout is impossible to read compared to the graphviz tree.
The slider is defined at <input type=”range” min=”0" max=”{len(nodes)-1}” value=”0" id=”slider”>
, with a listener attached at document.getElementById(“slider”).addEventListener
Plotly with ipywidget
I requested to not use the networkx
library, and for the slider to use ipywidgets
and the resulting code did use that, but the graph became a plotly scatterplot.
Responsive and colors well with slider, but still not a graphviz tree.
Going in circles? Maybe, but we’ll get to the bottom.
ax.plot (not refreshing)
All the previous attempts included attaching the graphviz image, and the printed Digraph text too in an attempt to generate a graphviz tree.
After prompting to use ipywidgets and graphviz specifically, gpt got close.
import matplotlib.pyplot as plt
import ipywidgets as widgets
from graphviz import Digraph
from io import BytesIO
from IPython.display import display, clear_output
# Generate the recursion tree and track nodes
def permute(nums):
graph = Digraph(format="png")
node_id = 0 # Unique ID for each node
nodes_data = [] # Store nodes and their depth
def backtrack(first=0, depth=0, parent=None):
nonlocal node_id
node_label = f"{first}: {nums}" # Label for the current node
current_id = str(node_id)
# Add the current node to the Graphviz tree
graph.node(current_id, label=node_label)
if parent is not None:
graph.edge(parent, current_id) # Add edge from parent to current node
# Store the node and its depth for later use in the visualization
nodes_data.append((current_id, depth))
node_id += 1
if first == len(nums) - 1:
return
# Recursive backtracking to generate permutations
for i in range(first, len(nums)):
nums[first], nums[i] = nums[i], nums[first] # Swap numbers
backtrack(first + 1, depth + 1, current_id) # Recurse
nums[first], nums[i] = nums[i], nums[first] # Swap back
# Generate the recursion tree
backtrack()
return nodes_data, graph
# Draw the tree and highlight nodes iteratively using a slider
def draw_graph(nodes_data, graph, num_iterations):
fig, ax = plt.subplots(figsize=(10, 8))
# Function to render the graph to a BytesIO object (in memory)
def render_graph():
# Render graph to a byte stream in memory using pipe()
output = graph.pipe(format="png") # Render to memory (PNG format)
img_bytes = BytesIO(output)
img = plt.imread(img_bytes) # Read the PNG from the byte stream
return img
# Display the initial graph
img = render_graph()
ax.axis("off")
plt.imshow(img)
# Create the slider widget to control iteration progress
slider = widgets.IntSlider(value=0, min=0, max=num_iterations - 1, step=1, description="Iteration:")
display(slider)
# Function to update the graph and highlight nodes based on the slider value
def update_graph(change):
# Get the nodes to highlight based on the slider value
clear_output(wait=True)
display(slider)
iteration = slider.value
highlighted_nodes = nodes_data[: iteration + 1] # All nodes up to this iteration
# Reset node colors before modifying
for node_id, _ in nodes_data:
graph.node(node_id, color="black", style="filled", fillcolor="white")
for i, (node_id, _) in enumerate(highlighted_nodes):
graph.node(node_id, style="filled", fillcolor="lightblue")
# Render the updated graph to the byte stream and get the image
img = render_graph()
ax.clear()
ax.axis("off")
ax.imshow(img)
fig.canvas.draw()
# Connect the slider to the update function
slider.observe(update_graph, names="value")
# Run the program
nums = [1, 2, 3]
nodes_data, graph = permute(nums) # Generate the recursion tree and the nodes data
num_iterations = len(nodes_data) # Number of iterations equals the number of nodes
draw_graph(nodes_data, graph, num_iterations) # Draw the graph with interactive slider
It does have a graphviz tree and a ipywidgets slider exactly like the gif at the beginning.
The bug
The slider began at 0 and a visible graph.
I’m baffled why interacting with the slider causes the graph to disappear, even when the slider is reset back to 0.
There must be some bugs in either:
slider.observe(update_graph , names=”value”)
def update_graph
Try to debug this (one-line change) before spoilers in the next section!
A simpler example
I asked chatgpt for a simpler example (see gist), and verified that the slider interactions do update graphviz node labels properly (Start 0 -> Start 1).
plt.show
Upon a closer look, I noticed the simple example using plt.show()
.
So I changed the previous code from
img = render_graph()
ax.clear()
ax.axis("off")
ax.imshow(img)
fig.canvas.draw()
to
img = render_graph()
ax.axis("off")
plt.imshow(img)
and the graph stopped disappearing after slider interactions. That made no sense because ax and plt have similar functionalities.
I also noticed the size of the graph going back to a smaller default size after slider interactions, ignoring the original figsize
defined in fig, ax = plt.subplots(figsize=(10, 8))
.
This means a new plot is created for each slider action, so while plt.imshow
fixes re-rendering bugs, it does not respect the predefined fig, ax
properties.
ax.plot (working)
Eventually, I realized that fig.canvas.draw()
only re-renders the figure from matplotlib’s point of view, but this doesn’t mean a refresh from jupyter’s point of view.
img = render_graph()
ax.clear()
ax.axis("off")
ax.imshow(img)
# fig.canvas.draw()
display(fig)
display(fig)
was the final piece of the puzzle. It both re-renders the figure and shows it on Jupyter.
Comparing ax.imshow vs plt.imshow
ax.imshow()
allows reusing the same ax created initially, avoiding problems of losing figure properties when redefining a new plotplt.imshow()
can automatically detect the active fig and ax, making it less restrictive in terms of refactoring.ax.imshow()
requires theax
variable to be in scope. It’s not necessarily a bad thing if the reduced refactoring freedom ofax
leads to cleaner code.
Pushing for Improvements
ax.imshow
with display(fig)
works, but we can do better.
Remember previous comments on recursion logic coupling with graphviz logic, and polluting the function parameters? Now’s the time to fix it, and it’s not trivial.
Chatgpt failed spectacularly here. No matter what I say, it couldn’t clean up the function signature and separate the graphviz logic.
That’s when I have to step in to handle business.
Decorators
Class-based vs Function-based
Class-based decorators are suited to this problem because it’s more flexible at tracking state (eg. self.parent
), and allows subclassing if needed.
Class-based decorators more easily create configurable decorators.
Function-based
Configuration of retries, delay
require an additional layer of wrapping with a decorator factory retry
(a function that produces configured decorators). This happens because the decorator
expects only a single input, that is the func
it is decorating.
def retry(retries=3, delay=1):
# This is the first layer of nesting: the function that takes the decorator arguments
def decorator(func):
# This is the second layer of nesting: the actual decorator function
def wrapper(*args, **kwargs):
##### TRUNCATED #####
return wrapper
return decorator
# Usage
@retry(retries=5, delay=2)
def unreliable_function():
##### TRUNCATED #####
Class-based
There is no need for nesting here, because the configuration parameters are received through a different path (__init__
) from the decorated func
(__call__
).
class RetryDecorator:
def __init__(self, retries=3, delay=1):
self.retries = retries
self.delay = delay
def __call__(self, func):
def wrapper(*args, **kwargs):
##### TRUNCATED #####
return wrapper
# Usage
@RetryDecorator(retries=5, delay=2)
def unreliable_function():
##### TRUNCATED #####
Side tour of Function-based decorators
Function based decorators are the more common variant in most tutorials.
If using Function based decorators, state could be handled in a few less obvious ways:
- mutables like list/dict
- attached to function attributes
- closures with
nonlocal count
- global variables
For your experiment
You can ask gpt to tweak the class-based decorator example below to each of the 4 methods above and observe whether output is 1,2,1,2 (first 3 options) or 1,2,3,4 (4th option) to familiarize with stateful function-based decorators. It counts how many times a decorated function is called.
class CountCalls:
def __init__(self, func):
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
print(f"# of calls: {self.count}")
return self.func(*args, **kwargs)
@CountCalls
def foo(x):
return x + 2
@CountCalls
def foo2(x):
return x + 2
foo(2)
foo(2)
foo2(2)
foo2(2)
Back to Class-based decorator
class Visualizer:
def __init__(self, func):
self.func = func
self.graph = Digraph(format="png")
self.node_id = 0
self.parent = None
self.depth = -1
self.nodes_data = []
def __call__(self, *args, **kwargs):
node_label = f"{args=}"
current_id = str(self.node_id)
self.node_id += 1
self.graph.node(current_id, label=node_label)
if self.parent is not None:
self.graph.edge(self.parent, current_id)
self.nodes_data.append((current_id, self.depth + 1))
previous_parent = self.parent
self.parent = current_id
self.depth += 1
self.func(*args, **kwargs)
self.parent = previous_parent
self.depth -= 1
return self.nodes_data, self.graph
The most difficult part of this exercise is handling the parent-child links correctly.
Previously, it was handled by backtrack(first + 1, depth + 1, current_id)
.
current_id
argument (sent by function caller)- passed to the
parent
parameter (expected by the function definition) graph.edge(parent, current_id)
creates the link, usingparent
from caller andcurrent_id
from an incrementingself.node_id
Because we want to avoid adding parameters to the recursive function to avoid polluting its logic, parent-child must be handled and backtracked more tediously.
Parent-Child handling
Notice how this is also backtracking!
previous_parent = self.parent
self.parent = current_id
self.func(*args, **kwargs)
self.parent = previous_parent
While the decorated function is backtracking by swapping values back, the decorator also has to backtrack by reinstating the temporarily saved parent.
previous_parent = self.parent
is needed because deeper calls overwrite self.parent
.
self.depth
is not strictly necessary, but is nice metadata to have for extending visualization capabilities. I left it there to show how to backtrack an integer.
Such pointer tracking is why it’s still valuable to know how to reverse a linked list even though it’s cited as an impractical exercise for a job by DS&A haters.
General Applicability
You can prove the decorator works on any function by uncommenting the below, or write your own fibonacci and slap a @Visualizer
on top.
# @Visualizer
# def f(n):
# if n == 1:
# return 1
# return f(n - 1) * n
Debugging Side Quest
For the hardcore, you can introduce a bug and see the graph go wrong.
I did this while merging my own ideas with chatgpt.
self.parent = current_id # add bug
self.nodes_data.append((current_id, self.depth + 1))
previous_parent = self.parent
self.parent = current_id
Add self.parent = current_id
so it runs twice.
digraph {
0 [label="args=([1, 2, 3],)"]
1 [label="args=([1, 2, 3], 1)"]
0 -> 1
2 [label="args=([1, 2, 3], 2)"]
1 -> 2
3 [label="args=([1, 3, 2], 2)"]
2 -> 3
4 [label="args=([2, 1, 3], 1)"]
1 -> 4
5 [label="args=([2, 1, 3], 2)"]
4 -> 5
6 [label="args=([2, 3, 1], 2)"]
5 -> 6
7 [label="args=([3, 2, 1], 1)"]
4 -> 7
8 [label="args=([3, 2, 1], 2)"]
7 -> 8
9 [label="args=([3, 1, 2], 2)"]
8 -> 9
}
Debugging Caveats
This allows practice for debugging recursive functions. If using VSCode debugger, you can click through the call stack to see how instance variables change, keeping in mind these instance variables (self.parent
) are shared across recursive calls, so deeper calls can overwrite values from shallower calls.
You can handle that with a self.call_stack = []
to store the full history and avoid overwriting.
A skill in debugging is to estimate how you are wrong. Are numbers/containers over-accumulated or under-accumulated?
Is this over/under consistently over/under?
Going over could indicate recursions are going 1 step too far, or initializations started 1 value too big or 1 element too much in containers.
It could also indicate wrong order of variable updating code, even if the necessary code is all there. The printed digraph helped me think along these lines.
You may have experienced this code ordering problem in Two Sum or Reverse Linked List .
- In Two Sum O(n) time complexity implementation, adding values to the hashmap before checking for constraint satisfaction (pair of values sum to target) allows the pair to come from the same index, not good if requirements disallow that.
- In reversing a linked list, doing assignments in the wrong order would cause pointers to be lost. (https://stackoverflow.com/a/71161751/8621823).
Don’t blindly memorize any order, because whether something is a feature or bug depends on requirements.
Possible Extensions
- Add more annotations to show what happens after a recursive call returns. Currently, it only prints input arguments
node_label = f"{args=}"
- Add memoization and show which nodes could have been skipped
- Other interactive elements besides slider (eg. checkboxes)
- More complex structures like mutual recursion
Other tools
You may be thinking why DIY?
Indeed, there are tools more suitable for understanding huge codebases, like pycallgraph2 (see Reference section).
pycallgraph2 operates as a dynamic code analysis tool, as opposed to static code analysis (eg. pyan).
Pycallgraph2 is great for:
- Planning refactoring by seeing hotpaths and the number of times a looped function is called
- Verifying refactoring by comparing before-after callgraphs between 2 commits
- Debugging by helping you plan where to put breakpoints
However, it is not interactive.
It’s more relevant for complex data pipelines involving numerous calls of different functions imported from different packages and modules.
Don’t need a sledgehammer to hit a fly.
Finally, we laid down the hammer on this chatgpt madness.
It’s time to look back on the journey and extract some lessons.
Lessons
Unexpected discoveries
Going off the perfect path showed me some interesting ideas.
graph.pipe
Initially, the code wrote png files for each step through the recursive execution. This would have created a lot of files in the local folder that could be difficult to clean up. Gpt showed how to do it in memory.
file.seek(0)
I did not show seek
in the notebook, but it was a red herring like fig.canvas.draw()
from gpt in def render_graph
. I am reminded that the problem may not just lie in display but also in data reading. Getting familiar with BytesIO (see Reference section) would help high performance computing.
UX
I used chatgpt in a temporary chat to get around a grayed out send button (maybe rate limited). After closing the browser I lost the whole conversation and opportunities to reflect on prompting efficiency. For difficult tasks I would be careful to use non-temporary chats in future.
Prompt efficiency
My actual experience was far more winding than described in this article.
A qualitative measure of the relationship between a developer and gpt is how many turns it takes to reach a goal.
This depends on:
- ability of the LLM
- quality of the prompt
- difficulty of the task
- conversation history which biases the LLM towards existing tools and arguments
After 4 turns I requested “Forget all previous code” (maybe I should have just opened a new chat window instead).
After 7 turns, I mentioned “do not use nx”
After 10 turns, I requested ipywidgets and matplotlib animation.
Turns increased because I had to investigate bugs along the way:
- simplifying by removing
graph.pipe
dependency by saving frames to disk - testing whether
file.seek
is the problem - testing on simpler code to prove that the image can even refresh successfully on slider interactions
Ideally, it’s all done in 1 turn. Tracking this is a way to measure my prompting skills.
Balance
Abstraction level
Similar to the Exploration–exploitation dilemma, the developer must prompt at the right level.
- too little constraints could lead to undesirable solutions, but also uncover blind spots
- too much constraints could trap the developer in old patterns as the world evolves, but make steady progress
The key is balancing your established methods with an openness to new ideas.
One approach is to start at a high level exploring existing solutions. Once the concept is developed, you can tighten the structure as the requirements are refined.
Ultimately, humans are essential to understanding why certain requirements are in place, making intelligent trade-offs, and recognizing that the issue may not be purely technical — factors like PESTEL (Political, Economic, Social, Technological, Environmental, and Legal) play a significant role too.
One-factor-at-a-time
There is also a choice of how many factors to change at once.
The more factors you adjust simultaneously, the more unpredictable the results might be, but it could also take fewer iterations to the goal.
We also see examples of a hybrid, taking large steps towards the goal before making smaller steps as we get close.
It’s similar to the difference between linear search and binary search, or a linked list versus a skip list , or gradually decreasing step sizes in gradient descent — each approach has its trade-offs in terms of efficiency and complexity.
Conclusion
An LLM’s effectiveness is largely dependent on the developer using it.
For a developer who doesn’t prioritize correct, performant, or secure code, simply copy-pasting LLM outputs may be sufficient for running code, especially in prototypes designed to showcase an idea.
However, a more skilled developer can craft better prompts and refine the LLM’s output to meet specific constraints, such as ensuring consistency in coding style or avoiding minefields in outdated libraries.
The developer still needs critical thinking and debugging skills to set a direction when challenges arise. For example, without asking for a simpler example, I wouldn’t have discovered issues like ax.imshow
failing, while plt.imshow
worked, or the difference between fig.canvas.draw()
and display(fig)
. These fundamentals can be developed through seeing how others do it and active practice of incorporating new thinking patterns.
When the constraints become too complex (controlling function signatures or managing variable scoping in decorators), the developer must step in, break the problem down, provide a skeleton, context, or even a full implementation to resolve the issue.
References
- Debug It! Book that developed my critical thinking: https://www.amazon.sg/Debug-Find-Repair-Prevent-Bugs/dp/193435628X
- Shows debugging thinking being universal across software and hardware: https://www.autodidacts.io/troubleshooting/
- Viewing stdout and stderr from ipywidgets with Output widget: https://www.stevejpurves.com/blog/debugging-ipywidgets-in-a-jupyter-notebook
- Practical applications of DS&A: https://code.visualstudio.com/blogs/2021/09/29/bracket-pair-colorization
- Working with BytesIO: https://pythonspeed.com/articles/bytesio-reduce-memory-usage/
- Pycallgraph2 is newer: https://pycallgraph.readthedocs.io/en/master/
- Survey on Recursion: https://files.eric.ed.gov/fulltext/EJ1064322.pdf