diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index de9463d..cfadab3 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -21,11 +21,17 @@ jobs:
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
+
+ - name: Install LaTeX
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y texlive-full
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e .[dev]
+ pip install git+https://github.com/gambitproject/gambit.git # TODO: add pygambit to dev dependencies after 16.5 release.
- name: Test with pytest
run: |
diff --git a/.gitignore b/.gitignore
index e695836..f68fa29 100644
--- a/.gitignore
+++ b/.gitignore
@@ -155,4 +155,5 @@ temp*
.temp*
# .ef files generated from .efg files (the test suite)
-games/efg/*.ef
\ No newline at end of file
+games/efg/*.ef
+tutorial/*.ef
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index ca3c578..4008bf4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -29,7 +29,7 @@ keywords = ["game theory", "tikz", "visualization", "trees", "economics"]
dependencies = ["jupyter-tikz", "ipykernel"]
[project.optional-dependencies]
-dev = ["pytest>=7.0.0", "pytest-cov"]
+dev = ["pytest>=7.0.0", "pytest-cov", "nbformat", "nbclient", "ipykernel"]
[project.scripts]
draw_tree = "draw_tree.cli:main"
diff --git a/src/draw_tree/__init__.py b/src/draw_tree/__init__.py
index eef1210..58bd975 100644
--- a/src/draw_tree/__init__.py
+++ b/src/draw_tree/__init__.py
@@ -15,9 +15,11 @@
generate_png,
ef_to_tex,
latex_wrapper,
- efg_to_ef
+ efg_dl_ef
)
+from .gambit_layout import gambit_layout_to_ef
+
__all__ = [
"draw_tree",
"generate_tikz",
@@ -26,5 +28,6 @@
"generate_png",
"ef_to_tex",
"latex_wrapper",
- "efg_to_ef"
+ "efg_dl_ef",
+ "gambit_layout_to_ef"
]
\ No newline at end of file
diff --git a/src/draw_tree/core.py b/src/draw_tree/core.py
index 4ad4a84..8e9842d 100644
--- a/src/draw_tree/core.py
+++ b/src/draw_tree/core.py
@@ -17,6 +17,8 @@
from typing import List, Optional
from IPython.core.getipython import get_ipython
+from draw_tree.layout import DefaultLayout
+
# Constants
DEFAULTFILE: str = "example.ef"
scale: float = 1
@@ -1163,7 +1165,7 @@ def commandline(argv: List[str]) -> tuple[str, bool, bool, bool, Optional[str],
return (output_mode, pdf_requested, png_requested, tex_requested, output_file, dpi)
-def ef_to_tex(ef_file: str, scale_factor: float = 1.0, show_grid: bool = False) -> str:
+def ef_to_tex(ef_file: str, scale_factor: float = 0.8, show_grid: bool = False) -> str:
"""
Convert an extensive form (.ef) file to TikZ code.
@@ -1251,27 +1253,50 @@ def ef_to_tex(ef_file: str, scale_factor: float = 1.0, show_grid: bool = False)
scale = original_scale
grid = original_grid
-def generate_tikz(ef_file: str, scale_factor: float = 1.0, show_grid: bool = False) -> str:
+def generate_tikz(
+ game,
+ save_to: Optional[str] = None,
+ scale_factor: float = 0.8,
+ level_spacing: int = 6,
+ sublevel_spacing: int = 2,
+ width_spacing: int = 2,
+ show_grid: bool = False
+ ) -> str:
"""
Generate complete TikZ code from an extensive form (.ef) file.
-
+
Args:
- ef_file: Path to the .ef file to process.
- scale_factor: Scale factor for the diagram (default: 1.0).
- show_grid: Whether to show grid lines (default: False).
-
+ game: Path to the .ef or .efg file to process, or a pygambit.gambit.Game object.
+ save_to: Optional path to save intermediate .ef file when generating from a pygambit.gambit.Game object.
+ scale_factor: Scale factor for the diagram.
+ level_spacing: Level spacing multiplier used when generating from a pygambit.gambit.Game object.
+ sublevel_spacing: Sublevel spacing multiplier used when generating from a pygambit.gambit.Game object.
+ width_spacing: Width spacing multiplier used when generating from a pygambit.gambit.Game object.
+ show_grid: Whether to show grid lines.
+
Returns:
Complete TikZ code ready for use in Jupyter notebooks or LaTeX documents.
"""
# If user supplied an EFG file, convert it to .ef first so the existing
- # ef-based pipeline can be reused. efg_to_ef returns a path string when
+ # ef-based pipeline can be reused. efg_dl_ef returns a path string when
# it successfully writes the .ef file.
- if isinstance(ef_file, str) and ef_file.lower().endswith('.efg'):
- try:
- ef_file = efg_to_ef(ef_file)
- except Exception:
- # fall through and let ef_to_tex raise a clearer error later
- pass
+ ef_file = game
+ if isinstance(game, str):
+ if game.lower().endswith('.efg'):
+ try:
+ ef_file = efg_dl_ef(game)
+ except Exception:
+ # fall through and let ef_to_tex raise a clearer error later
+ pass
+ else:
+ from .gambit_layout import gambit_layout_to_ef
+ ef_file = gambit_layout_to_ef(
+ game,
+ save_to=save_to,
+ level_multiplier=level_spacing,
+ sublevel_multiplier=sublevel_spacing,
+ xshift_multiplier=width_spacing
+ )
# Step 1: Generate the tikzpicture content using ef_to_tex logic
tikz_picture_content = ef_to_tex(ef_file, scale_factor, show_grid)
@@ -1324,15 +1349,27 @@ def generate_tikz(ef_file: str, scale_factor: float = 1.0, show_grid: bool = Fal
return tikz_code
-def draw_tree(ef_file: str, scale_factor: float = 1.0, show_grid: bool = False) -> Optional[str]:
+def draw_tree(
+ game,
+ save_to: Optional[str] = None,
+ scale_factor: float = 0.8,
+ level_spacing: int = 6,
+ sublevel_spacing: int = 2,
+ width_spacing: int = 2,
+ show_grid: bool = False,
+) -> Optional[str]:
"""
Generate TikZ code and display in Jupyter notebooks.
-
+
Args:
- ef_file: Path to the .ef file to process.
- scale_factor: Scale factor for the diagram (default: 1.0).
- show_grid: Whether to show grid lines (default: False).
-
+ game: Path to the .ef or .efg file to process, or a pygambit.gambit.Game object.
+ save_to: Optional path to save intermediate .ef file when generating from a pygambit.gambit.Game object.
+ scale_factor: Scale factor for the diagram.
+ level_spacing: Level spacing multiplier used when generating from a pygambit.gambit.Game object.
+ sublevel_spacing: Sublevel spacing multiplier used when generating from a pygambit.gambit.Game object.
+ width_spacing: Width spacing multiplier used when generating from a pygambit.gambit.Game object.
+ show_grid: Whether to show grid lines.
+
Returns:
The result of the Jupyter cell magic execution, or the TikZ code string
if cell magic fails.
@@ -1351,7 +1388,15 @@ def draw_tree(ef_file: str, scale_factor: float = 1.0, show_grid: bool = False)
ip.run_line_magic("load_ext", "jupyter_tikz")
# Generate TikZ code and execute cell magic
- tikz_code = generate_tikz(ef_file, scale_factor, show_grid)
+ tikz_code = generate_tikz(
+ game,
+ save_to=save_to,
+ scale_factor=scale_factor,
+ level_spacing=level_spacing,
+ sublevel_spacing=sublevel_spacing,
+ width_spacing=width_spacing,
+ show_grid=show_grid
+ )
return ip.run_cell_magic("tikz", "", tikz_code)
else:
raise EnvironmentError("draw_tree function requires a Jupyter notebook environment.")
@@ -1394,7 +1439,7 @@ def latex_wrapper(tikz_code: str) -> str:
return latex_document
-def generate_tex(ef_file: str, output_tex: Optional[str] = None, scale_factor: float = 1.0, show_grid: bool = False) -> str:
+def generate_tex(ef_file: str, output_tex: Optional[str] = None, scale_factor: float = 0.8, show_grid: bool = False) -> str:
"""
Generate a complete LaTeX document file directly from an extensive form (.ef) file.
@@ -1421,12 +1466,12 @@ def generate_tex(ef_file: str, output_tex: Optional[str] = None, scale_factor: f
# If input is an EFG file, convert it first
if isinstance(ef_file, str) and ef_file.lower().endswith('.efg'):
try:
- ef_file = efg_to_ef(ef_file)
+ ef_file = efg_dl_ef(ef_file)
except Exception:
pass
# Generate TikZ content using generate_tikz
- tikz_content = generate_tikz(ef_file, scale_factor, show_grid)
+ tikz_content = generate_tikz(ef_file, scale_factor=scale_factor, show_grid=show_grid)
# Wrap in complete LaTeX document
latex_document = latex_wrapper(tikz_content)
@@ -1438,7 +1483,7 @@ def generate_tex(ef_file: str, output_tex: Optional[str] = None, scale_factor: f
return str(Path(output_tex).absolute())
-def generate_pdf(ef_file: str, output_pdf: Optional[str] = None, scale_factor: float = 1.0, show_grid: bool = False, cleanup: bool = True) -> str:
+def generate_pdf(ef_file: str, output_pdf: Optional[str] = None, scale_factor: float = 0.8, show_grid: bool = False, cleanup: bool = True) -> str:
"""
Generate a PDF directly from an extensive form (.ef) file.
@@ -1465,7 +1510,7 @@ def generate_pdf(ef_file: str, output_pdf: Optional[str] = None, scale_factor: f
output_pdf = ef_path.with_suffix('.pdf').name
# Generate TikZ content using generate_tikz
- tikz_content = generate_tikz(ef_file, scale_factor, show_grid)
+ tikz_content = generate_tikz(ef_file, scale_factor=scale_factor, show_grid=show_grid)
# Create LaTeX wrapper document
latex_document = latex_wrapper(tikz_content)
@@ -1509,7 +1554,7 @@ def generate_pdf(ef_file: str, output_pdf: Optional[str] = None, scale_factor: f
raise RuntimeError("pdflatex not found. Please install a LaTeX distribution (e.g., TeX Live, MiKTeX).")
-def generate_png(ef_file: str, output_png: Optional[str] = None, scale_factor: float = 1.0,
+def generate_png(ef_file: str, output_png: Optional[str] = None, scale_factor: float = 0.8,
show_grid: bool = False, dpi: int = 300, cleanup: bool = True) -> str:
"""
Generate a PNG image directly from an extensive form (.ef) file.
@@ -1638,641 +1683,14 @@ def generate_png(ef_file: str, output_png: Optional[str] = None, scale_factor: f
raise RuntimeError(f"PNG generation failed: {e}")
-class DefaultLayout:
- """Encapsulate layout heuristics and emission for .ef generation.
-
- Accepts a list of descriptor dicts (in preorder) and optional
- player names, and produces the list of `.ef` lines via `to_lines()`.
- """
-
- class Node:
- def __init__(self, desc=None, move_name=None, prob=None):
- self.desc = desc
- self.move = move_name
- self.prob = prob
- self.children: List['DefaultLayout.Node'] = []
- self.parent: Optional['DefaultLayout.Node'] = None
- self.x = 0.0
- self.level = 0
-
- def __init__(self, descriptors: List[dict], player_names: List[str]):
- self.descriptors = descriptors
- self.player_names = player_names
- self.root: Optional[DefaultLayout.Node] = None
- self.leaves: List[DefaultLayout.Node] = []
- self.node_ids = {}
- self.iset_groups = {}
- self.counters_by_level = {}
-
- def build_tree(self):
- def build_node(i):
- if i >= len(self.descriptors):
- return None, i
- d = self.descriptors[i]
- node = DefaultLayout.Node(desc=d)
- i += 1
- if d['kind'] in ('c', 'p'):
- for m_i, mv in enumerate(d['moves']):
- prob = None
- if m_i < len(d['probs']):
- prob = d['probs'][m_i]
- child, i = build_node(i)
- if child is None:
- child = DefaultLayout.Node(desc={'kind': 't', 'payoffs': []})
- child.move = mv
- child.prob = prob
- child.parent = node
- node.children.append(child)
- return node, i
-
- self.root, _ = build_node(0)
-
- def collect_leaves(self):
- self.leaves = []
-
- def collect(n):
- if not n.children:
- self.leaves.append(n)
- else:
- for c in n.children:
- collect(c)
-
- if self.root:
- collect(self.root)
-
- def assign_x(self):
- BASE_LEAF_UNIT = 3.58
- if len(self.leaves) > 1:
- total = (len(self.leaves) - 1) * BASE_LEAF_UNIT
- for i, leaf in enumerate(self.leaves):
- leaf.x = -total / 2 + i * BASE_LEAF_UNIT
- elif self.leaves:
- self.leaves[0].x = 0.0
-
- def set_internal_x(self, n: 'DefaultLayout.Node'):
- if n.children:
- for c in n.children:
- self.set_internal_x(c)
- n.x = sum(c.x for c in n.children) / len(n.children)
-
- def assign_levels(self):
- if not self.root:
- return
- self.root.level = 0
-
- def assign(n):
- for c in n.children:
- if n.level == 0:
- step = 2
- else:
- step = 4 if c.children else 2
- c.level = n.level + step
- assign(c)
-
- assign(self.root)
-
- def compute_scale_and_mult(self):
- BASE_LEAF_UNIT = 3.58
- emit_scale = 1.0
- try:
- if self.root and self.root.children:
- max_offset = max(abs(c.x - self.root.x) for c in self.root.children)
- if max_offset > 1e-9:
- emit_scale = BASE_LEAF_UNIT / max_offset
- except Exception:
- emit_scale = 1.0
- num_leaves = len(self.leaves)
- try:
- adaptive_mult = max(0.5, min(1.167, 6.0 / float(num_leaves)))
- except Exception:
- adaptive_mult = 1.0
- # compute root-child imbalance ratio for selective top-level widening
- ratio = 1.0
- try:
- root_desc = getattr(self.root, 'desc', None)
- if root_desc is not None and root_desc.get('kind') == 'c' and self.root and self.root.children:
- def count_leaves(n: 'DefaultLayout.Node') -> int:
- if not n.children:
- return 1
- s = 0
- for ch in n.children:
- s += count_leaves(ch)
- return s
- counts = [count_leaves(ch) for ch in self.root.children]
- if counts and min(counts) > 0:
- ratio = max(counts) / float(min(counts))
- else:
- ratio = 1.0
- except Exception:
- ratio = 1.0
- # store ratio for emit_node to use
- self._root_child_ratio = ratio
- return emit_scale, adaptive_mult
-
- def _separate_iset_levels(self):
- """Relocate colliding information-set groups to distinct integer levels.
-
- For each info-set group that shares an integer level with other groups,
- deterministically move the later groups to the nearest available
- integer level that is strictly greater than all their parents' levels
- and strictly less than all their children's levels. Update
- self.node_ids, node.level and entries in self.iset_groups.
- """
- if not self.iset_groups:
- return
-
- # Build quick lookup from (int_level, local_id) -> node_obj
- lookup = {}
- for node_obj, (lvl, lid) in list(self.node_ids.items()):
- try:
- il = int(round(lvl))
- except Exception:
- il = int(lvl)
- lookup[(il, lid)] = node_obj
-
- # Treat levels that contain terminal nodes as unavailable for iset placement.
- # Find levels of terminal nodes and mark them occupied so we never
- # relocate an info-set into a level that already holds terminals.
- terminal_levels = set()
- for nobj, (lv, lid) in list(self.node_ids.items()):
- desc = getattr(nobj, 'desc', None)
- if desc and desc.get('kind') == 't':
- terminal_levels.add(int(round(lv)))
-
- # Only consider info-set groups that actually have multiple members.
- # Singleton iset entries should not be treated as colliding groups or
- # as occupied levels — they are emitted as normal nodes.
- filtered_iset_groups = {k: v for k, v in self.iset_groups.items() if len(v) >= 2}
-
- # iset levels collected only from filtered groups
- iset_levels = set()
- for lst in filtered_iset_groups.values():
- for lv, _ in lst:
- iset_levels.add(int(round(lv)))
-
- # Occupied levels are terminal levels plus existing multi-member iset levels.
- occupied = set()
- occupied.update(terminal_levels)
- occupied.update(iset_levels)
-
- # Map integer level -> groups present there (only multi-member groups)
- level_groups = {}
- for group_key, lst in filtered_iset_groups.items():
- for lv, nid in lst:
- il = int(round(lv))
- level_groups.setdefault(il, set()).add(group_key)
-
- # Process levels in increasing order deterministically
- for il in sorted(level_groups.keys()):
- groups = sorted(level_groups[il], key=lambda k: (k[0], k[1]))
- if len(groups) <= 1:
- continue
- # keep the first group, move others
- for group_key in groups[1:]:
- # find nodes of this group at this integer level
- entries = [ (lv, nid) for (lv, nid) in list(self.iset_groups.get(group_key, [])) if int(round(lv)) == il ]
- node_objs = []
- for lv, nid in entries:
- n = lookup.get((il, nid))
- if n is not None:
- node_objs.append((n, nid))
- if not node_objs:
- continue
-
- # Also consider all nodes that belong to this iset group (not just those at il).
- full_group_nodes = []
- for glv, gid in list(self.iset_groups.get(group_key, [])):
- gnode = lookup.get((int(round(glv)), gid))
- if gnode is not None:
- full_group_nodes.append((gnode, gid))
-
- # compute bounds: must be > all parents' levels and < all childrens' levels
- # Use full_group_nodes for bounds so we don't miss children/parents
- parents = []
- children_mins = []
- source_nodes = full_group_nodes if full_group_nodes else node_objs
- for (nnode, _) in source_nodes:
- if nnode.parent is not None:
- parents.append(int(round(nnode.parent.level)))
- if nnode.children:
- children_mins.append(min(int(round(ch.level)) for ch in nnode.children))
- parent_max = max(parents) if parents else -100000
- child_min = min(children_mins) if children_mins else 100000
- min_allowed = parent_max + 1
- max_allowed = child_min - 1
-
- # search nearest free integer level within [min_allowed, max_allowed]
- candidate = None
- if min_allowed <= il <= max_allowed and il not in occupied:
- candidate = il
- else:
- # try offsets 1, -1, 2, -2 ... within allowed window
- for offset in range(1, 201):
- # prefer shifting outward (il+offset) then inward (il-offset)
- for cand in (il + offset, il - offset):
- if cand < min_allowed or cand > max_allowed:
- continue
- if cand not in occupied:
- candidate = cand
- break
- if candidate is not None:
- break
-
- # if still not found, try any free slot from min_allowed upward
- if candidate is None:
- for cand in range(min_allowed, max_allowed + 1):
- if cand not in occupied:
- candidate = cand
- break
-
- if candidate is None:
- # try to find next free integer >= min_allowed (may exceed max_allowed)
- cand = max(min_allowed, il + 1)
- while cand in occupied:
- cand += 1
- desired = cand
- # If desired would be below children (i.e., > max_allowed),
- # shift the subtrees of these nodes' children upward so we can
- # insert the info-set level without placing it under terminals.
- if max_allowed is not None and desired > max_allowed:
- shift_needed = desired - max_allowed
-
- # collect descendants (exclude the group nodes themselves)
- def collect_subtree(n: 'DefaultLayout.Node', acc: set):
- if n in acc:
- return
- acc.add(n)
- for ch in n.children:
- collect_subtree(ch, acc)
-
- descendant_nodes = set()
- for n_obj, _ in full_group_nodes:
- for ch in n_obj.children:
- collect_subtree(ch, descendant_nodes)
-
- # shift levels for descendant nodes (lift children/terminals upward)
- for nshift in descendant_nodes:
- old_level = int(round(nshift.level))
- nshift.level = int(round(nshift.level)) + shift_needed
- if nshift in self.node_ids:
- _, lid = self.node_ids[nshift]
- self.node_ids[nshift] = (nshift.level, lid)
- # update any iset_groups entries that reference this node
- for gkey, glst in self.iset_groups.items():
- for j, (olv, oid) in enumerate(list(glst)):
- if int(round(olv)) == old_level and oid == self.node_ids.get(nshift, (nshift.level, None))[1]:
- glst[j] = (nshift.level, oid)
-
- # update occupied set to include new levels
- occupied.update(int(round(n.level)) for n in descendant_nodes)
- # also ensure we don't select terminal levels later
- occupied.update(terminal_levels)
- candidate = desired
- else:
- candidate = desired
-
- # apply candidate to all members of the full info-set group
- for node_obj, nid in full_group_nodes:
- node_obj.level = int(candidate)
- self.node_ids[node_obj] = (int(candidate), nid)
- # update lookup
- lookup[(int(candidate), nid)] = node_obj
- occupied.add(int(candidate))
- # update iset_groups stored levels for this group to the candidate
- lst = self.iset_groups.get(group_key, [])
- for i, (oldlv, idn) in enumerate(list(lst)):
- lst[i] = (int(candidate), idn)
-
- # Phase 2 unification was removed to preserve canonical example layouts
-
- def to_lines(self) -> List[str]:
- # Build tree and layout
- self.build_tree()
- if self.root is None:
- return []
- self.collect_leaves()
- self.assign_x()
- self.set_internal_x(self.root)
- self.assign_levels()
- # Post-process: ensure every connected parent->child pair has at least
- # two integer-levels of separation. This enforces the invariant
- # child.level >= parent.level + 2 for every edge, repeating until
- # stable so transitive adjustments propagate deterministically.
- def enforce_spacing():
- changed = True
- while changed:
- changed = False
- def walk(n):
- nonlocal changed
- for c in n.children:
- try:
- plevel = int(round(n.level))
- clevel = int(round(c.level))
- except Exception:
- plevel = int(n.level)
- clevel = int(c.level)
- if clevel < plevel + 2:
- c.level = plevel + 2
- changed = True
- # always continue walking to enforce transitive constraints
- if c.children:
- walk(c)
- if self.root:
- walk(self.root)
-
- enforce_spacing()
- emit_scale, adaptive_mult = self.compute_scale_and_mult()
-
- LEVEL_XSHIFT = {
- 2: 3.58,
- 6: 1.9,
- 8: 0.90,
- 9: 0.90,
- 10: 0.90,
- 11: 0.90,
- 12: 0.45,
- 14: 2.205,
- 18: 1.095,
- 20: 0.73,
- }
-
- out_lines: List[str] = []
- for i, name in enumerate(self.player_names, start=1):
- pname = name.replace(' ', '~')
- out_lines.append(f"player {i} name {pname}")
-
- # First pass to allocate ids deterministically
- self.node_ids = {}
- self.iset_groups = {}
- self.counters_by_level = {}
-
- def alloc_local_id(level: float) -> int:
- self.counters_by_level.setdefault(level, 0)
- self.counters_by_level[level] += 1
- return self.counters_by_level[level]
-
- def alloc_ids(n: 'DefaultLayout.Node'):
- if n not in self.node_ids:
- lid = alloc_local_id(n.level)
- self.node_ids[n] = (n.level, lid)
- if n.desc and n.desc.get('iset_id') is not None and n.desc.get('player') is not None:
- key = (n.desc['player'], n.desc['iset_id'])
- self.iset_groups.setdefault(key, []).append((n.level, lid))
- for c in n.children:
- if c not in self.node_ids:
- clid = alloc_local_id(c.level)
- self.node_ids[c] = (c.level, clid)
- if c.desc and c.desc.get('iset_id') is not None and c.desc.get('player') is not None:
- key = (c.desc['player'], c.desc['iset_id'])
- self.iset_groups.setdefault(key, []).append((c.level, clid))
- for c in reversed(n.children):
- alloc_ids(c)
-
- alloc_ids(self.root)
-
- # After ids are allocated, ensure info-set groups do not collide
- # on the same integer level by relocating groups if necessary.
- try:
- self._separate_iset_levels()
- except Exception:
- pass
-
- # Final spacing enforcement: _separate_iset_levels may have moved
- # nodes around; ensure now that every connected parent->child pair
- # has at least two integer levels separation. Update self.node_ids
- # entries to match any changed node.level and rebuild iset_groups so
- # subsequent emission uses consistent integer levels.
- def enforce_spacing_after_separation():
- changed = True
- # Repeat until stable because raising one child can require
- # raising its children as well.
- while changed:
- changed = False
- # iterate over node objects deterministically
- for node_obj in list(self.node_ids.keys()):
- if node_obj.parent is None:
- continue
- try:
- plevel = int(round(node_obj.parent.level))
- clevel = int(round(node_obj.level))
- except Exception:
- plevel = int(node_obj.parent.level)
- clevel = int(node_obj.level)
- if clevel < plevel + 2:
- node_obj.level = plevel + 2
- # update node_ids to the new integer level, keep lid
- lid = self.node_ids[node_obj][1]
- self.node_ids[node_obj] = (int(node_obj.level), lid)
- changed = True
-
- # rebuild iset_groups deterministically from node_ids and descriptors
- new_iset = {}
- for nobj, (lv, lid) in list(self.node_ids.items()):
- if nobj.desc and nobj.desc.get('iset_id') is not None and nobj.desc.get('player') is not None:
- key = (nobj.desc['player'], nobj.desc['iset_id'])
- new_iset.setdefault(key, []).append((int(round(nobj.level)), lid))
- # sort entries for determinism
- for k in new_iset:
- new_iset[k] = sorted(new_iset[k], key=lambda t: (int(t[0]), int(t[1])))
- self.iset_groups = new_iset
-
- try:
- enforce_spacing_after_separation()
- except Exception:
- pass
-
- # Unify terminal levels by tree depth: ensure all leaves at the same
- # tree depth share the same integer level. If any leaf at a given
- # depth is higher (larger integer level) than its peers, raise the
- # others to match that level and update node_ids/isets.
- try:
- # compute depth (distance from root) for every node
- node_depth = {}
- def compute_depth(n, d=0):
- node_depth[n] = d
- for ch in n.children:
- compute_depth(ch, d+1)
- if self.root:
- compute_depth(self.root, 0)
-
- # group leaves by depth
- depth_groups = {}
- for leaf in self.leaves:
- d = node_depth.get(leaf, 0)
- depth_groups.setdefault(d, []).append(leaf)
-
- changed = False
- for d, leaves in depth_groups.items():
- # find maximum integer level among these leaves
- maxlvl = max(int(round(leaf.level)) for leaf in leaves)
- for leaf in leaves:
- if int(round(leaf.level)) < maxlvl:
- leaf.level = int(maxlvl)
- # update node_ids if present
- if leaf in self.node_ids:
- lid = self.node_ids[leaf][1]
- self.node_ids[leaf] = (int(maxlvl), lid)
- changed = True
-
- if changed:
- # rebuild iset_groups deterministically
- new_iset = {}
- for nobj, (lv, lid) in list(self.node_ids.items()):
- if nobj.desc and nobj.desc.get('iset_id') is not None and nobj.desc.get('player') is not None:
- key = (nobj.desc['player'], nobj.desc['iset_id'])
- new_iset.setdefault(key, []).append((int(round(nobj.level)), lid))
- for k in new_iset:
- new_iset[k] = sorted(new_iset[k], key=lambda t: (int(t[0]), int(t[1])))
- self.iset_groups = new_iset
- except Exception:
- pass
-
- nodes_in_isets = set()
- for nodes_list in self.iset_groups.values():
- if len(nodes_list) >= 2:
- for lv, nid in nodes_list:
- nodes_in_isets.add((lv, nid))
-
- def emit_node(n: 'DefaultLayout.Node'):
- lvl, lid = self.node_ids[n]
- if n.parent is None:
- if n.desc and n.desc.get('kind') == 'c':
- out_lines.append(f"level {lvl} node {lid} player 0 ")
- elif n.desc and n.desc.get('kind') == 'p':
- pl = n.desc.get('player') if n.desc.get('player') is not None else 1
- out_lines.append(f"level {lvl} node {lid} player {pl}")
-
- for c in n.children:
- if c not in self.node_ids:
- clid = alloc_local_id(c.level)
- self.node_ids[c] = (c.level, clid)
- # guard descriptor access - some nodes may have None desc
- if c.desc and c.desc.get('iset_id') is not None and c.desc.get('player') is not None:
- key = (c.desc['player'], c.desc['iset_id'])
- self.iset_groups.setdefault(key, []).append((c.level, clid))
- nodes_in_isets.add((c.level, clid))
- clvl, clid = self.node_ids[c]
- base = (c.x - n.x) * emit_scale
- if n.level == 0:
- mult = 1.0
- else:
- mult = adaptive_mult if c.children else 1.0
- fallback = base * mult
- chosen_candidate = False
- if clvl in LEVEL_XSHIFT:
- xmag = LEVEL_XSHIFT[clvl]
- root_desc = getattr(self.root, 'desc', None)
- # Apply a controlled widening for top-level branches when
- # root is a chance node and the child-subtrees are imbalanced.
- # Use the precomputed self._root_child_ratio capped at 2.0 and
- # only apply when ratio indicates meaningful imbalance.
- if n.parent is None and root_desc is not None and root_desc.get('kind') == 'c':
- try:
- ratio = float(getattr(self, '_root_child_ratio', 1.0))
- except Exception:
- ratio = 1.0
- if ratio >= 1.5:
- factor = min(2.0, max(1.0, ratio))
- xmag *= factor
- if clvl == 6 and ((root_desc is not None and root_desc.get('kind') == 'c') or len(self.leaves) <= 4):
- xmag = 4.18
- candidate = xmag if base > 0 else -xmag
- tol_candidate = 0.25 * abs(candidate) + 0.05
- if (
- abs(fallback) < 1.0
- or abs(candidate - fallback) <= tol_candidate
- or (abs(fallback) > 1e-9 and abs(candidate) > 1.5 * abs(fallback))
- or (abs(fallback) > 3.0 * abs(candidate))
- ):
- xshift = candidate
- chosen_candidate = True
- else:
- xshift = fallback
- chosen_candidate = False
- else:
- xshift = fallback
- chosen_candidate = False
-
- # formatting
- if chosen_candidate:
- if abs(xshift) < 1.0:
- xs = f"{xshift:.2f}"
- else:
- s = f"{xshift:.3f}"
- if '.' in s:
- s = s.rstrip('0').rstrip('.')
- xs = s
- else:
- if abs(xshift) < 1.0:
- xs = f"{xshift:.2f}"
- else:
- s = f"{xshift:.2f}"
- if '.' in s:
- s = s.rstrip('0').rstrip('.')
- xs = s
-
- # prepare move label and attach chance probability if parent is a chance node
- mv = c.move if c.move else ''
- if c.prob and n.desc and n.desc.get('kind') == 'c':
- if '/' in c.prob:
- num, den = c.prob.split('/')
- mv = f"{mv}~(\\frac{{{num}}}{{{den}}})"
- else:
- mv = f"{mv}~({c.prob})"
-
- if c.desc and (c.desc.get('kind') == 'p' or c.desc.get('kind') == 'c'):
- # For chance nodes emit player 0; for player nodes emit the
- # declared player number (default 1). This fixes cases like
- # `cent2` where internal chance nodes must be printed as player 0.
- if c.desc.get('kind') == 'c':
- pl = 0
- else:
- pl = c.desc.get('player') if c.desc.get('player') is not None else 1
- if clvl == 2:
- emit_player_field = True
- else:
- emit_player_field = (c.desc.get('player') is not None)
- if c.desc and c.desc.get('iset_id') is not None and c.desc.get('player') is not None:
- key = (c.desc['player'], c.desc['iset_id'])
- if len(self.iset_groups.get(key, [])) >= 2:
- emit_player_field = False
- if emit_player_field:
- out_lines.append(f"level {clvl} node {clid} player {pl} xshift {xs} from {lvl},{lid} move {mv}")
- else:
- out_lines.append(f"level {clvl} node {clid} xshift {xs} from {lvl},{lid} move {mv}")
- else:
- pay = ''
- if c.desc and c.desc.get('payoffs'):
- pay = ' '.join(str(x) for x in c.desc['payoffs'])
- # use the prepared move label (which may include probability)
- mvname = mv
- if mvname:
- out_lines.append(f"level {clvl} node {clid} xshift {xs} from {lvl},{lid} move {mvname} payoffs {pay}")
- else:
- out_lines.append(f"level {clvl} node {clid} xshift {xs} from {lvl},{lid} move payoffs {pay}")
-
- for c in reversed(n.children):
- emit_node(c)
-
- emit_node(self.root)
-
- # emit isets
- for (player, iset_id), nodes_list in self.iset_groups.items():
- if len(nodes_list) >= 2:
- nodes_sorted = sorted(nodes_list, key=lambda t: -t[1])
- parts = ' '.join(f"{lv},{nid}" for lv, nid in nodes_sorted)
- out_lines.append(f"iset {parts} player {player}")
-
- return out_lines
-
-
-def efg_to_ef(efg_file: str) -> str:
+def efg_dl_ef(efg_file: str) -> str:
"""Convert a Gambit .efg file to the `.ef` format used by generate_tikz.
The function implements a focused parser and deterministic layout
- heuristics for producing `.ef` directives from a conservative subset of
- EFG records (chance nodes `c`, player nodes `p`, and terminals `t`). It
- emits node level/position lines and information-set (`iset`) groupings.
+ heuristics via the DefaultLayout class for producing `.ef` directives
+ from a conservative subset of EFG records (chance nodes `c`, player nodes
+ `p`, and terminals `t`). It emits node level/position lines and
+ information-set (`iset`) groupings.
Args:
efg_file: Path to the input .efg file.
diff --git a/src/draw_tree/gambit_layout.py b/src/draw_tree/gambit_layout.py
new file mode 100644
index 0000000..421c5a0
--- /dev/null
+++ b/src/draw_tree/gambit_layout.py
@@ -0,0 +1,173 @@
+import pygambit
+from typing import Optional
+
+
+def determine_node_level(
+ gbt_level: int,
+ gbt_sublevel: int,
+ level_multiplier: int = 6,
+ sublevel_multiplier: int = 2,
+) -> int:
+ """Determine the node level in the .ef format based on Gambit layout levels."""
+ # If node is in an infoset
+ if gbt_level > 1 and gbt_sublevel != 0:
+ return (gbt_level * level_multiplier) + ((gbt_sublevel - 1) * sublevel_multiplier) - (level_multiplier / 2)
+ return gbt_level * level_multiplier
+
+
+def gambit_layout_to_ef(
+ game: pygambit.gambit.Game,
+ save_to: Optional[str] = None,
+ level_multiplier: int = 6,
+ sublevel_multiplier: int = 2,
+ xshift_multiplier: int = 2
+) -> str:
+ """Convert an extensive form Gambit game to the `.ef` format
+ using the layout tree defined by pygambit.layout_tree(game.)
+
+ Args:
+ game: A pygambit.gambit.Game object representing the game.
+ save_to: Optional path to save the generated `.ef` file.
+
+ Returns:
+ The filename of the generated `.ef` file.
+ """
+
+ # Get the layout from pygambit
+ layout = pygambit.layout_tree(game)
+
+ # Start building the .ef string
+ ef = ""
+
+ # Add the player lines to the .ef string
+ player_ids = {}
+ p = 1
+ for player in game.players:
+ player_name = player.label.replace(" ", "~")
+ ef += f"player {p} name {player_name}\n"
+ player_ids[player] = p
+ p += 1
+
+ # Group nodes by their infosets
+ # Also collect parent node levels for level determination
+ infoset_groups = {}
+ gbt_parent_levels = {}
+ for node, node_coords in layout.items():
+ if node.infoset:
+ if node.infoset not in infoset_groups:
+ infoset_groups[node.infoset] = []
+ infoset_groups[node.infoset].append(node)
+ # Get the level of a parent node, if applicable
+ if not node == game.root:
+ parent_coords = layout[node.parent]
+ gbt_parent_levels[node] = (parent_coords.level, parent_coords.sublevel)
+
+ # For each node, determine its level and node count within that level
+ # Also collect offsets for normalisation
+ levels_nodecount = {}
+ node_levels = {}
+ offsets = []
+ for node, node_coords in layout.items():
+
+ # Calculate the node level, using gambit level and sublevel
+ # Ignore sublevel for nodes that don't share an infoset
+ gbt_sublevel = node_coords.sublevel
+ if node.infoset in infoset_groups:
+ if len(infoset_groups[node.infoset]) == 1:
+ gbt_sublevel = 0
+ level = determine_node_level(node_coords.level, gbt_sublevel, level_multiplier, sublevel_multiplier)
+
+ # Ensure child nodes have levels greater than their parents
+ if not node == game.root:
+ gbt_parent_level, gbt_parent_sublevel = gbt_parent_levels[node]
+ parent_level = determine_node_level(gbt_parent_level, gbt_parent_sublevel, level_multiplier, sublevel_multiplier)
+ while level <= parent_level:
+ level += level_multiplier
+
+ # Track node counts per level
+ if level not in levels_nodecount:
+ levels_nodecount[level] = 1
+ else:
+ levels_nodecount[level] += 1
+ node_levels[node] = (level, levels_nodecount[level])
+
+ # Collect offsets for normalisation
+ offsets.append(node_coords.offset)
+
+ # Calculate midpoint for offset normalisation
+ midpoint = (min(offsets) + max(offsets)) / 2
+
+ # Normalise offsets based on the midpoint
+ nodes_with_normalised_offsets = {}
+ for node, node_coords in layout.items():
+ nodes_with_normalised_offsets[node] = -(node_coords.offset - midpoint) * xshift_multiplier
+
+ # Now, build the node lines in the .ef string
+ for node, node_coords in layout.items():
+
+ # Determine the player for the node
+ player = None
+ if node.player:
+ if node.player.is_chance:
+ player = "0"
+ else:
+ player = player_ids[node.player]
+
+ # Add the level and node count
+ # This is effectively the node ID in .ef format
+ level, nodecount = node_levels[node]
+ ef += f"level {level} node {nodecount} "
+
+ # Add player if applicable to this node
+ # Do not add player if in infoset with multiple nodes (will be defined by `iset` later)
+ if player and len(infoset_groups[node.infoset]) == 1:
+ ef += f"player {player} "
+
+ # Calculate xshift and add to .ef string not root node
+ if level > 0:
+ xshift = nodes_with_normalised_offsets[node] - (
+ nodes_with_normalised_offsets[node.parent] if node.parent else 0
+ )
+ ef += f"xshift {xshift} "
+
+ # Determine where the node comes from (its parent and prior action)
+ if node.parent:
+ parent_level, parent_nodecount = node_levels[node.parent]
+ ef += f"from {parent_level},{parent_nodecount} "
+ prior_action_label = node.prior_action.label.replace(" ", "~")
+ ef += f"move {prior_action_label}"
+
+ # Add probability if the parent is a chance player
+ if node.parent.player.is_chance:
+ prob = str(node.prior_action.prob).split("/")
+ ef += f"~(\\frac{{{prob[0]}}}{{{prob[1]}}})"
+ ef += " "
+
+ # Add payoffs to terminal nodes, if applicable
+ if node.is_terminal:
+ ef += "payoffs "
+ if node.outcome:
+ for player in game.players:
+ ef += f"{node.outcome.__getitem__(player)} "
+ ef += "\n"
+
+ # Build the infoset lines in the .ef string with `iset`
+ for _, nodes in infoset_groups.items():
+ if len(nodes) > 1:
+ ef += "iset "
+ for node in nodes:
+ level, nodecount = node_levels[node]
+ ef += f"{level},{nodecount} "
+ ef += f"player {player_ids[node.player]} "
+ ef += "\n"
+
+ # Save the constructed .ef string to file based on the game's name
+ if save_to:
+ ef_file = save_to
+ if ".ef" not in save_to:
+ ef_file = save_to + ".ef"
+ else:
+ ef_file = game.title + ".ef"
+ with open(ef_file, "w", encoding="utf-8") as f:
+ f.write(ef)
+ return ef_file
diff --git a/src/draw_tree/layout.py b/src/draw_tree/layout.py
new file mode 100644
index 0000000..a42c5ce
--- /dev/null
+++ b/src/draw_tree/layout.py
@@ -0,0 +1,705 @@
+from typing import List, Optional
+
+
+class DefaultLayout:
+ """Encapsulate layout heuristics and emission for .ef generation.
+
+ Accepts a list of descriptor dicts (in preorder) and optional
+ player names, and produces the list of `.ef` lines via `to_lines()`.
+ """
+
+ class Node:
+ def __init__(self, desc=None, move_name=None, prob=None):
+ self.desc = desc
+ self.move = move_name
+ self.prob = prob
+ self.children: List["DefaultLayout.Node"] = []
+ self.parent: Optional["DefaultLayout.Node"] = None
+ self.x = 0.0
+ self.level = 0
+
+ def __init__(self, descriptors: List[dict], player_names: List[str]):
+ self.descriptors = descriptors
+ self.player_names = player_names
+ self.root: Optional[DefaultLayout.Node] = None
+ self.leaves: List[DefaultLayout.Node] = []
+ self.node_ids = {}
+ self.iset_groups = {}
+ self.counters_by_level = {}
+
+ def build_tree(self):
+ def build_node(i):
+ if i >= len(self.descriptors):
+ return None, i
+ d = self.descriptors[i]
+ node = DefaultLayout.Node(desc=d)
+ i += 1
+ if d["kind"] in ("c", "p"):
+ for m_i, mv in enumerate(d["moves"]):
+ prob = None
+ if m_i < len(d["probs"]):
+ prob = d["probs"][m_i]
+ child, i = build_node(i)
+ if child is None:
+ child = DefaultLayout.Node(desc={"kind": "t", "payoffs": []})
+ child.move = mv
+ child.prob = prob
+ child.parent = node
+ node.children.append(child)
+ return node, i
+
+ self.root, _ = build_node(0)
+
+ def collect_leaves(self):
+ self.leaves = []
+
+ def collect(n):
+ if not n.children:
+ self.leaves.append(n)
+ else:
+ for c in n.children:
+ collect(c)
+
+ if self.root:
+ collect(self.root)
+
+ def assign_x(self):
+ BASE_LEAF_UNIT = 3.58
+ if len(self.leaves) > 1:
+ total = (len(self.leaves) - 1) * BASE_LEAF_UNIT
+ for i, leaf in enumerate(self.leaves):
+ leaf.x = -total / 2 + i * BASE_LEAF_UNIT
+ elif self.leaves:
+ self.leaves[0].x = 0.0
+
+ def set_internal_x(self, n: "DefaultLayout.Node"):
+ if n.children:
+ for c in n.children:
+ self.set_internal_x(c)
+ n.x = sum(c.x for c in n.children) / len(n.children)
+
+ def assign_levels(self):
+ if not self.root:
+ return
+ self.root.level = 0
+
+ def assign(n):
+ for c in n.children:
+ if n.level == 0:
+ step = 2
+ else:
+ step = 4 if c.children else 2
+ c.level = n.level + step
+ assign(c)
+
+ assign(self.root)
+
+ def compute_scale_and_mult(self):
+ BASE_LEAF_UNIT = 3.58
+ emit_scale = 1.0
+ try:
+ if self.root and self.root.children:
+ max_offset = max(abs(c.x - self.root.x) for c in self.root.children)
+ if max_offset > 1e-9:
+ emit_scale = BASE_LEAF_UNIT / max_offset
+ except Exception:
+ emit_scale = 1.0
+ num_leaves = len(self.leaves)
+ try:
+ adaptive_mult = max(0.5, min(1.167, 6.0 / float(num_leaves)))
+ except Exception:
+ adaptive_mult = 1.0
+ # compute root-child imbalance ratio for selective top-level widening
+ ratio = 1.0
+ try:
+ root_desc = getattr(self.root, "desc", None)
+ if (
+ root_desc is not None
+ and root_desc.get("kind") == "c"
+ and self.root
+ and self.root.children
+ ):
+
+ def count_leaves(n: "DefaultLayout.Node") -> int:
+ if not n.children:
+ return 1
+ s = 0
+ for ch in n.children:
+ s += count_leaves(ch)
+ return s
+
+ counts = [count_leaves(ch) for ch in self.root.children]
+ if counts and min(counts) > 0:
+ ratio = max(counts) / float(min(counts))
+ else:
+ ratio = 1.0
+ except Exception:
+ ratio = 1.0
+ # store ratio for emit_node to use
+ self._root_child_ratio = ratio
+ return emit_scale, adaptive_mult
+
+ def _separate_iset_levels(self):
+ """Relocate colliding information-set groups to distinct integer levels.
+
+ For each info-set group that shares an integer level with other groups,
+ deterministically move the later groups to the nearest available
+ integer level that is strictly greater than all their parents' levels
+ and strictly less than all their children's levels. Update
+ self.node_ids, node.level and entries in self.iset_groups.
+ """
+ if not self.iset_groups:
+ return
+
+ # Build quick lookup from (int_level, local_id) -> node_obj
+ lookup = {}
+ for node_obj, (lvl, lid) in list(self.node_ids.items()):
+ try:
+ il = int(round(lvl))
+ except Exception:
+ il = int(lvl)
+ lookup[(il, lid)] = node_obj
+
+ # Treat levels that contain terminal nodes as unavailable for iset placement.
+ # Find levels of terminal nodes and mark them occupied so we never
+ # relocate an info-set into a level that already holds terminals.
+ terminal_levels = set()
+ for nobj, (lv, lid) in list(self.node_ids.items()):
+ desc = getattr(nobj, "desc", None)
+ if desc and desc.get("kind") == "t":
+ terminal_levels.add(int(round(lv)))
+
+ # Only consider info-set groups that actually have multiple members.
+ # Singleton iset entries should not be treated as colliding groups or
+ # as occupied levels — they are emitted as normal nodes.
+ filtered_iset_groups = {
+ k: v for k, v in self.iset_groups.items() if len(v) >= 2
+ }
+
+ # iset levels collected only from filtered groups
+ iset_levels = set()
+ for lst in filtered_iset_groups.values():
+ for lv, _ in lst:
+ iset_levels.add(int(round(lv)))
+
+ # Occupied levels are terminal levels plus existing multi-member iset levels.
+ occupied = set()
+ occupied.update(terminal_levels)
+ occupied.update(iset_levels)
+
+ # Map integer level -> groups present there (only multi-member groups)
+ level_groups = {}
+ for group_key, lst in filtered_iset_groups.items():
+ for lv, nid in lst:
+ il = int(round(lv))
+ level_groups.setdefault(il, set()).add(group_key)
+
+ # Process levels in increasing order deterministically
+ for il in sorted(level_groups.keys()):
+ groups = sorted(level_groups[il], key=lambda k: (k[0], k[1]))
+ if len(groups) <= 1:
+ continue
+ # keep the first group, move others
+ for group_key in groups[1:]:
+ # find nodes of this group at this integer level
+ entries = [
+ (lv, nid)
+ for (lv, nid) in list(self.iset_groups.get(group_key, []))
+ if int(round(lv)) == il
+ ]
+ node_objs = []
+ for lv, nid in entries:
+ n = lookup.get((il, nid))
+ if n is not None:
+ node_objs.append((n, nid))
+ if not node_objs:
+ continue
+
+ # Also consider all nodes that belong to this iset group (not just those at il).
+ full_group_nodes = []
+ for glv, gid in list(self.iset_groups.get(group_key, [])):
+ gnode = lookup.get((int(round(glv)), gid))
+ if gnode is not None:
+ full_group_nodes.append((gnode, gid))
+
+ # compute bounds: must be > all parents' levels and < all childrens' levels
+ # Use full_group_nodes for bounds so we don't miss children/parents
+ parents = []
+ children_mins = []
+ source_nodes = full_group_nodes if full_group_nodes else node_objs
+ for nnode, _ in source_nodes:
+ if nnode.parent is not None:
+ parents.append(int(round(nnode.parent.level)))
+ if nnode.children:
+ children_mins.append(
+ min(int(round(ch.level)) for ch in nnode.children)
+ )
+ parent_max = max(parents) if parents else -100000
+ child_min = min(children_mins) if children_mins else 100000
+ min_allowed = parent_max + 1
+ max_allowed = child_min - 1
+
+ # search nearest free integer level within [min_allowed, max_allowed]
+ candidate = None
+ if min_allowed <= il <= max_allowed and il not in occupied:
+ candidate = il
+ else:
+ # try offsets 1, -1, 2, -2 ... within allowed window
+ for offset in range(1, 201):
+ # prefer shifting outward (il+offset) then inward (il-offset)
+ for cand in (il + offset, il - offset):
+ if cand < min_allowed or cand > max_allowed:
+ continue
+ if cand not in occupied:
+ candidate = cand
+ break
+ if candidate is not None:
+ break
+
+ # if still not found, try any free slot from min_allowed upward
+ if candidate is None:
+ for cand in range(min_allowed, max_allowed + 1):
+ if cand not in occupied:
+ candidate = cand
+ break
+
+ if candidate is None:
+ # try to find next free integer >= min_allowed (may exceed max_allowed)
+ cand = max(min_allowed, il + 1)
+ while cand in occupied:
+ cand += 1
+ desired = cand
+ # If desired would be below children (i.e., > max_allowed),
+ # shift the subtrees of these nodes' children upward so we can
+ # insert the info-set level without placing it under terminals.
+ if max_allowed is not None and desired > max_allowed:
+ shift_needed = desired - max_allowed
+
+ # collect descendants (exclude the group nodes themselves)
+ def collect_subtree(n: "DefaultLayout.Node", acc: set):
+ if n in acc:
+ return
+ acc.add(n)
+ for ch in n.children:
+ collect_subtree(ch, acc)
+
+ descendant_nodes = set()
+ for n_obj, _ in full_group_nodes:
+ for ch in n_obj.children:
+ collect_subtree(ch, descendant_nodes)
+
+ # shift levels for descendant nodes (lift children/terminals upward)
+ for nshift in descendant_nodes:
+ old_level = int(round(nshift.level))
+ nshift.level = int(round(nshift.level)) + shift_needed
+ if nshift in self.node_ids:
+ _, lid = self.node_ids[nshift]
+ self.node_ids[nshift] = (nshift.level, lid)
+ # update any iset_groups entries that reference this node
+ for gkey, glst in self.iset_groups.items():
+ for j, (olv, oid) in enumerate(list(glst)):
+ if (
+ int(round(olv)) == old_level
+ and oid
+ == self.node_ids.get(
+ nshift, (nshift.level, None)
+ )[1]
+ ):
+ glst[j] = (nshift.level, oid)
+
+ # update occupied set to include new levels
+ occupied.update(int(round(n.level)) for n in descendant_nodes)
+ # also ensure we don't select terminal levels later
+ occupied.update(terminal_levels)
+ candidate = desired
+ else:
+ candidate = desired
+
+ # apply candidate to all members of the full info-set group
+ for node_obj, nid in full_group_nodes:
+ node_obj.level = int(candidate)
+ self.node_ids[node_obj] = (int(candidate), nid)
+ # update lookup
+ lookup[(int(candidate), nid)] = node_obj
+ occupied.add(int(candidate))
+ # update iset_groups stored levels for this group to the candidate
+ lst = self.iset_groups.get(group_key, [])
+ for i, (oldlv, idn) in enumerate(list(lst)):
+ lst[i] = (int(candidate), idn)
+
+ # Phase 2 unification was removed to preserve canonical example layouts
+
+ def to_lines(self) -> List[str]:
+ # Build tree and layout
+ self.build_tree()
+ if self.root is None:
+ return []
+ self.collect_leaves()
+ self.assign_x()
+ self.set_internal_x(self.root)
+ self.assign_levels()
+
+ # Post-process: ensure every connected parent->child pair has at least
+ # two integer-levels of separation. This enforces the invariant
+ # child.level >= parent.level + 2 for every edge, repeating until
+ # stable so transitive adjustments propagate deterministically.
+ def enforce_spacing():
+ changed = True
+ while changed:
+ changed = False
+
+ def walk(n):
+ nonlocal changed
+ for c in n.children:
+ try:
+ plevel = int(round(n.level))
+ clevel = int(round(c.level))
+ except Exception:
+ plevel = int(n.level)
+ clevel = int(c.level)
+ if clevel < plevel + 2:
+ c.level = plevel + 2
+ changed = True
+ # always continue walking to enforce transitive constraints
+ if c.children:
+ walk(c)
+
+ if self.root:
+ walk(self.root)
+
+ enforce_spacing()
+ emit_scale, adaptive_mult = self.compute_scale_and_mult()
+
+ LEVEL_XSHIFT = {
+ 2: 3.58,
+ 6: 1.9,
+ 8: 0.90,
+ 9: 0.90,
+ 10: 0.90,
+ 11: 0.90,
+ 12: 0.45,
+ 14: 2.205,
+ 18: 1.095,
+ 20: 0.73,
+ }
+
+ out_lines: List[str] = []
+ for i, name in enumerate(self.player_names, start=1):
+ pname = name.replace(" ", "~")
+ out_lines.append(f"player {i} name {pname}")
+
+ # First pass to allocate ids deterministically
+ self.node_ids = {}
+ self.iset_groups = {}
+ self.counters_by_level = {}
+
+ def alloc_local_id(level: float) -> int:
+ self.counters_by_level.setdefault(level, 0)
+ self.counters_by_level[level] += 1
+ return self.counters_by_level[level]
+
+ def alloc_ids(n: "DefaultLayout.Node"):
+ if n not in self.node_ids:
+ lid = alloc_local_id(n.level)
+ self.node_ids[n] = (n.level, lid)
+ if (
+ n.desc
+ and n.desc.get("iset_id") is not None
+ and n.desc.get("player") is not None
+ ):
+ key = (n.desc["player"], n.desc["iset_id"])
+ self.iset_groups.setdefault(key, []).append((n.level, lid))
+ for c in n.children:
+ if c not in self.node_ids:
+ clid = alloc_local_id(c.level)
+ self.node_ids[c] = (c.level, clid)
+ if (
+ c.desc
+ and c.desc.get("iset_id") is not None
+ and c.desc.get("player") is not None
+ ):
+ key = (c.desc["player"], c.desc["iset_id"])
+ self.iset_groups.setdefault(key, []).append((c.level, clid))
+ for c in reversed(n.children):
+ alloc_ids(c)
+
+ alloc_ids(self.root)
+
+ # After ids are allocated, ensure info-set groups do not collide
+ # on the same integer level by relocating groups if necessary.
+ try:
+ self._separate_iset_levels()
+ except Exception:
+ pass
+
+ # Final spacing enforcement: _separate_iset_levels may have moved
+ # nodes around; ensure now that every connected parent->child pair
+ # has at least two integer levels separation. Update self.node_ids
+ # entries to match any changed node.level and rebuild iset_groups so
+ # subsequent emission uses consistent integer levels.
+ def enforce_spacing_after_separation():
+ changed = True
+ # Repeat until stable because raising one child can require
+ # raising its children as well.
+ while changed:
+ changed = False
+ # iterate over node objects deterministically
+ for node_obj in list(self.node_ids.keys()):
+ if node_obj.parent is None:
+ continue
+ try:
+ plevel = int(round(node_obj.parent.level))
+ clevel = int(round(node_obj.level))
+ except Exception:
+ plevel = int(node_obj.parent.level)
+ clevel = int(node_obj.level)
+ if clevel < plevel + 2:
+ node_obj.level = plevel + 2
+ # update node_ids to the new integer level, keep lid
+ lid = self.node_ids[node_obj][1]
+ self.node_ids[node_obj] = (int(node_obj.level), lid)
+ changed = True
+
+ # rebuild iset_groups deterministically from node_ids and descriptors
+ new_iset = {}
+ for nobj, (lv, lid) in list(self.node_ids.items()):
+ if (
+ nobj.desc
+ and nobj.desc.get("iset_id") is not None
+ and nobj.desc.get("player") is not None
+ ):
+ key = (nobj.desc["player"], nobj.desc["iset_id"])
+ new_iset.setdefault(key, []).append((int(round(nobj.level)), lid))
+ # sort entries for determinism
+ for k in new_iset:
+ new_iset[k] = sorted(new_iset[k], key=lambda t: (int(t[0]), int(t[1])))
+ self.iset_groups = new_iset
+
+ try:
+ enforce_spacing_after_separation()
+ except Exception:
+ pass
+
+ # Unify terminal levels by tree depth: ensure all leaves at the same
+ # tree depth share the same integer level. If any leaf at a given
+ # depth is higher (larger integer level) than its peers, raise the
+ # others to match that level and update node_ids/isets.
+ try:
+ # compute depth (distance from root) for every node
+ node_depth = {}
+
+ def compute_depth(n, d=0):
+ node_depth[n] = d
+ for ch in n.children:
+ compute_depth(ch, d + 1)
+
+ if self.root:
+ compute_depth(self.root, 0)
+
+ # group leaves by depth
+ depth_groups = {}
+ for leaf in self.leaves:
+ d = node_depth.get(leaf, 0)
+ depth_groups.setdefault(d, []).append(leaf)
+
+ changed = False
+ for d, leaves in depth_groups.items():
+ # find maximum integer level among these leaves
+ maxlvl = max(int(round(leaf.level)) for leaf in leaves)
+ for leaf in leaves:
+ if int(round(leaf.level)) < maxlvl:
+ leaf.level = int(maxlvl)
+ # update node_ids if present
+ if leaf in self.node_ids:
+ lid = self.node_ids[leaf][1]
+ self.node_ids[leaf] = (int(maxlvl), lid)
+ changed = True
+
+ if changed:
+ # rebuild iset_groups deterministically
+ new_iset = {}
+ for nobj, (lv, lid) in list(self.node_ids.items()):
+ if (
+ nobj.desc
+ and nobj.desc.get("iset_id") is not None
+ and nobj.desc.get("player") is not None
+ ):
+ key = (nobj.desc["player"], nobj.desc["iset_id"])
+ new_iset.setdefault(key, []).append(
+ (int(round(nobj.level)), lid)
+ )
+ for k in new_iset:
+ new_iset[k] = sorted(
+ new_iset[k], key=lambda t: (int(t[0]), int(t[1]))
+ )
+ self.iset_groups = new_iset
+ except Exception:
+ pass
+
+ nodes_in_isets = set()
+ for nodes_list in self.iset_groups.values():
+ if len(nodes_list) >= 2:
+ for lv, nid in nodes_list:
+ nodes_in_isets.add((lv, nid))
+
+ def emit_node(n: "DefaultLayout.Node"):
+ lvl, lid = self.node_ids[n]
+ if n.parent is None:
+ if n.desc and n.desc.get("kind") == "c":
+ out_lines.append(f"level {lvl} node {lid} player 0 ")
+ elif n.desc and n.desc.get("kind") == "p":
+ pl = n.desc.get("player") if n.desc.get("player") is not None else 1
+ out_lines.append(f"level {lvl} node {lid} player {pl}")
+
+ for c in n.children:
+ if c not in self.node_ids:
+ clid = alloc_local_id(c.level)
+ self.node_ids[c] = (c.level, clid)
+ # guard descriptor access - some nodes may have None desc
+ if (
+ c.desc
+ and c.desc.get("iset_id") is not None
+ and c.desc.get("player") is not None
+ ):
+ key = (c.desc["player"], c.desc["iset_id"])
+ self.iset_groups.setdefault(key, []).append((c.level, clid))
+ nodes_in_isets.add((c.level, clid))
+ clvl, clid = self.node_ids[c]
+ base = (c.x - n.x) * emit_scale
+ if n.level == 0:
+ mult = 1.0
+ else:
+ mult = adaptive_mult if c.children else 1.0
+ fallback = base * mult
+ chosen_candidate = False
+ if clvl in LEVEL_XSHIFT:
+ xmag = LEVEL_XSHIFT[clvl]
+ root_desc = getattr(self.root, "desc", None)
+ # Apply a controlled widening for top-level branches when
+ # root is a chance node and the child-subtrees are imbalanced.
+ # Use the precomputed self._root_child_ratio capped at 2.0 and
+ # only apply when ratio indicates meaningful imbalance.
+ if (
+ n.parent is None
+ and root_desc is not None
+ and root_desc.get("kind") == "c"
+ ):
+ try:
+ ratio = float(getattr(self, "_root_child_ratio", 1.0))
+ except Exception:
+ ratio = 1.0
+ if ratio >= 1.5:
+ factor = min(2.0, max(1.0, ratio))
+ xmag *= factor
+ if clvl == 6 and (
+ (root_desc is not None and root_desc.get("kind") == "c")
+ or len(self.leaves) <= 4
+ ):
+ xmag = 4.18
+ candidate = xmag if base > 0 else -xmag
+ tol_candidate = 0.25 * abs(candidate) + 0.05
+ if (
+ abs(fallback) < 1.0
+ or abs(candidate - fallback) <= tol_candidate
+ or (
+ abs(fallback) > 1e-9
+ and abs(candidate) > 1.5 * abs(fallback)
+ )
+ or (abs(fallback) > 3.0 * abs(candidate))
+ ):
+ xshift = candidate
+ chosen_candidate = True
+ else:
+ xshift = fallback
+ chosen_candidate = False
+ else:
+ xshift = fallback
+ chosen_candidate = False
+
+ # formatting
+ if chosen_candidate:
+ if abs(xshift) < 1.0:
+ xs = f"{xshift:.2f}"
+ else:
+ s = f"{xshift:.3f}"
+ if "." in s:
+ s = s.rstrip("0").rstrip(".")
+ xs = s
+ else:
+ if abs(xshift) < 1.0:
+ xs = f"{xshift:.2f}"
+ else:
+ s = f"{xshift:.2f}"
+ if "." in s:
+ s = s.rstrip("0").rstrip(".")
+ xs = s
+
+ # prepare move label and attach chance probability if parent is a chance node
+ mv = c.move if c.move else ""
+ if c.prob and n.desc and n.desc.get("kind") == "c":
+ if "/" in c.prob:
+ num, den = c.prob.split("/")
+ mv = f"{mv}~(\\frac{{{num}}}{{{den}}})"
+ else:
+ mv = f"{mv}~({c.prob})"
+
+ if c.desc and (c.desc.get("kind") == "p" or c.desc.get("kind") == "c"):
+ # For chance nodes emit player 0; for player nodes emit the
+ # declared player number (default 1). This fixes cases like
+ # `cent2` where internal chance nodes must be printed as player 0.
+ if c.desc.get("kind") == "c":
+ pl = 0
+ else:
+ pl = (
+ c.desc.get("player")
+ if c.desc.get("player") is not None
+ else 1
+ )
+ if clvl == 2:
+ emit_player_field = True
+ else:
+ emit_player_field = c.desc.get("player") is not None
+ if (
+ c.desc
+ and c.desc.get("iset_id") is not None
+ and c.desc.get("player") is not None
+ ):
+ key = (c.desc["player"], c.desc["iset_id"])
+ if len(self.iset_groups.get(key, [])) >= 2:
+ emit_player_field = False
+ if emit_player_field:
+ out_lines.append(
+ f"level {clvl} node {clid} player {pl} xshift {xs} from {lvl},{lid} move {mv}"
+ )
+ else:
+ out_lines.append(
+ f"level {clvl} node {clid} xshift {xs} from {lvl},{lid} move {mv}"
+ )
+ else:
+ pay = ""
+ if c.desc and c.desc.get("payoffs"):
+ pay = " ".join(str(x) for x in c.desc["payoffs"])
+ # use the prepared move label (which may include probability)
+ mvname = mv
+ if mvname:
+ out_lines.append(
+ f"level {clvl} node {clid} xshift {xs} from {lvl},{lid} move {mvname} payoffs {pay}"
+ )
+ else:
+ out_lines.append(
+ f"level {clvl} node {clid} xshift {xs} from {lvl},{lid} move payoffs {pay}"
+ )
+
+ for c in reversed(n.children):
+ emit_node(c)
+
+ emit_node(self.root)
+
+ # emit isets
+ for (player, iset_id), nodes_list in self.iset_groups.items():
+ if len(nodes_list) >= 2:
+ nodes_sorted = sorted(nodes_list, key=lambda t: -t[1])
+ parts = " ".join(f"{lv},{nid}" for lv, nid in nodes_sorted)
+ out_lines.append(f"iset {parts} player {player}")
+
+ return out_lines
diff --git a/tests/test_default_layout.py b/tests/test_default_layout.py
index ef0dfaf..bc6b846 100644
--- a/tests/test_default_layout.py
+++ b/tests/test_default_layout.py
@@ -1,4 +1,4 @@
-from draw_tree.core import DefaultLayout
+from draw_tree.layout import DefaultLayout
def make_descriptor(kind, player=None, moves=None, probs=None, payoffs=None, iset_id=None, raw=""):
diff --git a/tests/test_drawtree.py b/tests/test_drawtree.py
index 85e44bf..0b7a4db 100644
--- a/tests/test_drawtree.py
+++ b/tests/test_drawtree.py
@@ -640,7 +640,7 @@ def test_commandline_invalid_dpi_string(self):
assert dpi == 300 # Should default to 300 for invalid values
-def test_efg_to_ef_conversion_examples():
+def test_efg_dl_ef_conversion_examples():
"""Integration test: convert the repository's example .efg files and
require exact equality with their corresponding canonical .ef outputs.
@@ -655,10 +655,10 @@ def test_efg_to_ef_conversion_examples():
]
for efg_path, expected_ef_path in examples:
- out = draw_tree.efg_to_ef(efg_path)
+ out = draw_tree.efg_dl_ef(efg_path)
# Converter must return a path and write the file
- assert isinstance(out, str), "efg_to_ef must return a file path string"
- assert os.path.exists(out), f"efg_to_ef did not create output file: {out}"
+ assert isinstance(out, str), "efg_dl_ef must return a file path string"
+ assert os.path.exists(out), f"efg_dl_ef did not create output file: {out}"
with open(out, 'r', encoding='utf-8') as f:
generated = f.read().strip().splitlines()
diff --git a/tests/test_tutorials.py b/tests/test_tutorials.py
new file mode 100644
index 0000000..6dcf957
--- /dev/null
+++ b/tests/test_tutorials.py
@@ -0,0 +1,69 @@
+import contextlib
+import os
+from pathlib import Path
+
+import nbformat
+import pytest
+
+# Ensure Jupyter uses the new platformdirs paths to avoid DeprecationWarning
+# This will become the default in `jupyter_core` v6
+os.environ.setdefault("JUPYTER_PLATFORM_DIRS", "1")
+
+from nbclient import NotebookClient # noqa: E402
+from nbclient.exceptions import CellExecutionError # noqa: E402
+
+
+def _find_tutorial_notebooks():
+ """Return a sorted list of notebook Paths under tutorial.
+
+ Skips the entire module if the tutorials directory does not exist.
+ """
+ root = Path(__file__).resolve().parents[1] / "tutorial"
+ if not root.exists():
+ pytest.skip(f"Tutorials folder not found: {root}")
+
+ # Collect all notebooks under tutorial (including any subfolders).
+ # Exclude Jupyter checkpoint files
+ notebooks = sorted(
+ p for p in root.rglob("*.ipynb") if ".ipynb_checkpoints" not in p.parts
+ )
+
+ if not notebooks:
+ pytest.skip(f"No tutorial notebooks found in: {root}")
+ return notebooks
+
+
+# Discover notebooks at import time so pytest can parametrize them.
+_NOTEBOOKS = _find_tutorial_notebooks()
+
+
+@pytest.mark.parametrize("nb_path", _NOTEBOOKS, ids=[p.name for p in _NOTEBOOKS])
+def test_execute_notebook(nb_path):
+ """Execute a single Jupyter notebook and fail if any cell errors occur.
+
+ This uses nbclient.NotebookClient to run the notebook in its parent directory
+ so relative paths within the notebook resolve correctly.
+ """
+ nb = nbformat.read(str(nb_path), as_version=4)
+
+ # Prefer the notebook's kernelspec if provided, otherwise let nbclient pick the default.
+ kernel_name = nb.metadata.get("kernelspec", {}).get("name")
+
+ client = NotebookClient(
+ nb,
+ timeout=600,
+ kernel_name=kernel_name,
+ resources={"metadata": {"path": str(nb_path.parent)}},
+ )
+
+ try:
+ client.execute()
+ except CellExecutionError as exc:
+ # Re-raise with more context so pytest shows which notebook failed.
+ raise AssertionError(
+ f"Error while executing notebook {nb_path}: {exc}"
+ ) from exc
+ finally:
+ # Ensure kernel is shut down.
+ with contextlib.suppress(Exception):
+ client.shutdown_kernel()
diff --git a/tutorial/basic_usage.ipynb b/tutorial/basic_usage.ipynb
deleted file mode 100644
index c11f4f6..0000000
--- a/tutorial/basic_usage.ipynb
+++ /dev/null
@@ -1,7920 +0,0 @@
-{
- "cells": [
- {
- "cell_type": "code",
- "execution_count": 1,
- "id": "162e2935",
- "metadata": {},
- "outputs": [],
- "source": [
- "from draw_tree import draw_tree, generate_tex, generate_pdf, generate_png"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 2,
- "id": "55aebb9a-ac08-4e8b-9c60-b85274881d9f",
- "metadata": {},
- "outputs": [
- {
- "data": {
- "image/svg+xml": [
- ""
- ],
- "text/plain": [
- ""
- ]
- },
- "execution_count": 2,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "draw_tree(\"../games/one_card_poker.ef\")"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 3,
- "id": "176cb959-b61b-43ad-b44f-3e95eafcdbf8",
- "metadata": {},
- "outputs": [
- {
- "data": {
- "image/svg+xml": [
- ""
- ],
- "text/plain": [
- ""
- ]
- },
- "execution_count": 3,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "draw_tree(\"../games/efg/one_card_poker.efg\")"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 4,
- "id": "5fd3f2b4-c4f2-4a6d-b8cf-3525a73e8761",
- "metadata": {},
- "outputs": [
- {
- "data": {
- "image/svg+xml": [
- ""
- ],
- "text/plain": [
- ""
- ]
- },
- "execution_count": 4,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "draw_tree(\"../games/efg/trust_game.efg\")"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 5,
- "id": "c58d058a-cd34-49e5-9b45-c336867ae0e3",
- "metadata": {},
- "outputs": [
- {
- "data": {
- "image/svg+xml": [
- ""
- ],
- "text/plain": [
- ""
- ]
- },
- "execution_count": 5,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "draw_tree(\"../games/2smp.ef\")"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 6,
- "id": "8ce755bf-083a-4378-aa53-5232ef24544b",
- "metadata": {},
- "outputs": [
- {
- "data": {
- "image/svg+xml": [
- ""
- ],
- "text/plain": [
- ""
- ]
- },
- "execution_count": 6,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "draw_tree(\"../games/efg/2smp.efg\")"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 7,
- "id": "8b0d5897-76f1-4728-94e2-0e33167b6830",
- "metadata": {},
- "outputs": [
- {
- "data": {
- "image/svg+xml": [
- ""
- ],
- "text/plain": [
- ""
- ]
- },
- "execution_count": 7,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "draw_tree(\"../games/2s2x2x2.ef\")"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 8,
- "id": "6622c1b7-5128-43a9-9d84-0c9265d9cb13",
- "metadata": {},
- "outputs": [
- {
- "data": {
- "image/svg+xml": [
- ""
- ],
- "text/plain": [
- ""
- ]
- },
- "execution_count": 8,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "draw_tree(\"../games/efg/2s2x2x2.efg\")"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 9,
- "id": "01d4f44e-d260-4866-bfed-00ebadab5199",
- "metadata": {},
- "outputs": [
- {
- "data": {
- "image/svg+xml": [
- ""
- ],
- "text/plain": [
- ""
- ]
- },
- "execution_count": 9,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "draw_tree(\"../games/cent2.ef\")"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 10,
- "id": "24d174f6-e34e-435e-97bd-fea8cfeded8e",
- "metadata": {},
- "outputs": [
- {
- "data": {
- "image/svg+xml": [
- ""
- ],
- "text/plain": [
- ""
- ]
- },
- "execution_count": 10,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "draw_tree(\"../games/efg/cent2.efg\")"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 11,
- "id": "584e6cdc",
- "metadata": {},
- "outputs": [
- {
- "data": {
- "image/svg+xml": [
- ""
- ],
- "text/plain": [
- ""
- ]
- },
- "execution_count": 11,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "draw_tree(\"../games/crossing.ef\")"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 12,
- "id": "f6160e69",
- "metadata": {},
- "outputs": [
- {
- "data": {
- "image/svg+xml": [
- ""
- ],
- "text/plain": [
- ""
- ]
- },
- "execution_count": 12,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "draw_tree(\"../games/Figure1.ef\")"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 13,
- "id": "22534773",
- "metadata": {},
- "outputs": [
- {
- "data": {
- "image/svg+xml": [
- ""
- ],
- "text/plain": [
- ""
- ]
- },
- "execution_count": 13,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "draw_tree(\"../games/MyTree1.ef\")"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 14,
- "id": "23d9ad2e",
- "metadata": {},
- "outputs": [
- {
- "data": {
- "image/svg+xml": [
- ""
- ],
- "text/plain": [
- ""
- ]
- },
- "execution_count": 14,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "draw_tree(\"../games/oldex.ef\")"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 15,
- "id": "cc060d85",
- "metadata": {},
- "outputs": [
- {
- "data": {
- "image/svg+xml": [
- ""
- ],
- "text/plain": [
- ""
- ]
- },
- "execution_count": 15,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "draw_tree(\"../games/x1.ef\")"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 16,
- "id": "1f81020a-14da-47a4-8d1e-c3a3f3b3eaeb",
- "metadata": {},
- "outputs": [
- {
- "data": {
- "image/svg+xml": [
- ""
- ],
- "text/plain": [
- ""
- ]
- },
- "execution_count": 16,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "draw_tree(\"../games/efg/cross.efg\")"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 17,
- "id": "1b0ad93a-dfd9-4c66-b789-82ddcea13cfb",
- "metadata": {},
- "outputs": [
- {
- "data": {
- "image/svg+xml": [
- ""
- ],
- "text/plain": [
- ""
- ]
- },
- "execution_count": 17,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "draw_tree(\"../games/efg/holdout.efg\")"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 18,
- "id": "168a43f8-8609-4610-9cdd-474a8086bcc0",
- "metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "'/Users/echalstrey/projects/draw_tree/tutorial/x1.png'"
- ]
- },
- "execution_count": 18,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "generate_tex('../games/x1.ef')\n",
- "generate_pdf('../games/x1.ef')\n",
- "generate_png('../games/x1.ef')"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 19,
- "id": "9a6167a9-e270-4874-9b4b-40887b3e4d6e",
- "metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "'/Users/echalstrey/projects/draw_tree/tutorial/one_card_poker.png'"
- ]
- },
- "execution_count": 19,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "generate_tex('../games/efg/one_card_poker.efg')\n",
- "generate_pdf('../games/efg/one_card_poker.efg')\n",
- "generate_png('../games/efg/one_card_poker.efg')"
- ]
- }
- ],
- "metadata": {
- "kernelspec": {
- "display_name": "Python (draw_tree)",
- "language": "python",
- "name": "draw_tree"
- },
- "language_info": {
- "codemirror_mode": {
- "name": "ipython",
- "version": 3
- },
- "file_extension": ".py",
- "mimetype": "text/x-python",
- "name": "python",
- "nbconvert_exporter": "python",
- "pygments_lexer": "ipython3",
- "version": "3.13.7"
- }
- },
- "nbformat": 4,
- "nbformat_minor": 5
-}
diff --git a/tutorial/draw_ef.ipynb b/tutorial/draw_ef.ipynb
new file mode 100644
index 0000000..1988244
--- /dev/null
+++ b/tutorial/draw_ef.ipynb
@@ -0,0 +1,1401 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "162e2935",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from draw_tree import draw_tree, generate_tex, generate_pdf, generate_png"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "55aebb9a-ac08-4e8b-9c60-b85274881d9f",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": [
+ ""
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 2,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "draw_tree(\"../games/one_card_poker.ef\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "id": "584e6cdc",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": [
+ ""
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 6,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "draw_tree(\"../games/crossing.ef\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "id": "f6160e69",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": [
+ ""
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 7,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "draw_tree(\"../games/Figure1.ef\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 8,
+ "id": "22534773",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": [
+ ""
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 8,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "draw_tree(\"../games/MyTree1.ef\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 9,
+ "id": "23d9ad2e",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": [
+ ""
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 9,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "draw_tree(\"../games/oldex.ef\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 10,
+ "id": "cc060d85",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": [
+ ""
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 10,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "draw_tree(\"../games/x1.ef\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 11,
+ "id": "168a43f8-8609-4610-9cdd-474a8086bcc0",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "'/Users/echalstrey/projects/draw_tree/tutorial/x1.png'"
+ ]
+ },
+ "execution_count": 11,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "generate_tex('../games/x1.ef')\n",
+ "generate_pdf('../games/x1.ef')\n",
+ "generate_png('../games/x1.ef')"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.5"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/tutorial/draw_efg_defaultLayout.ipynb b/tutorial/draw_efg_defaultLayout.ipynb
new file mode 100644
index 0000000..3fa5ed5
--- /dev/null
+++ b/tutorial/draw_efg_defaultLayout.ipynb
@@ -0,0 +1,4682 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "162e2935",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from draw_tree import draw_tree, generate_tex, generate_pdf, generate_png"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "176cb959-b61b-43ad-b44f-3e95eafcdbf8",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": [
+ ""
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 2,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "draw_tree(\"../games/efg/one_card_poker.efg\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "5fd3f2b4-c4f2-4a6d-b8cf-3525a73e8761",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": [
+ ""
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 3,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "draw_tree(\"../games/efg/trust_game.efg\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "8ce755bf-083a-4378-aa53-5232ef24544b",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": [
+ ""
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 4,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "draw_tree(\"../games/efg/2smp.efg\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "6622c1b7-5128-43a9-9d84-0c9265d9cb13",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": [
+ ""
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 5,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "draw_tree(\"../games/efg/2s2x2x2.efg\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "id": "1f81020a-14da-47a4-8d1e-c3a3f3b3eaeb",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": [
+ ""
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 6,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "draw_tree(\"../games/efg/cross.efg\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "id": "e0860ddc-1bf2-4902-a0e9-05e385673bc0",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": [
+ ""
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 7,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "draw_tree(\"../games/efg/cent2.efg\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 8,
+ "id": "1b0ad93a-dfd9-4c66-b789-82ddcea13cfb",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": [
+ ""
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 8,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "draw_tree(\"../games/efg/holdout.efg\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 9,
+ "id": "9a6167a9-e270-4874-9b4b-40887b3e4d6e",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "'/Users/echalstrey/projects/draw_tree/tutorial/one_card_poker.png'"
+ ]
+ },
+ "execution_count": 9,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "generate_tex('../games/efg/one_card_poker.efg')\n",
+ "generate_pdf('../games/efg/one_card_poker.efg')\n",
+ "generate_png('../games/efg/one_card_poker.efg')"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.5"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/tutorial/draw_efg_gambitLayout.ipynb b/tutorial/draw_efg_gambitLayout.ipynb
new file mode 100644
index 0000000..44af6e0
--- /dev/null
+++ b/tutorial/draw_efg_gambitLayout.ipynb
@@ -0,0 +1,5079 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "162e2935",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from draw_tree import draw_tree, generate_tex, generate_pdf, generate_png, gambit_layout_to_ef"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "40778418-73b1-48b1-8c87-09554e2b8bfc",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import pygambit as gbt"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "57729e57-c0af-48e7-9e99-b9c46e024e4e",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Writing .ef file to: One card poker game, after Myerson (1991).ef\n"
+ ]
+ },
+ {
+ "data": {
+ "image/svg+xml": [
+ ""
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 3,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "g = gbt.read_efg(\"../games/efg/one_card_poker.efg\")\n",
+ "draw_tree(g, level_spacing=5, width_spacing=3, sublevel_spacing=4, scale_factor=0.8)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "4fe58449-2e31-4bac-9e02-7ebf90955545",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Writing .ef file to: One-shot trust game, after Kreps (1990).ef\n"
+ ]
+ },
+ {
+ "data": {
+ "image/svg+xml": [
+ ""
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 4,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "g = gbt.Game.new_tree(players=[\"Buyer\", \"Seller\"], title=\"One-shot trust game, after Kreps (1990)\")\n",
+ "g.append_move(g.root, player=\"Buyer\", actions=[\"Trust\", \"Not trust\"])\n",
+ "g.append_move(g.root.children[\"Trust\"], player=\"Seller\", actions=[\"Honor\", \"Abuse\"])\n",
+ "g.set_outcome(g.root.children[\"Trust\"].children[\"Honor\"], outcome=g.add_outcome(payoffs=[1, 1], label=\"Trustworthy\"))\n",
+ "g.set_outcome(g.root.children[\"Trust\"].children[\"Abuse\"], outcome=g.add_outcome(payoffs=[-1, 2], label=\"Untrustworthy\"))\n",
+ "g.set_outcome(g.root.children[\"Not trust\"], g.add_outcome(payoffs=[0, 0], label=\"Opt-out\"))\n",
+ "draw_tree(g, level_spacing=2)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "cc536303-f14d-4bd3-8aa3-f2b3ee370a93",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Writing .ef file to: ../games/efg/2smp-gambit.ef\n"
+ ]
+ },
+ {
+ "data": {
+ "image/svg+xml": [
+ ""
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 5,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "g = gbt.read_efg(\"../games/efg/2smp.efg\")\n",
+ "draw_tree(g, save_to=\"../games/efg/2smp-gambit.ef\", sublevel_spacing=1, scale_factor=0.5)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "id": "19ec1c52-9c65-43e4-b01b-7672863f7d48",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "'/Users/echalstrey/projects/draw_tree/tutorial/2smp-gambit.png'"
+ ]
+ },
+ "execution_count": 7,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "generate_tex(\"../games/efg/2smp-gambit.ef\")\n",
+ "generate_pdf(\"../games/efg/2smp-gambit.ef\")\n",
+ "generate_png(\"../games/efg/2smp-gambit.ef\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 8,
+ "id": "a9c6ccb4-09e8-4b5c-af32-dbe737d9a8b9",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Writing .ef file to: Two stage McKelvey McLennan game with 9 equilibria each stage.ef\n"
+ ]
+ },
+ {
+ "data": {
+ "image/svg+xml": [
+ ""
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 8,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "g = gbt.read_efg(\"../games/efg/2s2x2x2.efg\")\n",
+ "draw_tree(g, scale_factor=0.5)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 9,
+ "id": "94ca27cd-4800-4388-8b3b-3b3070a64bfe",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Writing .ef file to: Centipede game. Two inning, with probability of altruists. .ef\n"
+ ]
+ },
+ {
+ "data": {
+ "image/svg+xml": [
+ ""
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 9,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "g = gbt.read_efg(\"../games/efg/cent2.efg\")\n",
+ "draw_tree(g, scale_factor=0.4, width_spacing=4, level_spacing=10, sublevel_spacing=3)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 10,
+ "id": "465f840a-77cf-4428-bb64-17bc7e4df8b0",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Writing .ef file to: Criss-crossing infosets.ef\n"
+ ]
+ },
+ {
+ "data": {
+ "image/svg+xml": [
+ ""
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 10,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "g = gbt.read_efg(\"../games/efg/cross.efg\")\n",
+ "draw_tree(g, width_spacing=4)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 11,
+ "id": "76e54312-85c7-4435-8848-640a644562a4",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Writing .ef file to: McKelvey-Palfrey (JET 77), 7 stage version of holdout game.ef\n"
+ ]
+ },
+ {
+ "data": {
+ "image/svg+xml": [
+ ""
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 11,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "g = gbt.read_efg(\"../games/efg/holdout.efg\")\n",
+ "draw_tree(g, scale_factor=0.3)"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.5"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}