# Spec: Mirror Facts passam por Reconciliação

> Source PRD: `.vibeflow/prds/mirror-facts-reconcile.md`
> Generated: 2026-04-09

## Objective

Fazer mirror facts (fatos sintéticos criados a partir de relações sem evidence match) passarem pelo mesmo `reconcile_facts()` que os fatos extraídos pelo LLM, eliminando de uma só vez três bugs: speaker=None, dedup bypassada e inconsistência de idioma.

## Context

O `_create_mirror_fact()` em `src/arandu/write/upsert.py:147-195` é um **caminho paralelo** de criação de `MemoryFact`. Quando `upsert_relations()` não encontra um "evidence fact" para uma relação via matching heurístico (`_find_evidence_fact_for_relation`), ele chama `_create_mirror_fact()` que instancia `MemoryFact` diretamente via `session.add()`, sem passar pelo pipeline canônico de reconciliação.

Consequências observadas em produção (v0.11.6):

1. **Speaker perdido:** `_create_mirror_fact` não aceita parâmetro `speaker` — o fato sai com `speaker=None`.
2. **Duplicatas:** mirror facts não passam por `reconcile_facts()` → o LLM nunca tem a chance de marcar como NOOP contra fatos recém-criados ou pré-existentes. Resultado real: dois fatos sobre "Pedro lives in Brazil/Brasil" no mesmo evento, um com confidence 0.95 (via pipeline canônico) e um com 0.60 (mirror).
3. **Inconsistência de idioma:** `fact_text` é gerado por template (`f"{source} {rel} {target}"`) usando `display_name` + `rel_type` que podem estar em idiomas diferentes do que o LLM extraiu nos outros fatos do mesmo evento.

A causa raiz é arquitetural. Existe apenas **um jeito certo** de criar `MemoryFact`: passar por `reconcile_facts()` → `execute_upsert()` → `_add_fact()`/`_update_fact()`. Qualquer caminho paralelo vai, mais cedo ou mais tarde, divergir em metadata, qualidade e lógica. Bugfix cirúrgico só no speaker não resolve o problema estrutural.

## Definition of Done

1. `_create_mirror_fact()` não instancia `MemoryFact` diretamente — em vez disso, monta um `ExtractedFact` sintético e delega a criação do fato para o mesmo fluxo `reconcile_facts()` → `_add_fact()` que os fatos normais usam.
2. `upsert_relations()` recebe `speaker: str | None` como parâmetro e propaga para o novo fluxo de mirror fact creation.
3. `run_write_pipeline()` passa `speaker_name` para `upsert_relations()`.
4. Reproduzindo o cenário do bug report (dois writes zerados, mensagem PT-BR, resposta do agente), o segundo write produz **no máximo 5 fatos sobre Pedro Menezes** — o mirror fact duplicado é detectado como NOOP pelo reconciler.
5. Todos os fatos criados num write têm `speaker == speaker_name` passado no `write()`. Nenhum fato com `speaker=None` quando `speaker_name` foi fornecido.
6. **Qualidade:** a nova criação de mirror fact reaproveita `_add_fact()` (não cria código paralelo). Só existe **um** lugar no repositório que chama `MemoryFact(...)` para persistir fato novo — `_add_fact()`. `_update_fact()` pode continuar criando direto por ser parte do versionamento.
7. Testes unitários cobrem: (a) mirror fact passa por reconcile, (b) mirror fact duplicado vira NOOP, (c) mirror fact ganha speaker correto. Testes existentes de `tests/test_mirror_facts.py` continuam passando (com ajustes mínimos para a nova assinatura).

## Scope

### Código

- **`src/arandu/write/upsert.py`**:
  - Refatorar `_create_mirror_fact()`: em vez de instanciar `MemoryFact`, construir um `ExtractedFact` sintético (subject = `source_entity.canonical_name_lower`, fact = template text, confidence=0.60) e retornar. Assinatura nova: retorna `ExtractedFact`, não `MemoryFact`.
  - Em `upsert_relations()`, quando o matching heurístico falhar: acumular os `ExtractedFact`s sintéticos numa lista, depois chamar `reconcile_facts()` + `execute_upsert()` uma única vez por batch antes do final da função.
  - `upsert_relations()` recebe novo parâmetro `speaker: str | None = None` e propaga via `execute_upsert(..., speaker=speaker)`.
  - `upsert_relations()` precisa receber `llm: LLMProvider` (hoje só recebe `embeddings`) — necessário para `reconcile_facts()`.

