# PRD: Relation Lifecycle — Vinculação, Invalidação e Consistência Fato↔Relação

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

## Problema

O Arandu mantém um knowledge graph com dois tipos de artefato: **Fatos** (`MemoryFact`) como fonte da verdade em linguagem natural, e **Relações** (`MemoryEntityRelationship`) como arestas dirigidas para navegação BFS. O campo `evidence_fact_id` na relação (FK para `memory_facts.id`) deveria vincular relação ao fato de origem, mas a implementação tem 4 lacunas críticas que comprometem a integridade do grafo:

1. **`evidence_fact_id` nunca é preenchido** — `upsert_relations()` (upsert.py L380-401) não seta o campo. É código morto: existe no schema mas é sempre NULL. Impossível rastrear qual fato sustenta uma relação.

2. **Extração paralela desconecta fatos de relações** — `_multi_pass_extract()` (extract.py L299-316) extrai fatos e relações via chamadas LLM independentes e concorrentes (`asyncio.gather`). Não existe mecanismo para associar qual relação corresponde a qual fato. São outputs desconectados.

3. **Relações nunca são invalidadas** — `_update_fact()` e `_delete_fact()` (upsert.py) setam `valid_to`/`invalidated_at` nos fatos, mas não tocam relações. Os campos `valid_to` e `invalidated_at` existem no modelo `MemoryEntityRelationship` (models.py L262-263) mas nunca são setados. O BFS em `graph_retrieval.py` L219 já filtra `invalidated_at.is_(None)`, ou seja, está preparado — mas relações invalidadas nunca existem. Resultado: relações imortais, com cenários de arestas contraditórias ativas poluindo o retrieval.

4. **Relações inferidas não geram fatos** — Quando a extração infere uma relação implícita (ex: "vou pra Curitiba ver minha mãe" → infere `mãe --[lives_in]--> curitiba`), a informação fica apenas na aresta, sem fato em linguagem natural. Se a relação for invalidada, a informação some sem rastro.

Adicionalmente, o sistema de tipos de relação é rígido: `CANONICAL_REL_TYPES` (constants.py) é um conjunto fixo de 11 tipos, e o prompt LLM restringe a extração a esses tipos ("Allowed types: works_at, manages, ..."). Relações que não encaixam (ex: `inspired_by`, `mentored_by`) são perdidas ou forçadas em tipos inadequados, empobrecendo o grafo.

## Público-alvo

Desenvolvedores que integram o Arandu SDK como camada de memória em agentes de IA conversacional. Indiretamente, os usuários finais que conversam com esses agentes e dependem de um knowledge graph preciso e consistente.

## Solução Proposta

Dividir em 3 partes incrementais:

### Parte 1 — Integridade Fato↔Relação (core)

Resolver P1, P2 e P3 (vínculo, cascata, match heurístico):

- **Vincular `evidence_fact_id`**: No write pipeline (`pipeline.py`), após o step 5 (`execute_upsert`) que persiste fatos e gera IDs, coletar os `fact_id`s criados. No step 6 (`upsert_relations`), fazer match heurístico entre cada relação extraída e os fatos recém-criados para preencher `evidence_fact_id`.
- **Heurística de match relação→fato**: Para cada relação `(source, target, rel_type)`, buscar fatos recém-criados cujo `entity_key` é o source OU target, e cujo `fact_text` menciona ambos. Se múltiplos candidatos, usar o de maior confidence. Se nenhum candidato, `evidence_fact_id` fica NULL (sem fato-espelho nesta parte).
- **Cascata de invalidação**: Em `_update_fact()` e `_delete_fact()` (upsert.py), após invalidar o fato, buscar relações com `evidence_fact_id` apontando para o fato invalidado e setar `invalidated_at = now`, `valid_to = now`.
- **Manter extração paralela**: Não alterar `_multi_pass_extract()`. O match relação→fato acontece no pipeline, pós-extração e pós-persistência dos fatos.
- **Passar fact_ids do step 5 para o step 6**: Ajustar a assinatura de `upsert_relations()` para receber os fatos recém-criados (ou um mapeamento `subject → fact_id`).

