# PRD: Fact-Entity Links — Eliminar Duplicação Cross-Entity

> Generated via /vibeflow:discover on 2026-03-23

## Problem

O Arandu armazena cada fact com um único `entity_key` (subject). Quando uma informação envolve 2+ entidades ("Clara saiu da Vertix"), o multi-pass cria facts duplicados — um por entidade — porque não tem como indexar o fact pra múltiplas entidades sem duplicar.

O benchmark v0.6.21 mostra ~40% de false positives no multi-pass vindos dessa duplicação cross-entity. Todos os sistemas de memória relevantes (Zep/Graphiti, Mem0, Hindsight, MAGMA) convergem pra um modelo onde o fact existe **uma vez** e é linkado a todas as entidades que menciona.

O modelo atual força uma escolha impossível: ou o fact pertence a Clara (e Vertix não acha via semantic search) ou duplica (e polui retrieval, background jobs e storage).

## Target Audience

Desenvolvedores usando o SDK. O benchmark do Inspector expôs o problema, mas afeta qualquer usuário com mensagens que mencionam múltiplas entidades (a maioria dos casos reais).

## Proposed Solution

Criar uma tabela associativa `MemoryFactEntityLink` que conecta cada fact a TODAS as entidades que ele menciona. O fact continua com `entity_key` (subject primário), mas o retrieval e os background jobs consultam via links.

O fact "Clara saiu da Vertix" é armazenado **uma vez**, com:
- `entity_key = "person:clara_rezende"` (subject primário, backward compat)
- Link → `person:clara_rezende`
- Link → `organization:vertix`

Retrieval por "Vertix" acha o fact via link. Zero duplicação.

## Success Criteria

1. Multi-pass extrai <= 1.2x o número de facts do GT (hoje é ~2x)
2. Retrieval por qualquer entidade mencionada num fact retorna o fact
3. `MemoryFact.entity_key` continua existindo (backward compat) mas retrieval usa links
4. Background jobs (spreading activation, clustering, importance) funcionam com o novo modelo

## Scope v0

1. **`MemoryFactEntityLink` model** — tabela associativa `(fact_id, entity_key, is_primary)` com índices
2. **Migration** — criar tabela + popular links pra facts existentes a partir do `entity_key` atual
3. **Write pipeline: criar links no upsert** — após persistir o fact, extrair entidades mencionadas no `fact_text` e criar links
4. **Write pipeline: remover duplicação no multi-pass** — com links, o prompt de "primary subject only" se torna enforcement real (1 fact por informação), não sugestão ignorável
5. **Read pipeline: retrieval via links** — `retrieve_candidates()` e `retrieve_graph_facts()` consultam via `MemoryFactEntityLink` em vez de `MemoryFact.entity_key` direto
6. **Spreading activation: usar links** — hop por entity usa links pra achar facts
7. **Docs EN + PT-BR**

## Anti-scope

- **Remover `MemoryFact.entity_key`** — manter pra backward compat e como "primary subject"
- **Mudar o modelo de `MemoryEntity`** — entities continuam como estão
- **Mudar `MemoryEntityRelationship`** — relationships continuam como estão
- **Entity resolution no read** — já funciona, não mudar
- **Reconciliation** — continua comparando por entity_key (primary subject). Não mudar a lógica de ADD/UPDATE/NOOP/DELETE
- **Dedup por embedding** — o link model elimina a necessidade. Não implementar.
- **Mudar interface pública** — `WriteResult`, `RetrieveResult`, `ScoredFact` mantêm assinatura
- **Single-pass extraction** — já não tem problema de duplicação. Não mudar.

## Technical Context

### Modelo atual
```
MemoryFact:
  id, user_id, entity_key (singular), entity_type, entity_name,
  fact_text, embedding, confidence, ...
```

Retrieval: `WHERE entity_key = X` ou semantic search no embedding.

### Modelo proposto
```
MemoryFactEntityLink (NOVA):
  id (UUID PK)
  fact_id (FK → MemoryFact.id)
  entity_key (str, indexed)
  is_primary (bool, default False)
  user_id (str, indexed — pra queries por user)

  Index: (user_id, entity_key) — query principal
  Index: (fact_id) — join reverso
```

### Como extrair entity_keys do fact_text

Duas opções:
- **(A) Usar entities já resolvidas** — o write pipeline já tem o `entity_map` com todas as entities da mensagem. Pra cada fact, verificar quais entity_keys do entity_map aparecem no `fact_text` (substring match no display_name).
- **(B) Usar as relações** — se existe relação `Clara → works_at → Vertix`, o fact "Clara trabalha na Vertix" deveria linkar a ambos.

Opção A é mais robusta — não depende de relações terem sido extraídas.

### Impacto nos queries de retrieval

Hoje: `SELECT ... FROM memory_facts WHERE entity_key = X`
Depois: `SELECT ... FROM memory_facts f JOIN memory_fact_entity_links l ON f.id = l.fact_id WHERE l.entity_key = X`

Ou usar `EXISTS` subquery pra evitar duplicar rows no JOIN.

### Patterns existentes
- **Data Access** — async SQLAlchemy com savepoints
- **Pipeline Orchestration** — links criados como sub-step do upsert
- **Error Handling** — fail-safe: se link creation falha, fact ainda persiste (backward compat via entity_key)
- **Testing** — mock-based, sem DB real

## Open Questions

1. **Reconciliation usa links?** — Hoje reconcilia por `entity_key` (busca facts existentes da mesma entidade). Com links, poderia buscar facts existentes de QUALQUER entidade linkada. Mas isso muda a semântica de UPDATE — um fact sobre Clara poderia "superseder" um fact sobre Vertix. Por enquanto, manter reconciliação por `entity_key` (primary subject). Revisitar depois se necessário.
