Skip to content

Commit f722906

Browse files
tmp
1 parent 60eb9db commit f722906

13 files changed

Lines changed: 5211 additions & 0 deletions

docs/explanation/2026_02_containers.ipynb

Lines changed: 3127 additions & 0 deletions
Large diffs are not rendered by default.

docs/tutorials.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ tutorials/tut_2_datalog
99
tutorials/tut_3_analysis
1010
tutorials/tut_4_scheduling
1111
tutorials/tut_5_extraction
12+
tutorials/pandoc_symreg_ac_multiset
1213
```

docs/tutorials/pandoc_symreg_ac_multiset.ipynb

Lines changed: 512 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
# # Pandoc-Symreg EqSat Replication, Then a Multiset Hypothesis
2+
#
3+
# This tutorial reproduces a small `pandoc-symreg` equality-saturation pipeline in Egglog and then
4+
# asks a narrower follow-up question: if we replace binary associative/commutative structure with
5+
# multiset containers, do we reduce A/C blow-up without breaking the rest of the simplification flow?
6+
#
7+
# The source material for provenance is the local clone at `/Users/saul/p/pandoc-symreg`, especially:
8+
#
9+
# - `/Users/saul/p/pandoc-symreg/problems`
10+
# - `/Users/saul/p/pandoc-symreg/erro`
11+
# - `/Users/saul/p/pandoc-symreg/examples/feynman_I_6_2.hl`
12+
# - `/Users/saul/p/pandoc-symreg/examples/example.pysr`
13+
# - `/Users/saul/p/pandoc-symreg/src/Data/SRTree/EqSat.hs`
14+
#
15+
# We copy the four main rule families from `EqSat.hs` into Egglog:
16+
#
17+
# - `rewritesBasic`
18+
# - `constReduction`
19+
# - `constFusion`
20+
# - `rewritesFun`
21+
#
22+
# The baseline section below is the replication target. The multiset section is a hypothesis test.
23+
24+
# +
25+
from __future__ import annotations
26+
27+
from collections.abc import Iterable
28+
29+
try:
30+
import matplotlib.pyplot as plt
31+
except ImportError: # pragma: no cover - docs environments usually have matplotlib
32+
plt = None
33+
34+
from egglog.exp.pandoc_symreg import (
35+
PipelineReport,
36+
Witness,
37+
build_sanity_witnesses,
38+
count_float_params,
39+
run_binary_pipeline,
40+
run_multiset_pipeline,
41+
selected_witnesses,
42+
)
43+
# -
44+
45+
46+
# ## 1. Overview of the chosen problems
47+
#
48+
# We use three kinds of examples:
49+
#
50+
# - `erro:1` is the sanity case. It is small and it really does reduce parameter count.
51+
# - `problems:4` is the readable A/C witness.
52+
# - `feynman_I_6_2.hl:11` is the larger dramatic A/C witness.
53+
# - `example.pysr:3` is an optional extra stress case.
54+
#
55+
# The paper evaluates simplification mainly by how much it reduces the number of parameters. It also
56+
# checks whether the remaining parameters are actually linearly independent by comparing against the
57+
# numeric rank of the Jacobian. We use the same post-extraction scoring idea here:
58+
#
59+
# 1. Convert non-integer float constants to parameters.
60+
# 2. Count resulting parameters.
61+
# 3. Compare that count to numeric Jacobian rank.
62+
#
63+
# We also track engineering metrics that matter for the multiset hypothesis:
64+
#
65+
# - extracted cost
66+
# - total e-graph size via `sum(size for _, size in egraph.all_function_sizes())`
67+
# - wall-clock runtime
68+
# - sampled numeric agreement with the original expression
69+
70+
# +
71+
sanity_1, sanity_2 = build_sanity_witnesses()
72+
readable, dramatic, pysr_stress = selected_witnesses()
73+
core_witnesses = [sanity_1, readable, dramatic]
74+
75+
76+
def _format_table(rows: list[dict[str, str]]) -> str:
77+
headers = list(rows[0])
78+
widths = {header: max(len(header), *(len(str(row[header])) for row in rows)) for header in headers}
79+
header_line = " | ".join(header.ljust(widths[header]) for header in headers)
80+
separator = "-+-".join("-" * widths[header] for header in headers)
81+
body = "\n".join(" | ".join(str(row[header]).ljust(widths[header]) for header in headers) for row in rows)
82+
return f"{header_line}\n{separator}\n{body}"
83+
84+
85+
def _overview_rows(witnesses: Iterable[Witness]) -> list[dict[str, str]]:
86+
rows: list[dict[str, str]] = []
87+
for witness in witnesses:
88+
rows.append({
89+
"name": witness.name,
90+
"source": f"{witness.source_path}:{witness.row}",
91+
"inputs": ", ".join(witness.input_names),
92+
"float_params_before": str(count_float_params(witness.expr)),
93+
"description": witness.description,
94+
})
95+
return rows
96+
97+
98+
print(_format_table(_overview_rows([sanity_1, readable, dramatic, pysr_stress])))
99+
# -
100+
101+
102+
# ## 2. Binary EqSat replication in Egglog
103+
#
104+
# The baseline pipeline matches the Haskell schedule shape from `pandoc-symreg`:
105+
#
106+
# - `rewriteConst = rewritesBasic + constReduction`
107+
# - `rewriteAll = rewritesBasic + constReduction + constFusion + rewritesFun`
108+
# - run the `const` pass once
109+
# - run the `all` pass up to two more times, rebuilding from the extracted term between passes
110+
#
111+
# All saturation is driven through `egraph.run(schedule.saturate())`. There are no direct
112+
# saturation calls on `EGraph` itself.
113+
#
114+
# We start with the three core witnesses. `erro:1` is included first because it is the cleanest
115+
# demonstration that the replicated Egglog rules do perform the kind of parameter reduction the
116+
# paper reports.
117+
118+
# +
119+
binary_reports = {witness.name: run_binary_pipeline(witness) for witness in core_witnesses}
120+
121+
122+
def _report_row(witness: Witness, report: PipelineReport) -> dict[str, str]:
123+
before_params = count_float_params(witness.expr)
124+
metrics = report.metric_report
125+
return {
126+
"witness": witness.name,
127+
"before_params": str(before_params),
128+
"after_params": str(metrics.parameter_count),
129+
"ratio": f"{metrics.parameter_reduction_ratio:.3f}",
130+
"jacobian_rank": str(metrics.jacobian_rank),
131+
"rank_gap": str(metrics.parameter_count - metrics.jacobian_rank),
132+
"cost": str(report.cost),
133+
"total_size": str(report.total_size),
134+
"time_sec": f"{report.total_sec:.4f}",
135+
"max_abs_error": f"{report.numeric_max_abs_error:.3g}",
136+
}
137+
138+
139+
print(_format_table([_report_row(w, binary_reports[w.name]) for w in core_witnesses]))
140+
# -
141+
142+
143+
# `erro:1` is the positive replication case:
144+
#
145+
# - the starting expression has four float parameters
146+
# - the Egglog EqSat pipeline reduces that to two
147+
# - the parameter-reduction ratio is therefore `0.5`
148+
#
149+
# The two A/C witnesses are different: they are here mainly to stress the representation. Under the
150+
# copied rules, they keep the same parameter count after simplification, so they are useful for
151+
# measuring graph growth and extraction stability rather than paper-style parameter reduction.
152+
153+
# +
154+
for witness in core_witnesses:
155+
report = binary_reports[witness.name]
156+
print(f"{witness.name} extracted:")
157+
print(report.python_source)
158+
print()
159+
# -
160+
161+
162+
# ## 3. Replacing binary A/C with multisets
163+
#
164+
# The multiset hypothesis is narrower than the baseline replication:
165+
#
166+
# - additive islands become `sum_(MultiSet[Term])`
167+
# - multiplicative islands become `product_(MultiSet[Term])`
168+
# - constants inside those containers are combined there
169+
# - one distributive expansion rule is ported to the container world
170+
#
171+
# The important limitation is that this is still a partial integration. After the multiset phase, the
172+
# current implementation reruns the copied binary rules on the extracted term so that downstream
173+
# simplifications from `constFusion` and `rewritesFun` still fire.
174+
175+
# +
176+
multiset_reports = {
177+
witness.name: run_multiset_pipeline(witness) for witness in [sanity_1, readable, dramatic, pysr_stress]
178+
}
179+
print(_format_table([_report_row(w, multiset_reports[w.name]) for w in [sanity_1, readable, dramatic, pysr_stress]]))
180+
# -
181+
182+
183+
# The extracted forms remain numerically identical on the sampled points, but the representation
184+
# changes the e-graph size significantly on the A/C-heavy cases.
185+
186+
# +
187+
for witness in [sanity_1, readable, dramatic]:
188+
report = multiset_reports[witness.name]
189+
print(f"{witness.name} multiset extracted:")
190+
print(report.python_source)
191+
for note in report.notes:
192+
print(f"note: {note}")
193+
print()
194+
# -
195+
196+
197+
# ## 4. What improved, what did not, and where the current blocker is
198+
#
199+
# The next table compares the two modes directly on the main witnesses.
200+
201+
# +
202+
comparison_rows: list[dict[str, str]] = []
203+
for witness in [sanity_1, readable, dramatic, pysr_stress]:
204+
binary = run_binary_pipeline(witness)
205+
multiset = multiset_reports[witness.name]
206+
comparison_rows.append({
207+
"witness": witness.name,
208+
"binary_size": str(binary.total_size),
209+
"multiset_size": str(multiset.total_size),
210+
"size_drop": f"{1 - (multiset.total_size / binary.total_size):.3f}",
211+
"binary_time": f"{binary.total_sec:.4f}",
212+
"multiset_time": f"{multiset.total_sec:.4f}",
213+
"binary_ratio": f"{binary.metric_report.parameter_reduction_ratio:.3f}",
214+
"multiset_ratio": f"{multiset.metric_report.parameter_reduction_ratio:.3f}",
215+
})
216+
217+
print(_format_table(comparison_rows))
218+
# -
219+
220+
221+
# A few conclusions are immediate from the current runs:
222+
#
223+
# - `erro:1` shows that the baseline replication is doing real symbolic work, not just preserving the
224+
# input.
225+
# - On the readable and dramatic A/C witnesses, the multiset representation sharply reduces total
226+
# e-graph size while preserving extracted cost and sampled numeric behavior.
227+
# - That size reduction does not automatically produce a runtime win yet. The dramatic witness still
228+
# runs slower in the current multiset pipeline because the implementation has to cross from
229+
# containers back into the copied binary rules.
230+
#
231+
# So the current result is mixed:
232+
#
233+
# - hypothesis supported for graph-size control
234+
# - not yet supported for faster end-to-end execution
235+
# - no parameter-reduction improvement yet on the chosen A/C stress cases
236+
237+
# +
238+
if plt is None:
239+
print("matplotlib is not installed; skipping plots.")
240+
else:
241+
names = [row["witness"] for row in comparison_rows]
242+
binary_sizes = [int(row["binary_size"]) for row in comparison_rows]
243+
multiset_sizes = [int(row["multiset_size"]) for row in comparison_rows]
244+
binary_times = [float(row["binary_time"]) for row in comparison_rows]
245+
multiset_times = [float(row["multiset_time"]) for row in comparison_rows]
246+
247+
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
248+
x = range(len(names))
249+
width = 0.35
250+
251+
axes[0].bar([i - width / 2 for i in x], binary_sizes, width=width, label="binary")
252+
axes[0].bar([i + width / 2 for i in x], multiset_sizes, width=width, label="multiset")
253+
axes[0].set_title("Total E-Graph Size")
254+
axes[0].set_xticks(list(x), names, rotation=20)
255+
axes[0].legend()
256+
257+
axes[1].bar([i - width / 2 for i in x], binary_times, width=width, label="binary")
258+
axes[1].bar([i + width / 2 for i in x], multiset_times, width=width, label="multiset")
259+
axes[1].set_title("Runtime (seconds)")
260+
axes[1].set_xticks(list(x), names, rotation=20)
261+
axes[1].legend()
262+
263+
plt.tight_layout()
264+
fig
265+
# -
266+
267+
268+
# The current blocker is not correctness but integration depth. The multiset phase still has to hand
269+
# control back to the binary rule set to recover the rest of the EqSat behavior. A more complete
270+
# container-native port of `constFusion` and the nonlinear rules would be the next step if the goal is
271+
# to turn the graph-size win into a runtime win as well.

0 commit comments

Comments
 (0)