# Spec: Deterministic Entity Resolution for Read Pipeline

> Generated via /vibeflow:gen-spec on 2026-03-20
> PRD: `.vibeflow/prds/deterministic-entity-resolution-read.md`
> Budget: ≤ 6 files

## Objective

Tornar o graph signal do read pipeline independente de LLM para entity extraction, usando resolução determinística como camada primária e unificando 3 fontes de entities antes do graph gate.

## Context

O read pipeline tem um defeito arquitetural: o graph signal depende 100% do LLM planner para extrair entities. Quando o LLM falha (variância natural), `plan.entities` fica vazio e o BFS 2-hop não roda.

O SDK já tem infraestrutura que resolve o problema mas não está conectada:
- `expand_query()` resolve aliases via `MemoryEntityAlias` e retorna `primed_entities` — mas esses entities **não alimentam o graph**. Só `expanded.expanded_terms` é usado (linha 195 do pipeline.py).
- `MemoryEntity.display_name` tem os nomes legíveis das entities — não é consultado no read.
- O schema cache em `retrieval_agent._get_user_schema()` tem todos os entity_keys — poderia ser usado pra substring match.

O fix é conectar o que já existe + adicionar display_name matching.

## Definition of Done

1. **[ ] Resolução determinística funciona** — `resolve_query_entities(session, user_id, query)` recebe texto e retorna `list[str]` de entity_keys via alias + display_name + slug match, sem LLM.
2. **[ ] Graph gate usa entities unificados** — `pipeline.py` faz union de `plan.entities` + `expanded.primed_entities` + `resolve_query_entities()` antes do `if entities:` gate.
3. **[ ] Normalização de entities do LLM** — `_parse_plan()` normaliza entities com formato errado (`entity:Carlos` → `person:carlos`) contra o schema passado como parâmetro.
4. **[ ] Trace registra fonte das entities** — Step "retrieval" no trace inclui `entities_sources: {llm: [...], deterministic: [...], expansion: [...]}` para observabilidade.
5. **[ ] Testes passam** — Todos os 338+ testes existentes passam + novos testes cobrindo determinístico, union, e normalização.
6. **[ ] Docs atualizados EN + PT-BR** — Seção do Planner atualizada explicando entity resolution híbrida (determinístico primário, LLM suplementar).

## Scope

### 1. `src/arandu/read/query_expansion.py` — Nova função `resolve_query_entities()`

```python
async def resolve_query_entities(
    session: AsyncSession,
    user_id: str,
    query_text: str,
) -> list[str]:
```

Resolução em 3 sub-layers (union dos resultados):

**Layer A — Alias match** (já existe em `_resolve_aliases()`):
- Extrai words da query via `_extract_query_words()`
- Match exato contra `MemoryEntityAlias.alias`
- Retorna canonical_entity_keys

**Layer B — Display name match** (novo):
- Query `MemoryEntity` onde `lower(display_name)` contém alguma word da query
- Usa `func.lower(MemoryEntity.display_name).contains(word)` (case-insensitive)
- Retorna `MemoryEntity.canonical_key`

**Layer C — Slug match contra schema** (novo):
- Pega entity_keys do user (via _get_user_schema ou query direta)
- Extrai slug de cada entity_key: `"person:carlos"` → `"carlos"`
- Case-insensitive match contra words da query
- Retorna entity_keys que matchearam

Union de A + B + C, dedup, limit `_MAX_ENTITIES_EXPAND`.

### 2. `src/arandu/read/pipeline.py` — Merge entities antes do graph gate

Antes do `if plan.entities:` (linha 226), inserir:

```python
# Unify entities from all sources for graph signal
all_entities: list[str] = list(plan.entities)  # LLM source

# Add entities from query expansion (alias resolution)
for ek in expanded.primed_entities:
    if ek not in all_entities:
        all_entities.append(ek)

# Add entities from deterministic resolution
deterministic_entities = await resolve_query_entities(session, user_id, query)
for ek in deterministic_entities:
    if ek not in all_entities:
        all_entities.append(ek)

# Use unified entities for graph gate
if all_entities:
    graph_task = asyncio.ensure_future(
        retrieve_graph_facts(session, user_id, all_entities, ...)
    )
```

Atualizar trace step "retrieval" com breakdown de fontes.

### 3. `src/arandu/read/retrieval_agent.py` — Normalização no `_parse_plan()`

Passar `schema_prefixes` para `_parse_plan()` (hoje não recebe). Para cada entity em `raw_entities`:
- Se já é um entity_key válido (contém `:` e prefix está nos schema_prefixes) → manter
- Se não: tentar normalizar. Ex: `"entity:Carlos"` → procurar `"carlos"` nos schema_prefixes → `"person:carlos"`
- Se não resolve → descartar (comportamento atual)

