Skip to content

Commit 6d951b0

Browse files
feat: add SearchGraph and Node classes for representing and persisting search tree states
1 parent 547b0b4 commit 6d951b0

1 file changed

Lines changed: 146 additions & 0 deletions

File tree

MaxKernel/auto_search/graph.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import json
2+
import logging
3+
import os
4+
from typing import Any, Dict, Optional
5+
6+
from pydantic import BaseModel, Field
7+
8+
logger = logging.getLogger(__name__)
9+
10+
11+
class EvaluationResult(BaseModel):
12+
compiled: bool = False
13+
correct: bool = False
14+
latency_ms: Optional[float] = None
15+
profiling_summary: Optional[str] = None
16+
compilation_error: Optional[str] = None
17+
test_error: Optional[str] = None
18+
19+
20+
class Node(BaseModel):
21+
node_id: str
22+
parent_id: Optional[str]
23+
session_id: str
24+
code: str
25+
plan: str
26+
depth: int
27+
strategy_applied: Optional[str] = None
28+
execution_status: str = "FAIL" # SUCCESS, FAIL
29+
execution_error: Optional[str] = None
30+
evaluation: EvaluationResult = Field(default_factory=EvaluationResult)
31+
32+
@property
33+
def is_valid_candidate(self) -> bool:
34+
return (
35+
self.execution_status == "SUCCESS"
36+
and self.evaluation.compiled
37+
and self.evaluation.correct
38+
)
39+
40+
41+
class SearchGraph:
42+
def __init__(self, problem_id: str, graph_db_path: Optional[str] = None):
43+
self.problem_id = problem_id
44+
self.graph_db_path = graph_db_path
45+
self.nodes: Dict[str, Node] = {}
46+
self.root_id: Optional[str] = None
47+
self.best_node_id: Optional[str] = None
48+
self.metadata: Dict[str, Any] = {} # Stores orchestrator state
49+
logger.info(f"Initializing SearchGraph for problem: {problem_id}")
50+
51+
if self.graph_db_path and os.path.exists(self.graph_db_path):
52+
self.load()
53+
54+
def add_node(self, node: Node) -> None:
55+
self.nodes[node.node_id] = node
56+
logger.info(
57+
f"Added node {node.node_id} (parent: {node.parent_id}, "
58+
f"depth: {node.depth}, status: {node.execution_status}) to graph."
59+
)
60+
61+
if node.parent_id is None:
62+
self.root_id = node.node_id
63+
64+
self._update_best_node(node)
65+
self.save()
66+
67+
def get_node(self, node_id: str) -> Optional[Node]:
68+
"""Retrieves a node by its id. Returns None if not found."""
69+
if node_id not in self.nodes:
70+
logger.warning(f"Node {node_id} not found in graph.")
71+
return None
72+
return self.nodes[node_id]
73+
74+
def _update_best_node(self, node: Node) -> None:
75+
if not node.is_valid_candidate:
76+
logger.warning(f"Node {node.node_id} is not a valid candidate. Skip.")
77+
return
78+
79+
latency = node.evaluation.latency_ms
80+
if latency is None:
81+
logger.warning(f"Node {node.node_id} has no latency.")
82+
return
83+
84+
if self.best_node_id is None:
85+
self.best_node_id = node.node_id
86+
logger.info(
87+
f"Set initial best node: {node.node_id} (latency: {latency} ms)"
88+
)
89+
return
90+
91+
best_node = self.nodes[self.best_node_id]
92+
best_latency = best_node.evaluation.latency_ms
93+
if best_latency is None or latency < best_latency:
94+
old_best_id = self.best_node_id
95+
old_latency = best_latency
96+
self.best_node_id = node.node_id
97+
logger.info(
98+
f"New best node found: {node.node_id} ({latency} ms). "
99+
f"Previous best: {old_best_id} ({old_latency} ms)."
100+
)
101+
102+
def save(self) -> None:
103+
if not self.graph_db_path:
104+
return
105+
logger.info(f"Saving search graph to {self.graph_db_path}...")
106+
107+
dir_name = os.path.dirname(self.graph_db_path)
108+
if dir_name:
109+
os.makedirs(dir_name, exist_ok=True)
110+
111+
data = {
112+
"problem_id": self.problem_id,
113+
"root_id": self.root_id,
114+
"best_node_id": self.best_node_id,
115+
"nodes": {nid: node.model_dump() for nid, node in self.nodes.items()},
116+
"metadata": self.metadata,
117+
}
118+
119+
# Atomic write for the graph DB
120+
tmp_path = self.graph_db_path + ".tmp"
121+
try:
122+
with open(tmp_path, "w") as f:
123+
json.dump(data, f, indent=2)
124+
os.replace(tmp_path, self.graph_db_path)
125+
except Exception as e:
126+
logger.error(f"Failed to save search graph atomically: {e}")
127+
if os.path.exists(tmp_path):
128+
try:
129+
os.remove(tmp_path)
130+
except OSError:
131+
pass
132+
133+
def load(self) -> None:
134+
logger.info(f"Loading search graph from {self.graph_db_path}...")
135+
with open(self.graph_db_path, "r") as f:
136+
data = json.load(f)
137+
self.problem_id = data["problem_id"]
138+
self.root_id = data["root_id"]
139+
self.best_node_id = data["best_node_id"]
140+
self.metadata = data.get("metadata", {})
141+
self.nodes = {
142+
nid: Node.model_validate(ndat) for nid, ndat in data["nodes"].items()
143+
}
144+
logger.info(
145+
f"Successfully loaded search graph with {len(self.nodes)} nodes."
146+
)

0 commit comments

Comments
 (0)