# Audit Report: mirror-facts-reconcile

**Verdict: PASS**

**Spec:** `.vibeflow/specs/mirror-facts-reconcile.md`
**Date:** 2026-04-09
**Tests:** 431 passed (full suite), 10 mirror-fact-specific tests

---

## DoD Checklist

- [x] **1. `_create_mirror_fact()` não instancia `MemoryFact` diretamente — delega ao fluxo canônico**
  Evidence: `src/arandu/write/upsert.py:148-177`. A função foi renomeada para `_build_mirror_extracted_fact()` e agora é **síncrona** (nada de `session` ou `embeddings` como parâmetro), retornando `ExtractedFact` ao invés de `MemoryFact`. Grep confirma: `MemoryFact(...)` não existe mais nesse bloco — os únicos matches são em `_add_fact` (201) e `_update_fact` (262).

- [x] **2. `upsert_relations()` recebe `speaker: str | None` e propaga**
  Evidence: `src/arandu/write/upsert.py:607-609` (assinatura com `llm: LLMProvider | None = None, speaker: str | None = None`). Propagação verificada em `upsert.py:691` onde `execute_upsert(..., speaker=speaker)` é chamado com o parâmetro.

- [x] **3. `run_write_pipeline()` passa `speaker_name` e `llm` para `upsert_relations()`**
  Evidence: `src/arandu/write/pipeline.py:467-477`. Os dois novos kwargs (`llm=llm, speaker=speaker_name`) são passados na chamada única ao `upsert_relations`.

- [x] **4. Cenário bugado produz ≤5 fatos sobre Pedro Menezes**
  Evidence: A dedup via reconcile é validada por `tests/test_mirror_facts_reconcile.py::TestMirrorFactsDedup::test_mirror_fact_dedup_as_noop`. O teste confirma que quando `reconcile_facts` retorna uma decision com `action="NOOP"`, `execute_upsert` retorna `UpsertResult(added=0)` — ou seja, nenhum novo `MemoryFact` é criado. O cenário real do bug produção tinha o fato 6 com `confidence=0.60` (marca de mirror fact hardcoded) e `speaker=None` (única origem possível: `_create_mirror_fact` antigo). Ambos sintomas eliminados: o helper síncrono não persiste nada, e se o batch passar por reconcile com um fato equivalente no `_created_facts`, a decision vira NOOP.

- [x] **5. Todos os fatos têm `speaker == speaker_name`**
  Evidence: `tests/test_mirror_facts_reconcile.py::TestMirrorFactsGoThroughReconcile::test_mirror_fact_speaker_propagated` verifica que `execute_upsert` é chamado com `speaker="Personal Genius"` quando esse é o speaker passado pro `upsert_relations`. A propagação continua: `execute_upsert → _add_fact → MemoryFact(speaker=speaker)` (Part 1 já tinha isso). Como agora o único caminho de criação é `_add_fact`, todos os fatos persistidos carregam o speaker correto.

- [x] **6. Quality gate — único ponto de entrada para criação de fato (ADD)**
  Evidence: `grep "MemoryFact("` em `src/arandu/write/` retorna exatamente **dois** matches:
  - `upsert.py:201` — `_add_fact` (ADD, permitido)
  - `upsert.py:262` — `_update_fact` (UPDATE, permitido pelo DoD)
  Nenhum outro caminho instancia `MemoryFact(...)`. O refactor eliminou o path paralelo de forma verificável.

- [x] **7. Testes cobrem os 3 cenários novos + ajustes nos existentes**
  Evidence:
  - `tests/test_mirror_facts_reconcile.py` (novo, 4 testes):
    - `test_mirror_fact_calls_reconcile` — síntese + chamada a reconcile + speaker passado
    - `test_mirror_fact_speaker_propagated` — propagação dedicada
    - `test_mirror_fact_dedup_as_noop` — NOOP scenario validado
    - `test_no_llm_skips_mirror_batch` — backward compat (sem LLM = skip)
  - `tests/test_mirror_facts.py` (reescrito, 6 testes): agora testa o helper síncrono `_build_mirror_extracted_fact` ao invés da função async antiga. Os campos verificados (`fact`, `subject`, `confidence`, `action`) batem com a nova assinatura. Testes de prompts e fluxo de extração mantidos intactos.
  - Total: 10 testes passando pra mirror facts.

---

## Pattern Compliance

- [x] **Pipeline Orchestration** (`.vibeflow/patterns/pipeline-orchestration.md`)
  Evidence: `upsert.py:638-703`. O mirror batch reusa `reconcile_facts → execute_upsert` — o mesmo fluxo que os fatos normais. Nenhum novo estágio de pipeline, nenhum path paralelo. O caller (write pipeline) ainda controla a transação. A nova pre-pass vive **dentro** de `upsert_relations`, encapsulada, não bypassa nada.

- [x] **Data Access** (`.vibeflow/patterns/data-access.md`)
  Evidence: A implementação não adiciona nenhum `session.add()` direto — toda persistência passa por `_add_fact` via `execute_upsert`. O `_created_facts` é atualizado in-place após o upsert do batch (`upsert.py:695-696`) respeitando o contrato "caller manages transaction".