### Parte 2 — Free Relation Extraction + Relation Resolution

Resolver a rigidez do sistema de tipos:

- **Remover `CANONICAL_REL_TYPES` dos prompts de extração**: Tanto `EXTRACTION_SYSTEM_PROMPT` quanto `RELATION_EXTRACTION_PROMPT` listam "Allowed types: ..." — remover essa restrição. O LLM extrai `rel_type` livremente.
- **Criar step de Relation Resolution**: Novo módulo/função que normaliza tipos de relação extraídos. Similar ao entity resolution mas para arestas: agrupa sinônimos, detecta tipos canônicos emergentes, usa `_REL_TYPE_ALIASES` como base mas aceita tipos novos.
- **Adaptar `normalize_rel_type()`**: Manter como fallback, mas permitir que tipos fora do canonical set passem sem serem descartados.
- **Adaptar `upsert_relations()`**: Aceitar tipos dinâmicos na unique constraint (a constraint `uq_entity_relationship` já usa `rel_type` como componente).
- **Considerar impacto no graph retrieval**: `graph_retrieval.py` não filtra por `rel_type` no BFS — já funciona com tipos dinâmicos. Verificar se algum consumer filtra.

### Parte 3 — Fato-espelho + Melhoria de Prompts

Resolver P4 (relações inferidas sem fatos):

- **Melhorar prompt de extração de fatos**: Instruir o LLM a extrair fatos implícitos em relações inferidas (ex: se infere `mãe --[lives_in]--> curitiba`, também extrair o fato "a mãe do usuário mora em Curitiba"). Isso resolve o problema na raiz.
- **Fallback com fato sintético**: Quando o match relação→fato (Parte 1) não encontra candidato, criar um fato em linguagem natural automaticamente. Marcar com `confidence = 0.60` e `source_context = "inferred_from_relation"`.
- **Onde**: No step 6 do pipeline, após o match, para relações sem match e sem `evidence_fact_id`.

### Documentação (transversal a todas as partes)

Cada parte deve atualizar a documentação correspondente:

- **Docs GitHub Pages** (EN + PT-BR):
  - `docs/{en,pt-BR}/concepts/write-pipeline.md` — Descrever novos steps do pipeline (match relação→fato, cascata, relation resolution).
  - `docs/{en,pt-BR}/advanced/write-api.md` — Atualizar referência da API (novos campos, novos comportamentos).
  - `docs/{en,pt-BR}/advanced/data-types.md` — Documentar novos campos/comportamentos de `MemoryEntityRelationship`.
  - `docs/{en,pt-BR}/advanced/database.md` — Atualizar schema reference se necessário.
- **Docs internos** (`.vibeflow/`):
  - `.vibeflow/patterns/pipeline-orchestration.md` — Atualizar exemplos do write pipeline.
  - `.vibeflow/patterns/data-access.md` — Adicionar exemplo da cascata de invalidação.
  - `.vibeflow/conventions.md` — Documentar nova convenção de relation types dinâmicos (Parte 2).
  - `.vibeflow/decisions.md` — Registrar decisões arquiteturais (match heurístico, manter paralelo, relation resolution).

## Critérios de Sucesso

- Toda relação criada a partir da Parte 1 tem `evidence_fact_id` preenchido quando há fato correspondente.
- Quando um fato é invalidado (UPDATE ou DELETE), relações que o referenciam via `evidence_fact_id` são invalidadas automaticamente.
- O BFS do graph retrieval para de retornar relações contraditórias (cenário: "mudou de cidade").
- Após Parte 2, relações com tipos fora do canonical set (ex: `mentored_by`) são extraídas e persistidas corretamente.
- Após Parte 3, relações inferidas têm fatos correspondentes, visíveis via busca semântica.
- Testes existentes continuam passando em cada parte.
- Novos testes cobrem cada comportamento novo.
- Documentação EN + PT-BR e `.vibeflow/` atualizadas em cada parte.

