adaptive_memory 0.1.0

An associative memory system using spreading activation with SQLite FTS5 full-text search
Documentation

Adaptive Memory

An associative memory system using spreading activation. Memories are stored in SQLite with FTS5 full-text search, and retrieved using BM25 text matching combined with graph-based activation spreading through explicit relationships.

Core Concepts

Memories

Entries with id, datetime, text, and optional source. Stored in SQLite with FTS5 full-text indexing. IDs are sequential integers assigned on insertion.

Relationships

Symmetric connections between memories. Created only via explicit strengthen calls - no auto-generated relationships. Multiple strengthen events accumulate; effective strength is the sum of all events (with optional decay).

Relationships are stored canonically (from_mem < to_mem) as an event log. This allows strength to build up over time through repeated strengthening.

Spreading Activation

Search works by:

  1. FTS5 Search: Find memories matching query using BM25 ranking
  2. Seed Selection: Top BM25 results become seeds with energy 0.1-1.0 (proportional to relevance)
  3. Energy Propagation: Energy spreads through relationship graph
    • Energy is distributed across neighbors (PageRank-style normalization)
    • Each hop multiplies by energy_decay (default 0.5)
    • Propagation stops when energy < 0.01 threshold
  4. Results: Memories sorted by ID (timeline order) with accumulated energy scores

Context Expansion

Instead of pre-computed temporal relationships, use --context N to fetch N memories before/after each result by ID. This is like grep -B/-A for temporal context.

Installation

cd adaptive_memory
cargo build --release
# Binary at: target/release/adaptive-memory

CLI Usage

adaptive-memory [OPTIONS] <COMMAND>

Commands:
  init        Initialize the database
  add         Add a new memory
  search      Search for memories
  strengthen  Strengthen relationships between memories

Global Options:
  --db <PATH>  Database path (default: ~/.adaptive_memory.db)

Initialize Database

adaptive-memory init

Add Memory

adaptive-memory add [OPTIONS] <TEXT>

Options:
  -s, --source <SOURCE>      Source identifier (e.g., "journal", "slack")
  -d, --datetime <DATETIME>  Override datetime (RFC3339 format)

Examples:

# Simple memory
adaptive-memory add "Had coffee with Sarah, discussed the new project"

# With source
adaptive-memory add "Reviewed PR #123" -s "github"

# Historical entry
adaptive-memory add "Started learning Rust" -d "2023-06-15T10:00:00Z"

Output:

{
  "memory": {
    "id": 42,
    "datetime": "2026-01-05T12:30:00Z",
    "text": "Had coffee with Sarah, discussed the new project",
    "source": null
  }
}

Search Memories

adaptive-memory search [OPTIONS] <QUERY>

Options:
  -l, --limit <N>          Maximum results (default: 100)
  -c, --context <N>        Fetch N memories before/after each result (default: 0)
  --decay <FACTOR>         Relationship decay over memory distance (default: 0)
  --energy-decay <FACTOR>  Energy multiplier per hop (default: 0.5)

Examples:

# Basic search
adaptive-memory search "project meeting"

# With temporal context (like grep -B2 -A2)
adaptive-memory search "rust" --context 2

# Limit results
adaptive-memory search "database" --limit 10

# Deeper activation spread (reach more distant associations)
adaptive-memory search "ideas" --energy-decay 0.7

Output:

{
  "query": "project meeting",
  "seed_count": 15,
  "total_activated": 47,
  "iterations": 234,
  "memories": [
    {
      "id": 38,
      "datetime": "2026-01-04T09:00:00Z",
      "text": "Project kickoff meeting with the team",
      "source": "calendar",
      "energy": 1.87
    },
    {
      "id": 42,
      "datetime": "2026-01-05T12:30:00Z",
      "text": "Had coffee with Sarah, discussed the new project",
      "source": null,
      "energy": 2.45
    }
  ]
}

Results are sorted by memory ID (timeline order). The energy field indicates relevance:

  • ~1.0 = direct BM25 match
  • ~0.5 = one hop from a seed
  • < 0.1 = reached via multi-hop spreading

Context items (from --context) have energy: 0.0 and is_context: true.

Strengthen Relationships

Create explicit associations between memories.

adaptive-memory strengthen <IDS>

Arguments:
  <IDS>  Comma-separated memory IDs (max 10)

Examples:

# Link two related memories (adds 1.0 strength)
adaptive-memory strengthen 42,38

# Link multiple (creates all pairs, 1.0 each)
# 4 IDs = 6 pairs, each gets 1.0 strength
adaptive-memory strengthen 1,5,12,34

Output:

{
  "relationships": [
    {
      "from_mem": 38,
      "to_mem": 42,
      "effective_strength": 2.0,
      "event_count": 2
    }
  ],
  "event_count": 1
}

FTS5 Query Syntax

The search query uses SQLite FTS5 syntax, which supports powerful search operators:

Syntax Meaning Example
word Match word meeting
word1 word2 Match both (implicit AND) project meeting
word1 OR word2 Match either cat OR dog
"phrase" Exact phrase "weekly standup"
word* Prefix match meet* matches meeting, meetings
NOT word Exclude meeting NOT standup
NEAR(w1 w2, N) Words within N tokens NEAR(rust memory, 5)
^word Match at start of field ^TODO

