fix(watcher): repair hand-written notes without a ULID outside a full scan
Found by running the service for real: creating a file in the vault while mdmem is up only logged 'reindex of changed file failed' until the next scan, which is exactly the workflow the vault is meant to support. Adds the live test suite (-m live) and the llama-swap model entry.
This commit is contained in:
1 parent
48e8d9bc26
commit
c79e55f3e7
7 files changed
+143
-6
No files matched your search
Binary file not shown.
@@ -0,0 +1,25 @@
|
||||
# Add to llama-swap's config on evo-x2 (10.0.0.59:9000) — the box Bifrost's
|
||||
# `llamaswap` provider already points at. Not managed by Flux; llama-swap runs
|
||||
# off-cluster (§7).
|
||||
#
|
||||
# --pooling last is mandatory: Qwen3 embeddings use last-token pooling and the
|
||||
# wrong pooling degrades retrieval silently (§14.1, [R2][R3]).
|
||||
# Never add --reranking to this instance: with both flags on, a reported
|
||||
# llama.cpp bug returns all-zero embeddings (§14.2, [R5]).
|
||||
# Q8_0, not Q4_K: Q4_K measured 0.974 cosine against the reference (§14.3, [R4]).
|
||||
models:
|
||||
qwen3-embedding-4b:
|
||||
cmd: >
|
||||
llama-server --port ${PORT}
|
||||
-m /models/Qwen3-Embedding-4B-Q8_0.gguf
|
||||
--embedding --pooling last
|
||||
-ub 8192 -c 8192 -ngl 99
|
||||
ttl: 0 # keep resident; an embedding call must not evict the chat model
|
||||
|
||||
# Keep it co-resident with the chat model rather than swapping. Check the group
|
||||
# syntax against your llama-swap version:
|
||||
# groups:
|
||||
# resident:
|
||||
# swap: false
|
||||
# exclusive: false
|
||||
# members: ["qwen3-embedding-4b", "<your chat model>"]
|
||||
+2
-2
@@ -216,7 +216,7 @@ class Indexer:
|
||||
for path, _state in self.vault.iter_files():
|
||||
stats.scanned += 1
|
||||
try:
|
||||
note = self._read_and_fix(path)
|
||||
note = self.read_and_fix(path)
|
||||
except Exception as exc: # a broken file must not abort the scan
|
||||
stats.errors.append(f"{path}: {exc}")
|
||||
log.warning(
|
||||
@@ -242,7 +242,7 @@ class Indexer:
|
||||
stats.seconds = time.monotonic() - started
|
||||
return stats
|
||||
|
||||
def _read_and_fix(self, path: Path) -> Note:
|
||||
def read_and_fix(self, path: Path) -> Note:
|
||||
"""Read a note, assigning a ULID and filename if a human created the file by hand."""
|
||||
note = self.vault.read(path)
|
||||
if not is_ulid(note.id):
|
||||
|
||||
@@ -165,9 +165,11 @@ class MemoryService:
|
||||
if self.vault.find(note_id) is None:
|
||||
self.indexer.remove(note_id)
|
||||
return []
|
||||
note = self.vault.read(path)
|
||||
# A note created by hand in a running vault has no ULID yet; repair it here
|
||||
# too, not only on a full scan.
|
||||
note = self.indexer.read_and_fix(path)
|
||||
row = self.db.read().execute("SELECT file_hash, path FROM notes WHERE id=?", (note.id,)).fetchone()
|
||||
if row and row["file_hash"] == note.file_hash and row["path"] == str(path):
|
||||
if row and row["file_hash"] == note.file_hash and row["path"] == str(note.path):
|
||||
return [] # our own write, already indexed (§14.6)
|
||||
return self.indexer.index_note(note)
|
||||
|
||||
|
||||
@@ -102,8 +102,12 @@ async def test_every_variant_runs_at_the_same_budget(seeded):
|
||||
svc, ids = seeded
|
||||
queries = [EvalQuery(q="llama-swap Modelle", relevant=[ids["llama-swap routet Modelle"]])]
|
||||
reports = []
|
||||
for v in (Variant("rrf", fusion="rrf"), Variant("lex", fusion="rrf", channels=("lex",)),
|
||||
Variant("sem", fusion="rrf", channels=("sem",)), Variant("cc", fusion="cc", cc_alpha=0.5)):
|
||||
for v in (
|
||||
Variant("rrf", fusion="rrf"),
|
||||
Variant("lex", fusion="rrf", channels=("lex",)),
|
||||
Variant("sem", fusion="rrf", channels=("sem",)),
|
||||
Variant("cc", fusion="cc", cc_alpha=0.5),
|
||||
):
|
||||
reports.append(await run_variant(svc, queries, v, max_chars=500))
|
||||
assert all(r["mean_chars"] <= 800 for r in reports)
|
||||
assert {r["variant"] for r in reports} == {"rrf", "lex", "sem", "cc"}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""The one test that talks to the real backend. Excluded from the default run.
|
||||
|
||||
uv run pytest -m live
|
||||
|
||||
Needs BIFROST_URL, BIFROST_VK and EMBED_MODEL pointing at the real gateway —
|
||||
run it from inside the cluster, or with a port-forward:
|
||||
|
||||
kubectl -n apps port-forward svc/bifrost 8080:8080
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from mdmem.embed import QUERY_INSTRUCT, BifrostEmbedder, EmbeddingError
|
||||
|
||||
pytestmark = pytest.mark.live
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def embedder():
|
||||
vk = os.environ.get("BIFROST_VK")
|
||||
if not vk:
|
||||
pytest.skip("BIFROST_VK is not set")
|
||||
return BifrostEmbedder(
|
||||
os.environ.get("BIFROST_URL", "http://bifrost.apps.svc.cluster.local:8080"),
|
||||
vk,
|
||||
os.environ.get("EMBED_MODEL", "llamaswap/qwen3-embedding-4b"),
|
||||
int(os.environ.get("EMBED_DIM", "2560")),
|
||||
)
|
||||
|
||||
|
||||
async def test_the_model_answers_with_the_configured_dimension(embedder):
|
||||
info = await embedder.selftest()
|
||||
assert info["dim"] == embedder.dim, (
|
||||
f"{embedder.model} returned dim {info['dim']}, expected {embedder.dim}"
|
||||
)
|
||||
await embedder.aclose()
|
||||
|
||||
|
||||
async def test_a_bare_alias_without_the_provider_prefix_fails(embedder):
|
||||
"""§14.14: the single most likely first-deploy mistake, made loud."""
|
||||
bare = BifrostEmbedder(embedder.base_url, embedder.api_key, embedder.model.split("/")[-1], embedder.dim)
|
||||
with pytest.raises(EmbeddingError):
|
||||
await bare.embed(["test"])
|
||||
await bare.aclose()
|
||||
await embedder.aclose()
|
||||
|
||||
|
||||
async def test_pooling_looks_like_last_token_not_mean(embedder):
|
||||
"""Mean pooling makes a sentence and its reversal nearly identical; last-token
|
||||
pooling does not. A wrong --pooling silently degrades retrieval (§14.1)."""
|
||||
a = "Bifrost fronts llama-swap on the evo-x2 box"
|
||||
b = " ".join(reversed(a.split()))
|
||||
v = await embedder.embed([a, b])
|
||||
cos = float(np.dot(v[0], v[1]))
|
||||
assert cos < 0.98, f"cosine {cos:.4f} between a sentence and its reversal — check --pooling last"
|
||||
await embedder.aclose()
|
||||
|
||||
|
||||
async def test_documents_and_queries_are_embedded_differently(embedder):
|
||||
text = "Welcher Router verteilt meine lokalen Modelle?"
|
||||
doc = await embedder.embed([text], is_query=False)
|
||||
query = await embedder.embed([text], is_query=True)
|
||||
assert float(np.dot(doc[0], query[0])) < 0.9999, (
|
||||
f"the instruction prefix had no effect — is {QUERY_INSTRUCT!r} reaching the model?"
|
||||
)
|
||||
await embedder.aclose()
|
||||
|
||||
|
||||
async def test_the_same_text_twice_returns_the_same_vector(embedder):
|
||||
"""A semantic cache in front of /v1/embeddings would break this the other way
|
||||
round — a *changed* text returning a stale vector (§14.13). This catches the
|
||||
coarse version: non-deterministic embeddings."""
|
||||
a = await embedder.embed(["Longhorn liefert die Volumes"])
|
||||
b = await embedder.embed(["Longhorn liefert die Volumes"])
|
||||
assert float(np.dot(a[0], b[0])) > 0.999
|
||||
c = await embedder.embed(["Longhorn liefert die Volumes."])
|
||||
assert float(np.dot(a[0], c[0])) < 1.0, "a changed text returned an identical vector — stale cache?"
|
||||
await embedder.aclose()
|
||||
@@ -69,3 +69,28 @@ async def test_service_own_write_does_not_double_index(svc):
|
||||
await asyncio.sleep(0.9) # let the watcher see our own write
|
||||
rows = svc.db.read().execute("SELECT count(*) FROM chunks WHERE note_id=?", (res["id"],)).fetchone()[0]
|
||||
assert rows == 1
|
||||
|
||||
|
||||
async def test_a_handwritten_file_without_an_id_is_repaired_by_the_watcher(svc):
|
||||
"""Creating a note by hand in a running vault is the expected workflow; the
|
||||
watcher must assign the ULID, not just log a failure until the next scan."""
|
||||
p = svc.vault.root / "active" / "spontane-notiz.md"
|
||||
p.write_text(
|
||||
"---\ntitle: Spontane Notiz\ntype: note\n---\nIch wurde direkt in den Vault geschrieben.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
found = None
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + 3.0
|
||||
while loop.time() < deadline:
|
||||
r = await svc.search("direkt in den Vault geschrieben", k=5)
|
||||
if r.results:
|
||||
found = r.results[0]
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
assert found is not None, "hand-written note never became searchable"
|
||||
assert found.title == "Spontane Notiz"
|
||||
assert len(found.id) == 26
|
||||
assert not p.exists() # renamed to slug--ULID.md
|
||||
Reference in new issue
Block a user