# Write Pipeline

When you call `memory.write()`, the SDK reads a natural language message and automatically extracts who and what was mentioned, figures out if it's new or updated information, and stores it as structured, versioned facts — all in one call.

**You don't need to understand the internals to use it.** Just call `write()` and check `result.facts_added`. This page explains what happens under the hood for when you want to tune behavior or debug results.

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

## Overview

Every `memory.write(user_id, message)` call runs these steps:

0. **Guard** — Empty messages return immediately. No event, no LLM call, no tokens consumed.
1. **Log the event** — The raw message is saved as an immutable audit trail (never modified or deleted).
2. **Detect emotion** — Classifies the message's emotion, intensity, and energy level.
3. **Extract** — An LLM reads the message and identifies people, places, facts, and relationships.
4. **Resolve entities** — Deduplicates mentions ("Ana", "my wife Ana", "Aninha") into a single canonical entity.
5. **Reconcile** — Compares each new fact against what's already known. Is it new? An update? Already known? A retraction?
6. **Upsert** — Saves the results to the database.

Each stage is independently fail-safe: if extraction fails, the event is still logged. If reconciliation fails for one fact, the others proceed normally.

---

## Stage 1: Extraction

**In plain English:** The SDK sends your message to an LLM and asks "What people, places, facts, and relationships are mentioned here?" The LLM returns structured data that the pipeline can work with. Think of it as a smart parser that understands natural language.

The extraction stage uses an LLM to parse natural language into structured data: entities, facts, and relationships.

### How It Works

Extraction runs 3 LLM calls, with the last two concurrent:

1. **Entity scan** — Identify all entities mentioned in the message
2. **Fact extraction + Relation extraction** — Run concurrently via `asyncio.gather()`: fact extraction receives all entities in a single call, while relation extraction identifies relationships between entities

Relation extraction includes an **automatic retry**: if the LLM returns 0 relations but 2+ entities were found, the SDK retries the relation call once before accepting an empty result. When `verbose=True`, the trace includes `relation_retry_triggered`.

**Subject-centric extraction:** Facts are extracted from the perspective of the primary subject only. "Carlos lives in Curitiba" is a fact about Carlos — the system does NOT also create "Curitiba is where Carlos lives" as a separate fact. The relationship `Carlos → lives_in → Curitiba` + entity links handle cross-entity retrieval.

**Semantic dedup:** After extraction, facts are compared pairwise by embedding cosine similarity. Near-duplicates (> 0.85 similarity) are removed, keeping the first occurrence. This eliminates cross-entity reformulations that the LLM sometimes produces despite prompt instructions.

### Configuration

| Parameter | Default | Description |
|-----------|---------|-------------|
| `extraction_timeout_sec` | `30.0` | Timeout per LLM call |

### What Gets Extracted

For each message, the extraction stage produces:

- **Entities** — Named things: people, organizations, places, concepts, etc.
- **Facts** — Self-contained statements about entities in natural language (e.g., "Fernanda Lima is a software engineer", "Marcos Tavares lives in Porto Alegre"). Each fact text always includes the entity name — never just "is a software engineer" without a subject. Every relationship also generates a corresponding fact — so "Sarah is my wife" produces both a relation (`user → spouse_of → sarah`) and a fact ("Sarah is user's wife"). Duplicate facts (same subject + same text, ignoring punctuation) are automatically removed post-extraction.
- **Relations** — Connections between entities (e.g., "Rafael" → `works_at` → "Acme Corp"). Relations serve as graph edges for traversal; the paired fact makes the information searchable via text/embedding.

Each fact includes a **confidence level**:

| Level | Score | Example |
|-------|-------|---------|
| Explicit statement | 0.95 | "I live in São Paulo" |
| Strong inference | 0.80 | "We went to the São Paulo office" (implies location) |
| Weak inference | 0.60 | Contextual implication |
| Speculation | 0.40 | Uncertain information |

!!! note "How confidence works in practice"
    Confidence is assigned by the LLM during extraction based on how the information was stated. Direct statements ("I live in SP") get high confidence; hedged statements ("I think maybe...") get lower confidence. You cannot set confidence directly — it's inferred. You can filter low-confidence facts at retrieval time using `min_confidence` in MemoryConfig (default 0.55).

### Alias Grouping & Subject Normalization

When the same entity is mentioned by multiple names in a single message (e.g., "my friend Guili (Guilherme Maturana)"), the extraction groups them into a **single entity with aliases** instead of creating duplicates.

