',
+ ]
+
+ last_time_step = None
+ for original_index, turn in indexed_turns:
+ # Build classes
+ agent_class = f"agent-{re.sub('[^a-z0-9_-]', '-', turn.agent_id.lower())}"
+ role_class = f"role-{turn.role}"
+ collapsed_class = " collapsed" if turn.role == "user" else ""
+
+ # Badge content
+ if turn.role == "assistant":
+ name = html.escape(turn.agent_id)
+ emoji = '
π€'
+ raw_val = turn.reward
+ if isinstance(raw_val, (int, float)):
+ reward_val = f"{raw_val:.4f}".rstrip("0").rstrip(".")
+ if len(reward_val) > 8:
+ reward_val = reward_val[:8] + "β¦"
+ else:
+ reward_val = str(raw_val)
+ # Format: "π€ Alice β’ Reward: 5.5556 β’ π¬ :"
+ badge_inner = (
+ f'{emoji}
{name}'
+ f'
β’ Reward β = {reward_val}'
+ )
+ else:
+ # For user messages, show "User of {Agent ID}" in the badge
+ name = "User of " + html.escape(turn.agent_id)
+ emoji = '
βοΈ'
+ # Format (no reward): "βοΈ User of Alice β’ "
+ badge_inner = f'{emoji}
{name} β’ :'
+
+ badge = f'
{badge_inner}'
+
+ # Inline timestep distinction badge at step boundaries (render before first message)
+ ts_badge_html = ""
+ if last_time_step is None or turn.time_step != last_time_step:
+ ts_badge_html = f'
β± {turn.time_step}'
+ last_time_step = turn.time_step
+
+ escaped_content = html.escape(turn.content)
+ reasoning_html = ""
+ if turn.reasoning_content:
+ # Normalize reasoning to avoid leading/newline whitespace that creates visual gaps
+ _raw_reasoning = turn.reasoning_content.replace("\r\n", "\n")
+ _raw_reasoning = _re.sub(
+ r"^\s*\n+", "", _raw_reasoning
+ ) # drop leading blank lines
+ _raw_reasoning = _re.sub(
+ r"\*\*(\s*\n\s*)", r"** ", _raw_reasoning
+ ) # newline right after **
+ _raw_reasoning = _re.sub(
+ r"(\s*\n\s*)\*\*", r" **", _raw_reasoning
+ ) # newline right before **
+ escaped_reasoning = html.escape(_raw_reasoning)
+ reasoning_html = f'
π{escaped_reasoning}'
+ collapsed_text = re.sub(r"\s+", " ", escaped_content).strip()
+
+ html_parts.append(
+ f'
'
+ f'
{ts_badge_html}{badge}'
+ f'{reasoning_html}π¬ {collapsed_text}'
+ f'(...)'
+ f"
"
+ f"
"
+ )
+
+ html_parts.append("
") # close linear flow
+ if enable_split_view:
+ import html as _html_mod
+
+ html_parts.append(
+ ''
+ )
+ html_parts.append('
')
+ # Per-agent columns
+ per_agent_turns = {
+ aid: [t for t in chat_turns if t.agent_id == aid]
+ for aid in assistant_agents
+ }
+ for idx, aid in enumerate(assistant_agents):
+ turns_agent = per_agent_turns[aid]
+ html_parts.append(
+ f'
'
+ )
+ last_ts_agent = None
+ for turn in turns_agent:
+ agent_class = (
+ f"agent-{re.sub('[^a-z0-9_-]', '-', turn.agent_id.lower())}"
+ )
+ role_class = f"role-{turn.role}"
+ collapsed_class = " collapsed" if turn.role == "user" else ""
+ ts_badge_html = ""
+ if last_ts_agent is None or turn.time_step != last_ts_agent:
+ ts_badge_html = f'
β± {turn.time_step}'
+ last_ts_agent = turn.time_step
+ esc_content = _html_mod.escape(turn.content)
+ reasoning_html = ""
+ if turn.reasoning_content:
+ _raw_reasoning = turn.reasoning_content.replace("\r\n", "\n")
+ _raw_reasoning = _re.sub(r"^\s*\n+", "", _raw_reasoning)
+ _raw_reasoning = _re.sub(r"\*\*(\s*\n\s*)", r"** ", _raw_reasoning)
+ _raw_reasoning = _re.sub(r"(\s*\n\s*)\*\*", r" **", _raw_reasoning)
+ esc_reasoning = _html_mod.escape(_raw_reasoning)
+ reasoning_html = f'
π{esc_reasoning}'
+ collapsed_text = re.sub(r"\s+", " ", esc_content).strip()
+ if turn.role == "assistant":
+ name = _html_mod.escape(turn.agent_id)
+ emoji = '
π€'
+ raw_val = turn.reward
+ if isinstance(raw_val, (int, float)):
+ reward_val = f"{raw_val:.4f}".rstrip("0").rstrip(".")
+ if len(reward_val) > 8:
+ reward_val = reward_val[:8] + "β¦"
+ else:
+ reward_val = str(raw_val)
+ badge_inner = (
+ f'{emoji}
{name}'
+ f'
β’ Reward β : {reward_val}'
+ )
+ else:
+ name = "User of " + _html_mod.escape(turn.agent_id)
+ emoji = '
βοΈ'
+ badge_inner = f'{emoji}
{name} β’ :'
+ badge = f'
{badge_inner}'
+ html_parts.append(
+ f'
'
+ f'
{ts_badge_html}{badge}'
+ f'{reasoning_html}π¬ {collapsed_text}'
+ f'(...)'
+ f"
"
+ )
+ html_parts.append("
") # close split col
+ html_parts.append("
") # split-wrapper
+ html_parts.append("
") # flow-split
+ html_parts.extend(["", ""])
+
+ return "\n".join(html_parts)
+
+
+def export_html_from_rollout_tree(path: Path, outdir: Path, main_only: bool = False):
+ """Process a rollout tree file and generate HTML files for each path.
+ Creates separate HTML files for the main path and each branch path.
+ The main path is saved in the root output directory, while branch paths
+ are saved in a 'branches' subdirectory.
+
+ Args:
+ path: Path to the rollout tree JSON file
+ outdir: Output directory for HTML files
+ main_only: If True, only export the main trajectory (default: False)
+ """
+ root = load_rollout_tree(path)
+ mgid = root.id
+
+ main_path, branch_paths = get_rollout_tree_paths(root)
+
+ outdir.mkdir(parents=True, exist_ok=True)
+
+ # Create branches subdirectory if we have branch paths
+ if not main_only and branch_paths:
+ branches_dir = outdir / f"mgid:{mgid}_branches_html_renders"
+ branches_dir.mkdir(parents=True, exist_ok=True)
+
+ # Generate HTML for the main path
+ chat_turns = gather_all_chat_turns_for_path(main_path)
+ html_content = html_from_chat_turns(chat_turns)
+ output_file = outdir / f"mgid:{mgid}_main_html_render.render.html"
+ with open(output_file, "w", encoding="utf-8") as f:
+ f.write(html_content)
+
+ # Generate HTML for each branch path
+ for path_obj in branch_paths:
+ chat_turns = gather_all_chat_turns_for_path(path_obj)
+
+ html_content = html_from_chat_turns(chat_turns)
+
+ path_id: str = path_obj.id
+ output_filename = f"{path_id}_html_render.render.html"
+
+ output_file = branches_dir / output_filename
+
+ with open(output_file, "w", encoding="utf-8") as f:
+ f.write(html_content)
diff --git a/src_code_for_reproducibility/utils/rollout_tree_gather_utils.py b/src_code_for_reproducibility/utils/rollout_tree_gather_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..d1844aa5a20c01b591865cad108f1fb1577d3ef4
--- /dev/null
+++ b/src_code_for_reproducibility/utils/rollout_tree_gather_utils.py
@@ -0,0 +1,314 @@
+from __future__ import annotations
+
+import csv
+import os
+import pickle
+import re
+from collections import defaultdict
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple
+
+from mllm.markov_games.rollout_tree import *
+
+
+
+
+
+def load_rollout_tree(path: Path) -> RolloutTreeRootNode:
+ """Load a rollout tree from a PKL file containing a dict."""
+ with open(path, "rb") as f:
+ data = pickle.load(f)
+ return RolloutTreeRootNode.model_validate(data)
+
+
+@dataclass
+class RolloutNodeList:
+ id: str
+ nodes: List[RolloutTreeNode]
+
+
+def get_rollout_tree_paths(
+ root: RolloutTreeRootNode, mgid: Optional[str] = None
+) -> Tuple[RolloutNodeList, List[RolloutNodeList]]:
+ """
+ Returns:
+ main_path: The main path from the root to the end of the tree.
+ branch_paths: A list of all branch paths from the root to the end of the tree.
+ Each branch path contains a list of nodes that are part of the branch, including the nodes from the main path before the branch was taken.
+ """
+ branch_paths = []
+
+ def collect_path_nodes(current) -> List[RolloutTreeNode]:
+ """Recursively collect all nodes in a path starting from current node."""
+ if current is None:
+ return []
+
+ if isinstance(current, RolloutTreeNode):
+ return [current] + collect_path_nodes(current.child)
+
+ elif isinstance(current, RolloutTreeBranchNode):
+ # For branch nodes, we only follow the main_child for path collection
+ if current.main_child:
+ return [current.main_child] + collect_path_nodes(
+ current.main_child.child
+ )
+ else:
+ return []
+
+ def traverse_for_branches(
+ current,
+ main_path_prefix: List[RolloutTreeNode],
+ path_id: str,
+ current_time_step: Optional[int] = 0,
+ ):
+ """Traverse tree to collect all branch paths."""
+ if current is None:
+ return
+
+ if isinstance(current, RolloutTreeNode):
+ # Continue traversing with this node added to the main path prefix
+ new_prefix = main_path_prefix + [current]
+ traverse_for_branches(current.child, new_prefix, path_id, current.time_step)
+
+ elif isinstance(current, RolloutTreeBranchNode):
+ # Collect all branch paths
+ if current.branches:
+ for agent_id, branch_node_list in current.branches.items():
+ if branch_node_list:
+ # Start with the main path prefix, then recursively collect all nodes in this branch
+ branch_path_nodes = main_path_prefix.copy()
+ for branch_node in branch_node_list:
+ branch_path_nodes.extend(collect_path_nodes(branch_node))
+
+ # Create proper branch path ID with mgid, agent_id, and time_step
+ mgid_str = mgid or str(root.id)
+ branch_path_id = f"mgid:{mgid_str}_type:branch_agent:{agent_id}_time_step:{current_time_step}"
+ branch_paths.append(
+ RolloutNodeList(id=branch_path_id, nodes=branch_path_nodes)
+ )
+
+ # Process the main child and add to prefix
+ new_prefix = main_path_prefix
+ if current.main_child:
+ new_prefix = main_path_prefix + [current.main_child]
+
+ # Continue traversing the main path
+ if current.main_child:
+ traverse_for_branches(
+ current.main_child.child,
+ new_prefix,
+ path_id,
+ current.main_child.time_step,
+ )
+
+ # Collect the main path nodes
+ main_path_nodes = collect_path_nodes(root.child)
+
+ # Traverse to collect all branch paths
+ traverse_for_branches(root.child, [], "")
+
+ # Create the main path with proper mgid format
+ mgid_str = mgid or str(root.id)
+ main_path = RolloutNodeList(id=f"mgid:{mgid_str}_type:main", nodes=main_path_nodes)
+
+ return main_path, branch_paths
+
+
+class ChatTurnLog(BaseModel):
+ time_step: int
+ agent_id: str
+ role: str
+ content: str
+ reasoning_content: Optional[str] = None
+ is_state_end: bool
+ reward: float
+
+
+def gather_agent_chat_turns_for_path(
+ agent_id: str, path: RolloutNodeList
+) -> List[ChatTurnLog]:
+ """Iterate through all chat turns for a specific agent in a path sorted by time step."""
+ turns = []
+ for node in path.nodes:
+ action_log = node.step_log.action_logs.get(agent_id, [])
+ if action_log:
+ for chat_turn in action_log.chat_turns or []:
+ turns.append(
+ ChatTurnLog(
+ time_step=node.time_step,
+ agent_id=agent_id,
+ role=chat_turn.role,
+ content=chat_turn.content,
+ reasoning_content=getattr(chat_turn, "reasoning_content", None),
+ is_state_end=chat_turn.is_state_end,
+ reward=node.step_log.simulation_step_log.rewards.get(
+ agent_id, 0
+ ),
+ )
+ )
+ return turns
+
+
+def gather_all_chat_turns_for_path(path: RolloutNodeList) -> List[ChatTurnLog]:
+ """Iterate through all chat turns for all agents in a path sorted by time step."""
+ turns = []
+
+ # Collect turns from all agents, but interleave them per timestep by (user, assistant) pairs
+ for node in path.nodes:
+ # Build (user[, assistant]) pairs for each agent at this timestep
+ agent_ids = sorted(list(node.step_log.action_logs.keys()))
+ per_agent_pairs: Dict[str, List[List[ChatTurnLog]]] = {}
+
+ for agent_id in agent_ids:
+ action_log = node.step_log.action_logs.get(agent_id)
+ pairs: List[List[ChatTurnLog]] = []
+ current_pair: List[ChatTurnLog] = []
+
+ if action_log and action_log.chat_turns:
+ for chat_turn in action_log.chat_turns:
+ turn_log = ChatTurnLog(
+ time_step=node.time_step,
+ agent_id=agent_id,
+ role=chat_turn.role,
+ content=chat_turn.content,
+ reasoning_content=getattr(chat_turn, "reasoning_content", None),
+ is_state_end=chat_turn.is_state_end,
+ reward=node.step_log.simulation_step_log.rewards.get(
+ agent_id, 0
+ ),
+ )
+
+ if chat_turn.role == "user":
+ # If a previous pair is open, close it and start a new one
+ if current_pair:
+ pairs.append(current_pair)
+ current_pair = []
+ current_pair = [turn_log]
+ else:
+ # assistant: attach to an open user message if present; otherwise stand alone
+ if (
+ current_pair
+ and len(current_pair) == 1
+ and current_pair[0].role == "user"
+ ):
+ current_pair.append(turn_log)
+ pairs.append(current_pair)
+ current_pair = []
+ else:
+ # No preceding user or already paired; treat as its own unit
+ pairs.append([turn_log])
+
+ if current_pair:
+ # Unpaired trailing user message
+ pairs.append(current_pair)
+
+ per_agent_pairs[agent_id] = pairs
+
+ # Interleave pairs across agents: A1, B1, A2, B2, ...
+ index = 0
+ while True:
+ added_any = False
+ for agent_id in agent_ids:
+ agent_pairs = per_agent_pairs.get(agent_id, [])
+ if index < len(agent_pairs):
+ for tl in agent_pairs[index]:
+ turns.append(tl)
+ added_any = True
+ if not added_any:
+ break
+ index += 1
+
+ return turns
+
+
+def chat_turns_to_dict(chat_turns: Iterator[ChatTurnLog]) -> Iterator[Dict[str, Any]]:
+ """Render all chat turns for a path as structured data for JSON."""
+ for chat_turn in chat_turns:
+ yield chat_turn.model_dump()
+
+
+def get_all_agents(root: RolloutTreeRootNode) -> List[str]:
+ """list of all agent IDs that appear in the tree."""
+ if root.child is None:
+ return []
+
+ # Get the first node to extract all agent IDs
+ first_node = root.child
+ if isinstance(first_node, RolloutTreeBranchNode):
+ first_node = first_node.main_child
+
+ if first_node is None:
+ return []
+
+ # All agents should be present in the first node
+ agents = set(first_node.step_log.action_logs.keys())
+ agents.update(first_node.step_log.simulation_step_log.rewards.keys())
+
+ return sorted(list(agents))
+
+
+def gather_agent_main_rewards(agent_id: str, path: RolloutNodeList) -> List[float]:
+ """Gather main rewards for a specific agent in a path."""
+ rewards = []
+ for node in path.nodes:
+ reward = node.step_log.simulation_step_log.rewards[agent_id]
+ rewards.append(reward)
+ return rewards
+
+
+def gather_all_rewards(path: RolloutNodeList) -> List[Dict[AgentId, float]]:
+ """Gather main rewards from main trajectory in a path."""
+ rewards = []
+ for node in path.nodes:
+ rewards.append(node.step_log.simulation_step_log.rewards.copy())
+ return rewards
+
+
+def gather_simulation_stats(
+ path: RolloutNodeList,
+ filter: Callable[[SimulationStepLog], bool],
+ stat_func: Callable[[SimulationStepLog], Any],
+) -> List[Any]:
+ """Gather stats from main trajectory in a path."""
+ stats = []
+ for node in path.nodes:
+ sl = node.step_log.simulation_step_log
+ if filter(sl):
+ stats.append(stat_func(sl))
+ return stats
+
+
+def gather_simulation_step_logs(path: RolloutNodeList) -> List[SimulationStepLog]:
+ """Gather simulation information from main trajectory in a path."""
+ infos = []
+ for node in path.nodes:
+ infos.append(node.step_log.simulation_step_log)
+ return infos
+
+
+def export_chat_logs(path: Path, outdir: Path):
+ """Process a rollout tree PKL file and generate a JSONL of chat turns as dicts.
+ Each line contains an object with path_id and chat_turns for a single path.
+ """
+ import json
+
+ root = load_rollout_tree(path)
+ mgid = root.id
+
+ main_path, branch_paths = get_rollout_tree_paths(root)
+ all_paths = [main_path] + branch_paths
+
+ outdir.mkdir(parents=True, exist_ok=True)
+ output_file = outdir / f"mgid:{mgid}_plucked_chats.render.jsonl"
+
+ with open(output_file, "w", encoding="utf-8") as f:
+ for path_obj in all_paths:
+ chat_turns = gather_all_chat_turns_for_path(path_obj)
+ output_obj = {
+ "path_id": str(path_obj.id),
+ "chat_turns": list(chat_turns_to_dict(iter(chat_turns))),
+ }
+ f.write(json.dumps(output_obj, ensure_ascii=False) + "\n")
+
+
diff --git a/src_code_for_reproducibility/utils/rollout_tree_stats.py b/src_code_for_reproducibility/utils/rollout_tree_stats.py
new file mode 100644
index 0000000000000000000000000000000000000000..1ac3cd0e34212e7fdbeba19e501b7d96a5f128e1
--- /dev/null
+++ b/src_code_for_reproducibility/utils/rollout_tree_stats.py
@@ -0,0 +1,50 @@
+from typing import Any, Callable, List, Tuple
+
+from mllm.markov_games.rollout_tree import RolloutTreeRootNode
+from mllm.markov_games.simulation import SimulationStepLog
+from mllm.utils.rollout_tree_gather_utils import (
+ gather_simulation_step_logs,
+ get_rollout_tree_paths,
+)
+from mllm.utils.stat_pack import StatPack
+
+
+def get_rollout_tree_stat_tally(
+ rollout_tree: RolloutTreeRootNode,
+ metrics: List[Callable[[SimulationStepLog], List[Tuple[str, float]]]],
+) -> StatPack:
+ stat_tally = StatPack()
+ # get simulation step logs
+ node_list = get_rollout_tree_paths(rollout_tree)[0]
+ simulation_step_logs = gather_simulation_step_logs(node_list)
+ for simulation_step_log in simulation_step_logs:
+ for metric in metrics:
+ metric_result = metric(simulation_step_log)
+ if metric_result is not None:
+ for key, value in metric_result:
+ stat_tally.add_stat(key, value)
+ return stat_tally
+
+
+def get_rollout_tree_mean_stats(
+ rollout_tree: RolloutTreeRootNode, metrics: List[Callable[[SimulationStepLog], Any]]
+) -> StatPack:
+ """Get the mean stats for a rollout tree."""
+ stat_tally = get_rollout_tree_stat_tally(rollout_tree, metrics)
+ return stat_tally.mean()
+
+
+def get_mean_rollout_tree_stats(
+ rollout_trees: List[RolloutTreeRootNode],
+ metrics: List[Callable[[SimulationStepLog], Any]],
+) -> StatPack:
+ """Get the mean stats for a list of rollout trees."""
+ # TODO complete this
+ stat_tallies = [
+ get_rollout_tree_mean_stats(rollout_tree, metrics)
+ for rollout_tree in rollout_trees
+ ]
+ mean_stat_tally = StatPack()
+ for stat_tally in stat_tallies:
+ mean_stat_tally.add_stats(stat_tally)
+ return mean_stat_tally.mean()
diff --git a/src_code_for_reproducibility/utils/short_id_gen.py b/src_code_for_reproducibility/utils/short_id_gen.py
new file mode 100644
index 0000000000000000000000000000000000000000..bb275cad8fe5e0dd52bebb14e7424e98dcbaba54
--- /dev/null
+++ b/src_code_for_reproducibility/utils/short_id_gen.py
@@ -0,0 +1,11 @@
+import uuid
+
+
+def generate_short_id() -> int:
+ """
+ Generates a short unique ID for tracking adapter versions.
+
+ Returns:
+ int: An 8-digit integer ID.
+ """
+ return int(str(uuid.uuid4().int)[:8])
diff --git a/src_code_for_reproducibility/utils/stat_pack.py b/src_code_for_reproducibility/utils/stat_pack.py
new file mode 100644
index 0000000000000000000000000000000000000000..46b397139a1a8a4149030a9cc33d2b3afb7b4a12
--- /dev/null
+++ b/src_code_for_reproducibility/utils/stat_pack.py
@@ -0,0 +1,113 @@
+import csv
+import json
+import os
+import pickle
+from collections import Counter
+from copy import deepcopy
+from locale import strcoll
+from statistics import mean
+from typing import Any, Dict, Iterator, List, Optional, Tuple, TypedDict
+
+import matplotlib.pyplot as plt
+import numpy as np
+from torch.utils.tensorboard import SummaryWriter
+
+plt.style.use(
+ "https://raw.githubusercontent.com/dereckpiche/DedeStyle/refs/heads/main/dedestyle.mplstyle"
+)
+
+import wandb
+
+from . import wandb_utils
+
+
+class StatPack:
+ def __init__(self):
+ self.data = {}
+
+ def add_stat(self, key: str, value: float | int | None):
+ assert (
+ isinstance(value, float) or isinstance(value, int) or value is None
+ ), f"Value {value} is not a valid type"
+ if key not in self.data:
+ self.data[key] = []
+ self.data[key].append(value)
+
+ def add_stats(self, other: "StatPack"):
+ for key in other.keys():
+ self.add_stat(key, other[key])
+
+ def __getitem__(self, key: str):
+ return self.data[key]
+
+ def __setitem__(self, key: str, value: Any):
+ self.data[key] = value
+
+ def __contains__(self, key: str):
+ return key in self.data
+
+ def __len__(self):
+ return len(self.data)
+
+ def __iter__(self):
+ return iter(self.data)
+
+ def keys(self):
+ return self.data.keys()
+
+ def values(self):
+ return self.data.values()
+
+ def items(self):
+ return self.data.items()
+
+ def mean(self):
+ mean_st = StatPack()
+ for key in self.keys():
+ if isinstance(self[key], list):
+ # TODO: exclude None values
+ non_none_values = [v for v in self[key] if v is not None]
+ if non_none_values:
+ mean_st[key] = np.mean(np.array(non_none_values))
+ else:
+ mean_st[key] = None
+ return mean_st
+
+ def store_plots(self, folder: str):
+ os.makedirs(folder, exist_ok=True)
+ for key in self.keys():
+ plt.figure(figsize=(10, 5))
+ plt.plot(self[key])
+ plt.title(key)
+ plt.savefig(os.path.join(folder, f"{key}.pdf"))
+ plt.close()
+
+ def store_numpy(self, folder: str):
+ os.makedirs(folder, exist_ok=True)
+ for key in self.keys():
+ # Sanitize filename components (avoid slashes, spaces, etc.)
+ safe_key = str(key).replace(os.sep, "_").replace("/", "_").replace(" ", "_")
+ values = self[key]
+ # Convert None to NaN for numpy compatibility
+ arr = np.array(
+ [(np.nan if (v is None) else v) for v in values], dtype=float
+ )
+ np.save(os.path.join(folder, f"{safe_key}.npy"), arr)
+
+ def store_json(self, folder: str, filename: str = "stats.json"):
+ os.makedirs(folder, exist_ok=True)
+ with open(os.path.join(folder, filename), "w") as f:
+ json.dump(self.data, f, indent=4)
+
+ def store_csv(self, folder: str):
+ os.makedirs(folder, exist_ok=True)
+ for key in self.keys():
+ with open(os.path.join(folder, f"stats.csv"), "w") as f:
+ writer = csv.writer(f)
+ writer.writerow([key] + self[key])
+
+ def store_pickle(self, folder: str):
+ os.makedirs(folder, exist_ok=True)
+ for key in self.keys():
+ with open(os.path.join(folder, f"stats.pkl"), "wb") as f:
+ pickle.dump(self[key], f)
diff --git a/src_code_for_reproducibility/utils/update_start_epoch.py b/src_code_for_reproducibility/utils/update_start_epoch.py
new file mode 100644
index 0000000000000000000000000000000000000000..036ddce31b12e7a6547c5099dd37962a88055643
--- /dev/null
+++ b/src_code_for_reproducibility/utils/update_start_epoch.py
@@ -0,0 +1,9 @@
+import os
+
+# During run, set hydra.run.dir=./outputs/{folder}
+def update_start_epoch(cfg, output_directory):
+ if cfg["experiment"]["resume_experiment"]:
+ folders = [f for f in os.listdir(output_directory) if f.startswith("iteration_")]
+ iterations = [int(f.split("_")[1]) for f in folders] if folders else [0]
+ cfg["experiment"]["start_epoch"] = max(iterations)
+ return None
diff --git a/src_code_for_reproducibility/utils/wandb_utils.py b/src_code_for_reproducibility/utils/wandb_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..3e5d83ed1a5304f208288f78457582b0acdb58c4
--- /dev/null
+++ b/src_code_for_reproducibility/utils/wandb_utils.py
@@ -0,0 +1,164 @@
+import os
+from typing import Any, Dict, Optional
+
+
+_WANDB_AVAILABLE = False
+_WANDB_RUN = None
+
+
+def _try_import_wandb():
+ global _WANDB_AVAILABLE
+ if _WANDB_AVAILABLE:
+ return True
+ try:
+ import wandb # type: ignore
+
+ _WANDB_AVAILABLE = True
+ return True
+ except Exception:
+ _WANDB_AVAILABLE = False
+ return False
+
+
+def _safe_get(cfg: Dict[str, Any], path: list[str], default: Any = None) -> Any:
+ cur: Any = cfg
+ for key in path:
+ if not isinstance(cur, dict) or key not in cur:
+ return default
+ cur = cur[key]
+ return cur
+
+
+def is_enabled(cfg: Dict[str, Any]) -> bool:
+ return bool(_safe_get(cfg, ["logging", "wandb", "enabled"], False))
+
+
+def init(cfg: Dict[str, Any], run_dir: str, run_name: Optional[str] = None) -> None:
+ """
+ Initialize Weights & Biases if enabled in config. No-op if disabled or wandb not installed.
+ """
+ global _WANDB_RUN
+ if not is_enabled(cfg):
+ return
+ if not _try_import_wandb():
+ return
+
+ import wandb # type: ignore
+
+ project = _safe_get(cfg, ["logging", "wandb", "project"], "llm-negotiation")
+ entity = _safe_get(cfg, ["logging", "wandb", "entity"], None)
+ mode = _safe_get(cfg, ["logging", "wandb", "mode"], "online")
+ tags = _safe_get(cfg, ["logging", "wandb", "tags"], []) or []
+ notes = _safe_get(cfg, ["logging", "wandb", "notes"], None)
+ group = _safe_get(cfg, ["logging", "wandb", "group"], None)
+ name = _safe_get(cfg, ["logging", "wandb", "name"], run_name)
+
+ # Ensure files are written into the hydra run directory
+ os.makedirs(run_dir, exist_ok=True)
+ os.environ.setdefault("WANDB_DIR", run_dir)
+
+ # Convert cfg to plain types for W&B config; fallback to minimal dictionary
+ try:
+ from omegaconf import OmegaConf # type: ignore
+
+ cfg_container = OmegaConf.to_container(cfg, resolve=True) # type: ignore
+ except Exception:
+ cfg_container = cfg
+
+ _WANDB_RUN = wandb.init(
+ project=project,
+ entity=entity,
+ mode=mode,
+ name=name,
+ group=group,
+ tags=tags,
+ notes=notes,
+ config=cfg_container,
+ dir=run_dir,
+ reinit=True,
+ )
+
+
+def log(metrics: Dict[str, Any], step: Optional[int] = None) -> None:
+ """Log a flat dictionary of metrics to W&B if active."""
+ if not _WANDB_AVAILABLE or _WANDB_RUN is None:
+ return
+ try:
+ import wandb # type: ignore
+
+ wandb.log(metrics if step is None else dict(metrics, step=step))
+ except Exception:
+ pass
+
+
+def _flatten(prefix: str, data: Dict[str, Any], out: Dict[str, Any]) -> None:
+ for k, v in data.items():
+ key = f"{prefix}.{k}" if prefix else k
+ if isinstance(v, dict):
+ _flatten(key, v, out)
+ else:
+ out[key] = v
+
+
+def _summarize_value(value: Any) -> Dict[str, Any]:
+ import numpy as np # local import to avoid hard dependency during disabled mode
+
+ if value is None:
+ return {"none": 1}
+ # Scalars
+ if isinstance(value, (int, float)):
+ return {"value": float(value)}
+ # Lists or arrays
+ try:
+ arr = np.asarray(value)
+ if arr.size == 0:
+ return {"size": 0}
+ return {
+ "mean": float(np.nanmean(arr)),
+ "min": float(np.nanmin(arr)),
+ "max": float(np.nanmax(arr)),
+ "last": float(arr.reshape(-1)[-1]),
+ "size": int(arr.size),
+ }
+ except Exception:
+ # Fallback: string repr
+ return {"text": str(value)}
+
+
+def log_tally(array_tally: Dict[str, Any], prefix: str = "", step: Optional[int] = None) -> None:
+ """
+ Flatten and summarize Tally.array_tally and log to WandB.
+ Each leaf list/array is summarized with mean/min/max/last/size.
+ """
+ if not _WANDB_AVAILABLE or _WANDB_RUN is None:
+ return
+ summarized: Dict[str, Any] = {}
+
+ def walk(node: Any, path: list[str]):
+ if isinstance(node, dict):
+ for k, v in node.items():
+ walk(v, path + [k])
+ return
+ # node is a list of values accumulated over time
+ key = ".".join([p for p in ([prefix] if prefix else []) + path])
+ try:
+ summary = _summarize_value(node)
+ for sk, sv in summary.items():
+ summarized[f"{key}.{sk}"] = sv
+ except Exception:
+ summarized[f"{key}.error"] = 1
+
+ walk(array_tally, [])
+ if summarized:
+ log(summarized, step=step)
+
+
+def log_flat_stats(stats: Dict[str, Any], prefix: str = "", step: Optional[int] = None) -> None:
+ if not _WANDB_AVAILABLE or _WANDB_RUN is None:
+ return
+ flat: Dict[str, Any] = {}
+ _flatten(prefix, stats, flat)
+ if flat:
+ log(flat, step=step)
+
+