# PRD: Benchmark Infrastructure (tokens_used + config_overrides write + dry_run)

> Generated via /vibeflow:discover on 2026-03-22
> Updated: 2026-03-22 — breaking change autorizada, LLMProvider retorna LLMResult

## Problem

O time de testes interno quer comparar quantitativamente `multi_pass` vs `single_pass` extraction, mas o SDK não expõe token counts. `duration_ms` não serve como proxy — latência varia com carga da API. Token count é a única métrica estável de custo.

Adicionalmente, o `write()` não suporta `config_overrides` (o `retrieve()` já suporta desde v0.6.11), forçando o benchmark a criar múltiplos clients. E sem dry_run, rodar a mesma mensagem nos dois modos contamina o banco.

## Target Audience

Time interno de testes (Arandu Inspector) rodando benchmark multi_pass vs single_pass. SDK ainda não tem usuários externos — breaking changes são aceitáveis.

## Proposed Solution

1. **`LLMProvider.complete()` retorna `LLMResult`** — Breaking change no protocol. Padrão de mercado (OpenAI, Anthropic, LiteLLM). `LLMResult` tem `text: str` e `usage: TokenUsage | None`. O SDK acumula usage de todas as chamadas LLM e expõe no resultado.

2. **`config_overrides` no `write()`** — Mesma mecânica do `retrieve()`: dict opcional que faz merge com `self._config` pra aquela request.

3. **`dry_run` no `write()`** — Flag que executa extraction + entity resolution + reconciliation mas pula o upsert. Retorna `WriteResult` com facts extraídos e tokens usados, sem persistir nada.

## Success Criteria

- `result.tokens_used.total_tokens > 0` após qualquer `write()` com mensagem não-vazia
- `config_overrides={"extraction_mode": "single_pass"}` no `write()` produz resultado single_pass
- `dry_run=True` no `write()` retorna facts extraídos com `tokens_used` sem criar registros no banco
- `OpenAIProvider.complete()` retorna `LLMResult` com usage populado

## Scope v0

1. **`LLMResult` e `TokenUsage` dataclasses** em `protocols.py`
2. **`LLMProvider.complete()` retorna `LLMResult`** — breaking change
3. **Atualizar `OpenAIProvider`** pra retornar `LLMResult` com usage da API
4. **Atualizar todos os callers** de `llm.complete()` pra usar `result.text`
5. **Acumular usage no pipeline** — write e read pipelines somam `TokenUsage` de todas as chamadas
6. **`tokens_used` no `WriteResult` e `RetrieveResult`**
7. **`config_overrides` no `write()`** — mesma mecânica do `retrieve()`
8. **`dry_run` no `write()`** — pula upsert stage
9. **Atualizar `FakeLLMProvider` nos testes** pra retornar `LLMResult`
10. **Docs EN + PT-BR atualizados** — protocol migration guide

## Anti-scope

- **Token tracking pra `EmbeddingProvider`** — Embedding tokens são baratos e previsíveis. Não vale o esforço.
- **`by_step` token breakdown** — O total é suficiente. Per-step pode vir depois.
- **Novo scheduler ou benchmark runner** — O inspector cria o endpoint deles.
- **Métricas de custo em dólar** — Preços variam por provider. O SDK expõe tokens, o caller calcula custo.
- **Backward compatibility shim** — Sem usuários externos, não precisa de compat layer.

## Technical Context

### LLMProvider protocol (atual)

```python
class LLMProvider(Protocol):
    async def complete(self, messages, temperature, response_format, max_tokens) -> str: ...
```

### LLMProvider protocol (novo)

```python
@dataclass
class TokenUsage:
    input_tokens: int = 0
    output_tokens: int = 0
    total_tokens: int = 0

@dataclass
class LLMResult:
    text: str
    usage: TokenUsage | None = None

class LLMProvider(Protocol):
    async def complete(self, messages, temperature, response_format, max_tokens) -> LLMResult: ...
```

### Impacto nos callers

Todos os `await llm.complete(...)` que hoje recebem `str` precisam mudar pra `result = await llm.complete(...)` e usar `result.text`. Grep rápido: ~15 call sites em extraction, reconciliation, retrieval_agent, reranker, query_expansion, consolidation, etc.

### OpenAIProvider

O OpenAI SDK já retorna `response.usage.prompt_tokens` e `response.usage.completion_tokens`. Basta mapear pro `TokenUsage`.

### Usage accumulation

Cada pipeline cria um `TokenUsage()` acumulador. Cada chamada `llm.complete()` soma `result.usage` no acumulador. No final, o acumulador vai pro `WriteResult.tokens_used` / `RetrieveResult.tokens_used`.

### config_overrides no write()

Mesmo pattern de `retrieve()` — `_apply_config_overrides()` em `client.py`.

### dry_run

O write pipeline em `src/arandu/write/pipeline.py` é chamado por `client.py`. O `client.py` gerencia a transaction. Com dry_run, o pipeline roda extraction + resolution + reconciliation mas pula o upsert stage. O `WriteResult` retorna com `facts_added` populado (o que seria adicionado) mas nada persiste.

## Open Questions

None.
