# arandu — Documentação Completa

> Last updated: 2026-03-24

---

# arandu

**Memória de longo prazo para agentes de IA.** Extraia fatos de conversas, resolva entidades, reconcilie conhecimento ao longo do tempo e recupere contexto relevante — tudo com PostgreSQL e pgvector.

> *O nome "Arandu" vem da palavra Guarani que significa "sabedoria adquirida pela experiencia" — literalmente "ouvir o tempo." Assim como o conceito Guarani descreve o conhecimento construido atraves da vivencia, o Arandu da ao seu agente de IA a capacidade de acumular, consolidar e recuperar conhecimento ao longo do tempo.*

---

## Por que arandu?

A maioria dos agentes de IA é stateless. Eles esquecem tudo entre sessões. O `arandu` dá ao seu agente uma memória persistente e estruturada que fica mais inteligente com o tempo:

- **Extração automática de fatos** — O write pipeline usa LLMs para extrair entidades, fatos e relacionamentos de linguagem natural.
- **Entity resolution** — Reconhece que "minha esposa Ana", "Ana" e "ela" se referem à mesma pessoa, usando um resolver de 3 fases (exact → fuzzy → LLM).
- **Reconciliação de conhecimento** — Decide se uma informação nova deve ADD, UPDATE ou DELETE fatos existentes. Sem duplicatas, sem dados obsoletos.
- **Retrieval multi-signal** — Combina busca semântica (pgvector), keyword matching, graph traversal e scoring de recência para encontrar os fatos mais relevantes.
- **Manutenção em background** — Clustering, consolidation e importance scoring mantêm a memória organizada e atualizada — como o cérebro consolida durante o sono.
- **Provider-agnostic** — Traga seu próprio LLM e embedding provider via protocolos Python simples. Provider OpenAI incluído.

## Instalação

```bash
pip install arandu
```

Com suporte a OpenAI (recomendado):

```bash
pip install arandu[openai]
```

### Requisitos

