Skip to content

Commit f88721c

Browse files
authored
Merge pull request #245 from llmsresearch/feat/multi-candidate
feat: multi-candidate generation with --num-candidates
2 parents d104b09 + 5909287 commit f88721c

7 files changed

Lines changed: 869 additions & 195 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,7 @@ paperbanana generate \
263263
| `--caption` | `-c` | Figure caption / communicative intent (required for new runs) |
264264
| `--output` | `-o` | Output image path (default: auto-generated in `outputs/`) |
265265
| `--iterations` | `-n` | Number of Visualizer-Critic refinement rounds (default: 3) |
266+
| `--num-candidates` | `-k` | Generate N candidate images in parallel, 1-8 (default: 1). Planning runs once; refinement fans out per candidate with seed offsets. Outputs land in `candidates/cand_<i>/`; the run-root `final_output` is candidate 1. Cost estimates and `--budget` account for the fan-out |
266267
| `--auto` | | Loop until critic is satisfied (with `--max-iterations` safety cap) |
267268
| `--max-iterations` | | Safety cap for `--auto` mode (default: 30) |
268269
| `--optimize` | | Preprocess inputs with parallel context enrichment and caption sharpening |

paperbanana/cli.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,17 @@ def generate(
314314
"--budget",
315315
help="Budget cap in USD; pipeline aborts gracefully when exceeded",
316316
),
317+
num_candidates: Optional[int] = typer.Option(
318+
None,
319+
"--num-candidates",
320+
"-k",
321+
min=1,
322+
max=8,
323+
help=(
324+
"Generate N candidate images in parallel (1-8). Planning runs once; "
325+
"the Visualizer-Critic refinement fans out per candidate"
326+
),
327+
),
317328
dry_run: bool = typer.Option(
318329
False,
319330
"--dry-run",
@@ -514,6 +525,8 @@ def generate(
514525
overrides["seed"] = seed
515526
if budget is not None:
516527
overrides["budget_usd"] = budget
528+
if num_candidates is not None:
529+
overrides["num_candidates"] = num_candidates
517530
if venue:
518531
overrides["venue"] = venue
519532
if vector_export is not None:
@@ -728,6 +741,10 @@ async def _run_continue():
728741
f"Image: {settings.image_provider} / {settings.effective_image_model}",
729742
f"Iterations: {iter_est}",
730743
f"Optimize: {'yes' if settings.optimize_inputs else 'no'}",
744+
]
745+
if settings.num_candidates > 1:
746+
lines.append(f"Candidates: {settings.num_candidates} (parallel)")
747+
lines += [
731748
"",
732749
f"Estimated VLM calls: {estimate['vlm_calls']}",
733750
f"Estimated image calls: {estimate['image_calls']}",
@@ -765,13 +782,16 @@ async def _run_continue():
765782
else:
766783
iter_label = str(settings.refinement_iterations)
767784

785+
candidates_label = (
786+
f"\nCandidates: {settings.num_candidates} (parallel)" if settings.num_candidates > 1 else ""
787+
)
768788
if not progress_json:
769789
console.print(
770790
Panel.fit(
771791
f"[bold]PaperBanana[/bold] - Generating Methodology Diagram\n\n"
772792
f"VLM: {settings.vlm_provider} / {settings.effective_vlm_model}\n"
773793
f"Image: {settings.image_provider} / {settings.effective_image_model}\n"
774-
f"Iterations: {iter_label}",
794+
f"Iterations: {iter_label}{candidates_label}",
775795
border_style="blue",
776796
)
777797
)
@@ -856,16 +876,19 @@ def on_progress(event: PipelineProgressEvent) -> None:
856876
else " [green]✓[/green]"
857877
)
858878
elif event.stage == PipelineProgressStage.VISUALIZER_START:
859-
if event.iteration == 1:
860-
console.print("[bold]Phase 2[/bold] — Iterative Refinement")
861879
extra = event.extra or {}
880+
candidate = extra.get("candidate")
881+
if event.iteration == 1 and candidate in (None, 1):
882+
console.print("[bold]Phase 2[/bold] — Iterative Refinement")
862883
total = extra.get("total_iterations", 0)
863884
if event.iteration and total:
864885
label = f"{event.iteration}/{total}"
865886
else:
866887
label = str(event.iteration or "")
867888
if settings.auto_refine:
868889
label += " (auto)"
890+
if candidate is not None:
891+
label = f"cand {candidate} · {label}"
869892
console.print(f" [dim]●[/dim] Generating image [{label}]...", end="")
870893
elif event.stage == PipelineProgressStage.VISUALIZER_END:
871894
console.print(
@@ -927,6 +950,14 @@ def on_progress(event: PipelineProgressEvent) -> None:
927950
f" · {len(result.iterations)} iterations[/dim]\n"
928951
)
929952
console.print(f" Output: [bold]{result.image_path}[/bold]")
953+
for cand in result.metadata.get("candidates") or []:
954+
if cand.get("error"):
955+
console.print(
956+
f" Candidate {cand['index']}: [yellow]failed[/yellow] "
957+
f"[dim]{str(cand['error'])[:120]}[/dim]"
958+
)
959+
elif cand.get("image_path"):
960+
console.print(f" Candidate {cand['index']}: {cand['image_path']}")
930961
if result.tikz_path:
931962
console.print(f" TikZ: [bold]{result.tikz_path}[/bold]")
932963
elif settings.export_tikz:

paperbanana/core/config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ class Settings(BaseSettings):
8282
exemplar_retrieval_max_retries: int = 2
8383
venue: Venue = "neurips"
8484
vector_export: VectorExportMode = "none"
85+
num_candidates: int = Field(
86+
default=1,
87+
ge=1,
88+
le=8,
89+
description="Number of parallel Phase-2 candidate branches (1-8)",
90+
)
8591

8692
# Reference settings
8793
reference_set_path: str = "data/reference_sets"
@@ -309,6 +315,7 @@ def _flatten_yaml(config: dict, prefix: str = "") -> dict:
309315
"pipeline.optimize_inputs": "optimize_inputs",
310316
"pipeline.output_resolution": "output_resolution",
311317
"pipeline.seed": "seed",
318+
"pipeline.num_candidates": "num_candidates",
312319
"pipeline.exemplar_retrieval_enabled": "exemplar_retrieval_enabled",
313320
"pipeline.exemplar_retrieval_endpoint": "exemplar_retrieval_endpoint",
314321
"pipeline.exemplar_retrieval_mode": "exemplar_retrieval_mode",

paperbanana/core/cost_estimator.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ def estimate_cost(
4141
else:
4242
iterations = settings.refinement_iterations
4343

44+
num_candidates = max(1, getattr(settings, "num_candidates", 1))
45+
4446
# Count expected API calls
4547
vlm_calls = 0
4648
image_calls = 0
@@ -75,10 +77,12 @@ def _image_cost() -> float:
7577
if diagram_type == DiagramType.METHODOLOGY and ve != "none":
7678
breakdown["structurer"] = _vlm_cost("structurer")
7779

78-
# Phase 2: Iterative refinement
80+
# Phase 2: Iterative refinement. Multi-candidate fan-out runs Phase 2
81+
# once per candidate (Phase 1 planning is shared), so visualizer and
82+
# critic costs scale by num_candidates.
7983
vis_total = 0.0
8084
critic_total = 0.0
81-
for _ in range(iterations):
85+
for _ in range(iterations * num_candidates):
8286
if diagram_type == DiagramType.STATISTICAL_PLOT:
8387
vis_total += _vlm_cost("visualizer_vlm")
8488
else:
@@ -98,11 +102,17 @@ def _image_cost() -> float:
98102
f"Auto-refine: estimated for max {iterations} iterations; "
99103
"actual cost may be lower if critic is satisfied early"
100104
)
105+
if num_candidates > 1:
106+
notes.append(
107+
f"Multi-candidate: visualizer/critic costs scaled by "
108+
f"{num_candidates} parallel candidates"
109+
)
101110

102111
return {
103112
"estimated_total_usd": round(total, 6),
104113
"vlm_calls": vlm_calls,
105114
"image_calls": image_calls,
115+
"num_candidates": num_candidates,
106116
"breakdown_by_agent": {k: round(v, 6) for k, v in breakdown.items()},
107117
"pricing_note": "; ".join(notes) if notes else None,
108118
}

0 commit comments

Comments
 (0)