# Spec: FactQuery Builder (Part 1/4)

> Source PRD: `.vibeflow/prds/fact-query-abstraction.md`
> Generated: 2026-04-09

## Objective

Criar o módulo `_fact_query.py` com a classe `FactQuery` completa (factory methods, filtros, ordenação com tiebreaker embutido, terminal build), sem usar em nenhum site existente — só a infraestrutura.

## Context

O PRD `fact-query-abstraction` identificou 26 query sites contra `MemoryFact` em 7 arquivos, com 25 deles sem tiebreaker estável e 14 duplicações verbatim do filtro base. Dois bugs já foram reportados em produção por causa disso (`get_all` em março, `retrieve` não-determinístico em abril).

A solução estrutural é uma classe `FactQuery` que centraliza toda construção de queries de leitura de `MemoryFact`. Esta spec **cria** o builder. Nenhum site existente é migrado aqui — a migração acontece nas Partes 2, 3 e 4.

Fazer a Parte 1 isolada significa: o builder pode ser construído, testado e revisado sem risco de quebrar nada. A migração fica separada em specs subsequentes, cada uma auditável independentemente.

## Definition of Done

1. Arquivo `src/arandu/_fact_query.py` existe e contém a classe `FactQuery` com todos os métodos listados no scope abaixo.
2. `FactQuery.active(agent_id)` e `FactQuery.all_versions(agent_id)` retornam instâncias válidas com o filtro base aplicado corretamente (`valid_to IS NULL` para `active`, sem esse filtro para `all_versions`).
3. Todos os métodos de ordenação (`order_by_recency`, `order_by_created`, `order_by_confidence`, `order_by_similarity`) embutem `MemoryFact.id` como tiebreaker final — **não há jeito de construir uma query ordenada sem tiebreaker via essa API**.
4. `.build()` retorna um `Select[MemoryFact]` (ou `Select[tuple[MemoryFact, float]]` no caso de similarity) mypy-tipado corretamente, aceitável sem erros em `python -m mypy src/arandu/`.
5. Testes unitários cobrem: cada factory method, cada filtro, cada ordenação, combinações encadeadas, similarity com embedding, `for_entities` com lista vazia e com lista preenchida, comportamento de `limit`/`offset`. Mínimo 1 teste por método público.
6. **Quality gate:** a API não expõe `order_by()` cru nem permite construir query ordenada sem passar por um `order_by_*` com tiebreaker. Isso é verificado por teste — tentar adicionar ordenação fora do conjunto definido é impossível pela assinatura (não existe método).
7. Arquivo tem docstring de módulo explicando o invariante ("única forma de consultar MemoryFact no read path"), docstrings Google-style em todos os métodos públicos, e uma referência ao PRD de origem.

## Scope

### Arquivo novo

- **`src/arandu/_fact_query.py`**: módulo privado com a classe `FactQuery`.

#### API completa da classe

**Factory methods (entry points):**

```python
@classmethod
def active(cls, agent_id: str) -> FactQuery:
    """Start a query for active facts only (valid_to IS NULL)."""

@classmethod
def all_versions(cls, agent_id: str) -> FactQuery:
    """Start a query over all fact versions (no valid_to filter).

    Used by point-lookup helpers that need to return soft-deleted facts
    — e.g. ``MemoryClient.get(fact_id)``.
    """
```

**Filter methods (return self for chaining):**

```python
def with_min_confidence(self, threshold: float) -> FactQuery:
    """Filter facts with confidence >= threshold."""

def with_embedding(self) -> FactQuery:
    """Filter facts with non-null embedding_vec (required for semantic search)."""

def for_entity(self, entity_key: str) -> FactQuery:
    """Filter facts linked to a specific entity (EXISTS subquery)."""

def for_entities(self, entity_keys: list[str]) -> FactQuery:
    """Filter facts linked to any of the given entities (EXISTS with IN)."""

def for_cluster(self, cluster_id: uuid.UUID) -> FactQuery:
    """Filter facts belonging to a specific cluster."""

def within_temporal_window(
    self, start: datetime | None, end: datetime | None
) -> FactQuery:
    """Filter facts whose valid_from is within [start, end]."""

def excluding_ids(self, fact_ids: Iterable[uuid.UUID]) -> FactQuery:
    """Filter out facts with the given IDs (useful for 'exclude seen' patterns)."""

def matching_text(self, patterns: list[str]) -> FactQuery:
    """Filter facts whose fact_text ILIKE any of the patterns (OR combination)."""

def by_ids(self, fact_ids: list[uuid.UUID]) -> FactQuery:
    """Filter to an explicit set of fact IDs."""

def with_entity_text(self) -> FactQuery:
    """Filter facts whose entity_name is not null (used by graph rendering)."""
```

**Order methods (each embeds MemoryFact.id as tiebreaker):**

