curryparty 0.2.3__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: curryparty
3
- Version: 0.2.3
3
+ Version: 0.3.0
4
4
  Summary: Python playground to learn lambda calculus
5
5
  Author: Antonin P
6
6
  Author-email: Antonin P <antonin.peronnet@telecom-paris.fr>
@@ -8,6 +8,11 @@ Requires-Dist: svg-py>=1.9.2
8
8
  Requires-Python: >=3.13
9
9
  Description-Content-Type: text/markdown
10
10
 
11
+ <p align="center">
12
+ <img src="https://github.com/rambip/curryparty/blob/main/logo.svg?raw=true" width="500px"/>
13
+ </p>
14
+
15
+
11
16
  # Curry party
12
17
 
13
18
  `curryparty` is a library created to explore, visualize and teach lambda-calculus concepts.
@@ -1,3 +1,8 @@
1
+ <p align="center">
2
+ <img src="https://github.com/rambip/curryparty/blob/main/logo.svg?raw=true" width="500px"/>
3
+ </p>
4
+
5
+
1
6
  # Curry party
2
7
 
3
8
  `curryparty` is a library created to explore, visualize and teach lambda-calculus concepts.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "curryparty"
3
- version = "0.2.3"
3
+ version = "0.3.0"
4
4
  description = "Python playground to learn lambda calculus"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -17,12 +17,21 @@ from .display import (
17
17
  compute_svg_frame_init,
18
18
  compute_svg_frame_phase_a,
19
19
  compute_svg_frame_phase_b,
20
+ count_variables,
20
21
  )
21
- from .utils import ShapeAnim
22
+ from .utils import ShapeAnim, ShapeAnimFrame
22
23
 
23
24
  __all__ = ["L", "V"]
24
25
 
25
26
 
