← Blog

The 7 Dimensions of Memory

How Ember decomposes every moment into a signal constellation — and why each dimension matters

Robert Praul//12 min read

Every memory system in AI reduces to the same operation: embed text, search by similarity, return a ranked list.

One dimension. One axis. Semantic distance.

But human memory doesn't work this way. You don't remember Thanksgiving because of word similarity to “turkey.” You remember it because cinnamon + cold air + a specific voice + the feeling of being 12 years old all converge simultaneously.

Ember models this convergence explicitly. Every indexed memory and every incoming message is decomposed into a signal constellation — 7 independent scoring axes that must align before recall fires.

Here's how each one works.

1. Semantic

What it captures

Meaning-level similarity between text passages

This is the dimension every RAG system has. Ember uses MiniLM-L6-v2 to generate 384-dimensional embeddings entirely on-device. No API calls, no network latency — 12ms per embedding on CPU.

But here's the key difference: in Ember, semantic similarity is necessary but not sufficient. A high cosine score gets a memory past the first gate. It doesn't ignite it. The memory still needs convergence on at least 2 other dimensions to fire.

# Semantic is always extracted automatically
ember.index("Walking through autumn leaves in the park")
# → 384d embedding generated locally via MiniLM
# → No API key, no network, runs on CPU

Weight: 0.30 (highest single weight, but still requires partners to fire).

2. Emotional

What it captures

Valence (grief → joy), arousal (calm → intense), and emotion labels

The emotional dimension uses a curated lexicon to extract three signals without any LLM call:

  • Valence — A float from -1.0 (grief, despair) to +1.0 (joy, elation). Computed from emotion word frequencies with intensity weighting.
  • Arousal — A float from 0.0 (calm, meditative) to 1.0 (excited, agitated). Independent from valence: peaceful joy and anxious anticipation are both valid states.
  • Labels — Discrete emotion tags like “nostalgic,” “tender,” “bittersweet.” Matched against the memory's indexed labels with Jaccard overlap.

Scoring uses absolute difference for valence/arousal (closer = higher) and label overlap ratio. The three sub-scores are averaged.

ember.index(
    "The last morning before we moved away",
    emotions=["bittersweet", "nostalgic", "tender"],
    # Valence and arousal are auto-extracted from the text,
    # or you can provide explicit overrides:
    # emotion_valence=0.2,
    # emotion_arousal=0.3,
)

Weight: 0.25. Emotional resonance is the second strongest signal. Two memories with identical semantic content but different emotional textures score very differently.

3. Sensory

What it captures

Visual, auditory, olfactory, tactile, and gustatory signals

This is the dimension that makes Ember feel different from everything else. Most memory systems ignore sensory detail entirely — they treat “the smell of rain on hot pavement” the same as “precipitation event.”

Ember maintains a curated vocabulary for each modality:

Visualfireflies, street lights, neon, shadows, sunrise
Auditorythunder, rain, waves, crickets, engine hum
Olfactorybbq smoke, salt air, cinnamon, gasoline, pine
Tactilecold wax, sand, humid air, rough bark, warm mug
Gustatorysalt, coffee, miso, lime, char

Scoring: for each modality present in both the message and the memory, compute the overlap ratio of detected markers. Then average across all modalities that have data on either side.

The power of sensory scoring is cross-modal convergence. A message that mentions both “cinnamon” (olfactory) and “crackling fire” (auditory) scores much higher against a winter cabin memory than either signal alone.

Weight: 0.15.

4. Temporal

What it captures

Season, time of day, and era references (childhood, college, present)

Temporal extraction uses regex + keyword matching — fast and deterministic. Three sub-signals:

  • Season — summer, winter, autumn, spring. Exact match scoring. “August heat” maps to summer.
  • Time of day — morning, afternoon, evening, night, dawn, dusk. “3 AM” maps to night.
  • Era — childhood, teenage, college, early career, present. “When I was 8” maps to childhood.

A memory indexed with season="summer", era="childhood" will resonate with messages that reference hot days, fireflies, or being young — even if the semantic content is about something completely different.

