Skip to main content

The problem

Extraction echoes whatever spelling the text used. Over a few thousand memories the graph ends up holding Claude Opus 4.8 and Opus 4.8, Postgres and PostgreSQL, Claude Code and claude-code as separate entities. That is not just untidy. Recall’s entity arm anchors a query to at most 10 entities, so every duplicate spelling spends one of those slots naming a thing another slot already names. Retrieval gets worse as the graph grows. Name-key resolution catches only the cheapest case (@memloom/core and memloom/core). Everything else needs a decision.

What a fold is

A fold picks one entity as the canonical and moves the other into it:
  • every mention edge and typed relationship repoints to the canonical,
  • the absorbed spelling is recorded as an alias, with the original row’s id and vector,
  • a merge record stores exactly which edges moved and which were deactivated.
Nothing is destroyed. Edges are deactivated, never deleted, and the merge record is what makes revert exact rather than approximate. The alias row does more than document the fold. It is also the lookup extraction does on every mention, so the next memory that says Opus 4.8 lands on the canonical instead of minting the row again. Without it a fold quietly undoes itself as soon as new content arrives, which no amount of re-running the backfill would reveal.

How candidate pairs are found

Candidate generation is lexical and deterministic. Measured against a real 1794-entity store, the duplicates a graph accumulates are spelling variants, not synonyms, so an embedding is used only to confirm a pair the rules already found. It never pairs two entities on its own. That keeps precision high and costs nothing per pair. Entities are bucketed by their words and by the first characters of their normalized key, so only plausible pairs are ever compared. Each pair then gets one of three verdicts.

The rules that say no

These run first, because the pairs that look most similar are often the ones that must stay apart.

The one rule that folds

Two names whose normalized keys are identical after casefolding and dropping every non-alphanumeric character. Claude Code, claude-code and ClaudeCode all collapse to claudecode. This is the only case applied without asking, with two escapes:
  • keys shorter than 3 characters ask instead (C# and C collide),
  • a punctuation-prefixed name asks instead (.next is a build directory, Next is a framework).

The rules that ask

People are the one exception to the two-word requirement. A human’s name is written at several lengths and means one human, so Ada pairs with Ada Lovelace. Both sides must be typed person, and it still only asks: John against both John Smith and John Doe is a real question.
Three of these rules exist because of what a real store did to earlier versions. Naive word containment produced 1129 questions, because a one-word name like memloom sits inside memloom serve, MEMLOOM_HOME and @memloom/cli. Requiring two contiguous words, refusing punctuation-prefixed names, and scaling edit tolerance with name length took that to 241.

Running it

The pass is idempotent and safe to re-run. It reports what it did: deferred exists so that “nothing queued” never hides “there was more to ask about”. The queue is capped at 50 pending questions so entity folds cannot bury the memory conflicts the surface exists for; the lowest-impact questions wait, ranked by the weaker side’s mention count, and a later pass picks them up as answers come in.

Uncertain folds go to the conflicts queue

An uncertain fold is the same shape of question as a contradiction between two memories: something is inconsistent and only you can settle it. So it lands in the same place, with the same reversibility. See Conflicts.
On an entity row, keep_both means “settled, do not ask again”. It is written both by you choosing that the two are distinct and by the resolver settling a question that became moot because both sides were already folded together. Do not read the decision log as a record of human judgment about distinctness.
Answering one question can dissolve another: one entity is often the candidate in many queued pairs, and folding it away leaves the rest naming a row that no longer exists. Those rows stay answerable, because resolution follows both sides through the alias chain first.

Recall follows folds

The fold deletes the absorbed row, so without help a query spelled the old way would stop anchoring on anything, and folding would make retrieval worse. The absorbed vector is not gone, though: it moved to the alias row. The entity arm anchors on entity vectors and alias vectors together, mapping every alias hit to its canonical. Canonical and alias are deduplicated before the limit is applied, since two spellings of one name have close vectors and would otherwise spend two anchor slots naming one entity. The net effect on a real store: 38 correct automatic folds freed 37 entities from competing for the anchor budget, and a query using a folded spelling now reaches everything the canonical is attached to.

Everything is reversible

POST /memory/entities/merges/{id}/revert restores the absorbed entity with its original id, type, vector and creation time, repoints exactly the edges the merge moved, reactivates exactly the edges it deactivated, and removes the alias. The original id matters: deactivated edges still point at it, so a restored row must be the same row, not a new one wearing the same name.
Deleting an entity that an unreverted fold points at is refused. The alias row is what carries the absorbed entity’s id and vector, so deleting the canonical would make the fold permanent and sweep the absorbed row’s mentions with it. Revert the fold first, then delete whichever entity you meant.

Asking the graph who is connected to what

Once identity is settled, the graph can answer questions about connections rather than content:
The target can be an id, a name, or a folded-away spelling: an alias resolves to its canonical and the answer says which spelling was asked for. Two kinds of connection come back separately, never blended into one score:
  • Stated relationships are typed predicate edges extraction asserted (works_on, uses, part_of), with their direction and the extractor’s confidence.
  • Co-mention is entities named by the same sources, with the count. Weaker evidence, so it never outranks a stated link.
Folding feeds this directly: because the absorbed spelling’s mention edges were repointed, a fold makes a neighbourhood more complete rather than merely tidier. In the Console this is the traverse panel on the Graph tab. Searching an entity dims the canvas to its neighbourhood, so the answer is the part of the graph still lit; clicking a neighbour follows it and the lit region moves one hop. Agents reach the same thing through the related_entities MCP tool.

What it deliberately will not do

A nickname that shares no letters with the name, like Bob against Robert, will never be proposed. That is not a lexical judgement and no rule here pretends otherwise. Declaring it yourself works and needs nothing new: POST /memory/entities/{id}/merge folds any two entities regardless of spelling, records the same reversible merge, and every future mention of the alias resolves to the canonical. The missing piece is only a channel that proposes such a pair, which today is you.

Where to do this