# Plan: Fast Default Optimization with Progress Reporting

## Context
Sequential optimization with many fields is slow because it runs a full DSPy `.compile()` per field — N fields + 2 prompts = N+2 optimizer runs. For a 10-field model, this can take 5-15 min. There's also a bug: `num_threads` is only passed to MIPROv2, not to BootstrapFewShot (the auto-selected default for 3-19 examples). Progress is print-only — there's no way to capture it programmatically. The user wants a fast default and a way to observe/return per-field progress.

## What Needs to Change

### 1. New `FieldOptimizationProgress` dataclass — `src/dspydantic/types.py`

Add after `OptimizationResult` (line 38):

```python
@dataclass
class FieldOptimizationProgress:
    phase: str           # "baseline" | "fields" | "system_prompt" | "instruction_prompt" | "complete"
    score_before: float
    score_after: float
    improved: bool
    total_fields: int
    field_path: str | None = None    # None for non-field phases
    field_index: int | None = None   # 1-based, None for non-field phases
    elapsed_seconds: float = 0.0
```

Export in `src/dspydantic/__init__.py`: add to import from `dspydantic.types` and to `__all__`.

---

### 2. `fast` parameter + `on_progress` callback — `src/dspydantic/optimizer.py`

#### A. Module-level constants (near top, after imports)

```python
_FAST_MODE_KWARGS: dict[str, dict] = {
    "bootstrapfewshot": {"max_bootstrapped_demos": 1},
    "bootstrapfewshotwithrandomsearch": {"max_bootstrapped_demos": 1, "num_candidates": 4},
    "miprov2": {"auto": "light", "max_bootstrapped_demos": 1},
    "miprov2zeroshot": {"auto": "light"},
}
_FAST_MODE_FIELD_THRESHOLD = 8  # switch to single-pass when field count exceeds this
```

#### B. `PydanticOptimizer.__init__` — add parameters after `sequential: bool = True` (~line 195)

```python
fast: bool = False,
on_progress: Callable[[FieldOptimizationProgress], None] | None = None,
```

In the body, after `self.sequential = sequential`:
```python
self.fast = fast
self.on_progress = on_progress

if self.fast and self.custom_optimizer is None:
    fast_defaults = _FAST_MODE_KWARGS.get(self.optimizer_type, {})
    self.optimizer_kwargs = {**fast_defaults, **self.optimizer_kwargs}  # user kwargs win
    if len(self.field_descriptions) > _FAST_MODE_FIELD_THRESHOLD and self.sequential:
        self.sequential = False
        if self.verbose:
            print(f"[fast mode] {len(self.field_descriptions)} fields > {_FAST_MODE_FIELD_THRESHOLD}, switching to single-pass.")
```

#### C. Fix `num_threads` bug in `_create_teleprompter` (~line 834)

Currently `BootstrapFewShot` gets `{"metric": metric}` only. Fix:

```python
default_kwargs = {"metric": metric}
# Fix: pass num_threads to all optimizers that support it
if "num_threads" in inspect.signature(optimizer_class.__init__).parameters:
    default_kwargs["num_threads"] = self.num_threads
if self.optimizer_type == "bootstrapfewshot" and len(self.examples) < 5:
    default_kwargs["max_bootstrapped_demos"] = max(1, min(2, len(self.examples) - 1))
return optimizer_class(**{**default_kwargs, **self.optimizer_kwargs})
```

`inspect` is already imported at the top of `optimizer.py`.

#### D. Add progress emission to `_optimize_sequential` (~line 1087)

At the start of `_optimize_sequential`, after the baseline is computed:
```python
import time
_t0 = time.perf_counter()

def _emit(phase, score_before, score_after, field_path=None, field_index=None):
    if self.on_progress is None:
        return
    try:
        self.on_progress(FieldOptimizationProgress(
            phase=phase,
            score_before=score_before,
            score_after=score_after,
            improved=score_after > score_before,
            total_fields=total_fields,
            field_path=field_path,
            field_index=field_index,
            elapsed_seconds=time.perf_counter() - _t0,
        ))
    except Exception:
        pass  # Never abort optimization due to callback error
```

Emit at 5 natural points:
1. `_emit("baseline", baseline_avg, baseline_avg)` — after baseline is computed
2. `score_before = current_score` before each `_optimize_single_field` call; `_emit("fields", score_before, new_score, field_path, idx)` after each
3. `_emit("system_prompt", score_before_sp, current_score)` after system prompt optimization
4. `_emit("instruction_prompt", score_before_ip, current_score)` after instruction prompt optimization
5. `_emit("complete", baseline_avg, current_score)` before returning `OptimizationResult`

For single-pass mode (in `optimize()`, non-sequential path): emit `"baseline"` before compile and `"complete"` after scoring.

---

### 3. Surface `fast` and `on_progress` — `src/dspydantic/prompter.py`

In `Prompter.optimize()` (line 254), add to signature after `sequential: bool = True`:
```python
fast: bool = False,
on_progress: Callable[[FieldOptimizationProgress], None] | None = None,
```

Pass through to `PydanticOptimizer(...)` constructor. Import `FieldOptimizationProgress` from `dspydantic.types`.

---

## Files to Modify

| File | Change |
|------|--------|
| `src/dspydantic/types.py` | Add `FieldOptimizationProgress` dataclass |
| `src/dspydantic/__init__.py` | Export `FieldOptimizationProgress` |
| `src/dspydantic/optimizer.py` | `fast`/`on_progress` params, num_threads bug fix, emit calls |
| `src/dspydantic/prompter.py` | Pass-through for `fast` and `on_progress` |

---

## Usage After Change

```python
from dspydantic import Prompter, FieldOptimizationProgress

def show_progress(p: FieldOptimizationProgress):
    if p.phase == "fields":
        print(f"  [{p.field_index}/{p.total_fields}] {p.field_path}: {p.score_before:.0%} → {p.score_after:.0%} {'✓' if p.improved else '–'}")
    elif p.phase == "complete":
        print(f"Done in {p.elapsed_seconds:.1f}s: {p.score_before:.0%} → {p.score_after:.0%}")

result = prompter.optimize(
    examples=examples,
    fast=True,             # max_bootstrapped_demos=1, auto single-pass for >8 fields
    on_progress=show_progress,
)
```

**fast=True effects:**
- `max_bootstrapped_demos=1` (vs DSPy default of ~3-4) — ~3-4x fewer calls per field
- `num_threads` now also passed to BootstrapFewShot (was a bug) — parallel eval within each compile
- Auto single-pass when fields > 8 (saves N+2 compiles → 1 compile)

---

## Verification

1. Run existing tests: `pytest tests/unit/ -v`
2. Run `compare_optimization.py` with `fast=True` and `on_progress=show_progress` to see progress output
3. Confirm `result.api_calls` is lower with `fast=True` than without
4. Confirm callback fires for each field when `sequential=True`
5. Confirm no regressions — existing `optimize()` calls without new params still work
