# Spec: Fact-Entity Links — Part 1: Model + Migration + Write Pipeline

> Generated via /vibeflow:gen-spec on 2026-03-23
> PRD: `.vibeflow/prds/fact-entity-links.md`
> Budget: ≤ 6 files

## Objective

Criar o modelo `MemoryFactEntityLink` e popular links durante o write pipeline, permitindo que um fact seja encontrado por qualquer entidade que ele menciona — sem duplicação.

## Context

Hoje `MemoryFact.entity_key` é singular. Quando "Clara saiu da Vertix" envolve Clara e Vertix, o multi-pass cria 2 facts (um por entidade). Benchmark mostra ~40% de false positives vindos dessa duplicação. Todo o mercado (Zep, Mem0, Hindsight) armazena o fact uma vez e linka a múltiplas entidades.

Part 1 cria a fundação: modelo, migration, e criação de links no write. Parts 2 e 3 consomem os links no read e background jobs.

## Definition of Done

1. **[ ] Modelo `MemoryFactEntityLink` existe** — Tabela com `(id, fact_id, entity_key, is_primary, user_id)`, índices em `(user_id, entity_key)` e `(fact_id)`. FK pra `MemoryFact.id`.
2. **[ ] Migration aplica sem erro** — Alembic migration cria tabela e popula links pra facts existentes a partir de `MemoryFact.entity_key` (cada fact existente gera 1 link com `is_primary=True`).
3. **[ ] Write pipeline cria links no upsert** — Após persistir cada fact (ADD ou UPDATE), o upsert cria links pra: (a) o `entity_key` do fact (primary) e (b) todas as entidades mencionadas no `fact_text` via substring match contra o `entity_map` da mensagem.
4. **[ ] Link creation é fail-safe** — Se criar um link falha, o fact persiste normalmente (backward compat). Logger warning.
5. **[ ] Testes passam** — Todos os existentes + novos testes cobrindo: model creation, link creation no upsert, link creation pra multiple entities, fail-safe on error.

## Scope

### 1. `src/arandu/models.py` — Novo modelo

```python
class MemoryFactEntityLink(Base):
    __tablename__ = "memory_fact_entity_links"

    id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    fact_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("memory_facts.id", ondelete="CASCADE"), nullable=False)
    entity_key: Mapped[str] = mapped_column(String, nullable=False)
    is_primary: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
    user_id: Mapped[str] = mapped_column(Text, nullable=False)

    __table_args__ = (
        Index("ix_fact_entity_links_user_entity", "user_id", "entity_key"),
        Index("ix_fact_entity_links_fact", "fact_id"),
        UniqueConstraint("fact_id", "entity_key", name="uq_fact_entity_link"),
    )
```

Relationship no `MemoryFact`:
```python
entity_links: Mapped[list["MemoryFactEntityLink"]] = relationship(back_populates="fact", cascade="all, delete-orphan")
```

### 2. Migration

SQL migration via `apply_migration` pattern:
- CREATE TABLE `memory_fact_entity_links`
- INSERT INTO `memory_fact_entity_links` (id, fact_id, entity_key, is_primary, user_id) SELECT gen_random_uuid(), id, entity_key, true, user_id FROM memory_facts WHERE entity_key IS NOT NULL

### 3. `src/arandu/write/upsert.py` — Criar links após fact persist

Na função que persiste facts (ADD/UPDATE), após flush do fact:

```python
async def _create_entity_links(
    session: AsyncSession,
    fact: MemoryFact,
    entity_map: dict[str, str],  # display_name -> entity_key
) -> None:
```

- Cria link primário: `(fact.id, fact.entity_key, is_primary=True, fact.user_id)`
- Pra cada entry no `entity_map`: se `display_name.lower()` aparece em `fact.fact_text.lower()`, cria link secundário: `(fact.id, entity_key, is_primary=False, fact.user_id)`
- Dedup: skip se `entity_key == fact.entity_key` (já é o primary)
- `ON CONFLICT DO NOTHING` pra idempotência

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

- `test_link_model_creation` — cria model, verifica campos
- `test_upsert_creates_primary_link` — fact ADD cria link primary
- `test_upsert_creates_secondary_links` — fact com 2 entities no texto cria 2 links
- `test_link_creation_failsafe` — se link falha, fact persiste
- `test_link_dedup_on_conflict` — mesmo fact+entity_key não duplica

### 5. Docs EN + PT-BR

Adicionar seção "Fact-Entity Links" no write-pipeline.md explicando o modelo.

## Anti-scope

- **Read pipeline** — Part 2. Não mudar retrieval queries aqui.
- **Spreading activation** — Part 3. Não mudar.
- **Background jobs** — Part 3. Não mudar.
- **Reconciliation** — Continua usando `entity_key` (primary subject). Não mudar.
- **Remover `MemoryFact.entity_key`** — Manter pra backward compat.
- **`single_pass` extraction** — Não mudar.
- **Multi-pass dedup prompt** — Manter como está. A redução de duplicatas vem do modelo, não do prompt.

## Technical Decisions

### TD-1: Substring match pra detectar entities no fact_text
**Trade-off:** Substring match pode ter false positives (entity "Ana" matcha "Banana"). Embedding match seria mais preciso mas adiciona latência.
**Decisão:** Substring match com word boundary (`display_name.lower() in fact_text.lower()`). Pra nomes de 1 palavra curta (< 3 chars), skip. O false positive rate é aceitável — ter um link extra é melhor que não ter.

### TD-2: `ON CONFLICT DO NOTHING` vs upsert
**Trade-off:** Upsert atualiza is_primary se mudar. DO NOTHING é mais simples.
**Decisão:** DO NOTHING. is_primary é set na criação e não muda. Sem race conditions.

### TD-3: CASCADE delete no FK
**Trade-off:** Se um fact é deletado, links são auto-removidos. Sem orphans.
**Decisão:** `ondelete="CASCADE"`. Limpo.

### TD-4: `entity_map` passado pro upsert
**Trade-off:** O upsert hoje não recebe entity_map. Precisa mudar assinatura.
**Decisão:** Adicionar parâmetro opcional `entity_map: dict[str, str] | None = None` nas funções de upsert. Se None, cria só o link primary. Backward compat.

## Applicable Patterns

- **Data Access** — async SQLAlchemy, savepoints pra isolamento, `ON CONFLICT DO NOTHING`
- **Error Handling** — fail-safe: link creation wrappada em try/except, fact persiste mesmo se link falha
- **Pipeline Orchestration** — link creation como sub-step do upsert, não stage separado
- **Testing** — mock-based com AsyncMock, sem DB real

## Risks

1. **Performance: N links por fact** — Mitigação: mensagens complexas têm ~8 entities. 8 INSERTs extras por fact é negligível vs chamada LLM.
2. **False positive links** — "Ana" matchando "Banana". Mitigação: skip display_names < 3 chars. Aceitável ter link extra — melhor que faltar.
3. **Migration em banco grande** — Mitigação: INSERT ... SELECT é atomic e indexed. Pra bancos com milhões de facts, pode demorar, mas é one-time.
