Skip to content

fix(repair): preserve embeddings on rebuild + cap divergence-floor - #1367

Open
zhapostolski wants to merge 46 commits into
MemPalace:developfrom
zhapostolski:fix/repair-embed-and-divergence-threshold
Open

fix(repair): preserve embeddings on rebuild + cap divergence-floor#1367
zhapostolski wants to merge 46 commits into
MemPalace:developfrom
zhapostolski:fix/repair-embed-and-divergence-threshold

Conversation

@zhapostolski

Copy link
Copy Markdown

Problem

Two bugs discovered when a 94k-drawer palace required emergency manual repair after HNSW drift:

1. _extract_drawers omits embeddings → hours of unnecessary recomputation

_extract_drawers only fetched documents and metadatas, then called upsert() without embeddings. ChromaDB silently recomputed all embeddings from scratch on every rebuild. For a 94k-drawer palace on CPU this took 4+ hours and caused heavy swap pressure (process peaked at 1.9 GB RSS, 625% CPU, swap nearly exhausted).

Fix: Include "embeddings" in the col.get() call and pass them to upsert(). Falls back to recomputation when the source HNSW is unreadable (the exact scenario being repaired).

2. Divergence-floor exceeds collection size when hnsw:sync_threshold is large → repair-status reports OK on 47% drift

PR #1191 set hnsw:sync_threshold = 50_000. This made divergence_floor = 2 × 50_000 = 100_000. For a 94k-drawer palace the 10%-fraction cap (9_459) is never reached since max(100_000, 9_459) = 100_000, so repair-status reported OK even with 44,592 drawers (47%) missing from the HNSW.

Fix: Cap divergence_floor at _HNSW_DIVERGENCE_FLOOR_CAP = 10_000 regardless of sync_threshold. Also tighten _HNSW_DIVERGENCE_FRACTION from 0.100.05 so that a 5%-or-greater drift is always flagged.

Behavior after fix

Scenario Before After
Rebuild 94k drawers (HNSW readable) ~4h CPU (re-embeds all) ~2min (copies stored embeddings)
Rebuild 94k drawers (HNSW unreadable) ~4h CPU ~4h CPU (expected fallback)
repair-status with 47% drift, sync_threshold=50k OK ❌ DIVERGED ✓
repair-status with normal flush-lag (<10k), sync_threshold=50k OK ✓ OK ✓

Test plan

  • pytest tests/test_repair.py tests/test_backends.py — no new failures introduced (10 pre-existing failures unrelated to these changes remain)
  • Rebuild smoke: mempalace repair on a small test palace — should print "Re-using stored embeddings" and complete in seconds
  • repair-status on a palace where HNSW < SQLite by >10k — should report DIVERGED

🤖 Generated with Claude Code

bensig and others added 30 commits April 20, 2026 11:46
release: v3.3.3 — sync develop → main for tag cut
Bumps every version source from 3.3.3 to 3.3.4:
- pyproject.toml
- mempalace/version.py (canonical)
- .claude-plugin/plugin.json
- .claude-plugin/marketplace.json
- .codex-plugin/plugin.json
- README.md badge

