Skip to content

Graph Memory

Most agentic systems split state across multiple stores: a vector database for retrieval, a document store for raw content, a relational table for task status, and a cache for intermediate results. This fragmentation creates synchronization problems and limits the kinds of queries the system can answer about its own state.

Lethe stores all session state in a single Neo4j graph database. Tasks, evidence, and their relationships coexist in one topology. This design enables Personalized PageRank to traverse both structural relationships (task decomposition, dependency ordering) and semantic similarity links in a single computation, and it eliminates the consistency overhead of maintaining separate stores.

Schema

The graph contains two node types and five edge types.

Node Types

classDiagram
    class TaskNode {
        +NodeId id
        +ShortId short_id
        +str description
        +str embed_text
        +TaskStatus status
        +str result_summary
        +str prepared_prompt
        +str antifocus
        +ndarray embedding
        +ndarray result_embedding
        +float created_at
        +serialize_context()
    }

    class EvidenceNode {
        +NodeId id
        +ShortId short_id
        +str content
        +str embed_text
        +float strength
        +str source_description
        +ndarray embedding
        +bool superseded
        +serialize_context()
    }

    TaskNode --> TaskNode : SUBTASK_OF
    TaskNode --> TaskNode : DEPENDS_ON
    TaskNode --> EvidenceNode : PRODUCED
    EvidenceNode --> EvidenceNode : SUPERSEDES
    TaskNode --> TaskNode : SIMILAR_TO
    EvidenceNode --> EvidenceNode : SIMILAR_TO

TaskNode represents a unit of work in the agent's DAG. Tasks have a lifecycle (PENDING → ACTIVE → DONE or FAILED) and carry both a content embedding and a result embedding. Task creation and mutation happen exclusively through DIN operations.

The embed_text field contains an embedding-optimized text projection --- not the raw description, but a version rewritten by the LLM to embed well with the Qwen3-Embedding model. This dual representation (natural-language description for the LLM, concise 8--12 word embed_text for the vector space) addresses a fundamental mismatch: LLM-generated prose suffers from length collapse and anisotropy when embedded directly, producing vectors that cluster regardless of semantic content. The projection layer produces one sentence stating the single core fact, preserving technical terms and proper nouns.

EvidenceNode represents a finding with a strength score in \([0, 1]\), full content (zero truncation), and a source description for provenance tracking. Evidence nodes are immutable once created; the superseded flag soft-deletes evidence that has been replaced by a synthesis. Strength is derived from goal alignment via linear mapping.

Edge Types

Edge Direction Semantics
SUBTASK_OF child → parent Task decomposition hierarchy
DEPENDS_ON dependent → dependency Execution ordering constraint
PRODUCED task → evidence Provenance: which task discovered this evidence
SUPERSEDES new → old Evidence replacement chain
SIMILAR_TO bidirectional Cosine similarity above threshold

Strong ID Types

Nodes use two identifier systems:

  • NodeId --- UUID primary key, used internally for all graph operations
  • ShortId --- hex-truncated identifier like git short hashes (t-a3f1, e-7c0b), used in the DIN protocol for compact LLM communication

The separation prevents the LLM from needing to work with full UUIDs while maintaining referential integrity.

Memory Types

The schema above defines the structural vocabulary. These node and edge types implement three distinct memory roles, each serving a different function in the agent's reasoning process:

Working Memory

Active tasks represent the agent's current plan. The task DAG encodes decomposition (SUBTASK_OF), ordering (DEPENDS_ON), and execution state (status lifecycle). The ready_set query identifies tasks that are PENDING and have all dependencies satisfied (see ready set):

ready = {t ∈ Tasks | t.status = PENDING ∧ ∀d ∈ deps(t): d.status = DONE}

Episodic Memory

Evidence nodes store discovered findings --- web page content, research results, synthesized conclusions. Each evidence node carries:

  • Content --- full text, never truncated
  • Strength --- quality score derived from goal-alignment (GA), refined by NLI corroboration from independent sources
  • Embedding --- 2560-dimensional vector from embed_text projection
  • Source --- description of where the evidence came from

Semantic Memory

SIMILAR_TO edges create a sparse semantic graph connecting nodes whose embeddings exceed a cosine similarity threshold (default 0.55, calibrated for Qwen3-Embedding asymmetric prefixes). These edges are synchronized each iteration via a four-phase pipeline: compute candidate pairs, classify via bidirectional DeBERTa NLI (support, contradiction, confidence, independence, equivalence), commit edges, and refine evidence strengths from NLI corroboration.

This semantic graph serves the same role as a nearest-neighbor index in a vector database, but within the graph topology. Personalized PageRank traverses SIMILAR_TO edges alongside structural edges, discovering semantically related content that is not structurally connected. This is a key design decision: rather than maintaining a separate vector store (as in typical RAG architectures), the semantic index lives in the same graph as the structural relationships, enabling a single random walk to traverse both.

Evidence Lifecycle

Evidence enters the graph through a multi-stage ingestion pipeline. When the research engine discovers web pages, the following process transforms raw HTML/PDF/video content into embedded, scored, and deduplicated graph nodes:

