# PRD: FactQuery — Abstração Centralizada pra Queries de MemoryFact

> Generated via /vibeflow:discover on 2026-04-09

## Problem

O Arandu SDK tem **26 query sites em 7 arquivos** que constroem queries SQL contra `MemoryFact` manualmente, **sem nenhuma abstração compartilhada**. Um levantamento detalhado mostrou:

- **25 dos 26 sites não têm tiebreaker estável** no `ORDER BY`. O único que tem é o `get_all()` do CRUD, corrigido em março após um bug reportado.
- **Apenas 1 site** (`get_all`) já tem stable ordering — todos os outros 25 podem produzir resultados não-determinísticos quando há ties (mesmo timestamp, mesma distância semântica).
- **O filtro base `agent_id == X AND valid_to IS NULL AND confidence >= min`** é repetido verbatim **14 vezes** em diferentes arquivos. Se um dia a semântica de "fato ativo" mudar, vira edit de 14 lugares.
- **Zero abstração existente.** Nenhum repository, query builder, ou base query helper. Cada módulo hand-rolls `select(MemoryFact).where(...).order_by(...)` do zero.

Isso causou **dois bugs reportados em produção em menos de 6 meses**:

1. **Março/2026 — `get_all()` com paginação instável** (`1ce37bf`): fatos do mesmo write compartilham `created_at`, OFFSET pulava/repetia registros. Fix: adicionar `id` como tiebreaker. **Cirúrgico. Afetou apenas 1 site.**

2. **Abril/2026 — `retrieve()` não-determinístico** (reportado pelo Personal Genius via thread `2026-04-09 - retrieve pobre e entity_keys DX.md`): rodar 5 vezes o mesmo `retrieve()` contra memória fixa retorna facts diferentes. Causa raiz: `semantic_search` ordena por cosine distance sem tiebreaker; `keyword_search` ordena por `valid_from DESC` sem tiebreaker; facts do mesmo write têm timestamps idênticos → PostgreSQL retorna em ordem arbitrária (heap order).

**O segundo bug é a terceira manifestação do mesmo problema estrutural.** Graph retrieval (`_fetch_facts_for_entities`), spreading activation (`_fetch_neighbor_facts`), consolidation (`run_consolidation`), clustering, memify, reconcile — **todos esses têm o mesmo tipo de ordering instável aguardando pra ser o próximo bug reportado**.

Cada vez que a gente corrige cirurgicamente, a gente deixa 20+ outros sites vulneráveis. O fix de "adicionar `.id`" resolve **hoje**. A **quarta manifestação** desse bug vai aparecer em alguns meses, em outro lugar. E a quinta. E a sexta.

A raiz não é "faltou tiebreaker" — é **"não existe abstração que force tiebreaker a existir"**. Enquanto cada query site for construído do zero, o SDK vai repetir esse tipo de bug.

## Target Audience

**Primário:** mantenedores do Arandu SDK (especialmente o Claude Code + agentes de dev). O padrão atual de "cada módulo constrói sua query" é uma pegadinha arquitetural que vai continuar sangrando.

**Secundário:** consumidores do SDK que dependem de determinismo no retrieve/get_all. Hoje: Personal Genius (MCP server do Pedro), Chief of Staff (assistente profissional), qualquer teste unitário em consumidor que compare resultados entre runs.

**Terciário:** qualquer feature futura que queira fazer A/B testing, benchmark de rerankers, ou observabilidade baseada em diff de resultado entre versões — tudo isso depende de baseline determinístico.

## Proposed Solution

Criar uma camada fina de **query builder** pra `MemoryFact`, chamada `FactQuery`, que centraliza toda a construção de queries SQL de leitura contra fatos. Migrar **todos os 25 call sites ad-hoc** pra usar esse builder.

### O que o `FactQuery` oferece

1. **Factory methods com filtros base:**
   - `FactQuery.active(agent_id)` → só facts com `valid_to IS NULL`
   - `FactQuery.all_versions(agent_id)` → inclui facts invalidados (para `get()` por ID)