Dates the CHANGELOG section and adds entries for the bug fixes that
landed this cycle (MemPalace#1135, MemPalace#1191, MemPalace#1230, MemPalace#1231) plus expands the MemPalace#1194
entry to credit the lookup-side recovery path from MemPalace#1197.

Pre-tag verification:
- 1441 passed, 1 skipped (full suite minus benchmarks, all platforms)
- ruff check + format clean
- 44/44 in test_version_consistency + test_readme_claims (6-file sync)
- JPH invariant: pyproject.toml + .claude-plugin/plugin.json both
  reference mempalace-mcp
- Wheel build + fresh-venv install: mempalace --version reports 3.3.4,
  mempalace-mcp --help works (catches the v3.3.2-class regression)
Agent-Logs-Url: https://github.com/MemPalace/mempalace/sessions/01a1089d-da46-4dc8-85e8-d7e50763dd58

Co-authored-by: igorls <4753812+igorls@users.noreply.github.com>
The fix landed this cycle and is documented under 3.3.4. The 3.3.0
Bug Fixes block is shipped history and shouldn't grow new entries
retroactively.
…entry

The PR documenting the fix is MemPalace#1232; referencing it from inside its
own changelog entry is circular.
…MemPalace#1288

Three fixes landed on develop after the initial release-prep cut and
were brought in via the develop merge. Document them in the 3.3.4
Bug Fixes section so the release notes reflect what users will
actually receive.

- MemPalace#1287 - HNSW divergence floor scales with hnsw:sync_threshold
  (resolves a silent-fallback regression introduced by the
  interaction between MemPalace#1191 and MemPalace#1227 in this release)
- MemPalace#1262 - ChromaBackend get_or_create_collection split, fixing the
  stop-hook SIGSEGV class on legacy palaces with mismatched stored
  metadata (MemPalace#1089)
- MemPalace#1288 / MemPalace#1254 - repair --mode max-seq-id heuristic now decodes
  BLOB-typed embeddings.seq_id, restoring the un-poison path added
  in MemPalace#1135 for palaces where chromadb 1.5.x writes seq_ids natively
The release was originally cut on 2026-04-27 but did not tag that day.
Three additional bug fixes have been folded in since then (MemPalace#1262,
MemPalace#1287, MemPalace#1288) and the actual tag will happen on 2026-04-30. Update
the header date to match.
…MCP server companion

The same try/except split that MemPalace#1262 applied at the backend layer
(ChromaBackend.get_collection) was needed at the parallel call site
in mcp_server._get_collection(create=True), which carries the same
metadata payload directly to chromadb's Python client. Both reopen
paths in mempalace now bypass get_or_create_collection on existing
collections, closing the SIGSEGV class for both tool_add_drawer
and tool_diary_write (the Stop hook's path).
A triple with valid_to < valid_from satisfies neither of the temporal
filter clauses in query_entity():

    valid_from <= as_of AND valid_to >= as_of

so the triple is invisible to every query — silently corrupt. Reject
at write time with a clear error instead of letting bad data pile up
in the SQLite store.

The guard only fires when both bounds are present; open intervals
(only valid_from or only valid_to) are still accepted, and same-day
intervals (valid_from == valid_to, point-in-time facts) are explicitly
allowed.
…rash

EntityRegistry.save() called Path.write_text() directly, which truncates
the target file and then writes — so a crash mid-write (power loss, OOM,
filesystem-full mid-flush) leaves an empty or half-written
entity_registry.json. The whole people/projects map is lost; the system
falls back to an empty registry on next load.

Switch to the standard atomic-write pattern: serialize to a sibling
.tmp file in the same directory (so os.replace stays on one filesystem),
fsync, chmod 0o600, then os.replace over the target. The replace is
atomic on POSIX and Windows, so any crash leaves the previous registry
intact instead of a truncated file.

Tests cover: no leftover .tmp on success, and previous content preserved
when os.replace itself raises mid-save.
Without this, on ext4 (and similar) filesystems the rename ack does not
guarantee durability across power loss — a crash can revert to a state
where the temp file is present and the target is at the old version.

Suggested by @jphein on MemPalace#1215.
…nflation

Repeated upserts to the mempalace_compressed collection across runs
cause the HNSW link_lists.bin sparse file to grow without GC,
eventually filling the disk (observed: 1.7 TB physical, 17 TB logical,
on macOS ARM with chromadb 1.5.8).

Drop and recreate the collection at the start of each compress run so
the HNSW index is rebuilt from scratch each time. Re-vectorizing ~10K
embeddings costs a few minutes on the local ONNX backend; far cheaper
than risking TBs of disk.

The miner code already does the equivalent (delete-by-source_file
before re-insert, see miner.py:718) for the same hnswlib behavior.
This brings the compress path in line.

Related: MemPalace#1092
… boundary

Add validate_iso_date() to config.py and apply it at the MCP boundary
in tool_kg_query (as_of), tool_kg_add (valid_from), and
tool_kg_invalidate (ended).

Previously, non-ISO date strings like 'March 2026' or 'Jan 2025' were
forwarded to SQLite without validation, silently producing empty result
sets instead of matching facts. Now the MCP tools reject malformed dates
early with a clear error message.

Fixes MemPalace#1164
The validate_iso_date function uses PEP 604 union syntax (str | None)
which requires Python 3.10+ at runtime. Adding future annotations import
makes it work on the project's minimum supported Python 3.9.

Addresses Qodo-Free-For-OSS review comment.
Replace  with  in validate_iso_date() to
avoid potential SyntaxError on Python 3.9, even though  is present. Some type-checking tools and runtime
introspection may still evaluate annotations.

Also add 11 new edge-case tests:
- Leap year / non-leap year Feb 29
- Rejected formats: slash, dot, datetime, month 0, day 0
- Non-string input handling
- Param name in error messages

Ref: Qodo review on MemPalace#1283
MemPalace#976 protects `mempalace mine`, but MCP/direct backend writers still call
ChromaCollection.add/upsert/update/delete without the palace lock. This
moves the lock boundary to the Chroma backend seam so all Chroma writes
share the same palace-level serialization, with a re-entrant guard for
miner paths that already hold the lock.

mine_palace_lock(palace_path) gains a per-thread re-entrant guard
(threading.local + pid-tag against fork inheritance) so
ChromaCollection write methods can take the lock without
self-deadlocking when called from inside miner.mine()'s outer hold.

ChromaCollection.__init__ accepts an optional palace_path; when set,
add/upsert/update/delete wrap their underlying chromadb call with
mine_palace_lock(palace_path). palace_path=None preserves the legacy
no-lock behaviour for direct callers and tests. ChromaBackend's
get_collection/create_collection pass palace_path through;
mcp_server._get_collection forwards _config.palace_path so all MCP
write tools inherit the wrapping.

Tests: 5 new in tests/test_chroma_collection_lock.py covering opt-in,
writer-blocks-during-mine, re-entrant-inside-mine, two-process
serialization, and a source-level read-path-not-locked pin. Plus 1 new
+ 1 rewritten in tests/test_palace_locks.py for the re-entrant
semantics. 52 passed in 1.01s including the existing test_backends.py
regression suite.

Refs MemPalace#1161.
Rollback cleanup was instantiating a fresh ChromaBackend, so the live backend that had opened the PersistentClient could keep file handles alive during restore. Close the active backend instance instead so rollback and CLI recovery can release Windows-safe locks before copying the backup back into place.
…lace#1299)

`mcp_server._get_collection` bypassed `ChromaBackend.get_collection`
and called `client.get_collection` / `client.create_collection` without
`embedding_function=`. ChromaDB 1.x does not persist the EF identity
with the collection, so the MCP server's reopen silently bound
chromadb's built-in `DefaultEmbeddingFunction` while the miner / Stop
hook ingest path bound `mempalace.embedding.get_embedding_function()`.

On bleeding-edge interpreters (python 3.14 + chromadb 1.5.x on Apple
Silicon, per MemPalace#1299) the default EF's lazy ONNX provider selection could
SIGSEGV the host process on first `col.add()`, killing the MCP stdio
server and leaving every subsequent tool call returning
`Connection closed` until Claude Code was relaunched. Reads worked
because `col.get(ids=...)` and metadata fetches don't invoke the EF;
the auto-ingest path worked because mining routes through the backend
abstraction. Diary writes were the consistent failure surface.

Resolve the EF up front (matching `ChromaBackend._resolve_embedding_function`)
and pass it into both reopen branches. Falls back to the chromadb default
only if `mempalace.embedding.get_embedding_function` itself raises.

Regression test patches the chromadb client class to capture
`embedding_function=` on every `get_collection` / `create_collection`
call from `_get_collection(create=True)` and `_get_collection()`, and
fails if any call omits it.

Follow-up to MemPalace#1262 / MemPalace#1289 (which fixed the metadata-mismatch SIGSEGV
path); this addresses the EF-mismatch SIGSEGV path on the same surface.
…emPalace#1314)

`tool_kg_add` previously accepted only `valid_from` and `source_closet`,
silently dropping `valid_to`, `source_file`, and `source_drawer_id` at
the MCP boundary. Backfilling already-ended historical facts therefore
collapsed to "still current," and adapter provenance never reached
the SQLite layer even though `KnowledgeGraph.add_triple` already
supported every column.

`tool_kg_invalidate` returned the literal string `"today"` whenever the
caller omitted `ended`, hiding the actual stamped date from anyone trying
to verify what got persisted.

Changes:
- Extend `tool_kg_add` signature + MCP input_schema with `valid_to`,
  `source_file`, `source_drawer_id`; forward all of them to
  `_kg.add_triple` and to the WAL log.
- Resolve `ended` to `date.today().isoformat()` in `tool_kg_invalidate`
  before logging / returning, so the response always reports the actual
  date stored in `valid_to`.
- Add regression tests for valid_to round-trip, source_file /
  source_drawer_id provenance, and the resolved-ended-date contract.
- Leave TODO(MemPalace#1283) markers so the open ISO-8601 validation PR can drop
  `validate_iso_date` over `valid_from` / `valid_to` / `ended` cleanly.

The underlying `KnowledgeGraph.add_triple` already accepted these
kwargs (RFC 002 §5.5) — only the MCP edge needed wiring up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…alace#1243)

`tool_diary_write` stored the `agent` metadata verbatim after `sanitize_name`
(which preserves case), while `tool_diary_read` filtered by exact match —
so writing as "Claude" and reading as "claude" silently returned zero rows.

Both endpoints now lowercase `agent_name` immediately after sanitization.
The default per-agent wing slug is also stable across casings since it's
derived from the same normalized form.

Behavior change: entries written prior to this fix under mixed-case agent
names will not match the new lowercase filter; documented under v3.3.5
in CHANGELOG with a `mempalace repair` pointer.

Adds a regression test (`test_diary_read_case_insensitive_agent`) and
updates the existing `test_diary_write_and_read` to assert the new
lowercase agent identity.

Closes MemPalace#1243

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mvalentsev and others added 15 commits May 3, 2026 20:41
- Case-insensitive directory match (d.lower() == 'subagents') so
  the filter still kicks in if Claude Code or a plugin ever emits
  'Subagents/' on case-preserving filesystems (Windows, macOS APFS).
- Drop defensive getattr in cmd_mine: argparse always defines the
  attribute since --include-subagents is unconditionally registered.
  Direct args.include_subagents access matches every neighbouring
  field and fails loudly if the registration is ever removed.
- Soften CLI help and docstring: drop the 80:1 ratio (reporter-
  specific) and the in-code (MemPalace#1217) reference. Add explicit
  default=False on the argparse flag for symmetry with --extract.
- Add 2 negative tests: 'mysubagents'/'subagentsbackup' must still
  be mined (regression guard against substring-match), and
  'Subagents/' must be skipped (case-insensitive coverage).
layers.py (wake-up) always opens knowledge_graph.sqlite3 from the
palace directory. The MCP server previously fell back to
~/.mempalace/knowledge_graph.sqlite3 when --palace was not passed,
causing a split: kg_add wrote to the root KG while session wake-up
read the palace KG. New KG facts never appeared in the next session's
context.

Fix: always resolve the KG path from _config.palace_path, which
already reflects --palace, MEMPALACE_PALACE_PATH, or the default from
config.json. The --palace arg still controls the palace path; the
conditional around it was only needed for the now-removed special case.
Previously hook_session_start was a no-op stub. Now it:
- Parses cwd + source from the harness input
- Matches cwd basename to a known wing via tool_status()
- Builds additionalContext from MemoryStack.wake_up() + a protocol nudge
  telling the LLM to kg_query/search before responding and add_drawer/kg_add
  when making decisions
- Returns Claude-Code-compatible {hookSpecificOutput.additionalContext}
  shape; harnesses that ignore the key see a no-op

This makes SessionStart actually useful: the LLM gets ~700 tokens of
project context on every session start instead of an empty {}.

Tests: replaced the pass-through assertion with two cases (context
injected when palace resolvable, no-op when MempalaceConfig fails).
All 121 existing tests still pass.
Adds _mirror_local_memory() called from hook_stop on every fire.
Scans default memory locations (~/.claude/projects, ~/.codex/memories,
~/.gemini/memory, ~/.qwen/memory) for *.md files; for each new or
modified file, calls tool_add_drawer with wing derived from the
project slug and room from the filename stem.

Why: even with the SessionStart protocol nudge, models (notably
Claude Code) reflexively write to their per-CLI auto-memory dirs
instead of mempalace MCP tools. This makes mempalace a guaranteed
mirror of those .md files regardless of LLM cooperation.

Behavior:
- Idempotent via {path: mtime} state in ~/.mempalace/hook_state/mirror_state.json
- Skips index files (MEMORY.md, CLAUDE.md, GEMINI.md, QWEN.md, AGENTS.md)
- Skips empty files (still records mtime so we don't keep re-reading)
- Records duplicates (mempalace dedupe) as "seen" to avoid retry loops
- Wing slug parser: -home-...-projects-<wing> → <wing> (Claude convention),
  else first directory after the root
- All errors caught — never blocks the harness
- Disable via MEMPAL_MIRROR_DISABLED=1
- Override roots via MEMPAL_MIRROR_ROOTS=path:path:...

Verified: real run on 312 .md files added 248 new drawers (rest were
chromadb dupes or empty). Second run: 0 added, 312 skipped_unchanged.
All 128 tests pass (8 new mirror tests).
The save and precompact wrappers ran via bare 'python3', which resolves
to whatever interpreter is on PATH. From a CLI launched in a clean shell
(or any shell without the right venv activated), python3 is system
python — which lacks chromadb and other mempalace deps. The hooks
silently degraded: hook_session_start would no-op, and the new
_mirror_local_memory() would log
  "mirror: cannot import tool_add_drawer (No module named 'chromadb')"
and skip the entire mirror, defeating Option-2 dual-write.

Fix: each wrapper now resolves $MEMPAL_PYTHON, defaulting to
$HOME/.mempalace/venv/bin/python (the canonical install location), and
falls back to plain python3 if that doesn't exist. All three wrappers
(save, precompact, session-start) follow the same pattern so they
behave identically across Claude / Codex / Gemini / Qwen.

Verified end-to-end from a stripped env (env -i PATH=/usr/bin:/bin):
- save_hook returns {} cleanly with no chromadb errors in hook.log
- precompact_hook returns {"decision":"allow"}
- session_start_hook returns Claude-shape additionalContext with
  wing-matched wake-up text
- mempal_enrich_knowledge.sh: enriches KG with session insights post-stop
- mempal_verify_knowledge.sh: validates KG entries for consistency
- mempal_semantic_search.sh: CLI wrapper for semantic search from hooks
- mempal_opencode_hook.sh: Stop hook adapter for OpenCode harness
- mempal_opencode_simple.sh: lightweight OpenCode hook (no KG enrichment)
- mempal_save_hook_throttled.sh: rate-limited save wrapper (Python handles intervals)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ommit

The fix(hooks) commit that corrected CLI entry points and harness
detection inadvertently replaced normalize.py with a truncated version
(207 lines vs the correct 676). Restore from origin/main verbatim.

No functional change to normalize.py — only the hooks_cli.py content
from that commit was intentional.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…apes

Claude Code wraps every slash-command invocation in a five-tag envelope
(<local-command-caveat>, <command-name>, <command-message>, <command-args>,
<local-command-stdout>). strip_noise() previously covered only command-name
and command-message; the other three survived into stored drawers as tag
remnants like "<command-args></command-args>" or whole stdout payloads.

Bash tool_result content captured by Claude Code preserves ANSI color codes
verbatim. These pass through strip_noise() untouched, bloating embeddings
(each escape is several BPE tokens) and garbling semantic search.

Extend _NOISE_TAGS with the missing three, and add CSI + OSC ANSI strippers
applied after tag removal in strip_noise(). Each ANSI pattern is anchored on
the literal ESC byte so user prose that mentions e.g. "[1m]" by name stays
intact — verbatim-safety preserved per the existing design.

Tests: add coverage for each new tag, the full slash-command envelope, the
empty <command-args></command-args> shape, ANSI CSI / truecolor / cursor /
OSC-title / OSC-hyperlink sequences, ANSI inside a noise tag (no double-strip
needed), and two preservation tests for user prose that documents these
constructs by name.

Closes MemPalace#1333
…hromadb version mismatch

When an HNSW segment has a mtime gap > 7200s (2 h), quarantine it
regardless of the metadata sniff-test result. ChromaDB flush-lag is
measured in seconds; a 2+ hour gap means the segment was written by a
different process/version. Observed: chromadb 0.6.x segfaults loading
segments whose metadata passes the pickle format check but whose binary
layout is incompatible with the current runtime.

The original directory is renamed (not deleted), so manual recovery
is still possible if the heuristic misfires.

Includes cherry-pick of MemPalace#1334:
fix(normalize): strip Claude Code local-command tags and ANSI escapes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… timeout to 60s

SessionStart now fires a background ChromaDB get_collection so the HNSW
index and ONNX embedding model are loaded before the first Stop hook fires.
Eliminates the 7 cancelled Stop hooks (30s cold-start timeout) observed in
the ananas-os session.

Separately: ~/.claude/settings.json hook timeouts bumped 30→60s so a slow
cold ChromaDB start still completes rather than being cancelled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- _ingest_transcript: check _mine_already_running() before spawning + register
  PID in _MINE_PID_FILE so subsequent Stop hook fires see the running mine
- hook_stop (silent mode): skip diary save when mine is running; don't advance
  last_save counter so next Stop hook retries — eliminates 'another mine
  already running' failures seen in production hook.log
- hook_precompact: change default mode from 'proceed' (no-op for claude-code
  and gemini) to 'block_once', so pre-compaction saves happen for all harnesses
  without infinite-loop risk (flag keyed by session_id)
- _auto_kg_write: new helper that adds session-YYYY-MM-DD → covered_topic →
  theme relationships after each successful silent diary checkpoint
- fix(repair): _detect_poisoned_max_seq_ids now handles BLOB-typed seq_id
  values from ChromaDB 1.5.x (8-byte big-endian uint64); previously crashed
  with ValueError: invalid literal for int()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two bugs found when a 94k-drawer palace required emergency manual repair:

1. **_extract_drawers omits embeddings** — the rebuild passed only
   documents/metadatas to upsert(), forcing ChromaDB to recompute all
   embeddings from scratch.  On CPU this takes hours for large palaces
   and causes heavy swap pressure.  Fix: include \"embeddings\" in the
   get() call and pass them through to upsert(); fall back to
   recomputation when embeddings are unavailable (HNSW unreadable).

2. **Divergence floor ignores _HNSW_DIVERGENCE_FRACTION when
   sync_threshold is large** — PR MemPalace#1191 set hnsw:sync_threshold=50 000,
   making divergence_floor = 2 × 50 000 = 100 000.  For a 94k-drawer
   palace the 10 %-fraction threshold (9 459) is never reached, so
   repair-status reported OK even with 47 % of drawers (44 592) missing
   from the HNSW.  Fix: cap divergence_floor at _HNSW_DIVERGENCE_FLOOR_CAP
   (10 000) so large sync_thresholds cannot make DIVERGED unreachable.
   Also tighten _HNSW_DIVERGENCE_FRACTION from 0.10 → 0.05.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@igorls igorls added bug Something isn't working area/cli CLI commands storage labels May 6, 2026
@messelink

Copy link
Copy Markdown
Contributor

Thanks for catching this — the 94K-with-100K-floor blind spot is real and your reproducer is convincing. The _extract_drawers embeddings fix is also clearly correct and independent of the threshold question; that part I'd land on its own without hesitation.

On the threshold change, one concern worth raising about the opposite-direction blind spot the cap re-creates for sync_threshold=50_000 palaces under steady write load:

I hit this from the other side back on a 98K palace right after rebuilding via the same extract-from-sqlite path you're now formalizing. Right after rebuild, chromadb had flushed exactly one window (50K of 98K) to HNSW; the remaining 48K sat in embeddings_queue waiting for the next flush trigger. Under #1287's floor = 2 × sync_threshold rule, the 48K stayed under the 100K floor → OK. Under your proposed floor = min(2 × sync_threshold, 10_000) = 10_000, the same 48K → DIVERGED. That's the original _refresh_vector_disabled_flag BM25-fallback regime PR #1287 was specifically trying to escape. The precondition (sqlite well past one flush window, queue holding up to one full window of pending writes) is the normal case for any actively-mined palace on the bloat-guard config — not just immediately post-rebuild.

The hard part is that your 47%-actually-missing case and the 48%-queued-for-flush case look identical to a count-only probe. Both have ~half the drawers absent from the HNSW pickle. The signal that distinguishes them lives in embeddings_queue: real corruption usually shows queue depth that's stuck (not draining), or gaps that don't correspond to missing flush events; flush-lag shows queue depth that fluctuates and resets after each flush.

Couple of shapes worth considering instead of a hard 10K cap:

  • Cap relative to palace size, not absolute: floor = min(2 × sync_threshold, sqlite_count // 2) — the floor can never exceed half the palace, so a 94K palace never accepts a 100K floor; but a 500K palace with sync_threshold=50_000 still tolerates one full flush window of lag.
  • Queue-state probe: read MAX(seq_id) FROM embeddings_queue against the segment's max_seq_id row. The difference is the actual unflushed-write count, distinct from sqlite_count - hnsw_count which conflates lag with corruption. More targeted, but pulls in another file path.
  • Time-bounded floor: record a "last successful flush" timestamp; tolerate divergence ≤ sync_threshold only when that timestamp is recent (e.g. < N minutes). After that, the queue is suspect even at moderate sizes. Adds state, requires a place to store it.

Would any of these address the case you hit while leaving the 50K-window steady-state alone? Happy to test against the 98K palace if it helps — both the pre-rescue corrupted snapshot and the post-rebuild healthy one are still on disk, which gives concrete inputs for both sides of the boundary.

(As the person who hit the original false-positive that led to #1287, I have a vested interest in the floor not collapsing back to 10K — but the underlying concern about false positives during normal flush cycles is independent of that history.)

jphein added a commit to techempower-org/mempalace that referenced this pull request May 13, 2026
…MemPalace#1367 (embeddings portion only)

Cherry-picks the `_extract_drawers` embeddings-preserve fix from upstream
PR MemPalace#1367 (@zhapostolski), per the cherry-pick evaluation in
#31 and @messelink's review guidance: take the
embeddings fix, SKIP the divergence-floor cap (which would re-create
_refresh_vector_disabled_flag false-positives on our sync_threshold=
50000 palace per MemPalace#1287's design).

What the embeddings-preserve fix does:
- _extract_drawers now includes "embeddings" in the col.get() call
- Returns a 4-tuple: ids, docs, metas, embeddings (or None when any
  embedding slot is missing — HNSW unreadable case)
- _rebuild_collection_via_temp accepts an all_embeddings= kwarg and
  passes embeddings through to upsert() when available
- rebuild_index threads embeddings from _extract_drawers into
  _rebuild_collection_via_temp

When embeddings ARE available (the common case), rebuild skips ONNX
recomputation entirely. On a 150K+ palace this cuts rebuild from hours
to minutes. Fallback to recompute when any embedding is None (the bug
condition we're trying to repair).

Updates two call sites in mempalace/cli.py (the cmd_repair path) and
mempalace/repair.py (the rebuild_index path) for the new 4-tuple
signature.

Adds 3 tests:
- test_extract_drawers_preserves_embeddings_when_all_present
- test_extract_drawers_falls_back_to_none_when_any_embedding_missing
- test_extract_drawers_embeddings_absent_in_batch_signals_recompute

Plus updates the existing 5 _extract_drawers tests to unpack the new
4-tuple. All 75 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@jphein

jphein commented May 13, 2026

Copy link
Copy Markdown
Collaborator

Hi @zhapostolski — pulled the embeddings-preserve portion of this PR into our fork at jphein/mempalace@aa84fa4 after the cherry-pick evaluation at jphein/mempalace#31.

Followed @messelink's split-recommendation: take the _extract_drawers embeddings fix (clearly correct, independent), skip the divergence-floor cap (would re-create the false-positive _refresh_vector_disabled_flag BM25-fallback regime that #1287 was added to fix — on our sync_threshold=50000 palace, divergence_floor=10000 flags DIVERGED during routine flush-window operation).

Field data on the embeddings fix (151K-drawer palace, hook-driven writes, daemon-served chromadb 1.5.9):

  • Pre-fix: _extract_drawers omitted embeddings → rebuild had to recompute every vector through ONNX. Observed multi-hour rebuild times even before today's blocker.
  • Post-fix (with your change): rebuild reuses stored vectors when available; falls back to recompute when any embedding is None (the actual bug-recovery scenario).

The fix is clean and the metadata-sanitization fork-side addition (#1458) composes cleanly with it. Looking forward to seeing this merge upstream once the divergence-cap question settles separately.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/cli CLI commands bug Something isn't working performance Performance improvements storage

Projects

None yet

Development

Successfully merging this pull request may close these issues.