```python
def order_by_recency(self) -> FactQuery:
    """Order by valid_from DESC, id (stable pagination)."""

def order_by_created(self) -> FactQuery:
    """Order by created_at DESC, id (stable pagination)."""

def order_by_confidence(self) -> FactQuery:
    """Order by confidence DESC, id."""

def order_by_similarity(self, embedding: list[float]) -> FactQuery:
    """Order by cosine distance to embedding ASC, id.

    When this ordering is used, ``build()`` returns a 2-column Select
    so the similarity score is available in the result rows.
    """
```

**Pagination:**

```python
def limit(self, count: int) -> FactQuery:
    """Limit result size."""

def offset(self, count: int) -> FactQuery:
    """Offset for pagination."""

def with_deferred_embedding(self) -> FactQuery:
    """Defer the ``embedding`` column (use with retrieval where bytes are heavy)."""
```

**Terminal:**

```python
def build(self) -> Select:
    """Return the final SQLAlchemy Select statement.

    Returns:
        Select[MemoryFact] for most methods. When ``order_by_similarity``
        was called, returns Select[tuple[MemoryFact, float]] with the
        similarity column labeled 'similarity'.
    """
```

### Testes

- **`tests/test_fact_query.py`** (novo arquivo):
  - `TestFactoryMethods`: `active` aplica filtro `valid_to IS NULL`; `all_versions` não aplica.
  - `TestFilters`: cada filtro adiciona a cláusula correta e é composável.
  - `TestOrdering`: cada `order_by_*` inclui `id` como segundo critério. Teste específico que pega o texto do SQL compilado e confirma a presença de `ORDER BY ... , memory_facts.id`.
  - `TestSimilarity`: `order_by_similarity` retorna 2-column select com label `similarity`.
  - `TestChaining`: combinação fluente `active().for_entity().order_by_recency().limit()` funciona e produz query esperada.
  - `TestPointlookup`: `by_ids([uuid])` + `all_versions()` reproduz o padrão usado por `MemoryClient.get()`.

### Docs internas (não user-facing ainda)

Nada. Atualização de `.vibeflow/patterns/data-access.md` é escopo da Parte 2 (quando o padrão começa a ser usado de verdade).

## Anti-scope

- **Nenhum site existente é migrado nesta spec.** Zero edits em `retrieval.py`, `graph_retrieval.py`, `spreading_activation.py`, `client.py`, `reconcile.py`, `background/*`. Tudo isso é Parte 2, 3, 4.
- **Teste de regressão arquitetural** (grep test pra `select(MemoryFact)` fora do builder) — fica na Parte 2, junto com a primeira migração. Fazer antes é prematuro porque o teste falharia instantaneamente nos 25 sites ainda não migrados.
- **Caching, memoization, LRU** — stateless.
- **Métodos para outras entidades** (`MemoryEntity`, `MemoryEvent`, etc.) — spec diferente se virar dor. PRD explicitamente anti-scope.
- **Operações mutating** (`save`, `update`, `delete`) — read-only. Invariante de Fact creation continua em `_add_fact`/`_update_fact`.
- **Execução de queries.** Builder só constrói `Select`. Quem chama ainda faz `await session.execute(stmt)`.
- **Suporte a raw `text()` SQL.** Se `reconcile._find_similar_facts` não couber naturalmente, fica no whitelist da Parte 3.
- **Bump de versão.** Código novo, zero uso, não justifica release. Bump fica na Parte 2 (quando a primeira migração sai).
- **Atualização do `.vibeflow/decisions.md`.** A decisão é registrada na Parte 2, junto com o enforcement.

## Technical Decisions

### 1. Classe com métodos fluentes retornando `self`, não builder imutável

**Decisão:** `FactQuery` é mutable — cada `.method()` modifica o estado interno e retorna `self`.

**Trade-off:** Alternativa seria builder imutável (cada método retorna nova instância). Mais "puro", mas cada chamada aloca novo objeto e adiciona boilerplate sem ganho real — o builder é descartável e não é compartilhado entre threads.

**Justificativa:** Simplicidade. SQLAlchemy's `Select` já é imutável internamente — o builder pode acumular configuração em atributos privados e só construir o `Select` final no `.build()`.

### 2. Estado interno acumula "pieces" da query, não um `Select` parcialmente construído

**Decisão:** `FactQuery` guarda os filtros, ordering, limit, offset, etc. em atributos internos. O `Select` só é construído de uma vez em `.build()`.

**Trade-off:** Alternativa seria acumular um `Select` e aplicar `.where()` em cada método. Seria mais simples de ler mas dificulta debug (não dá pra inspecionar o estado intermediário).

**Justificativa:** Debuggabilidade + separação entre "intenção" e "SQL final". Também permite validar regras no `.build()` (ex: "se order_by_similarity foi chamado, deve haver embedding; se limit foi chamado, tem que haver ordering — senão log warning porque paginação sem ordering é armadilha").

### 3. `order_by_similarity` tem retorno diferente do resto

**Decisão:** Quando `order_by_similarity(embedding)` é chamado, `.build()` retorna `Select[tuple[MemoryFact, float]]` (2 colunas — fato + similarity). Os outros métodos retornam `Select[MemoryFact]` simples.

