A brand-new memory store worked fine in one process. In the next one the plugin's init failed, and every Hermes tool call after that came back with YantrikDB is not active for this session. Underneath, the engine had refused to attach the embedder:
this database's vectors were built by potion-base-8M (digest sha256:89dd…, dim 256), and the embedder being attached declares no `fingerprint` or `digest`.
Queries would be encoded in a different space than the vectors they are compared against, and cosine distance still returns a plausible number for unrelated spaces — so the results would look fine and be wrong.
Options: attach the embedder that built them; set `.fingerprint = "sha256:89dd…"` on your embedder if it IS that model; call reembed() to rebuild the vectors in the new space; or pass allow_unverified_embedder=True if you accept the risk.
albertoMartinsen filed that as #84 on 2026-09-16, on plugin 0.25.0, engine 0.23.0, Hermes Agent 0.21.3, macOS arm64 and Python 3.11.15, with minishlab/potion-multilingual-128M configured through the plugin's model2vec option.
YantrikDB is a persistent memory engine for AI agents, a Rust core shipped to Python as the yantrikdb wheel. yantrikdb-hermes-plugin is a memory provider for Hermes Agent whose default backend runs that engine in-process. I maintain both.
An embedder is the model that turns memory text into vectors. The engine attaches a default one at the store's dimension when it has one: potion-base-2M at 64, inside the wheel, or potion-base-8M at 256, downloaded on first use (about 28 MB, SHA-256 pinned) and cached. The plugin lets you configure your own, and the reporter's multilingual model is 256-d too.
The guard works off a stamp. When the engine's own embedder encodes anything for an unstamped store, it records its identity there (one appears in the output further down). A Python embedder can carry the matching digest as .fingerprint or .digest. Attach one that carries neither to a stamped store and the engine can't tell it from any other model of that width, so it refuses. That refusal is the fix for engine issue #117, which I opened on 2026-07-28: a reopened store accepted a different embedder at the same dimension, and every recall after that was wrong with no error. PR #138 closed it in August.
The reporter had noticed the stamp named the engine's default, potion-base-8M, and read that as the store recording the wrong identity. They proposed a stable fingerprint on the plugin's loader wrappers, with the store recording the intended identity at creation, and offered reembed() or allow_unverified_embedder=True as alternatives. About half an hour later I agreed, on the strength of a grep (no .fingerprint on the wrappers, no allow-unverified flag on that call path), and wrote "Real fix is on the plugin side per your suggested directions", which put the bug in the wrong repo.
83, the reporter's other issue that day (an always-empty fleet view), had a similar shortcut in its tests: they mocked list_records, the very method that swapped a missing namespace for the base one, so the swap never ran.
Counting encode() calls
About nine and a half hours after that reply I posted a different diagnosis. I'd reproduced their failure, same sha256:89dd… digest, and counted the attached embedder's encode() calls during a write: 0 at 256 dimensions, where the engine had a native embedder, and 1 at 384, where it had none.
I reran it today (2026-09-24) with a script like the one further down, plus a call counter on the embedder: an unfingerprinted embedder, one record_text, a read of db.embedder_identity(), then a reopen and a reattach. set_embedder calls encode() once as a probe, so the counter is zeroed after attaching. Each version got its own venv from PyPI, on Windows 11 with Python 3.13.5. Trimmed (the ##### headers are mine; I cut the digest and the refusal text):
##### 0.23.0 / dim 256
native embedder present before attach: True
my encode() calls during record_text: 0
identity stamped on the store: {'name': 'potion-base-8M', 'digest': 'sha256:89dd9605...', 'dim': 256}
reopen + reattach my embedder: REFUSED
##### 0.23.1 / dim 256
native embedder present before attach: True
my encode() calls during record_text: 1
identity stamped on the store: None
reopen + reattach my embedder: OK
At 64, where the default is the potion-base-2M inside the wheel, both versions behave as they do at 256; PR #240's five tests run at 64. At 384 there's no default and both look like the 0.23.1 block, matching my 09-16 table.
On 0.23.0, at those widths, no write went through the attached object. potion-base-8M had encoded the reporter's records, so the stamp was accurate and the refusal on reopen was the #117 check doing what it was built for. The plugin log said attached embedder model2vec=… (dim=256) throughout. Someone who chose a 256-d multilingual model on 0.23.0 to hold non-English memories would have been getting potion-base-8M vectors instead, with no error while writing. I haven't measured what that does to recall.
With that override in place, the fingerprint plan (the one I'd endorsed) would have stamped the multilingual model's identity at creation and let the reopen pass, over vectors potion-base-8M produced. allow_unverified_embedder=True skips the comparison. Either would have ended the refusal while the native-first order stayed, and the attached model would still have gotten no calls.
That second comment also said a fresh store, or reembed(), would fix stores already written. Minutes later I retracted the reembed() half: hasattr(YantrikDB, "reembed") is False. It's a staged rebuild with crash recovery in the Rust engine that the Python binding doesn't expose. The guard message above lists it among the options. That's engine issue #241, still open.
The override was in the Python binding, crates/yantrikdb-python/src/py_engine/mod.rs. embed_text asked the Rust-native embedder first and fell through to the attached Python object only when there wasn't one. set_embedder(obj) probes the object with encode("__yantrikdb_probe__"), checks the output is numeric, runs check_embedder_identity and stores it, as the fallback. The wheel is built with the embedder-download feature, so at 64, and at 256 whenever potion-base-8M was cached or could be fetched, the native embedder was already there. The fix, abbreviated:
// embed_text, before: native first; the attached Python object was only the fallback below
if let Some(db) = &self.inner {
if db.has_embedder() {
return db.embed(text).map_err(map_err);
}
}
// embed_text, after: the attached object first; the native check follows
if let Some(emb) = &self.embedder {
let result = emb.call_method1(py, "encode", (text,))?;
...
return ...
}
Two more places in memory.rs made the same choice, the record_text fast path and then the correction path, and each got one clause:
-None if db.has_embedder() =>
+None if db.has_embedder() && self.embedder.is_none() =>
-let use_caller_embed = new_text.is_some() && !db.has_embedder() && self.embedder.is_some();
+let use_caller_embed = new_text.is_some() && self.embedder.is_some();
I think a default the engine picked because the dimension matched should lose to an object the caller attached by hand, and that's the order now; set_embedder still runs the identity check on that object.
PR #240 merged as 49c7aac, 10h31m after the report (+172/-20, 3 files). Its five tests in tests/test_attached_embedder_precedence.py check both that the attached embedder runs and that the native one still runs when nothing is attached; one first asserts that the dimension really auto-attaches a native embedder.
v0.23.1 shipped on 2026-09-19 with a behaviour change, in a patch release: record_text with an idempotency_key and a Python embedder now raises the existing explicit refusal, where 0.23.0 succeeded through the native path. Stores written the old way keep refusing, and the notes say to start a fresh one.
Plugin v0.27.0 went out the same day. Its dependency range, on main and in the 0.27.0 metadata, is yantrikdb>=0.12.1,!=0.15.0,!=0.15.1,!=0.15.2,<0.24.0, so an install already on 0.23.0 keeps the old order until someone runs pip install -U yantrikdb. I said so in a 09-20 status comment, after running the PR's test file against 0.23.1 from PyPI in a clean venv (5 passed), and left #84 open. I haven't run the reporter's exact configuration, potion-multilingual-128M through the plugin's model2vec path.
The reverse case
On 2026-09-22 the reporter confirmed the fix on plugin 0.27.0 and engine 0.23.1 (a brand-new model2vec store, 5 of 5 reopens in fresh processes through make_backend), then posted a three-step repro for the opposite failure, each step a new process. Create a store with the external embedder; its meta has no embedder_* keys. Open it with the default config and do one ordinary recall, which succeeds without a warning and leaves the store stamped potion-base-8M/256. Go back to the external embedder and it's refused, on a file where PRAGMA quick_check still passes.
I traced it through the engine that day. check_embedder_identity sits in the Python binding and runs only while a Python embedder is being attached, through the constructor's embedder= or set_embedder(). On the engine's own encode path, YantrikDB::embed() calls stamp_embedder_identity_once() after every successful embed, and nothing on that path asks whether the vectors already in the store came from a different model. Query text goes through embed() too, so one recall is enough.
Two days later I ran it. The script below does their three steps against the engine only, 0.23.1 from PyPI in a clean venv, same Windows 11 and Python 3.13.5. Theirs went through make_backend with model2vec on macOS; mine uses a hash-based stand-in and three handles in one process, closed between steps. Trimmed (docstring and an unused import removed):
import math
import shutil
import tempfile
from pathlib import Path
import yantrikdb
from yantrikdb import YantrikDB
DIM = 256
class Counting:
def encode(self, text):
if not isinstance(text, str):
return [self.encode(t) for t in text]
h = abs(hash(text)) % 100_000
v = [math.sin(h * (i + 1) * 0.001) for i in range(DIM)]
n = math.sqrt(sum(x * x for x in v)) or 1.0
return [x / n for x in v]
d = tempfile.mkdtemp()
path = str(Path(d) / "s.db")
print(f"yantrikdb {getattr(yantrikdb, '__version__', '?')}")
db = YantrikDB(path, embedding_dim=DIM)
db.set_embedder(Counting())
db.record_text(text="Dana Okafor leads the ML Platform team.")
print("1. external embedder writes; identity on store:", db.embedder_identity())
db.close()
db = YantrikDB(path, embedding_dim=DIM) # default config, nothing attached
hits = db.recall_text("who leads ML Platform", top_k=1)
print("2. default open + one recall; identity on store:",
(db.embedder_identity() or {}).get("name"))
db.close()
db = YantrikDB(path, embedding_dim=DIM)
try:
db.set_embedder(Counting())
print("3. reattach the external embedder: OK")
except Exception as e: # noqa: BLE001
print("3. reattach the external embedder: REFUSED")
print(" ", str(e).replace("\n", " ")[:170] + "...")
finally:
db.close()
shutil.rmtree(d, ignore_errors=True)
yantrikdb 0.23.1
1. external embedder writes; identity on store: None
2. default open + one recall; identity on store: potion-base-8M
3. reattach the external embedder: REFUSED
this database's vectors were built by potion-base-8M (digest sha256:89dd960591c4fa0c7f7a45ed4cb94167ce4e09886f39bae008b8072b42439ac5, dim 256), and the embedder being att...
Repeating steps 1 and 2 with the same class, then trying the reattach plain and with the flag the guard message offers, gives:
yantrikdb 0.23.1
after default open + recall, identity: potion-base-8M
plain set_embedder: REFUSED
set_embedder(allow_unverified_embedder=True): OK, write succeeded; identity still: potion-base-8M
The plugin doesn't pass that flag on the external-embedder path (my first reply on #84 says as much), so through the plugin that store refuses the embedder that built it on every open. Nothing supported rewrites the stamp either: adopt_embedder_identity refuses when a different digest is already recorded, and reembed() is #241.
The doc comment on stamp_embedder_identity_once covers this, in a paragraph headed "Known over-claim", and argues the stamp "does not create a failure that was not already there". I think that's wrong, and the repro is why. Skip step 2 and the reattach works (the 0.23.1 run at 256 in the first output block). Do step 2 and it's refused. The default-config session does query in the wrong space, as the comment says, but only for that session; the stamp it leaves locks the plugin out of the embedder that wrote the store.
The reporter suggested two fixes: a symmetric check that refuses the native embedder against a store with vectors and no recorded identity, or stamping the external embedder's identity at the first write. It reproduces on 0.23.1 as of 2026-09-24 and only exists as comments on #84 right now. No engine commit since 2026-09-21 touches the stamping; the latest is PR #246, a vault-key change.
Pranab Sarkar, Independent Researcher
Top comments (0)