The LLM is instructed to pick one canonical name (usually the most complete) and list the others as aliases:

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

After extraction, a **subject normalization** pass rewrites any fact or relation that references an alias to use the canonical name instead. Identity relations (e.g., `same_as` between an alias and its canonical name) are removed automatically since they become self-referencing after normalization.

This eliminates intra-message entity duplication at the source — before entity resolution even runs.

### Entity Types

Entity types are **free-form strings** — the LLM chooses the most appropriate type for each entity. Common types include `person`, `organization`, `place`, `product`, `event`, `concept`, `pet`, but any descriptive type is accepted. Types are normalized to lowercase during entity resolution (e.g., `"Person"` → `"person"`, `"PRODUCT"` → `"product"`).

The extraction prompt instructs the LLM to classify types carefully — for example, cities are `place`, companies are `organization`, software products are `product`.

### Fail-safe Behavior

If an LLM call fails (timeout, invalid JSON, rate limit), the extraction returns an empty result rather than raising an exception. The event is still logged — no data is lost. The next message may capture the same information.

!!! tip "Detecting timeouts"
    When extraction times out, the result is indistinguishable from "message had no extractable content" — 0 entities, 0 facts, no exception. To detect timeouts, compare the extraction `duration_ms` in the trace against your configured `extraction_timeout_sec`, or check for 0 entities despite a content-rich message.

