import networkx as nx import pytest from aviary.message import Message, join from aviary.tools import ToolResponseMessage from ldp.data_structures import Transition, TransitionTree def test_tree_mc_value(): root_id = "dummy" tree = TransitionTree(root_id=root_id) kw = { "agent_state": None, "next_agent_state": None, "observation": Transition.NO_OBSERVATION, "next_observation": Transition.NO_OBSERVATION, "action": None, } # Construct a tree with some rewards scattered about tree.add_transition(f"{root_id}:0", Transition(timestep=0, reward=0.0, **kw)) tree.add_transition(f"{root_id}:0:0", Transition(timestep=1, reward=1.0, **kw)) for i in range(3): tree.add_transition( f"{root_id}:0:0:{i}", Transition(timestep=2, reward=float(i), done=True, **kw), ) tree.add_transition( f"{root_id}:0:1", Transition(timestep=1, reward=-1.0, done=True, **kw) ) tree.assign_mc_value_estimates(discount_factor=0.9) # Now make sure the value estimates are as expected # First, check the terminal nodes: Q==reward for i in range(3): assert tree.get_transition(f"{root_id}:0:0:{i}").value == float(i) assert tree.get_transition(f"{root_id}:0:1").value == -1.0 # Then go up the tree assert tree.get_transition(f"{root_id}:0:0").value == pytest.approx( 1 + 0.9 * ((0 + 1 + 2) / 3), rel=0.001 ) assert tree.get_transition(f"{root_id}:0").value == pytest.approx( 0.0 + 0.9 * ((1.9 - 1) / 2), rel=0.001 ) def test_tree_node_merging() -> None: root_id = "dummy" tree = TransitionTree(root_id=root_id) kw = { "next_agent_state": None, "observation": Transition.NO_OBSERVATION, "next_observation": Transition.NO_OBSERVATION, "action": None, } # Construct a tree with two identical nodes for child_id in ("0", "1"): tree.add_transition( f"{root_id}:{child_id}", Transition(timestep=0, reward=0.0, agent_state=0, **kw), ) # Add some identical nodes from a stochastic environment for child_id, next_o_content in zip(("2", "3"), ("stub", "stub 123"), strict=True): tree.add_transition( f"{root_id}:{child_id}", Transition( timestep=0, reward=0.0, agent_state=0, **(kw | {"next_observation": [Message(content=next_o_content)]}), ), ) # Now some children nodes for parent in ("0", "1"): tree.add_transition( # This tests merge of child nodes too f"{root_id}:{parent}:0", Transition(timestep=1, reward=0.0, agent_state=1, **kw), ) for parent in ("2", "3"): tree.add_transition( f"{root_id}:{parent}:0", Transition(timestep=1, reward=0.0, agent_state=9, **kw), ) # Tree at this stage is: # ROOT -> 0 -> 0:0; ROOT -> 1 -> 1:0; ROOT -> 2 -> 2:0; ROOT -> 3 -> 3:0 def custom_next_obs_join(obs: list[ToolResponseMessage | Message]) -> str: return join( type(o)( content=(o.content or "").split(" ", maxsplit=1)[0], **o.model_dump(exclude={"content"}), ) for o in obs ) merged_tree = tree.merge_identical_nodes( lambda state: state, next_observation_hash_fn=custom_next_obs_join ) # Tree should now be ROOT -> 0/1 -> 0:0/1:0; ROOT -> 2/3 -> 2:0 assert { step_id: tree.get_weight(step_id) for step_id in nx.topological_sort(tree.tree) } == { "dummy": 1.0, # Root "dummy:0": 1.0, "dummy:1": 1.0, "dummy:2": 1.0, "dummy:3": 1.0, "dummy:0:0": 1.0, "dummy:1:0": 1.0, "dummy:2:0": 1.0, "dummy:3:0": 1.0, } assert { step_id: merged_tree.get_weight(step_id) for step_id in nx.topological_sort(merged_tree.tree) } == { "dummy": 1.0, # Root "dummy:0": 2.0, # Middle 0/1 "dummy:2": 2.0, # Middle 2/3 "dummy:0:0": 2.0, # Bottom 0 (from 0/1) "dummy:2:0": 2.0, # Bottom 0 (from 2/3) }