Special characters: Characters like +, -, @ have special meaning in FTS5. To search for literal special characters, quote them: "2024-01-15" or "email@example.com".

Examples:

# All memories with "rust" AND "async"
adaptive-memory search "rust async"

# Either term
adaptive-memory search "rust OR python"

# Exact phrase
adaptive-memory search '"weekly standup"'

# Prefix matching
adaptive-memory search "meet*"

# Exclude term
adaptive-memory search "project NOT cancelled"

# Words near each other
adaptive-memory search "NEAR(database migration, 10)"

Library Usage

use adaptive_memory::{MemoryStore, MemoryError, SearchParams};

fn main() -> Result<(), MemoryError> {
    let mut store = MemoryStore::open("~/.adaptive_memory.db")?;

    // Add memories
    let result = store.add("Learning about spreading activation", Some("research"))?;
    println!("Added memory {}", result.memory.id);

    // Search with default params
    let results = store.search("activation", &SearchParams::default())?;
    for mem in results.memories {
        println!("{}: {} (energy: {:.2})", mem.memory.id, mem.memory.text, mem.energy);
    }

    // Search with context expansion
    let params = SearchParams {
        limit: 50,
        context: 2,
        ..SearchParams::default()
    };
    let results = store.search("activation", &params)?;

    // Strengthen relationships
    store.strengthen(&[1, 2, 3])?;

    Ok(())
}

Configuration

Compile-time Constants (src/lib.rs)

Constant Default Description
ENERGY_THRESHOLD 0.01 Stop propagation below this energy
MAX_SPREADING_ITERATIONS 5000 Safety limit on activation iterations
MAX_STRENGTHEN_SET 10 Max memories per strengthen call
DEFAULT_LIMIT 100 Default result limit

Runtime Parameters (SearchParams)

Parameter Default Description
limit 100 Max results (also seed count for FTS)
decay_factor 0.0 Relationship strength decay over memory distance
energy_decay 0.5 Energy multiplier per hop (0.5 = halves each hop)
context 0 Fetch N memories before/after each result

Tuning energy_decay

Controls how far activation spreads through the graph:

Value Behavior Max Depth
0.3 Shallow spread, stick close to seeds ~4 hops
0.5 Balanced (default) ~7 hops
0.7 Deep spread, reach distant associations ~12 hops

Energy at each hop (starting from seed with energy 1.0):

Hop:    0     1      2       3        4
0.5:   1.0   0.50   0.25    0.125    0.0625
0.7:   1.0   0.70   0.49    0.343    0.240

Tuning decay_factor

Controls how relationship strength fades over memory distance:

effective_strength = stored_strength × exp(-distance × decay_factor)
Value At 10 memories At 50 memories At 100 memories
0.0 100% (no decay) 100% 100%
0.01 90% 61% 37%
0.03 74% 22% 5%
0.05 61% 8% 0.7%

Default is 0.0 (no decay). The ln_1p compression and PageRank-style normalization already prevent old relationships from dominating.

Database Schema

CREATE TABLE memories (
    id INTEGER PRIMARY KEY,
    datetime TEXT NOT NULL,
    text TEXT NOT NULL,
    source TEXT
);

CREATE VIRTUAL TABLE memories_fts USING fts5(text, content=memories, content_rowid=id);

CREATE TABLE relationships (
    id INTEGER PRIMARY KEY,
    from_mem INTEGER NOT NULL,
    to_mem INTEGER NOT NULL,
    created_at_mem INTEGER NOT NULL,
    strength REAL NOT NULL,
    CHECK (from_mem < to_mem)
);

How It Works

Adding a Memory

  1. Insert into memories table
  2. FTS5 trigger auto-indexes the text
  3. No relationships created (use strengthen or --context for associations)

Searching

  1. FTS5: BM25-ranked text matches become seeds
  2. Spreading Activation:
    • Seeds get energy proportional to BM25 score
    • Energy spreads through relationships (delta propagation)
    • Neighbors' strengths are normalized (sum to 1.0) - energy is distributed, not amplified
    • Raw strength is compressed via ln(1+x) for diminishing returns
  3. Context Expansion: Optionally fetch surrounding memories by ID
  4. Results: Sorted by memory ID (timeline order)

Strengthening

  1. For each pair of IDs, add relationship event with strength 1.0
  2. Events accumulate - the pair's effective strength grows with repeated strengthening
  3. ln_1p compression means: 1st event → 0.69 effective, 10 events → 2.40, 100 events → 4.62

Tips

  • Source field: Tag memories for filtering/identification (e.g., "slack", "journal", "calendar")
  • Strengthen after retrieval: If a search surfaces related memories, strengthen them to reinforce the association
  • Context for temporal: Use --context N instead of pre-computed temporal links
  • Batch import: Use -d to preserve original timestamps when importing historical data
  • Quote special chars: FTS5 special characters (+, -, *, etc.) should be quoted for literal matching

License

MIT