|
| 1 | +import os |
| 2 | +import json |
| 3 | +import glob |
| 4 | +import logging |
| 5 | +from flask import Flask, render_template_string, jsonify |
| 6 | +from pathlib import Path |
| 7 | + |
| 8 | +app = Flask(__name__) |
| 9 | + |
| 10 | +# HTML template with D3.js for network visualization |
| 11 | +HTML_TEMPLATE = """ |
| 12 | +<!DOCTYPE html> |
| 13 | +<html lang="en"> |
| 14 | +<head> |
| 15 | + <meta charset="UTF-8"> |
| 16 | + <title>OpenEvolve Evolution Visualizer</title> |
| 17 | + <script src="https://d3js.org/d3.v7.min.js"></script> |
| 18 | + <style> |
| 19 | + html, body { height: 100%; margin: 0; padding: 0; } |
| 20 | + body { font-family: Arial, sans-serif; background: #f7f7f7; height: 100vh; width: 100vw; } |
| 21 | + #graph { width: 100vw; height: 100vh; } |
| 22 | + .node circle { stroke: #fff; stroke-width: 2px; } |
| 23 | + .node text { pointer-events: none; font-size: 12px; } |
| 24 | + .link { stroke: #999; stroke-opacity: 0.6; } |
| 25 | + .tooltip { |
| 26 | + position: absolute; |
| 27 | + text-align: left; |
| 28 | + width: 400px; |
| 29 | + padding: 10px; |
| 30 | + font: 12px sans-serif; |
| 31 | + background: #fff; |
| 32 | + border: 1px solid #aaa; |
| 33 | + border-radius: 8px; |
| 34 | + pointer-events: none; |
| 35 | + box-shadow: 2px 2px 8px #aaa; |
| 36 | + z-index: 10; |
| 37 | + } |
| 38 | + pre { background: #f0f0f0; padding: 6px; border-radius: 4px; } |
| 39 | + </style> |
| 40 | +</head> |
| 41 | +<body> |
| 42 | + <h1>OpenEvolve Evolution Visualizer</h1> |
| 43 | + <div id="graph"></div> |
| 44 | + <script> |
| 45 | + let width = window.innerWidth; |
| 46 | + let height = window.innerHeight - document.querySelector('h1').offsetHeight; |
| 47 | +
|
| 48 | + const svg = d3.select("#graph").append("svg") |
| 49 | + .attr("width", width) |
| 50 | + .attr("height", height) |
| 51 | + .call(d3.zoom() |
| 52 | + .scaleExtent([0.1, 10]) |
| 53 | + .on("zoom", (event) => { |
| 54 | + g.attr("transform", event.transform); |
| 55 | + })) |
| 56 | + .on("dblclick.zoom", null); |
| 57 | +
|
| 58 | + const g = svg.append("g"); |
| 59 | +
|
| 60 | + const tooltip = d3.select("body").append("div") |
| 61 | + .attr("class", "tooltip") |
| 62 | + .style("opacity", 0); |
| 63 | +
|
| 64 | + let lastDataStr = null; |
| 65 | + let sticky = false; |
| 66 | +
|
| 67 | + function formatMetrics(metrics) { |
| 68 | + return Object.entries(metrics).map(([k, v]) => `<b>${k}</b>: ${v}`).join('<br>'); |
| 69 | + } |
| 70 | +
|
| 71 | + function showTooltip(event, d) { |
| 72 | + tooltip.transition().duration(200).style("opacity", .95); |
| 73 | + tooltip.html( |
| 74 | + `<b>Program ID:</b> ${d.id}<br>` + |
| 75 | + `<b>Island:</b> ${d.island}<br>` + |
| 76 | + `<b>Generation:</b> ${d.generation}<br>` + |
| 77 | + `<b>Parent ID:</b> ${d.parent_id || 'None'}<br>` + |
| 78 | + `<b>Metrics:</b><br>${formatMetrics(d.metrics)}<br>` + |
| 79 | + `<b>Code:</b><pre>${d.code.replace(/</g, '<')}</pre>` |
| 80 | + ) |
| 81 | + .style("left", (event.pageX + 20) + "px") |
| 82 | + .style("top", (event.pageY - 20) + "px"); |
| 83 | + } |
| 84 | + function showTooltipSticky(event, d) { |
| 85 | + sticky = true; |
| 86 | + showTooltip(event, d); |
| 87 | + } |
| 88 | +
|
| 89 | + function hideTooltip() { |
| 90 | + if (sticky) return; |
| 91 | + tooltip.transition().duration(300).style("opacity", 0); |
| 92 | + } |
| 93 | + function resetTooltip() { |
| 94 | + sticky = false; |
| 95 | + hideTooltip(true); |
| 96 | + } |
| 97 | +
|
| 98 | + function renderGraph(data) { |
| 99 | + g.selectAll("*").remove(); |
| 100 | + const simulation = d3.forceSimulation(data.nodes) |
| 101 | + .force("link", d3.forceLink(data.edges).id(d => d.id).distance(80)) |
| 102 | + .force("charge", d3.forceManyBody().strength(-200)) |
| 103 | + .force("center", d3.forceCenter(width / 2, height / 2)); |
| 104 | +
|
| 105 | + const link = g.append("g") |
| 106 | + .attr("stroke", "#999") |
| 107 | + .attr("stroke-opacity", 0.6) |
| 108 | + .selectAll("line") |
| 109 | + .data(data.edges) |
| 110 | + .enter().append("line") |
| 111 | + .attr("stroke-width", 2); |
| 112 | +
|
| 113 | + const node = g.append("g") |
| 114 | + .attr("stroke", "#fff") |
| 115 | + .attr("stroke-width", 1.5) |
| 116 | + .selectAll("circle") |
| 117 | + .data(data.nodes) |
| 118 | + .enter().append("circle") |
| 119 | + .attr("r", 16) |
| 120 | + .attr("fill", d => d.island !== undefined ? d3.schemeCategory10[d.island % 10] : "#888") |
| 121 | + .on("mouseover", showTooltip) |
| 122 | + .on("click", showTooltipSticky) |
| 123 | + .on("mouseout", hideTooltip) |
| 124 | + .call(d3.drag() |
| 125 | + .on("start", dragstarted) |
| 126 | + .on("drag", dragged) |
| 127 | + .on("end", dragended)); |
| 128 | +
|
| 129 | + node.append("title").text(d => d.id); |
| 130 | +
|
| 131 | + simulation.on("tick", () => { |
| 132 | + link |
| 133 | + .attr("x1", d => d.source.x) |
| 134 | + .attr("y1", d => d.source.y) |
| 135 | + .attr("x2", d => d.target.x) |
| 136 | + .attr("y2", d => d.target.y); |
| 137 | + node |
| 138 | + .attr("cx", d => d.x) |
| 139 | + .attr("cy", d => d.y); |
| 140 | + }); |
| 141 | +
|
| 142 | + function dragstarted(event, d) { |
| 143 | + if (!event.active) simulation.alphaTarget(0.3).restart(); |
| 144 | + d.fx = d.x; |
| 145 | + d.fy = d.y; |
| 146 | + } |
| 147 | + function dragged(event, d) { |
| 148 | + d.fx = event.x; |
| 149 | + d.fy = event.y; |
| 150 | + } |
| 151 | + function dragended(event, d) { |
| 152 | + if (!event.active) simulation.alphaTarget(0); |
| 153 | + d.fx = null; |
| 154 | + d.fy = null; |
| 155 | + } |
| 156 | + } |
| 157 | +
|
| 158 | + // Add background click handler to reset tooltip |
| 159 | + svg.on("click", function(event) { |
| 160 | + // Only reset if the click target is the SVG itself (not a node) |
| 161 | + if (event.target === this) { |
| 162 | + resetTooltip(); |
| 163 | + } |
| 164 | + }); |
| 165 | +
|
| 166 | + function fetchAndRender() { |
| 167 | + fetch('/data') |
| 168 | + .then(resp => resp.json()) |
| 169 | + .then(data => { |
| 170 | + const dataStr = JSON.stringify(data); |
| 171 | + if (dataStr !== lastDataStr) { |
| 172 | + renderGraph(data); |
| 173 | + lastDataStr = dataStr; |
| 174 | + } |
| 175 | + }); |
| 176 | + } |
| 177 | + fetchAndRender(); |
| 178 | + setInterval(fetchAndRender, 2000); // Live update every 2s |
| 179 | +
|
| 180 | + // Responsive resize |
| 181 | + function resize() { |
| 182 | + width = window.innerWidth; |
| 183 | + height = window.innerHeight - document.querySelector('h1').offsetHeight; |
| 184 | + svg.attr("width", width).attr("height", height); |
| 185 | + fetchAndRender(); |
| 186 | + } |
| 187 | + window.addEventListener('resize', resize); |
| 188 | + </script> |
| 189 | +</body> |
| 190 | +</html> |
| 191 | +""" |
| 192 | + |
| 193 | +logger = logging.getLogger("openevolve.visualizer") |
| 194 | + |
| 195 | +def find_latest_checkpoint(base_folder): |
| 196 | + # Check whether the base folder is itself a checkpoint folder |
| 197 | + if os.path.basename(base_folder).startswith('checkpoint_'): |
| 198 | + return base_folder |
| 199 | + |
| 200 | + checkpoint_folders = glob.glob('**/checkpoint_*', root_dir=base_folder, recursive=True) |
| 201 | + if not checkpoint_folders: |
| 202 | + logger.info(f"No checkpoint folders found in {base_folder}") |
| 203 | + return None |
| 204 | + checkpoint_folders = [os.path.join(base_folder, folder) for folder in checkpoint_folders] |
| 205 | + checkpoint_folders.sort(key=lambda x: os.path.getmtime(x), reverse=True) |
| 206 | + logger.debug(f"Found checkpoint folder: {checkpoint_folders[0]}") |
| 207 | + return checkpoint_folders[0] |
| 208 | + |
| 209 | +def load_evolution_data(checkpoint_folder): |
| 210 | + meta_path = os.path.join(checkpoint_folder, 'metadata.json') |
| 211 | + programs_dir = os.path.join(checkpoint_folder, 'programs') |
| 212 | + if not os.path.exists(meta_path) or not os.path.exists(programs_dir): |
| 213 | + logger.info(f"Missing metadata.json or programs dir in {checkpoint_folder}") |
| 214 | + return {"nodes": [], "edges": []} |
| 215 | + with open(meta_path) as f: |
| 216 | + meta = json.load(f) |
| 217 | + |
| 218 | + nodes = [] |
| 219 | + id_to_program = {} |
| 220 | + for island_idx, id_list in enumerate(meta.get('islands', [])): |
| 221 | + for pid in id_list: |
| 222 | + prog_path = os.path.join(programs_dir, f"{pid}.json") |
| 223 | + if os.path.exists(prog_path): |
| 224 | + with open(prog_path) as pf: |
| 225 | + prog = json.load(pf) |
| 226 | + prog['island'] = island_idx |
| 227 | + nodes.append(prog) |
| 228 | + id_to_program[pid] = prog |
| 229 | + else: |
| 230 | + logger.debug(f"Program file not found: {prog_path}") |
| 231 | + |
| 232 | + edges = [] |
| 233 | + for prog in nodes: |
| 234 | + parent_id = prog.get('parent_id') |
| 235 | + if parent_id and parent_id in id_to_program: |
| 236 | + edges.append({"source": parent_id, "target": prog['id']}) |
| 237 | + |
| 238 | + logger.info(f"Loaded {len(nodes)} nodes and {len(edges)} edges from {checkpoint_folder}") |
| 239 | + return {"nodes": nodes, "edges": edges} |
| 240 | + |
| 241 | +@app.route('/') |
| 242 | +def index(): |
| 243 | + return render_template_string(HTML_TEMPLATE) |
| 244 | + |
| 245 | +@app.route('/data') |
| 246 | +def data(): |
| 247 | + base_folder = os.environ.get('EVOLVE_OUTPUT', 'examples/') |
| 248 | + checkpoint = find_latest_checkpoint(base_folder) |
| 249 | + if not checkpoint: |
| 250 | + logger.info(f"No checkpoints found in {base_folder}") |
| 251 | + return jsonify({"nodes": [], "edges": []}) |
| 252 | + |
| 253 | + logger.info(f"Loading data from checkpoint: {checkpoint}") |
| 254 | + data = load_evolution_data(checkpoint) |
| 255 | + logger.debug(f"Data: {data}") |
| 256 | + return jsonify(data) |
| 257 | + |
| 258 | +if __name__ == '__main__': |
| 259 | + import argparse |
| 260 | + |
| 261 | + parser = argparse.ArgumentParser(description='OpenEvolve Evolution Visualizer') |
| 262 | + parser.add_argument('--path', type=str, default='examples/', |
| 263 | + help='Path to openevolve_output or checkpoints folder') |
| 264 | + parser.add_argument('--host', type=str, default='127.0.0.1') |
| 265 | + parser.add_argument('--port', type=int, default=8080) |
| 266 | + parser.add_argument('--log-level', type=str, default='INFO', |
| 267 | + help='Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)') |
| 268 | + args = parser.parse_args() |
| 269 | + |
| 270 | + log_level = getattr(logging, args.log_level.upper(), logging.INFO) |
| 271 | + logging.basicConfig( |
| 272 | + level=log_level, |
| 273 | + format='[%(asctime)s] %(levelname)s %(name)s: %(message)s' |
| 274 | + ) |
| 275 | + |
| 276 | + os.environ['EVOLVE_OUTPUT'] = args.path |
| 277 | + logger.info(f"Starting server at http://{args.host}:{args.port} with log level {args.log_level.upper()}") |
| 278 | + app.run(host=args.host, port=args.port, debug=True) |
0 commit comments