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.
Files changed (46) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/README.md +13 -2
  3. package/commands/annotate.md +23 -0
  4. package/commands/archive.md +23 -0
  5. package/commands/cite.md +23 -0
  6. package/commands/flashback.md +22 -0
  7. package/commands/merge.md +24 -0
  8. package/commands/present.md +23 -0
  9. package/commands/prune.md +26 -0
  10. package/commands/quantize.md +24 -0
  11. package/commands/replay.md +23 -0
  12. package/commands/search.md +22 -0
  13. package/commands/surgery.md +27 -0
  14. package/commands/template.md +22 -0
  15. package/commands/trend.md +21 -0
  16. package/commands/turing.md +22 -0
  17. package/package.json +1 -1
  18. package/src/install.js +2 -0
  19. package/src/verify.js +11 -0
  20. package/templates/scripts/__pycache__/architecture_surgery.cpython-314.pyc +0 -0
  21. package/templates/scripts/__pycache__/experiment_annotations.cpython-314.pyc +0 -0
  22. package/templates/scripts/__pycache__/experiment_archive.cpython-314.pyc +0 -0
  23. package/templates/scripts/__pycache__/experiment_replay.cpython-314.pyc +0 -0
  24. package/templates/scripts/__pycache__/experiment_search.cpython-314.pyc +0 -0
  25. package/templates/scripts/__pycache__/experiment_templates.cpython-314.pyc +0 -0
  26. package/templates/scripts/__pycache__/model_merger.cpython-314.pyc +0 -0
  27. package/templates/scripts/__pycache__/model_pruning.cpython-314.pyc +0 -0
  28. package/templates/scripts/__pycache__/model_quantization.cpython-314.pyc +0 -0
  29. package/templates/scripts/__pycache__/scaffold.cpython-314.pyc +0 -0
  30. package/templates/scripts/__pycache__/session_flashback.cpython-314.pyc +0 -0
  31. package/templates/scripts/__pycache__/trend_analysis.cpython-314.pyc +0 -0
  32. package/templates/scripts/architecture_surgery.py +238 -0
  33. package/templates/scripts/citation_manager.py +436 -0
  34. package/templates/scripts/experiment_annotations.py +392 -0
  35. package/templates/scripts/experiment_archive.py +534 -0
  36. package/templates/scripts/experiment_replay.py +592 -0
  37. package/templates/scripts/experiment_search.py +451 -0
  38. package/templates/scripts/experiment_templates.py +501 -0
  39. package/templates/scripts/generate_changelog.py +464 -0
  40. package/templates/scripts/generate_figures.py +597 -0
  41. package/templates/scripts/model_merger.py +277 -0
  42. package/templates/scripts/model_pruning.py +182 -0
  43. package/templates/scripts/model_quantization.py +177 -0
  44. package/templates/scripts/scaffold.py +20 -0
  45. package/templates/scripts/session_flashback.py +461 -0
  46. 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()