2. **Métodos de filtro fluentes que retornam `self`:**
   - `.with_min_confidence(value)`
   - `.with_embedding()` → `embedding_vec IS NOT NULL`
   - `.for_entity(entity_key)` / `.for_entities(keys)` → usa `EXISTS` subquery contra `MemoryFactEntityLink`
   - `.for_cluster(cluster_id)`
   - `.within_temporal_window(start, end)`
   - `.excluding_ids(fact_ids)`
   - `.matching_text(patterns)` → ILIKE OR combination
   - `.by_ids(fact_ids)` → lookup direto

3. **Métodos de ordenação que garantem tiebreaker:**
   - `.order_by_recency()` → `valid_from DESC, id`
   - `.order_by_created()` → `created_at DESC, id` (como o `get_all` atual)
   - `.order_by_confidence()` → `confidence DESC, id`
   - `.order_by_similarity(embedding)` → `cosine_distance(embedding), id`
   - **É impossível construir uma query ordenada sem tiebreaker** — a API não expõe `.order_by()` cru.

4. **Métodos terminais:**
   - `.limit(n)` / `.offset(n)`
   - `.build()` → retorna o `Select[MemoryFact]` final (ou `Select[tuple[MemoryFact, float]]` pro similarity case)

### O que ele NÃO faz

- **Não é repository.** Zero operações mutating. Não tem `save`, `update`, `delete`. Fact creation continua em `_add_fact`/`_update_fact` (invariante da spec `mirror-facts-reconcile`).
- **Não executa queries.** Retorna o `Select` pronto. Quem chama ainda faz `await session.execute(stmt)`.
- **Não cacheia.** Stateless — cada instância é descartável.
- **Não encapsula pgvector ops** exceto pelo `order_by_similarity` (necessário pra cobrir semantic_search). Queries raw com `text()` pra casos específicos podem continuar no site que precisa, se realmente não couberem no builder.

### Enforcement arquitetural

Um **teste de regressão estrutural** garante que depois da migração, nenhum módulo fora do próprio `_fact_query.py` faz `select(MemoryFact)` direto. Grep test:

```python
def test_no_direct_memory_fact_select_outside_builder():
    """Architectural invariant: all MemoryFact queries go through FactQuery."""
    # Scan src/arandu/ for `select(MemoryFact)` outside _fact_query.py
    # Allowed exceptions: _add_fact, _update_fact, _confirm_fact, _delete_fact
    # (single-row by-id lookups are low-value to abstract)
```

Isso é o mesmo padrão do invariante `single entry point for MemoryFact creation` documentado em `.vibeflow/decisions.md` em 2026-04-09.

## Success Criteria

1. **Zero não-determinismo em retrieve.** Rodar o mesmo `retrieve(query=X, entity_keys=Y)` 20x contra memória fixa retorna o mesmo `list[ScoredFact]` (mesma ordem, mesmos facts). Validado pelo teste que o Personal Genius descreveu.

2. **Zero regressão funcional.** Todos os 443 testes existentes continuam passando sem alteração de assert (exceto os que tocam em mocks do `select(MemoryFact)` que precisam atualizar pra mockar `FactQuery`).

3. **Invariante arquitetural enforced.** O teste de regressão estrutural falha se alguém introduzir novo `select(MemoryFact)` fora do builder. Verde hoje = invariante ativo.

4. **Filtro base num único lugar.** A expressão `agent_id == X AND valid_to IS NULL` existe exatamente **uma vez** no código (dentro de `FactQuery.active()`). Nenhum outro site repete.

5. **Todos os `ORDER BY` têm tiebreaker.** Grep test complementar: nenhum `order_by(...)` em `src/arandu/` tem menos que 2 expressões quando ordena `MemoryFact`.

6. **Migração completa dos 25 sites.** Todos os query sites do levantamento (exceto point-lookups por ID que já são determinísticos) passam a usar `FactQuery`. Isso significa tocar em:
   - `src/arandu/read/retrieval.py` (2 sites)
   - `src/arandu/read/graph_retrieval.py` (3 sites)
   - `src/arandu/read/spreading_activation.py` (4 sites)
   - `src/arandu/read/query_expansion.py` (1 site)
   - `src/arandu/client.py` (1 site: `get_all` — migrar o pioneiro pro padrão)
   - `src/arandu/write/reconcile.py` (2 sites, incluindo raw `text()`)
   - `src/arandu/background/consolidation.py` (2 sites)
   - `src/arandu/background/clustering.py` (3 sites)
   - `src/arandu/background/memify.py` (2 sites)

