Drawer/closet/triple IDs built by concatenating strings without a delimiter before hashing can collide: hash((s1 + str(i1))) == hash((s2 + str(i2))) whenever s1+str(i1) == s2+str(i2). Concretely, source_file="foo" + chunk_index=12 produces the same hash input as source_file="foo1" + chunk_index=2 — both yield "foo12". Under ChromaDB's primary-key constraint the second write silently overwrites the first; one chunk's content is lost without surfacing an error (the surrounding broad `except Exception:` blocks swallow any backend complaint). The defect class is "string + string concat fed to a hash without a delimiter." Per the styleguide's partial-scope-key-migration rule, every site sharing the pattern is a candidate and must be triaged — not just the ones the original issue named. FIX — 6 sites - mempalace/miner.py:1253 drawer_id, add_drawer() back-compat - mempalace/miner.py:1386 drawer_id, batched mine loop - mempalace/miner.py:1416 drawer_ids, closet-build re-derivation - mempalace/format_miner.py:643 drawer_id, format_miner batched loop - mempalace/mcp_server.py:1136 drawer_id, tool_add_drawer - mempalace/knowledge_graph.py:305 triple_id, KG triple insertion MIGRATED FOR CONSISTENCY — 2 sites - mempalace/convo_miner.py:87 sentinel_key — was `:`, now `|` - mempalace/convo_miner.py:422 drawer_key — was `:`, now `|` Pre-existing delimiter was `:`. Migrated because (a) source_file can contain literal `:` on Windows paths (`C:\Users\…`) and URL-like sources (`https://host:8080`), weakening `:` as a separator; (b) `|` is reserved in Windows filenames so source_file cannot contain it, making it strictly safer. DELIMITER CHOICE - `|` chosen over `:` (Windows / URL source_file edge cases) - `|` chosen over `\x00` (NUL breaks log greppability + print() debug + downstream string handling for marginal extra safety) - Matches the established precedent in mempalace/diary_ingest.py (lines 52, 76, 91, 98 — all already on `|`) EXEMPT — audited and correct as-is Single-input hashes (nothing to delimit): - mempalace/miner.py:1432 closet_id (source_file only) - mempalace/format_miner.py:559 sentinel_id (source_file only) - mempalace/palace.py:433 lock filename (source_file only) - mempalace/palace.py:629 palace_key (lock_key_source only) - mempalace/diary_ingest.py:158 content_hash (text only) - mempalace/hooks_cli.py:329 pidfile digest (joined cmd only) - mempalace/sources/context.py:141 record digest (source_file only) Already correctly delimited: - mempalace/hallways.py:157 `f"{wing}::{a}::{b}"` (`::`) - mempalace/palace_graph.py:454 `f"{a}↔{b}"` (`↔`) - mempalace/diary_ingest.py:52,76,91,98 (`|` precedent) Protected by composition (uniqueness guaranteed by the ID prefix, not by the hash slice): - mempalace/mcp_server.py:1635 entry_id is `diary_{wing}_{strftime('%Y%m%d_%H%M%S%f')}_{sha[:12]}`. Microsecond-resolution timestamp prefix supplies uniqueness; the trailing hash is a content-discriminator, not the write-time uniqueness guarantor. NEW MODULES - mempalace/ids.py — single source of truth for ID construction. Five helpers (make_drawer_id_from_chunk, make_drawer_id_from_content, make_convo_drawer_id, make_convo_sentinel_id, make_triple_id) plus an ID_RECIPE = "v2" constant. - mempalace/collision_scan.py — pre-mining defense. Runs immediately before each batched ChromaDB upsert; raises CollisionError naming the colliding (source_file, chunk_index) pairs if any proposed drawer_id appears more than once with conflicting metadata across the union of incoming and existing rows. DETECTION ChromaDB's primary-key constraint means past collisions were silently resolved at upsert time (last-write-wins), erasing evidence. A post-hoc audit cannot reconstruct what was lost from palace state alone. Therefore: - Pre-mining risk scan. Before each batched upsert, compute the proposed drawer_ids for the incoming chunk set AND query existing drawer_ids from the collection. If any proposed id appears more than once in the union (incoming-vs-incoming or incoming-vs- existing) with conflicting (source_file, chunk_index), abort the mine with an actionable error naming the colliding pairs. Collision is caught BEFORE it destroys data, which is the only point at which palace state still carries the evidence. - New metadata key: `"id_recipe": "v2"` on every drawer written under the delimited recipe. Audits compare like-for-like; drawers without `id_recipe` are treated as v1 legacy (undelimited or `:`-delimited), not as collisions. - Honest disclosure: palaces mined under any pre-v2 mempalace may carry silent past collisions whose original content is unrecoverable from palace state. Future library tier work will give users a per-drawer audit + opt-in archival path. TESTS - tests/test_ids.py: 17 unit tests covering all 5 helpers, the ID_RECIPE constant, the private `_delimited_sha256` helper, and the four defect-class collision shapes (chunk_index boundary, content boundary, extract_mode boundary, ISO datetime boundary). RED collision tests confirm the v2 recipe breaks the defect class. - tests/test_collision_scan.py: 10 tests covering clean batches, idempotent re-mines, incoming-vs-incoming collisions, incoming-vs- existing collisions, error-message quality, empty batches, metadata without chunk_index, and ChromaDB backend errors propagating cleanly. - tests/test_convo_miner_unit.py: FakeCol gains a stub `get()` so the pre-mining scan can probe an empty in-test collection. BACKWARDS COMPATIBILITY - Existing v1 drawers remain queryable; no rewrite of stored IDs. - New mining passes write v2 drawers alongside v1 via PR #1628's additive-mining model. - No user action required; opt-in cleanup ships separately. VERIFICATION - macOS Python 3.12 (local): 2304 passed, 3 skipped, 0 failed, coverage 85.44% (above 80% threshold). - Linux Python 3.9/3.11/3.13 (OrbStack with full `pip install -e '.[dev]'` flow): see PR body for per-version pass counts. - `ruff check .` + `ruff format --check .`: clean. - pylint score: ids.py 10.00/10, collision_scan.py 10.00/10; all modified files >= 8.92 (no regression). - bandit: clean on all new files; the one MEDIUM finding in knowledge_graph.py is on lines 385/407 (pre-existing SQL string construction in timeline queries), not in code touched by this PR. - pre_push_check.sh: ALL CHECKS PASSED. Refs: deferred from PR #1572 (Copilot finding #13). Styleguide audit documented above: 8 fix/migration + 11 enumerated exempt sites.
229 lines
8.9 KiB
Python
229 lines
8.9 KiB
Python
"""Tests for mempalace.collision_scan — pre-mining defense against
|
|
drawer_id collisions.
|
|
|
|
The scan runs immediately before batched chromadb upserts and aborts the
|
|
mine with an actionable error if any proposed drawer_id appears more than
|
|
once in the union of (incoming-vs-incoming) and (incoming-vs-existing).
|
|
|
|
Under the v2 hash recipe these collisions are vanishingly rare in practice
|
|
— SHA-256 truncated to 24 hex chars makes accidental collision ~2^-96.
|
|
The scan's real value is (a) catching upstream bugs that emit duplicate
|
|
(source_file, chunk_index) pairs in the same batch, and (b) surfacing the
|
|
astronomical-but-possible SHA-256 collision with a clear error instead of
|
|
a silent overwrite at the ChromaDB upsert.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Optional
|
|
|
|
import pytest
|
|
|
|
from mempalace.collision_scan import CollisionError, assert_no_collisions
|
|
|
|
|
|
class _MockGet:
|
|
"""Stand-in for ChromaDB's get() result. Real ChromaDB returns a
|
|
dict-like with ``ids`` and ``metadatas`` keys; we mirror that shape."""
|
|
|
|
def __init__(self, ids: list[str], metadatas: list[dict]):
|
|
self._payload = {"ids": ids, "metadatas": metadatas}
|
|
|
|
def __getitem__(self, key):
|
|
return self._payload[key]
|
|
|
|
def get(self, key, default=None):
|
|
return self._payload.get(key, default)
|
|
|
|
|
|
class _MockCollection:
|
|
"""Stand-in for a ChromaDB collection. Stores a fixed mapping of
|
|
drawer_id → metadata for the test to model 'existing palace state'.
|
|
``get(ids=[...])`` returns only the rows whose ids are in storage.
|
|
"""
|
|
|
|
def __init__(self, existing: Optional[dict[str, dict]] = None):
|
|
self._existing = existing or {}
|
|
|
|
def get(self, ids=None, include=None, **kwargs):
|
|
ids = ids or []
|
|
rows = [(did, self._existing[did]) for did in ids if did in self._existing]
|
|
return _MockGet(
|
|
ids=[did for did, _ in rows],
|
|
metadatas=[meta for _, meta in rows],
|
|
)
|
|
|
|
|
|
# ── Happy path: no collisions ────────────────────────────────────────
|
|
|
|
|
|
def test_assert_no_collisions_passes_for_clean_batch():
|
|
"""Distinct incoming ids + no overlap with existing = no error."""
|
|
proposed = [
|
|
("drawer_a", {"source_file": "/file_a.md", "chunk_index": 0}),
|
|
("drawer_b", {"source_file": "/file_a.md", "chunk_index": 1}),
|
|
("drawer_c", {"source_file": "/file_b.md", "chunk_index": 0}),
|
|
]
|
|
col = _MockCollection()
|
|
assert_no_collisions(proposed, col) is None
|
|
|
|
|
|
def test_assert_no_collisions_passes_for_clean_batch_with_existing_drawers():
|
|
"""Existing drawers with DIFFERENT ids than incoming = no error.
|
|
The scan only fires when an id appears more than once across the
|
|
union of incoming + existing."""
|
|
proposed = [
|
|
("drawer_new_1", {"source_file": "/new.md", "chunk_index": 0}),
|
|
]
|
|
col = _MockCollection(
|
|
existing={
|
|
"drawer_old_1": {"source_file": "/old.md", "chunk_index": 0},
|
|
"drawer_old_2": {"source_file": "/old.md", "chunk_index": 1},
|
|
}
|
|
)
|
|
assert_no_collisions(proposed, col) is None
|
|
|
|
|
|
def test_assert_no_collisions_treats_idempotent_re_mine_as_clean():
|
|
"""If incoming drawer_id matches an existing id AND the
|
|
(source_file, chunk_index) metadata also matches, that's a normal
|
|
re-mine of the same chunk — NOT a collision. The scan must let it
|
|
pass; otherwise re-mining a clean palace would always raise."""
|
|
proposed = [
|
|
("drawer_same", {"source_file": "/file.md", "chunk_index": 5}),
|
|
]
|
|
col = _MockCollection(
|
|
existing={
|
|
"drawer_same": {"source_file": "/file.md", "chunk_index": 5},
|
|
}
|
|
)
|
|
assert_no_collisions(proposed, col) is None
|
|
|
|
|
|
# ── Incoming-vs-incoming collisions ──────────────────────────────────
|
|
|
|
|
|
def test_assert_no_collisions_raises_on_incoming_duplicate_with_different_metadata():
|
|
"""Two incoming chunks producing the same drawer_id with DIFFERENT
|
|
(source_file, chunk_index) pairs = an upstream bug or astronomical
|
|
SHA-256 hash collision. Either way, abort the mine."""
|
|
proposed = [
|
|
("drawer_X", {"source_file": "/file_a.md", "chunk_index": 0}),
|
|
("drawer_X", {"source_file": "/file_b.md", "chunk_index": 1}),
|
|
]
|
|
col = _MockCollection()
|
|
with pytest.raises(CollisionError) as exc_info:
|
|
assert_no_collisions(proposed, col)
|
|
# Error message names the colliding (source_file, chunk_index) pairs
|
|
msg = str(exc_info.value)
|
|
assert "drawer_X" in msg
|
|
assert "/file_a.md" in msg
|
|
assert "/file_b.md" in msg
|
|
|
|
|
|
def test_assert_no_collisions_passes_on_incoming_duplicate_with_same_metadata():
|
|
"""Two incoming chunks with the SAME (source_file, chunk_index)
|
|
producing the same drawer_id = a duplicate chunk in the batch (still
|
|
an upstream bug, but the downstream collision damage is zero since
|
|
they'd write identical content). The scan does not fire on this
|
|
case because it's not the collision shape v2 was designed to
|
|
catch."""
|
|
proposed = [
|
|
("drawer_X", {"source_file": "/file.md", "chunk_index": 5}),
|
|
("drawer_X", {"source_file": "/file.md", "chunk_index": 5}),
|
|
]
|
|
col = _MockCollection()
|
|
assert_no_collisions(proposed, col) is None
|
|
|
|
|
|
# ── Incoming-vs-existing collisions ──────────────────────────────────
|
|
|
|
|
|
def test_assert_no_collisions_raises_on_incoming_matching_existing_with_different_metadata():
|
|
"""Incoming chunk produces the same drawer_id as an existing drawer
|
|
whose stored (source_file, chunk_index) DIFFERS = SHA-256 collision
|
|
or recipe-version skew. Abort the mine so the upsert doesn't
|
|
silently overwrite the existing row."""
|
|
proposed = [
|
|
("drawer_Y", {"source_file": "/incoming.md", "chunk_index": 3}),
|
|
]
|
|
col = _MockCollection(
|
|
existing={
|
|
"drawer_Y": {"source_file": "/existing.md", "chunk_index": 7},
|
|
}
|
|
)
|
|
with pytest.raises(CollisionError) as exc_info:
|
|
assert_no_collisions(proposed, col)
|
|
msg = str(exc_info.value)
|
|
assert "drawer_Y" in msg
|
|
assert "/incoming.md" in msg
|
|
assert "/existing.md" in msg
|
|
|
|
|
|
# ── Error message quality ────────────────────────────────────────────
|
|
|
|
|
|
def test_collision_error_lists_all_collisions_not_just_first():
|
|
"""If a batch contains multiple distinct collisions, the error
|
|
surfaces ALL of them — a user fixing one and re-running shouldn't
|
|
rediscover the next one from scratch."""
|
|
proposed = [
|
|
("drawer_A", {"source_file": "/f1.md", "chunk_index": 0}),
|
|
("drawer_A", {"source_file": "/f2.md", "chunk_index": 0}), # collision 1
|
|
("drawer_B", {"source_file": "/f3.md", "chunk_index": 0}),
|
|
("drawer_B", {"source_file": "/f4.md", "chunk_index": 0}), # collision 2
|
|
]
|
|
col = _MockCollection()
|
|
with pytest.raises(CollisionError) as exc_info:
|
|
assert_no_collisions(proposed, col)
|
|
msg = str(exc_info.value)
|
|
assert "drawer_A" in msg
|
|
assert "drawer_B" in msg
|
|
assert "/f1.md" in msg
|
|
assert "/f2.md" in msg
|
|
assert "/f3.md" in msg
|
|
assert "/f4.md" in msg
|
|
|
|
|
|
# ── Edge cases ───────────────────────────────────────────────────────
|
|
|
|
|
|
def test_assert_no_collisions_passes_for_empty_batch():
|
|
"""An empty mining batch is trivially collision-free — the scan
|
|
must not raise on len(proposed) == 0 so callers don't have to guard
|
|
the call site."""
|
|
assert_no_collisions([], _MockCollection()) is None
|
|
|
|
|
|
def test_assert_no_collisions_handles_metadata_without_chunk_index():
|
|
"""Some drawer types (e.g. diary entries, sentinels) don't carry
|
|
chunk_index. The scan should compare whatever metadata is present
|
|
without crashing on missing keys."""
|
|
proposed = [
|
|
("drawer_diary_1", {"source_file": "/diary.md"}),
|
|
]
|
|
col = _MockCollection(
|
|
existing={
|
|
"drawer_diary_1": {"source_file": "/diary_other.md"},
|
|
}
|
|
)
|
|
with pytest.raises(CollisionError):
|
|
assert_no_collisions(proposed, col)
|
|
|
|
|
|
def test_assert_no_collisions_tolerates_chromadb_get_failure():
|
|
"""ChromaDB's get() can raise on transient backend errors. The
|
|
scan should NOT swallow those — the caller's broad-except can decide
|
|
whether to abort the mine or proceed. The scan's contract is
|
|
'either confirms no collisions, or raises'. Hiding backend errors
|
|
behind a False is the silent-failure shape this PR was meant to
|
|
eliminate."""
|
|
|
|
class _RaisingCollection:
|
|
def get(self, **kwargs):
|
|
raise RuntimeError("chromadb is sad")
|
|
|
|
proposed = [("drawer_X", {"source_file": "/f.md", "chunk_index": 0})]
|
|
with pytest.raises(RuntimeError, match="chromadb is sad"):
|
|
assert_no_collisions(proposed, _RaisingCollection())
|