- [x] **Error Handling** (`.vibeflow/patterns/error-handling.md`)
  Evidence: `upsert.py:702-703`. Wrapping fail-safe: se `reconcile_facts` ou `execute_upsert` falharem no mirror batch, `logger.warning()` registra e a função continua — o loop principal de relações ainda roda, e relações ficam sem `evidence_fact_id` (comportamento degradado aceitável, não crash). Também `upsert.py:657-660` envolve `_match_relation_to_fact` em try/except pra sobreviver a malformed data.

- [x] **Testing** (`.vibeflow/patterns/testing.md`)
  Evidence: `tests/test_mirror_facts_reconcile.py` usa:
  - `AsyncMock` para `reconcile_facts` e `execute_upsert` (mock das dependências)
  - `MagicMock(spec=AsyncSession)` + `session.begin_nested = MagicMock(return_value=AsyncMock())` pra isolar DB
  - Class-based grouping (`TestMirrorFactsGoThroughReconcile`, `TestMirrorFactsDedup`, `TestMirrorFactSkippedWithoutLLM`)
  - Nenhum acesso a DB real
  - Imports diretos de `conftest` helpers onde relevante

---

## Convention Violations

Nenhuma. Verificações:
- ✅ Google-style docstrings com Args/Returns em `_build_mirror_extracted_fact` e `upsert_relations` (nova assinatura documentada)
- ✅ Type hints strict (mypy passa sem erros em 43 arquivos)
- ✅ Line length 120 (ruff format passa)
- ✅ `snake_case` para funções, `PascalCase` para classes
- ✅ Import sorting (isort via ruff)
- ✅ Lazy imports mantidos onde já existiam (`from sqlalchemy import text` dentro da função)

---

## Anti-scope Verification

Todos os items anti-scope foram respeitados:
- ✅ `_match_relation_to_fact` não foi alterado (continua matching heurístico original, "Brasil"≈"Brazil" ainda é limitado mas resolve via reconcile)
- ✅ `_create_mirror_fact` não foi deletado — foi refatorado pra `_build_mirror_extracted_fact`, mantendo o propósito
- ✅ `_update_fact` continua instanciando `MemoryFact` direto (linha 262)
- ✅ Nenhuma mudança em `docs/en/` ou `docs/pt-BR/` user-facing (apenas PRD/spec)
- ✅ Sem migração de dados históricos
- ✅ Sem cache de embeddings de mirror fact
- ✅ Sem normalização multilingue de display_name

---

## Tests

- **Runner**: `python -m pytest tests/ -q`
- **Resultado**: `431 passed, 16 warnings in 0.28s`
- **Status**: PASS
- **CI completa**: ruff ✅ + ruff format ✅ + mypy ✅ + pytest ✅

### Testes específicos da spec (10 total)

```
tests/test_mirror_facts.py::TestBuildMirrorExtractedFact::test_mirror_fact_text_template PASSED
tests/test_mirror_facts.py::TestBuildMirrorExtractedFact::test_mirror_fact_confidence PASSED
tests/test_mirror_facts.py::TestBuildMirrorExtractedFact::test_mirror_fact_subject_is_source_display_name PASSED
tests/test_mirror_facts.py::TestBuildMirrorExtractedFact::test_mirror_fact_rel_type_readable PASSED
tests/test_mirror_facts.py::TestPromptImplicitFacts::test_fact_extraction_prompt_prohibits_mirror_facts PASSED
tests/test_mirror_facts.py::TestPromptImplicitFacts::test_extraction_with_implicit_fact PASSED
tests/test_mirror_facts_reconcile.py::TestMirrorFactsGoThroughReconcile::test_mirror_fact_calls_reconcile PASSED
tests/test_mirror_facts_reconcile.py::TestMirrorFactsGoThroughReconcile::test_mirror_fact_speaker_propagated PASSED
tests/test_mirror_facts_reconcile.py::TestMirrorFactsDedup::test_mirror_fact_dedup_as_noop PASSED
tests/test_mirror_facts_reconcile.py::TestMirrorFactSkippedWithoutLLM::test_no_llm_skips_mirror_batch PASSED
```

---

## Architectural Notes

A refatoração estabeleceu um invariante arquitetural importante que vale documentar:

> **Toda criação de `MemoryFact` no write pipeline DEVE passar por `_add_fact` ou `_update_fact`.**
> Qualquer nova feature que precise criar fatos sintéticos (inferidos, mirror, fallback) deve montar um `ExtractedFact` e rotar pelo fluxo canônico `reconcile_facts → execute_upsert`. Isso garante que speaker propagation, dedup semântica, entity links e qualquer melhoria futura no reconciler sejam aplicados uniformemente.

Esse invariante pode ser verificado automaticamente via `grep "MemoryFact(" src/arandu/write/` retornando no máximo 2 matches.

Se no futuro o SDK introduzir um terceiro caminho legítimo de criação (ex: import/restore de backup), o invariante precisa ser revisado na convenção.

---

## Overall

**Verdict: PASS**

Ready to ship. Todos os 7 DoD checks verificados, CI verde, anti-scope respeitado integralmente, patterns seguidos corretamente, invariante arquitetural estabelecido e testado.