!!! info "Neuroscience parallel"
    Extraction mirrors **encoding** in human memory — the process of converting sensory input (a conversation) into a memory trace. Just as human encoding is selective (we don't remember every word), the LLM extracts only salient facts and entities.

---

## Stage 2: Entity Resolution

**In plain English:** When someone says "Ana", "my wife Ana", and "Aninha" in different messages, they're all talking about the same person. This stage figures that out and links everything to one canonical entity — so you don't end up with three separate "Ana" records in the database.

### Three-Phase Resolution

```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
```

**Phase 1: Exact match**

Checks the alias cache, entity slugs, and display names. Instant, no LLM call.

Includes **prefix/diminutive matching** for person entities: "Carol" matches "Carolina" (minimum 3 characters). Note: "Jo" will NOT match "João" (< 3 chars). "Bob" will match "Roberto" only if registered as an alias, not via prefix matching.

**Phase 2: Fuzzy match**

Uses embedding cosine similarity (in-memory) to find candidates:

- **≥ `fuzzy_threshold`** (default 0.85) — High confidence match, resolves directly
- **0.50–`fuzzy_threshold`** — Ambiguous, forwards top-3 candidates to Phase 3 (LLM)
- **< 0.50** — No match, creates a new entity

Lowering `fuzzy_threshold` expands the fuzzy-resolve range and reduces LLM calls. For example, setting `fuzzy_threshold=0.50` eliminates the ambiguous range entirely — everything above 0.50 resolves directly.

Falls back to `difflib.SequenceMatcher` when embeddings are unavailable.

**Phase 3: LLM fallback**

Sends ambiguous candidates to the injected `LLMProvider` for disambiguation. The LLM sees the entity name, the candidates, and decides which (if any) is a match.

??? example "Walkthrough: how entity resolution works"
    **Message:** "Talked to Guili about the project. Guilherme said it's on track."

    1. **Extract:** Two names found: "Guili" and "Guilherme"
    2. **Phase 1 (exact):** "Guilherme" matches existing entity `person:guilherme_maturana`
    3. **Phase 2 (fuzzy):** "Guili" has 0.87 cosine similarity with "Guilherme" → auto-resolves
    4. **Result:** Both names resolve to the same entity. Alias "Guili" registered.

    Next time "Guili" appears, Phase 1 catches it instantly via the alias cache — no fuzzy or LLM call needed.

### Special Cases

- **Self-references** — "I", "me", "eu", "myself" automatically resolve to `user:self`
- **Relationship terms** — "my girlfriend", "my brother", "meu amigo" resolve to `user:self` (the relationship is about the user, not a separate entity)
- **Relational hints** — `"Carol (Rafael's girlfriend)"` strips the hint and forces `type="person"`

### Alias Registration

When a new alias is discovered (e.g., "Aninha" resolves to `person:ana`), it's registered in `MemoryEntityAlias` with **first-write-wins** semantics — concurrent writes won't create conflicting aliases. Aliases are scoped per `user_id`: the same alias can map to different entities for different users.

**Extraction-provided aliases** are also registered automatically: when entity resolution creates a new entity that has aliases from extraction (e.g., "Guili" for "Guilherme Maturana"), all aliases are registered in `MemoryEntityAlias` and added to the in-memory alias cache. This means subsequent entities in the same batch can immediately resolve via Phase 1 exact match — no fuzzy or LLM calls needed.

This creates a two-line defense against duplicates:

1. **Intra-message** — Alias grouping in extraction prevents duplicates within a single message
2. **Cross-message** — Registered aliases enable exact match in future messages (e.g., if message 1 creates "Guilherme Maturana" with alias "Guili", message 2 mentioning "Guili" resolves instantly via Phase 1)

### Entity Persistence

After entity resolution completes, the pipeline ensures **every resolved entity** has a row in the `memory_entities` table — not just newly created ones. Entities resolved via exact match, fuzzy match, or LLM disambiguation are also upserted using `ON CONFLICT DO UPDATE` (idempotent).

This is critical because background jobs (importance scoring, summary refresh, spreading activation) read from `memory_entities`. Without a row, these jobs are blind to the entity and can't operate on it.

The entity upsert is fail-safe: if one entity fails to persist (e.g., constraint violation), the others proceed normally and the pipeline continues.

### Configuration

| Parameter | Default | Description |
|-----------|---------|-------------|
| `fuzzy_threshold` | `0.85` | Cosine similarity threshold for direct fuzzy match |
| `enable_llm_resolution` | `True` | Whether to use LLM for ambiguous cases. When `False`, ambiguous candidates create a new entity instead of calling LLM. |

!!! note "Model selection"
    The LLM model used for entity resolution is determined by the `LLMProvider` you inject into `MemoryClient`. To use a different model for resolution vs. extraction, inject different providers.

!!! info "Neuroscience parallel"
    Entity resolution mirrors **associative memory** — the brain's ability to link new stimuli to existing representations. Hearing "Carol" activates the neural pattern for "Carolina" through pattern completion, just as fuzzy matching activates candidate entities through embedding similarity.

---

## Stage 3: Reconciliation

**In plain English:** If the user said "I live in São Paulo" last week and now says "I moved to Rio", the system needs to figure out that this is an update, not a second home. This stage compares each new fact against what's already stored and decides: is this new info? An update to something existing? Already known? Or a retraction?

### Decision Logic

For each extracted fact, the reconciler:

1. **Fetches existing facts** for the same entity
2. **Computes similarity** between the new fact and each existing fact (via embeddings)
3. **Decides the action**:

| Action | When | Example |
|--------|------|---------|
| **ADD** | New information, no similar existing fact (similarity < 0.50) | "speaks French" when no language fact exists |
| **UPDATE** | Supersedes an existing fact (similarity 0.50–0.85+) | "lives in Rio" supersedes "lives in São Paulo" |
| **NOOP** | Already known (high similarity) | "works at Acme" when this fact already exists |
| **DELETE** | Explicitly retracts a fact | "I no longer work at Acme" |

### Reconciliation Performance

- **Fast path (similarity < 0.50):** Auto-ADD without LLM call (~300ms). This is the common path for novel information.
- **Slow path (similarity ≥ 0.50):** LLM evaluates whether to ADD, UPDATE, DELETE, or NOOP (~2-3s). This requires an LLM call with full context.

Plan accordingly: bulk imports of new data are fast; updates to existing knowledge require LLM decision-making.

!!! note "UPDATE chains may branch"
    The reconciliation LLM may choose ADD over UPDATE when it interprets new information as distinct rather than a replacement. For example, "I moved to BH" might create separate facts for "lives in BH" and "used to live in RJ" instead of a simple update chain. This preserves more information but may break the `supersedes_fact_id` chain. This is expected behavior — the LLM prioritizes information preservation.

### Fail-safe Behavior

If the reconciliation LLM call fails, the system defaults to **ADD** — it's better to have a near-duplicate than to lose information. The background consolidation jobs (clustering, deduplication) clean up duplicates later.

### Fact Versioning

Facts are versioned using temporal validity windows (`valid_from`, `valid_to`):

- **Active facts** have `valid_to = NULL`
- **Updated facts** get both `valid_to` and `invalidated_at` set, and a new fact is created with `supersedes_fact_id` pointing to the old one
- **Deleted facts** get both `valid_to` and `invalidated_at` set

This enables time-travel queries: you can ask what the system knew at any point in time.

!!! info "Neuroscience parallel"
    Reconciliation mirrors **reconsolidation** — the process by which retrieved memories become labile and can be modified. When you recall a memory ("lives in São Paulo") and encounter new information ("just moved to Rio"), the original memory is updated. The brain doesn't simply overwrite — it creates a new trace linked to the original, just as UPDATE creates a new fact with `supersedes_fact_id`.

---

## Stage 4: Upsert

**In plain English:** This is where the decisions from the previous stage are actually saved to the database. New facts are inserted, outdated facts are marked as superseded, and relationships between entities are created or strengthened. Everything runs inside a transaction — if one fact fails to save, the others still go through.

| Decision | Database action |
|----------|-----------------|
| ADD | Create new `MemoryFact` with embedding |
| UPDATE | Close old fact (`valid_to = now`), create new one with `supersedes_fact_id` |
| NOOP | Update `last_confirmed_at` on existing fact |
| DELETE | Close fact (`valid_to = now`, `invalidated_at = now`) |

### Fact-Entity Links

After each fact is persisted (ADD or UPDATE), the pipeline creates **entity links** connecting the fact to every entity it mentions — not just its primary subject. This enables cross-entity retrieval without duplicating facts.

For example, "Clara Rezende left Vertix" is stored **once** with `entity_key = person:clara_rezende` (primary subject). But entity links are created for both `person:clara_rezende` (primary) and `organization:vertix` (secondary). When you query about Vertix, the system finds this fact via the link — no duplicate fact needed.

Links are created by matching entity display names against the fact text (case-insensitive substring match). Very short names (< 3 characters) are skipped to avoid false positives. Link creation is fail-safe: if it fails, the fact persists normally.

### Relationship Tracking

During upsert, extracted relationships are also persisted:

- Creates/updates `MemoryEntityRelationship` records
- Resolves source and target entities via the entity map
- **Strength reinforcement**: repeated relationships increase `strength` (initial: 0.8, reinforced up to 1.0 across multiple messages)
- Uses `ON CONFLICT DO UPDATE` for idempotent upserts

!!! warning "Relationships are **unidirectional**"
    Writing "Ana works at Acme" creates `ana → works_at → acme_corp`, but **not** `acme_corp → employs → ana`. This means graph retrieval starting from "Acme Corp" won't find Ana through relationships (but may still find her via semantic similarity). To create both directions, mention them explicitly: "Ana works at Acme. Acme has Ana as a data scientist."

#### Evidence Linkage & Cascade Invalidation

**The problem:** Without linkage between facts and relationships, contradictory edges accumulate. If a user says "I live in Curitiba" and later "I moved to São Paulo", the old relationship `user --[lives_in]--> curitiba` would remain active alongside the new one — polluting retrieval with stale context.

**The solution:** Each relationship is linked to the fact that supports it via `evidence_fact_id`. When that fact is superseded (UPDATE) or retracted (DELETE), the relationship is **automatically invalidated** — no manual cleanup needed.

How evidence linkage works:

1. After facts are persisted in the upsert stage, a heuristic match associates each relationship with a corresponding fact. For a relationship `(source, target)`, the matcher looks for facts whose `fact_text` mentions both entity names.
2. If multiple facts match, the one with the highest confidence is selected.
3. The matched fact's ID is stored as `evidence_fact_id` on the relationship.

When a fact is invalidated (via UPDATE or DELETE), **cascade invalidation** automatically sets `invalidated_at` and `valid_to` on all relationships that reference it. The [graph retrieval BFS](../advanced/read-api.md) already filters out invalidated relationships, so stale edges are immediately excluded from context.

```
User: "I live in Curitiba"
  → fact: "User lives in Curitiba" (fact_1)
  → rel:  user --[lives_in]--> curitiba (evidence_fact_id = fact_1)

User: "I moved to São Paulo"
  → reconciliation: UPDATE fact_1 → fact_2 "User lives in São Paulo"
  → cascade: rel lives_in→curitiba is INVALIDATED (evidence_fact_id = fact_1)
  → new rel: user --[lives_in]--> sao_paulo (evidence_fact_id = fact_2)
```

!!! note "Relationship types are dynamic"
    The `rel_type` field accepts any descriptive `snake_case` string — not just a fixed set. Common types include `works_at`, `lives_in`, `family_of`, but the LLM may also produce types like `mentored_by` or `inspired_by`. See [Dynamic Relationship Types](../advanced/data-types.md#dynamic-relationship-types) for details on normalization and aliases.

#### Mirror Facts

Sometimes the LLM infers a relationship from context without extracting a corresponding fact. For example, "I'm going to Curitiba to visit my mom" implies `mom --[lives_in]--> curitiba`, but the LLM may only extract a fact about the user's trip — not about where mom lives. Without a fact, the relationship can't participate in cascade invalidation and isn't findable via semantic search.

To solve this, a **mirror fact** is automatically created as a fallback when no heuristic match is found. The mirror fact is a simple natural-language sentence generated from the relationship: `"{source_name} {rel_type} {target_name}"` (e.g., `"Mom lives in Curitiba"`).

Mirror facts are marked with:

- `confidence = 0.60` (weak inference — lower priority in retrieval ranking)
- `source_context = "inferred_from_relation"` (allows filtering or downranking if needed)

!!! note "Mirror facts may persist after source invalidation"
    Mirror facts are not automatically invalidated when the source relationship is removed. They may persist as stale data. Applications should consider filtering by `source_context` when accuracy is critical.

The mirror fact's ID is used as the relationship's `evidence_fact_id`, so cascade invalidation works for inferred relationships too.

!!! tip "Reducing mirror facts"
    The extraction prompts instruct the LLM to extract implicit facts alongside relationships (e.g., "my mom lives in Curitiba" as a fact, not just a relation). As LLM extraction improves, fewer mirror facts are needed — they're the safety net, not the primary mechanism.

### Transaction Safety

The entire write pipeline runs inside a database transaction. Individual fact upserts use **savepoints** (`session.begin_nested()`) so that a failure in one fact doesn't abort the entire batch:

```python
# If this fact fails, only this savepoint rolls back
async with session.begin_nested():
    session.add(new_fact)
    await session.flush()
```

The event record is created and flushed first, so it survives even if all subsequent stages fail.

---

## WriteResult

After the pipeline completes, you get a `WriteResult` with full observability:

```python
result = await memory.write(
    user_id="user_123",
    message="...",
    recent_messages=["previous message for pronoun resolution"],  # optional
)

# What happened
print(result.facts_added)       # List of facts created
print(result.facts_updated)     # List of facts superseded
print(result.facts_unchanged)   # List of confirmed facts (NOOP decisions)
print(result.facts_deleted)     # List of retracted facts (DELETE decisions)
print(result.entities_resolved) # List of resolved entities
print(result.duration_ms)       # Total pipeline time
print(result.event_id)          # Unique event ID for this write
print(result.tokens_used)       # TokenUsage(input_tokens=..., output_tokens=..., total_tokens=...)
print(result.pipeline)          # PipelineTrace (when verbose=True)
print(result.success)           # True if pipeline completed without errors (always check this)
print(result.error)             # Error message if pipeline failed (None on success)
```

### Trace Enrichment (verbose=True)

When `verbose=True`, the extraction step in `PipelineTrace` includes additional metadata:

| Field | Type | Description |
|-------|------|-------------|
| `relation_retry_triggered` | `bool` | Whether the automatic relation retry was used |

```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` reports the total LLM tokens consumed across all calls in the pipeline (extraction, entity resolution, reconciliation). Useful for benchmarking and cost estimation.

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

!!! note "Token tracking requires provider support"
    `tokens_used` is populated from `LLMResult.usage` returned by your `LLMProvider`. The built-in `OpenAIProvider` reports usage automatically. Custom providers that return `LLMResult(text=..., usage=None)` will show zero tokens.

### Config Overrides

Override any `MemoryConfig` field for a single `write()` call without creating a new client:

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

Only the provided keys are changed; all others inherit from the client config. Invalid keys emit a warning and are ignored. Type mismatches raise `ValueError`.

### Dry Run

Run extraction without persisting anything to the database:

```python
result = await memory.write(
    user_id="user_123",
    message="I live in São Paulo with my wife Ana",
    dry_run=True,
)
# result.facts_added contains what WOULD be extracted
# result.tokens_used shows cost of this extraction
# No event, no facts, no entities persisted
```

Useful for benchmarking: run the same message with `dry_run=True` and compare `tokens_used` across different configurations.

---

## Pipeline Diagram (Complete)

```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"]
```