7. **Docs internos atualizados.** O `.vibeflow/patterns/data-access.md` ganha uma seção "Querying MemoryFact" explicando o invariante e o uso do `FactQuery`. `.vibeflow/decisions.md` ganha a decisão arquitetural.

## Scope v0

O escopo se divide em **4 partes executáveis** em série. Cada parte é uma spec separada com DoD próprio e auditável independentemente.

### Parte 1 — Criar o FactQuery builder

- Criar `src/arandu/_fact_query.py` com a classe `FactQuery` completa:
  - 2 factory methods (`active`, `all_versions`)
  - Todos os filtros que os 25 sites levantados precisam
  - Todos os 4 `order_by_*` methods com tiebreaker embutido
  - `.build()` retornando `Select[MemoryFact]`
  - Suporte a `similarity` via tuple return
- Testes completos do builder (unit tests, sem tocar em nenhum site existente).
- Zero mudança em qualquer outro arquivo. **O builder existe mas ainda não é usado.**
- Docstrings completas — esse arquivo vai ser referência de leitura pra próximas migrações.

### Parte 2 — Migrar read pipeline

Migrar todos os 10 query sites de `src/arandu/read/*`:

- `retrieval.py`: `semantic_search`, `keyword_search`, `_resolve_by_slug`
- `graph_retrieval.py`: `_build_edge_dicts`, `_fetch_facts_for_entities` (primary + fallback)
- `spreading_activation.py`: `_fetch_facts_by_ids`, `_fetch_neighbor_facts` (primary + fallback), `_fetch_cluster_facts`
- `query_expansion.py`: `_fetch_entity_context`

**Esse é o sprint que resolve os bugs reportados.** Determinismo do retrieve volta. Personal Genius pode seguir com a Spec 2 (reranker benchmark) em base estável. Shippar como v0.12.0 (minor bump — interno mudou mas comportamento fica mais correto, não quebrado).

Testes existentes de read devem continuar passando. Os mocks podem precisar de ajuste pra reflectir a nova cadeia de chamadas.

### Parte 3 — Migrar write pipeline

Migrar `src/arandu/write/reconcile.py`:

- `_fetch_entity_facts` → `FactQuery.active(agent_id).for_entity(entity_key).order_by_recency().limit(50).build()`
- `_find_similar_facts` (raw SQL via `text()`) → se possível, reescrever como `FactQuery`. Se não couber (vector ops específicas), documentar a exceção no `_fact_query.py` e adicionar ao whitelist do teste arquitetural.

Pontos importantes:
- **Não mexer em `_add_fact`, `_update_fact`, `_confirm_fact`, `_delete_fact`.** Esses são point-lookups por ID ou mutations — fora do escopo do builder.
- **Não mexer no invariante de Fact creation.** O invariante documentado em 2026-04-09 (`grep "MemoryFact(" src/arandu/write/` ≤ 2 matches) continua valendo.

### Parte 4 — Migrar background jobs

Migrar os 7 sites de `src/arandu/background/*`:

- `consolidation.py`: `run_consolidation`, `run_profile_consolidation`
- `clustering.py`: `cluster_user_facts` (scan + repr lookup), `rebuild_clusters_for_corrected_facts` (2 sites)
- `memify.py`: `run_memify` (step 1 + step 3)

Background jobs fazem full scans (sem limit) em alguns sites — o tiebreaker ainda importa pra estabilidade de clustering/consolidation entre runs. Full scan com ordenação determinística é requisito pra reproduzibilidade de outputs desses jobs.

### Enforcement final (junto com a Parte 2 ou isolado)

- Teste arquitetural de regressão: scan de `src/arandu/` buscando `select(MemoryFact)` fora de `_fact_query.py` + whitelist de exceções (point lookups por ID).
- Atualizar `.vibeflow/patterns/data-access.md` com a nova seção "Querying MemoryFact".
- Atualizar `.vibeflow/decisions.md` com a decisão "FactQuery as single source of read queries for MemoryFact".

## Anti-scope

