claude-turing 3.3.0 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +13 -2
- package/commands/annotate.md +23 -0
- package/commands/archive.md +23 -0
- package/commands/cite.md +23 -0
- package/commands/flashback.md +22 -0
- package/commands/merge.md +24 -0
- package/commands/present.md +23 -0
- package/commands/prune.md +26 -0
- package/commands/quantize.md +24 -0
- package/commands/replay.md +23 -0
- package/commands/search.md +22 -0
- package/commands/surgery.md +27 -0
- package/commands/template.md +22 -0
- package/commands/trend.md +21 -0
- package/commands/turing.md +22 -0
- package/package.json +1 -1
- package/src/install.js +2 -0
- package/src/verify.js +11 -0
- package/templates/scripts/__pycache__/architecture_surgery.cpython-314.pyc +0 -0
- package/templates/scripts/__pycache__/experiment_annotations.cpython-314.pyc +0 -0
- package/templates/scripts/__pycache__/experiment_archive.cpython-314.pyc +0 -0
- package/templates/scripts/__pycache__/experiment_replay.cpython-314.pyc +0 -0
- package/templates/scripts/__pycache__/experiment_search.cpython-314.pyc +0 -0
- package/templates/scripts/__pycache__/experiment_templates.cpython-314.pyc +0 -0
- package/templates/scripts/__pycache__/model_merger.cpython-314.pyc +0 -0
- package/templates/scripts/__pycache__/model_pruning.cpython-314.pyc +0 -0
- package/templates/scripts/__pycache__/model_quantization.cpython-314.pyc +0 -0
- package/templates/scripts/__pycache__/scaffold.cpython-314.pyc +0 -0
- package/templates/scripts/__pycache__/session_flashback.cpython-314.pyc +0 -0
- package/templates/scripts/__pycache__/trend_analysis.cpython-314.pyc +0 -0
- package/templates/scripts/architecture_surgery.py +238 -0
- package/templates/scripts/citation_manager.py +436 -0
- package/templates/scripts/experiment_annotations.py +392 -0
- package/templates/scripts/experiment_archive.py +534 -0
- package/templates/scripts/experiment_replay.py +592 -0
- package/templates/scripts/experiment_search.py +451 -0
- package/templates/scripts/experiment_templates.py +501 -0
- package/templates/scripts/generate_changelog.py +464 -0
- package/templates/scripts/generate_figures.py +597 -0
- package/templates/scripts/model_merger.py +277 -0
- package/templates/scripts/model_pruning.py +182 -0
- package/templates/scripts/model_quantization.py +177 -0
- package/templates/scripts/scaffold.py +20 -0
- package/templates/scripts/session_flashback.py +461 -0
- package/templates/scripts/trend_analysis.py +503 -0
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Presentation figure generation for the autoresearch pipeline.
|
|
3
|
+
|
|
4
|
+
Generates structured figure specifications (data + layout config)
|
|
5
|
+
for research presentations and papers. Produces JSON figure specs
|
|
6
|
+
rather than rendered images, since matplotlib may not be available
|
|
7
|
+
in all environments.
|
|
8
|
+
|
|
9
|
+
Supported figure types:
|
|
10
|
+
- training: metric trajectory over experiments
|
|
11
|
+
- comparison: model family comparison bar chart data
|
|
12
|
+
- ablation: ablation table with delta values
|
|
13
|
+
- pareto: accuracy vs latency/size scatter with Pareto frontier
|
|
14
|
+
- sensitivity: hyperparameter sensitivity heatmap data
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
python scripts/generate_figures.py training
|
|
18
|
+
python scripts/generate_figures.py comparison --style dark
|
|
19
|
+
python scripts/generate_figures.py ablation --format json
|
|
20
|
+
python scripts/generate_figures.py pareto
|
|
21
|
+
python scripts/generate_figures.py sensitivity
|
|
22
|
+
python scripts/generate_figures.py --all --style poster
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import json
|
|
29
|
+
import sys
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
import yaml
|
|
34
|
+
|
|
35
|
+
from scripts.turing_io import load_config, load_experiments
|
|
36
|
+
|
|
37
|
+
DEFAULT_LOG_PATH = "experiments/log.jsonl"
|
|
38
|
+
DEFAULT_OUTPUT_DIR = "paper/figures"
|
|
39
|
+
VALID_FIGURE_TYPES = ["training", "comparison", "ablation", "pareto", "sensitivity"]
|
|
40
|
+
|
|
41
|
+
STYLE_PRESETS = {
|
|
42
|
+
"light": {
|
|
43
|
+
"background": "#ffffff",
|
|
44
|
+
"text_color": "#1e293b",
|
|
45
|
+
"grid_color": "#e2e8f0",
|
|
46
|
+
"palette": ["#2563eb", "#16a34a", "#dc2626", "#d97706", "#7c3aed", "#0891b2"],
|
|
47
|
+
"font_size": 12,
|
|
48
|
+
"title_size": 16,
|
|
49
|
+
"line_width": 2,
|
|
50
|
+
"marker_size": 6,
|
|
51
|
+
},
|
|
52
|
+
"dark": {
|
|
53
|
+
"background": "#0f172a",
|
|
54
|
+
"text_color": "#e2e8f0",
|
|
55
|
+
"grid_color": "#334155",
|
|
56
|
+
"palette": ["#60a5fa", "#4ade80", "#f87171", "#fbbf24", "#a78bfa", "#22d3ee"],
|
|
57
|
+
"font_size": 12,
|
|
58
|
+
"title_size": 16,
|
|
59
|
+
"line_width": 2,
|
|
60
|
+
"marker_size": 6,
|
|
61
|
+
},
|
|
62
|
+
"poster": {
|
|
63
|
+
"background": "#ffffff",
|
|
64
|
+
"text_color": "#0f172a",
|
|
65
|
+
"grid_color": "#cbd5e1",
|
|
66
|
+
"palette": ["#1d4ed8", "#15803d", "#b91c1c", "#b45309", "#6d28d9", "#0e7490"],
|
|
67
|
+
"font_size": 18,
|
|
68
|
+
"title_size": 28,
|
|
69
|
+
"line_width": 3,
|
|
70
|
+
"marker_size": 10,
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# --- Figure Generators ---
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def generate_training_figure(
|
|
79
|
+
experiments: list[dict],
|
|
80
|
+
config: dict,
|
|
81
|
+
style: dict,
|
|
82
|
+
) -> dict:
|
|
83
|
+
"""Generate metric trajectory figure specification."""
|
|
84
|
+
metric_name = config.get("evaluation", {}).get("primary_metric", "accuracy")
|
|
85
|
+
lower_is_better = config.get("evaluation", {}).get("lower_is_better", False)
|
|
86
|
+
|
|
87
|
+
data_points = []
|
|
88
|
+
best_so_far = None
|
|
89
|
+
best_envelope = []
|
|
90
|
+
|
|
91
|
+
for exp in experiments:
|
|
92
|
+
val = exp.get("metrics", {}).get(metric_name)
|
|
93
|
+
if val is None or not isinstance(val, (int, float)):
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
if best_so_far is None:
|
|
97
|
+
best_so_far = val
|
|
98
|
+
elif lower_is_better and val < best_so_far:
|
|
99
|
+
best_so_far = val
|
|
100
|
+
elif not lower_is_better and val > best_so_far:
|
|
101
|
+
best_so_far = val
|
|
102
|
+
|
|
103
|
+
data_points.append({
|
|
104
|
+
"x": len(data_points),
|
|
105
|
+
"experiment_id": exp.get("experiment_id", "?"),
|
|
106
|
+
"value": round(val, 6),
|
|
107
|
+
"status": exp.get("status", "unknown"),
|
|
108
|
+
})
|
|
109
|
+
best_envelope.append(round(best_so_far, 6))
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
"type": "training",
|
|
113
|
+
"title": f"{metric_name.replace('_', ' ').title()} Trajectory",
|
|
114
|
+
"x_label": "Experiment Index",
|
|
115
|
+
"y_label": metric_name,
|
|
116
|
+
"style": style,
|
|
117
|
+
"data": {
|
|
118
|
+
"points": data_points,
|
|
119
|
+
"best_envelope": best_envelope,
|
|
120
|
+
},
|
|
121
|
+
"annotations": {
|
|
122
|
+
"total_experiments": len(data_points),
|
|
123
|
+
"final_best": best_so_far,
|
|
124
|
+
"lower_is_better": lower_is_better,
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def generate_comparison_figure(
|
|
130
|
+
experiments: list[dict],
|
|
131
|
+
config: dict,
|
|
132
|
+
style: dict,
|
|
133
|
+
) -> dict:
|
|
134
|
+
"""Generate model family comparison bar chart data."""
|
|
135
|
+
metric_name = config.get("evaluation", {}).get("primary_metric", "accuracy")
|
|
136
|
+
lower_is_better = config.get("evaluation", {}).get("lower_is_better", False)
|
|
137
|
+
|
|
138
|
+
families: dict[str, list[float]] = {}
|
|
139
|
+
for exp in experiments:
|
|
140
|
+
family = exp.get("family") or exp.get("config", {}).get("model_type", "unknown")
|
|
141
|
+
val = exp.get("metrics", {}).get(metric_name)
|
|
142
|
+
if val is not None and isinstance(val, (int, float)):
|
|
143
|
+
families.setdefault(family, []).append(val)
|
|
144
|
+
|
|
145
|
+
bars = []
|
|
146
|
+
for family, values in sorted(families.items()):
|
|
147
|
+
n = len(values)
|
|
148
|
+
mean = sum(values) / n
|
|
149
|
+
sorted_vals = sorted(values)
|
|
150
|
+
median = sorted_vals[n // 2] if n % 2 else (sorted_vals[n // 2 - 1] + sorted_vals[n // 2]) / 2
|
|
151
|
+
best = min(values) if lower_is_better else max(values)
|
|
152
|
+
variance = sum((v - mean) ** 2 for v in values) / n if n > 1 else 0.0
|
|
153
|
+
|
|
154
|
+
bars.append({
|
|
155
|
+
"family": family,
|
|
156
|
+
"mean": round(mean, 6),
|
|
157
|
+
"median": round(median, 6),
|
|
158
|
+
"best": round(best, 6),
|
|
159
|
+
"std": round(variance ** 0.5, 6),
|
|
160
|
+
"n_experiments": n,
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
# Sort by best performance
|
|
164
|
+
bars.sort(key=lambda b: b["best"], reverse=not lower_is_better)
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
"type": "comparison",
|
|
168
|
+
"title": f"Model Family Comparison ({metric_name})",
|
|
169
|
+
"x_label": "Model Family",
|
|
170
|
+
"y_label": metric_name,
|
|
171
|
+
"style": style,
|
|
172
|
+
"data": {"bars": bars},
|
|
173
|
+
"annotations": {
|
|
174
|
+
"n_families": len(bars),
|
|
175
|
+
"metric": metric_name,
|
|
176
|
+
"lower_is_better": lower_is_better,
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def generate_ablation_figure(
|
|
182
|
+
experiments: list[dict],
|
|
183
|
+
config: dict,
|
|
184
|
+
style: dict,
|
|
185
|
+
) -> dict:
|
|
186
|
+
"""Generate ablation table with delta values.
|
|
187
|
+
|
|
188
|
+
Identifies experiments that are ablation variants (share a base
|
|
189
|
+
experiment or have ablation tags) and computes performance deltas.
|
|
190
|
+
"""
|
|
191
|
+
metric_name = config.get("evaluation", {}).get("primary_metric", "accuracy")
|
|
192
|
+
lower_is_better = config.get("evaluation", {}).get("lower_is_better", False)
|
|
193
|
+
|
|
194
|
+
# Load ablation studies if available
|
|
195
|
+
ablation_dir = Path("experiments/ablations")
|
|
196
|
+
ablation_rows = []
|
|
197
|
+
|
|
198
|
+
if ablation_dir.exists():
|
|
199
|
+
for f in sorted(ablation_dir.glob("*-ablation.yaml")):
|
|
200
|
+
try:
|
|
201
|
+
with open(f) as fh:
|
|
202
|
+
study = yaml.safe_load(fh)
|
|
203
|
+
if not study or not isinstance(study, dict):
|
|
204
|
+
continue
|
|
205
|
+
base_val = study.get("baseline_metric")
|
|
206
|
+
for variant in study.get("variants", []):
|
|
207
|
+
var_val = variant.get("metric_value")
|
|
208
|
+
if base_val is not None and var_val is not None:
|
|
209
|
+
delta = var_val - base_val
|
|
210
|
+
ablation_rows.append({
|
|
211
|
+
"experiment_id": study.get("experiment_id", "?"),
|
|
212
|
+
"component": variant.get("removed_component", "?"),
|
|
213
|
+
"baseline": round(base_val, 6),
|
|
214
|
+
"ablated": round(var_val, 6),
|
|
215
|
+
"delta": round(delta, 6),
|
|
216
|
+
"impact": "positive" if (delta > 0) != lower_is_better else "negative",
|
|
217
|
+
})
|
|
218
|
+
except (yaml.YAMLError, OSError):
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
# Fallback: infer from experiment descriptions containing "ablation" or "without"
|
|
222
|
+
if not ablation_rows:
|
|
223
|
+
kept = [e for e in experiments if e.get("status") == "kept"]
|
|
224
|
+
if kept:
|
|
225
|
+
# Use the best kept experiment as baseline
|
|
226
|
+
best_exp = min(kept, key=lambda e: e.get("metrics", {}).get(metric_name, float("inf"))) \
|
|
227
|
+
if lower_is_better else \
|
|
228
|
+
max(kept, key=lambda e: e.get("metrics", {}).get(metric_name, float("-inf")))
|
|
229
|
+
base_val = best_exp.get("metrics", {}).get(metric_name)
|
|
230
|
+
|
|
231
|
+
if base_val is not None:
|
|
232
|
+
for exp in experiments:
|
|
233
|
+
desc = (exp.get("description") or "").lower()
|
|
234
|
+
if "ablat" in desc or "without" in desc or "remove" in desc:
|
|
235
|
+
val = exp.get("metrics", {}).get(metric_name)
|
|
236
|
+
if val is not None:
|
|
237
|
+
delta = val - base_val
|
|
238
|
+
ablation_rows.append({
|
|
239
|
+
"experiment_id": exp.get("experiment_id", "?"),
|
|
240
|
+
"component": exp.get("description", "?")[:60],
|
|
241
|
+
"baseline": round(base_val, 6),
|
|
242
|
+
"ablated": round(val, 6),
|
|
243
|
+
"delta": round(delta, 6),
|
|
244
|
+
"impact": "positive" if (delta > 0) != lower_is_better else "negative",
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
# Sort by absolute delta (biggest impact first)
|
|
248
|
+
ablation_rows.sort(key=lambda r: abs(r["delta"]), reverse=True)
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
"type": "ablation",
|
|
252
|
+
"title": f"Ablation Study ({metric_name})",
|
|
253
|
+
"columns": ["Component", "Baseline", "Ablated", "Delta", "Impact"],
|
|
254
|
+
"style": style,
|
|
255
|
+
"data": {"rows": ablation_rows},
|
|
256
|
+
"annotations": {
|
|
257
|
+
"n_ablations": len(ablation_rows),
|
|
258
|
+
"metric": metric_name,
|
|
259
|
+
"lower_is_better": lower_is_better,
|
|
260
|
+
},
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def generate_pareto_figure(
|
|
265
|
+
experiments: list[dict],
|
|
266
|
+
config: dict,
|
|
267
|
+
style: dict,
|
|
268
|
+
) -> dict:
|
|
269
|
+
"""Generate accuracy vs latency/size scatter with Pareto frontier."""
|
|
270
|
+
metric_name = config.get("evaluation", {}).get("primary_metric", "accuracy")
|
|
271
|
+
lower_is_better = config.get("evaluation", {}).get("lower_is_better", False)
|
|
272
|
+
|
|
273
|
+
# Determine secondary axis (latency or model size)
|
|
274
|
+
points = []
|
|
275
|
+
secondary_label = "latency_seconds"
|
|
276
|
+
|
|
277
|
+
for exp in experiments:
|
|
278
|
+
metrics = exp.get("metrics", {})
|
|
279
|
+
primary = metrics.get(metric_name)
|
|
280
|
+
if primary is None or not isinstance(primary, (int, float)):
|
|
281
|
+
continue
|
|
282
|
+
|
|
283
|
+
# Try latency, then model_size, then training_time
|
|
284
|
+
secondary = None
|
|
285
|
+
for candidate in ["latency_seconds", "latency", "model_size", "training_time_seconds"]:
|
|
286
|
+
secondary = metrics.get(candidate)
|
|
287
|
+
if secondary is not None:
|
|
288
|
+
secondary_label = candidate
|
|
289
|
+
break
|
|
290
|
+
|
|
291
|
+
if secondary is None:
|
|
292
|
+
# Use training time from top-level if available
|
|
293
|
+
secondary = exp.get("training_time")
|
|
294
|
+
if secondary is not None:
|
|
295
|
+
secondary_label = "training_time"
|
|
296
|
+
|
|
297
|
+
if secondary is None:
|
|
298
|
+
continue
|
|
299
|
+
|
|
300
|
+
points.append({
|
|
301
|
+
"experiment_id": exp.get("experiment_id", "?"),
|
|
302
|
+
"primary": round(float(primary), 6),
|
|
303
|
+
"secondary": round(float(secondary), 6),
|
|
304
|
+
"family": exp.get("family") or exp.get("config", {}).get("model_type", "unknown"),
|
|
305
|
+
"status": exp.get("status", "unknown"),
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
# Compute Pareto frontier
|
|
309
|
+
frontier_ids = set()
|
|
310
|
+
if points:
|
|
311
|
+
# Sort by secondary ascending (lower cost is better)
|
|
312
|
+
sorted_pts = sorted(points, key=lambda p: p["secondary"])
|
|
313
|
+
best_primary = float("-inf") if not lower_is_better else float("inf")
|
|
314
|
+
for pt in sorted_pts:
|
|
315
|
+
if lower_is_better:
|
|
316
|
+
if pt["primary"] <= best_primary:
|
|
317
|
+
best_primary = pt["primary"]
|
|
318
|
+
frontier_ids.add(pt["experiment_id"])
|
|
319
|
+
else:
|
|
320
|
+
if pt["primary"] >= best_primary:
|
|
321
|
+
best_primary = pt["primary"]
|
|
322
|
+
frontier_ids.add(pt["experiment_id"])
|
|
323
|
+
|
|
324
|
+
for pt in points:
|
|
325
|
+
pt["on_frontier"] = pt["experiment_id"] in frontier_ids
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
"type": "pareto",
|
|
329
|
+
"title": f"Pareto Frontier: {metric_name} vs {secondary_label}",
|
|
330
|
+
"x_label": secondary_label.replace("_", " ").title(),
|
|
331
|
+
"y_label": metric_name,
|
|
332
|
+
"style": style,
|
|
333
|
+
"data": {
|
|
334
|
+
"points": points,
|
|
335
|
+
"frontier_ids": list(frontier_ids),
|
|
336
|
+
},
|
|
337
|
+
"annotations": {
|
|
338
|
+
"n_points": len(points),
|
|
339
|
+
"n_frontier": len(frontier_ids),
|
|
340
|
+
"metric": metric_name,
|
|
341
|
+
"cost_axis": secondary_label,
|
|
342
|
+
},
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def generate_sensitivity_figure(
|
|
347
|
+
experiments: list[dict],
|
|
348
|
+
config: dict,
|
|
349
|
+
style: dict,
|
|
350
|
+
) -> dict:
|
|
351
|
+
"""Generate hyperparameter sensitivity heatmap data.
|
|
352
|
+
|
|
353
|
+
Extracts hyperparameter values from experiment configs and
|
|
354
|
+
correlates them with the primary metric to build a sensitivity matrix.
|
|
355
|
+
"""
|
|
356
|
+
metric_name = config.get("evaluation", {}).get("primary_metric", "accuracy")
|
|
357
|
+
|
|
358
|
+
# Collect param-value-metric triples
|
|
359
|
+
param_values: dict[str, list[tuple[float, float]]] = {}
|
|
360
|
+
|
|
361
|
+
for exp in experiments:
|
|
362
|
+
val = exp.get("metrics", {}).get(metric_name)
|
|
363
|
+
if val is None or not isinstance(val, (int, float)):
|
|
364
|
+
continue
|
|
365
|
+
|
|
366
|
+
exp_config = exp.get("config", {})
|
|
367
|
+
for param, pval in exp_config.items():
|
|
368
|
+
if isinstance(pval, (int, float)) and param != metric_name:
|
|
369
|
+
param_values.setdefault(param, []).append((float(pval), float(val)))
|
|
370
|
+
|
|
371
|
+
# Compute sensitivity score per parameter (range of metric across param values)
|
|
372
|
+
heatmap_data = []
|
|
373
|
+
for param, pairs in param_values.items():
|
|
374
|
+
if len(pairs) < 2:
|
|
375
|
+
continue
|
|
376
|
+
|
|
377
|
+
metric_vals = [p[1] for p in pairs]
|
|
378
|
+
metric_range = max(metric_vals) - min(metric_vals)
|
|
379
|
+
metric_mean = sum(metric_vals) / len(metric_vals)
|
|
380
|
+
metric_std = (sum((v - metric_mean) ** 2 for v in metric_vals) / len(metric_vals)) ** 0.5
|
|
381
|
+
|
|
382
|
+
# Bin parameter values for heatmap cells
|
|
383
|
+
param_vals = sorted(set(p[0] for p in pairs))
|
|
384
|
+
cells = []
|
|
385
|
+
for pv in param_vals:
|
|
386
|
+
associated = [p[1] for p in pairs if p[0] == pv]
|
|
387
|
+
cells.append({
|
|
388
|
+
"param_value": pv,
|
|
389
|
+
"metric_mean": round(sum(associated) / len(associated), 6),
|
|
390
|
+
"n_experiments": len(associated),
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
heatmap_data.append({
|
|
394
|
+
"parameter": param,
|
|
395
|
+
"sensitivity_score": round(metric_range, 6),
|
|
396
|
+
"metric_std": round(metric_std, 6),
|
|
397
|
+
"n_unique_values": len(param_vals),
|
|
398
|
+
"n_experiments": len(pairs),
|
|
399
|
+
"cells": cells,
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
# Sort by sensitivity score descending
|
|
403
|
+
heatmap_data.sort(key=lambda h: h["sensitivity_score"], reverse=True)
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
"type": "sensitivity",
|
|
407
|
+
"title": f"Hyperparameter Sensitivity ({metric_name})",
|
|
408
|
+
"x_label": "Parameter Value",
|
|
409
|
+
"y_label": "Parameter",
|
|
410
|
+
"style": style,
|
|
411
|
+
"data": {"parameters": heatmap_data},
|
|
412
|
+
"annotations": {
|
|
413
|
+
"n_parameters": len(heatmap_data),
|
|
414
|
+
"most_sensitive": heatmap_data[0]["parameter"] if heatmap_data else None,
|
|
415
|
+
"metric": metric_name,
|
|
416
|
+
},
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# --- Report ---
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
FIGURE_GENERATORS = {
|
|
424
|
+
"training": generate_training_figure,
|
|
425
|
+
"comparison": generate_comparison_figure,
|
|
426
|
+
"ablation": generate_ablation_figure,
|
|
427
|
+
"pareto": generate_pareto_figure,
|
|
428
|
+
"sensitivity": generate_sensitivity_figure,
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def format_figures_report(figures: list[dict]) -> str:
|
|
433
|
+
"""Format figure specifications as a readable summary."""
|
|
434
|
+
lines = [
|
|
435
|
+
f"# Figure Specifications ({len(figures)} figures)",
|
|
436
|
+
"",
|
|
437
|
+
]
|
|
438
|
+
|
|
439
|
+
for fig in figures:
|
|
440
|
+
ftype = fig.get("type", "?")
|
|
441
|
+
title = fig.get("title", "Untitled")
|
|
442
|
+
annotations = fig.get("annotations", {})
|
|
443
|
+
|
|
444
|
+
lines.append(f"## {title}")
|
|
445
|
+
lines.append(f"Type: {ftype}")
|
|
446
|
+
|
|
447
|
+
for key, value in annotations.items():
|
|
448
|
+
lines.append(f" {key}: {value}")
|
|
449
|
+
|
|
450
|
+
data = fig.get("data", {})
|
|
451
|
+
if "points" in data:
|
|
452
|
+
lines.append(f" Data points: {len(data['points'])}")
|
|
453
|
+
if "bars" in data:
|
|
454
|
+
lines.append(f" Bars: {len(data['bars'])}")
|
|
455
|
+
if "rows" in data:
|
|
456
|
+
lines.append(f" Rows: {len(data['rows'])}")
|
|
457
|
+
if "parameters" in data:
|
|
458
|
+
lines.append(f" Parameters: {len(data['parameters'])}")
|
|
459
|
+
|
|
460
|
+
lines.append("")
|
|
461
|
+
|
|
462
|
+
return "\n".join(lines)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def save_figures_report(
|
|
466
|
+
figures: list[dict],
|
|
467
|
+
output_dir: str = DEFAULT_OUTPUT_DIR,
|
|
468
|
+
) -> list[Path]:
|
|
469
|
+
"""Save each figure specification as a JSON file."""
|
|
470
|
+
out = Path(output_dir)
|
|
471
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
472
|
+
|
|
473
|
+
saved = []
|
|
474
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
475
|
+
|
|
476
|
+
for fig in figures:
|
|
477
|
+
ftype = fig.get("type", "unknown")
|
|
478
|
+
filename = f"{ftype}-{timestamp}.json"
|
|
479
|
+
filepath = out / filename
|
|
480
|
+
with open(filepath, "w") as f:
|
|
481
|
+
json.dump(fig, f, indent=2, default=str)
|
|
482
|
+
saved.append(filepath)
|
|
483
|
+
|
|
484
|
+
return saved
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
# --- Orchestration ---
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def run_generate_figures(
|
|
491
|
+
figure_types: list[str],
|
|
492
|
+
style_name: str = "light",
|
|
493
|
+
log_path: str = DEFAULT_LOG_PATH,
|
|
494
|
+
config_path: str = "config.yaml",
|
|
495
|
+
output_dir: str = DEFAULT_OUTPUT_DIR,
|
|
496
|
+
save: bool = True,
|
|
497
|
+
) -> dict:
|
|
498
|
+
"""Generate figure specifications for requested types."""
|
|
499
|
+
timestamp = datetime.now(timezone.utc).isoformat()
|
|
500
|
+
config = load_config(config_path)
|
|
501
|
+
experiments = load_experiments(log_path)
|
|
502
|
+
style = STYLE_PRESETS.get(style_name, STYLE_PRESETS["light"])
|
|
503
|
+
|
|
504
|
+
if not experiments:
|
|
505
|
+
return {"timestamp": timestamp, "error": "No experiments found in log"}
|
|
506
|
+
|
|
507
|
+
figures = []
|
|
508
|
+
errors = []
|
|
509
|
+
for ftype in figure_types:
|
|
510
|
+
generator = FIGURE_GENERATORS.get(ftype)
|
|
511
|
+
if not generator:
|
|
512
|
+
errors.append(f"Unknown figure type: {ftype}")
|
|
513
|
+
continue
|
|
514
|
+
try:
|
|
515
|
+
fig = generator(experiments, config, style)
|
|
516
|
+
fig["generated_at"] = timestamp
|
|
517
|
+
fig["style_name"] = style_name
|
|
518
|
+
figures.append(fig)
|
|
519
|
+
except Exception as e:
|
|
520
|
+
errors.append(f"{ftype}: {e}")
|
|
521
|
+
|
|
522
|
+
saved_paths = []
|
|
523
|
+
if save and figures:
|
|
524
|
+
saved_paths = save_figures_report(figures, output_dir)
|
|
525
|
+
|
|
526
|
+
return {
|
|
527
|
+
"timestamp": timestamp,
|
|
528
|
+
"figures": figures,
|
|
529
|
+
"saved_to": [str(p) for p in saved_paths],
|
|
530
|
+
"errors": errors if errors else None,
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def main() -> None:
|
|
535
|
+
"""CLI entry point."""
|
|
536
|
+
parser = argparse.ArgumentParser(
|
|
537
|
+
description="Generate presentation figure specifications from experiment data",
|
|
538
|
+
)
|
|
539
|
+
parser.add_argument("figure_types", nargs="*", default=[],
|
|
540
|
+
help="Figure types to generate (training, comparison, ablation, pareto, sensitivity)")
|
|
541
|
+
parser.add_argument("--all", action="store_true",
|
|
542
|
+
help="Generate all figure types")
|
|
543
|
+
parser.add_argument("--style", choices=["light", "dark", "poster"], default="light",
|
|
544
|
+
help="Visual style preset")
|
|
545
|
+
parser.add_argument("--format", dest="fmt", choices=["json"], default="json",
|
|
546
|
+
help="Output format")
|
|
547
|
+
parser.add_argument("--output-dir", default=DEFAULT_OUTPUT_DIR,
|
|
548
|
+
help="Output directory for figure specs")
|
|
549
|
+
parser.add_argument("--no-save", action="store_true",
|
|
550
|
+
help="Print to stdout instead of saving files")
|
|
551
|
+
parser.add_argument("--config", default="config.yaml", help="Path to config.yaml")
|
|
552
|
+
parser.add_argument("--log", default=DEFAULT_LOG_PATH, help="Path to experiment log")
|
|
553
|
+
parser.add_argument("--json", action="store_true", help="Output raw JSON")
|
|
554
|
+
args = parser.parse_args()
|
|
555
|
+
|
|
556
|
+
if args.all:
|
|
557
|
+
figure_types = VALID_FIGURE_TYPES
|
|
558
|
+
elif args.figure_types:
|
|
559
|
+
figure_types = args.figure_types
|
|
560
|
+
else:
|
|
561
|
+
print("ERROR: Specify figure types or use --all", file=sys.stderr)
|
|
562
|
+
parser.print_help()
|
|
563
|
+
sys.exit(1)
|
|
564
|
+
|
|
565
|
+
# Validate types
|
|
566
|
+
for ft in figure_types:
|
|
567
|
+
if ft not in VALID_FIGURE_TYPES:
|
|
568
|
+
print(f"ERROR: Unknown figure type '{ft}'. Valid: {VALID_FIGURE_TYPES}",
|
|
569
|
+
file=sys.stderr)
|
|
570
|
+
sys.exit(1)
|
|
571
|
+
|
|
572
|
+
report = run_generate_figures(
|
|
573
|
+
figure_types=figure_types,
|
|
574
|
+
style_name=args.style,
|
|
575
|
+
log_path=args.log,
|
|
576
|
+
config_path=args.config,
|
|
577
|
+
output_dir=args.output_dir,
|
|
578
|
+
save=not args.no_save,
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
if args.json or args.fmt == "json":
|
|
582
|
+
print(json.dumps(report, indent=2, default=str))
|
|
583
|
+
else:
|
|
584
|
+
if "error" in report:
|
|
585
|
+
print(f"ERROR: {report['error']}", file=sys.stderr)
|
|
586
|
+
sys.exit(1)
|
|
587
|
+
figures = report.get("figures", [])
|
|
588
|
+
print(format_figures_report(figures))
|
|
589
|
+
saved = report.get("saved_to", [])
|
|
590
|
+
if saved:
|
|
591
|
+
print(f"Saved {len(saved)} figure(s) to {args.output_dir}/")
|
|
592
|
+
for p in saved:
|
|
593
|
+
print(f" {p}")
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
if __name__ == "__main__":
|
|
597
|
+
main()
|