27
+ def log2(n):
28
+ if n <= 0:
29
+ raise ValueError(f"log2 of negative number {n}")
30
+ elif n == 1:
31
+ return 0
32
+ return 1 + log2(n // 2)
33
+
34
+
26
35
  class Term:
27
36
  def __init__(self, nodes: pl.DataFrame):
28
37
  assert nodes.schema == SCHEMA, (
@@ -58,7 +67,7 @@ class Term:
58
67
  if term is None:
59
68
  break
60
69
 
61
- def show_beta(self, x0=-10, width=30):
70
+ def show_beta(self, duration=7):
62
71
  """
63
72
  Generates an HTML representation that toggles visibility between
64
73
  a static state and a SMIL animation on hover using pure CSS.
@@ -71,37 +80,41 @@ class Term:
71
80
  new_nodes = beta_reduce(self.nodes, lamb, b)
72
81
  vars = find_variables(self.nodes, lamb)["id"]
73
82
  b_subtree = subtree(self.nodes, b)
74
- height = compute_height(self.nodes) + 3
75
- shapes: dict[int, ShapeAnim] = {}
83
+ height = min(compute_height(self.nodes), compute_height(new_nodes)) * 2
84
+ if count_variables(self.nodes) == 0:
85
+ return "no width"
86
+ raw_width = max(count_variables(self.nodes), count_variables(new_nodes))
87
+ width = 1 << (1 + log2(raw_width))
88
+ frame_data: list[ShapeAnimFrame] = []
76
89
  N_STEPS = 6
77
90
 
78
91
  for t in range(N_STEPS):
79
92
  if t == 0:
80
- items = compute_svg_frame_init(self.nodes)
93
+ items = compute_svg_frame_init(self.nodes, t)
81
94
  elif t == 1 or t == 2:
82
- items = compute_svg_frame_phase_a(self.nodes, lamb, b_subtree, vars)
95
+ items = compute_svg_frame_phase_a(self.nodes, lamb, b_subtree, vars, t)
83
96
  elif t == 3 or t == 4:
84
97
  items = compute_svg_frame_phase_b(
85
- self.nodes, lamb, b_subtree, new_nodes
98
+ self.nodes, lamb, b_subtree, new_nodes, t
86
99
  )
87
100
  else:
88
- items = compute_svg_frame_final(new_nodes)
89
- for k, e, attributes in items:
90
- if k not in shapes:
91
- shapes[k] = ShapeAnim(e)
92
- shapes[k].append_frame(t, attributes.items())
101
+ items = compute_svg_frame_final(new_nodes, t)
102
+ frame_data.extend(items)
93
103
 
94
104
  figure_id = uuid.uuid4()
95
105
  box_id = f"lambda_box_{figure_id}".replace("-", "")
106
+ grouped = ShapeAnim.group_by_key(frame_data)
107
+ anims = [ShapeAnim.from_frames(frames, duration) for frames in grouped.values()]
108
+ anims.sort(key=lambda a: a.zindex)
96
109
  anim_elements = [
97
110
  x.to_element(N_STEPS, begin=f"{box_id}.click", reset=f"{box_id}.mouseover")
98
- for x in shapes.values()
111
+ for x in anims
99
112
  ]
100
113
 
101
114
  anim_elements.append(
102
115
  Rect(
103
116
  id=box_id,
104
- x=str(x0),
117
+ x=f"{-width}",
105
118
  y="0",
106
119
  width="100%",
107
120
  height="100%",
@@ -109,32 +122,43 @@ class Term:
109
122
  )
110
123
  )
111
124
 
125
+ # prefered size in pixels
126
+ H = height * 40
112
127
  anim_svg = SVG(
113
128
  xmlns="http://www.w3.org/2000/svg",
114
- viewBox=f"{x0} 0 {width} {height}",
115
- height=f"{35 * height}px",
129
+ viewBox=f"{-width} 0 {2 * width} {height}",
130
+ style=f"max-height:{H}px",
116
131
  elements=anim_elements,
117
132
  ).as_str()
118
133
 
119
134
  return Html(
120
- f'<div><div style="margin:5px">click to animate, move away and back to reset</div>{anim_svg}</div>'
135
+ '<div style="width:100%">'
136
+ '<div style="margin-bottom:30px">'
137
+ "click to animate, move away and back to reset"
138
+ "</div>"
139
+ f"{anim_svg}"
140
+ "</div>"
121
141
  )
122
142
 
123
- def _repr_html_(self, x0=-10, width=30):
124
- frame = compute_svg_frame_init(self.nodes)
125
- elements = []
143
+ def _repr_html_(self, x0=-10):
144
+ frame = sorted(compute_svg_frame_init(self.nodes), key=lambda x: x.zindex)
126
145
 
146
+ width = (1 << (1 + log2(count_variables(self.nodes)))) + 4
127
147
  height = compute_height(self.nodes) + 1
128
- for _, e, attributes in frame:
129
- for name, v in attributes.items():
130
- e.__setattr__(name, v)
131
- elements.append(e)
132
- return SVG(
148
+
149
+ elements = [ShapeAnim.from_single_frame(x) for x in frame]
150
+
151
+ # prefered size in pixels
152
+ H = height * 40
153
+ W = width * 40
154
+
155
+ svg_element = SVG(
133
156
  xmlns="http://www.w3.org/2000/svg",
134
- viewBox=f"{x0} 0 {width} {height}", # type: ignore
135
- height=f"{35 * height}px", # type: ignore
157
+ viewBox=f"{-1} 0 {width} {height}", # type: ignore
136
158
  elements=elements,
159
+ style=f"max-height:{H}px; margin-left: clamp(0px, calc(100% - {W}px), 100px)",
137
160
  ).as_str()
161
+ return f"<div>{svg_element}</div>"
138
162
 
139
163
 
140
164
  def offset_var(x: Union[int, str], offset: int) -> Union[int, str]:
@@ -156,26 +180,32 @@ class L:
156
180
  self.lambdas[name] = self.n
157
181
  return self
158
182
 
159
- def _append_subtree_or_subexpression(self, t: Union[str, "L"]):
183
+ def _append_subtree_or_subexpression(self, t: Union[str, "L", Term]):
184
+ offset = self.n
160
185
  if isinstance(t, L):
161
- offset = self.n
162
186
  for i, x in t.refs:
163
187
  self.refs.append((offset + i, offset_var(t.lambdas.get(x, x), offset)))
164
188
 
165
189
  for i, x in t.args:
166
190
  self.args.append((offset + i, offset + x))
167
191
  self.n += t.n
192
+ elif isinstance(t, Term):
193
+ for i, x in t.nodes.select("id", "ref").drop_nulls().iter_rows():
194
+ self.refs.append((offset + i, offset + x))
195
+ for i, x in t.nodes.select("id", "arg").drop_nulls().iter_rows():
196
+ self.args.append((offset + i, offset + x))
197
+ self.n += len(t.nodes)
168
198
  else:
169
199
  assert isinstance(t, str)
170
200
  self.refs.append((self.n, t))
171
201
  self.n += 1
172
202
 
173
- def _(self, x: Union[str, "L"]) -> "L":
203
+ def _(self, x: Union[str, "L", Term]) -> "L":
174
204
  self.last_ = self.n
175
205
  self._append_subtree_or_subexpression(x)
176
206
  return self
177
207
 
178
- def call(self, arg: Union[str, "L"]) -> "L":
208
+ def call(self, arg: Union[str, "L", Term]) -> "L":
179
209
  assert self.last_ is not None
180
210
  self.refs = [
181
211
  (i + 1, offset_var(x, 1)) if i >= self.last_ else (i, x)
@@ -3,7 +3,16 @@ from typing import Any, Iterable, Optional, Union
3
3
  import polars as pl
4
4
  import svg
5
5
 
6
- from .utils import Interval
6
+ from .utils import Interval, ShapeAnimFrame
7
+
8
+
9
+ def compute_height(nodes: pl.DataFrame):
10
+ _, y = compute_layout(nodes)
11
+ return max(interval[1] for interval in y.values() if interval) + 1
12
+
13
+
14
+ def count_variables(nodes: pl.DataFrame):
15
+ return nodes["ref"].count()
7
16
 
8
17
 
9
18
  def compute_layout(
@@ -21,7 +30,7 @@ def compute_layout(
21
30
  else:
22
31
  y[child] = y[node].shift(1)
23
32
 
24
- next_var_x = nodes.select(pl.col("ref").count()).item()
33
+ next_var_x = count_variables(nodes) - 1
25
34
 
26
35
  for node, ref, arg in (
27
36
  nodes.sort("id", descending=True).select("id", "ref", "arg").iter_rows()
@@ -38,10 +47,6 @@ def compute_layout(
38
47
  y[node] = y[child] | y[node]
39
48
  return x, y
40
49
 
41
- def compute_height(nodes: pl.DataFrame):
42
- _, y = compute_layout(nodes)
43
- return max(interval[1] for interval in y.values() if interval)
44
-
45
50
 
46
51
  def draw(
47
52
  x: dict[Union[int, tuple[int, int]], Interval],
@@ -50,42 +55,60 @@ def draw(
50
55
  ref: Optional[int],
51
56
  arg: Optional[int],
52
57
  key: Any,
58
+ idx: int,
53
59
  replaced=False,
54
60
  removed=False,
55
- ) -> Iterable[tuple[Any, svg.Element, dict]]:
61
+ hide_arg=False,
62
+ ) -> Iterable[ShapeAnimFrame]:
56
63
  x_node = x[i_node]
57
64
  y_node = y[i_node]
58
- if True:
59
- if arg is not None or removed:
60
- color = "transparent"
61
- elif replaced or removed:
62
- color = "green"
63
- elif ref is not None:
64
- color = "red"
65
- else:
66
- color = "blue"
65
+ if arg is not None or removed:
66
+ color = "transparent"
67
+ elif replaced or removed:
68
+ color = "green"
69
+ elif ref is not None:
70
+ color = "red"
71
+ else:
72
+ color = "blue"
67
73
 
68
- stroke_width = 0.05
69
- stroke = "gray"
70
- if arg is not None:
71
- stroke_width = 0.1
72
- stroke = "orange"
74
+ r = svg.Rect(
75
+ height=0.8,
76
+ stroke_width=0.05,
77
+ stroke="gray",
78
+ )
79
+
80
+ yield ShapeAnimFrame(
81
+ element=r,
82
+ key=("r", key),
83
+ idx=idx,
84
+ attrs={
85
+ "x": 0.1 + x_node[0],
86
+ "y": 0.1 + y_node[0] + (1 if replaced else 0),
87
+ "width": 0.8 + x_node[1] - x_node[0],
88
+ "fill_opacity": 1 if arg is None else 0,
89
+ "fill": color,
90
+ },
91
+ zindex=0,
92
+ )
93
+ if arg is not None and not hide_arg:
73
94
  r = svg.Rect(
74
95
  height=0.8,
75
- stroke_width=stroke_width,
76
- stroke=stroke,
96
+ stroke_width=0.1,
97
+ stroke="orange",
77
98
  )
78
99
 
79
- yield (
80
- ("r", key),
81
- r,
82
- {
100
+ yield ShapeAnimFrame(
101
+ element=r,
102
+ key=("a", key),
103
+ idx=idx,
104
+ attrs={
83
105
  "x": 0.1 + x_node[0],
84
106
  "y": 0.1 + y_node[0],
85
107
  "width": 0.8 + x_node[1] - x_node[0],
86
108
  "fill_opacity": 1 if arg is None else 0,
87
109
  "fill": color,
88
110
  },
111
+ zindex=1,
89
112
  )
90
113
 
91
114
  if ref is not None:
@@ -94,63 +117,65 @@ def draw(
94
117
  stroke_width=0.2,
95
118
  stroke="gray",
96
119
  )
97
- yield (
98
- ("l", key),
99
- e,
100
- {
120
+ yield ShapeAnimFrame(
121
+ element=e,
122
+ key=("l", key),
123
+ idx=idx,
124
+ attrs={
101
125
  "x1": x_node[0] + 0.5,
102
- "y1": y_node[0] + 0.1,
126
+ "y1": y_ref[0] + 0.9,
103
127
  "x2": x_node[0] + 0.5,
104
- "y2": y_ref[0] + 0.9,
128
+ "y2": y_node[0] + 0.1 + (1 if replaced else 0),
105
129
  "stroke": "green" if replaced else "gray",
106
130
  },
131
+ zindex=2,
107
132
  )
108
133
 
109
134
  if arg is not None:
110
135
  x_arg = x[arg]
111
- y_arg = y[arg]
112
136
  e1 = svg.Line(
113
137
  stroke="black",
114
138
  stroke_width=0.05,
115
139
  )
116
140
  e2 = svg.Circle(fill="black", r=0.1)
117
141
  if not removed:
118
- yield (
119
- ("b", key),
120
- e1,
121
- {
142
+ yield ShapeAnimFrame(
143
+ element=e1,
144
+ key=("b", key),
145
+ idx=idx,
146
+ attrs={
122
147
  "x1": 0.5 + x_node[1],
123
148
  "y1": 0.5 + y_node[0],
124
149
  "x2": 0.5 + x_arg[0],
125
- "y2": 0.5 + y_arg[0],
150
+ "y2": 0.5 + y_node[0],
126
151
  },
152
+ zindex=3,
127
153
  )
128
- yield (
129
- ("c", key),
130
- e2,
131
- {
154
+ yield ShapeAnimFrame(
155
+ element=e2,
156
+ key=("c", key),
157
+ idx=idx,
158
+ attrs={
132
159
  "cx": 0.5 + x_node[1],
133
160
  "cy": 0.5 + y_node[0],
134
161
  },
162
+ zindex=3,
135
163
  )
136
164
 
137
165
 
138
166
  def compute_svg_frame_init(
139
- nodes: pl.DataFrame,
140
- ) -> Iterable[tuple[Any, svg.Element, dict[str, Any]]]:
167
+ nodes: pl.DataFrame, idx: int = 0
168
+ ) -> Iterable[ShapeAnimFrame]:
141
169
  x, y = compute_layout(nodes)
142
170
  for target_id, ref, arg in (
143
171
  nodes.select("id", "ref", "arg").sort("id", descending=True).iter_rows()
144
172
  ):
145
- yield from draw(x, y, target_id, ref, arg, target_id)
173
+ yield from draw(x, y, target_id, ref, arg, key=target_id, idx=idx)
146
174
 
147
175
 
148
176
  def compute_svg_frame_phase_a(
149
- nodes: pl.DataFrame,
150
- lamb: int,
151
- b_subtree: pl.DataFrame,
152
- vars: pl.Series,
153
- ):
177
+ nodes: pl.DataFrame, lamb: int, b_subtree: pl.DataFrame, vars: pl.Series, idx: int
178
+ ) -> Iterable[ShapeAnimFrame]:
154
179
  redex = lamb - 1 if lamb is not None else None
155
180
  b_width = b_subtree.count()["ref"].item()
156
181
  x, y = compute_layout(nodes, lamb=lamb, replaced_var_width=b_width)
@@ -164,7 +189,8 @@ def compute_svg_frame_phase_a(
164
189
  target_id,
165
190
  ref,
166
191
  arg,
167
- target_id,
192
+ key=target_id,
193
+ idx=idx,
168
194
  replaced=replaced,
169
195
  removed=(target_id == lamb or target_id == redex),
170
196
  )
@@ -173,7 +199,7 @@ def compute_svg_frame_phase_a(
173
199
  for minor, ref, arg in (
174
200
  b_subtree.select("id", "ref", "arg").sort("id", descending=True).iter_rows()
175
201
  ):
176
- yield from draw(x, y, minor, ref, arg, key=(v, minor))
202
+ yield from draw(x, y, minor, ref, arg, key=(v, minor), idx=idx)
177
203
 
178
204
 
179
205
  def compute_svg_frame_phase_b(
@@ -181,7 +207,8 @@ def compute_svg_frame_phase_b(
181
207
  lamb: int,
182
208
  b_subtree: pl.DataFrame,
183
209
  new_nodes: pl.DataFrame,
184
- ):
210
+ idx: int,
211
+ ) -> Iterable[ShapeAnimFrame]:
185
212
  b_width = b_subtree.count()["ref"].item()
186
213
  b = b_subtree["id"][0]
187
214
  x, y = compute_layout(nodes, lamb=lamb, replaced_var_width=b_width)
@@ -192,7 +219,7 @@ def compute_svg_frame_phase_b(
192
219
  v = bid["major"]
193
220
  minor = bid["minor"]
194
221
  delta_x = x[v][0] - b_x
195
- delta_y = y[v][0] - b_y
222
+ delta_y = y[v][0] - b_y + 1
196
223
  x[(v, minor)] = x[minor].shift(delta_x)
197
224
  y[(v, minor)] = y[minor].shift(delta_y)
198
225
 
@@ -217,13 +244,21 @@ def compute_svg_frame_phase_b(
217
244
  if bid_arg["major"] != bid_arg["minor"]
218
245
  else bid_arg["minor"]
219
246
  )
220
- if bid_arg["minor"] == b:
221
- arg = bid_arg["major"]
222
247
  key = (v, minor) if minor != v else minor
223
- yield from draw(x, y, key, ref, arg, key=key)
248
+ yield from draw(
249
+ x,
250
+ y,
251
+ key,
252
+ ref,
253
+ arg,
254
+ key=key,
255
+ idx=idx,
256
+ )
224
257
 
225
258
 
226
- def compute_svg_frame_final(reduced: pl.DataFrame):
259
+ def compute_svg_frame_final(
260
+ reduced: pl.DataFrame, idx: int
261
+ ) -> Iterable[ShapeAnimFrame]:
227
262
  x, y = compute_layout(reduced)
228
263
  for target_id, bid, ref, arg in (
229
264
  reduced.select("id", "bid", "ref", "arg")
@@ -233,4 +268,4 @@ def compute_svg_frame_final(reduced: pl.DataFrame):
233
268
  minor = bid["minor"]
234
269
  major = bid["major"]
235
270
  key = (major, minor) if minor != major else minor
236
- yield from draw(x, y, target_id, ref, arg, key)
271
+ yield from draw(x, y, target_id, ref, arg, key, idx=idx)
@@ -1,4 +1,4 @@
1
- from dataclasses import dataclass
1
+ from dataclasses import dataclass, field
2
2
  from datetime import timedelta
3
3
  from typing import Any, Iterable, Optional
4
4
 
@@ -33,30 +33,78 @@ class Interval:
33
33
  return Interval((self.values[0] + offset, self.values[1] + offset))
34
34
 
35
35
 
36
+ @dataclass
37
+ class ShapeAnimFrame:
38
+ element: Element
39
+ key: Any
40
+ idx: int
41
+ attrs: dict[str, Any]
42
+ zindex: int = 0
43
+
44
+ def apply_attributes(self):
45
+ for name, v in self.attrs.items():
46
+ self.element.__setattr__(name, v)
47
+
48
+
36
49
  @dataclass
37
50
  class ShapeAnim:
38
- shape: Element
39
- attributes: set[str]
40
- values: dict[tuple[int, str], Any]
41
- n: int
51
+ key: Any
52
+ element: Element
53
+ zindex: int
42
54
  duration: int
55
+ attributes: set[str] = field(default_factory=set)
56
+ values: dict[tuple[int, str], Any] = field(default_factory=dict)
57
+
58
+ @staticmethod
59
+ def from_single_frame(frame: ShapeAnimFrame) -> Element:
60
+ frame.apply_attributes()
61
+ return frame.element
62
+
63
+ @staticmethod
64
+ def from_frames(frames: list[ShapeAnimFrame], duration: int = 7) -> "ShapeAnim":
65
+ if not frames:
66
+ raise ValueError("frames list cannot be empty")
67
+
68
+ frames.sort(key=lambda f: f.idx)
69
+
70
+ zindex = frames[0].zindex
71
+ for f in frames[1:]:
72
+ if f.zindex != zindex:
73
+ raise ValueError(
74
+ f"zindex mismatch for key {frames[0].key}: got {f.zindex}, expected {zindex}"
75
+ )
76
+
77
+ anim = ShapeAnim(
78
+ key=frames[0].key,
79
+ element=frames[0].element,
80
+ zindex=zindex,
81
+ duration=duration,
82
+ )
83
+
84
+ for f in frames:
85
+ for name, v in f.attrs.items():
86
+ anim.attributes.add(name)
87
+ anim.values[f.idx, name] = v
88
+
89
+ return anim
43
90
 
44
- def __init__(
45
- self,
46
- shape: Element,
47
- duration: int = 7,
48
- ):
49
- self.shape = shape
50
- self.attributes = set()
51
- self.values = {}
52
- self.duration = duration
91
+ @staticmethod
92
+ def group_by_key(
93
+ frames: Iterable[ShapeAnimFrame],
94
+ ) -> dict[Any, list[ShapeAnimFrame]]:
95
+ groups: dict[Any, list[ShapeAnimFrame]] = {}
96
+ for frame in frames:
97
+ if frame.key not in groups:
98
+ groups[frame.key] = []
99
+ groups[frame.key].append(frame)
100
+ return groups
53
101
 
54
102
  def append_frame(self, i: int, attributes: Iterable[tuple[str, Any]]):
55
103
  for name, v in attributes:
56
104
  self.attributes.add(name)
57
105
  self.values[i, name] = v
58
106
 
59
- def to_element(self, n: int, begin: str, reset: str):
107
+ def to_element(self, n: int, begin: str, reset: str) -> Element:
60
108
  elements = []
61
109
 
62
110
  visible = [
@@ -74,7 +122,7 @@ class ShapeAnim:
74
122
  for i in range(n):
75
123
  if (i, name) not in self.values:
76
124
  self.values[i, name] = non_nulls[0]
77
- self.shape.__setattr__(name, non_nulls[0])
125
+ self.element.__setattr__(name, non_nulls[0])
78
126
 
79
127
  elements.append(
80
128
  Animate(
@@ -113,7 +161,6 @@ class ShapeAnim:
113
161
  )
114
162
  )
115
163
 
116
- assert not self.shape.elements
117
- self.shape
118
- self.shape.elements = elements
119
- return self.shape
164
+ assert not self.element.elements
165
+ self.element.elements = elements
166
+ return self.element