9. Tutorial: Branch Decomposition of a Reeb Graph

A branch decomposition breaks a Reeb graph into a collection of non-overlapping upward paths called branches. Each branch travels strictly upward in function value, starting at either a local minimum or a saddle point and ending at either a local maximum or another saddle point.

Each branch stores:

  • The function values at its two endpoints (f_low, f_high)

  • Attachment information: which earlier branch each endpoint belongs to (pointing to itself if it is a local min/max)

This tutorial follows the branch decomposition definition from Samira Jabry’s thesis, Unsmoothing of Reeb Graphs, Saint Louis University, 2025. Further developments will be presented in forthcoming work.

For the API and class reference, see the branch decomposition documentation page.

[1]:
import matplotlib.pyplot as plt

from cereeberus import ReebGraph
import cereeberus.data.ex_reebgraphs as ex_rg
from cereeberus.reeb.branchdecomp import BranchDecomp

9.1. A simple example: the dancing man

We’ll start with the dancing_man example graph, which is a connected Reeb graph with several saddle points.

[2]:
R = ex_rg.dancing_man()

fig, axes = plt.subplots(1, 2, figsize=(14, 6))
R.draw(ax=axes[0])
axes[0].set_title('Reeb Graph', fontsize=14, fontweight='bold')

bd = R.branch_decomp()
bd.draw(ax=axes[1])

plt.tight_layout()
plt.show()
../_images/notebooks_branch_decomp_basics_4_0.png

The branch decomposition diagram shows each branch as a vertical line positioned at its function values. Labels next to each endpoint indicate which branch that endpoint is attached to:

  • Purple (lower endpoint): the branch ID whose path contains this vertex

  • Green (upper endpoint): the branch ID whose path contains this vertex

When a label matches the branch’s own ID, that endpoint is a local min or max (no other branch claims it).

9.2. Inspecting the branches

The branches are stored as an \(n \times 4\) numpy array. Each row is one branch:

Column

Meaning

0

f_low — function value of the lower endpoint

1

f_high — function value of the upper endpoint

2

low_attach — branch ID owning the lower endpoint

3

high_attach — branch ID owning the upper endpoint

[3]:
print("Branches (f_low, f_high, low_attach, high_attach):")
print(bd.branches)
Branches (f_low, f_high, low_attach, high_attach):
[[1. 7. 0. 0.]
 [4. 5. 1. 0.]
 [4. 6. 0. 0.]
 [5. 6. 2. 3.]]
[4]:
# You can also inspect individual branches and their vertex paths
for i in range(len(bd.branches)):
    print(f"Branch {i}: f=[{bd.branches[i,0]}, {bd.branches[i,1]}]  "
          f"attaches=({int(bd.branches[i,2])}, {int(bd.branches[i,3])})  "
          f"path={bd.get_branch_path(i)}")
Branch 0: f=[1.0, 7.0]  attaches=(0, 0)  path=[7, 6, 2, 1, 0]
Branch 1: f=[4.0, 5.0]  attaches=(1, 0)  path=[5, 2]
Branch 2: f=[4.0, 6.0]  attaches=(0, 0)  path=[6, 3, 1]
Branch 3: f=[5.0, 6.0]  attaches=(2, 3)  path=[3, 4]

9.3. Reconstructing the Reeb graph

Once you have a branch decomposition, you can reconstruct the original Reeb graph exactly from it using reconstruct().

[5]:
R2 = bd.reconstruct()

fig, axes = plt.subplots(1, 2, figsize=(12, 5))
R.draw(ax=axes[0])
axes[0].set_title('Original', fontsize=13, fontweight='bold')
R2.draw(ax=axes[1])
axes[1].set_title('Reconstructed', fontsize=13, fontweight='bold')
plt.tight_layout()
plt.show()

print(f"Original:      {R}")
print(f"Reconstructed: {R2}")
../_images/notebooks_branch_decomp_basics_10_0.png
Original:      ReebGraph with 8 nodes and 8 edges.
Reconstructed: ReebGraph with 8 nodes and 8 edges.

9.4. Handling isolated vertices: the juggling man

The juggling_man graph has isolated vertices (vertices with no edges), representing disconnected components. These are handled as degenerate branches where f_low == f_high and both attachment IDs point to the branch itself.

[6]:
J = ex_rg.juggling_man()

fig, axes = plt.subplots(1, 2, figsize=(16, 6))
J.draw(ax=axes[0])
axes[0].set_title('Juggling Man Reeb Graph', fontsize=14, fontweight='bold')

bd_j = J.branch_decomp()
bd_j.draw(ax=axes[1])

plt.tight_layout()
plt.show()
../_images/notebooks_branch_decomp_basics_12_0.png
[7]:
# Identify the degenerate branches (isolated vertices)
print("Degenerate branches (isolated vertices):")
for i, (f_low, f_high, low_a, high_a) in enumerate(bd_j.branches):
    if f_low == f_high:
        print(f"  Branch {i}: vertex={bd_j.get_branch_path(i)}, f={f_low}, "
              f"attaches=({int(low_a)}, {int(high_a)})")
Degenerate branches (isolated vertices):
  Branch 4: vertex=[8], f=6.5, attaches=(4, 4)
  Branch 5: vertex=[9], f=6.5, attaches=(5, 5)
  Branch 6: vertex=[10], f=5.5, attaches=(6, 6)

9.5. Summary

The BranchDecomp class provides:

  • decompose(R) — compute the decomposition from a ReebGraph

  • branches — the \(n \times 4\) array of branch data

  • get_branch(i) — return row \(i\) of the branches array

  • get_branch_path(i) — return the list of vertices in branch \(i\)

  • reconstruct() — rebuild the original ReebGraph from the stored paths

  • draw() — visualise the decomposition

You can also call R.branch_decomp() directly on any ReebGraph or MapperGraph as a shortcut.