Weight: 0.10.

5. Spatial

What it captures

GPS proximity, location names, and place types (beach, kitchen, city)

Added in v0.3.1, the spatial dimension supports three scoring paths:

  • GPS proximity — Haversine distance between two coordinates. Within 500m = full score. Linear decay to 50km.
  • Named locations — Exact and partial match on location strings. “El Porto” matches “El Porto Beach, CA.”
  • Place types — Category matching: “beach” matches “beach,” “coast” matches “beach.” Extracted from location context.
# GPS-aware memory: fires when the user is physically nearby
ember.index(
    "Dawn patrol at El Porto, cold wax and salt air",
    latitude=33.895, longitude=-118.421,
    location="El Porto, CA",
)

# At check time, pass the user's coordinates
result = ember.check(
    "Beautiful morning",
    context={"location": {"lat": 33.896, "lon": -118.422}},
)
# Spatial dimension fires — user is 100m from the memory

This is how real memory works: you walk through your old neighborhood and things surface. The GPS scoring is especially powerful for mobile agents and wearable companions.

Weight: 0.05 (accent dimension — potent when combined).

6. Relational

What it captures

People mentioned or implied, and relationship context

Memories aren't just about what happened — they're about who was there. The relational dimension scores based on shared people between the current context and stored memories.

Scoring uses name overlap (exact + fuzzy) with a trust-level modifier. Memories involving high-trust relationships (family, close friends) get amplified. Casual mentions get dampened.

ember.index(
    "Teaching Jake to ride a bike in the driveway",
    people=["Jake"],
    relationships={"Jake": "son"},
)

# Later, any mention of Jake amplifies relational scoring
result = ember.check("Jake's soccer game was amazing today")

Weight: 0.10.

7. Musical

What it captures

Artists, tracks, genres, and musical associations

Music is one of the most powerful memory triggers in human experience. A song you haven't heard in years can instantly transport you to a specific place and time.

The musical dimension is optional — it only fires when music context exists on both sides. Scoring matches on artist name, track title, and genre. This makes it an accent dimension: silent most of the time, vivid when it activates.

ember.index(
    "Road trip to Joshua Tree, windows down",
    music={"artist": "Khruangbin", "genre": "psychedelic"},
    season="summer",
)

# When the user mentions the same artist or genre,
# the musical dimension adds to the convergence score

Weight: 0.05 (accent dimension).

Why Convergence Matters

The 7-dimensional design isn't about having more features. It's about modeling a fundamental property of human memory: single signals don't trigger recall.

The word “summer” alone doesn't make you remember anything. But “summer” + “BBQ smoke” + “evening” + “being a kid” hits different. That's temporal + sensory + temporal + era all converging.

Ember requires a minimum of 3 dimensions to fire. Below that threshold, the candidate is discarded — it's noise, not memory. Above it, the composite score determines intensity:

Faint3 dimensions, score 0.28–0.40. A whisper of recognition.
Warm4–5 dimensions, score 0.40–0.60. “This reminds me of...”
Vivid5+ dimensions, score above 0.60. Full sensory immersion.

Custom Dimensions

The 7 built-in dimensions cover the most universal memory triggers. But Ember is extensible — you can register custom dimensions for domain-specific scoring:

from ember import register_dimension, register_extractor

@register_dimension("culinary")
def score_culinary(memory_data, message_data):
    """Score based on shared culinary references."""
    mem_foods = set(memory_data.get("foods", []))
    msg_foods = set(message_data.get("foods", []))
    if not mem_foods or not msg_foods:
        return 0.0
    return len(mem_foods & msg_foods) / len(mem_foods | msg_foods)

@register_extractor("culinary")
def extract_culinary(text):
    """Extract food references from text."""
    # Your extraction logic here
    return {"foods": detected_foods}

Custom dimensions participate in the same convergence pipeline. They get their own weight, their own extractor, and their own scoring logic. A culinary companion might add a “flavor” dimension. A fitness tracker might add “exertion.”

Build With All 7

pip install ember-experiences

v0.4.0 — 228 tests — Apache 2.0 — GitHub