- Python 3.11+
- PostgreSQL com a extensão [pgvector](https://github.com/pgvector/pgvector)

## Quick Start

```python
import asyncio
from arandu import MemoryClient
from arandu.providers.openai import OpenAIProvider

async def main():
    # 1. Configurar providers
    provider = OpenAIProvider(api_key="sk-...")

    # 2. Criar client
    memory = MemoryClient(
        database_url="postgresql+psycopg://user:pass@localhost/mydb",
        llm=provider,
        embeddings=provider,
    )

    # 3. Inicializar tabelas (idempotente)
    await memory.initialize()

    # 4. Write — extrai fatos automaticamente
    result = await memory.write(
        user_id="user_123",
        message="I live in São Paulo and work at Acme Corp as a backend engineer.",
    )
    print(f"Added {len(result.facts_added)} facts, resolved {len(result.entities_resolved)} entities")

    # 5. Retrieve — encontra contexto relevante
    context = await memory.retrieve(
        user_id="user_123",
        query="where does the user live and work?",
    )
    print(context.context)

    # 6. Cleanup
    await memory.close()

asyncio.run(main())
```

## Como Funciona

### Write Pipeline

```
Message → Extract (LLM) → Resolve Entities → Reconcile → Upsert
```

Toda mensagem passa por quatro estágios: o LLM extrai fatos estruturados, entidades são resolvidas para registros canônicos, novos fatos são reconciliados contra o conhecimento existente, e as decisões (ADD/UPDATE/NOOP/DELETE) são executadas.

> [Saiba mais sobre o Write Pipeline](concepts/write-pipeline.md)

### Read Pipeline

```
Query → Plan (LLM) → Retrieve (semantic + keyword + graph) → Rerank → Format
```

Queries passam por um LLM planner que decide a estratégia de retrieval, depois três sinais paralelos são combinados, opcionalmente rerankeados, e comprimidos em uma string de contexto.

> [Saiba mais sobre o Read Pipeline](concepts/read-pipeline.md)

### Background Jobs

```
Clustering → Consolidation → Importance Scoring → Summary Refresh
```

Jobs periódicos em background mantêm a memória organizada e atualizada — como o processamento durante o sono no cérebro.

> [Saiba mais sobre Background Jobs](concepts/background-jobs.md)

## Arquitetura

O `arandu` é projetado em torno de três princípios:

1. **DI baseada em Protocol** — LLM e embedding providers são injetados via `typing.Protocol`. Sem vendor lock-in.
2. **Fail-safe por padrão** — Toda chamada LLM tem timeouts e fallbacks. Uma extração falha ainda registra o evento. Uma reconciliação falha tem default ADD.
3. **Composição sobre herança** — Módulos pequenos e focados compostos em pipelines. Sem hierarquias profundas de classes.

> [Saiba mais sobre a Filosofia de Design](concepts/design-philosophy.md)

## Próximos Passos

<div class="grid cards" markdown>

- :material-rocket-launch:{ .lg .middle } **Primeiros Passos**

    ---

    Guia completo de setup: PostgreSQL, pgvector, primeiro write e retrieve.

    [:octicons-arrow-right-24: Primeiros Passos](getting-started.md)

- :material-brain:{ .lg .middle } **Conceitos**

    ---

    Deep dive em como cada pipeline funciona e por quê.

    [:octicons-arrow-right-24: Write Pipeline](concepts/write-pipeline.md)

</div>


---

# Primeiros Passos

Este guia te leva do zero ao funcionamento com `arandu`: instalando dependências, configurando PostgreSQL com pgvector, escrevendo seus primeiros fatos e recuperando-os.

## Pré-requisitos

- **Python 3.11+**
- **PostgreSQL 15+** com a extensão [pgvector](https://github.com/pgvector/pgvector) instalada
- Uma **chave de API da OpenAI** (ou qualquer LLM/embedding provider — veja [Custom Providers](#custom-providers))

## Passo 1: Instalação

```bash
pip install arandu[openai]
```

Isso instala o SDK core mais o provider OpenAI incluído. Se você usa um LLM provider diferente, instale apenas o core:

```bash
pip install arandu
```

## Passo 2: Configurar PostgreSQL + pgvector

O `arandu` armazena fatos, entidades e embeddings no PostgreSQL usando a extensão pgvector para busca por similaridade vetorial.

### Opção A: Docker (recomendado para desenvolvimento)

```bash
docker run -d \
  --name memory-db \
  -e POSTGRES_USER=memory \
  -e POSTGRES_PASSWORD=memory \
  -e POSTGRES_DB=memory \
  -p 5432:5432 \
  pgvector/pgvector:pg16
```

A imagem `pgvector/pgvector` já vem com a extensão pré-instalada. Sua connection string será:
`postgresql+psycopg://memory:memory@localhost:5432/memory`

!!! warning "psycopg vs psycopg2"
    O Arandu usa `psycopg` (driver async), **não** `psycopg2` (sync). Sua connection string deve começar com `postgresql+psycopg://`, não `postgresql+psycopg2://`. Muitos tutoriais de Django/Flask usam psycopg2 — certifique-se de usar o correto.

### Opção B: PostgreSQL existente

Se você já tem PostgreSQL rodando, habilite a extensão pgvector:

```sql
CREATE EXTENSION IF NOT EXISTS vector;
```

!!! note "Instalação do pgvector"
    Se você não tem o pgvector instalado no seu servidor, siga o
    [guia de instalação do pgvector](https://github.com/pgvector/pgvector#installation).

## Passo 3: Inicializar o Client

```python
import asyncio
from arandu import MemoryClient, MemoryConfig
from arandu.providers.openai import OpenAIProvider

async def main():
    # Criar o provider de LLM + embedding
    provider = OpenAIProvider(api_key="sk-...")

    # Criar o memory client
    memory = MemoryClient(
        database_url="postgresql+psycopg://memory:memory@localhost:5432/memory",
        llm=provider,
        embeddings=provider,
    )

    # Criar tabelas (seguro chamar múltiplas vezes)
    await memory.initialize()

    print("Memory initialized!")
    await memory.close()

asyncio.run(main())
```

O `initialize()` cria todas as tabelas e índices necessários (incluindo índices HNSW do pgvector). É idempotente — seguro para chamar a cada startup.

!!! info "Sobre o `user_id`"
    O `user_id` é sua **chave de particionamento**. Cada user_id tem seu próprio espaço de memória isolado — fatos escritos para um user nunca são retornados para outro. Use o identificador de usuário da sua aplicação (ID do banco, email, UUID — qualquer string). O mesmo user_id deve ser usado em `write()` e `retrieve()` para o mesmo usuário.

## Passo 4: Escrever Seus Primeiros Fatos

O método `write()` recebe uma mensagem em linguagem natural e automaticamente:

1. Extrai entidades, fatos e relacionamentos usando um LLM
2. Resolve entidades para registros canônicos (deduplicação)
3. Reconcilia novos fatos contra o conhecimento existente
4. Faz upsert dos resultados no banco de dados

```python
async def write_example(memory: MemoryClient):
    # Primeira mensagem
    result = await memory.write(
        user_id="user_123",
        message="My name is Rafael and I live in São Paulo. I work at Acme Corp as a backend engineer.",
    )
    print(f"Facts added: {len(result.facts_added)}")
    for fact in result.facts_added:
        print(f"  [{fact.entity_name}] {fact.value_text} (confidence: {fact.confidence})")
    # Output:
    #   [Rafael] Lives in São Paulo (confidence: 0.95)
    #   [Rafael] Works at Acme Corp as a backend engineer (confidence: 0.95)
    #   [Acme Corp] Rafael works at Acme Corp (confidence: 0.95)
    print(f"Entities resolved: {len(result.entities_resolved)}")
    print(f"Duration: {result.duration_ms:.0f}ms")

    # Segunda mensagem — o sistema reconhece "Rafael" e atualiza o conhecimento
    result = await memory.write(
        user_id="user_123",
        message="I just moved to Rio de Janeiro. Still working at Acme though.",
    )
    print(f"Facts added: {len(result.facts_added)}")
    print(f"Facts updated: {len(result.facts_updated)}")  # "lives in São Paulo" → "lives in Rio"
```

### Entendendo o WriteResult

O objeto `WriteResult` te diz exatamente o que aconteceu:

| Campo | Tipo | Descrição |
|-------|------|-----------|
| `event_id` | `str` | ID único deste evento de escrita |
| `facts_added` | `list` | Novos fatos criados (decisões ADD) |
| `facts_updated` | `list` | Fatos existentes substituídos (decisões UPDATE) |
| `facts_unchanged` | `int` | Fatos confirmados mas não alterados (decisões NOOP) |
| `facts_deleted` | `int` | Fatos retratados (decisões DELETE) |
| `entities_resolved` | `list` | Entidades identificadas e resolvidas |
| `duration_ms` | `float` | Duração total do pipeline |
| `success` | `bool` | Se o pipeline completou sem erros |
| `error` | `str \| None` | Mensagem de erro se o pipeline falhou internamente |

## Passo 5: Recuperar Contexto

O método `retrieve()` encontra fatos relevantes para uma query usando múltiplos sinais:

```python
async def retrieve_example(memory: MemoryClient):
    result = await memory.retrieve(
        user_id="user_123",
        query="where does Rafael live and what does he do?",
    )

    # Opção 1: String pré-formatada — cole direto no prompt do LLM
    print(result.context)

    # Opção 2: Fatos individuais — para acesso programático
    for fact in result.facts:
        print(f"  [{fact.score:.2f}] {fact.entity_name}: {fact.value}")

    # Com ajustes de config (ex: desabilitar reranker para resultados mais rápidos)
    fast_result = await memory.retrieve(
        user_id="user_123",
        query="onde o Rafael mora?",
        config_overrides={"enable_reranker": False, "topk_facts": 5},
    )

    print(f"Total candidates evaluated: {result.total_candidates}")
    print(f"Duration: {result.duration_ms:.0f}ms")
```

!!! tip "`.context` vs `.facts`"
    Use **`result.context`** quando precisa apenas de uma string para injetar no prompt do LLM — já vem formatada com labels de tier (CORE MEMORY, EXTENDED CONTEXT, etc.). Use **`result.facts`** quando precisa de acesso programático aos fatos individuais, scores e metadados.

### Overrides de Config por Request

Você pode sobrescrever qualquer campo do `MemoryConfig` para uma única request, sem alterar o config padrão do client:

```python
result = await memory.retrieve(
    user_id="user_123",
    query="onde o Rafael mora?",
    config_overrides={
        "enable_reranker": False,
        "topk_facts": 5,
        "spreading_activation_hops": 0,
    },
)

# config_effective mostra o config efetivo usado nesta request
print(result.config_effective)
```

Apenas as chaves fornecidas são sobrescritas; todos os outros campos herdam do `MemoryConfig` do client.

### Entendendo o RetrieveResult

| Campo | Tipo | Descrição |
|-------|------|-----------|
| `facts` | `list[ScoredFact]` | Fatos ranqueados com scores |
| `context` | `str` | String de contexto pré-formatada para prompts LLM |
| `total_candidates` | `int` | Total de fatos avaliados antes do ranking |
| `duration_ms` | `float` | Duração total do pipeline |
| `config_effective` | `dict` | Valores de config efetivos usados nesta request |

Cada `ScoredFact` contém:

| Campo | Tipo | Descrição |
|-------|------|-----------|
| `fact_id` | `str` | Identificador único do fato |
| `entity_name` | `str` | Nome legível da entidade |
| `attribute_key` | `str` | Categoria/atributo do fato |
| `value` | `str` | O conteúdo do fato |
| `score` | `float` | Score combinado de relevância (0-1) |
| `scores` | `dict` | Detalhamento por sinal (semantic, recency, etc.) |

## Passo 6: Configurar (Opcional)

Todos os aspectos do pipeline são configuráveis via `MemoryConfig`:

```python
from arandu import MemoryConfig

config = MemoryConfig(
    # Modelo rápido com timeout curto
    extraction_timeout_sec=15.0,

    # Ajustar retrieval
    topk_facts=30,
    min_similarity=0.25,
    enable_reranker=True,

    # Ajustar pesos dos scores
    score_weights={
        "semantic": 0.60,
        "recency": 0.25,
        "importance": 0.15,
    },

    # Definir timezone para cálculos de recência
    timezone="America/Sao_Paulo",
)

memory = MemoryClient(
    database_url="postgresql+psycopg://memory:memory@localhost/memory",
    llm=provider,
    embeddings=provider,
    config=config,
)
```

Todos os parâmetros têm defaults sensatos — você só precisa sobrescrever o que importa para o seu caso de uso.

## Debug com Verbose Mode

Passe `verbose=True` para `write()` ou `retrieve()` para obter um trace detalhado de cada step do pipeline:

```python
result = await memory.write(user_id="user_123", message="...", verbose=True)

# Acessar o trace do pipeline
if result.pipeline:
    for step in result.pipeline.steps:
        print(f"  {step.name}: {step.duration_ms:.1f}ms")
        print(f"    data: {step.data}")
```

O trace inclui steps como `extraction`, `entity_resolution`, `reconciliation` e `upsert`, cada um com timing e dados intermediários. Se o pipeline falhar internamente, um step `error` é adicionado com os detalhes da exceção — útil para diagnosticar falhas silenciosas.

Você pode serializar o trace completo com `result.pipeline.to_dict()`.

## Passo 7: Cleanup

Sempre feche o client ao terminar para liberar conexões do banco:

```python
await memory.close()
```

Ou use como um padrão de contexto async:

```python
memory = MemoryClient(...)
await memory.initialize()
try:
    # ... usar memory
finally:
    await memory.close()
```

## Exemplo Completo

Aqui está um exemplo completo funcional juntando tudo:

```python
import asyncio
from arandu import MemoryClient
from arandu.providers.openai import OpenAIProvider


async def main():
    provider = OpenAIProvider(api_key="sk-...")
    memory = MemoryClient(
        database_url="postgresql+psycopg://memory:memory@localhost:5432/memory",
        llm=provider,
        embeddings=provider,
    )
    await memory.initialize()

    try:
        # Escrever alguns fatos
        await memory.write(
            user_id="user_123",
            message="I'm a software engineer living in Berlin. I love cycling and craft coffee.",
        )
        await memory.write(
            user_id="user_123",
            message="My girlfriend Ana is a designer. We adopted a cat named Pixel last month.",
        )

        # Recuperar contexto
        result = await memory.retrieve(user_id="user_123", query="tell me about this person")
        print(result.context)

        # Retrieval direcionado
        result = await memory.retrieve(user_id="user_123", query="who is Ana?")
        for fact in result.facts:
            print(f"  [{fact.score:.2f}] {fact.entity_name}: {fact.value}")
    finally:
        await memory.close()


asyncio.run(main())
```

## Custom Providers

O `arandu` usa protocolos Python para injeção de dependência. Você pode trazer qualquer LLM ou embedding provider implementando duas interfaces simples:

```python
from arandu.protocols import LLMProvider, EmbeddingProvider

class MyLLMProvider:
    async def complete(
        self,
        messages: list[dict],
        temperature: float = 0,
        response_format: dict | None = None,
        max_tokens: int | None = None,
    ) -> str:
        # Chame seu LLM aqui
        ...

class MyEmbeddingProvider:
    async def embed(self, texts: list[str]) -> list[list[float]]:
        # Retorne embeddings para um batch
        ...

    async def embed_one(self, text: str) -> list[float] | None:
        # Retorne embedding para um único texto
        ...
```

Sem herança necessária — apenas implemente os métodos com as assinaturas corretas.

## Próximos Passos

- [**Write Pipeline**](concepts/write-pipeline.md) — Entenda como fatos são extraídos, entidades resolvidas e conhecimento reconciliado
- [**Read Pipeline**](concepts/read-pipeline.md) — Aprenda como o retrieval multi-signal encontra os fatos mais relevantes
- [**Tipos de Dados & Schema**](advanced/data-types.md) — Referência do schema do banco (tabelas, colunas, tipos) para queries SQL diretas
- [**Background Jobs**](concepts/background-jobs.md) — Configure clustering, consolidation e importance scoring
- [**Filosofia de Design**](concepts/design-philosophy.md) — Explore a arquitetura inspirada em neurociência


---

# Write Pipeline

Quando você chama `memory.write()`, o SDK lê uma mensagem em linguagem natural e automaticamente extrai quem e o que foi mencionado, descobre se é informação nova ou atualizada, e armazena tudo como fatos estruturados e versionados — numa única chamada.

**Você não precisa entender os internals pra usar.** Basta chamar `write()` e verificar `result.facts_added`. Esta página explica o que acontece por baixo dos panos, pra quando você quiser ajustar o comportamento ou debugar resultados.

```mermaid
flowchart LR
    A["Message"] --> B["Extract"]
    B --> C["Resolve Entities"]
    C --> D["Reconcile"]
    D --> E["Upsert"]
    E --> F["WriteResult"]
```

## Visão Geral

Toda chamada a `memory.write(user_id, message)` executa estes passos:

0. **Guard** — Mensagens vazias retornam imediatamente. Sem evento, sem chamada LLM, sem tokens consumidos.
1. **Registrar o evento** — A mensagem bruta é salva como trilha de auditoria imutável (nunca modificada ou deletada).
2. **Detectar emoção** — Classifica a emoção, intensidade e nível de energia da mensagem.
3. **Extrair** — Um LLM lê a mensagem e identifica pessoas, lugares, fatos e relacionamentos.
4. **Resolver entidades** — Deduplica menções ("Ana", "minha esposa Ana", "Aninha") em uma única entidade canônica.
5. **Reconciliar** — Compara cada fato novo contra o que já é conhecido. É novo? Uma atualização? Já conhecido? Uma retratação?
6. **Upsert** — Salva os resultados no banco de dados.

Cada estágio é independentemente fail-safe: se a extração falhar, o evento ainda é registrado. Se a reconciliação falhar para um fato, os outros prosseguem normalmente.

---

## Estágio 1: Extraction

**Em português claro:** O SDK manda sua mensagem pra um LLM e pergunta "Quais pessoas, lugares, fatos e relacionamentos são mencionados aqui?" O LLM retorna dados estruturados que o pipeline consegue trabalhar. Pense nisso como um parser inteligente que entende linguagem natural.

O estágio de extraction usa um LLM para transformar linguagem natural em dados estruturados: entidades, fatos e relacionamentos.

### Como Funciona

A extração roda 3 chamadas LLM, com as duas últimas concorrentes:

1. **Entity scan** — Identifica todas as entidades mencionadas na mensagem
2. **Fact extraction + Relation extraction** — Rodam concorrentemente via `asyncio.gather()`: fact extraction recebe todas as entidades numa única chamada, enquanto relation extraction identifica relacionamentos entre entidades

A extração de relations inclui um **retry automático**: se o LLM retornar 0 relations mas 2+ entities foram encontradas, o SDK repete a chamada uma vez. Quando `verbose=True`, o trace inclui `relation_retry_triggered`.

**Extração subject-centric:** Facts são extraídos do ponto de vista do subject principal apenas. "Carlos mora em Curitiba" é um fact sobre Carlos — o sistema NÃO cria "Curitiba é onde Carlos mora". O relacionamento `Carlos → lives_in → Curitiba` + entity links cuidam do retrieval cross-entity.

**Dedup semântico:** Após a extração, facts são comparados par a par por cosine similarity de embeddings. Near-duplicates (> 0.85 de similaridade) são removidos, mantendo a primeira ocorrência. Isso elimina reformulações cross-entity que o LLM às vezes produz apesar das instruções do prompt.

### Configuração

| Parâmetro | Default | Descrição |
|-----------|---------|-----------|
| `extraction_timeout_sec` | `30.0` | Timeout por chamada LLM |

### O Que é Extraído

Para cada mensagem, o estágio de extraction produz:

- **Entidades** — Coisas nomeadas: pessoas, organizações, lugares, conceitos, etc.
- **Fatos** — Afirmações auto-contidas sobre entidades em linguagem natural (ex: "Fernanda Lima é engenheira de software", "Marcos Tavares mora em Porto Alegre"). Cada texto de fato sempre inclui o nome da entidade — nunca apenas "é engenheira de software" sem um sujeito. Toda relação também gera um fato correspondente — então "Sarah é minha esposa" produz tanto uma relação (`user → spouse_of → sarah`) quanto um fato ("Sarah é esposa do user"). Fatos duplicados (mesmo subject + mesmo texto, ignorando pontuação) são removidos automaticamente pós-extração.
- **Relações** — Conexões entre entidades (ex: "Rafael" → `works_at` → "Acme Corp"). Relações servem como arestas de grafo pra traversal; o fato pareado torna a informação pesquisável via texto/embedding.

Cada fato inclui um **nível de confiança**:

| Nível | Score | Exemplo |
|-------|-------|---------|
| Declaração explícita | 0.95 | "Eu moro em São Paulo" |
| Inferência forte | 0.80 | "Fomos ao escritório de São Paulo" (implica localização) |
| Inferência fraca | 0.60 | Implicação contextual |
| Especulação | 0.40 | Informação incerta |

!!! note "Como a confiança funciona na prática"
    A confiança é atribuída pelo LLM durante a extração com base em como a informação foi declarada. Afirmações diretas ("Eu moro em SP") recebem alta confiança; afirmações incertas ("Acho que talvez...") recebem confiança mais baixa. Você não pode definir a confiança diretamente — ela é inferida. Você pode filtrar fatos de baixa confiança no retrieval usando `min_confidence` no MemoryConfig (padrão 0.55).

### Agrupamento de Aliases e Normalização de Subjects

Quando a mesma entidade é mencionada por múltiplos nomes em uma única mensagem (ex: "meu amigo Guili (Guilherme Maturana)"), a extração agrupa tudo em uma **única entidade com aliases** ao invés de criar duplicatas.

O LLM é instruído a escolher um nome canônico (geralmente o mais completo) e listar os outros como aliases:

```json
{
  "entities": [
    {"name": "Guilherme Maturana", "type": "person", "aliases": ["Guili"]}
  ]
}
```

Após a extração, um passo de **normalização de subjects** reescreve qualquer fato ou relação que referencia um alias para usar o nome canônico. Relações de identidade (ex: `same_as` entre um alias e seu nome canônico) são removidas automaticamente, já que se tornam auto-referenciantes após a normalização.

Isso elimina a duplicação de entidades intra-mensagem na origem — antes mesmo do entity resolution rodar.

### Entity Types

Entity types são **strings livres** — o LLM escolhe o tipo mais apropriado pra cada entidade. Tipos comuns incluem `person`, `organization`, `place`, `product`, `event`, `concept`, `pet`, mas qualquer tipo descritivo é aceito. Types são normalizados pra lowercase durante a entity resolution (ex: `"Person"` → `"person"`, `"PRODUCT"` → `"product"`).

O prompt de extração instrui o LLM a classificar types com cuidado — por exemplo, cidades são `place`, empresas são `organization`, produtos de software são `product`.

### Comportamento Fail-safe

Se uma chamada LLM falhar (timeout, JSON inválido, rate limit), a extraction retorna um resultado vazio em vez de lançar uma exceção. O evento ainda é registrado — nenhum dado é perdido. A próxima mensagem pode capturar a mesma informação.

!!! tip "Detectando timeouts"
    Quando a extração sofre timeout, o resultado é indistinguível de "mensagem sem conteúdo extraível" — 0 entidades, 0 fatos, sem exceção. Para detectar timeouts, compare o `duration_ms` da extração no trace com o `extraction_timeout_sec` configurado, ou verifique 0 entidades apesar de uma mensagem com conteúdo.

!!! info "Paralelo com neurociência"
    A extraction espelha a **codificação** na memória humana — o processo de converter input sensorial (uma conversa) em um traço de memória. Assim como a codificação humana é seletiva (não lembramos cada palavra), o LLM extrai apenas fatos e entidades salientes.

---

## Estágio 2: Entity Resolution

**Em português claro:** Quando alguém fala "Ana", "minha esposa Ana" e "Aninha" em mensagens diferentes, estão falando da mesma pessoa. Este estágio descobre isso e linka tudo a uma única entidade canônica — pra você não acabar com três registros separados de "Ana" no banco.

### Resolução em Três Fases

```mermaid
flowchart LR
    A["Entity name"] --> B{"Exact match?"}
    B -->|Yes| F["Resolved"]
    B -->|No| C{"Fuzzy match?"}
    C -->|"≥ 0.85"| F
    C -->|"0.50–0.85"| D{"LLM decides"}
    C -->|"< 0.50"| E["Create new entity"]
    D -->|Match| F
    D -->|No match| E
    E --> F
```

**Fase 1: Exact match**

Verifica o cache de aliases, slugs de entidades e display names. Instantâneo, sem chamada LLM.

Inclui **prefix/diminutive matching** para entidades do tipo pessoa: "Carol" corresponde a "Carolina" (mínimo 3 caracteres). Nota: "Jo" NÃO corresponde a "João" (< 3 chars). "Bob" corresponde a "Roberto" apenas se registrado como alias, não via prefix matching.

**Fase 2: Fuzzy match**

Usa similaridade de cosseno via embedding (in-memory) para encontrar candidatos:

- **≥ `fuzzy_threshold`** (default 0.85) — Match de alta confiança, resolve diretamente
- **0.50–`fuzzy_threshold`** — Ambíguo, encaminha top-3 candidatos para a Fase 3 (LLM)
- **< 0.50** — Sem match, cria uma nova entidade

Reduzir `fuzzy_threshold` expande a faixa de fuzzy-resolve e reduz chamadas LLM. Por exemplo, definir `fuzzy_threshold=0.50` elimina a faixa ambígua inteiramente — tudo acima de 0.50 resolve diretamente.

Faz fallback para `difflib.SequenceMatcher` quando embeddings não estão disponíveis.

**Fase 3: LLM fallback**

Envia candidatos ambíguos para o `LLMProvider` injetado para desambiguação. O LLM vê o nome da entidade, os candidatos, e decide qual (se algum) é um match.

??? example "Passo a passo: como a entity resolution funciona"
    **Mensagem:** "Falei com o Guili sobre o projeto. O Guilherme disse que tá no prazo."

    1. **Extração:** Dois nomes encontrados: "Guili" e "Guilherme"
    2. **Fase 1 (exata):** "Guilherme" bate com a entidade existente `person:guilherme_maturana`
    3. **Fase 2 (fuzzy):** "Guili" tem 0.87 de similaridade com "Guilherme" → resolve automaticamente
    4. **Resultado:** Ambos os nomes resolvem para a mesma entidade. Alias "Guili" registrado.

    Da próxima vez que "Guili" aparecer, a Fase 1 resolve instantaneamente via cache de aliases — sem precisar de fuzzy ou LLM.

### Casos Especiais

- **Auto-referências** — "I", "me", "eu", "myself" resolvem automaticamente para `user:self`
- **Termos de relacionamento** — "my girlfriend", "my brother", "meu amigo" resolvem para `user:self` (o relacionamento é sobre o usuário, não uma entidade separada)
- **Dicas relacionais** — `"Carol (namorada do Rafael)"` remove a dica e força `type="person"`

### Registro de Aliases

Quando um novo alias é descoberto (ex: "Aninha" resolve para `person:ana`), ele é registrado em `MemoryEntityAlias` com semântica **first-write-wins** — escritas concorrentes não criam aliases conflitantes. Aliases são scoped por `user_id`: o mesmo alias pode mapear para entidades diferentes para usuários diferentes.

**Aliases da extração** também são registrados automaticamente: quando o entity resolution cria uma nova entidade que tem aliases da extração (ex: "Guili" para "Guilherme Maturana"), todos os aliases são registrados em `MemoryEntityAlias` e adicionados ao cache de aliases in-memory. Isso significa que entidades subsequentes no mesmo batch podem resolver imediatamente via Fase 1 exact match — sem chamadas fuzzy ou LLM.

Isso cria uma defesa em duas linhas contra duplicatas:

1. **Intra-mensagem** — Agrupamento de aliases na extração previne duplicatas dentro de uma mesma mensagem
2. **Cross-mensagem** — Aliases registrados habilitam exact match em mensagens futuras (ex: se mensagem 1 cria "Guilherme Maturana" com alias "Guili", mensagem 2 mencionando "Guili" resolve instantaneamente via Fase 1)

### Persistência de Entidades

Após o entity resolution completar, o pipeline garante que **toda entidade resolvida** tenha um row na tabela `memory_entities` — não apenas as recém-criadas. Entidades resolvidas via exact match, fuzzy match ou LLM também recebem upsert via `ON CONFLICT DO UPDATE` (idempotente).

Isso é crítico porque background jobs (importance scoring, summary refresh, spreading activation) leem de `memory_entities`. Sem um row, esses jobs ficam cegos à entidade e não operam sobre ela.

O upsert de entidades é fail-safe: se uma entidade falhar ao persistir (ex: constraint violation), as outras prosseguem normalmente e o pipeline continua.

### Configuração

| Parâmetro | Default | Descrição |
|-----------|---------|-----------|
| `fuzzy_threshold` | `0.85` | Threshold de similaridade de cosseno para fuzzy match direto |
| `enable_llm_resolution` | `True` | Se deve usar LLM para casos ambíguos. Quando `False`, candidatos ambíguos criam uma nova entidade ao invés de chamar LLM. |

!!! note "Seleção de modelo"
    O modelo LLM usado para entity resolution é determinado pelo `LLMProvider` injetado no `MemoryClient`. Para usar um modelo diferente para resolução vs. extração, injete providers diferentes.

!!! info "Paralelo com neurociência"
    A entity resolution espelha a **memória associativa** — a capacidade do cérebro de vincular novos estímulos a representações existentes. Ouvir "Carol" ativa o padrão neural de "Carolina" através de pattern completion, assim como o fuzzy matching ativa entidades candidatas através de similaridade de embedding.

---

## Estágio 3: Reconciliação

**Em português claro:** Se o usuário disse "Eu moro em São Paulo" na semana passada e agora diz "Me mudei pro Rio", o sistema precisa entender que isso é uma atualização, não uma segunda casa. Este estágio compara cada fato novo contra o que já tá armazenado e decide: é informação nova? Atualização de algo existente? Já conhecido? Ou uma retratação?

### Lógica de Decisão

Para cada fato extraído, o reconciler:

1. **Busca fatos existentes** para a mesma entidade
2. **Calcula similaridade** entre o novo fato e cada fato existente (via embeddings)
3. **Decide a ação**:

| Ação | Quando | Exemplo |
|------|--------|---------|
| **ADD** | Informação nova, sem fato existente similar (similaridade < 0.50) | "fala francês" quando não existe fato de idioma |
| **UPDATE** | Substitui um fato existente (similaridade 0.50-0.85+) | "mora no Rio" substitui "mora em São Paulo" |
| **NOOP** | Já é conhecido (alta similaridade) | "trabalha na Acme" quando esse fato já existe |
| **DELETE** | Retrata explicitamente um fato | "Não trabalho mais na Acme" |

### Performance da Reconciliação

- **Fast path (similaridade < 0.50):** Auto-ADD sem chamada LLM (~300ms). Caminho comum para informação nova.
- **Slow path (similaridade ≥ 0.50):** LLM avalia se deve ADD, UPDATE, DELETE ou NOOP (~2-3s). Requer chamada LLM com contexto completo.

Planeje adequadamente: importações em massa de dados novos são rápidas; atualizações de conhecimento existente requerem decisão do LLM.

!!! note "Chains de UPDATE podem ramificar"
    O LLM de reconciliação pode escolher ADD ao invés de UPDATE quando interpreta a nova informação como distinta, não como substituição. Por exemplo, "me mudei pra BH" pode criar fatos separados para "mora em BH" e "morou no RJ" ao invés de um simples update chain. Isso preserva mais informação mas pode quebrar a chain de `supersedes_fact_id`. Este é o comportamento esperado — o LLM prioriza preservação de informação.

### Comportamento Fail-safe

Se a chamada LLM de reconciliação falhar, o sistema faz default para **ADD** — é melhor ter uma quase-duplicata do que perder informação. Os jobs de consolidation em background (clustering, deduplicação) limpam duplicatas depois.

### Versionamento de Fatos

Fatos são versionados usando janelas de validade temporal (`valid_from`, `valid_to`):

- **Fatos ativos** têm `valid_to = NULL`
- **Fatos atualizados** recebem tanto `valid_to` quanto `invalidated_at` definidos, e um novo fato é criado com `supersedes_fact_id` apontando para o antigo
- **Fatos deletados** recebem tanto `valid_to` quanto `invalidated_at` definidos

Isso permite queries de time-travel: você pode perguntar o que o sistema sabia em qualquer momento no tempo.

!!! info "Paralelo com neurociência"
    A reconciliação espelha a **reconsolidação** — o processo pelo qual memórias recuperadas se tornam lábeis e podem ser modificadas. Quando você recupera uma memória ("mora em São Paulo") e encontra nova informação ("acabou de se mudar pro Rio"), a memória original é atualizada. O cérebro não simplesmente sobrescreve — ele cria um novo traço ligado ao original, assim como UPDATE cria um novo fato com `supersedes_fact_id`.

---

## Estágio 4: Upsert

**Em português claro:** Aqui as decisões do estágio anterior são de fato salvas no banco. Fatos novos são inseridos, fatos desatualizados são marcados como substituídos, e relacionamentos entre entidades são criados ou reforçados. Tudo roda dentro de uma transação — se um fato falhar ao salvar, os outros ainda passam.

O estágio de upsert executa as decisões de reconciliação no banco de dados:

| Decisão | Ação no banco |
|---------|---------------|
| ADD | Cria novo `MemoryFact` com embedding |
| UPDATE | Fecha fato antigo (`valid_to = now`), cria novo com `supersedes_fact_id` |
| NOOP | Atualiza `last_confirmed_at` no fato existente |
| DELETE | Fecha fato (`valid_to = now`, `invalidated_at = now`) |

### Fact-Entity Links

Após cada fato ser persistido (ADD ou UPDATE), o pipeline cria **entity links** conectando o fato a todas as entidades que ele menciona — não só ao subject primário. Isso permite retrieval cross-entity sem duplicar fatos.

Por exemplo, "Clara Rezende saiu da Vertix" é armazenado **uma vez** com `entity_key = person:clara_rezende` (subject primário). Mas entity links são criados tanto pra `person:clara_rezende` (primário) quanto pra `organization:vertix` (secundário). Quando se busca sobre Vertix, o sistema encontra esse fato via link — sem necessidade de fato duplicado.

Links são criados por match de display names contra o texto do fato (case-insensitive substring match). Nomes muito curtos (< 3 caracteres) são pulados pra evitar false positives. A criação de links é fail-safe: se falhar, o fato persiste normalmente.

### Rastreamento de Relacionamentos

Durante o upsert, relacionamentos extraídos também são persistidos:

- Cria/atualiza registros `MemoryEntityRelationship`
- Resolve entidades de origem e destino via entity map
- **Reforço de strength**: relacionamentos repetidos aumentam `strength` (inicial: 0.8, reforçado até 1.0 ao longo de múltiplas mensagens)
- Usa `ON CONFLICT DO UPDATE` para upserts idempotentes

!!! warning "Relacionamentos são **unidirecionais**"
    Escrever "Ana trabalha na Acme" cria `ana → works_at → acme_corp`, mas **não** `acme_corp → employs → ana`. Isso significa que o graph retrieval começando por "Acme Corp" não vai encontrar Ana via relacionamentos (mas pode encontrá-la via similaridade semântica). Para criar ambas as direções, mencione-as explicitamente: "Ana trabalha na Acme. A Acme tem a Ana como data scientist."

#### Vinculação de Evidência e Cascata de Invalidação

**O problema:** Sem vinculação entre fatos e relacionamentos, arestas contraditórias se acumulam. Se um usuário diz "moro em Curitiba" e depois "me mudei pra São Paulo", o relacionamento antigo `user --[lives_in]--> curitiba` continuaria ativo junto com o novo — poluindo o retrieval com contexto obsoleto.

**A solução:** Cada relacionamento é vinculado ao fato que o sustenta via `evidence_fact_id`. Quando esse fato é substituído (UPDATE) ou retratado (DELETE), o relacionamento é **automaticamente invalidado** — sem necessidade de limpeza manual.

Como a vinculação de evidência funciona:

1. Após os fatos serem persistidos no estágio de upsert, um match heurístico associa cada relacionamento a um fato correspondente. Para um relacionamento `(source, target)`, o matcher busca fatos cujo `fact_text` menciona ambos os nomes de entidade.
2. Se múltiplos fatos fizerem match, o de maior confidence é selecionado.
3. O ID do fato é armazenado como `evidence_fact_id` no relacionamento.

Quando um fato é invalidado (via UPDATE ou DELETE), a **cascata de invalidação** automaticamente seta `invalidated_at` e `valid_to` em todos os relacionamentos que o referenciam. O [BFS do graph retrieval](../advanced/read-api.md) já filtra relacionamentos invalidados, então arestas obsoletas são imediatamente excluídas do contexto.

```
Usuário: "Moro em Curitiba"
  → fato: "User mora em Curitiba" (fact_1)
  → rel:  user --[lives_in]--> curitiba (evidence_fact_id = fact_1)

Usuário: "Me mudei pra São Paulo"
  → reconciliação: UPDATE fact_1 → fact_2 "User mora em São Paulo"
  → cascata: rel lives_in→curitiba INVALIDADA (evidence_fact_id = fact_1)
  → nova rel: user --[lives_in]--> sao_paulo (evidence_fact_id = fact_2)
```

!!! note "Tipos de relacionamento são dinâmicos"
    O campo `rel_type` aceita qualquer string descritiva em `snake_case` — não apenas um conjunto fixo. Tipos comuns incluem `works_at`, `lives_in`, `family_of`, mas o LLM também pode produzir tipos como `mentored_by` ou `inspired_by`. Veja [Tipos de Relacionamento Dinâmicos](../advanced/data-types.md#tipos-de-relacionamento-dinamicos) para detalhes sobre normalização e aliases.

#### Fatos-espelho

Às vezes o LLM infere um relacionamento a partir do contexto sem extrair um fato correspondente. Por exemplo, "vou pra Curitiba ver minha mãe" implica `mãe --[lives_in]--> curitiba`, mas o LLM pode extrair apenas um fato sobre a viagem do usuário — não sobre onde a mãe mora. Sem um fato, o relacionamento não participa da cascata de invalidação e não é encontrável via busca semântica.

Para resolver isso, um **fato-espelho** é criado automaticamente como fallback quando nenhum match heurístico é encontrado. O fato-espelho é uma frase simples em linguagem natural gerada a partir do relacionamento: `"{source_name} {rel_type} {target_name}"` (ex: `"Mom lives in Curitiba"`).

Fatos-espelho são marcados com:

- `confidence = 0.60` (inferência fraca — prioridade menor no ranking de retrieval)
- `source_context = "inferred_from_relation"` (permite filtrar ou reduzir prioridade se necessário)

!!! note "Mirror facts podem ficar stale"
    Mirror facts não são automaticamente invalidados quando o relacionamento de origem é removido. Eles podem persistir como dados obsoletos. Aplicações devem considerar filtrar por `source_context` quando a acurácia é crítica.

O ID do fato-espelho é usado como `evidence_fact_id` do relacionamento, então a cascata de invalidação funciona também para relacionamentos inferidos.

!!! tip "Reduzindo fatos-espelho"
    Os prompts de extração instruem o LLM a extrair fatos implícitos junto com relacionamentos (ex: "minha mãe mora em Curitiba" como fato, não apenas como relação). Conforme a extração LLM melhora, menos fatos-espelho são necessários — eles são a rede de segurança, não o mecanismo principal.

### Segurança Transacional

O write pipeline inteiro roda dentro de uma transação do banco de dados. Upserts individuais de fatos usam **savepoints** (`session.begin_nested()`) para que uma falha em um fato não aborte o batch inteiro:

```python
# Se este fato falhar, apenas este savepoint faz rollback
async with session.begin_nested():
    session.add(new_fact)
    await session.flush()
```

O registro do evento é criado e flushed primeiro, então ele sobrevive mesmo se todos os estágios subsequentes falharem.

---

## WriteResult

Após o pipeline completar, você recebe um `WriteResult` com observabilidade total:

```python
result = await memory.write(
    user_id="user_123",
    message="...",
    recent_messages=["mensagem anterior para resolução de pronomes"],  # opcional
)

# O que aconteceu
print(result.facts_added)       # Lista de fatos criados
print(result.facts_updated)     # Lista de fatos substituídos
print(result.facts_unchanged)   # Lista de fatos confirmados (decisões NOOP)
print(result.facts_deleted)     # Lista de fatos retratados (decisões DELETE)
print(result.entities_resolved) # Lista de entidades resolvidas
print(result.duration_ms)       # Tempo total do pipeline
print(result.event_id)          # ID único do evento para esta escrita
print(result.tokens_used)       # TokenUsage(input_tokens=..., output_tokens=..., total_tokens=...)
print(result.pipeline)          # PipelineTrace (quando verbose=True)
print(result.success)           # True se o pipeline completou sem erros (sempre verifique)
print(result.error)             # Mensagem de erro se o pipeline falhou (None em sucesso)
```

### Enriquecimento do Trace (verbose=True)

Quando `verbose=True`, o step de extraction no `PipelineTrace` inclui metadados adicionais:

| Campo | Tipo | Descrição |
|-------|------|-----------|
| `relation_retry_triggered` | `bool` | Se o retry automático de relations foi utilizado |

```python
result = await memory.write(user_id, message, verbose=True)
extraction_step = result.pipeline.steps[0]  # "extraction"
print(extraction_step.data["relation_retry_triggered"])  # True/False
```

### Token Usage

`tokens_used` reporta o total de tokens LLM consumidos em todas as chamadas do pipeline (extraction, entity resolution, reconciliation). Útil pra benchmarking e estimativa de custo.

```python
result = await memory.write(user_id, message)
print(result.tokens_used.input_tokens)   # ex: 1200
print(result.tokens_used.output_tokens)  # ex: 350
print(result.tokens_used.total_tokens)   # ex: 1550
```

!!! note "Token tracking requer suporte do provider"
    `tokens_used` é populado a partir de `LLMResult.usage` retornado pelo seu `LLMProvider`. O `OpenAIProvider` built-in reporta usage automaticamente. Providers customizados que retornam `LLMResult(text=..., usage=None)` mostrarão zero tokens.

### Config Overrides

Sobrescreva qualquer campo do `MemoryConfig` pra uma única chamada `write()` sem criar novo client:

```python
result = await memory.write(
    user_id="user_123",
    message="...",
    config_overrides={"extraction_timeout_sec": 60.0},
)
```

Só as chaves fornecidas são alteradas; todas as outras herdam do config do client. Chaves inválidas emitem warning e são ignoradas. Incompatibilidade de tipo levanta `ValueError`.

### Dry Run

Rode extraction sem persistir nada no banco:

```python
result = await memory.write(
    user_id="user_123",
    message="Eu moro em São Paulo com minha esposa Ana",
    dry_run=True,
)
# result.facts_added contém o que SERIA extraído
# result.tokens_used mostra o custo dessa extraction
# Nenhum evento, fato ou entidade é persistido
```

Útil pra benchmarking: rode a mesma mensagem com `dry_run=True` e compare `tokens_used` entre configurações diferentes.

---

## Diagrama do Pipeline (Completo)

```mermaid
flowchart TD
    MSG["User message"] --> EVT["Create MemoryEvent\n(immutable log + embedding)"]
    EVT --> EXT["Extraction\n(Entity Scan → Facts → Relations)"]
    EXT --> DEDUP["Semantic Dedup\n(remove near-duplicates)"]
    DEDUP --> RES["Entity Resolution\n(exact → fuzzy → LLM)"]
    RES --> REC["Reconciliation\n(ADD / UPDATE / NOOP / DELETE)"]
    REC --> UPS["Upsert + Entity Links\n(with savepoints)"]
    UPS --> REL["Relationship Tracking\n(strength reinforcement)"]
    REL --> WR["WriteResult"]
```


---

# Read Pipeline

Quando você chama `memory.retrieve()`, o SDK busca tudo que sabe sobre um usuário e retorna os fatos mais relevantes pra sua query — ranqueados, pontuados e formatados numa string que você pode colar direto num prompt de LLM.

**Você não precisa entender os internals pra usar.** Basta chamar `retrieve()` e usar `result.context`. Esta página explica o que acontece por baixo dos panos, pra quando você quiser ajustar o comportamento ou debugar resultados.

```mermaid
flowchart LR
    A["Query"] --> B["Plan"]
    B --> C["Retrieve\n(3 signals)"]
    C --> D["Enhance"]
    D --> E["Rerank"]
    E --> F["RetrieveResult"]
```

## Visão Geral

Toda chamada a `memory.retrieve(user_id, query)` executa cinco estágios:

1. **Plan** — Descobre *o que* buscar. Reformula sua query, identifica quais pessoas/lugares/coisas são relevantes.
2. **Retrieve** — Busca fatos usando três métodos em paralelo: similaridade de significado, match de palavras-chave e travessia do grafo de relacionamentos.
3. **Enhance** — Expande o contexto seguindo relacionamentos entre entidades pra encontrar fatos relacionados que não foram diretamente matcheados.
4. **Rerank** — Um LLM reavalia os top resultados e reordena pela relevância real pra sua query.
5. **Format** — Comprime os fatos ranqueados numa string com orçamento de tokens, organizada por tiers de relevância.

---

## Estágio 1: Retrieval Agent (Planner)

**Em português claro:** Antes de buscar, o pipeline pergunta a um LLM: "O que essa pessoa tá procurando?" O LLM reformula a query pra melhores resultados de busca, identifica quais entidades (pessoas, lugares, empresas) são relevantes, e decide a estratégia.

O retrieval agent é um planner alimentado por LLM que decide **como** recuperar, não apenas **o que** recuperar. Ele analisa a query e produz um `RetrievalPlan`.

### O Que o Planner Decide

| Campo | Descrição | Exemplo |
|-------|-----------|---------|
| `strategy` | Estratégia de retrieval | `"multi_signal"` (padrão) ou `"skip"` (para saudações) |
| `similarity_query` | Query reformulada para busca semântica | "user location city" (de "onde eu moro?") |
| `entities` | Entity keys para sinal graph | `["person:ana", "organization:acme"]` |
| `as_of_range` | Janela de time-travel (opcional) | `{"start": "2024-01-01", "end": "2024-06-30"}` |
| `broad_query` | Se deve expandir o escopo do graph | `true` para "me conte tudo sobre..." |
| `reason` | Explicação da estratégia | Para debugging e observabilidade |

### Reformulação da Query

O planner não simplesmente passa a query do usuário para busca semântica. Ele **reformula** para melhorar o matching por similaridade vetorial:

- `"onde eu moro?"` → `"user location city residence"`
- `"o que a Ana faz de trabalho?"` → `"Ana profession occupation job role"`

Isso fecha a lacuna de vocabulário entre como os usuários fazem perguntas e como os fatos são armazenados.

### Planejamento Schema-Aware

O planner inspeciona o schema real da memória para este usuário:

- Quais tipos de entidade existem (pessoas, organizações, lugares...)
- Quais entidades têm mais fatos
- Quais atributos estão armazenados

Isso ancora o plano na realidade — o planner não vai buscar entidades que não existem.

### Entity Resolution Híbrida

Quando você pergunta "Onde o Carlos mora?", o pipeline precisa descobrir que "Carlos" significa a entidade `person:carlos` no banco. Ele usa três métodos pra isso — se um falhar, os outros cobrem:

1. **Resolução determinística (primária)** — Faz match de palavras da query contra aliases de entidades conhecidas (`MemoryEntityAlias`), display names (`MemoryEntity.display_name`) e slugs de entity_keys. Rápido (< 10ms), confiável, custo zero de LLM. Por exemplo, "Onde o Carlos mora?" resolve deterministicamente para `person:carlos` via slug match.

2. **LLM planner (suplementar)** — O retrieval agent extrai entidades com base no entendimento da query. Captura referências indiretas que o determinístico não resolve (ex: "meu chefe" → `person:kevin`).

3. **Query expansion (priming de aliases)** — `expand_query()` resolve aliases e busca vizinhos 1-hop no KG, adicionando entidades relacionadas.

As três fontes são **unificadas** antes do graph gate. Se qualquer fonte identificar uma entidade, o graph traversal roda. Isso torna o sinal de grafo tolerante a falhas: se o LLM falha em extrair entidades (variância do LLM), a camada determinística cobre.

O trace step `"retrieval"` inclui um breakdown `entities_sources` mostrando quais entidades vieram de cada fonte (`llm`, `deterministic`, `expansion`).

### Estratégia Skip

Para saudações e mensagens casuais ("oi", "como vai?"), o planner retorna `strategy: "skip"`, fazendo short-circuit no pipeline. Sem queries no banco, sem chamadas LLM, resposta instantânea.

!!! info "Paralelo com neurociência"
    O retrieval agent espelha **pistas de recuperação** na psicologia cognitiva. Quando você tenta lembrar algo, seu cérebro não faz uma busca exaustiva — ele usa pistas contextuais para estreitar o espaço de busca. O planner identifica entidades e reformula queries como pistas que guiam os sinais de retrieval.

---

## Estágio 2: Retrieval Multi-Signal

**Em português claro:** O pipeline busca fatos relevantes usando três métodos diferentes ao mesmo tempo — por significado, por palavras exatas e por conexões entre entidades. Isso captura fatos que qualquer método sozinho perderia.

Três sinais independentes rodam **em paralelo** via `asyncio.gather()`, cada um encontrando candidatos de um ângulo diferente:

```mermaid
flowchart TD
    P["RetrievalPlan"] --> S["Semantic Search\n(pgvector cosine)"]
    P --> K["Keyword Search\n(SQL ILIKE)"]
    P --> G["Graph Traversal\n(BFS 2-hop)"]
    S --> M["Merge & Rank\n(RRF + weighted scoring)"]
    K --> M
    G --> M
```

### Sinal 1: Semantic Search

Usa similaridade de cosseno do pgvector para encontrar fatos cujos embeddings são próximos ao embedding da query.

- Embeds a query reformulada (do planner)
- Busca na tabela `MemoryFact` com índice HNSW
- Retorna top-N candidatos acima do threshold `min_similarity`
- Filtros: `user_id`, fatos ativos (`valid_to IS NULL`), confiança >= `min_confidence`

Este é o sinal primário — encontra fatos que são **semanticamente similares** à query, mesmo que não compartilhem keywords exatas.

### Sinal 2: Keyword Search

Matching SQL ILIKE em `fact_text` para hits exatos ou parciais de keywords.

- Extrai palavras significativas (> 2 caracteres) da query
- Faz match contra o texto do fato (até 5 keywords)
- Score = fração de palavras da query encontradas no fato

Complementa a busca semântica capturando matches exatos que a similaridade de embedding pode perder (ex: nomes próprios, termos técnicos, abreviações).

### Sinal 3: Graph Retrieval

Percorre relacionamentos de entidades para encontrar fatos conectados às entidades da query.

- Começa das entidades identificadas pelo planner
- Traversal BFS até 2 hops em `MemoryEntityRelationship`
- Fatos são buscados via **entity links** (`MemoryFactEntityLink`), não só pelo `entity_key` primário. Isso significa que um fato "Clara saiu da Vertix" (subject primário: Clara) também é encontrado ao buscar sobre Vertix — porque o fato tem um entity link secundário pra Vertix.
- Fórmula de scoring: `edge_strength × recency_factor × edge_recency_factor × query_bonus`
- `query_bonus`: 1.5x quando o nome da entidade aparece no texto da query
- **Fallback**: se a tabela de entity links está vazia (pré-migration), retrieval faz fallback pra match direto por `entity_key`

Graph retrieval se destaca em encontrar fatos **contextuais**. Quando você pergunta sobre uma pessoa, ele também encontra fatos sobre seu local de trabalho, seus relacionamentos e seus projetos.

### Merge & Rank

Depois que os três sinais retornam, os resultados são mesclados:

1. **Deduplicar** por fact ID (o mesmo fato pode aparecer em múltiplos sinais)
2. **Aplicar decay de recência** — Decay exponencial com half-life configurável (`recency_half_life_days`, padrão 14)
3. **Aplicar decay de confiança** — Fatos mais antigos com menor confiança são penalizados
4. **Calcular score combinado** — Soma ponderada:

!!! warning "O reranker faz blend com estes pesos"
    Por padrão, `enable_reranker=True` — o reranker LLM usa um blend multiplicativo com o score da fórmula computado a partir destes pesos. O score da fórmula continua sendo importante porque o reranker só pode atenuar ou amplificar, nunca zerar. Configure `enable_reranker=False` para depender apenas destes pesos no ranking final.

```python
score = (
    score_weights["semantic"]   * semantic_score +    # default 0.70
    score_weights["recency"]    * recency_score +     # default 0.20
    score_weights["importance"] * importance_score     # default 0.10
)
```

### Breakdown Completo do Score

Cada fato é pontuado em múltiplas dimensões. Você pode inspecionar em `fact.scores` pra entender **por que** um fato ficou onde ficou no ranking:

Cada dict `ScoredFact.scores` contém **todos os 6 sinais** computados ao longo do pipeline. A fórmula ponderada acima produz o score combinado base; estágios posteriores adicionam sinais que podem modificar o ranking final:

| Chave | Origem | Range | Descrição |
|-------|--------|-------|-----------|
| `semantic` | Busca semântica | 0.0–1.0 | Similaridade de cosseno entre embeddings da query e do fato. Sinal primário de retrieval. |
| `keyword` | Busca por keyword | 0.0–1.0 | Fração de palavras da query encontradas no texto do fato. Complementa semântico para matches exatos. |
| `recency` | Merge & Rank | 0.0–1.0 | Decay exponencial a partir do `created_at`, half-life = `recency_half_life_days` (padrão 14). |
| `importance` | Importância dinâmica | 0.05–3.0 | Computado a partir de frequência de retrieval, recência de uso, correções do usuário e participação em padrões. Começa em 0.5 para fatos novos e evolui conforme o fato é recuperado e confirmado. Requer o job de importância em background para produzir valores não-default. |
| `confidence` | Merge & Rank | 0.0–1.0 | Confiança efetiva após decay temporal. A confiança base é atribuída pelo LLM durante extração (tipicamente 0.95 para afirmações assertivas). Decai ao longo do tempo e é usada como filtro (`min_confidence`) e sinal de scoring. |
| `reranker` | Reranking | 0.0, 0.3, 0.5, 0.8, 1.0 | Score de relevância baseado em LLM. Presente apenas quando `enable_reranker=True`. Valores discretos atribuídos pelo reranker LLM. |

Sinais adicionais computados durante enhancement (não estão em `score_weights` mas afetam o score final):

| Chave | Origem | Descrição |
|-------|--------|-----------|
| `pattern` | Enhancement | Boost aditivo para fatos confirmados recentemente (até +0.10). |
| `graph` | Graph traversal | Score do traversal BFS de 2 saltos em relacionamentos de entidades. |

### Configuração

| Parâmetro | Default | Descrição |
|-----------|---------|-----------|
| `topk_facts` | `20` | Máximo de fatos a retornar |
| `topk_events` | `8` | Máximo de eventos a considerar |
| `min_similarity` | `0.20` | Similaridade de cosseno mínima para resultados semânticos |
| `min_confidence` | `0.55` | Confiança mínima do fato |
| `recency_half_life_days` | `14` | Half-life para decay de recência |
| `score_weights` | Veja acima | Pesos para cada sinal de scoring |
| `enable_reranker` | `True` | Se deve usar reranking LLM |

!!! info "Paralelo com neurociência"
    O retrieval multi-signal espelha **spreading activation** em redes semânticas (Collins & Loftus, 1975). Quando você pensa em "médico", a ativação se espalha para conceitos relacionados ("hospital", "remédio", "consulta") através de links associativos. Da mesma forma, graph retrieval se espalha a partir de entidades da query ao longo de arestas de relacionamento, enquanto busca semântica ativa fatos através de proximidade de embedding.

---

## Estágio 3: Enhancement

**Em português claro:** Depois de encontrar os resultados iniciais, o pipeline segue conexões pra descobrir fatos relacionados. Se você pergunta sobre uma pessoa, ele pode puxar fatos sobre o trabalho dela, projetos e equipe — coisas que você não perguntou diretamente mas que adicionam contexto útil.

### Spreading Activation

A partir dos top-K fatos semente, o pipeline expande o contexto seguindo relacionamentos de entidades:

- Para cada fato semente, encontra os relacionamentos da entidade
- Percorre relacionamentos por N hops (`spreading_activation_hops`, padrão 2). Defina como `0` para desabilitar spreading completamente.
- Aplica fator de decay por hop (`spreading_decay_factor`, padrão 0.50). Hop 1 usa o fator diretamente; Hop 2 usa o fator ao quadrado (decay composto).
- Retorna até `spreading_facts_per_entity` fatos adicionais por entidade (padrão 3), aplicado tanto no Hop 1 quanto no Hop 2.

Isso captura contexto importante que não foi diretamente matcheado. Se você perguntar "o que o Rafael faz?", spreading activation pode trazer fatos sobre seu local de trabalho, time e projetos.

!!! tip "Quando o spreading activation faz diferença?"
    O spreading tem mais impacto com **20+ entidades** e relações cruzadas entre domínios (ex: pessoas → projetos → clientes → tecnologias). Com datasets pequenos (< 15 entidades), os sinais semântico, keyword e graph já cobrem todo o espaço de fatos — o spreading pode retornar candidatos mas eles serão dedupados contra os resultados existentes. Os campos do trace `spreading_candidates_returned` e `spreading_candidates_unique` permitem confirmar se o spreading está contribuindo fatos novos pro seu dataset.

### Sinal de Padrão

Fatos que foram confirmados recentemente (decisões NOOP no write atualizam `last_confirmed_at`) recebem um boost aditivo no score:

- Fatos confirmados recentemente → até 0.10 de score extra
- Captura fatos frequentemente mencionados e bem estabelecidos

### Configuração

| Parâmetro | Default | Descrição |
|-----------|---------|-----------|
| `spreading_activation_hops` | `2` | Máximo de hops a partir de fatos semente. Defina como `0` para desabilitar. |
| `spreading_decay_factor` | `0.50` | Decay de score por hop. Hop 1 = fator, Hop 2 = fator² |
| `spreading_facts_per_entity` | `3` | Máximo de fatos buscados por entidade no Hop 1 e Hop 2 |
| `spreading_max_related_entities` | `5` | Máximo de entidades KG-relacionadas exploradas no Hop 1 |

---

## Estágio 4: Reranking (Opcional)

**Em português claro:** Os estágios anteriores encontram fatos relevantes, mas o ranking é baseado em matemática (scores de similaridade, keyword overlap). O reranker pergunta a um LLM: "Dado o que essa pessoa tá perguntando, quais desses fatos são realmente mais úteis?" Isso produz um ranking final mais inteligente.

Quando `enable_reranker=True`, os top candidatos são rerankeados por um LLM que considera a intenção da query:

- Respeita o significado semântico da query (não apenas overlap de keywords)
- Pode promover fatos que são indiretamente relevantes mas importantes
- Degradação graceful: se o reranker falhar ou exceder `reranker_timeout_sec` (padrão 5.0s), o ranking original é preservado
- O timeout é enforced via `asyncio.wait_for` — a chamada LLM é cancelada se exceder o timeout configurado

O reranker é o estágio mais custoso, mas fornece a maior melhoria de qualidade para queries complexas.

!!! warning "Veto do reranker: `min_reranker_score`"
    Quando `enable_reranker=True`, qualquer fato que recebe um score do reranker **abaixo** de `min_reranker_score` (padrão `0.10`) é eliminado dos resultados (final_score definido como 0.0). Isso dá ao reranker poder de veto sobre fatos completamente irrelevantes — mesmo que o score da fórmula seja alto (ex: graph BFS dá 0.80 pra um fato distante e não relacionado). Quando `enable_reranker=False`, esta configuração não tem efeito. Ajuste: `config_overrides={"min_reranker_score": 0.05}` para resultados mais permissivos, `0.20` para filtragem mais rigorosa.

!!! info "Scoring por multiplicative blend"
    O reranker NÃO substitui o score da fórmula. Ele usa um **blend multiplicativo**: `final_score = formula_score × (floor + reranker_weight × reranker_score)` onde `floor = 1 - reranker_weight`. Com o default `reranker_weight=0.70`, um fato com formula=0.9 e reranker=0.0 fica com final = 0.9 × 0.30 = 0.27 (não 0.0). O reranker pode amplificar ou atenuar fatos mas não consegue zerar um fato com bons sinais de retrieval. O dict `scores` preserva tanto `formula` (pré-reranker) quanto `reranker` (score do LLM) pra debugging.

---

## Estágio 5: Formatação

**Em português claro:** O pipeline pega os fatos ranqueados e organiza numa string pronta pra usar no prompt do seu LLM. Os fatos mais importantes vão primeiro (CORE MEMORY), fatos de suporte depois (EXTENDED CONTEXT), e histórico por último (RELEVANT_EVENTS) — tudo dentro de um orçamento de tokens pra não estourar seu prompt.

### Compressão de Contexto

Fatos são divididos em três tiers dentro de um orçamento de tokens (`context_max_tokens`):

!!! note "`context_max_tokens` é um orçamento proporcional, não um limite rígido"
    O parâmetro `context_max_tokens` controla o tamanho **relativo** do contexto de saída, mas a contagem real de tokens pode exceder o valor configurado. O pipeline garante um contexto mínimo para fatos core e usa o parâmetro como orçamento proporcional entre tiers. Trate como um target, não um limite estrito. Por exemplo, configurar `context_max_tokens=100` pode produzir ~240 tokens devido às garantias mínimas do hot tier.

| Tier | Config Key | Label no Output | Parcela | Conteúdo |
|------|-----------|----------------|---------|----------|
| **Hot** | `hot_tier_ratio` | `CORE MEMORY` | 50% | Fatos mais relevantes (scores mais altos) |
| **Warm** | `warm_tier_ratio` | `EXTENDED CONTEXT` | 30% | Contexto de suporte |
| **Cold** | (restante) | `RELEVANT_EVENTS` | 20% | Fatos de background e histórico de eventos |

### Configuração

| Parâmetro | Default | Descrição |
|-----------|---------|-----------|
| `context_max_tokens` | `2000` | Máximo de tokens no contexto formatado |
| `hot_tier_ratio` | `0.50` | Parcela do orçamento para fatos top |
| `warm_tier_ratio` | `0.30` | Parcela do orçamento para fatos de suporte |

### Formato de Output

A string `context` é formatada para injeção direta em prompts LLM:

```
## Known facts about the user:
- Lives in São Paulo (confidence: 0.95)
- Works at Acme Corp as a backend engineer (confidence: 0.90)
- Wife's name is Ana (confidence: 0.92)
```

---

## RetrieveResult

```python
result = await memory.retrieve(user_id="user_123", query="...")

# Contexto pré-formatado (pronto para prompts LLM)
print(result.context)

# Fatos individuais com scores
for fact in result.facts:
    print(f"[{fact.score:.2f}] {fact.entity_name}: {fact.value}")
    print(f"  Scores: {fact.scores}")  # {"semantic": 0.85, "recency": 0.72, ...}

# Stats do pipeline
print(f"Candidates evaluated: {result.total_candidates}")
print(f"Duration: {result.duration_ms:.0f}ms")
```

---

## Diagrama do Pipeline (Completo)

```mermaid
flowchart TD
    Q["User query"] --> AG["Retrieval Agent\n(LLM planner)"]
    AG -->|skip| SKIP["Return empty\n(greeting/casual)"]
    AG -->|multi_signal| PAR["Parallel retrieval"]
    PAR --> SEM["Semantic Search\n(pgvector cosine)"]
    PAR --> KW["Keyword Search\n(SQL ILIKE)"]
    PAR --> GR["Graph Traversal\n(BFS 2-hop)"]
    SEM --> MERGE["Merge & Rank\n(dedup + weighted scoring)"]
    KW --> MERGE
    GR --> MERGE
    MERGE --> SA["Spreading Activation\n(expand context along edges)"]
    SA --> RR{"Reranker\nenabled?"}
    RR -->|yes| RERANK["LLM Rerank"]
    RR -->|no| FMT["Format & Compress"]
    RERANK --> FMT
    FMT --> RES["RetrieveResult"]
```

!!! info "Paralelo com neurociência"
    A compressão em tiers (hot/warm/cold) espelha **níveis de ativação** na working memory. No modelo de processos embutidos de Cowan, um número pequeno de itens está no foco de atenção (hot tier), cercado por memória de longo prazo ativada (warm tier), com o restante da memória de longo prazo disponível mas não ativa (cold tier). O orçamento de tokens age como o limite de capacidade da working memory.


---

# Background Jobs

Os background jobs melhoram a qualidade da memória ao longo do tempo. Eles rodam **separadamente** de `write()` e `retrieve()` — você agenda eles (a cada algumas horas, via cron, APScheduler ou um loop simples).

### Preciso deles?

**Pra começar: não.** Os pipelines de `write()` e `retrieve()` funcionam sem background jobs. Seu agente ainda vai extrair fatos, resolver entidades e retornar contexto relevante.

**Pra produção: sim.** Sem eles, scores de importância ficam flat (0.5 pra tudo), resumos de entidades nunca são gerados, padrões e contradições passam despercebidos, e a qualidade do retrieval degrada com o tempo conforme a memória cresce.

```mermaid
flowchart LR
    A["Scheduler\n(periodic)"] --> B["Clustering"]
    A --> C["Consolidation"]
    A --> D["Importance\nScoring"]
    A --> E["Summary\nRefresh"]
```

## Visão Geral

O `arandu` fornece quatro categorias de background jobs:

| Job | Propósito | Usa LLM? | Frequência |
|-----|-----------|----------|------------|
| **Clustering** | Agrupar fatos relacionados semanticamente | Sim (resumos) | A cada 4-8 horas |
| **Consolidation** | Detectar padrões, contradições, tendências | Sim | A cada 4-8 horas |
| **Memify** | Converter fatos episódicos em conhecimento procedural/semântico | Sim | Diário |
| **Sleep-time compute** | Pontuar importância, atualizar resumos, detectar comunidades | Parcialmente | A cada 4-8 horas |

Todos os jobs são expostos como funções async que você pode chamar diretamente ou agendar com seu task runner preferido (APScheduler, Celery, cron, etc.).

!!! info "Paralelo com neurociência"
    Os background jobs espelham o **processamento durante o sono** no cérebro. Durante o sono, o cérebro consolida memórias, transfere informações do hipocampo (curto prazo) para o neocórtex (longo prazo), poda conexões irrelevantes e fortalece as importantes. Esses jobs realizam as mesmas operações na memória do seu agente.

---

## Clustering

**Em português claro:** Agrupa fatos relacionados. Fatos sobre o trabalho, colegas e projetos de alguém ficam num cluster. Isso torna o retrieval mais contextual — quando você pergunta sobre o trabalho de alguém, o sistema sabe quais fatos são relacionados.

### Fact Clustering

```python
from arandu import cluster_user_facts, ClusteringResult

result: ClusteringResult = await cluster_user_facts(
    session=db_session,
    user_id="user_123",
    llm=llm_provider,
    embeddings=embedding_provider,
    config=memory_config,
)
```

**Como funciona:**

1. Agrupa fatos por `(entity_type, entity_key)` — fatos sobre a mesma entidade ficam juntos
2. Gera um resumo de 2-3 frases por cluster usando um LLM
3. Calcula e armazena embeddings do cluster para detecção de comunidades posterior
4. Idempotente — atualiza clusters existentes em vez de criar duplicatas

### Detecção de Comunidades

```python
from arandu import detect_communities, CommunityDetectionResult

result: CommunityDetectionResult = await detect_communities(
    session=db_session,
    user_id="user_123",
    llm=llm_provider,
    embeddings=embedding_provider,
    config=memory_config,
)
```

**Como funciona:**

1. Compara embeddings de clusters usando similaridade de cosseno
2. Agrupa clusters acima do `community_similarity_threshold` (padrão 0.75)
3. Cria registros `MemoryMetaObservation` com tipo `"entity_community"`
4. Exemplo: uma comunidade "trabalho" pode incluir clusters sobre colegas, projetos e fatos da empresa

### Configuração

| Parâmetro | Default | Descrição |
|-----------|---------|-----------|
| `cluster_max_age_days` | `90` | Idade máxima dos fatos a incluir no clustering |
| `community_similarity_threshold` | `0.75` | Threshold de similaridade de cosseno para agrupar clusters |

---

## Consolidation

**Em português claro:** Olha pra todos os fatos e eventos recentes e encontra padrões maiores: "Essa pessoa menciona corrida toda segunda" (padrão), "Ela disse que mora em SP mas também em RJ" (contradição), "O humor dela tá melhorando" (tendência). Armazena como meta-observações que enriquecem o retrieval.

### Consolidação Periódica (L2)

```python
from arandu import run_consolidation, ConsolidationResult

result: ConsolidationResult = await run_consolidation(
    session=db_session,
    user_id="user_123",
    llm=llm_provider,
    config=memory_config,
)
```

**Como funciona:**

1. Analisa eventos e fatos em uma janela de lookback (`consolidation_lookback_days`)
2. Detecta padrões entre fatos:
   - **Insights** — Compreensão emergente de múltiplos fatos
   - **Padrões** — Comportamentos ou preferências repetidos
   - **Contradições** — Fatos conflitantes que precisam de resolução
   - **Tendências** — Mudanças ao longo do tempo
3. Gera registros `MemoryMetaObservation`
4. Marca eventos com emoções (emoção, intensidade, nível de energia)

### Consolidação de Perfil (L3)

```python
from arandu import run_profile_consolidation

await run_profile_consolidation(
    session=db_session,
    user_id="user_123",
    llm=llm_provider,
    config=memory_config,
)
```

**Como funciona:**

1. Atualiza resumos de entidades via LLM — uma visão de nível mais alto de cada entidade
2. Atualiza a visão geral do perfil
3. Disparado periodicamente (menos frequente que L2)

### Configuração

| Parâmetro | Default | Descrição |
|-----------|---------|-----------|
| `consolidation_min_events` | `3` | Mínimo de eventos antes de rodar consolidation |
| `consolidation_lookback_days` | `7` | Quantos dias olhar para trás em busca de padrões |

!!! info "Paralelo com neurociência"
    A consolidation espelha a **consolidação de memória durante o sono** do cérebro. O hipocampo repete experiências recentes, o neocórtex detecta padrões e os integra em estruturas de conhecimento existentes, e contradições são sinalizadas para resolução. A consolidação L2 é análoga ao replay durante o sono de ondas lentas (SWS), enquanto a consolidação de perfil L3 é análoga ao papel do sono REM na integração de memórias em conhecimento semântico.

---

## Memify

**Em português claro:** Com o tempo, detalhes específicos ("foi num meetup de Python dia 5 de março") viram conhecimento geral ("frequenta meetups de tech regularmente"). O Memify destila fatos episódicos em conhecimento de nível mais alto e poda fatos que não são mencionados há tempo.

### Executar Memify

```python
from arandu import run_memify, MemifyResult

result: MemifyResult = await run_memify(
    session=db_session,
    user_id="user_123",
    llm=llm_provider,
    config=memory_config,
)
```

**Como funciona:**

1. Agrupa fatos relacionados por entidade e tópico
2. Gera resumos destilados (conhecimento procedural/semântico)
3. Verifica vitalidade — fatos mencionados recentemente são mantidos; fatos obsoletos podem ser deprecados
4. Mescla procedimentos similares para prevenir fragmentação de conhecimento

### Scoring de Vitalidade

```python
from arandu import compute_vitality

vitality_scores = await compute_vitality(
    session=db_session,
    user_id="user_123",
    config=memory_config,
)
```

A vitalidade mede quão "vivo" um fato está com base em:

- **Recência** — Quando o fato foi confirmado ou mencionado pela última vez?
- **Reforço** — Quantas vezes esse fato foi confirmado (decisões NOOP)?
- **Importância** — Quão relevante esse fato é para o perfil do usuário?

!!! info "Paralelo com neurociência"
    O memify espelha a **curva de esquecimento** descrita por Hermann Ebbinghaus (1885). Memórias decaem exponencialmente ao longo do tempo a menos que sejam reforçadas através de prática de recuperação. Fatos com alta vitalidade (acessados frequentemente) resistem ao decay, enquanto fatos de baixa vitalidade vão se apagando gradualmente — assim como o cérebro poda conexões sinápticas para informações não utilizadas.

---

## Sleep-Time Compute

**Em português claro:** Três jobs de manutenção que mantêm o retrieval afiado: (1) pontuar quais entidades são mais importantes, (2) atualizar resumos das entidades importantes, (3) detectar comunidades de entidades relacionadas. O primeiro é SQL puro (barato), os outros dois usam LLM.

### Job 1: Entity Importance Scoring

```python
from arandu import compute_entity_importance, EntityImportanceResult

result: EntityImportanceResult = await compute_entity_importance(
    session=db_session,
    user_id="user_123",
    config=memory_config,
)
```

Computação pura em SQL (sem chamadas LLM). Pontua cada entidade de 0.0 a 1.0 usando quatro sinais normalizados:

| Sinal | Peso | Descrição |
|-------|------|-----------|
| Densidade de fatos | 0.30 | Número de fatos linkados à entidade (via `MemoryFactEntityLink`). Inclui fatos onde a entidade é subject primário E fatos que apenas a mencionam. |
| Recência | 0.25 | Decay exponencial (half-life de 30 dias) |
| Frequência de retrieval | 0.25 | Quão frequentemente fatos sobre essa entidade são recuperados |
| Grau de relacionamento | 0.20 | Número de relacionamentos entrantes + saintes |

O importance score é usado como sinal no scoring de retrieval e como fator de prioridade para atualização de resumos.

### Job 2: Entity Summary Refresh

```python
from arandu import refresh_entity_summaries, SummaryRefreshResult

result: SummaryRefreshResult = await refresh_entity_summaries(
    session=db_session,
    user_id="user_123",
    llm=llm_provider,
    config=memory_config,
)
```

Atualiza resumos obsoletos de entidades:

- **Condição de obsolescência**: `summary_text IS NULL` ou última atualização > 7 dias atrás
- **Prioridade**: entidades com `importance_score` mais alto são atualizadas primeiro
- **Limite**: 10 entidades por execução (previne timeout)
- Gera resumos de 2-3 frases a partir dos fatos da entidade usando um LLM

### Job 3: Entity Community Detection

```python
from arandu import detect_entity_communities

result = await detect_entity_communities(
    session=db_session,
    user_id="user_123",
    llm=llm_provider,
    embeddings=embedding_provider,
    config=memory_config,
)
```

Encontra grupos de entidades relacionadas usando o grafo de relacionamentos:

1. Carrega entidades ativas e arestas (strength >= 0.3)
2. Executa Union-Find (com path compression + union by rank) para encontrar componentes conectados
3. Filtra por threshold mínimo de entidades
4. Gera resumo LLM e embedding para cada comunidade
5. Deduplica contra comunidades existentes (overlap de membros Jaccard)
6. Armazena como registros `MemoryMetaObservation`

!!! info "Paralelo com neurociência"
    O sleep-time compute espelha o **processamento offline durante o sono**. O cérebro não apenas armazena memórias passivamente durante o sono — ele as reorganiza ativamente. O importance scoring é análogo ao processo de **homeostase sináptica** (Tononi & Cirelli), onde sinapses fortemente ativadas são mantidas enquanto fracamente ativadas são podadas. O summary refresh espelha a formação de **memórias de essência** — representações comprimidas que capturam a essência de episódios detalhados.

---

## Agendamento

O `arandu` não inclui um scheduler — você traz o seu. Todas as funções de background são simples callables async que podem ser integradas com qualquer sistema de agendamento.

### Exemplo: APScheduler

```python
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from arandu import (
    cluster_user_facts,
    run_consolidation,
    compute_entity_importance,
    refresh_entity_summaries,
)

scheduler = AsyncIOScheduler()

async def maintenance_cycle():
    async with get_session() as session:
        for user_id in await get_active_users(session):
            await compute_entity_importance(session, user_id, config)
            await refresh_entity_summaries(session, user_id, llm, config)
            await cluster_user_facts(session, user_id, llm, embeddings, config)
            await run_consolidation(session, user_id, llm, config)

scheduler.add_job(maintenance_cycle, "interval", hours=4)
scheduler.start()
```

### Exemplo: Loop Simples

```python
import asyncio

async def background_loop():
    while True:
        await maintenance_cycle()
        await asyncio.sleep(4 * 3600)  # a cada 4 horas
```

### Cadência Recomendada

| Job | Frequência | Custo |
|-----|-----------|-------|
| Entity importance | A cada 4h | Barato (apenas SQL) |
| Summary refresh | A cada 4h | Moderado (LLM, limitado a 10/execução) |
| Clustering | A cada 4-8h | Moderado (LLM para resumos) |
| Consolidation | A cada 4-8h | Moderado (LLM para detecção de padrões) |
| Memify | Diário | Moderado (LLM para destilação) |
| Community detection | Diário | Moderado (LLM + embeddings) |

Execute importance scoring primeiro — sua saída é usada pelo summary refresh para priorizar entidades.


---

# Filosofia de Design

O `arandu` é projetado em torno de duas bases: **princípios de engenharia de software** que o tornam confiável e extensível, e **modelos de ciência cognitiva** que informam sua arquitetura. Esta página cobre ambos — as decisões de engenharia e os paralelos com neurociência que as inspiraram.

---

## Princípios de Engenharia

### Injeção de Dependência Baseada em Protocol

O SDK usa `typing.Protocol` do Python para todas as dependências externas (LLM, embeddings). Sem herança necessária — apenas implemente as assinaturas dos métodos:

```python
@runtime_checkable
class LLMProvider(Protocol):
    async def complete(
        self,
        messages: list[dict],
        temperature: float = 0,
        response_format: dict | None = None,
        max_tokens: int | None = None,
    ) -> str: ...
```

**Por quê:** Vendor lock-in mata adoção. Ao usar tipagem estrutural (duck typing), qualquer LLM provider funciona sem herdar de uma classe base. O provider OpenAI está incluído por conveniência, mas você pode trocar por Anthropic, modelos locais ou endpoints customizados sem nenhuma mudança no SDK.

### Fail-Safe por Padrão

Todo estágio do pipeline tem comportamento de fallback:

| Estágio | Falha | Fallback |
|---------|-------|----------|
| Extraction | Timeout/erro do LLM | Retorna extração vazia; evento ainda é registrado |
| Entity Resolution | Fallback LLM falha | Cria nova entidade (prefere duplicatas a dados perdidos) |
| Reconciliation | Erro do LLM | Default para ADD |
| Reranking | Reranker falha | Mantém ranking original |
| Background jobs | Qualquer job falha | Outros jobs continuam independentemente |

**Por quê:** Em um agente de IA em produção, a memória é um sistema de suporte — nunca deve crashar o fluxo principal. Uma resposta degradada (faltando algum contexto) é sempre melhor que um erro.

### Composição Sobre Herança

O SDK não tem classes base abstratas, nem hierarquias profundas de classes. É construído com módulos pequenos e focados compostos em pipelines:

- `write/extract.py` → `write/entity_resolution.py` → `write/reconcile.py` → `write/upsert.py`
- `read/retrieval_agent.py` → `read/retrieval.py` → `read/reranker.py`

**Por quê:** Cada módulo tem uma única responsabilidade com inputs e outputs claros. Você pode entender, testar e substituir qualquer módulo independentemente. Segue a filosofia Unix: faça uma coisa bem feita.

### Segurança Transacional com Savepoints

Operações de escrita usam savepoints do banco de dados (`session.begin_nested()`) para que uma falha em um fato não aborte o batch inteiro:

```python
async with session.begin_nested():
    # Se isso falhar, apenas este savepoint faz rollback
    session.add(new_fact)
    await session.flush()
```

**Por quê:** Em um pipeline que processa múltiplos fatos por mensagem, transações atômicas tudo-ou-nada são frágeis demais. Savepoints dão atomicidade por fato mantendo a transação externa viva.

---

## Paralelos com Neurociência

A arquitetura do `arandu` se inspira em modelos estabelecidos de neurociência cognitiva. Cada paralelo abaixo mapeia um componente do sistema para seu correspondente biológico.

### Codificação: O Write Pipeline

**Sistema:** Message → Extract → Resolve → Reconcile → Upsert

**Cérebro:** Input sensorial → Percepção → Associação → Consolidação → Armazenamento

Quando você vivencia algo, seu cérebro não grava um vídeo bruto. Ele codifica uma **representação seletiva** — extraindo características salientes, vinculando-as ao conhecimento existente, e armazenando o resultado de forma que possa ser recuperado depois. O write pipeline faz o mesmo:

- **Extraction** é a percepção: um LLM seleciona o que importa da mensagem bruta
- **Entity resolution** é a associação: vincular novas menções a traços de memória existentes
- **Reconciliation** é a reconsolidação: atualizar memórias existentes quando nova informação chega
- **Upsert** é o armazenamento: comprometer o traço processado na memória de longo prazo

### Memória Associativa: Entity Resolution

**Sistema:** Resolução em 3 fases (exact → fuzzy → LLM)

**Cérebro:** Pattern completion em circuitos hipocampais-neocorticais

O cérebro não armazena memórias como registros isolados — armazena como padrões de ativação em redes neurais. Quando você encontra uma pista parcial ("Carol"), seu cérebro completa o padrão para recuperar a representação completa ("Carolina, minha colega do trabalho").

A entity resolution espelha esse processo:

- **Exact match** = recuperação direta (associações fortes e bem estabelecidas)
- **Fuzzy match** = pattern completion (pista parcial ativa o padrão existente mais similar)
- **LLM fallback** = recall deliberado (esforço consciente para desambiguar quando a recuperação automática falha)

O **fuzzy threshold** (0.85) e a **faixa de fallback LLM** (0.50-0.85) modelam o gradiente de confiança do cérebro: matches fortes são automáticos, matches ambíguos requerem deliberação.

### Reconsolidação: Reconciliação de Fatos

**Sistema:** Decisões ADD / UPDATE / NOOP / DELETE

**Cérebro:** Reconsolidação de memória (Nader, Schiller, & LeDoux, 2000)

Quando uma memória é recuperada, ela entra em um **estado lábil** onde pode ser modificada. Isso é a reconsolidação — o mecanismo do cérebro para atualizar memórias com nova informação preservando o traço original.

O estágio de reconciliação modela esse processo:

- **NOOP** = recuperação sem modificação (memória confirmada, `last_confirmed_at` atualizado)
- **UPDATE** = reconsolidação (memória antiga substituída, nova versão criada com link de proveniência via `supersedes_fact_id`)
- **ADD** = nova codificação (sem memória existente para reconsolidar)
- **DELETE** = esquecimento ativo (retração explícita, modelado definindo `invalidated_at`)

O sistema de versionamento de fatos (`valid_from`, `valid_to`, `supersedes_fact_id`) preserva o histórico completo — assim como o cérebro retém traços de memórias originais mesmo após a reconsolidação.

### Spreading Activation: Graph Retrieval

**Sistema:** Traversal BFS 2-hop com fator de decay

**Cérebro:** Spreading activation em redes semânticas (Collins & Loftus, 1975)

No modelo de Collins e Loftus, quando um conceito é ativado (ex: "caminhão de bombeiros"), a ativação se espalha ao longo de links associativos para conceitos relacionados ("vermelho", "caminhão", "emergência"), com a força diminuindo conforme a distância aumenta.

O graph retrieval implementa isso diretamente:

- **Entidades semente** da query ativam os nós iniciais
- **Hop 1** ativa vizinhos diretos (sem pruning — todas as conexões disparam)
- **Hop 2** ativa conexões de segundo grau (com pruning por `min_edge_strength`)
- **Fator de decay** (0.50 por hop) modela a atenuação da ativação ao longo da distância
- **Edge strength** modela a força associativa entre conceitos (reforçada por co-menção repetida)

O `query_bonus` (1.5x) para entidades cujos nomes aparecem na query modela **priming top-down** — quando você menciona explicitamente uma entidade, suas conexões são mais fortemente ativadas.

### Sleep-Time Compute: Processamento em Background

**Sistema:** Clustering, consolidation, importance scoring, summary refresh

**Cérebro:** Consolidação de memória durante o sono (Diekelmann & Born, 2010)

Durante o sono, o cérebro realiza manutenção crítica:

1. **Replay hipocampal** — Experiências recentes são reexecutadas em forma comprimida, transferindo-as do armazenamento de curto prazo (hipocampal) para longo prazo (neocortical)
2. **Homeostase sináptica** — Sinapses fortemente ativadas são mantidas enquanto fracamente ativadas são podadas (Tononi & Cirelli)
3. **Detecção de padrões** — O neocórtex detecta regularidades estatísticas entre episódios
4. **Extração de essência** — Memórias episódicas detalhadas são comprimidas em conhecimento semântico

Os background jobs mapeiam para esses processos:

| Processo cerebral | Job do sistema | Mecanismo |
|-------------------|---------------|-----------|
| Replay hipocampal | Consolidation | Revisa eventos recentes, detecta padrões e contradições |
| Homeostase sináptica | Importance scoring | Pontua entidades por densidade + recência + frequência de retrieval + conectividade |
| Detecção de padrões | Community detection | Encontra grupos de entidades relacionadas via análise de grafos |
| Extração de essência | Summary refresh + Memify | Gera resumos comprimidos a partir de fatos detalhados |

### Curva de Esquecimento: Vitalidade e Recência

**Sistema:** Decay de recência, scoring de vitalidade, pruning baseado em importância

**Cérebro:** Curva de esquecimento de Ebbinghaus (1885)

Hermann Ebbinghaus demonstrou que a retenção de memória decai exponencialmente ao longo do tempo, mas cada recuperação (prática) reseta a curva e desacelera o decay futuro. Este é o **efeito de espaçamento** — o achado mais robusto na pesquisa sobre memória.

O `arandu` modela isso com:

- **Decay de recência** — Decay exponencial com half-life configurável (`recency_half_life_days`). Fatos recentes pontuam mais alto. Isso modela a curva básica de esquecimento.
- **Reforço por retrieval** — Cada decisão NOOP (fato confirmado durante write) atualiza `last_confirmed_at`, efetivamente "praticando" o fato e resetando sua curva de decay.
- **Scoring de vitalidade** — Combina recência, recência de confirmação (`last_confirmed_at`) e importância para determinar quão "vivo" um fato está. Fatos de baixa vitalidade são candidatos a consolidation ou pruning.

### Atenção Seletiva: Reranking

**Sistema:** LLM reranker em candidatos de retrieval

**Cérebro:** Atenção seletiva (Broadbent, 1958; Treisman, 1964)

O cérebro não processa todo input sensorial igualmente — a atenção seletiva filtra e prioriza informações com base nos objetivos atuais. O efeito cocktail party demonstra isso: você consegue focar em uma conversa em um ambiente barulhento filtrando sinais irrelevantes.

O reranker age como o filtro de atenção:

- Sinais brutos de retrieval (semantic, keyword, graph) produzem um conjunto amplo de candidatos — como o input sensorial completo
- O reranker avalia cada candidato contra a intenção da query — como a seleção atencional
- Apenas os fatos mais relevantes passam para o contexto — como o sinal atendido

É por isso que o reranker usa um LLM (não apenas heurísticas de scoring): a atenção é direcionada a objetivos e requer compreender o **significado** tanto da query quanto dos candidatos.

### Working Memory: Orçamento de Contexto

**Sistema:** Orçamento de tokens com tiers hot/warm/cold

**Cérebro:** Working memory (Baddeley & Hitch, 1974; Cowan, 2001)

A working memory tem um limite de capacidade estrito — Cowan estima que 4 mais ou menos 1 itens podem ser mantidos no foco de atenção simultaneamente. O orçamento de contexto modela essa restrição:

- **Orçamento de tokens** = limite de capacidade (você não pode enviar contexto infinito para um LLM)
- **Hot tier** (50%) = foco de atenção (os fatos mais relevantes para a query atual)
- **Warm tier** (30%) = memória de longo prazo ativada (contexto de suporte que está disponível mas não focal)
- **Cold tier** (20%) = ativação periférica (fatos de background que podem se tornar relevantes)

Essa abordagem em tiers garante que o LLM receba um contexto focado e priorizado, em vez de um despejo barulhento de tudo que o sistema sabe.

---

## Tabela Resumo

| Componente do Sistema | Modelo de Neurociência | Referência |
|----------------------|----------------------|------------|
| Write Pipeline | Codificação | -- |
| Entity Resolution | Memória associativa / Pattern completion | -- |
| Reconciliation | Reconsolidação | Nader, Schiller, & LeDoux (2000) |
| Graph Retrieval | Spreading activation | Collins & Loftus (1975) |
| Decay de Recência | Curva de esquecimento | Ebbinghaus (1885) |
| Background Jobs | Consolidação durante o sono | Diekelmann & Born (2010) |
| Importance Scoring | Homeostase sináptica | Tononi & Cirelli (SHY) |
| Summary Refresh | Formação de memória de essência | -- |
| Reranking | Atenção seletiva | Broadbent (1958) |
| Orçamento de Contexto | Capacidade da working memory | Baddeley & Hitch (1974); Cowan (2001) |
| Vitalidade/Reforço | Efeito de espaçamento | Ebbinghaus (1885) |

!!! note "Estes são analogias, não afirmações"
    Os paralelos acima são inspirações arquiteturais, não afirmações científicas. O `arandu` é um sistema de engenharia, não um modelo cognitivo. O cérebro é vastamente mais complexo — esses paralelos destacam as intuições de design, não os mecanismos biológicos.


---

# API do Write Pipeline

!!! warning "API Avançada"
    Estas são APIs avançadas para usuários que desejam interagir diretamente com estágios individuais do pipeline. A maioria dos usuários deve usar [`MemoryClient.write()`](../reference/index.md), que orquestra o pipeline completo automaticamente.

Todas as funções do write pipeline são exportadas de `arandu.write`.

```python
from arandu.write import (
    classify_input, select_strategy, run_write_pipeline,
    canonicalize_attribute_key, normalize_key, validate_proposed_key,
    create_or_update_entity, get_entities_for_user, get_entity_by_key,
    detect_and_record_corrections, is_user_correction,
    get_pending, clear_pending, save_pending_execution, save_pending_selection,
)
```

---

## Orquestrador do Pipeline

### run_write_pipeline

Executa o pipeline completo de escrita: **extract** -> **resolve** -> **reconcile** -> **upsert**.

```python
async def run_write_pipeline(
    session: AsyncSession,
    user_id: str,
    message: str,
    llm: LLMProvider,
    embeddings: EmbeddingProvider,
    config: MemoryConfig,
    source: str = "api",
    recent_messages: list[str] | None = None,
    trace: PipelineTrace | None = None,
) -> dict
```

| Parametro | Tipo | Descricao |
|-----------|------|-----------|
| `session` | `AsyncSession` | Sessao do banco (o caller gerencia transacao/commit). |
| `user_id` | `str` | Identificador unico do usuario. |
| `message` | `str` | Texto da mensagem do usuario. |
| `llm` | `LLMProvider` | Provider de LLM injetado. |
| `embeddings` | `EmbeddingProvider` | Provider de embeddings injetado. |
| `config` | `MemoryConfig` | Configuracao da memoria. |
| `source` | `str` | Identificador do canal de origem (padrao `"api"`). |
| `recent_messages` | `list[str] | None` | Contexto de conversacao opcional (ultimas N mensagens) para resolver pronomes e anaforas. |

**Retorna:** `dict` com chaves `event_id`, `facts_added`, `facts_updated`, `facts_unchanged`, `facts_deleted`, `entities_resolved`, `duration_ms`.

O pipeline cria um `MemoryEvent` imutavel primeiro (sobrevive mesmo se estagios posteriores falharem), depois executa extracao, resolucao de entidades, reconciliacao e upsert dentro de um savepoint para atomicidade.

---

## Estrategia de Extracao

Funcoes puras (sem LLM, sem DB) que classificam o texto de entrada e escolhem um modo de extracao baseado em heuristicas.

### InputType

```python
class InputType(str, Enum):
    SHORT = "short"        # < 500 caracteres
    MEDIUM = "medium"      # 500-2000 caracteres, nao estruturado
    LONG = "long"          # > 2000 caracteres, nao estruturado
    STRUCTURED = "structured"  # > 500 caracteres com headers/bullets/tabelas
```

### ExtractionMode

```python
class ExtractionMode(str, Enum):
    SINGLE_SHOT = "single_shot"
    CHUNKED = "chunked"
```

### InputClassification

Resultado da analise do texto de entrada.

| Campo | Tipo | Descricao |
|-------|------|-----------|
| `input_type` | `InputType` | Tipo de entrada classificado. |
| `char_count` | `int` | Numero de caracteres. |
| `estimated_tokens` | `int` | Contagem estimada de tokens (chars // 4). |
| `has_headers` | `bool` | Se headers foram detectados. |
| `has_bullets` | `bool` | Se bullet points foram detectados. |
| `has_tables` | `bool` | Se tabelas foram detectadas. |
| `section_count` | `int` | Numero de secoes de texto. |
| `line_count` | `int` | Numero de linhas. |

### ExtractionStrategy

Estrategia de extracao selecionada.

| Campo | Tipo | Descricao |
|-------|------|-----------|
| `mode` | `ExtractionMode` | Modo de extracao (single_shot ou chunked). |
| `reason` | `str` | Motivo legivel da selecao. |
| `max_tokens_per_call` | `int` | Maximo de tokens por chamada LLM. |
| `estimated_chunks` | `int` | Numero esperado de chunks (1 para single-shot). |
| `chunk_context_hint` | `str | None` | Dica sobre tipo de documento para modo chunked. |

### classify_input

Classifica o texto de entrada usando heuristicas (sem chamada LLM).

```python
def classify_input(text: str) -> InputClassification
```

| Parametro | Tipo | Descricao |
|-----------|------|-----------|
| `text` | `str` | Texto de entrada para classificar. |

**Retorna:** `InputClassification` com features detectadas.

```python
from arandu.write import classify_input, select_strategy

classification = classify_input("Minha esposa se chama Ana e moramos em Sao Paulo.")
print(classification.input_type)  # InputType.SHORT
print(classification.char_count)  # 50
```

### select_strategy

Seleciona a estrategia de extracao a partir de um resultado de classificacao.

```python
def select_strategy(classification: InputClassification) -> ExtractionStrategy
```

| Parametro | Tipo | Descricao |
|-----------|------|-----------|
| `classification` | `InputClassification` | Resultado de `classify_input()`. |

**Retorna:** `ExtractionStrategy` com modo e parametros.

```python
strategy = select_strategy(classification)
print(strategy.mode)             # ExtractionMode.SINGLE_SHOT
print(strategy.estimated_chunks) # 1
```

---

## Canonicalizacao de Chaves de Atributo

Pipeline: **match exato** -> **alias** -> **variante pontuada** -> **sufixo** -> **catalogo aberto** -> **drop**.

### normalize_key

Normaliza uma chave de atributo bruta: lowercase, strip, espacos/hifens para pontos. Underscores sao preservados.

```python
def normalize_key(raw: str) -> str
```

| Parametro | Tipo | Descricao |
|-----------|------|-----------|
| `raw` | `str` | String bruta da chave de atributo. |

**Retorna:** String da chave normalizada.

```python
from arandu.write import normalize_key

normalize_key("Personal Info")    # "personal.info"
normalize_key("food_preference")  # "food_preference"
```

### validate_proposed_key

Valida se uma chave proposta atende as regras de nomenclatura.

```python
def validate_proposed_key(
    key: str,
    extra_namespaces: set[str] | None = None,
) -> bool
```

| Parametro | Tipo | Descricao |
|-----------|------|-----------|
| `key` | `str` | Chave normalizada para validar. |
| `extra_namespaces` | `set[str] | None` | Namespaces adicionais fornecidos pelo deployer. |

**Retorna:** `True` se a chave e bem formada e esta em um namespace permitido.

### canonicalize_attribute_key

Canonicaliza uma chave de atributo via catalogo, alias e estrategias de recuperacao. Funcao async que consulta o banco de dados.

```python
async def canonicalize_attribute_key(
    session: AsyncSession,
    user_id: str,
    raw_key: str,
    config: MemoryConfig,
) -> tuple[str | None, Literal["allow", "map", "propose", "drop"], dict[str, Any]]
```

| Parametro | Tipo | Descricao |
|-----------|------|-----------|
| `session` | `AsyncSession` | Sessao do banco de dados. |
| `user_id` | `str` | Identificador do usuario. |
| `raw_key` | `str` | Chave bruta do atributo vinda da extracao. |
| `config` | `MemoryConfig` | Configuracao da memoria. |

**Retorna:** Tupla de `(canonical_key, action, metadata)` onde action e um de `"allow"`, `"map"`, `"propose"` ou `"drop"`.

---

## Helpers de Entidades

Operacoes CRUD async para registros `MemoryEntity` usando upsert `ON CONFLICT` do PostgreSQL.

### create_or_update_entity

Cria um `MemoryEntity` ou atualiza se ja existir.

```python
async def create_or_update_entity(
    session: AsyncSession,
    user_id: str,
    canonical_key: str,
    display_name: str | None = None,
    entity_type: str = "other",
) -> MemoryEntity
```

| Parametro | Tipo | Descricao |
|-----------|------|-----------|
| `session` | `AsyncSession` | Sessao do banco de dados. |
| `user_id` | `str` | Identificador do usuario. |
| `canonical_key` | `str` | Chave canonica da entidade. |
| `display_name` | `str | None` | Nome de exibicao opcional. |
| `entity_type` | `str` | Tipo da entidade (person, pet, place, etc.). Padrao `"other"`. |

**Retorna:** O `MemoryEntity` criado ou atualizado.

### get_entity_by_key

Obtem um unico `MemoryEntity` por user_id e canonical_key.

```python
async def get_entity_by_key(
    session: AsyncSession,
    user_id: str,
    canonical_key: str,
) -> MemoryEntity | None
```

**Retorna:** `MemoryEntity` ou `None` se nao encontrado.

### get_entities_for_user

Lista todos os registros `MemoryEntity` de um usuario.

```python
async def get_entities_for_user(
    session: AsyncSession,
    user_id: str,
    active_only: bool = True,
) -> list[MemoryEntity]
```

| Parametro | Tipo | Descricao |
|-----------|------|-----------|
| `session` | `AsyncSession` | Sessao do banco de dados. |
| `user_id` | `str` | Identificador do usuario. |
| `active_only` | `bool` | Se True, retorna apenas entidades ativas. Padrao `True`. |

**Retorna:** Lista de registros `MemoryEntity`, ordenados por `last_seen_at` decrescente.

---

## Deteccao de Correcoes

Detecta quando usuarios corrigem fatos da memoria comparando valores antigos vs novos para o mesmo attribute_key.

### CorrectionResult

| Campo | Tipo | Descricao |
|-------|------|-----------|
| `corrections_detected` | `int` | Numero de correcoes encontradas. Padrao `0`. |
| `corrected_keys` | `list[str]` | Chaves de atributo que foram corrigidas. |
| `facts_corrected_ids` | `list[str]` | IDs dos fatos antigos que foram corrigidos. |

### is_user_correction

Verifica se um novo fato corrige um fato antigo (mesma chave, valor diferente).

```python
def is_user_correction(old_fact: object, new_fact: object) -> bool
```

| Parametro | Tipo | Descricao |
|-----------|------|-----------|
| `old_fact` | `object` | O fato existente sendo substituido. |
| `new_fact` | `object` | O novo fato que o substitui. |

**Retorna:** `True` se for uma correcao do usuario.

### detect_and_record_corrections

Detecta supersedes com mudancas de valor e incrementa o contador de correcoes nos fatos antigos.

```python
async def detect_and_record_corrections(
    session: AsyncSession,
    user_id: str,
    saved_facts: list[Any],
) -> CorrectionResult
```

| Parametro | Tipo | Descricao |
|-----------|------|-----------|
| `session` | `AsyncSession` | Sessao do banco de dados. |
| `user_id` | `str` | Identificador do usuario. |
| `saved_facts` | `list[Any]` | Lista de objetos MemoryFact recem-salvos. |

**Retorna:** `CorrectionResult` com estatisticas de deteccao.

---

## Operacoes Pendentes

Armazenamento em memoria para operacoes destrutivas pendentes com TTL de 5 minutos. O estado e por processo e perdido ao reiniciar.

### save_pending_selection

Salva uma selecao pendente quando uma busca retornou resultados aguardando escolha do usuario.

```python
def save_pending_selection(
    user_id: str,
    intent: str,
    transactions: list[Any],
    confirmation_text: str,
    edit_params: dict[str, Any] | None = None,
) -> None
```

| Parametro | Tipo | Descricao |
|-----------|------|-----------|
| `user_id` | `str` | Identificador do usuario. |
| `intent` | `str` | A intencao do usuario (delete, edit, etc.). |
| `transactions` | `list[Any]` | Lista de transacoes candidatas. |
| `confirmation_text` | `str` | Texto para mostrar ao usuario para confirmacao. |
| `edit_params` | `dict | None` | Parametros opcionais para operacoes de edicao. |

### save_pending_execution

Salva uma execucao pendente quando uma operacao destrutiva foi bloqueada.

```python
def save_pending_execution(
    user_id: str,
    tool_calls: list[Any],
    search_result: str,
    confirmation_text: str,
) -> None
```

| Parametro | Tipo | Descricao |
|-----------|------|-----------|
| `user_id` | `str` | Identificador do usuario. |
| `tool_calls` | `list[Any]` | Tool calls bloqueadas. |
| `search_result` | `str` | Contexto da busca. |
| `confirmation_text` | `str` | Texto para mostrar ao usuario para confirmacao. |

### get_pending

Obtem operacao pendente se existir e nao tiver expirado (TTL de 5 minutos).

```python
def get_pending(user_id: str) -> dict[str, Any] | None
```

**Retorna:** Dict da operacao pendente, ou `None` se expirado/ausente.

### clear_pending

Remove operacao pendente apos execucao ou cancelamento.

```python
def clear_pending(user_id: str) -> None
```


---

# API do Read Pipeline

!!! warning "API Avancada"
    Estas sao APIs avancadas para usuarios que desejam interagir diretamente com estagios individuais de retrieval. A maioria dos usuarios deve usar [`MemoryClient.retrieve()`](../reference/index.md), que orquestra o pipeline multi-signal completo automaticamente.

Todas as funcoes do read pipeline sao exportadas de `arandu.read`.

```python
from arandu.read import (
    run_read_pipeline,
    plan_retrieval, expand_query,
    retrieve_relevant_events, compute_pattern_signal,
    retrieve_graph_facts, spread_activation,
    compress_context, compress_broad_context,
    materialize_emotional_trends, get_emotional_summary_for_context,
    compute_dynamic_importance,
    generate_optimized_directives, check_directive_contradiction,
    effective_confidence, invalidate_directive_cache,
)
```

---

## Orquestrador do Pipeline

### run_read_pipeline

Executa o pipeline completo de leitura: **agent** -> **retrieve (multi-signal)** -> **rerank** -> **format**.

O retrieval multi-signal executa semantic + keyword + graph em paralelo via `asyncio.gather()`. O retrieval agent planeja quais entidades usar para o sinal de grafo e reformula a query.

```python
async def run_read_pipeline(
    session: AsyncSession,
    user_id: str,
    query: str,
    llm: LLMProvider,
    embeddings: EmbeddingProvider,
    config: MemoryConfig,
    trace: PipelineTrace | None = None,
) -> ReadResult
```

| Parametro | Tipo | Descricao |
|-----------|------|-----------|
| `session` | `AsyncSession` | Sessao do banco (o caller gerencia transacao). |
| `user_id` | `str` | Identificador do usuario. |
| `query` | `str` | A query para buscar na memoria. |
| `llm` | `LLMProvider` | Provider de LLM injetado. |
| `embeddings` | `EmbeddingProvider` | Provider de embeddings injetado. |
| `config` | `MemoryConfig` | Configuracao da memoria. |

**Retorna:** `ReadResult` com `facts` (lista de `ScoredFact`), `context` (string pronta para prompt), `total_candidates` e `duration_ms`.

---

## Retrieval Agent

O retrieval agent e um planejador LLM que analisa a query do usuario e decide a estrategia de retrieval antes de qualquer busca acontecer.

### PatternQuery

Uma query baseada em padrao para matching de sinal de keyword.

| Campo | Tipo | Descricao |
|-------|------|-----------|
| `entity_pattern` | `str` | Padrao SQL LIKE para matching de entity_key. |
| `attribute_filter` | `str | None` | Filtro opcional de chave de atributo (sempre `None` no V5). |

### RetrievalPlan

Saida do retrieval agent. V5 executa todos os sinais (semantic, graph, keyword) em paralelo.

| Campo | Tipo | Padrao | Descricao |
|-------|------|--------|-----------|
| `strategy` | `str` | `"multi_signal"` | `"multi_signal"` (padrao) ou `"skip"`. |
| `entities` | `list[str]` | `[]` | entity_keys detectadas para sinal de grafo. |
| `pattern_queries` | `list[PatternQuery]` | `[]` | Pattern queries para sinal de keyword. |
| `similarity_query` | `str | None` | `None` | Query reformulada para sinal semantico. |
| `max_facts` | `int` | `50` | Budget por sinal. |
| `reason` | `str` | `""` | Motivo da escolha do plano. |
| `latency_ms` | `float` | `0.0` | Tempo gasto no planejamento. |
| `as_of_range` | `tuple[datetime, datetime] | None` | `None` | Janela temporal opcional (time-travel). |
| `broad_query` | `bool` | `False` | True para queries abrangentes. |

### plan_retrieval

Chama o LLM para decidir a estrategia de retrieval. Fallback para `multi_signal` em timeout/parse/erro de API.

```python
async def plan_retrieval(
    session: AsyncSession,
    user_id: str,
    query_text: str,
    llm: LLMProvider,
    *,
    session_context: Any | None = None,
) -> RetrievalPlan
```

| Parametro | Tipo | Descricao |
|-----------|------|-----------|
| `session` | `AsyncSession` | Sessao do banco de dados. |
| `user_id` | `str` | Identificador do usuario. |
| `query_text` | `str` | A query do usuario. |
| `llm` | `LLMProvider` | Provider de LLM injetado. |
| `session_context` | `Any | None` | Digest de sessao opcional com contexto de anafora. |

**Retorna:** `RetrievalPlan` com estrategia, entidades e parametros de query.

---

## Expansao de Query

Pos-processa um `RetrievalPlan` com entity priming -- resolve entidades mencionadas na query via knowledge graph (aliases + relacionamentos) e injeta termos de contexto.

### ExpandedQuery

| Campo | Tipo | Descricao |
|-------|------|-----------|
| `primed_entities` | `list[str]` | Entity keys descobertas via alias + KG priming. |
| `temporal_range` | `tuple[datetime, datetime] | None` | Faixa de datas resolvida. |
| `expanded_terms` | `list[str]` | Termos de contexto adicionais dos fatos das entidades. |

### expand_query

Expande um plano de retrieval com entity priming. Fail-safe: qualquer excecao retorna um `ExpandedQuery` vazio.

```python
async def expand_query(
    session: AsyncSession,
    user_id: str,
    query: str,
    plan: RetrievalPlan,
    llm: object,
) -> ExpandedQuery
```

| Parametro | Tipo | Descricao |
|-----------|------|-----------|
| `session` | `AsyncSession` | Sessao do banco de dados. |
| `user_id` | `str` | Identificador do usuario. |
| `query` | `str` | Texto original da query do usuario. |
| `plan` | `RetrievalPlan` | RetrievalPlan do retrieval agent. |
| `llm` | `object` | Provider de LLM (reservado para uso futuro). |

**Retorna:** `ExpandedQuery` com entidades primadas, faixa temporal e termos expandidos.

---

## Retrieval de Fatos

### retrieve_relevant_events

Recupera eventos relevantes por similaridade de embedding + scoring de recencia.

```python
async def retrieve_relevant_events(
    session: AsyncSession,
    user_id: str,
    query_embedding: list[float],
    config: MemoryConfig,
    limit: int | None = None,
) -> list[dict[str, Any]]
```

| Parametro | Tipo | Descricao |
|-----------|------|-----------|
| `session` | `AsyncSession` | Sessao do banco de dados. |
| `user_id` | `str` | Identificador do usuario. |
| `query_embedding` | `list[float]` | Vetor de embedding da query. |
| `config` | `MemoryConfig` | Configuracao da memoria. |
| `limit` | `int | None` | Maximo de eventos a retornar. |

**Retorna:** Lista de dicts de evento com `date`, `text`, `score`, `event_id`.

### compute_pattern_signal

Impulsiona fatos confirmados recentemente (sinal de padrão). Fatos com `last_confirmed_at` recente (confirmados via decisões NOOP no write) recebem um boost aditivo pequeno (até 0.1).

```python
def compute_pattern_signal(
    candidates: list[RetrievalCandidate],
) -> list[RetrievalCandidate]
```

| Parametro | Tipo | Descricao |
|-----------|------|-----------|
| `candidates` | `list[RetrievalCandidate]` | Candidatos ranqueados atuais. |

**Retorna:** Candidatos com scores atualizados, ordenados por `final_score`.

---

## Retrieval de Grafo

Travessia BFS de 2 saltos no knowledge graph `MemoryEntityRelationship` com poda de relevancia.

### GraphRetrievalResult

| Campo | Tipo | Descricao |
|-------|------|-----------|
| `facts` | `list[dict[str, Any]]` | Dicts de fatos pontuados com `source="graph"`. |
| `neighbor_keys` | `list[str]` | Entity keys descobertas via BFS. |
| `edges_traversed` | `int` | Total de arestas examinadas durante BFS. |
| `edges` | `list[dict[str, Any]]` | Dicts de arestas deduplicadas com nomes de exibicao. |

### retrieve_graph_facts

Retrieval BFS de 2 saltos com scoring composto: `edge_strength * recency * edge_recency * query_bonus`.

```python
async def retrieve_graph_facts(
    session: AsyncSession,
    user_id: str,
    entity_keys: list[str],
    *,
    min_confidence: float = 0.3,
    as_of_start: datetime | None = None,
    as_of_end: datetime | None = None,
    broad_query: bool = False,
    max_facts: int | None = None,
    query_text: str = "",
    min_edge_strength: float = 0.5,
) -> GraphRetrievalResult
```

| Parametro | Tipo | Padrao | Descricao |
|-----------|------|--------|-----------|
| `session` | `AsyncSession` | -- | Sessao do banco de dados. |
| `user_id` | `str` | -- | Identificador do usuario. |
| `entity_keys` | `list[str]` | -- | entity_keys semente para iniciar BFS. |
| `min_confidence` | `float` | `0.3` | Threshold minimo de confianca do fato. |
| `as_of_start` | `datetime | None` | `None` | Inicio da janela temporal. |
| `as_of_end` | `datetime | None` | `None` | Fim da janela temporal. |
| `broad_query` | `bool` | `False` | Quando True, permite budget expandido. |
| `max_facts` | `int | None` | `None` | Override do limite padrao (30). |
| `query_text` | `str` | `""` | Texto original da query para scoring de query_bonus. |
| `min_edge_strength` | `float` | `0.5` | Forca minima de aresta para poda no salto 2+. |

**Retorna:** `GraphRetrievalResult` com fatos pontuados e metadados do grafo.

---

## Spreading Activation

Expande contexto a partir de fatos semente seguindo links de `entity_key`, `cluster_id` e relacionamentos do knowledge graph. Usa scoring de importancia dinamica com decaimento por salto.

### SpreadingActivationResult

| Campo | Tipo | Descricao |
|-------|------|-----------|
| `candidates` | `list[RetrievalCandidate]` | Candidatos expandidos dos saltos 1-2. |
| `meta_observations` | `list[Any]` | Meta-observacoes relevantes referenciando fatos semente. |
| `entities_explored` | `list[str]` | Entity keys exploradas durante spreading. |
| `clusters_explored` | `list[str]` | Cluster IDs explorados durante spreading. |
| `hop1_count` | `int` | Numero de fatos encontrados no salto 1. |
| `hop2_count` | `int` | Numero de fatos encontrados no salto 2. |
| `kg_relationships_explored` | `int` | Numero de relacionamentos KG percorridos. |

### spread_activation

Expande contexto a partir de fatos semente via entity_key, cluster_id e relacionamentos KG (saltos 1-2).

```python
async def spread_activation(
    session: AsyncSession,
    user_id: str,
    seed_fact_ids: list[str],
    config: MemoryConfig,
    *,
    seed_scores: dict[str, float] | None = None,
    allowed_keys: set[str] | None = None,
) -> list[RetrievalCandidate]
```

| Parametro | Tipo | Descricao |
|-----------|------|-----------|
| `session` | `AsyncSession` | Sessao do banco de dados. |
| `user_id` | `str` | Identificador do usuario. |
| `seed_fact_ids` | `list[str]` | IDs dos fatos semente para expandir. |
| `config` | `MemoryConfig` | Configuracao da memoria com parametros de spreading activation. |
| `seed_scores` | `dict[str, float] | None` | Dict opcional mapeando ID do fato semente para score. |
| `allowed_keys` | `set[str] | None` | Conjunto opcional de chaves de atributo permitidas. |

**Retorna:** Lista de objetos `RetrievalCandidate` do spreading activation. Fail-safe: retorna lista vazia em caso de erro.

---

## Compressao de Contexto

Constroi uma string de contexto pronta para prompt a partir de fatos pontuados, eventos, clusters e meta-observacoes usando um sistema em camadas: **Hot** (Tier 1), **Warm** (Tier 2), **Cold** (Tier 3).

### CompressedContext

| Campo | Tipo | Descricao |
|-------|------|-----------|
| `context_text` | `str` | String de contexto final pronta para prompt. |
| `hot_count` | `int` | Numero de fatos na camada hot (Tier 1). |
| `warm_count` | `int` | Numero de fatos na camada warm (Tier 2). |
| `cold_count` | `int` | Numero de itens na camada cold (Tier 3). |
| `total_tokens` | `int` | Contagem estimada de tokens do context_text. |

### compress_context

Constroi texto de contexto em camadas dentro do budget de tokens.

```python
async def compress_context(
    facts: list[dict[str, Any]],
    events: list[dict[str, Any]],
    config: MemoryConfig,
    *,
    clusters: list[Any] | None = None,
    meta_observations: list[Any] | None = None,
    stale_keys: set[str] | None = None,
    stale_threshold_days: int = 90,
    now: datetime | None = None,
) -> CompressedContext
```

| Parametro | Tipo | Descricao |
|-----------|------|-----------|
| `facts` | `list[dict]` | Dicts de fatos pontuados (devem ter chaves `score`, `fact`, `entity`, `attribute`, `value`, `date`). |
| `events` | `list[dict]` | Dicts de evento com chaves `date` e `text`. |
| `config` | `MemoryConfig` | Configuracao da memoria com budget de tokens e ratios de camada. |
| `clusters` | `list | None` | Objetos de cluster opcionais. |
| `meta_observations` | `list | None` | Objetos de meta-observacao opcionais. |
| `stale_keys` | `set[str] | None` | Chaves de atributo consideradas sempre obsoletas. |
| `stale_threshold_days` | `int` | Dias apos os quais um fato e considerado obsoleto (padrao 90). |
| `now` | `datetime | None` | Timestamp atual (padrao UTC now). |

**Retorna:** `CompressedContext` com texto de contexto em camadas.

### compress_broad_context

Constroi contexto para queries abrangentes usando clusters como unidade primaria.

```python
async def compress_broad_context(
    cluster_facts: dict[str, list[dict[str, Any]]],
    clusters: list[Any],
    config: MemoryConfig,
    *,
    meta_observations: list[Any] | None = None,
    events: list[dict[str, Any]] | None = None,
) -> CompressedContext
```

| Parametro | Tipo | Descricao |
|-----------|------|-----------|
| `cluster_facts` | `dict[str, list[dict]]` | Mapeamento de cluster_label para dicts de fatos. |
| `clusters` | `list[Any]` | Objetos de cluster com `label`, `summary_text`, `fact_count`. |
| `config` | `MemoryConfig` | Configuracao da memoria. |
| `meta_observations` | `list | None` | Objetos de meta-observacao opcionais. |
| `events` | `list[dict] | None` | Dicts de evento opcionais. |

**Retorna:** `CompressedContext` com texto de contexto cluster-first.

---

## Tendencias Emocionais

Materializa tendencias emocionais a partir de eventos de memoria e fornece sumarios formatados para injecao no contexto de retrieval.

### EmotionalTrendsResult

| Campo | Tipo | Descricao |
|-------|------|-----------|
| `emotion_counts` | `dict[str, int]` | Mapeamento de emocao para contagem de ocorrencias. |
| `trend_direction` | `str` | `"increasing"`, `"decreasing"` ou `"stable"`. |
| `dominant_emotion` | `str | None` | Emocao mais frequente, ou None. |
| `trigger_keywords` | `list[str]` | Top keywords de eventos de alta intensidade. |
| `avg_intensity` | `float` | Intensidade emocional media entre eventos. |
| `dominant_intensity` | `float` | Intensidade media da emocao dominante. |
| `dominant_energy` | `str` | Nivel de energia predominante (high/medium/low). |
| `events_analyzed` | `int` | Numero de eventos analisados. |
| `observation_created` | `bool` | Se uma meta-observacao foi criada/atualizada. |
| `observation_id` | `str | None` | ID da observacao criada/atualizada. |

### materialize_emotional_trends

Agrega dados de emocao de eventos, detecta tendencias e materializa como meta-observacao.

```python
async def materialize_emotional_trends(
    session: AsyncSession,
    user_id: str,
    config: MemoryConfig,
) -> EmotionalTrendsResult
```

| Parametro | Tipo | Descricao |
|-----------|------|-----------|
| `session` | `AsyncSession` | Sessao do banco de dados. |
| `user_id` | `str` | Identificador do usuario. |
| `config` | `MemoryConfig` | Configuracao da memoria com janela de tendencia e minimo de eventos. |

**Retorna:** `EmotionalTrendsResult` com dados de tendencia agregados.

### get_emotional_summary_for_context

Retorna sumario emocional formatado para injecao no contexto de retrieval. Retorna `None` se nao existir tendencia emocional ativa recente (7 dias).

```python
async def get_emotional_summary_for_context(
    session: AsyncSession,
    user_id: str,
) -> str | None
```

**Retorna:** String de sumario formatada, ou `None`.

---

## Importancia Dinamica

### compute_dynamic_importance

Calcula score de importancia dinamica para um fato de memoria. Inspirado em modelos cognitivos de forca de memoria.

Componentes:

- **retrieval_boost**: `log(1 + times_retrieved)` -- satura gradualmente
- **recency_of_use_boost**: decai a partir de `last_retrieved_at` (meia-vida de 7 dias)
- **correction_penalty**: `0.8^n` para cada correcao do usuario
- **pattern_boost**: 1.3x se o fato faz parte de uma meta-observacao ativa

```python
def compute_dynamic_importance(
    base_importance: float,
    times_retrieved: int,
    last_retrieved_at: datetime | None,
    user_correction_count: int,
    is_in_active_pattern: bool,
    now: datetime | None = None,
) -> float
```

| Parametro | Tipo | Descricao |
|-----------|------|-----------|
| `base_importance` | `float` | Score de importancia base (tipicamente 0.5). |
| `times_retrieved` | `int` | Numero de vezes que este fato foi recuperado. |
| `last_retrieved_at` | `datetime | None` | Quando o fato foi recuperado pela ultima vez. |
| `user_correction_count` | `int` | Numero de correcoes do usuario neste fato. |
| `is_in_active_pattern` | `bool` | Se o fato faz parte de uma meta-observacao ativa. |
| `now` | `datetime | None` | Timestamp atual (padrao UTC now). |

**Retorna:** Score de importancia dinamica, limitado a `[0.05, 3.0]`.

---

## Memoria Procedimental

Sistema de diretivas comportamentais otimizado para LLM que comprime persona + preferencias comportamentais aprendidas em blocos de instrucao coesos.

### DirectiveBlock

| Campo | Tipo | Descricao |
|-------|------|-----------|
| `text` | `str` | Bloco de instrucoes comportamentais coeso. |
| `directive_count` | `int` | Numero de diretivas ativas usadas. |
| `cache_hit` | `bool` | Se foi servido do cache. |

### ContradictionResult

| Campo | Tipo | Descricao |
|-------|------|-----------|
| `has_contradiction` | `bool` | Se uma contradicao foi encontrada. |
| `conflicting_directive` | `str | None` | Titulo da diretiva conflitante. |
| `resolution` | `str | None` | Explicacao de como a contradicao foi resolvida. |

### generate_optimized_directives

Gera um bloco de instrucoes comportamentais otimizado por LLM integrando persona + diretivas aprendidas.

```python
async def generate_optimized_directives(
    session: AsyncSession,
    user_id: str,
    llm_provider: LLMProvider,
    config: MemoryConfig,
    *,
    persona_text: str = "",
) -> DirectiveBlock
```

| Parametro | Tipo | Descricao |
|-----------|------|-----------|
| `session` | `AsyncSession` | Sessao do banco de dados. |
| `user_id` | `str` | Identificador do usuario. |
| `llm_provider` | `LLMProvider` | Provider de LLM injetado. |
| `config` | `MemoryConfig` | Configuracao da memoria. |
| `persona_text` | `str` | Descricao de persona opcional. |

**Retorna:** `DirectiveBlock` com texto gerado. Resultado e cacheado por hash dos IDs de diretivas + contagens de reforco. Fail-safe: retorna `DirectiveBlock` vazio em caso de erro.

### check_directive_contradiction

Verifica uma nova diretiva contra existentes para contradicoes. Usa similaridade de embedding como pre-filtro, depois LLM como juiz.

```python
async def check_directive_contradiction(
    session: AsyncSession,
    user_id: str,
    new_directive: str,
    embedding_provider: EmbeddingProvider,
    llm_provider: LLMProvider,
    *,
    similarity_threshold: float = 0.80,
) -> ContradictionResult
```

| Parametro | Tipo | Descricao |
|-----------|------|-----------|
| `session` | `AsyncSession` | Sessao do banco de dados. |
| `user_id` | `str` | Identificador do usuario. |
| `new_directive` | `str` | Texto da nova diretiva para verificar. |
| `embedding_provider` | `EmbeddingProvider` | Provider de embedding injetado. |
| `llm_provider` | `LLMProvider` | Provider de LLM injetado. |
| `similarity_threshold` | `float` | Similaridade minima para acionar verificacao LLM (padrao 0.80). |

**Retorna:** `ContradictionResult` com resultado da verificacao. Fail-safe: retorna sem contradicao em caso de erro.

### effective_confidence

Aplica decaimento temporal na confianca de diretivas. Formula: `base_confidence * 0.95^semanas`.

```python
def effective_confidence(
    base_confidence: float,
    created_at: datetime,
    now: datetime | None = None,
) -> float
```

| Parametro | Tipo | Descricao |
|-----------|------|-----------|
| `base_confidence` | `float` | Valor de confianca original (0.0-1.0). |
| `created_at` | `datetime` | Quando a diretiva foi criada. |
| `now` | `datetime | None` | Timestamp atual (padrao UTC now). |

**Retorna:** Confianca com decaimento, piso em 0.10.

### invalidate_directive_cache

Invalida manualmente o cache de diretivas de um usuario.

```python
def invalidate_directive_cache(user_id: str) -> None
```


---

# Utilitarios de Banco de Dados

O modulo `arandu.db` fornece funcoes de baixo nivel para configuracao do banco de dados. Sao usadas internamente pelo `MemoryClient`, mas estao disponiveis para casos de uso avancados onde voce precisa de controle direto sobre o engine e o ciclo de vida das sessoes.

```python
from arandu.db import create_engine, create_session_factory, init_db
```

---

## create_engine

Cria um engine async do SQLAlchemy a partir de uma string de conexao.

Converte automaticamente `postgresql://` para `postgresql+psycopg://` se o prefixo do driver async estiver ausente.

```python
def create_engine(database_url: str) -> AsyncEngine
```

| Parametro | Tipo | Descricao |
|-----------|------|-----------|
| `database_url` | `str` | String de conexao PostgreSQL. |

**Retorna:** Instancia de `AsyncEngine`.

```python
from arandu.db import create_engine

engine = create_engine("postgresql://user:pass@localhost:5432/mydb")
# Internamente se torna: postgresql+psycopg://user:pass@localhost:5432/mydb
```

---

## create_session_factory

Cria uma fabrica de sessoes async vinculada ao engine fornecido.

```python
def create_session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]
```

| Parametro | Tipo | Descricao |
|-----------|------|-----------|
| `engine` | `AsyncEngine` | O engine async para vincular as sessoes. |

**Retorna:** `async_sessionmaker[AsyncSession]` com `expire_on_commit=False`.

```python
from arandu.db import create_engine, create_session_factory

engine = create_engine("postgresql://user:pass@localhost:5432/mydb")
SessionFactory = create_session_factory(engine)

async with SessionFactory() as session:
    # Use a sessao para queries
    ...
```

---

## init_db

Cria todas as tabelas de memoria no banco de dados do consumidor.

Usa `Base.metadata.create_all` -- seguro para chamar multiplas vezes (cria apenas tabelas que ainda nao existem). Garante que todas as classes de modelo SQLAlchemy estejam registradas antes de criar as tabelas.

```python
async def init_db(engine: AsyncEngine) -> None
```

| Parametro | Tipo | Descricao |
|-----------|------|-----------|
| `engine` | `AsyncEngine` | O engine async para criar as tabelas. |

```python
from arandu.db import create_engine, init_db

engine = create_engine("postgresql://user:pass@localhost:5432/mydb")
await init_db(engine)
```

---

## Schema do Banco de Dados

O SDK define seus modelos SQLAlchemy em `arandu.models`. As tabelas principais incluem:

| Tabela | Descricao |
|--------|-----------|
| `memory_events` | Registros de eventos imutaveis (mensagens do usuario com embeddings). |
| `memory_facts` | Fatos extraidos com triplas entidade/atributo/valor e embeddings. |
| `memory_entities` | Registro de entidades (pessoas, lugares, pets, etc.). |
| `memory_entity_aliases` | Aliases para resolucao de entidades. |
| `memory_entity_relationships` | Arestas do knowledge graph entre entidades. |
| `memory_clusters` | Clusters semanticos de fatos relacionados. |
| `memory_meta_observations` | Padroes detectados, insights e preferencias comportamentais. |
| `memory_attribute_registry` | Registro de chaves de atributo customizadas por usuario. |
| `session_observations` | Observacoes de nivel L1 de sessao do observer. |

Todas as tabelas usam chaves primarias UUID e incluem `user_id` para isolamento multi-tenant. As tabelas `memory_facts` e `memory_events` possuem colunas de embedding `pgvector` para busca semantica.

!!! info "Gerenciamento de Schema"
    Para deploys em producao, considere usar migracoes Alembic em vez de `init_db()`. A funcao `init_db()` e conveniente para desenvolvimento e testes, mas nao lida com migracoes de schema para tabelas existentes.


---

# Referencia de Tipos de Dados

Esta pagina documenta todas as dataclasses, enums e tipos de resultado usados nos pipelines de escrita, leitura e jobs de background que nao sao cobertos na [Referencia da API](../reference/index.md) principal.

---

## Tipos do Write Pipeline

### InputType

```python
class InputType(str, Enum)
```

Tipos de classificacao de texto de entrada, determinados por heuristicas em `classify_input()`.

| Valor | Descricao |
|-------|-----------|
| `SHORT` | Menos de 500 caracteres. |
| `MEDIUM` | 500-2000 caracteres, nao estruturado. |
| `LONG` | Mais de 2000 caracteres, nao estruturado. |
| `STRUCTURED` | Mais de 500 caracteres com headers, bullets ou tabelas. |

### ExtractionMode

```python
class ExtractionMode(str, Enum)
```

| Valor | Descricao |
|-------|-----------|
| `SINGLE_SHOT` | Chamada unica de LLM para extracao. |
| `CHUNKED` | Entrada e dividida em chunks, cada um processado separadamente. |

### InputClassification

```python
@dataclass
class InputClassification
```

Resultado de `classify_input()`. Veja [API do Write Pipeline](write-api.md#inputclassification) para referencia completa dos campos.

### ExtractionStrategy

```python
@dataclass
class ExtractionStrategy
```

Resultado de `select_strategy()`. Veja [API do Write Pipeline](write-api.md#extractionstrategy) para referencia completa dos campos.

### CorrectionResult

```python
@dataclass
class CorrectionResult
```

Resultado da deteccao de correcoes.

| Campo | Tipo | Padrao | Descricao |
|-------|------|--------|-----------|
| `corrections_detected` | `int` | `0` | Numero de correcoes encontradas. |
| `corrected_keys` | `list[str]` | `[]` | Chaves de atributo que foram corrigidas. |
| `facts_corrected_ids` | `list[str]` | `[]` | IDs dos fatos antigos que foram corrigidos. |

---

## Tipos do Read Pipeline

### ExpandedQuery

```python
@dataclass
class ExpandedQuery
```

Resultado da expansao de query (entity priming).

| Campo | Tipo | Descricao |
|-------|------|-----------|
| `primed_entities` | `list[str]` | Entity keys descobertas via alias + KG priming. |
| `temporal_range` | `tuple[datetime, datetime] | None` | Faixa de datas resolvida. |
| `expanded_terms` | `list[str]` | Termos de contexto adicionais dos fatos das entidades. |

### PatternQuery

```python
@dataclass
class PatternQuery
```

Uma query baseada em padrao para matching de sinal de keyword.

| Campo | Tipo | Padrao | Descricao |
|-------|------|--------|-----------|
| `entity_pattern` | `str` | -- | Padrao SQL LIKE para matching de entity_key. |
| `attribute_filter` | `str | None` | `None` | Filtro opcional de chave de atributo. |

### RetrievalPlan

```python
@dataclass
class RetrievalPlan
```

Saida do retrieval agent LLM planner. Veja [API do Read Pipeline](read-api.md#retrievalplan) para referencia completa dos campos.

### GraphRetrievalResult

```python
@dataclass
class GraphRetrievalResult
```

Resultado do retrieval baseado em grafo BFS de 2 saltos.

| Campo | Tipo | Padrao | Descricao |
|-------|------|--------|-----------|
| `facts` | `list[dict[str, Any]]` | `[]` | Dicts de fatos pontuados com `source="graph"`. |
| `neighbor_keys` | `list[str]` | `[]` | Entity keys descobertas via BFS. |
| `edges_traversed` | `int` | `0` | Total de arestas examinadas durante BFS. |
| `edges` | `list[dict[str, Any]]` | `[]` | Dicts de arestas deduplicadas com nomes de exibicao. |

### SpreadingActivationResult

```python
@dataclass
class SpreadingActivationResult
```

Resultado da expansao por spreading activation.

| Campo | Tipo | Padrao | Descricao |
|-------|------|--------|-----------|
| `candidates` | `list[RetrievalCandidate]` | `[]` | Candidatos expandidos dos saltos 1-2. |
| `meta_observations` | `list[Any]` | `[]` | Meta-observacoes relevantes referenciando fatos semente. |
| `entities_explored` | `list[str]` | `[]` | Entity keys exploradas durante spreading. |
| `clusters_explored` | `list[str]` | `[]` | Cluster IDs explorados durante spreading. |
| `hop1_count` | `int` | `0` | Numero de fatos encontrados no salto 1. |
| `hop2_count` | `int` | `0` | Numero de fatos encontrados no salto 2. |
| `kg_relationships_explored` | `int` | `0` | Numero de relacionamentos KG percorridos. |

### CompressedContext

```python
@dataclass
class CompressedContext
```

Resultado da compressao de contexto (camadas hot/warm/cold).

| Campo | Tipo | Padrao | Descricao |
|-------|------|--------|-----------|
| `context_text` | `str` | `""` | String de contexto final pronta para prompt. |
| `hot_count` | `int` | `0` | Numero de fatos na camada hot (Tier 1). |
| `warm_count` | `int` | `0` | Numero de fatos na camada warm (Tier 2). |
| `cold_count` | `int` | `0` | Numero de itens na camada cold (Tier 3). |
| `total_tokens` | `int` | `0` | Contagem estimada de tokens do context_text. |

### EmotionalTrendsResult

```python
@dataclass
class EmotionalTrendsResult
```

Resultado da materializacao de tendencias emocionais.

| Campo | Tipo | Padrao | Descricao |
|-------|------|--------|-----------|
| `emotion_counts` | `dict[str, int]` | `{}` | Mapeamento de emocao para contagem de ocorrencias. |
| `trend_direction` | `str` | `"stable"` | `"increasing"`, `"decreasing"` ou `"stable"`. |
| `dominant_emotion` | `str | None` | `None` | Emocao mais frequente. |
| `trigger_keywords` | `list[str]` | `[]` | Top keywords de eventos de alta intensidade. |
| `avg_intensity` | `float` | `0.0` | Intensidade emocional media. |
| `dominant_intensity` | `float` | `0.0` | Intensidade media da emocao dominante. |
| `dominant_energy` | `str` | `"medium"` | Nivel de energia predominante. |
| `events_analyzed` | `int` | `0` | Numero de eventos analisados. |
| `observation_created` | `bool` | `False` | Se uma meta-observacao foi criada/atualizada. |
| `observation_id` | `str | None` | `None` | ID da observacao criada/atualizada. |

### DirectiveBlock

```python
@dataclass
class DirectiveBlock
```

Resultado da geracao de diretivas (memoria procedimental).

| Campo | Tipo | Padrao | Descricao |
|-------|------|--------|-----------|
| `text` | `str` | `""` | Bloco de instrucoes comportamentais coeso. |
| `directive_count` | `int` | `0` | Numero de diretivas ativas usadas. |
| `cache_hit` | `bool` | `False` | Se foi servido do cache. |

### ContradictionResult

```python
@dataclass
class ContradictionResult
```

Resultado da verificacao de contradicao entre diretivas.

| Campo | Tipo | Padrao | Descricao |
|-------|------|--------|-----------|
| `has_contradiction` | `bool` | `False` | Se uma contradicao foi encontrada. |
| `conflicting_directive` | `str | None` | `None` | Titulo da diretiva conflitante. |
| `resolution` | `str | None` | `None` | Explicacao da resolucao. |

---

## Tipos de Resultado de Background Jobs

### ClusteringResult

```python
@dataclass
class ClusteringResult
```

Resultado do clustering de fatos.

| Campo | Tipo | Padrao | Descricao |
|-------|------|--------|-----------|
| `clusters_created` | `int` | `0` | Numero de novos clusters criados. |
| `clusters_reinforced` | `int` | `0` | Numero de clusters existentes atualizados. |
| `summaries_generated` | `int` | `0` | Numero de sumarios de cluster gerados via LLM. |
| `facts_assigned` | `int` | `0` | Numero de fatos atribuidos a clusters. |

### CommunityDetectionResult

```python
@dataclass
class CommunityDetectionResult
```

Resultado da deteccao de comunidades cross-entity.

| Campo | Tipo | Padrao | Descricao |
|-------|------|--------|-----------|
| `communities_created` | `int` | `0` | Novas observacoes de comunidade criadas. |
| `communities_reinforced` | `int` | `0` | Observacoes de comunidade existentes reforcadas. |
| `clusters_in_communities` | `int` | `0` | Total de clusters atribuidos a comunidades. |
| `skipped` | `bool` | `False` | Se a deteccao foi pulada. |
| `skip_reason` | `str | None` | `None` | Motivo de ter sido pulada. |

### ConsolidationResult

```python
@dataclass
class ConsolidationResult
```

Resultado da consolidacao L2/L3.

| Campo | Tipo | Padrao | Descricao |
|-------|------|--------|-----------|
| `events_processed` | `int` | `0` | Numero de eventos analisados. |
| `observations_created` | `int` | `0` | Novas meta-observacoes criadas. |
| `observations_reinforced` | `int` | `0` | Observacoes existentes reforcadas. |
| `skipped` | `bool` | `False` | Se a consolidacao foi pulada. |
| `skip_reason` | `str | None` | `None` | Motivo de ter sido pulada. |

### MemifyResult

```python
@dataclass
class MemifyResult
```

Resultado do pipeline memify (scoring de vitalidade, marcacao de obsolescencia, gerenciamento de arestas).

| Campo | Tipo | Padrao | Descricao |
|-------|------|--------|-----------|
| `facts_scored` | `int` | `0` | Numero de fatos pontuados por vitalidade. |
| `facts_marked_stale` | `int` | `0` | Numero de fatos marcados como obsoletos. |
| `edges_reinforced` | `int` | `0` | Numero de arestas KG reforcadas. |
| `merges_executed` | `int` | `0` | Numero de merges de entidade executados. |

### EntityImportanceResult

```python
@dataclass
class EntityImportanceResult
```

Resultado do scoring de importancia de entidades (sleep-time compute).

| Campo | Tipo | Padrao | Descricao |
|-------|------|--------|-----------|
| `entities_scored` | `int` | `0` | Numero de entidades pontuadas. |
| `top_entities` | `list[tuple[str, float]]` | `[]` | Top entidades por score (pares chave, score). |

### SummaryRefreshResult

```python
@dataclass
class SummaryRefreshResult
```

Resultado da atualizacao de sumarios de entidades (sleep-time compute).

| Campo | Tipo | Padrao | Descricao |
|-------|------|--------|-----------|
| `summaries_refreshed` | `int` | `0` | Numero de sumarios gerados. |
| `summaries_skipped` | `int` | `0` | Numero de entidades puladas. |

---

## Funcoes de Background

### tag_event_emotion

Infere emocao, intensidade e energia a partir de texto de evento via LLM.

```python
async def tag_event_emotion(
    event_text: str,
    llm: LLMProvider,
) -> dict[str, Any] | None
```

| Parametro | Tipo | Descricao |
|-----------|------|-----------|
| `event_text` | `str` | Texto para analisar. |
| `llm` | `LLMProvider` | Provider de LLM injetado. |

**Retorna:** Dict com chaves `emotion`, `intensity`, `energy`, ou `None` em caso de falha.

```python
from arandu.background import tag_event_emotion

result = await tag_event_emotion("Estou muito feliz hoje!", llm)
# {"emotion": "joy", "intensity": 0.85, "energy": "high"}
```

---

## Modelos de Banco de Dados

Os modelos SQLAlchemy abaixo definem a camada de persistencia. Eles vivem em `arandu.models` e sao uteis para queries avancadas executadas diretamente contra o banco de dados.

### MemoryFact

Ledger de fatos versionados — armazena fatos estruturados com janelas de validade.

| Coluna | Tipo | Descricao |
|--------|------|-----------|
| `id` | `UUID` | Chave primaria. |
| `user_id` | `Text` | ID do usuario proprietario (qualquer string: UUID, email, numerico, etc.). |
| `entity_type` | `String` | Tipo da entidade (ex: `"person"`). |
| `entity_key` | `String` | Chave canonica da entidade (ex: `"person:ana"`). |
| `entity_name` | `String?` | Nome legivel da entidade. |
| `attribute_key` | `String?` | Chave do atributo (ex: `"occupation"`). |
| `fact_text` | `Text` | Fato em linguagem natural. |
| `category` | `String(50)?` | Categoria do fato. |
| `confidence` | `Float` | Score de confianca (padrao 0.8). |
| `importance` | `Float` | Score de importancia (padrao 0.5). |
| `is_sensitive` | `Boolean` | Se o fato contem dados sensiveis. |
| `valid_from` | `DateTime` | Inicio da janela de validade. |
| `valid_to` | `DateTime?` | Fim da validade (`NULL` = ativo atualmente). |
| `ttl_days` | `Integer?` | Time-to-live opcional em dias. |
| `source_event_id` | `UUID?` | FK para `MemoryEvent`. |
| `supersedes_fact_id` | `UUID?` | ID do fato que este substitui. |
| `embedding_vec` | `Vector(1536)` | Embedding pgvector para busca semantica. |
| `vitality_score` | `Float?` | Score de vitalidade do sleep-time. |
| `is_stale` | `Boolean` | Se foi marcado como obsoleto pelo memify. |
| `cluster_id` | `UUID?` | FK para `MemoryCluster`. |
| `times_retrieved` | `Integer` | Quantas vezes este fato foi recuperado. |
| `search_vector` | `TSVECTOR` | Coluna de indice de busca full-text. |

### MemoryEntity

No de entidade de primeira classe no knowledge graph.

| Coluna | Tipo | Descricao |
|--------|------|-----------|
| `id` | `UUID` | Chave primaria. |
| `user_id` | `Text` | ID do usuario proprietario (qualquer string: UUID, email, numerico, etc.). |
| `canonical_key` | `String(128)` | Chave canonica unica (ex: `"person:ana"`). |
| `display_name` | `String(256)?` | Nome de exibicao legivel. |
| `entity_type` | `String(32)` | Tipo (`"person"`, `"organization"`, etc.). |
| `summary_text` | `Text?` | Sumario da entidade gerado por LLM. |
| `embedding_vec` | `Vector(1536)` | Embedding da entidade. |
| `fact_count` | `Integer` | Numero de fatos vinculados. |
| `importance_score` | `Float?` | Score de importancia do sleep-time. |
| `is_active` | `Boolean` | Se a entidade esta ativa. |

Constraint unica: `(user_id, canonical_key)`.

### MemoryEntityAlias

Mapeia nomes de alias para chaves canonicas de entidade para resolucao de entidades.

| Coluna | Tipo | Descricao |
|--------|------|-----------|
| `id` | `UUID` | Chave primaria. |
| `user_id` | `Text` | ID do usuario proprietario (qualquer string: UUID, email, numerico, etc.). |
| `alias` | `String` | Texto do alias (ex: `"Ana"`). |
| `canonical_entity_key` | `String` | Chave canonica de destino. |
| `canonical_entity_type` | `String` | Tipo da entidade de destino. |

Constraint unica: `(user_id, alias)`.

### MemoryEntityRelationship

Aresta direcionada entre duas entidades no knowledge graph.

| Coluna | Tipo | Descricao |
|--------|------|-----------|
| `id` | `UUID` | Chave primaria. |
| `user_id` | `Text` | ID do usuario proprietario (qualquer string: UUID, email, numerico, etc.). |
| `source_entity_key` | `String(128)` | Chave canonica da entidade de origem. |
| `target_entity_key` | `String(128)` | Chave canonica da entidade de destino. |
| `rel_type` | `String(64)` | Tipo de relacionamento (ex: `"works_at"`, `"mentored_by"`). |
| `strength` | `Float` | Forca da aresta (padrao 0.8). |
| `evidence_fact_id` | `UUID?` | FK para o fato que evidencia esta aresta. |
| `provenance` | `String(16)` | Como a aresta foi criada (`"rule"`, `"llm"`). |
| `valid_from` | `DateTime` | Inicio da validade. |
| `valid_to` | `DateTime?` | Fim da validade (`NULL` = ativa). |

Constraint unica: `(user_id, source_entity_key, target_entity_key, rel_type)`.

#### Tipos de Relacionamento Dinamicos

O campo `rel_type` aceita **qualquer** string curta e descritiva em `snake_case` — nao e restrito a um conjunto fixo. O pipeline de extracao instrui o LLM a escolher o tipo mais descritivo para cada relacionamento.

Tipos comuns (usados como exemplos no prompt de extracao, nao como restricoes):

`works_at`, `manages`, `reports_to`, `family_of`, `friend_of`, `partner_of`, `owns`, `lives_in`, `member_of`, `studies_at`, `works_with`

O LLM tambem pode produzir tipos como `mentored_by`, `inspired_by`, `competed_with`, ou qualquer outro tipo descritivo.

**Normalizacao**: Todos os tipos de relacionamento sao normalizados via `normalize_rel_type()` antes da persistencia:

- Lowercase + underscores (ex: `"Mentored By"` → `"mentored_by"`)
- Aliases conhecidos sao mapeados para tipos comuns (ex: `"boss"` → `"reports_to"`, `"spouse"` → `"partner_of"`)
- Tipos desconhecidos passam apos sanitizacao

O set `CANONICAL_REL_TYPES` em `arandu.constants` esta disponivel como **referencia** para consumers que queiram filtrar por tipos conhecidos, mas nao e usado como filtro de validacao.

Veja [Vinculação de Evidência e Cascata de Invalidação](../concepts/write-pipeline.md#vinculacao-de-evidencia-e-cascata-de-invalidacao) para como relacionamentos são vinculados a fatos e automaticamente limpos quando fatos mudam.

### MemoryEvent

Log imutavel de eventos — armazena todas as mensagens do usuario com embeddings.

| Coluna | Tipo | Descricao |
|--------|------|-----------|
| `id` | `UUID` | Chave primaria. |
| `user_id` | `Text` | ID do usuario proprietario (qualquer string: UUID, email, numerico, etc.). |
| `occurred_at` | `DateTime` | Quando o evento aconteceu. |
| `text` | `Text` | Conteudo textual do evento. |
| `source` | `String` | Origem (padrao `"api"`). |
| `importance` | `Float` | Score de importancia (padrao 0.5). |
| `embedding_vec` | `Vector(1536)` | Embedding do evento para retrieval. |
| `emotion_primary` | `String(32)?` | Label de emocao primaria. |
| `emotion_intensity` | `Float?` | Intensidade da emocao (0-1). |
| `energy_level` | `String(16)?` | Nivel de energia (`"low"`, `"medium"`, `"high"`). |

### MemoryCluster

Cluster semantico agrupando fatos relacionados para contexto mais rico.

| Coluna | Tipo | Descricao |
|--------|------|-----------|
| `id` | `UUID` | Chave primaria. |
| `user_id` | `Text` | ID do usuario proprietario (qualquer string: UUID, email, numerico, etc.). |
| `label` | `String(128)` | Label do cluster. |
| `summary_text` | `Text?` | Sumario do cluster gerado por LLM. |
| `cluster_type` | `String(32)` | Tipo do cluster (padrao `"auto"`). |
| `fact_count` | `Integer` | Numero de fatos no cluster. |
| `importance` | `Float` | Importancia do cluster (padrao 0.5). |
| `embedding_vec` | `Vector(1536)` | Embedding do cluster. |
| `is_active` | `Boolean` | Se o cluster esta ativo. |

### MemoryMetaObservation

Meta-observacoes derivadas da consolidacao — padroes, insights, tendencias.

| Coluna | Tipo | Descricao |
|--------|------|-----------|
| `id` | `UUID` | Chave primaria. |
| `user_id` | `Text` | ID do usuario proprietario (qualquer string: UUID, email, numerico, etc.). |
| `observation_type` | `String(32)` | Tipo (`"pattern"`, `"trend"`, `"community"`, etc.). |
| `title` | `String(256)` | Titulo curto. |
| `text` | `Text` | Texto completo da observacao. |
| `supporting_event_ids` | `JSONB` | Lista de UUIDs de eventos de suporte. |
| `supporting_fact_ids` | `JSONB` | Lista de UUIDs de fatos de suporte. |
| `confidence` | `Float` | Confianca (padrao 0.7). |
| `importance` | `Float` | Importancia (padrao 0.5). |
| `times_reinforced` | `Integer` | Quantas vezes esta observacao foi reforcada. |
| `is_active` | `Boolean` | Se a observacao esta ativa. |
| `embedding_vec` | `Vector(1536)` | Embedding da observacao. |

### MemoryAttributeRegistry

Registro para gerenciamento de chaves de atributo — rastreia chaves propostas vs ativas.

| Coluna | Tipo | Descricao |
|--------|------|-----------|
| `id` | `UUID` | Chave primaria. |
| `key` | `String(64)` | Chave de atributo unica. |
| `status` | `String(20)` | `"proposed"` ou `"active"`. |
| `value_type` | `String(20)` | Tipo de valor esperado (padrao `"string"`). |
| `conflict_policy` | `String(20)` | Como lidar com conflitos (padrao `"supersede"`). |
| `ttl_days` | `Integer?` | TTL padrao opcional para fatos com esta chave. |
| `seen_count` | `Integer` | Quantas vezes esta chave foi vista. |
| `proposed_by` | `String(20)` | Quem propos a chave (`"llm"`, `"user"`). |
| `reason` | `Text?` | Por que a chave foi proposta. |


---

# Configuration Reference

All tuning parameters for the Arandu SDK live in `MemoryConfig`. Every field has a sensible default — override only what you need.

```python
from arandu.config import MemoryConfig

config = MemoryConfig(
    topk_facts=10,
    enable_reranker=False,
    min_score=0.15,
)
```

You can also override per-request via `config_overrides`:

```python
result = await memory.retrieve(
    user_id="user_123",
    query="...",
    config_overrides={"topk_facts": 5, "enable_reranker": False},
)
```

---

## Extraction

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `extraction_timeout_sec` | `float` | `30.0` | Timeout per LLM call during extraction. |

## Entity Resolution

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `fuzzy_threshold` | `float` | `0.85` | Cosine similarity threshold for direct fuzzy match. Above this → auto-resolve. Below 0.50 → new entity. Between → LLM decides. |
| `enable_llm_resolution` | `bool` | `True` | Whether to use LLM for ambiguous entity matches. When `False`, ambiguous cases create new entities instead. |

## Retrieval

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `topk_facts` | `int` | `20` | Maximum facts returned by `retrieve()`. |
| `topk_events` | `int` | `8` | Maximum events included in context. |
| `event_max_scan` | `int` | `200` | Maximum events scanned for relevance. |
| `min_similarity` | `float` | `0.20` | Minimum cosine similarity for semantic search candidates. |
| `min_confidence` | `float` | `0.55` | Minimum confidence for facts to be considered. |
| `min_score` | `float` | `0.15` | Minimum final score for facts to be included in results. Set higher (e.g., `0.20`) to filter low-relevance facts. |
| `recency_half_life_days` | `int` | `14` | Half-life for recency decay scoring. |

## Reranker

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `enable_reranker` | `bool` | `True` | Enable LLM-based reranking. When enabled, the reranker uses a multiplicative blend with the formula score — `score_weights` still matter for the base score. |
| `reranker_timeout_sec` | `float` | `5.0` | Timeout for the reranker LLM call. Falls back to original ranking on timeout. |
| `min_reranker_score` | `float` | `0.10` | Minimum reranker score for a fact to survive. Facts below this threshold are eliminated (final_score = 0.0), giving the reranker veto power over irrelevant facts. Only applies when `enable_reranker=True`. Set `0.05` for more permissive, `0.20` for stricter. |

## Score Weights

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `score_weights` | `dict` | `{"semantic": 0.70, "recency": 0.20, "importance": 0.10}` | Weights for hybrid ranking formula. Always affects the base formula score, which the reranker blends with multiplicatively. |

## Confidence

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `confidence_level_map` | `dict` | `{explicit: 0.95, strong: 0.80, weak: 0.60, speculation: 0.40}` | Confidence scores assigned during extraction. |
| `confidence_default` | `float` | `0.60` | Default confidence when LLM doesn't specify. |

## Spreading Activation

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `spreading_activation_hops` | `int` | `2` | Maximum hops from seed facts. Set to `0` to disable. |
| `spreading_decay_factor` | `float` | `0.50` | Score decay per hop. Hop 1 = factor, Hop 2 = factor². |
| `spreading_max_related_entities` | `int` | `5` | Max KG-related entities explored per hop. |
| `spreading_facts_per_entity` | `int` | `3` | Max facts fetched per entity in spreading. |

## Context Compression

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `context_max_tokens` | `int` | `2000` | Proportional token budget for context compression. Not a hard cap. |
| `hot_tier_ratio` | `float` | `0.5` | Fraction of budget for top-scoring facts (full detail). |
| `warm_tier_ratio` | `float` | `0.3` | Fraction of budget for mid-scoring facts (summarized). |

## Emotional Trends

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `emotional_trend_window_days` | `int` | `30` | Lookback window for emotional trend detection. |
| `emotional_trend_min_events` | `int` | `5` | Minimum events to compute a trend. |

## Clustering

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `cluster_max_age_days` | `int` | `90` | Maximum age of facts included in clustering. |
| `cluster_min_facts` | `int` | `2` | Minimum facts per cluster. |
| `community_similarity_threshold` | `float` | `0.75` | Cosine similarity threshold for grouping clusters into communities. |
| `community_min_clusters` | `int` | `2` | Minimum clusters to form a community. |

## Consolidation

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `consolidation_min_events` | `int` | `3` | Minimum events before running consolidation. |
| `consolidation_lookback_days` | `int` | `7` | How far back to look for patterns. |

## Sleep-Time Compute

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `importance_recency_halflife_days` | `int` | `30` | Half-life for importance score recency signal. |
| `summary_refresh_interval_days` | `int` | `7` | Entity summaries older than this are marked stale. |

## Memify

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `vitality_stale_threshold` | `float` | `0.2` | Vitality score below which facts are considered stale. |
| `memify_merge_similarity_threshold` | `float` | `0.90` | Threshold for merging similar procedural memories. |

## Procedural Memory

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `directive_max_tokens` | `int` | `300` | Max tokens for procedural directive generation. |
| `directive_cache_ttl_minutes` | `int` | `30` | TTL for directive cache. |
| `contradiction_similarity_threshold` | `float` | `0.80` | Threshold for detecting contradictions. |

## Locale

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `timezone` | `str` | `"UTC"` | IANA timezone for temporal resolution in retrieval. |

## Open Catalog (Extensions)

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `extra_attribute_keys` | `set[str]` | `set()` | Additional attribute keys accepted by the system. |
| `attribute_aliases` | `dict[str, str]` | `{}` | Aliases for attribute keys. |
| `extra_namespaces` | `set[str]` | `set()` | Additional entity namespaces. |
| `extra_self_references` | `frozenset[str]` | `frozenset()` | Additional terms that resolve to `user:self`. |
| `extra_relationship_hints` | `frozenset[str]` | `frozenset()` | Additional relationship hint patterns. |

## Limits

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `max_facts_per_event` | `int` | `100` | Maximum facts extracted per message (safety limit). |
| `embedding_dimensions` | `int` | `1536` | Embedding vector dimensions (must match your provider). |


---

# Database Schema

Arandu uses PostgreSQL with pgvector. All tables are created automatically by `memory.initialize()`. This page documents each table for debugging, querying, and understanding the data model.

---

## Core Tables

### memory_events

Immutable audit log. Every `write()` call creates one event.

| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `user_id` | TEXT | User identifier |
| `content` | TEXT | Raw message text |
| `embedding` | VECTOR | Message embedding |
| `emotion` | VARCHAR | Detected emotion (joy, sadness, anger, etc.) |
| `emotion_intensity` | FLOAT | Emotion intensity 0.0–1.0 |
| `energy_level` | VARCHAR | high, medium, or low |
| `created_at` | TIMESTAMP | When the event was created |

### memory_facts

Versioned factual knowledge. Each fact is a self-contained natural language statement about an entity.

| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `user_id` | TEXT | User identifier |
| `entity_type` | VARCHAR | Free-form entity type (person, organization, place, etc.) |
| `entity_key` | VARCHAR | Canonical entity key (e.g., `person:carlos`) |
| `entity_name` | VARCHAR | Display name of the entity |
| `attribute_key` | VARCHAR | Optional attribute category |
| `fact_text` | TEXT | The fact in natural language |
| `embedding` | VECTOR | Fact text embedding |
| `confidence` | FLOAT | Extraction confidence 0.0–1.0 |
| `importance` | FLOAT | Base importance score |
| `source_event_id` | UUID | FK to the event that created this fact |
| `supersedes_fact_id` | UUID | FK to the fact this one replaces (UPDATE chain) |
| `valid_from` | TIMESTAMP | When this fact became active |
| `valid_to` | TIMESTAMP | When this fact was superseded (NULL = active) |
| `invalidated_at` | TIMESTAMP | When explicitly invalidated |
| `is_stale` | BOOLEAN | Marked stale by memify |
| `last_confirmed_at` | TIMESTAMP | Last NOOP confirmation |
| `times_retrieved` | INT | Retrieval counter |
| `last_retrieved_at` | TIMESTAMP | Last retrieval time |
| `source_context` | VARCHAR | Origin marker (e.g., `inferred_from_relation` for mirror facts) |
| `cluster_id` | UUID | FK to cluster |
| `created_at` | TIMESTAMP | Row creation time |

### memory_fact_entity_links

Cross-entity links. Each fact is linked to ALL entities it mentions, not just its primary subject.

| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `fact_id` | UUID | FK to memory_facts (CASCADE delete) |
| `entity_key` | VARCHAR | Entity this fact is linked to |
| `is_primary` | BOOLEAN | True if this is the fact's primary subject |
| `user_id` | TEXT | User identifier |

**Unique constraint:** `(fact_id, entity_key)` — one link per fact-entity pair.

**Indexes:** `(user_id, entity_key)` for retrieval queries, `(fact_id)` for cascade operations.

### memory_entities

Canonical entity records. Created during entity resolution.

| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `user_id` | TEXT | User identifier |
| `canonical_key` | VARCHAR | Unique key (e.g., `person:carlos`) |
| `display_name` | VARCHAR | Human-readable name |
| `entity_type` | VARCHAR | Free-form type string |
| `embedding_vec` | VECTOR | Entity name embedding |
| `summary_text` | TEXT | LLM-generated summary (from background jobs) |
| `importance_score` | FLOAT | Computed importance 0.0–1.0 |
| `fact_count` | INT | Number of linked facts |
| `is_active` | BOOLEAN | Whether entity is active |

### memory_entity_aliases

Alias cache for fast exact-match entity resolution.

| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `user_id` | TEXT | User identifier |
| `alias` | VARCHAR | Normalized alias text |
| `canonical_entity_key` | VARCHAR | Resolved entity key |
| `canonical_entity_type` | VARCHAR | Entity type |

**Unique constraint:** `(user_id, alias)` — first-write-wins semantics.

### memory_entity_relationships

Knowledge graph edges between entities.

| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `user_id` | TEXT | User identifier |
| `source_entity_key` | VARCHAR | Source entity |
| `target_entity_key` | VARCHAR | Target entity |
| `rel_type` | VARCHAR | Relationship type (snake_case, free-form) |
| `strength` | FLOAT | 0.0–1.0, reinforced on repetition |
| `evidence_fact_id` | UUID | FK to the fact supporting this relationship |
| `valid_from` | TIMESTAMP | When created |
| `valid_to` | TIMESTAMP | When invalidated (NULL = active) |
| `invalidated_at` | TIMESTAMP | Cascade invalidation timestamp |

**Unique constraint:** `(user_id, source_entity_key, target_entity_key, rel_type)`.

!!! warning "Relationships are unidirectional"
    `ana → works_at → acme` does NOT create `acme → employs → ana`. Graph retrieval traverses both directions, but the edge itself is one-way.

---

## Supporting Tables

### memory_clusters

Semantic fact clusters (created by background jobs).

| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `user_id` | TEXT | User identifier |
| `entity_type` | VARCHAR | Cluster entity type |
| `entity_key` | VARCHAR | Cluster entity key |
| `summary` | TEXT | LLM-generated cluster summary |
| `embedding` | VECTOR | Cluster embedding |
| `created_at` | TIMESTAMP | Creation time |

### memory_meta_observations

Higher-order patterns detected by consolidation.

| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `user_id` | TEXT | User identifier |
| `observation_type` | VARCHAR | Type: insight, pattern, contradiction, trend, entity_community |
| `content` | TEXT | Observation text |
| `supporting_fact_ids` | JSONB | Array of fact IDs supporting this observation |
| `is_active` | BOOLEAN | Whether still relevant |
| `created_at` | TIMESTAMP | Creation time |

### memory_attribute_registry

Tracks known attribute keys per user.

| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `user_id` | TEXT | User identifier |
| `attribute_key` | VARCHAR | Attribute key |
| `first_seen_at` | TIMESTAMP | When first used |

### memory_intentions

User intentions detected from events (experimental).

| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `user_id` | TEXT | User identifier |
| `intention` | TEXT | Detected intention |
| `source_event_id` | UUID | Source event |
| `confidence` | FLOAT | Detection confidence |
| `created_at` | TIMESTAMP | Creation time |


---

# Configuração

Todos os parâmetros do sistema de memória são configurados através de uma única dataclass `MemoryConfig`. Todo parâmetro tem um default sensato — sobrescreva apenas o que importa para o seu caso de uso.

```python
from arandu import MemoryClient, MemoryConfig

config = MemoryConfig(
    extraction_timeout_sec=15.0,
    topk_facts=30,
    enable_reranker=True,
)

memory = MemoryClient(
    database_url="postgresql+psycopg://...",
    llm=provider,
    embeddings=provider,
    config=config,
)
```

---

## Extraction

Parâmetros que controlam como fatos, entidades e relacionamentos são extraídos de mensagens.

| Parâmetro | Tipo | Default | Descrição |
|-----------|------|---------|-----------|
| `extraction_timeout_sec` | `float` | `30.0` | Timeout por chamada LLM durante extração. No timeout, a extração retorna resultado vazio (fail-safe) — sem exceção |

**Dicas:**

- Use um modelo menor como `"gpt-4o-mini"` para extração mais rápida e barata
- Reduza `extraction_timeout_sec` se precisa de respostas mais rápidas ao custo de extrações potencialmente perdidas

---

## Entity Resolution

Parâmetros que controlam como menções de entidades extraídas são resolvidas para registros canônicos.

| Parâmetro | Tipo | Default | Descrição |
|-----------|------|---------|-----------|
| `fuzzy_threshold` | `float` | `0.85` | Threshold de similaridade de cosseno para fuzzy match direto. Score ≥ esse valor resolve diretamente; score entre 0.50 e esse valor encaminha para LLM; score < 0.50 cria nova entidade. Reduzir esse valor expande a faixa de fuzzy-resolve e reduz chamadas LLM |
| `enable_llm_resolution` | `bool` | `True` | Se deve usar um LLM para fuzzy matches ambíguos (faixa 0.50–`fuzzy_threshold`). Quando `False`, candidatos ambíguos criam nova entidade |

**Dicas:**

- Reduza `fuzzy_threshold` (ex: 0.75) para ser mais agressivo no matching — isso encolhe a faixa "ambígua" que requer chamadas LLM
- Defina `enable_llm_resolution=False` para pular o fallback LLM em matches ambíguos (mais rápido, mas pode criar mais entidades duplicadas)
- O modelo LLM para entity resolution e reconciliação é determinado pelo `LLMProvider` injetado no `MemoryClient`

---

## Retrieval

Parâmetros que controlam como fatos são recuperados em resposta a queries.

| Parâmetro | Tipo | Default | Descrição |
|-----------|------|---------|-----------|
| `topk_facts` | `int` | `20` | Número máximo de fatos a retornar |
| `topk_events` | `int` | `8` | Número máximo de eventos a considerar para contexto |
| `event_max_scan` | `int` | `200` | Máximo de eventos a escanear durante retrieval |
| `min_similarity` | `float` | `0.20` | Similaridade de cosseno mínima para resultados de busca semântica |
| `min_confidence` | `float` | `0.55` | Confiança mínima do fato para incluir nos resultados de retrieval |

!!! warning "`min_confidence` é um **filtro apenas de read-time**"
    Todos os fatos são persistidos durante o write independente do score de confidence. A filtragem acontece durante `memory.read()` / `memory.retrieve()`. Isso é por design: a confidence pode ser ajustada ao longo do tempo via reforço (confirmações NOOP), e descartar fatos no write-time seria irreversível.
| `recency_half_life_days` | `int` | `14` | Half-life (em dias) para decay exponencial de recência |
| `enable_reranker` | `bool` | `True` | Se deve usar reranking LLM nos resultados de retrieval |
| `reranker_timeout_sec` | `float` | `5.0` | Timeout para chamadas LLM do reranker |
| `min_reranker_score` | `float` | `0.10` | Score mínimo do reranker para um fato sobreviver. Abaixo disso → eliminado (final_score = 0.0). Só quando `enable_reranker=True`. |

**Dicas:**

- Aumente `topk_facts` (ex: 50) para contexto mais amplo ao custo de mais ruído
- Reduza `min_similarity` (ex: 0.10) para capturar matches semânticos mais distantes
- Aumente `recency_half_life_days` (ex: 30) se fatos mais antigos devem permanecer relevantes por mais tempo
- Defina `enable_reranker=False` para retrieval mais rápido quando precisão é menos crítica

---

## Score Weights

Pesos para a fórmula de ranking híbrido que combina múltiplos sinais de retrieval.

| Parâmetro | Tipo | Default | Descrição |
|-----------|------|---------|-----------|
| `score_weights` | `dict` | `{"semantic": 0.70, "recency": 0.20, "importance": 0.10}` | Pesos para cada sinal de scoring (devem somar ~1.0) |

```python
config = MemoryConfig(
    score_weights={
        "semantic": 0.60,   # reduzir semântico, aumentar outros sinais
        "recency": 0.25,
        "importance": 0.15,
    },
)
```

**Dicas:**

- Aumente o peso de `"recency"` para aplicações onde frescor importa mais que relevância semântica
- Aumente o peso de `"importance"` para favorecer entidades bem estabelecidas e fatos frequentemente mencionados

---

## Confidence

Parâmetros que controlam os níveis de confiança atribuídos a fatos extraídos.

| Parâmetro | Tipo | Default | Descrição |
|-----------|------|---------|-----------|
| `confidence_level_map` | `dict` | `{"explicit_statement": 0.95, "strong_inference": 0.80, "weak_inference": 0.60, "speculation": 0.40}` | Mapeamento de nomes de nível de confiança para scores numéricos |
| `confidence_default` | `float` | `0.60` | Confiança padrão quando o LLM não especifica um nível |

---

## Spreading Activation

Parâmetros que controlam como o contexto se expande a partir de fatos semente ao longo de relacionamentos de entidades.

| Parâmetro | Tipo | Default | Descrição |
|-----------|------|---------|-----------|
| `spreading_activation_hops` | `int` | `2` | Número máximo de hops de relacionamento a partir de fatos semente |
| `spreading_decay_factor` | `float` | `0.50` | Multiplicador de decay de score por hop (0.5 = dividido pela metade a cada hop) |
| `spreading_max_related_entities` | `int` | `5` | Máximo de entidades relacionadas a seguir por semente |
| `spreading_facts_per_entity` | `int` | `3` | Máximo de fatos a puxar de cada entidade relacionada |

---

## Compressão de Contexto

Parâmetros que controlam como fatos recuperados são comprimidos na string de contexto final.

| Parâmetro | Tipo | Default | Descrição |
|-----------|------|---------|-----------|
| `context_max_tokens` | `int` | `2000` | Máximo de tokens no output de contexto formatado |
| `hot_tier_ratio` | `float` | `0.50` | Parcela do orçamento de tokens para fatos com scores mais altos |
| `warm_tier_ratio` | `float` | `0.30` | Parcela do orçamento de tokens para fatos de suporte |

O orçamento restante (1 - hot - warm = 0.20) vai para o cold tier (contexto de background).

---

## Tendências Emocionais

Parâmetros para detectar padrões emocionais em mensagens do usuário.

| Parâmetro | Tipo | Default | Descrição |
|-----------|------|---------|-----------|
| `emotional_trend_window_days` | `int` | `30` | Janela para análise de tendências emocionais |
| `emotional_trend_min_events` | `int` | `5` | Mínimo de eventos necessários para detectar uma tendência |

---

## Clustering

Parâmetros para o background job de fact clustering.

| Parâmetro | Tipo | Default | Descrição |
|-----------|------|---------|-----------|
| `cluster_max_age_days` | `int` | `90` | Idade máxima dos fatos a incluir no clustering |
| `cluster_min_facts` | `int` | `2` | Mínimo de fatos por cluster |
| `community_similarity_threshold` | `float` | `0.75` | Threshold de similaridade de cosseno para agrupar clusters em comunidades |
| `community_min_clusters` | `int` | `2` | Mínimo de clusters para formar uma comunidade |

---

## Consolidation

Parâmetros para o background job de consolidation.

| Parâmetro | Tipo | Default | Descrição |
|-----------|------|---------|-----------|
| `consolidation_min_events` | `int` | `3` | Mínimo de eventos antes de rodar consolidation |
| `consolidation_lookback_days` | `int` | `7` | Quantos dias olhar para trás (em dias) em busca de padrões |

---

## Sleep-Time Compute

Parâmetros para importance scoring e summary refresh em background.

| Parâmetro | Tipo | Default | Descrição |
|-----------|------|---------|-----------|
| `importance_recency_halflife_days` | `int` | `30` | Half-life para sinal de recência no importance scoring |
| `summary_refresh_interval_days` | `int` | `7` | Dias antes de um resumo de entidade ser considerado obsoleto |

---

## Memify

Parâmetros para o background job de memify (conhecimento episódico → procedural).

| Parâmetro | Tipo | Default | Descrição |
|-----------|------|---------|-----------|
| `vitality_stale_threshold` | `float` | `0.2` | Score de vitalidade abaixo do qual um fato é considerado obsoleto |
| `memify_merge_similarity_threshold` | `float` | `0.90` | Threshold de similaridade para mesclar procedimentos similares |

---

## Memória Procedural

Parâmetros para retrieval de memória diretiva/procedural.

| Parâmetro | Tipo | Default | Descrição |
|-----------|------|---------|-----------|
| `directive_max_tokens` | `int` | `300` | Máximo de tokens para diretivas procedurais |
| `directive_cache_ttl_minutes` | `int` | `30` | Cache TTL para lookups de diretivas |
| `contradiction_similarity_threshold` | `float` | `0.80` | Threshold para detectar diretivas contraditórias |

---

## Locale / Deploy

| Parâmetro | Tipo | Default | Descrição |
|-----------|------|---------|-----------|
| `timezone` | `str` | `"UTC"` | Timezone IANA para interpretar referências temporais relativas |

O parâmetro `timezone` afeta como referências temporais relativas ("ontem", "semana passada", "hoje de manhã") são interpretadas durante a extração de fatos e retrieval. Todos os timestamps no banco são armazenados em **UTC** independente desta configuração.

Por exemplo: se `timezone="Asia/Tokyo"` e o usuário diz "ontem", o SDK interpreta "ontem" relativo ao horário de Tokyo (JST), não UTC.

---

## Catálogo Aberto (Extensões do Deployer)

Parâmetros para estender o catálogo de atributos built-in com entradas customizadas.

| Parâmetro | Tipo | Default | Descrição |
|-----------|------|---------|-----------|
| `extra_attribute_keys` | `set[str]` | `set()` | Attribute keys adicionais reconhecidas durante extração |
| `attribute_aliases` | `dict[str, str]` | `{}` | Aliases para attribute keys (ex: `{"hometown": "city"}`) |
| `extra_namespaces` | `set[str]` | `set()` | Namespaces de entidade adicionais além dos tipos built-in |
| `extra_self_references` | `frozenset[str]` | `frozenset()` | Palavras adicionais tratadas como auto-referências (ex: `{"yo"}` para espanhol) |
| `extra_relationship_hints` | `frozenset[str]` | `frozenset()` | Palavras adicionais de dica de relacionamento para entity resolution |

---

## Limites

| Parâmetro | Tipo | Default | Descrição |
|-----------|------|---------|-----------|
| `max_facts_per_event` | `int` | `100` | Máximo de fatos extraídos de uma única mensagem |
| `embedding_dimensions` | `int` | `1536` | Dimensionalidade dos vetores de embedding (deve corresponder ao seu provider) |


---

# Custom Providers

O `arandu` usa protocolos Python para injeção de dependência. Você pode usar qualquer backend de LLM ou embedding implementando duas interfaces simples — sem herança necessária.

## Os Protocolos

O SDK define dois protocolos em `arandu.protocols`:

### LLMProvider

Seu LLM provider deve implementar um único método `complete`:

```python
class LLMProvider(Protocol):
    async def complete(
        self,
        messages: list[dict],
        temperature: float = 0,
        response_format: dict | None = None,
        max_tokens: int | None = None,
    ) -> str: ...
```

| Parâmetro | Descrição |
|-----------|-----------|
| `messages` | Lista de dicts de mensagem com chaves `"role"` e `"content"` (formato OpenAI) |
| `temperature` | Temperatura de sampling (0 = determinístico) |
| `response_format` | Especificação de formato opcional (ex: `{"type": "json_object"}` para modo JSON) |
| `max_tokens` | Máximo opcional de tokens para a resposta |
| **Retorna** | O texto de resposta do assistente como string |

!!! important "Suporte a modo JSON"
    O pipeline de memória depende fortemente de respostas em modo JSON (`response_format={"type": "json_object"}`).
    Seu provider deve suportar isso — nativamente ou parseando a resposta.

### EmbeddingProvider

Seu embedding provider deve implementar dois métodos:

```python
class EmbeddingProvider(Protocol):
    async def embed(self, texts: list[str]) -> list[list[float]]: ...
    async def embed_one(self, text: str) -> list[float] | None: ...
```

| Método | Descrição |
|--------|-----------|
| `embed(texts)` | Gera embeddings para um batch de textos. Retorna um vetor por input. |
| `embed_one(text)` | Gera embedding para um único texto. Retorna `None` se o texto for vazio/inválido. |

!!! note "Dimensões de embedding"
    O `embedding_dimensions` padrão em `MemoryConfig` é 1536 (OpenAI `text-embedding-3-small`).
    Se seu provider usa dimensões diferentes, defina `MemoryConfig(embedding_dimensions=...)` de acordo.

---

## Exemplo: Provider Anthropic

Aqui está um exemplo completo implementando ambos os protocolos usando o SDK da Anthropic:

```python
import asyncio
import json
from anthropic import AsyncAnthropic


class AnthropicLLMProvider:
    """LLM provider usando a API Claude da Anthropic."""

    def __init__(self, api_key: str, model: str = "claude-sonnet-4-20250514") -> None:
        self._client = AsyncAnthropic(api_key=api_key)
        self._model = model

    async def complete(
        self,
        messages: list[dict],
        temperature: float = 0,
        response_format: dict | None = None,
        max_tokens: int | None = None,
    ) -> str:
        # Converter mensagens formato OpenAI para formato Anthropic
        system_msg = ""
        chat_messages = []
        for msg in messages:
            if msg["role"] == "system":
                system_msg = msg["content"]
            else:
                chat_messages.append({
                    "role": msg["role"],
                    "content": msg["content"],
                })

        # Adicionar instrução JSON se modo json solicitado
        if response_format and response_format.get("type") == "json_object":
            system_msg += "\n\nRespond with valid JSON only. No markdown fences."

        response = await self._client.messages.create(
            model=self._model,
            system=system_msg,
            messages=chat_messages,
            temperature=temperature,
            max_tokens=max_tokens or 4096,
        )

        return response.content[0].text
```

!!! tip "Providers separados"
    Você pode usar providers diferentes para LLM e embeddings. Por exemplo,
    use Anthropic para completions e OpenAI para embeddings:

    ```python
    memory = MemoryClient(
        database_url="...",
        llm=AnthropicLLMProvider(api_key="sk-ant-..."),
        embeddings=OpenAIProvider(api_key="sk-..."),  # apenas para embeddings
    )
    ```

---

## Exemplo: Provider de Modelo Local

Para rodar com modelos locais (ex: via Ollama):

```python
import httpx


class OllamaProvider:
    """LLM + Embedding provider usando um servidor Ollama local."""

    def __init__(
        self,
        base_url: str = "http://localhost:11434",
        model: str = "llama3.1",
        embedding_model: str = "nomic-embed-text",
    ) -> None:
        self._base_url = base_url
        self._model = model
        self._embedding_model = embedding_model
        self._client = httpx.AsyncClient(timeout=60.0)

    # -- LLMProvider --

    async def complete(
        self,
        messages: list[dict],
        temperature: float = 0,
        response_format: dict | None = None,
        max_tokens: int | None = None,
    ) -> str:
        payload: dict = {
            "model": self._model,
            "messages": messages,
            "stream": False,
            "options": {"temperature": temperature},
        }
        if response_format and response_format.get("type") == "json_object":
            payload["format"] = "json"

        response = await self._client.post(
            f"{self._base_url}/api/chat",
            json=payload,
        )
        response.raise_for_status()
        return response.json()["message"]["content"]

    # -- EmbeddingProvider --

    async def embed(self, texts: list[str]) -> list[list[float]]:
        results = []
        for text in texts:
            if not text.strip():
                continue
            response = await self._client.post(
                f"{self._base_url}/api/embed",
                json={"model": self._embedding_model, "input": text},
            )
            response.raise_for_status()
            results.append(response.json()["embeddings"][0])
        return results

    async def embed_one(self, text: str) -> list[float] | None:
        if not text or not text.strip():
            return None
        results = await self.embed([text])
        return results[0] if results else None
```

!!! warning "Dimensões de embedding"
    Quando usar modelos locais, verifique as dimensões de embedding e configure de acordo:

    ```python
    config = MemoryConfig(
        embedding_dimensions=768,  # nomic-embed-text usa 768 dims
    )
    ```

---

## Testando Seu Provider

Você pode verificar se seu provider funciona com o sistema de memória antes de ir para produção:

```python
import asyncio
from arandu import MemoryClient, MemoryConfig


async def test_provider():
    provider = YourProvider(...)
    memory = MemoryClient(
        database_url="postgresql+psycopg://memory:memory@localhost/memory",
        llm=provider,
        embeddings=provider,
    )
    await memory.initialize()

    try:
        # Testar write
        result = await memory.write(
            user_id="test",
            message="Testing the provider. My name is Alice and I work at Acme.",
        )
        assert len(result.facts_added) > 0, "No facts extracted — check LLM responses"
        assert len(result.entities_resolved) > 0, "No entities resolved"
        print(f"Write OK: {len(result.facts_added)} facts, {len(result.entities_resolved)} entities")

        # Testar retrieve
        context = await memory.retrieve(user_id="test", query="who is Alice?")
        assert len(context.facts) > 0, "No facts retrieved — check embeddings"
        print(f"Retrieve OK: {len(context.facts)} facts found")
        print(f"Context: {context.context}")
    finally:
        await memory.close()


asyncio.run(test_provider())
```

## Requisitos Importantes

Ao implementar um custom provider, tenha estes requisitos em mente:

1. **Modo JSON** — O pipeline envia `response_format={"type": "json_object"}` frequentemente. Seu provider deve retornar JSON válido quando isso é definido.

2. **Async** — Ambos os protocolos são async (`async def`). Se o SDK do seu backend é síncrono, encapsule chamadas com `asyncio.to_thread()`.

3. **Tratamento de vazio/erro** — `embed_one` deve retornar `None` para input vazio, não lançar exceção. `embed` deve retornar `[]` para input vazio.

4. **Timeout** — Considere adicionar timeouts ao seu provider. O SDK define timeouts do seu lado via `MemoryConfig`, mas timeouts no nível do provider adicionam uma camada extra de segurança.

5. **Dimensões de embedding** — Defina `MemoryConfig(embedding_dimensions=N)` para corresponder às dimensões de output do seu provider. Dimensões incompatíveis causam erros no pgvector.


---

# Cookbook

Exemplos completos, prontos para copiar e colar, para casos de uso comuns.

---

## Uso Básico

A integração mais simples: escrever fatos de mensagens do usuário e recuperar contexto para respostas.

```python
import asyncio
from arandu import MemoryClient
from arandu.providers.openai import OpenAIProvider


async def main():
    provider = OpenAIProvider(api_key="sk-...")
    memory = MemoryClient(
        database_url="postgresql+psycopg://memory:memory@localhost:5432/memory",
        llm=provider,
        embeddings=provider,
    )
    await memory.initialize()

    try:
        # Simular uma conversa
        messages = [
            "Hi, I'm Rafael. I'm a backend engineer at Acme Corp in São Paulo.",
            "My girlfriend Ana is a UX designer. We have a cat named Pixel.",
            "I've been learning Rust lately, mostly on weekends.",
            "Actually, I just moved to Rio de Janeiro. Still remote at Acme.",
        ]

        for msg in messages:
            result = await memory.write(user_id="rafael", message=msg)
            added = len(result.facts_added)
            updated = len(result.facts_updated)
            print(f"Write: +{added} facts, ~{updated} updates ({result.duration_ms:.0f}ms)")

        # Recuperar contexto para diferentes queries
        queries = [
            "where does Rafael live?",
            "tell me about Rafael's relationships",
            "what are Rafael's hobbies?",
        ]

        for query in queries:
            result = await memory.retrieve(user_id="rafael", query=query)
            print(f"\nQuery: {query}")
            print(f"Found {len(result.facts)} facts ({result.duration_ms:.0f}ms)")
            for fact in result.facts[:5]:
                print(f"  [{fact.score:.2f}] {fact.entity_name}: {fact.value}")
    finally:
        await memory.close()


asyncio.run(main())
```

---

## Custom Provider (Anthropic)

Use Claude como seu LLM mantendo OpenAI para embeddings:

```python
import asyncio
from anthropic import AsyncAnthropic
from arandu import MemoryClient, MemoryConfig
from arandu.providers.openai import OpenAIProvider


class ClaudeLLM:
    """Anthropic Claude como LLM provider para arandu."""

    def __init__(self, api_key: str, model: str = "claude-sonnet-4-20250514") -> None:
        self._client = AsyncAnthropic(api_key=api_key)
        self._model = model

    async def complete(
        self,
        messages: list[dict],
        temperature: float = 0,
        response_format: dict | None = None,
        max_tokens: int | None = None,
    ) -> str:
        system_msg = ""
        chat_messages = []
        for msg in messages:
            if msg["role"] == "system":
                system_msg = msg["content"]
            else:
                chat_messages.append({"role": msg["role"], "content": msg["content"]})

        if response_format and response_format.get("type") == "json_object":
            system_msg += "\n\nYou MUST respond with valid JSON only. No markdown fences."

        response = await self._client.messages.create(
            model=self._model,
            system=system_msg,
            messages=chat_messages,
            temperature=temperature,
            max_tokens=max_tokens or 4096,
        )
        return response.content[0].text


async def main():
    # Claude para raciocínio, OpenAI para embeddings
    llm = ClaudeLLM(api_key="sk-ant-...")
    embeddings = OpenAIProvider(api_key="sk-...")

    memory = MemoryClient(
        database_url="postgresql+psycopg://memory:memory@localhost/memory",
        llm=llm,
        embeddings=embeddings,
    )
    await memory.initialize()

    try:
        result = await memory.write(
            user_id="demo",
            message="I love hiking in the mountains. Last weekend I went to Serra da Mantiqueira.",
        )
        print(f"Extracted {len(result.facts_added)} facts using Claude")

        context = await memory.retrieve(user_id="demo", query="outdoor activities")
        print(context.context)
    finally:
        await memory.close()


asyncio.run(main())
```

---

## Configuração Avançada (Tuning de Retrieval)

Ajuste fino do retrieval para diferentes casos de uso:

```python
import asyncio
from arandu import MemoryClient, MemoryConfig
from arandu.providers.openai import OpenAIProvider


async def main():
    provider = OpenAIProvider(api_key="sk-...")

    # Configuração para um chatbot que precisa de contexto amplo e recente
    config = MemoryConfig(
        # Extraction: modelo rápido + timeout curto para chat em tempo real
        extraction_timeout_sec=15.0,

        # Retrieval: mais resultados, favorecer recência
        topk_facts=40,
        min_similarity=0.15,          # rede mais ampla
        recency_half_life_days=7,     # favorecer fatos recentes mais agressivamente

        # Pesos de score: boost de recência para conversa dinâmica
        score_weights={
            "semantic": 0.50,
            "recency": 0.35,
            "importance": 0.15,
        },

        # Reranker: usar modelo rápido
        enable_reranker=True,

        # Contexto: orçamento maior para respostas ricas
        context_max_tokens=3000,

        # Spreading activation: expansão de contexto mais ampla
        spreading_activation_hops=3,
        spreading_max_related_entities=8,

        # Timezone para cálculos de recência
        timezone="America/Sao_Paulo",
    )

    memory = MemoryClient(
        database_url="postgresql+psycopg://memory:memory@localhost/memory",
        llm=provider,
        embeddings=provider,
        config=config,
    )
    await memory.initialize()

    try:
        # Escrever uma série de mensagens
        await memory.write(user_id="demo", message="I started a new job at TechCorp today!")
        await memory.write(user_id="demo", message="My manager's name is Sarah. She seems great.")
        await memory.write(user_id="demo", message="The office is in downtown with a nice view.")

        # Recuperar com configurações ajustadas
        result = await memory.retrieve(user_id="demo", query="what's new with the user?")
        print(f"Retrieved {len(result.facts)} facts")
        print(f"Context ({len(result.context)} chars):")
        print(result.context)

        # Verificar scores individuais para validar o tuning
        for fact in result.facts:
            print(f"\n  [{fact.score:.3f}] {fact.value}")
            print(f"    Scores: {fact.scores}")
    finally:
        await memory.close()


asyncio.run(main())
```

---

## Integração de Background Jobs

Configure manutenção periódica para manter a memória organizada:

```python
import asyncio
from arandu import (
    MemoryClient,
    MemoryConfig,
    cluster_user_facts,
    compute_entity_importance,
    detect_communities,
    refresh_entity_summaries,
    run_consolidation,
    run_memify,
)
from arandu.providers.openai import OpenAIProvider
from arandu.db import create_engine, create_session_factory


async def run_maintenance(
    database_url: str,
    user_ids: list[str],
    provider: OpenAIProvider,
    config: MemoryConfig,
) -> None:
    """Executa todos os jobs de manutenção em background para uma lista de usuários."""
    engine = create_engine(database_url)
    session_factory = create_session_factory(engine)

    try:
        async with session_factory() as session:
            for user_id in user_ids:
                print(f"\n--- Manutenção para {user_id} ---")

                # 1. Importance scoring (barato, apenas SQL)
                importance = await compute_entity_importance(session, user_id, config)
                print(f"  Importance: scored {importance.entities_scored} entities")

                # 2. Summary refresh (moderado, LLM)
                summaries = await refresh_entity_summaries(
                    session, user_id, provider, config
                )
                print(f"  Summaries: refreshed {summaries.refreshed_count}")

                # 3. Clustering (moderado, LLM)
                clusters = await cluster_user_facts(
                    session, user_id, provider, provider, config
                )
                print(f"  Clustering: {clusters.clusters_created} clusters")

                # 4. Community detection
                communities = await detect_communities(
                    session, user_id, provider, provider, config
                )
                print(f"  Communities: {communities.communities_found}")

                # 5. Consolidation (moderado, LLM)
                consolidation = await run_consolidation(session, user_id, provider, config)
                print(f"  Consolidation: {consolidation.observations_created} observations")

                # 6. Memify (moderado, LLM)
                memify = await run_memify(session, user_id, provider, config)
                print(f"  Memify: {memify.facts_memified} facts processed")

            await session.commit()
    finally:
        await engine.dispose()


async def main():
    provider = OpenAIProvider(api_key="sk-...")
    config = MemoryConfig()
    database_url = "postgresql+psycopg://memory:memory@localhost/memory"

    # Executar uma vez
    await run_maintenance(database_url, ["user_123", "user_456"], provider, config)

    # Ou agendar com asyncio
    # while True:
    #     await run_maintenance(database_url, user_ids, provider, config)
    #     await asyncio.sleep(4 * 3600)  # a cada 4 horas


asyncio.run(main())
```

---

## Setup Multi-Usuário

Gerencie múltiplos usuários com espaços de memória isolados:

```python
import asyncio
from arandu import MemoryClient
from arandu.providers.openai import OpenAIProvider


async def main():
    provider = OpenAIProvider(api_key="sk-...")
    memory = MemoryClient(
        database_url="postgresql+psycopg://memory:memory@localhost/memory",
        llm=provider,
        embeddings=provider,
    )
    await memory.initialize()

    try:
        # Cada usuário tem memória completamente isolada
        await memory.write(
            user_id="alice",
            message="I work at Google as a PM. I live in Mountain View.",
        )
        await memory.write(
            user_id="bob",
            message="I'm a freelance designer based in Berlin.",
        )

        # Contexto da Alice mostra apenas fatos da Alice
        alice_ctx = await memory.retrieve(user_id="alice", query="where do they work?")
        print("Alice:", alice_ctx.context)

        # Contexto do Bob mostra apenas fatos do Bob
        bob_ctx = await memory.retrieve(user_id="bob", query="where do they work?")
        print("Bob:", bob_ctx.context)
    finally:
        await memory.close()


asyncio.run(main())
```


---

# Changelog

All notable changes to the Arandu SDK.

## v0.6.35 — Reranker Veto + Unambiguous Facts

- **feat:** `min_reranker_score` config (default `0.10`) — when the reranker gives a score below this threshold, the fact is eliminated (final_score = 0.0). Gives the reranker veto power over completely irrelevant facts that survive via high formula scores
- **fix:** Extraction prompt now includes UNAMBIGUOUS rule — facts must have enough context to not be ambiguous
- **docs:** All docs updated with `min_reranker_score` parameter documentation

## v0.6.34 — Multiplicative Reranker Blend

- **fix:** Reranker uses multiplicative blend instead of additive — `final = formula × (floor + w × reranker)` where `floor = 1-w`. The reranker can no longer zero out facts with strong retrieval signals
- **feat:** Reranker now uses the main LLM provider (no separate provider needed)
- **fix:** `min_score` default raised 0.10 → 0.15 to filter irrelevant facts
- **fix:** `reranker_weight` default changed 0.60 → 0.70
- **fix:** Removed dead `context_budget_tokens` config field
- **docs:** All docs updated to reflect multiplicative blend formula and new defaults

## v0.6.29 — DX Polish + ScoredFact Rename

- **fix!:** `ScoredFact.value` renamed to `ScoredFact.fact_text` for clarity
- **feat:** `min_score` config parameter — filters facts below threshold from results (default 0.0)
- **fix:** Mermaid pipeline diagram updated (removed stale single_pass references)
- **docs:** CHANGELOG updated through v0.6.29
- **docs:** MemoryConfig full reference page added
- **docs:** Database schema reference page added

## v0.6.28 — Remove Single-Pass Mode

- **feat!:** Removed `single_pass` extraction mode entirely — `multi_pass` is the only mode
- **feat!:** Removed `extraction_mode` config parameter
- **feat!:** Removed reflexion pass (was single-pass only)
- **refactor:** ~150 lines of code removed from `extract.py`

## v0.6.27 — Semantic Dedup

- **feat:** Post-extraction semantic dedup via embedding cosine similarity (threshold 0.85)
- **feat:** Reduces cross-entity reformulations ("Vertix was co-founded by Ricardo" when "Ricardo co-founded Vertix" exists)
- **result:** Multi-pass facts dropped from 147 → 85 (vs 88 GT) over benchmark evolution

## v0.6.26 — Mirror Fact Matcher Improvement

- **fix:** Relation-to-fact matcher now searches ALL created facts (not just source/target entity keys)
- **fix:** Reduces unnecessary mirror fact creation when a cross-entity fact already covers the relationship

## v0.6.25 — Extraction Prompt Rewrite

- **fix:** Strong "NO DUPLICATE INFORMATION" rule added to extraction prompts
- **fix:** Multi-pass facts dropped from ~147 to ~105 in benchmark
- **refactor:** Prompts restructured with numbered rules for better LLM compliance

## v0.6.24 — Fact-Entity Links (Part 3)

- **feat:** Importance scoring counts `fact_density` via `MemoryFactEntityLink` (cross-entity coverage)
- **refactor:** Extraction prompts cleaned — removed restrictive dedup rule, replaced with soft "most natural subject" guidance

## v0.6.23 — Fact-Entity Links (Part 2)

- **feat:** Graph retrieval and spreading activation query facts via `MemoryFactEntityLink` instead of direct `entity_key` match
- **feat:** Cross-entity retrieval — "Clara left Vertix" found when querying about Vertix via secondary entity link
- **feat:** Fallback to direct `entity_key` match for backward compatibility

## v0.6.22 — Fact-Entity Links (Part 1)

- **feat:** New model `MemoryFactEntityLink` — links each fact to ALL entities it mentions
- **feat:** Write pipeline creates entity links after every ADD/UPDATE (primary + secondary via substring match)
- **feat:** Fail-safe link creation — fact persists even if link creation fails

## v0.6.21 — Self-Contained Facts + Dedup Fix

- **fix:** Facts now always include entity name in text ("Fernanda Lima is a software engineer", not "is a software engineer")
- **fix:** Exact dedup strips trailing punctuation before comparing
- **fix:** Single-pass extraction now deduplicates (was missing)
- **refactor:** Extracted `_deduplicate_facts()` as shared function for both extraction modes

## v0.6.20 — Prompt Cleanup + Batch Removal

- **feat!:** Removed multi-pass batching — all entities go in a single fact extraction call (3 LLM calls total: scan + facts + relations)
- **refactor:** Extraction prompts made domain-agnostic (removed corporate jargon like "squad", "tribe", "reporting chain")
- **fix:** Subject-centric extraction prompt added to reduce cross-entity duplication

## v0.6.19 — Relationship-Fact Pairing

- **fix:** Every relationship now also generates a corresponding fact (e.g., `spouse_of` relation creates "Sarah is user's wife" fact)
- **fix:** Relationship-fact pairing rule added to both single-pass and multi-pass prompts

## v0.6.18 — Free-Form Entity Types

- **fix!:** Entity types changed from `Literal["person", "organization", "place", "pet", "other"]` to free-form `str`
- **feat:** Entity types normalized to lowercase during resolution (`"Person"` → `"person"`)
- **refactor:** Extraction prompts suggest common types without enforcing a closed set

## v0.6.17 — Benchmark Infrastructure (Part 2)

- **feat:** `config_overrides` parameter added to `write()` (same pattern as `retrieve()`)
- **feat:** `dry_run=True` parameter for `write()` — runs extraction without persisting
- **feat:** `config_effective` dict added to `WriteResult` (shows actual config used)

## v0.6.16 — Benchmark Infrastructure (Part 1)

- **feat!:** `LLMProvider.complete()` now returns `LLMResult(text, usage)` instead of `str`
- **feat:** `TokenUsage` dataclass with `input_tokens`, `output_tokens`, `total_tokens`
- **feat:** `tokens_used` field added to `WriteResult` and `RetrieveResult`
- **feat:** Built-in `OpenAIProvider` reports token usage automatically

## v0.6.15 — DX Documentation Overhaul

- **docs:** Plain-English intros added to every stage of read pipeline, write pipeline, and background jobs
- **docs:** Entity resolution visual walkthrough with mermaid diagram
- **docs:** 14 DX issues from inspector review addressed
- **docs:** Spreading activation dataset threshold guidance added

## v0.6.14 — Config Enforcement + Observability

- **feat:** `config_overrides` parameter added to `retrieve()`
- **fix:** `spreading_activation_hops=0` now properly disables spreading (no DB queries)
- **fix:** `spreading_decay_factor` and `spreading_facts_per_entity` now consumed from config (were hardcoded)
- **fix:** Reranker timeout enforced via `asyncio.wait_for`
- **feat:** Spreading activation observability added to retrieval trace

## v0.6.13 — Deterministic Entity Resolution

- **feat:** 3-layer deterministic entity resolution for read pipeline (alias + display_name + slug match)
- **feat:** Graph signal no longer depends on LLM planner for entity extraction
- **feat:** Entity sources tracked in retrieval trace (`entities_sources: {llm, deterministic, expansion}`)
- **fix:** LLM entity normalization (`entity:Carlos` → `person:carlos`)


---

