# Spec: Config Enforcement — Spreading Activation + Reranker Timeout

> Generated via /vibeflow:gen-spec on 2026-03-21
> Budget: ≤ 6 files

## Objective

Fazer as configs `spreading_activation_hops`, `spreading_decay_factor`, `spreading_facts_per_entity` e `reranker_timeout_sec` terem efeito real no pipeline — hoje existem no MemoryConfig mas são parcialmente ou totalmente ignoradas pelo código.

## Context

O time de testes (Arandu Inspector) encontrou 2 gaps na v0.6.7:

**Gap #25 — Spreading activation configs sem efeito:**
- `hops=0` não desabilita spreading — Hop 1 roda incondicionalmente (`spreading_activation.py:165`). Guard `if hops >= 2` só existe no Hop 2 (linha 248).
- `spreading_decay_factor` (config: 0.50) é ignorado — código usa constantes hardcoded `HOP1_DECAY=0.6`, `HOP2_DECAY=0.35`.
- `spreading_facts_per_entity` (config: 3) só atua no Hop 2 (linha 260). Hop 1 usa constante `FACTS_PER_ENTITY_HOP1=5`.

**Gap #26 — Reranker timeout ignorado:**
- `reranker_timeout_sec` (config: 5.0) existe no MemoryConfig mas `rerank_with_llm()` chama `llm.complete()` sem timeout. Com timeout=0.001s, reranker rodou 1737ms sem erro.

Root cause: configs declaradas no MemoryConfig mas nunca consumidas (ou consumidas parcialmente) pelo código que deveria usá-las.

## Definition of Done

1. **[ ] `hops=0` desabilita spreading** — `_spread_activation_impl()` retorna `SpreadingActivationResult()` vazio quando `config.spreading_activation_hops == 0`. Guard no topo da função, antes de qualquer DB query.
2. **[ ] `spreading_decay_factor` é consumido** — Hop 1 e Hop 2 usam `config.spreading_decay_factor` em vez de constantes hardcoded. Remove ou deprecia `HOP1_DECAY`/`HOP2_DECAY`.
3. **[ ] `spreading_facts_per_entity` atua no Hop 1** — Hop 1 entity expansion usa `config.spreading_facts_per_entity` em vez de `FACTS_PER_ENTITY_HOP1`.
4. **[ ] Reranker timeout enforced** — `rerank_with_llm()` usa `asyncio.wait_for(timeout=config.reranker_timeout_sec)`. No timeout, fallback pro ranking original (graceful degradation).
5. **[ ] Testes passam** — Todos os testes existentes passam + novos testes cobrindo `hops=0`, decay factor configurável, e reranker timeout.

## Scope

### 1. `src/arandu/read/spreading_activation.py`

**1a. Guard `hops=0`:**
No topo de `_spread_activation_impl()`, após `hops = config.spreading_activation_hops`:
```python
if hops == 0:
    return SpreadingActivationResult()
```

**1b. Decay factor dinâmico:**
Substituir constantes:
- `HOP1_DECAY = 0.6` → usar `config.spreading_decay_factor`
- `HOP2_DECAY = 0.35` → usar `config.spreading_decay_factor ** 2` (decay composto)

Remover as constantes `HOP1_DECAY` e `HOP2_DECAY`. Manter `KG_REL_HOP_DECAY` como constante (já tem semântica diferente: pondera relationship strength).

**1c. Facts per entity no Hop 1:**
Substituir `FACTS_PER_ENTITY_HOP1` por `config.spreading_facts_per_entity` na chamada `_fetch_neighbor_facts()` do Hop 1 (linha 173). Remover a constante `FACTS_PER_ENTITY_HOP1`.

### 2. `src/arandu/read/reranker.py`

Wrap `llm.complete()` com `asyncio.wait_for`:
```python
import asyncio

try:
    raw = await asyncio.wait_for(
        llm.complete(
            messages=[{"role": "user", "content": prompt_text}],
            temperature=0,
            response_format={"type": "json_object"},
            max_tokens=300,
        ),
        timeout=config.reranker_timeout_sec,
    )
except asyncio.TimeoutError:
    logger.warning("reranker: timeout after %.1fs, falling back", config.reranker_timeout_sec)
    return candidates
```

O `except Exception` existente (linha 98) continua como fallback geral.

### 3. `tests/test_spreading_activation.py` — Novos testes

- `test_hops_zero_returns_empty` — config com `hops=0`, verifica retorno vazio sem DB queries
- `test_decay_factor_used` — verifica que score usa `config.spreading_decay_factor`, não constante
- `test_facts_per_entity_hop1` — verifica que Hop 1 usa `config.spreading_facts_per_entity` como limit