- **`src/arandu/write/pipeline.py`**:
  - Chamada de `upsert_relations()` passa `speaker=speaker_name` e `llm=llm`.

- **`pyproject.toml`** + **`src/arandu/__init__.py`**: bump patch (é bugfix).

### Testes

- **`tests/test_mirror_facts.py`**: ajustar os testes existentes para a nova assinatura de `_create_mirror_fact` (retorna `ExtractedFact`, não `MemoryFact`). Os mocks de sessão precisam cobrir a chamada a `reconcile_facts`.
- **`tests/test_mirror_facts_reconcile.py`** (novo arquivo):
  - `test_mirror_fact_goes_through_reconcile`: verifica que o `ExtractedFact` sintético é passado ao `reconcile_facts` (via mock).
  - `test_mirror_fact_dedup_as_noop`: cenário onde já existe fato equivalente → reconciler retorna NOOP → nenhum `MemoryFact` novo é criado.
  - `test_mirror_fact_propagates_speaker`: mirror fact criado via fluxo canônico carrega o `speaker_name`.

### Docs

- **Changelog apenas** — bugfix não exige mudança no getting-started nem reference (comportamento user-visible é "fatos que deveriam ser NOOP agora são NOOP, e todos têm speaker"). Se o changelog for só no PR/commit, não toca `docs/`.

## Anti-scope

- **Melhorar `_find_evidence_fact_for_relation`** para tratar "Brasil"≈"Brazil", acentos, sinônimos. O reconcile LLM já resolve isso. Fazer normalização multilingue no matching é uma toca de coelho que essa spec não ataca.
- **Remover `_create_mirror_fact` completamente** (opção B2 do discover). Mirror facts têm propósito legítimo — quando a relação não tem fato textual explícito, precisamos preservar evidence chain. Mantemos a função, só rotamos pelo reconcile.
- **Refatorar `_update_fact` pra usar `_add_fact` internamente**. Escopo diferente. O DoD #6 só exige que **fact creation** (ADD) tenha um único ponto de entrada; UPDATE é outro ciclo de vida.
- **Dedup cross-event / cross-write** — já existia via reconcile, essa spec não muda nada nisso.
- **Cachear embeddings de mirror fact texts** — prematuro.
- **Normalizar `display_name` / `rel_type`** antes de montar `fact_text` — se o reconcile dedupe corretamente, isso é irrelevante.
- **Migração de dados históricos** (corrigir mirror facts já persistidos com speaker=None) — escopo separado, se virar dor.

## Technical Decisions

### 1. Mirror facts viram `ExtractedFact` sintético, não `MemoryFact` direto

**Decisão:** `_create_mirror_fact()` constrói e retorna um `ExtractedFact`. A persistência acontece via `reconcile_facts()` → `execute_upsert()` → `_add_fact()`, que já propaga speaker e faz dedup semântica.

**Trade-off:** Adiciona uma chamada LLM (reconciliação) por relação sem evidence match. Custo pequeno (1 LLM call por subject, não por fato) e justificado — o benefício de zero duplicatas e metadata uniforme supera o custo.

**Alternativa rejeitada:** passar `speaker` direto pro `_create_mirror_fact()` existente (opção A do discover). Resolve speaker e nada mais. Duplicatas e idioma continuam bugados. Não ataca a raiz.

### 2. `upsert_relations()` faz batch de mirror facts antes de reconciliar

**Decisão:** Durante o loop sobre relações, acumular os `ExtractedFact`s sintéticos numa lista. No final do loop (antes do commit/return), chamar `reconcile_facts()` + `execute_upsert()` uma vez só com o batch inteiro.

