The associative half is easy to show in a few lines of Python. Store each note with the concepts it is about, count how often two concepts are written together, and let a read follow the strong pairs, so a question about timeouts brings back a note about backoff that never mentions a timeout.
The long-term half is where a demo can pass while being wrong. Restart the process and ask the same question. The notes come back, because they were saved. Whether what the agent learned comes back depends on a decision that is easy to make by accident: whether you saved the links themselves, or only the notes they were built from.
The short answer: store notes with concepts, keep the link weights as state of their own, expand each read one step along links at or above a threshold, let the outcome of an answer change those weights, and persist the weights, not only the notes. Then test it across processes, with a control that rebuilds the links from the notes.
Disclosure: I work on Mnemoverse. One section near the end uses its Python SDK; the code before it is standard-library Python.
What makes a read associative
A read is associative when it returns something the query never named, because that item was recorded as belonging with something the query did name. A result that shares no words with the query does not prove it on its own: it can still be a plain semantic match. The evidence is the path, so a useful implementation tells you which link brought each item back.
Three decisions
What makes a link. Here, two concepts written in the same note. Each note adds one to the count of every pair of its concepts, so a link starts out meaning only that two things appeared together.
When a read follows it. One step out from the query's concepts, and only along links with a weight of at least two, because one co-occurrence can be chance. The threshold is a demonstration setting, not a measured recommendation.
What changes it. The outcome. When an answer helped, the links between what was asked and what came back get stronger; when it did not, weaker, and never below zero. This is the part the notes do not record, and the reason for the test below.
Three processes and a control
Save this as assoc_long.py in an empty directory. It needs nothing outside the standard library.
import json
import sys
from collections import Counter
STORE = "assoc_memory.json"
def pair(a, b):
return tuple(sorted((a, b)))
def save(notes, links):
with open(STORE, "w", encoding="utf-8") as f:
json.dump({"notes": notes, "links": {"|".join(k): n for k, n in links.items()}}, f, indent=1)
def load(rebuild=False):
with open(STORE, encoding="utf-8") as f:
data = json.load(f)
notes = data["notes"]
if rebuild: # the control: links recomputed from the notes, as if only notes were saved
links = Counter()
for note in notes:
for a in note["concepts"]:
for b in note["concepts"]:
if a < b:
links[(a, b)] += 1
return notes, links
return notes, Counter({tuple(k.split("|")): n for k, n in data["links"].items()})
def write(notes, links, text, concepts):
notes.append({"text": text, "concepts": sorted(concepts)})
for a in concepts:
for b in concepts:
if a < b:
links[(a, b)] += 1
def read(notes, links, asked, floor=2):
added = {}
for (a, b), n in links.items():
for c, other in ((a, b), (b, a)):
if c in asked and other not in asked and n >= floor:
added[other] = max(added.get(other, 0), n)
rows = []
for note in notes:
direct = set(note["concepts"]) & set(asked)
via = sorted(set(note["concepts"]) & set(added))
if direct or via:
score = 10 * len(direct) + sum(added[c] for c in via)
rows.append((score, note["text"], [] if direct else via))
return sorted(rows, reverse=True), added
def feedback(links, asked, used, helped=True):
for a in asked:
for b in used:
if a != b:
links[pair(a, b)] = max(0, links[pair(a, b)] + (1 if helped else -1))
def show(label, notes, links, asked):
rows, added = read(notes, links, asked)
print(label)
print(" asked:", ", ".join(asked), "| links add:",
", ".join("%s(%d)" % (c, n) for c, n in sorted(added.items())) or "nothing")
for _, text, via in rows:
print(" - " + text + (" [via " + ", ".join(via) + "]" if via else ""))
if __name__ == "__main__":
mode = sys.argv[1] if len(sys.argv) > 1 else "recall"
if mode == "learn":
notes, links = [], Counter()
write(notes, links, "The deploy timed out after the release.", ["timeout", "deploy"])
write(notes, links, "Retried the request and it went through.", ["timeout", "retry"])
write(notes, links, "Timed out again, retried, fine.", ["timeout", "retry"])
write(notes, links, "Backoff of two seconds fixed the flaky call.", ["retry", "backoff"])
write(notes, links, "Jitter on the backoff spread the calls out.", ["backoff", "jitter"])
write(notes, links, "Cache warmed before the deploy.", ["cache", "deploy"])
show("PROCESS 1, before feedback", notes, links, ["timeout"])
feedback(links, ["timeout"], ["retry", "backoff"]) # the answer helped, twice
feedback(links, ["timeout"], ["retry", "backoff"])
show("PROCESS 1, after two helpful answers", notes, links, ["timeout"])
save(notes, links)
else:
notes, links = load(rebuild=(mode == "rebuild"))
show("NEW PROCESS, " + ("links rebuilt from notes" if mode == "rebuild" else "links loaded"),
notes, links, ["timeout"])
Run it three times, as three separate processes:
python assoc_long.py learn
python assoc_long.py recall
python assoc_long.py rebuild
The first process writes six notes, reads, records two helpful answers, reads again and saves. The second loads the notes and the links and reads. The third loads the same file, throws the saved links away and rebuilds them from the notes, which is what you get if only the notes are persisted. I ran the three twice on Python 3.12.10, each time from an empty directory, and both runs printed this byte for byte:
PROCESS 1, before feedback
asked: timeout | links add: retry(2)
- Timed out again, retried, fine.
- Retried the request and it went through.
- The deploy timed out after the release.
- Backoff of two seconds fixed the flaky call. [via retry]
PROCESS 1, after two helpful answers
asked: timeout | links add: backoff(2), retry(4)
- Timed out again, retried, fine.
- Retried the request and it went through.
- The deploy timed out after the release.
- Backoff of two seconds fixed the flaky call. [via backoff, retry]
- Jitter on the backoff spread the calls out. [via backoff]
NEW PROCESS, links loaded
asked: timeout | links add: backoff(2), retry(4)
- Timed out again, retried, fine.
- Retried the request and it went through.
- The deploy timed out after the release.
- Backoff of two seconds fixed the flaky call. [via backoff, retry]
- Jitter on the backoff spread the calls out. [via backoff]
NEW PROCESS, links rebuilt from notes
asked: timeout | links add: retry(2)
- Timed out again, retried, fine.
- Retried the request and it went through.
- The deploy timed out after the release.
- Backoff of two seconds fixed the flaky call. [via retry]
What the control shows
Before any feedback, retry is the only link from timeout strong enough to follow, so the backoff note arrives through it. After two helpful answers, timeout and backoff are linked although no note ever put them together, and the jitter note arrives through that new link. That link exists only in the saved weights.
The second process, which loaded the links, gives the same read as the end of the first. The third, which rebuilt them from the notes, is back where the first process stood before any feedback, and the jitter note is gone. Nothing crashed, and nothing was missing from the notes. What was lost is exactly what the outcomes taught.
The cache note never comes back. Its way in would be deploy, and deploy was written with timeout only once, below the threshold of two, so no process adds it. That is the threshold holding in every process, which is the second thing worth checking after a restart.
Where the file stops
The file is a way to watch the behaviour, not a store to deploy. Concepts are strings you type, matched exactly, so timeout and timeouts are strangers. Every read scans every note. Every save rewrites one JSON file, so when two agents write at once, the last save wins and the other one's work is gone. And assoc_memory.json is a relative path: it is whatever file sits in the directory the process started in, on one machine. That starts to matter the day the agent in your terminal and the one in your editor should remember the same thing.
The same loop against a hosted store
With the mnemoverse Python SDK, 0.3.1 (pip install "mnemoverse>=0.3.1"), the three decisions map onto three calls, and a fourth reads the links themselves. The agent's own calls are placeholders.
from mnemoverse import MnemoClient
client = MnemoClient(api_key="mk_live_YOUR_KEY")
w = client.write("Backoff of two seconds fixed the flaky call.", concepts=["retry", "backoff"])
print(w.stored, w.reason) # a write can be refused by the write gate
question = "why does the deploy keep timing out?"
recall = client.read(question)
print(recall.query_concepts, recall.expanded_concepts)
for item in recall.items:
print(item.source, item.content) # hebbian_retrieval: reached through a learned association
answer = agent.respond(question, recall.items)
used = [item.atom_id for item in answer.relied_on] # rate what the answer used, not every item
if used:
client.feedback(atom_ids=used, outcome=1.0, query_concepts=recall.query_concepts)
if recall.query_concepts: # seeds: 1 to 20 concept names
for edge in client.graph(recall.query_concepts[:20], depth=1).edges:
print(edge.source, edge.target, edge.weight, edge.count)
A read expands through associations by default (include_associations=True), and every returned item carries source, the retrieval stage that found it. The API reference names semantic, concept_scoped and hebbian_retrieval for production reads, plus episodic for an exact fingerprint match, and says to treat any other value as one more stage; hebbian_retrieval means the item was missed by the direct stages and reached through a learned association, via a concept the query did not name. That label plays the part of [via ...] in the file above at the level of the stage: it says a learned association brought the item, and expanded_concepts says which concepts the read added. For the links themselves, with their weights, there is the fourth call.
feedback updates the rated memories' valence (an outcome score from -1 to +1, which outcome moves) and the associations between the query's concepts and the result's concepts, which is why it takes query_concepts as well as ids. The update can land after the call returns, so read again a moment later before concluding nothing moved. graph returns the edges around the concepts you give it, one to three hops out, each with its weight, valence, count and updated_at. The links live on the server rather than in your process, so a restart, or another process with the same key, reads the same edges.
Check your own memory the same way
Hold the query fixed and ask three questions, in this order.
- Does a read return an item the query never named, and does the response say which link brought it? If all you have is the item, you have not yet told association from similarity.
- After you rate an answer, does the path change? Compare what the read expanded to before and after, not only the order of the results.
- After a restart, do you get the taught path or the written one? Start a fresh process and read again. If the answer matches the read from before the rating, the store kept the notes and lost the lesson.
The library page has the single-process version of the file, how six memory APIs document widening a read, and the three checks for each of them. Where does your agent keep what it learned: next to the notes, or rebuilt from them?
The Python SDK is open source (MIT) and on PyPI as mnemoverse: github.com/mnemoverse/mnemoverse-sdk-python. The MCP server package on npm is open source too: github.com/mnemoverse/mcp-memory-server.
Top comments (1)
Edward, "the long-term half is where a demo can pass while being wrong" is
the precondition assertion wearing a memory-system coat, and reading it
through my own lens makes your whole thesis click into place. In my golden
set a negative test that passes because the retriever never surfaced the trap
is a green result whose precondition was never asserted — the test ran, the
verdict came back, and the path that was supposed to be exercised was never
touched. Your restart test is the same shape one layer up: the notes come
back, the process reads, and the path that was supposed to be taught — the
links themselves, not only the notes they were built from — was never
asserted. That is not long-term memory; it is a green lie that looks like a
functioning store.
What strikes me is how cleanly your three checks map onto my own failure
modes. "Does a read return an item the query never named, and does the
response say which link brought it" is the route tag in memory form — the
answer must carry the proof of which path earned it, the same way every
verdict in my golden set carries the embedder that earned it. Your
hebbian_retrievallabel is exactly that stamp: it says the item was reachedthrough a learned association, via a concept the query did not name, and
expanded_conceptssays which concepts the read added. An answer withoutthat label is a pass with no route tag: you can see the item, you cannot see
which branch of association earned it, and the branch may have been a default
the store never chose.
"After you rate an answer, does the path change" is the counterfactual pair
made into a check: same query, before and after feedback, assert the path
moved. Your control — the third process that rebuilds links from notes — is
exactly my "test did not run" in memory form. If the answer matches the read
from before the rating, the store kept the notes and lost the lesson, which
is the same failure as a negative test that passes because the trap never
reached the model. The notes are there, the process reads, and the assertion
that the feedback would be recorded was never made.
"The taught path or the written one" is my stale-anchor problem in memory
form. A verbatim anchor that resolves to an id is a stamp; a paraphrase that
merely sounds like one is a stamp that lost its id, and it fails quietly in
exactly the way a rebuilt-from-notes memory fails quietly. Your rebuild
process is the negative test for this failure: it asserts the precondition
that the links themselves were persisted, not only the notes, and it fails
closed when the precondition was never asserted. That is the same discipline
as my anchors — the context that makes the assertion meaningful must travel
with the result, instead of being assumed.
The bridge I could not resist building from my side is this: your three
processes are the memory-layer version of my ver