- **Repository completo** com `save`/`delete`/`update`. O builder é read-only. Mutations continuam em `_add_fact`/`_update_fact` (invariante existente).
- **Query builder pra outras entidades** (`MemoryEntity`, `MemoryEvent`, `MemoryEntityRelationship`, etc.). Se no futuro virar dor, spec separada. A dor atual é só em `MemoryFact`.
- **Caching.** Stateless. Sem memoization, sem LRU, nada.
- **ORM wrapping além do que SQLAlchemy já oferece.** O builder retorna `Select[MemoryFact]` — o mesmo tipo que o código atual. Não substitui SQLAlchemy, apenas padroniza o uso.
- **Reescrever point-lookups por ID** (`_add_fact`, `_update_fact`, `_confirm_fact`, `_delete_fact`, `get()` no client). Esses são `select(MemoryFact).where(id == X)` determinísticos. Forçar usar o builder pra eles é over-engineering. Entram no whitelist do teste arquitetural.
- **Abstrair a raw SQL do pgvector em `reconcile.py::_find_similar_facts`** se não couber no builder naturalmente. Documentar como exceção e parar ali. Não vale reescrever pgvector operators pra se encaixar.
- **Mexer no reranker, no `min_reranker_score`, ou no cross-language issue.** Isso é a Spec 2 (investigation) separada, descrita em outra thread. Não entra aqui.
- **Mexer na semântica dos filtros existentes.** O builder replica 1:1 o que já existe. Se algum site estava filtrando "errado" (ex: sem `valid_to IS NULL` quando deveria), esse não é o lugar de corrigir. Migra com o comportamento atual e abre bug separado se achar algo.
- **Migração parcial dos 25 sites.** Tudo ou nada por parte. Ou a Parte 2 migra TODOS os 10 sites de read, ou não migra. Migração parcial deixa invariante quebrado.
- **Backward compat externa.** O builder é privado (`_fact_query.py`, prefix `_`). Não é API pública. Pode mudar livremente.

## Technical Context

### O que já existe

- **SQLAlchemy 2.0 async** com `select(Model).where(...).order_by(...)` é o padrão atual em todos os 26 sites.
- **`MemoryFact` model** em `src/arandu/models.py` com todos os campos relevantes (agent_id, valid_to, confidence, embedding_vec, cluster_id, etc.).
- **`MemoryFactEntityLink` model** com `CASCADE` no FK, usado pelo filtro `EXISTS` em `entity_keys`. Padrão já validado em 2 lugares (retrieval + get_all).
- **`get_all()` em client.py** é o único site que já tem tiebreaker (`created_at DESC, id`). Serve como referência do padrão correto e será migrado pra usar `FactQuery` como pioneiro migrado.
- **Pattern `EXISTS` subquery** pro `entity_keys` filter (da spec `retrieve-filters-part-2`) — será encapsulado no `.for_entities()` do builder.
- **Invariante "single entry point for MemoryFact creation"** documentado em `.vibeflow/decisions.md`. O builder segue o mesmo padrão pra reads.

### Patterns a seguir

- **Data Access** (`.vibeflow/patterns/data-access.md`): async SQLAlchemy, caller manages transaction. O builder **não** executa queries — quem chama ainda faz `await session.execute(stmt)`.
- **Testing** (`.vibeflow/patterns/testing.md`): mock-based, sem DB real. Testes do builder mockam `Select` compilado (ou verificam estrutura via `.compile()`).
- **Conventions** (`.vibeflow/conventions.md`): Google docstrings, type hints strict, `_` prefix pra private module.
- **Pipeline Orchestration** (`.vibeflow/patterns/pipeline-orchestration.md`): o builder não é stage de pipeline — é helper usado por stages.

### Constraints

- **Não pode quebrar os 443 testes existentes.** Migração tem que preservar comportamento 1:1. Testes podem precisar de ajuste nos mocks, mas asserts de output não mudam.
- **Não pode quebrar o MCP server em produção.** Personal Genius roda v0.11.8 hoje — quando atualizar pra v0.12.0 (pós-migração), não pode ter regressão. Critério de sucesso: o teste do Personal Genius do bug report deve retornar resultados idênticos (mesmo content) mas agora estáveis entre runs.
- **mypy strict.** O builder precisa ter types corretos — provavelmente `Select[MemoryFact]` como return type, com variante pra `Select[tuple[MemoryFact, float]]` no similarity case.
- **Ruff formatting.** Linha de 120 chars, docstrings Google-style.

## Open Questions

None — as 2 decisões abertas (nome `FactQuery` vs `FactRepository`, cobertura completa vs mínima) foram resolvidas antes da geração do PRD.
