Files
poimen-memory/docs/LIFECYCLE.md
T

644 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Document Lifecycle — Control-Plane Flow
This document traces the complete lifecycle of a single piece of knowledge from ingestion to retrieval, showing every component it touches and every decision made along the way.
---
## Example: "Rust Ownership & Borrowing" knowledge file
```
rust-ownership.md (source) → mem learn CLI → POST /memory/learn → chunk →
gated loop (evaluate + compact) → embed → pgvector → searchable via /memory/query
```
---
## Phase 1: Submission
**Actor:** Agent or human operator
**Entry point:** `mem learn` CLI or `POST /memory/learn`
```
┌─────────────────────────────────────────────────────┐
│ rust-ownership.md │
│ │
│ # Rust Ownership │
│ ## Borrowing Rules │
│ - &T immutable borrow, &mut T mutable borrow │
│ - Cannot have &mut while & exists │
│ ## Lifetimes │
│ - 'a annotations tell compiler how long refs live │
│ ## Smart Pointers │
│ - Box<T> heap allocation, single ownership │
│ - Arc<T> atomic reference counting, thread-safe │
└─────────────────────────────────────────────────────┘
┌──────────────────┐
│ mem learn CLI │
│ │
│ Reads file │
│ Validates .md │
│ Sends to API │
└────────┬─────────┘
HTTP POST /memory/learn
{
"project": "knowledge",
"text": "<full file content>",
"query": "What are key facts in rust-ownership?",
"model": "qwen2.5:3b-instruct",
"memory_budget": 4096
}
```
## Phase 2: Authentication & Rate Limiting
**Component:** `http_server.rs``jwt_validator.rs``rate_limiter.rs`
```
┌──────────────────────┐
│ Auth Gate │
│ │
│ JWT token valid? │──── no ──→ 401 Unauthorized
│ Has memory:write? │──── no ──→ 403 Forbidden
│ Rate limit ok? │──── no ──→ 429 Too Many Requests
│ (100 ingest/hr) │
└──────────┬───────────┘
│ yes
```
## Phase 3: Chunking
**Component:** `chunk_markdown_text()` in `http_server.rs`
The raw markdown is split into chunks on `## ` heading boundaries. Each chunk stays under `chunk_size` (default 2000 chars).
```
┌──────────────────────────────────┐
│ Chunker │
│ │
│ Split on ## headings │
│ Hard-split if > chunk_size │
│ │
│ Input: 1 file (3200 chars) │
│ Output: 4 chunks │
└──────────┬───────────────────────┘
┌────────────┼────────────┬──────────────┐
▼ ▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Chunk 1 │ │ Chunk 2 │ │ Chunk 3 │ │ Chunk 4 │
│ # Title │ │ ## Borrow│ │ ## Life │ │ ## Smart │
│ + intro │ │ Rules │ │ times │ │ Pointers │
│ 400 ch │ │ 600 ch │ │ 500 ch │ │ 700 ch │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
│ │ │ │
└────────────┴────────────┴──────────────┘
```
## Phase 4: Gated Loop (The Core)
**Component:** `gated_loop.rs` → LLM (`qwen2.5:3b-instruct`)
This is where evaluation and compaction happen. The LLM sees each chunk alongside the current memory and decides:
- **Update gate** (`<check>yes|no</check>`): Does this chunk contain new knowledge?
- **Candidate memory** (`<update>...</update>`): Rewritten memory incorporating the chunk
- **Exit gate** (`<next>continue|end</next>`): Should we stop scanning?
```
┌─────────────────────────────────────────────────────┐
│ GATED LOOP │
│ │
│ Memory = "" (empty at start) │
│ Query = "What are key facts in rust-ownership?" │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Turn 1: Chunk 1 (# Title + intro) │ │
│ │ │ │
│ │ LLM receives: │ │
│ │ System: "You are evaluating evidence..." │ │
│ │ User: "Memory: (empty) │ │
│ │ Chunk: # Rust Ownership..." │ │
│ │ │ │
│ │ LLM responds: │ │
│ │ <think>Title only, no substance</think> │ │
│ │ <check>no</check> │ │
│ │ <update></update> │ │
│ │ <next>continue</next> │ │
│ │ │ │
│ │ Decision: REJECTED (no new knowledge) │ │
│ │ Memory: "" (unchanged) │ │
│ └─────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Turn 2: Chunk 2 (## Borrowing Rules) │ │
│ │ │ │
│ │ LLM responds: │ │
│ │ <think>Core ownership rules, new</think> │ │
│ │ <check>yes</check> │ │
│ │ <update> │ │
│ │ Rust borrowing: &T immutable, &mut T │ │
│ │ mutable. Cannot hold &mut while & alive │ │
│ │ </update> │ │
│ │ <next>continue</next> │ │
│ │ │ │
│ │ Decision: ACCEPTED │ │
│ │ Memory: "Rust borrowing: &T immutable..." │ │
│ └─────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Turn 3: Chunk 3 (## Lifetimes) │ │
│ │ │ │
│ │ LLM receives current memory + new chunk │ │
│ │ │ │
│ │ LLM responds: │ │
│ │ <think>New info, adds to borrowing</think>│ │
│ │ <check>yes</check> │ │
│ │ <update> │ │
│ │ Rust ownership: &T immutable borrow, │ │
│ │ &mut T mutable. Cannot coexist. │ │
│ │ Lifetimes: 'a annotations for ref │ │
│ │ duration. Elision: single input → output│ │
│ │ </update> │ │
│ │ <next>continue</next> │ │
│ │ │ │
│ │ Decision: ACCEPTED + COMPACTED │ │
│ │ Memory: merged borrowing + lifetimes │ │
│ │ (Note: LLM rewrote, not appended) │ │
│ └─────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Turn 4: Chunk 4 (## Smart Pointers) │ │
│ │ │ │
│ │ Decision: ACCEPTED + COMPACTED │ │
│ │ Memory: ownership + lifetimes + pointers │ │
│ │ (Still within memory_budget: 4096 tokens) │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ Result: │
│ chunks_seen: 4 │
│ chunks_used: 3 (chunk 1 rejected) │
│ final_memory: "Rust ownership: &T immutable │
│ borrow, &mut T mutable. Cannot coexist. │
│ Lifetimes: 'a annotations... Box<T> heap, │
│ Arc<T> thread-safe shared ownership..." │
│ │
└──────────────────────┬──────────────────────────────┘
```
### Pluggable LLM Architecture
The gated loop accepts any `LlmClient` trait implementation. The current design
uses a single model for both gate and rewrite. The target design splits them:
```
┌─────────────────────────────────────────────────────────────────────┐
│ GATED LOOP (pluggable LLMs) │
│ │
│ LoopConfig { │
│ gate_model: "qwen2.5:3b-instruct" ← fast, cheap │
│ rewrite_model: "ornith:35b" ← accurate, expensive │
│ embed_model: "nomic-embed-text-v2" ← fixed 768-dim │
│ } │
│ │
│ For each chunk: │
│ │
│ ┌──────────┐ ┌───────────────────────────────────────────┐ │
│ │ Chunk │────▶│ GATE LLM (3B, fast) │ │
│ │ + M_t-1 │ │ │ │
│ └──────────┘ │ Prompt: "Does this chunk add new │ │
│ │ knowledge not already in memory?" │ │
│ │ │ │
│ │ Output: <check>yes|no</check> │ │
│ │ <next>continue|end</next> │ │
│ │ │ │
│ │ Cost: ~200 tokens in, ~20 tokens out │ │
│ │ Latency: ~100ms │ │
│ └──────────┬──────────┬─────────────────────┘ │
│ │ │ │
│ yes │ │ no │
│ ▼ ▼ │
│ ┌─────────────┐ ┌──────────┐ │
│ │ REWRITE LLM │ │ SKIP │ │
│ │ (35B, smart)│ │ │ │
│ │ │ │ M_t = M │ │
│ │ Prompt: │ │ (no │ │
│ │ "Rewrite │ │ change) │ │
│ │ memory │ └──────────┘ │
│ │ merging │ │
│ │ old + new. │ │
│ │ Keep all │ │
│ │ facts. │ │
│ │ Stay under │ │
│ │ budget." │ │
│ │ │ │
│ │ Cost: ~2K │ │
│ │ tokens in, │ │
│ │ ~1K out │ │
│ │ Latency: │ │
│ │ ~2-5s │ │
│ └──────┬──────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ Budget │ │
│ │ Check │ │
│ │ │ │
│ │ len(M_new) │ │
│ │ > budget? │ │
│ └──┬──────┬───┘ │
│ no │ │ yes │
│ ▼ ▼ │
│ ┌─────────┐ ┌──────────┐ │
│ │ Accept │ │ Reject │ │
│ │ M_t = │ │ Budget │ │
│ │ M_new │ │ exceeded │ │
│ └─────────┘ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
#### Cost Model (per file, ~5 chunks)
| Step | Model | Calls | Tokens/call | Total tokens |
|---|---|---|---|---|
| Gate (all chunks) | 3B | 5 | ~220 | ~1,100 |
| Rewrite (accepted only, ~3) | 35B | 3 | ~3,000 | ~9,000 |
| Embed (final memory) | nomic-embed | 1 | ~500 | ~500 |
Rejected chunks only cost a gate call (~100ms). The expensive rewrite
only runs when the gate accepts. For a typical file where 60% of chunks
are accepted, this saves ~40% of 35B inference cost vs single-model.
#### Trait Interface
```rust
/// Pluggable LLM backend for the gated loop.
pub trait LlmClient: Send + Sync {
fn complete_blocking(
&self, system: &str, user: &str, max_tokens: usize
) -> Result<String>;
}
/// Split-model client: fast gate + accurate rewrite.
pub struct SplitModelClient {
gate: Box<dyn LlmClient>, // qwen2.5:3b — decides yes/no
rewrite: Box<dyn LlmClient>, // ornith:35b — produces compacted memory
}
/// Single-model client (current default).
pub struct SingleModelClient {
llm: ChatClient, // same model for gate + rewrite
}
```
#### API Request (pluggable models)
```json
POST /memory/learn
{
"project": "knowledge",
"text": "## Rust Ownership\n...",
"gate_model": "qwen2.5:3b-instruct",
"rewrite_model": "ornith:35b",
"memory_budget": 4096
}
```
If `rewrite_model` is omitted, falls back to `gate_model` (single-model mode,
current behavior). This keeps the API backward-compatible.
### Supported Providers
The `ChatClient` uses OpenAI-compatible `/v1/chat/completions` format.
Auth mode is auto-detected from the base URL:
```
┌──────────────────────────────────────────────────────────────────┐
│ Provider Support │
│ │
│ ┌─────────────────┐ ┌──────────────────┐ │
│ │ riotpiao gateway│ │ OpenRouter │ │
│ │ │ │ │ │
│ │ base: api.riot │ │ base: openrouter │ │
│ │ piao.com/v1 │ │ .ai/api/v1 │ │
│ │ auth: apikey: │ │ auth: Bearer │ │
│ │ <key> header │ │ <key> header │ │
│ │ models: │ │ models: │ │
│ │ qwen2.5:3b │ │ openai/gpt-4o │ │
│ │ ornith:35b │ │ anthropic/ │ │
│ │ reasoning │ │ claude-sonnet │ │
│ └────────┬────────┘ │ google/gemini │ │
│ │ └────────┬─────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ ChatClient (OpenAI-compatible) │ │
│ │ │ │
│ │ POST {base_url}/chat/completions │ │
│ │ { │ │
│ │ "model": "...", │ │
│ │ "messages": [{role, content}, ...], │ │
│ │ "max_tokens": N │ │
│ │ } │ │
│ │ │ │
│ │ Auth auto-detected from URL: │ │
│ │ *openrouter.ai → Bearer │ │
│ │ *api.openai.com → Bearer │ │
│ │ *api.riotpiao.com → apikey header │ │
│ │ *localhost → None │ │
│ └──────────────────────────────────────────┘ │
│ │
│ ┌─────────────────┐ ┌──────────────────┐ │
│ │ OpenAI │ │ Ollama (local) │ │
│ │ │ │ │ │
│ │ base: api.open │ │ base: localhost │ │
│ │ ai.com/v1 │ │ :11434/v1 │ │
│ │ auth: Bearer │ │ auth: None │ │
│ │ models: │ │ models: │ │
│ │ gpt-4o │ │ llama3.1 │ │
│ │ gpt-4o-mini │ │ qwen2.5:3b │ │
│ └─────────────────┘ └──────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────┘
```
#### Environment Configuration
```bash
# riotpiao gateway (default)
export LLM_API_BASE=https://api.riotpiao.com/v1
export LLM_API_KEY=your-gateway-key
# OpenRouter (access to GPT-4o, Claude, Gemini, etc.)
export LLM_API_BASE=https://openrouter.ai/api/v1
export LLM_API_KEY=sk-or-v1-...
# OpenAI direct
export LLM_API_BASE=https://api.openai.com/v1
export LLM_API_KEY=sk-...
# Ollama local (no auth)
export LLM_API_BASE=http://localhost:11434/v1
export LLM_API_KEY=
```
#### Mixed Provider Example (gate local, rewrite cloud)
```json
POST /memory/learn
{
"project": "knowledge",
"text": "...",
"gate_model": "qwen2.5:3b-instruct",
"gate_base_url": "https://api.riotpiao.com/v1",
"rewrite_model": "anthropic/claude-sonnet-4",
"rewrite_base_url": "https://openrouter.ai/api/v1"
}
```
Gate runs on local 3B (free, fast), rewrite uses Claude via OpenRouter
(accurate, paid per token). Only accepted chunks hit the cloud.
### Why This Prevents Unbounded Growth
The key insight: **the LLM rewrites memory on every accepted chunk, not appends.** Turn 3 doesn't add lifetimes after borrowing — it produces a new memory that integrates both. If the same file is ingested again, most chunks will be rejected (`<check>no</check>`) because the knowledge already exists in memory.
The `memory_budget` parameter (default 4096 tokens) enforces a hard cap. If the LLM's candidate exceeds it, the chunk triggers `BudgetExceeded` and is skipped.
## Phase 5: Embedding
**Component:** `EmbeddingsClient``nomic-ai/nomic-embed-text-v2-moe`
The compacted final memory is embedded into a 768-dimensional vector.
```
┌──────────────────────────────────────────┐
│ Embedding │
│ │
│ Input: "Rust ownership: &T immutable │
│ borrow, &mut T mutable. Cannot..." │
│ │
│ Model: nomic-embed-text-v2-moe │
│ Output: [0.035, 0.022, -0.018, ...] │
│ 768 dimensions │
│ │
└──────────────────┬───────────────────────┘
```
## Phase 6: Storage
**Component:** `pgvector` (CNPG cluster, 2 replicas)
The compacted memory + embedding is stored. SHA256 dedup ensures re-ingesting the same content updates rather than duplicates.
```
┌──────────────────────────────────────────┐
│ pgvector (memory_chunks) │
│ │
│ INSERT INTO memory_chunks │
│ (id, project, level, text, │
│ embedding, sha256, source) │
│ ON CONFLICT (sha256) │
│ DO UPDATE SET text, embedding │
│ │
│ ┌────────────────────────────────────┐ │
│ │ id: uuid-abc-123 │ │
│ │ project: knowledge │ │
│ │ level: L1 │ │
│ │ text: "Rust ownership: ..." │ │
│ │ embedding: [0.035, 0.022, ...] │ │
│ │ sha256: de12cd34ef56... │ │
│ │ source: learn://knowledge │ │
│ └────────────────────────────────────┘ │
│ │
└──────────────────┬───────────────────────┘
```
## Phase 7: Response
**Component:** `http_server.rs`
```
┌──────────────────────────────────────────┐
│ HTTP Response │
│ │
│ 200 OK │
│ { │
│ "project": "knowledge", │
│ "status": "completed", │
│ "chunks_seen": 4, │
│ "chunks_used": 3, │
│ "memory": "Rust ownership: &T...", │
│ "memory_tokens": 156, │
│ "stored": true, │
│ "model": "qwen2.5:3b-instruct" │
│ } │
└──────────────────────────────────────────┘
```
---
## Phase 8: Retrieval (Later)
**Actor:** Agent querying memory during a task
**Entry point:** `POST /memory/query` or `POST /memory/context`
```
Agent: "How does Rust handle shared ownership across threads?"
┌─────────────────────────────────────────────────────────────┐
│ Retrieval Pipeline │
│ │
│ 1. Embed query → [0.041, -0.012, ...] (768-dim) │
│ │
│ 2. Parallel search: │
│ ├─ Semantic: pgvector cosine similarity │
│ │ SELECT * FROM memory_chunks │
│ │ ORDER BY embedding <=> query_vec │
│ │ → score: 0.89 │
│ │ │
│ └─ Lexical: OpenSearch BM25 │
│ multi_match: "shared ownership threads" │
│ → score: 0.76 │
│ │
│ 3. RRF Fusion: 0.6 × semantic + 0.4 × lexical │
│ → fused_score: 0.84 │
│ │
│ 4. Return: │
│ { │
│ "score": 0.84, │
│ "text": "Rust ownership: ... Arc<T> atomic │
│ reference counting, thread-safe shared ownership" │
│ } │
│ │
└─────────────────────────────────────────────────────────────┘
```
---
## Re-Ingestion (Idempotent)
When the same file is ingested again:
```
rust-ownership.md (unchanged) → POST /memory/learn → chunk → gated loop:
Turn 1: "# Rust Ownership" → <check>no</check> (title, no substance)
Turn 2: "## Borrowing" → <check>no</check> (already in memory)
Turn 3: "## Lifetimes" → <check>no</check> (already in memory)
Turn 4: "## Smart Pointers"→ <check>no</check> (already in memory)
Result: chunks_seen=4, chunks_used=0
Memory: unchanged, nothing new stored
```
When the file is updated with new content:
```
rust-ownership.md (added ## Async section) → POST /memory/learn → gated loop:
Turn 1-4: existing sections → <check>no</check> (known)
Turn 5: "## Async/Await" → <check>yes</check> (new!)
Memory rewritten: ownership + lifetimes + pointers + async
Result: chunks_seen=5, chunks_used=1
pgvector: ON CONFLICT (sha256) DO UPDATE → old record replaced
```
---
## Complete Data Flow Diagram
```
┌──────────┐
│ Source │
│ (.md) │
└────┬─────┘
┌────▼─────┐
│ mem learn│ CLI
│ (or any │ agent)
└────┬─────┘
│ POST /memory/learn
┌────▼─────────────────────┐
│ Memory Service (k8s) │
│ │
│ ┌─────────┐ │
│ │ Auth │ JWT/rate │
│ └────┬────┘ │
│ ▼ │
│ ┌─────────┐ │
│ │ Chunker │ split on ## │
│ └────┬────┘ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Gated Loop │ │
│ │ │ │
│ │ chunk ──→ LLM ──→ │
│ │ evaluate + compact │
│ │ (update gate) │
│ │ │ │
│ │ Memory rewritten │
│ │ each accepted turn │
│ └──────┬───────┘ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Embed │ │
│ │ 768-dim vec │ │
│ └──────┬───────┘ │
│ ▼ │
│ ┌──────────────┐ │
│ │ pgvector │ │
│ │ UPSERT │ │
│ │ (sha256 │ │
│ │ dedup) │ │
│ └──────────────┘ │
│ │
└──────────────────────────┘
┌─────────▼───────────┐
│ Query Path │
│ │
│ /memory/query │
│ /memory/context │
│ │
│ embed query → │
│ pgvector search + │
│ OpenSearch BM25 → │
│ RRF fusion → │
│ ranked results │
└─────────────────────┘
```
---
## Key Properties
| Property | Mechanism |
|---|---|
| **No unbounded growth** | Gated loop rewrites memory, doesn't append. `memory_budget` caps size. |
| **Deduplication** | SHA256 on compacted memory. `ON CONFLICT DO UPDATE`. |
| **Idempotent re-ingest** | Same content → LLM rejects all chunks → nothing changes. |
| **Quality gate** | LLM decides what's worth keeping. Title-only chunks rejected. |
| **Compaction built-in** | Every accepted chunk triggers a rewrite that merges + trims. |
| **Searchable** | Embedded in pgvector + indexed in OpenSearch. Hybrid retrieval. |
| **Auditable** | JSONL event log records all gated loop decisions (immutable). |
| **Model-agnostic** | `model` parameter selects evaluator LLM per request. |