flowchart TB
    TOOL["web_search / fetch_url tool call"]
    RESEARCH["ResearchEngine / BoundedCrawlProvider"]
    PAGES["Discovered pages"]
    CHUNK["chunk.py<br/>paragraph-group chunking"]
    EMBED["embed_doc()"]
    GA["Goal alignment scoring<br/>soft_gate(GA, ga_floor)"]
    DEDUP["Three-layer dedup<br/>URL + hash + cos ≥ 0.95"]
    STRENGTH["batch_strengths()<br/>linear GA → strength"]
    PERSIST["store.create_node()"]
    LINK["PRODUCED edge<br/>task → evidence"]
    SYNTH["SYNTHESIZE op<br/>agent-created summary"]
    SUPER["SUPERSEDES edge<br/>summary → chunks"]

    TOOL --> RESEARCH --> PAGES --> CHUNK
    CHUNK --> EMBED --> GA
    GA --> DEDUP
    DEDUP -->|novel| STRENGTH --> PERSIST --> LINK
    DEDUP -->|duplicate| SKIP["skipped"]
    LINK -.->|later| SYNTH --> SUPER

Ingestion

When the research engine returns discovered pages, the evidence ingestion pipeline processes each page:

  1. Chunking --- pages are split into paragraph groups by chunk_page(). The algorithm splits on markdown headers and double-newlines, then merges consecutive sections that fall below 100 characters. Oversized sections are split at sentence boundaries, with a hard cap as fallback. Target range is 100--500 characters per chunk. Each chunk becomes a separate EvidenceNode linked to its producing task via a PRODUCED edge. Pages shorter than 400 characters are stored as a single node.

  2. Embedding --- each chunk's embed_text is embedded via the embedding model. The page-level embedding is a position-weighted mean of chunk embeddings (\(w_i = 1/\sqrt{i+1}\), L2-normalized), giving earlier sections higher influence. This page embedding is used for GA scoring and deduplication.

  3. Goal alignment scoring --- each chunk's goal alignment is computed and mapped to a strength value via linear interpolation. Evidence with low GA receives near-floor strength rather than being dropped --- the soft_gate preserves all evidence at reduced influence.

  4. Deduplication --- three layers prevent redundant evidence: URL fingerprinting and content hashing (checked in apply_graph_ops / execute_calls_and_ingest before ingest), plus semantic similarity (cosine > 0.95 against existing evidence, checked during ingest via EvidenceIndex). All layers must pass before a new node is created. The three layers address distinct failure modes: URL dedup catches re-fetched pages, hash dedup catches identical content from different URLs, and semantic dedup catches paraphrased versions of the same finding. A single semantic pass would miss exact duplicates (cheaper to detect) and would require embedding every candidate before checking.

  5. Strength assignment --- linear map: strength = floor + clamp01(GA) * (ceil - floor), where floor=0.30, ceil=0.95 (see EmbedConfig). The GA value is already clamped to \([0,1]\) and multiplied by soft_gate(GA, ga_floor) during the scoring pass, so the strength mapping itself is a simple affine transform.

Synthesis

The LLM can issue a SYNTHESIZE operation that creates a new summary evidence node superseding multiple chunk-level nodes. The summary receives a SUPERSEDES edge to each source node, and the source nodes are marked superseded=True. Superseded nodes are excluded from context composition but remain in the graph for provenance.

SIMILAR_TO Synchronization

The sync_similar_edges function maintains the semantic memory layer with a critical design constraint: edges are created only between nodes with different parents (via PRODUCED or SUBTASK_OF). Evidence chunks from the same research share topic trivially; SIMILAR_TO provides inter-topic PPR topology exclusively. This prevents the semantic graph from degenerating into dense intra-cluster cliques that would overwhelm PageRank diffusion.

The synchronization process:

  1. Group nodes by parent task
  2. Sample representative embeddings per group; compute inter-group centroid distances as a pre-filter
  3. For qualifying cross-parent pairs above threshold (default 0.55), create or update SIMILAR_TO edges with cosine similarity and 9 NLI-derived scores as properties
  4. Prune edges that have fallen below threshold

This runs at two points: mid-iteration (after task selection, before context expansion) and at session finalization. The threshold (0.55) is calibrated from live Qwen3-Embedding data — cross-group max similarities cluster at 0.46–0.61 with asymmetric doc prefixes.

Node Immutability

Terminal nodes (status DONE or FAILED) are immutable. Once a task reaches a terminal state, its content, embeddings, and relationships cannot be modified. This invariant (principle P5) ensures that:

  • Completed work cannot be corrupted by later operations
  • Failed approaches are preserved as anti-patterns for the LLM
  • The task DAG is a reliable audit trail of the agent's reasoning

Replacement of failed work is done by creating new subtasks, not by modifying existing ones.

Graph Store

The GraphStore class is the sole write path to Neo4j (principle P6). All mutations go through store.create_node(), store.update_node(), or store.create_edge(), which enforce:

  • ACID transactions --- every mutation is wrapped in a Neo4j transaction
  • Immutability checks --- attempts to modify terminal nodes raise ImmutableNodeError
  • Type validation --- patches are typed (TaskPatch, EvidencePatch), not arbitrary dicts
  • Cycle detection --- after operations are applied within iterate(), detect_cycles checks the edge set for DAG violations and rolls back the write transaction if found

No other module in the system writes to Neo4j directly. Tools, the research engine, and the LLM produce data structures that the orchestrator applies through the store. Service connection details are in Neo4jConfig.


Further Reading