**Trade-off:** Quebra a uniformidade do tipo de retorno. Alternativa seria sempre retornar 1-column e o caller computar similarity separadamente — mas isso iria duplicar código no `semantic_search`.

**Justificativa:** O `semantic_search` atual já faz `select(MemoryFact, (1 - cosine_distance).label("similarity"))` exatamente porque precisa da similarity no result row pra filtrar por `min_similarity`. O builder respeita essa necessidade real.

O tipo de retorno é controlado por flag interna: `self._similarity_query: list[float] | None`. Se setado, o `.build()` monta o 2-column select.

### 4. `for_entities(list)` vs `for_entity(single)` são métodos separados

**Decisão:** Duas variantes pra expressar single vs multi. `for_entity("x")` internamente chama `for_entities(["x"])`.

**Trade-off:** Poderia ser só `for_entities([...])` sempre. Mas o uso típico single-entity (que é a maioria no código atual) fica mais legível com `for_entity("person:pedro")` do que com `for_entities(["person:pedro"])`.

**Justificativa:** Legibilidade. Custo é 2 linhas extras de código.

### 5. `.build()` não valida consistência (ex: "limit sem order")

**Decisão:** `.build()` retorna o `Select` sem validar combinações de chamadas. Não raise se, por exemplo, `limit(10)` foi chamado sem nenhum `order_by_*`.

**Trade-off:** Alternativa seria raise ou log warning em combinações suspeitas. Mas "limit sem order" é raro no novo padrão (a migração deve adicionar ordering em todos os lugares), e a validação adicionaria complexidade.

**Justificativa:** Keep it simple. Se aparecer dor no uso real, adiciona depois.

### 6. Não há método `.where(arbitrary_condition)` pra escape hatch

**Decisão:** O builder só expõe os filtros listados. Se um site precisa de um filtro que não está no builder (ex: filtro custom), essa é evidência de que o builder precisa de um método novo.

**Trade-off:** Rigidez. Mas rigidez é o ponto — a abstração existe justamente pra forçar padronização. Escape hatch é porta de saída pra reintrodução de bugs.

**Justificativa:** Invariante só funciona se não tem escape. Se um caso real não couber (ex: `_find_similar_facts` com raw pgvector), entra no whitelist do teste arquitetural da Parte 2, não no builder.

### 7. Placement: módulo privado em `src/arandu/_fact_query.py`

**Decisão:** Nome começa com `_` (privado por convenção Python), vive na raiz do package, não numa subpasta.

**Trade-off:** Alternativa seria `src/arandu/query/fact.py` (preparando pra query builders de outras entidades). Mas YAGNI — só `MemoryFact` tem dor hoje.

**Justificativa:** Simplicidade. Se no futuro precisar builder pra outras entidades, renomeia/reorganiza na época.

## Applicable Patterns

- **Data Access** (`.vibeflow/patterns/data-access.md`): o builder retorna `Select[MemoryFact]` compatível com o pattern atual (`select(Model).where(...)`). Caller ainda gerencia transação via `session.execute()`.
- **Conventions** (`.vibeflow/conventions.md`): Google docstrings, type hints strict, `snake_case` nos métodos, `PascalCase` na classe, `_` prefix no nome do módulo privado.
- **Testing** (`.vibeflow/patterns/testing.md`): testes unitários puros, sem DB. Verificação via `.compile()` do `Select` pra inspecionar a string SQL gerada. Class-based grouping.

## Risks

1. **A API do builder não acomoda 100% dos 25 sites existentes.** Durante a migração (Parte 2-4), algum site pode precisar de filtro que não está no builder. Mitigação: revisar o levantamento de query sites antes de escrever o builder (já feito — todos os filtros listados vieram do survey) e, se algum aparecer, adicionar método na Parte 2 antes de migrar aquele site.

2. **`order_by_similarity` com retorno diferente é confuso.** Dois tipos de return no mesmo método são estranhos pro usuário. Mitigação: documentar claramente na docstring + validar via mypy no site que usa.

3. **Builder fica over-engineered pra 4 `order_by_*`.** Talvez só `recency` e `similarity` fossem suficientes. Mitigação: aceito. O survey identificou os 4 padrões usados; cortar nenhum mantém a migração 1:1.

4. **Mocking nos testes existentes vai ficar complexo.** Os testes atuais mockam `session.execute` diretamente. Com o builder, os mocks vão precisar entender `Select` construído pelo `FactQuery`. Mitigação: **isso é problema da Parte 2**, não desta. Aqui os testes só verificam que `FactQuery` gera o `Select` correto — não executam contra session mockada.

5. **Tipo de retorno (`Select[MemoryFact]` vs `Select[tuple[...]]`) quebra mypy em chamadores.** Mitigação: documentar o contrato e forçar o caller a saber qual tipo tá recebendo. Pode ser dois métodos distintos (`build_entity()` vs `build_similarity()`) se o mypy reclamar — decisão no momento de implementar.

## Dependencies

Nenhuma. É a Parte 1.