**Justificativa:** Reconciliação agrupa por subject — rodar por fato individual desperdiçaria chamadas LLM. Batch preserva a otimização existente. Também evita intercalar criação de fatos com criação de relations links, mantendo o código mais linear.

### 3. `upsert_relations()` agora recebe `llm: LLMProvider`

**Decisão:** Hoje `upsert_relations()` só recebe `embeddings`. Precisamos adicionar `llm` para chamar `reconcile_facts()` no novo fluxo.

**Justificativa:** Reconciliação é LLM-driven. Passar o provider é coerente com como `run_write_pipeline()` já passa LLM pros outros stages. Sem alternativa limpa.

### 4. Evidence fact id do mirror é o fact criado via reconcile

**Decisão:** Depois que o batch de mirror facts passa por `execute_upsert()`, o `upsert_relations()` precisa casar cada `ExtractedFact` sintético com o `MemoryFact` resultante (se foi ADD) ou o existente (se foi NOOP) para popular `evidence_fact_id` da relação.

**Como:** usar o mesmo `_find_evidence_fact_for_relation` que já existe, mas agora rodando contra `created_facts` atualizado (que inclui os mirror facts que viraram ADD) + `matched_fact_id` das decisions que viraram NOOP.

**Trade-off:** Aumenta complexidade do loop em `upsert_relations`. Aceitável — a lógica de evidence linkage era frágil antes, agora fica explícita.

### 5. `_update_fact()` mantém `MemoryFact(...)` direto

**Decisão:** O DoD #6 exige um único ponto de entrada para **fact creation (ADD)**. `_update_fact()` continua instanciando `MemoryFact` direto porque faz parte do versionamento bi-temporal (fecha o fato antigo e cria o novo superseding).

**Justificativa:** Unificar ADD + UPDATE num único entry point é outro refactoring — fora do escopo. O objetivo desta spec é eliminar o path paralelo do mirror, não redesenhar o upsert.

## Applicable Patterns

- **Pipeline Orchestration** (`.vibeflow/patterns/pipeline-orchestration.md`): reusar o pipeline canônico `reconcile → upsert`, não criar caminhos paralelos.
- **Data Access** (`.vibeflow/patterns/data-access.md`): `session.add()` + `flush()` permanecem no `_add_fact()`. Nenhuma nova query.
- **Error Handling** (`.vibeflow/patterns/error-handling.md`): fail-safe no batch de mirror facts. Se `reconcile_facts` falhar (LLM timeout), cair para comportamento atual (criar mirror fact direto via `_add_fact` com `action=ADD`, sem reconciliar). Log warning.
- **Testing** (`.vibeflow/patterns/testing.md`): mock-based. `FakeLLMProvider` com response canonizada para simular NOOP do reconciler nos testes novos.

## Risks

1. **Reconcile pode marcar mirror fact válido como NOOP incorretamente** — o LLM pode decidir "isso já existe" quando na verdade é um fato diferente. Mitigação: o reconciler já é usado em produção pros fatos normais com qualidade aceitável; mirror facts seguem a mesma régua. Confiance baixa (0.60) ajuda o LLM a priorizar os fatos de 0.95 existentes como "ground truth".

2. **Perda de `evidence_fact_id` quando reconcile faz NOOP** — se o mirror vira NOOP matchando um fato existente, o `evidence_fact_id` da relação passa a apontar pro fato existente (não pro novo). Isso é **desejado** — a evidence chain fica mais forte, não mais fraca. Precisa de um teste que valida o behavior.

3. **Testes existentes de `test_mirror_facts.py` quebram** — a assinatura de `_create_mirror_fact` muda. Mitigação: o DoD #7 explicitamente exige ajustar os testes existentes como parte da spec.

4. **Regressão em cenário real de relação sem fato equivalente** — se a relação "(Pedro, married_to, Ana)" não tem fato textual correspondente e nenhum fato semelhante existe, o mirror fact DEVE ser criado. O fluxo via reconcile precisa retornar ADD nesse caso. Mitigação: teste unitário específico para esse cenário.

5. **Circular import** — `upsert.py` importando `reconcile.py`. Já existe import direto ou indireto? Checagem obrigatória na implementação.
