Skip to content

Commit c73155e

Browse files
liuzhengyang699codelion
authored andcommitted
fix(rstar): backpropagate rewards from evaluated rollout nodes
1 parent dabd59d commit c73155e

2 files changed

Lines changed: 77 additions & 9 deletions

File tree

‎optillm/rstar.py‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ async def expand_async(self, node: Node, action: str) -> Node:
5353
logger.debug(f"Expanded node with action: {action}")
5454
return child_node
5555

56-
async def simulate_async(self, node: Node) -> float:
56+
async def simulate_async(self, node: Node) -> Tuple[Node, float]:
57+
"""Return the evaluated node so the whole rollout can receive its reward."""
5758
current_node = node
5859
depth = 0
5960
logger.debug("Starting simulation")
@@ -66,7 +67,7 @@ async def simulate_async(self, node: Node) -> float:
6667
depth += 1
6768
value = self.evaluate(current_node)
6869
logger.debug(f"Simulation complete. Final value: {value}")
69-
return value
70+
return current_node, value
7071

7172
async def mcts_async(self, root_state: str) -> List[Node]:
7273
root = Node(root_state, None)
@@ -83,8 +84,8 @@ async def mcts_rollout_async(self, root: Node):
8384
action = random.choice(self.actions)
8485
if len(node.children) < len(self.actions):
8586
node = await self.expand_async(node, action)
86-
value = await self.simulate_async(node)
87-
self.backpropagate(node, value)
87+
terminal_node, value = await self.simulate_async(node)
88+
self.backpropagate(terminal_node, value)
8889

8990
async def solve_async(self, question: str) -> str:
9091
self.original_question = question
@@ -149,7 +150,8 @@ def expand(self, node: Node, action: str) -> Node:
149150
logger.debug(f"Expanded node with action: {action}")
150151
return child_node
151152

152-
def simulate(self, node: Node) -> float:
153+
def simulate(self, node: Node) -> Tuple[Node, float]:
154+
"""Return the evaluated node so the whole rollout can receive its reward."""
153155
current_node = node
154156
depth = 0
155157
logger.debug("Starting simulation")
@@ -162,7 +164,7 @@ def simulate(self, node: Node) -> float:
162164
depth += 1
163165
value = self.evaluate(current_node)
164166
logger.debug(f"Simulation complete. Final value: {value}")
165-
return value
167+
return current_node, value
166168

167169
def backpropagate(self, node: Node, value: float):
168170
logger.debug("Starting backpropagation")
@@ -183,8 +185,8 @@ def mcts(self, root_state: str) -> List[Node]:
183185
action = random.choice(self.actions)
184186
if len(node.children) < len(self.actions):
185187
node = self.expand(node, action)
186-
value = self.simulate(node)
187-
self.backpropagate(node, value)
188+
terminal_node, value = self.simulate(node)
189+
self.backpropagate(terminal_node, value)
188190
logger.debug("MCTS complete")
189191
return self.extract_trajectories(root)
190192

@@ -347,4 +349,4 @@ def solve(self, question: str) -> str:
347349
"""
348350
Synchronous wrapper for solve_async method.
349351
"""
350-
return asyncio.run(self.solve_async(question))
352+
return asyncio.run(self.solve_async(question))
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import asyncio
2+
import unittest
3+
from unittest.mock import Mock
4+
5+
from optillm.rstar import RStar
6+
7+
8+
class TestRStarBackpropagation(unittest.TestCase):
9+
def test_sync_rollouts_record_terminal_rewards(self):
10+
for depth in (0, 2):
11+
with self.subTest(depth=depth):
12+
search = RStar("", Mock(), "mock", max_depth=depth, num_rollouts=3)
13+
search.generate_response = Mock(return_value="reasoning step")
14+
search.evaluate = Mock(return_value=0.75)
15+
16+
trajectories = search.mcts("question")
17+
18+
self.assertEqual(trajectories[0][0].visits, 3)
19+
self.assertAlmostEqual(trajectories[0][0].value, 2.25)
20+
for trajectory in trajectories:
21+
for node in trajectory:
22+
self.assertGreater(node.visits, 0)
23+
self.assertAlmostEqual(node.value / node.visits, 0.75)
24+
self.assertEqual(search.trajectory_score(trajectory), 0.75)
25+
26+
27+
class TestAsyncRStarBackpropagation(unittest.IsolatedAsyncioTestCase):
28+
async def test_parallel_trajectories_keep_their_own_rewards(self):
29+
search = RStar("", Mock(), "mock", max_depth=1, num_rollouts=2)
30+
responses = iter(["1", "2", "3", "4"])
31+
32+
async def generate(prompt):
33+
# Both rollouts enter expansion before either completes it.
34+
await asyncio.sleep(0)
35+
return next(responses)
36+
37+
search.generate_response_async = generate
38+
search.evaluate = Mock(side_effect=lambda node: 10 - float(node.state))
39+
search.mutual_consistency = Mock(return_value=True)
40+
41+
trajectories = await search.mcts_async("question")
42+
43+
self.assertEqual(len(trajectories), 2)
44+
self.assertEqual(trajectories[0][0].visits, 2)
45+
self.assertEqual(trajectories[0][0].value, 13)
46+
for trajectory in trajectories:
47+
for node in trajectory[1:]:
48+
self.assertEqual(node.visits, 1)
49+
self.assertEqual(node.value, 10 - float(trajectory[-1].state))
50+
best = search.select_final_trajectory(trajectories)
51+
self.assertEqual(best[-1].state, "3")
52+
53+
async def test_zero_depth_counts_each_rollout_once(self):
54+
search = RStar("", Mock(), "mock", max_depth=0, num_rollouts=3)
55+
56+
async def generate(prompt):
57+
await asyncio.sleep(0)
58+
return "reasoning step"
59+
60+
search.generate_response_async = generate
61+
search.evaluate = Mock(return_value=0.5)
62+
trajectories = await search.mcts_async("question")
63+
64+
self.assertEqual(trajectories[0][0].visits, 3)
65+
self.assertEqual(trajectories[0][0].value, 1.5)
66+
self.assertTrue(all(search.trajectory_score(t) == 0.5 for t in trajectories))

0 commit comments

Comments
 (0)