Skip to content

Commit 0ee198e

Browse files
authored
Add beam search example using thermometer continuations (#431)
* add beam search example using thermometer continuations * address comments * add docstring * lint
1 parent cc87f6e commit 0ee198e

3 files changed

Lines changed: 172 additions & 0 deletions

File tree

docs/source/beam.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"""This example demonstrates a beam search over a program that uses a `choose`
2+
effect for nondeterminism and `score` effect to weigh its choices.
3+
4+
"""
5+
6+
import functools
7+
import heapq
8+
import random
9+
import typing
10+
from collections.abc import Callable
11+
from dataclasses import dataclass
12+
from pprint import pprint
13+
14+
from effectful.ops.semantics import fwd, handler
15+
from effectful.ops.syntax import ObjectInterpretation, defop, implements
16+
17+
18+
@defop
19+
def choose[T](choices: list[T]) -> T:
20+
result = random.choice(choices)
21+
print(f"choose({choices}) = {result}")
22+
return result
23+
24+
25+
@defop
26+
def score(value: float) -> None:
27+
pass
28+
29+
30+
class Suspend(Exception): ...
31+
32+
33+
class ReplayIntp(ObjectInterpretation):
34+
def __init__(self, trace):
35+
self.trace = trace
36+
self.step = 0
37+
38+
@implements(choose)
39+
def _(self, *args, **kwargs):
40+
if self.step < len(self.trace):
41+
result = self.trace[self.step][1]
42+
self.step += 1
43+
return result
44+
return fwd()
45+
46+
47+
class TraceIntp(ObjectInterpretation):
48+
def __init__(self):
49+
self.trace = []
50+
51+
@implements(choose)
52+
def _(self, *args, **kwargs):
53+
result = fwd()
54+
self.trace.append(((args, kwargs), result))
55+
return result
56+
57+
58+
class ScoreIntp(ObjectInterpretation):
59+
def __init__(self):
60+
self.score = 0.0
61+
62+
@implements(score)
63+
def _(self, value):
64+
self.score += value
65+
66+
67+
class ChooseOnceIntp(ObjectInterpretation):
68+
def __init__(self):
69+
self.is_first_call = True
70+
71+
@implements(choose)
72+
def _(self, *args, **kwargs):
73+
if not self.is_first_call:
74+
raise Suspend
75+
76+
self.is_first_call = False
77+
return fwd()
78+
79+
80+
@dataclass
81+
class BeamCandidate[S, T]:
82+
"""Represents a candidate execution path in beam search."""
83+
84+
trace: list[S]
85+
score: float
86+
in_progress: bool
87+
result: T | None
88+
89+
def __lt__(self, other: "BeamCandidate[S, T]") -> bool:
90+
return self.score < other.score
91+
92+
def expand[**P](self, model_fn: Callable[P, T], *args: P.args, **kwargs: P.kwargs):
93+
in_progress = False
94+
result = None
95+
score_intp = ScoreIntp()
96+
trace_intp = TraceIntp()
97+
with (
98+
handler(score_intp),
99+
handler(ChooseOnceIntp()),
100+
handler(ReplayIntp(self.trace)),
101+
handler(trace_intp),
102+
):
103+
try:
104+
result = model_fn(*args, **kwargs)
105+
except Suspend:
106+
in_progress = True
107+
108+
return BeamCandidate(trace_intp.trace, score_intp.score, in_progress, result)
109+
110+
111+
def beam_search[**P, S, T](
112+
model_fn: Callable[P, T], beam_width=3
113+
) -> Callable[P, BeamCandidate[S, T]]:
114+
@functools.wraps(model_fn)
115+
def wrapper(*args, **kwargs):
116+
beam = [BeamCandidate([], 0.0, True, None)]
117+
118+
while True:
119+
expandable = [c for c in beam if c.in_progress] * beam_width
120+
if not expandable:
121+
return beam
122+
123+
new_candidates = [c.expand(model_fn, *args, **kwargs) for c in expandable]
124+
125+
for c in new_candidates:
126+
heapq.heappushpop(beam, c) if len(
127+
beam
128+
) >= beam_width else heapq.heappush(beam, c)
129+
130+
return wrapper
131+
132+
133+
if __name__ == "__main__":
134+
135+
def model():
136+
s1 = choose(range(100))
137+
score(s1)
138+
s2 = choose(range(-100, 100))
139+
score(s2)
140+
s3 = choose(range(-100, 100))
141+
score(s3)
142+
return s3
143+
144+
result: BeamCandidate = beam_search(model)()
145+
pprint(result)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
Angelic Nondeterminism
2+
======================
3+
4+
Here we give an example of *angelic nondeterminism* in effectful [#f1]_.
5+
Our model is a nondeterministic program that makes choices using a ``choose`` effect and uses a ``score`` effect to sum up a final score.
6+
We implement a beam search that optimizes this final score as a handler for the ``choose`` and ``score`` effects.
7+
8+
The beam search works by running the model until it reaches a ``choose``, at which point the continuation is captured.
9+
This continuation is resumed multiple times with different values from ``choose`` to expand the beam.
10+
The intermediate score is used to rank the beam candidates.
11+
12+
Because Python does not have support for first-class continuations, we use *thermometer continuations* [#f2]_.
13+
A thermometer continuation works by tracking any nondeterminism
14+
(essentially, the model is rerun from the start replaying the ``choose`` effects).
15+
If ``choose`` is the only source of nondeterminism, then the
16+
after each ``choose`` and replaying it uses *thermometer continuations* to
17+
18+
.. literalinclude:: ./beam.py
19+
:language: python
20+
21+
References
22+
----------
23+
24+
.. [#f1] Li, Z., Solar-Lezama, A., Yue, Y., and Zheng, S., "EnCompass: Enhancing Agent Programming with Search Over Program Execution Paths", 2025. https://arxiv.org/abs/2512.03571
25+
26+
.. [#f2] James Koppel, Gabriel Scherer, and Armando Solar-Lezama. 2018. Capturing the future by replaying the past (functional pearl). Proc. ACM Program. Lang. 2, ICFP, Article 76 (September 2018), 29 pages. https://doi.org/10.1145/3236771

docs/source/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Table of Contents
1616
minipyro_example
1717
lambda_example
1818
semi_ring_example
19+
beam_search_example
1920

2021
.. toctree::
2122
:maxdepth: 2

0 commit comments

Comments
 (0)