Mudar assinatura: `_parse_plan(raw, query_text, schema_pairs=None)` — opcional pra não quebrar.

### 4. `tests/test_query_expansion.py` — Novos testes

- `test_resolve_by_alias` — word da query matcha alias → retorna canonical
- `test_resolve_by_display_name` — word da query matcha display_name → retorna canonical_key
- `test_resolve_by_slug` — word da query matcha slug do entity_key → retorna entity_key
- `test_resolve_union_dedup` — múltiplas sources resolvem mesma entity → sem duplicatas
- `test_resolve_empty_query` — query sem entities → retorna vazio
- `test_normalize_llm_entity` — `_parse_plan` normaliza `entity:Carlos` → `person:carlos`
- `test_pipeline_uses_unified_entities` — mock pipeline verifica que graph_task recebe entities de todas as fontes

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

Atualizar `docs/en/concepts/read-pipeline.md` e `docs/pt-BR/concepts/read-pipeline.md`:
- Seção "Stage 1: Retrieval Agent" — adicionar sub-seção "Hybrid Entity Resolution"
- Explicar: determinístico (alias + display_name + slug) é primário, LLM é suplementar
- Explicar: union das 3 fontes antes do graph gate

## Anti-scope

- **Não mudar `graph_retrieval.py`** — BFS funciona, o input que tava errado
- **Não mudar write pipeline** — entity resolution do write é diferente e completa
- **Não adicionar embedding-based fuzzy matching** — overkill pra v0
- **Não mudar interface pública** — `RetrievalPlan`, `plan_retrieval()`, `expand_query()` mantêm assinatura
- **Não fazer entity disambiguation** — se "João" resolve pra 2 entities, inclui ambos
- **Não adicionar cache novo** — reusar schema_cache e alias table existentes
- **Não mudar config defaults** — nenhum novo parâmetro em MemoryConfig

## Technical Decisions

### TD-1: Determinístico como primário, LLM como suplementar
**Trade-off:** O LLM é melhor pra referências indiretas ("meu chefe"), mas é inconsistente pra referências diretas ("o Carlos"). O determinístico é 100% consistente pra referências diretas, mas não resolve indiretas.
**Decisão:** Union das duas fontes. Determinístico garante base, LLM enriquece. Se ambos falharem, graph não roda (fail-safe existente, correto).

### TD-2: 3 layers de match (alias + display_name + slug) em vez de 1
**Trade-off:** Mais queries ao DB (potencialmente 2-3 queries extras). Mas são queries simples em tabelas indexadas.
**Decisão:** Fazer as 3 em paralelo com `asyncio.gather()` pra minimizar latência. Budget de < 10ms é atingível.

### TD-3: Passar schema_pairs pra `_parse_plan()` em vez de fazer query separada
**Trade-off:** Mudar assinatura de função privada vs. fazer query redundante.
**Decisão:** Passar como parâmetro opcional (`schema_pairs: list[tuple[str, str]] | None = None`). Já é computado em `plan_retrieval()`. Zero queries extras.

### TD-4: Não criar novo módulo — estender `query_expansion.py`
**Trade-off:** Poderia criar `entity_resolution_read.py` separado.
**Decisão:** `query_expansion.py` já faz entity priming com `_resolve_aliases()`. A nova função é extensão natural. Mantém coesão do módulo.

## Applicable Patterns

- **Pipeline Orchestration** — `resolve_query_entities()` é chamado como sub-stage dentro do pipeline. Timing via `time.perf_counter()`. Trace opt-in.
- **Data Access** — Queries async via `select()...where()...limit()`. Sem savepoints (read-only). Sem commit.
- **Error Handling** — Fail-safe: `resolve_query_entities()` wrappado em try/except, retorna `[]` no erro. Graph gate aceita vazio.
- **Testing** — Mock session com `AsyncMock`. `_mock_session_with_schema()` pattern dos testes existentes. Sem DB real.

## Risks

1. **Performance: queries extras no hot path** — Mitigação: asyncio.gather pras 3 layers. Se > 10ms em produção, avaliar caching.
2. **False positives: "são" matcha "place:sao_paulo"** — Mitigação: match é por word boundary, não substring arbitrário. Words de 2 chars são filtradas por `_extract_query_words()`. Para slugs, exigir match completo do slug, não parcial.
3. **Schema_pairs como None no _parse_plan** — Mitigação: parâmetro opcional com default None. Fallback pro comportamento atual (sem normalização). Zero breaking change.