### 4. `tests/test_read_pipeline.py` — Novo teste reranker timeout

- `test_rerank_timeout_fallback` — mock `llm.complete` com `asyncio.sleep(10)`, config com `reranker_timeout_sec=0.01`, verifica fallback pro ranking original

### 5-6. Docs EN + PT-BR

Atualizar `docs/en/concepts/read-pipeline.md` e `docs/pt-BR/concepts/read-pipeline.md`:
- Seção spreading activation: documentar que `hops=0` desabilita, `spreading_decay_factor` controla decay, `spreading_facts_per_entity` se aplica em ambos hops
- Seção reranker: documentar que `reranker_timeout_sec` é enforced, com graceful degradation no timeout

## Anti-scope

- **Não mudar default values** — `spreading_activation_hops=2`, `spreading_decay_factor=0.50`, `spreading_facts_per_entity=3`, `reranker_timeout_sec=5.0` mantêm seus defaults atuais
- **Não adicionar configs novas** — apenas conectar as que já existem
- **Não mudar `FACTS_PER_CLUSTER_HOP1`** — cluster expansion mantém constante (3), scope só entity expansion
- **Não mudar `KG_REL_HOP_DECAY`** — KG relationship decay é semanticamente diferente (pondera strength), fica constante
- **Não mudar `META_OBSERVATIONS_LIMIT`** — fora de scope, meta observations não são configuráveis hoje
- **Não adicionar trace step pra spreading** — o inspector mencionou, mas é nice-to-have, não gap
- **Não implementar `plan_override`** — sugestão do inspector, vai pro backlog
- **Não mudar pipeline.py** — spreading e reranker são chamados pelo pipeline mas o pipeline não precisa mudar

## Technical Decisions

### TD-1: Decay composto (`factor ** 2`) para Hop 2 em vez de config separada
**Trade-off:** Poderia ter `spreading_decay_factor_hop2` como config separada. Mas adicionar config é anti-scope e a relação hop1→hop2 naturalmente é composta.
**Decisão:** Hop 1 usa `config.spreading_decay_factor`, Hop 2 usa `config.spreading_decay_factor ** 2`. Mantém 1 config, comportamento intuitivo.

### TD-2: `asyncio.wait_for` em vez de `asyncio.timeout`
**Trade-off:** `asyncio.timeout` (Python 3.11+) é o context manager moderno. Mas `wait_for` é mais cirúrgico — cancela exatamente a coroutine alvo.
**Decisão:** Usar `asyncio.wait_for` pra cancelar só o `llm.complete()`. Compatível com o pattern existente de `try/except` no reranker.

### TD-3: Remover constantes hardcoded em vez de manter como fallback
**Trade-off:** Poderia manter `HOP1_DECAY` como fallback se config for None. Mas `spreading_decay_factor` é obrigatório no MemoryConfig (tem default 0.50), nunca será None.
**Decisão:** Remover `HOP1_DECAY`, `HOP2_DECAY`, `FACTS_PER_ENTITY_HOP1`. Sem fallback. Config é a fonte da verdade.

## Applicable Patterns

- **Error Handling** — Reranker timeout segue fail-safe: timeout → fallback pro ranking original. `logger.warning()` no timeout.
- **Pipeline Orchestration** — Spreading activation configs controlam sub-stage do pipeline. Timing existente não muda.
- **Testing** — Mock-based: `AsyncMock` para session, mock `llm.complete` com `asyncio.sleep` pra simular timeout.

## Risks

1. **Mudança de comportamento com defaults existentes** — Com `spreading_decay_factor=0.50`, Hop 1 agora usa 0.50 (antes era 0.60 hardcoded) e Hop 2 usa 0.25 (antes era 0.35). Scores de spreading vão diminuir ~17% no Hop 1. Mitigação: é a intenção — a config agora é a fonte da verdade. Quem quiser o comportamento antigo seta `spreading_decay_factor=0.60`.
2. **`spreading_facts_per_entity=3` no Hop 1** — Antes era 5 hardcoded, agora 3 (default da config). Retorna menos facts no Hop 1. Mitigação: default 3 é o que estava documentado/exposto ao usuário. Se retornava 5, era um bug silencioso.
3. **Reranker timeout com provider lento** — Se o provider do usuário é naturalmente lento, timeout default de 5s pode cortar frequentemente. Mitigação: 5s é generoso. Quem precisa de mais, aumenta a config.