## Escopo v0

Parte 1 é o v0 — resolve os 3 problemas mais graves (vínculo, cascata, contradições) com o menor risco.

## Anti-escopo

- **Sem mudança de schema** — Todos os campos necessários já existem em `MemoryEntityRelationship` (`evidence_fact_id`, `valid_to`, `invalidated_at`). Sem migrations.
- **Sem `EXCLUSIVE_REL_TYPES`** — Não implementar exclusividade 1:1 por tipo de relação. A cascata via `evidence_fact_id` resolve naturalmente (fato invalidado → relação morre).
- **Sem evidence_fact_id como array** — Mantém campo único (não multi-evidence). Simplificação aceitável para v0.
- **Sem backfill de dados existentes** — Relações já criadas sem `evidence_fact_id` continuam como estão. Backfill é trabalho futuro.
- **Sem alterar extração paralela na Parte 1** — O paralelismo de `_multi_pass_extract()` não muda. O match acontece pós-persistência no pipeline.
- **Sem alterar o read pipeline** — `graph_retrieval.py` já filtra `invalidated_at.is_(None)`. Sem mudanças necessárias.

## Contexto Técnico

**Stack**: Python 3.11+, SQLAlchemy async, pgvector, Pydantic.

**Pipeline flow atual** (pipeline.py):
1. `_create_event()` → MemoryEvent (imutável)
2. `extract_from_message()` → ExtractionOutput {entities, facts, relations}
3. `resolve_entities()` → entity_map
4. `reconcile_facts()` → decisions [ADD/UPDATE/NOOP/DELETE]
5. `execute_upsert()` → persiste fatos (gera fact IDs) ← **fatos têm IDs aqui**
6. `upsert_relations()` → persiste relações ← **mas não recebe fact IDs**

**Patterns a seguir** (de `.vibeflow/`):
- **Pipeline orchestration**: Stages recebem parâmetros explícitos, usam savepoints, não commitam.
- **Data access**: `session.begin_nested()` para isolamento, `session.flush()` para obter IDs.
- **LLM interaction**: Prompts como constantes, `temperature=0`, JSON mode, fail-safe.
- **Error handling**: Try/except com defaults seguros, nunca raise de falhas LLM.
- **Testing**: FakeLLMProvider com respostas JSON canned, sem DB.

**Schema relevante** (models.py):
- `MemoryEntityRelationship.evidence_fact_id` — FK para `memory_facts.id`, nullable, nunca preenchido.
- `MemoryEntityRelationship.valid_to` / `invalidated_at` — existem no modelo mas nunca são setados.
- Unique constraint: `uq_entity_relationship` em `(user_id, source_entity_key, target_entity_key, rel_type)`.

**Constraint de match**: A extração paralela gera fatos e relações como outputs desconectados. O match relação→fato é heurístico e acontece APÓS persistência dos fatos (step 5), usando `entity_key` e `fact_text` para correlacionar.

## Questões Abertas

- **Parte 2 — Relation Resolution como módulo separado ou função em `upsert.py`?** Decisão arquitetural a ser tomada no gen-spec da Parte 2. O padrão do entity resolution (módulo separado `entity_resolution.py`) sugere módulo separado `relation_resolution.py`.
- **Parte 3 — Template de fato-espelho**: Qual formato usar para gerar a frase em linguagem natural a partir de uma relação? Ex: `f"{source_name} {rel_type.replace('_', ' ')} {target_name}"` é simples mas produz inglês genérico. Pode precisar de LLM call para gerar frase natural — trade-off latência vs qualidade. Decisão para o gen-spec da Parte 3.
