fleet-memory/hindsight-api-slim/tests/test_hnsw_indexes.py
Nicolò Boschi 15ea23d5d6
feat: introduce hindsight-api-slim and hindsight-all-slim packages (#560)
* feat: introduce hindsight-api-slim and hindsight-all-slim packages

Closes #552

- Move all source code from hindsight-api/ to new hindsight-api-slim/
- hindsight-api-slim has heavy ML deps (torch, sentence-transformers,
  transformers, einops, flashrank, mlx, mlx-lm, safetensors) and
  pg0-embedded as optional extras: [local-ml], [embedded-db], [all]
- hindsight-api becomes a zero-code meta-package depending on
  hindsight-api-slim[all] for full backward compatibility
- Add hindsight-all-slim meta-package: hindsight-api-slim + client + embed
- hindsight-all updated to depend on hindsight-api-slim[all]
- pg0.py: lazy-import pg0 with clear ImportError pointing to [embedded-db]
- Dockerfile: replace sed hack with proper uv sync --extra flags
- Update release.yml, test.yml, lint.sh, release.sh, CLAUDE.md and
  all path references throughout the repo

* refactor: rename hindsight/ directory to hindsight-all/

* docs: document hindsight-api-slim and hindsight-all-slim package variants

Add package variants table and extras explanation to installation.md

* docs: remove emojis from installation.md, use professional tone

* docs: link Docker slim variant to pip package variants section

* docs: consolidate Docker image variants into single table

* ci: fix working-directory paths after package restructure

- Replace all hindsight-api → hindsight-api-slim in test.yml
- Replace hindsight → hindsight-all in test.yml
- Add --extra embedded-db to test-embed API install step

* ci: add local-ml and embedded-db extras to API sync steps

These extras were previously implicit in the old hindsight-api package
(which bundled everything). Now that hindsight-api-slim uses optional
extras, we must explicitly request local-ml and embedded-db in CI.

* ci: add API install step with embedded-db to test-embed smoke test

The smoke test starts hindsight-api as a daemon, which requires pg0-embedded.
Add a dedicated install step for hindsight-api-slim with embedded-db extra
so the daemon can start successfully.

* ci: remove --no-install-project when using optional extras

When --no-install-project is combined with --extra, the optional deps
are not installed because extras require the project to be active.
Remove --no-install-project from steps that need local-ml or embedded-db.

* ci: fix ordering of uv sync steps to preserve optional extras

When uv sync runs for a different workspace member, it removes optional
extras installed for other members. Fix by always running extra-requiring
API sync last, after other workspace member syncs.

Also remove --no-install-project from embedded-db sync in test-embed,
as --no-install-project prevents optional extras from being active.

* ci: add local-ml extra to test-embed API install for smoke test

The smoke test starts the full API server which needs sentence-transformers
for local embeddings (default provider). Add local-ml extra to the install.

* ci: simplify extras with --all-extras and add slim pip smoke test

- Replace explicit --extra local-ml --extra embedded-db with --all-extras
  for cleaner, more maintainable sync steps
- Add test-pip-slim job: tests hindsight-api-slim[embedded-db] without
  local ML models, using Cohere for embeddings/reranking (mirrors Docker
  slim smoke test approach)

* ci: simplify slim smoke test to health check only (mirrors Docker test)
2026-03-13 13:50:03 +01:00

193 lines
7.2 KiB
Python

"""
Tests for per-bank HNSW index lifecycle and UNION ALL retrieval.
Covers:
- _hnsw_index_name deterministic naming
- Per-bank HNSW indexes created on bank creation (retain_async / ensure_bank_exists)
- Per-bank HNSW indexes dropped on bank deletion
- retrieve_semantic_bm25_combined groups results correctly by fact_type and source
"""
import uuid
from datetime import datetime, timezone
import pytest
from hindsight_api.engine.retain.bank_utils import _HNSW_FACT_TYPES, _hnsw_index_name
# ---------------------------------------------------------------------------
# Unit tests — no DB required
# ---------------------------------------------------------------------------
class TestHnswIndexName:
def test_deterministic(self):
uid = "550e8400-e29b-41d4-a716-446655440000"
assert _hnsw_index_name("world", uid) == _hnsw_index_name("world", uid)
def test_strips_dashes(self):
uid = "550e8400-e29b-41d4-a716-446655440000"
name = _hnsw_index_name("world", uid)
# uid16 should be hex chars only
assert "-" not in name
def test_uses_first_16_hex_chars(self):
uid = "550e8400-e29b-41d4-a716-446655440000"
uid16 = uid.replace("-", "")[:16] # "550e8400e29b41d4"
assert name_ends_with(name=_hnsw_index_name("world", uid), suffix=uid16)
def test_suffix_per_fact_type(self):
uid = "550e8400-e29b-41d4-a716-446655440000"
names = {ft: _hnsw_index_name(ft, uid) for ft in _HNSW_FACT_TYPES}
# All three names must be distinct
assert len(set(names.values())) == 3
def test_all_fact_types_covered(self):
assert set(_HNSW_FACT_TYPES) == {"world", "experience", "observation"}
def test_fits_pg_identifier_limit(self):
# PostgreSQL max identifier length is 63 chars
uid = "f" * 32 # simulated UUID without dashes
for ft in _HNSW_FACT_TYPES:
assert len(_hnsw_index_name(ft, uid)) <= 63
def name_ends_with(name: str, suffix: str) -> bool:
return name.endswith(suffix)
# ---------------------------------------------------------------------------
# Integration tests — require DB (memory fixture)
# ---------------------------------------------------------------------------
async def _get_bank_hnsw_indexes(pool, bank_id: str) -> list[str]:
"""Return index names for memory_units that match the per-bank pattern."""
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT indexname
FROM pg_indexes
WHERE tablename = 'memory_units'
AND indexname LIKE 'idx_mu_emb_%'
AND indexdef LIKE $1
ORDER BY indexname
""",
f"%bank_id = '{bank_id}'%",
)
return [row["indexname"] for row in rows]
@pytest.mark.asyncio
async def test_retain_creates_per_bank_hnsw_indexes(memory, request_context):
"""retain_async on a new bank must create 3 per-(bank, fact_type) HNSW indexes."""
bank_id = f"test_hnsw_create_{uuid.uuid4().hex[:8]}"
try:
await memory.retain_async(
bank_id=bank_id,
content="Alice is a software engineer.",
request_context=request_context,
)
indexes = await _get_bank_hnsw_indexes(memory._pool, bank_id)
assert len(indexes) == 3, f"Expected 3 per-bank HNSW indexes, got: {indexes}"
for ft_short in _HNSW_FACT_TYPES.values():
assert any(ft_short in idx for idx in indexes), (
f"Missing index for fact_type short '{ft_short}' in {indexes}"
)
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_delete_bank_drops_hnsw_indexes(memory, request_context):
"""delete_bank must drop all per-bank HNSW indexes."""
bank_id = f"test_hnsw_drop_{uuid.uuid4().hex[:8]}"
await memory.retain_async(
bank_id=bank_id,
content="Bob is a data scientist.",
request_context=request_context,
)
# Verify indexes exist before deletion
indexes_before = await _get_bank_hnsw_indexes(memory._pool, bank_id)
assert len(indexes_before) == 3
await memory.delete_bank(bank_id, request_context=request_context)
indexes_after = await _get_bank_hnsw_indexes(memory._pool, bank_id)
assert indexes_after == [], f"Indexes should be dropped after bank deletion, got: {indexes_after}"
@pytest.mark.asyncio
async def test_retain_idempotent_bank_creation(memory, request_context):
"""Retaining into the same bank twice must not error and still have exactly 3 indexes."""
bank_id = f"test_hnsw_idem_{uuid.uuid4().hex[:8]}"
try:
await memory.retain_async(
bank_id=bank_id,
content="Carol is a product manager.",
request_context=request_context,
)
await memory.retain_async(
bank_id=bank_id,
content="Carol joined the company in 2022.",
request_context=request_context,
)
indexes = await _get_bank_hnsw_indexes(memory._pool, bank_id)
assert len(indexes) == 3
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_retrieve_semantic_bm25_grouped_by_fact_type(memory, request_context):
"""
retrieve_semantic_bm25_combined must return a dict keyed by fact_type with
(semantic_list, bm25_list) tuples. All returned facts must belong to their
declared fact_type.
"""
from hindsight_api.engine.search.retrieval import retrieve_semantic_bm25_combined
bank_id = f"test_retrieval_{uuid.uuid4().hex[:8]}"
try:
await memory.retain_async(
bank_id=bank_id,
content=(
"Alice is a software engineer at TechCorp. "
"She visited Paris in 2023 for a conference."
),
context="background",
event_date=datetime(2023, 6, 1, tzinfo=timezone.utc),
request_context=request_context,
)
query_emb = memory.embeddings.encode(["software engineer Alice"])
query_emb_str = str(query_emb[0])
fact_types = ["world", "experience"]
async with memory._pool.acquire() as conn:
results = await retrieve_semantic_bm25_combined(
conn=conn,
query_emb_str=query_emb_str,
query_text="software engineer Alice",
bank_id=bank_id,
fact_types=fact_types,
limit=5,
)
# Must return an entry for every requested fact_type
assert set(results.keys()) == set(fact_types)
for ft, (sem, bm25) in results.items():
# Semantic and BM25 lists must be lists
assert isinstance(sem, list)
assert isinstance(bm25, list)
# All semantic results must declare the correct fact_type
for r in sem:
assert r.fact_type == ft, f"Semantic result has wrong fact_type: {r.fact_type}"
# All BM25 results must declare the correct fact_type
for r in bm25:
assert r.fact_type == ft, f"BM25 result has wrong fact_type: {r.fact_type}"
finally:
await memory.delete_bank(bank_id, request_context=request_context)