From 405f6255951e82db2d02977801e8745ed14df205 Mon Sep 17 00:00:00 2001 From: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> Date: Tue, 12 May 2026 17:43:16 -0400 Subject: [PATCH] feat(startup): atomic swap dim-vs-MIGRATED guard for runtime schema validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add src/startup/embedding_validator.py that introspects the actual pgvector column dim at boot and refuses to start if it does not match EMBEDDING_VECTOR_DIMENSIONS. Runs after the DB pool is up and before the embedding client is constructed, in both src/main.py (FastAPI lifespan) and src/deriver/__main__.py. Implementation details: - Schema-qualified pg_attribute join through pg_class/pg_namespace respects DB.SCHEMA rather than relying on search_path - Bounded retry (3 attempts, 1s backoff) for transient introspection failure, then fail-closed with "could not validate embedding schema" — uncertainty is not a green light to serve traffic - External-store sampler (turbopuffer, lancedb) enumerates workspaces from the application DB and probes their lazy-created namespaces; current per-namespace probe is a no-op stub since the SDKs do not expose uniform dim introspection — full enumeration is left to `configure_embeddings --report` in Phase 3 Atomic guard swap: deletes the old dim-vs-MIGRATED config validator (which forbade non-1536 pgvector unless MIGRATED=True) in the same commit as the new runtime validator. There is no release window where non-1536 pgvector can start unprotected. The 9 dual-write branches that use VECTOR_STORE.MIGRATED remain untouched and load-bearing for legacy-tenant backend swaps. VECTOR_STORE_DIMENSIONS deprecation: drop the "must match" raise; in propagate_namespace, check model_fields_set and emit logger.warning + DeprecationWarning (DeprecationWarning alone is filtered by Python's default config and would not reach operators). Always overwrite with EMBEDDING.VECTOR_DIMENSIONS regardless. Test changes: - tests/test_models_vector_dim.py: Phase 1's VECTOR_STORE_TYPE=lancedb + MIGRATED=true escape hatches removed; the test now passes on plain EMBEDDING_VECTOR_DIMENSIONS=768 - tests/llm/test_model_config.py: the two tests asserting the old guards replaced with tests for the new deprecation + acceptance behavior - tests/startup/test_embedding_validator.py: 10 new tests — dim assertion logic (pass/mismatch/missing/unbounded/non-public-schema), fail-closed retry budget, real-test-DB pass, real-DB ALTER-then-validate, deprecation warning capture, non-1536 + pgvector + MIGRATED=false at config time --- src/config.py | 28 +-- src/deriver/__main__.py | 7 + src/main.py | 7 + src/startup/__init__.py | 8 + src/startup/embedding_validator.py | 220 ++++++++++++++++++++++ tests/llm/test_model_config.py | 66 ++++--- tests/startup/__init__.py | 0 tests/startup/test_embedding_validator.py | 205 ++++++++++++++++++++ tests/test_models_vector_dim.py | 13 +- 9 files changed, 501 insertions(+), 53 deletions(-) create mode 100644 src/startup/__init__.py create mode 100644 src/startup/embedding_validator.py create mode 100644 tests/startup/__init__.py create mode 100644 tests/startup/test_embedding_validator.py diff --git a/src/config.py b/src/config.py index c4e1d845..276792b1 100644 --- a/src/config.py +++ b/src/config.py @@ -1287,25 +1287,27 @@ class AppSettings(HonchoSettings): self.CACHE.NAMESPACE = self.NAMESPACE if "NAMESPACE" not in self.VECTOR_STORE.model_fields_set: self.VECTOR_STORE.NAMESPACE = self.NAMESPACE - if "DIMENSIONS" not in self.VECTOR_STORE.model_fields_set: - self.VECTOR_STORE.DIMENSIONS = self.EMBEDDING.VECTOR_DIMENSIONS - elif self.VECTOR_STORE.DIMENSIONS != self.EMBEDDING.VECTOR_DIMENSIONS: - raise ValueError( - "VECTOR_STORE.DIMENSIONS must match EMBEDDING.VECTOR_DIMENSIONS" + if "DIMENSIONS" in self.VECTOR_STORE.model_fields_set: + # VECTOR_STORE_DIMENSIONS is deprecated: EMBEDDING_VECTOR_DIMENSIONS + # is the single source of truth. Log a runtime-visible warning + # so operators see it (DeprecationWarning is filtered by Python's + # default config outside __main__/tests) and also raise the stdlib + # warning so tests can assert on it. + import warnings + + message = ( + "VECTOR_STORE_DIMENSIONS is deprecated; " + "EMBEDDING_VECTOR_DIMENSIONS is authoritative. " + "Drop VECTOR_STORE_DIMENSIONS from your .env." ) + logger.warning(message) + warnings.warn(message, DeprecationWarning, stacklevel=2) + self.VECTOR_STORE.DIMENSIONS = self.EMBEDDING.VECTOR_DIMENSIONS if "NAMESPACE" not in self.TELEMETRY.model_fields_set: self.TELEMETRY.NAMESPACE = self.NAMESPACE if "NAMESPACE" not in self.METRICS.model_fields_set: self.METRICS.NAMESPACE = self.NAMESPACE - if self.EMBEDDING.VECTOR_DIMENSIONS != 1536 and ( - self.VECTOR_STORE.TYPE == "pgvector" or not self.VECTOR_STORE.MIGRATED - ): - raise ValueError( - "EMBEDDING.VECTOR_DIMENSIONS must remain 1536 while pgvector is " - + "active or vector-store migration is incomplete" - ) - return self diff --git a/src/deriver/__main__.py b/src/deriver/__main__.py index c3d498b2..d017a813 100644 --- a/src/deriver/__main__.py +++ b/src/deriver/__main__.py @@ -6,6 +6,8 @@ import uvloop from prometheus_client import start_http_server from src.config import settings +from src.db import engine +from src.startup import validate_embedding_schema from src.telemetry import initialize_telemetry_async, shutdown_telemetry from .queue_manager import main @@ -56,6 +58,11 @@ async def run_deriver(): """Run the deriver with proper telemetry lifecycle management.""" # Initialize async telemetry (CloudEvents emitter) await initialize_telemetry_async() + + # Fail fast if the embedding schema does not match settings — same gate + # the API runs in its lifespan. + await validate_embedding_schema(engine) + try: await main() finally: diff --git a/src/main.py b/src/main.py index 64439bee..02f377a3 100644 --- a/src/main.py +++ b/src/main.py @@ -28,6 +28,7 @@ from src.routers import ( webhooks, workspaces, ) +from src.startup import validate_embedding_schema from src.telemetry import ( initialize_telemetry_async, metrics_endpoint, @@ -125,6 +126,12 @@ async def lifespan(_: FastAPI): # Initialize CloudEvents telemetry await initialize_telemetry_async() + # Validate embedding schema before serving any traffic. Fails closed: if + # the configured EMBEDDING_VECTOR_DIMENSIONS does not match the physical + # pgvector columns, the process refuses to start rather than silently + # writing wrong-dim vectors. + await validate_embedding_schema(engine) + try: await init_cache() except Exception as e: diff --git a/src/startup/__init__.py b/src/startup/__init__.py new file mode 100644 index 00000000..09f12744 --- /dev/null +++ b/src/startup/__init__.py @@ -0,0 +1,8 @@ +"""Startup-time validators that gate API/deriver boot.""" + +from src.startup.embedding_validator import ( + StartupValidationError, + validate_embedding_schema, +) + +__all__ = ("StartupValidationError", "validate_embedding_schema") diff --git a/src/startup/embedding_validator.py b/src/startup/embedding_validator.py new file mode 100644 index 00000000..a9982cfe --- /dev/null +++ b/src/startup/embedding_validator.py @@ -0,0 +1,220 @@ +"""Startup validator for the embedding pipeline. + +Crashes the process at boot if the configured EMBEDDING_VECTOR_DIMENSIONS does +not match the physical pgvector schema. Replaces an earlier config-time guard +that forbade non-1536 dims unless the operator asserted a VECTOR_STORE.MIGRATED +flag — the schema introspection here is more accurate because it inspects +actual state instead of operator-asserted state. + +For external stores (turbopuffer, lancedb) the check is best-effort: namespaces +are per-workspace and lazy-created, so this validator can only sample existing +ones. Full enumeration is available via `uv run python -m +src.scripts.configure_embeddings --report` (Phase 3). +""" + +from __future__ import annotations + +import asyncio +import logging + +from sqlalchemy import text +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncEngine + +from src.config import AppSettings, settings + +logger = logging.getLogger(__name__) + +# Embedding tables that must exist with matching dim. +_EMBEDDING_TABLES: tuple[str, ...] = ("documents", "message_embeddings") + +# Retry budget for transient introspection failures. Total wall time is +# bounded so a sick DB does not hang readiness; fail-closed after exhaustion. +_RETRY_ATTEMPTS = 3 +_RETRY_BACKOFF_SECONDS = 1.0 + +# Best-effort external sampler bounds. +_EXTERNAL_SAMPLE_LIMIT = 10 + + +class StartupValidationError(RuntimeError): + """Raised when the embedding configuration cannot be reconciled with the + physical schema. Always surfaced before any HTTP route is served or any + queue task is processed. + """ + + +async def validate_embedding_schema( + engine: AsyncEngine, + *, + app_settings: AppSettings | None = None, +) -> None: + """Validate that the embedding schema matches the configured dimension. + + Run after the DB pool is initialized and before the embedding client is + constructed. Fails closed: any unrecoverable introspection error raises + rather than letting the process serve traffic with an unknown state. + """ + s = app_settings if app_settings is not None else settings + target_dim = s.EMBEDDING.VECTOR_DIMENSIONS + schema = s.DB.SCHEMA + + dims = await _introspect_pgvector_dims_with_retry(engine, schema) + _assert_pgvector_dims_match(dims, schema=schema, target_dim=target_dim) + + if s.VECTOR_STORE.TYPE in ("turbopuffer", "lancedb"): + await _sample_external_namespaces(engine, target_dim=target_dim) + + +async def _introspect_pgvector_dims_with_retry( + engine: AsyncEngine, schema: str +) -> dict[str, int]: + """Schema-qualified pg_attribute introspection with bounded retries. + + Returns a mapping of table name -> raw ``atttypmod`` for the embedding + columns. Fails closed on the last attempt — uncertainty is not a green + light to serve traffic. + """ + last_exc: Exception | None = None + for attempt in range(_RETRY_ATTEMPTS): + try: + return await _introspect_pgvector_dims_once(engine, schema) + except SQLAlchemyError as e: + last_exc = e + remaining = _RETRY_ATTEMPTS - attempt - 1 + if remaining > 0: + logger.warning( + "Embedding schema introspection attempt %d/%d failed: %s", + attempt + 1, + _RETRY_ATTEMPTS, + e, + ) + await asyncio.sleep(_RETRY_BACKOFF_SECONDS) + raise StartupValidationError( + f"could not validate embedding schema: {last_exc}" + ) from last_exc + + +async def _introspect_pgvector_dims_once( + engine: AsyncEngine, schema: str +) -> dict[str, int]: + """Single-shot schema-qualified pg_attribute lookup. + + The join through ``pg_class``/``pg_namespace`` lets us respect + ``DB.SCHEMA`` rather than relying on the ambient search_path. + """ + query = text( + """ + SELECT c.relname AS table_name, a.atttypmod AS typmod + FROM pg_attribute a + JOIN pg_class c ON a.attrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE n.nspname = :schema + AND c.relname = ANY(:tables) + AND a.attname = 'embedding' + """ + ) + async with engine.connect() as conn: + result = await conn.execute( + query, + {"schema": schema, "tables": list(_EMBEDDING_TABLES)}, + ) + return {row.table_name: row.typmod for row in result} + + +def _assert_pgvector_dims_match( + dims: dict[str, int], *, schema: str, target_dim: int +) -> None: + expected = set(_EMBEDDING_TABLES) + missing = expected - dims.keys() + if missing: + listing = ", ".join(sorted(f"{schema}.{t}.embedding" for t in missing)) + raise StartupValidationError( + f"Required vector columns missing: {listing}." + + " Run `alembic upgrade head` first." + ) + for table in sorted(expected): + atttypmod = dims[table] + if atttypmod == -1: + raise StartupValidationError( + f"{schema}.{table}.embedding has no declared vector dimension" + + " (unbounded typmod). Run" + + " `uv run python -m src.scripts.configure_embeddings`." + ) + # pgvector stores the declared dim directly in atttypmod (no VARHDRSZ). + actual = atttypmod + if actual != target_dim: + raise StartupValidationError( + f"{schema}.{table}.embedding dim ({actual}) does not match" + + f" EMBEDDING_VECTOR_DIMENSIONS ({target_dim}). Run" + + " `uv run python -m src.scripts.configure_embeddings`" + + " or fix EMBEDDING_VECTOR_DIMENSIONS." + ) + + +async def _sample_external_namespaces(engine: AsyncEngine, *, target_dim: int) -> None: + """Best-effort dim check across existing external-store namespaces. + + External stores in this codebase are per-workspace and lazy-created on + first write (see ``src.vector_store.get_vector_namespace``), so there is + no canonical deployment-wide namespace to introspect. We enumerate up to + ``_EXTERNAL_SAMPLE_LIMIT`` workspaces from the application DB and probe + their derived namespaces. Missing namespaces are OK; mismatched dims + crash startup. Run ``configure_embeddings --report`` for full + enumeration when a hard guarantee is needed. + """ + workspace_names = await _sample_workspace_names(engine, _EXTERNAL_SAMPLE_LIMIT) + if not workspace_names: + logger.info( + "External-store validator: no workspaces exist yet, skipping sample" + ) + return + + # Import lazily to avoid pulling in vector store deps when not configured. + from src.vector_store import get_external_vector_store + + store = get_external_vector_store() + if store is None: + # Settings said TYPE != pgvector but the store could not be created. + # That is its own problem and not for this validator to swallow. + return + + mismatches: list[tuple[str, int]] = [] + for workspace_name in workspace_names: + namespace = store.get_vector_namespace("message", workspace_name) + actual_dim = await _probe_namespace_dim(store, namespace) + if actual_dim is not None and actual_dim != target_dim: + mismatches.append((namespace, actual_dim)) + + if mismatches: + formatted = ", ".join(f"{ns} (dim={d})" for ns, d in mismatches) + raise StartupValidationError( + f"Existing external-store namespaces have dim != {target_dim}:" + + f" {formatted}. Run" + + " `uv run python -m src.scripts.configure_embeddings --report`." + ) + + +async def _sample_workspace_names(engine: AsyncEngine, limit: int) -> list[str]: + """Pull up to ``limit`` workspace names ordered by creation time.""" + query = text("SELECT name FROM workspaces ORDER BY created_at DESC LIMIT :limit") + async with engine.connect() as conn: + result = await conn.execute(query, {"limit": limit}) + return [row.name for row in result] + + +async def _probe_namespace_dim(store: object, namespace: str) -> int | None: + """Best-effort: return the namespace's declared dim if introspectable. + + Returns ``None`` if the namespace does not exist or the SDK does not + expose dim metadata for the configured store. Callers must treat + ``None`` as "do not flag a mismatch" — full enumeration is delegated to + the ``configure_embeddings --report`` path. + """ + # The base ``VectorStore`` does not currently expose introspection of an + # individual namespace's dim. Until a concrete probe is added per store, + # treat all sampled namespaces as opaque. The pgvector check above is the + # load-bearing safety; first-write per workspace inherits its correctness + # from the embedding client honoring settings.EMBEDDING.VECTOR_DIMENSIONS. + _ = (store, namespace) + return None diff --git a/tests/llm/test_model_config.py b/tests/llm/test_model_config.py index 4ed1ca06..37c1ee68 100644 --- a/tests/llm/test_model_config.py +++ b/tests/llm/test_model_config.py @@ -1,5 +1,4 @@ import os -import re from pathlib import Path from typing import Any, cast @@ -251,14 +250,14 @@ def test_app_settings_propagate_embedding_dimensions_to_vector_store() -> None: assert settings.VECTOR_STORE.DIMENSIONS == 2048 -def test_app_settings_require_matching_embedding_and_vector_store_dimensions() -> None: - with pytest.raises( - ValueError, - match=re.escape( - "VECTOR_STORE.DIMENSIONS must match EMBEDDING.VECTOR_DIMENSIONS" - ), - ): - AppSettings( +def test_app_settings_explicit_vector_store_dimensions_warns_and_overrides() -> None: + """VECTOR_STORE.DIMENSIONS is deprecated: EMBEDDING.VECTOR_DIMENSIONS wins + and the operator gets a DeprecationWarning if they set it explicitly.""" + import warnings + + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always") + settings = AppSettings( EMBEDDING=EmbeddingSettings(VECTOR_DIMENSIONS=2048), VECTOR_STORE=VectorStoreSettings( TYPE="lancedb", @@ -266,28 +265,39 @@ def test_app_settings_require_matching_embedding_and_vector_store_dimensions() - DIMENSIONS=1536, ), ) + messages = [ + str(w.message) for w in captured if issubclass(w.category, DeprecationWarning) + ] + assert any( + "VECTOR_STORE_DIMENSIONS is deprecated" in m for m in messages + ), f"expected deprecation warning, got {messages!r}" + assert settings.EMBEDDING.VECTOR_DIMENSIONS == 2048 + assert settings.VECTOR_STORE.DIMENSIONS == 2048, ( + "EMBEDDING.VECTOR_DIMENSIONS should always overwrite the operator-supplied " + "VECTOR_STORE.DIMENSIONS value" + ) -def test_app_settings_reject_non_1536_dimensions_while_pgvector_or_dual_write_active() -> ( - None -): - with pytest.raises( - ValueError, - match=re.escape("EMBEDDING.VECTOR_DIMENSIONS must remain 1536"), - ): - AppSettings( - EMBEDDING=EmbeddingSettings(VECTOR_DIMENSIONS=2048), - VECTOR_STORE=VectorStoreSettings(TYPE="pgvector", MIGRATED=True), - ) - - with pytest.raises( - ValueError, - match=re.escape("EMBEDDING.VECTOR_DIMENSIONS must remain 1536"), - ): - AppSettings( - EMBEDDING=EmbeddingSettings(VECTOR_DIMENSIONS=2048), - VECTOR_STORE=VectorStoreSettings(TYPE="lancedb", MIGRATED=False), +def test_app_settings_accepts_non_1536_with_any_vector_store_configuration() -> None: + """The dim-vs-MIGRATED guard was removed; the runtime startup schema + validator (src/startup/embedding_validator.py) is the new safety net. + Construction must succeed for every combination at config time.""" + from typing import Literal + + combos: list[tuple[Literal["pgvector", "turbopuffer", "lancedb"], bool]] = [ + ("pgvector", True), + ("pgvector", False), + ("lancedb", True), + ("lancedb", False), + ] + for store_type, migrated in combos: + settings = AppSettings( + EMBEDDING=EmbeddingSettings(VECTOR_DIMENSIONS=768), + VECTOR_STORE=VectorStoreSettings(TYPE=store_type, MIGRATED=migrated), ) + assert settings.EMBEDDING.VECTOR_DIMENSIONS == 768 + assert store_type == settings.VECTOR_STORE.TYPE + assert settings.VECTOR_STORE.MIGRATED is migrated def test_config_toml_example_uses_nested_model_config_sections() -> None: diff --git a/tests/startup/__init__.py b/tests/startup/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/startup/test_embedding_validator.py b/tests/startup/test_embedding_validator.py new file mode 100644 index 00000000..ef31b24f --- /dev/null +++ b/tests/startup/test_embedding_validator.py @@ -0,0 +1,205 @@ +"""Phase 2: startup embedding-schema validator + VECTOR_STORE_DIMENSIONS deprecation.""" + +from __future__ import annotations + +import os +import subprocess +import sys +import warnings +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest +from sqlalchemy import text +from sqlalchemy.exc import OperationalError +from sqlalchemy.ext.asyncio import AsyncEngine + +from src.startup.embedding_validator import ( + StartupValidationError, + _assert_pgvector_dims_match, # pyright: ignore[reportPrivateUsage] + validate_embedding_schema, +) + +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent + + +# pgvector stores the declared dim directly in atttypmod (no VARHDRSZ offset). +def _typmod(dim: int) -> int: + return dim + + +# --------------------------------------------------------------------------- +# Pure-function unit tests for the dim assertion +# --------------------------------------------------------------------------- + + +def test_assert_pgvector_dims_match_passes_when_all_dims_align() -> None: + _assert_pgvector_dims_match( + {"documents": _typmod(1536), "message_embeddings": _typmod(1536)}, + schema="public", + target_dim=1536, + ) + + +def test_assert_pgvector_dims_match_raises_on_dim_mismatch() -> None: + with pytest.raises(StartupValidationError, match="dim .* does not match"): + _assert_pgvector_dims_match( + {"documents": _typmod(1536), "message_embeddings": _typmod(768)}, + schema="public", + target_dim=1536, + ) + + +def test_assert_pgvector_dims_match_lists_all_missing_columns() -> None: + with pytest.raises(StartupValidationError) as excinfo: + _assert_pgvector_dims_match( + {"documents": _typmod(1536)}, + schema="public", + target_dim=1536, + ) + msg = str(excinfo.value) + assert "message_embeddings" in msg + assert "alembic upgrade head" in msg + + +def test_assert_pgvector_dims_match_raises_on_unbounded_typmod() -> None: + with pytest.raises(StartupValidationError, match="unbounded typmod"): + _assert_pgvector_dims_match( + {"documents": -1, "message_embeddings": _typmod(1536)}, + schema="public", + target_dim=1536, + ) + + +def test_assert_pgvector_dims_match_respects_non_public_schema() -> None: + with pytest.raises(StartupValidationError, match="my_schema.documents"): + _assert_pgvector_dims_match( + {}, + schema="my_schema", + target_dim=1536, + ) + + +# --------------------------------------------------------------------------- +# Fail-closed retry behavior +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_validator_fails_closed_when_introspection_keeps_failing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """After the retry budget exhausts, the validator crashes — uncertainty + is not a green light to serve traffic.""" + + call_count = 0 + + async def always_raise(_engine: AsyncEngine, _schema: str) -> dict[str, int]: + nonlocal call_count + call_count += 1 + raise OperationalError("SELECT 1", {}, Exception("DB unreachable")) + + monkeypatch.setattr( + "src.startup.embedding_validator._introspect_pgvector_dims_once", + always_raise, + ) + # Make backoff effectively instant for the test. + monkeypatch.setattr("src.startup.embedding_validator._RETRY_BACKOFF_SECONDS", 0.0) + + with pytest.raises(StartupValidationError, match="could not validate"): + await validate_embedding_schema(engine=AsyncMock()) + + assert call_count == 3, "should exhaust the retry budget before failing" + + +# --------------------------------------------------------------------------- +# Integration: real test DB +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_validator_passes_against_test_database( + db_engine: AsyncEngine, +) -> None: + """The test DB is provisioned at the default dim (1536); the validator + should accept it without raising.""" + await validate_embedding_schema(db_engine) + + +@pytest.mark.asyncio +async def test_validator_raises_when_schema_dim_diverges_from_settings( + db_engine: AsyncEngine, +) -> None: + """ALTER one of the embedding columns to a non-1536 dim and confirm the + validator raises with an actionable message.""" + async with db_engine.begin() as conn: + await conn.execute( + text( + "ALTER TABLE documents" + + " ALTER COLUMN embedding TYPE vector(768) USING NULL" + ) + ) + try: + with pytest.raises(StartupValidationError, match="dim .* does not match"): + await validate_embedding_schema(db_engine) + finally: + async with db_engine.begin() as conn: + await conn.execute( + text( + "ALTER TABLE documents" + + " ALTER COLUMN embedding TYPE vector(1536) USING NULL" + ) + ) + + +# --------------------------------------------------------------------------- +# VECTOR_STORE_DIMENSIONS deprecation + dim-vs-MIGRATED guard removal +# --------------------------------------------------------------------------- + + +def test_vector_store_dimensions_explicit_set_warns( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Setting VECTOR_STORE_DIMENSIONS explicitly should trigger a deprecation + warning. EMBEDDING_VECTOR_DIMENSIONS remains authoritative.""" + monkeypatch.setenv("PYTHON_DOTENV_DISABLED", "1") + monkeypatch.setenv("VECTOR_STORE_DIMENSIONS", "1536") + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always") + from src.config import AppSettings + + AppSettings() + messages = [ + str(w.message) for w in captured if issubclass(w.category, DeprecationWarning) + ] + assert any( + "VECTOR_STORE_DIMENSIONS is deprecated" in m for m in messages + ), f"expected deprecation warning, got {messages!r}" + + +def test_non_1536_pgvector_without_migrated_no_longer_raises_at_config_time() -> None: + """Phase 2 removed the dim-vs-MIGRATED guard. Constructing AppSettings + with non-1536 + default pgvector + MIGRATED=false should now succeed + (the runtime schema validator at startup is the new safety net).""" + env = { + **os.environ, + "PYTHON_DOTENV_DISABLED": "1", + "EMBEDDING_VECTOR_DIMENSIONS": "768", + } + # Use a subprocess so the global settings singleton in this test + # process is not perturbed and is re-evaluated freshly in the child. + snippet = ( + "from src.config import AppSettings\n" + "s = AppSettings()\n" + "print(s.EMBEDDING.VECTOR_DIMENSIONS, s.VECTOR_STORE.TYPE, s.VECTOR_STORE.MIGRATED)\n" + ) + result = subprocess.run( + [sys.executable, "-c", snippet], + env=env, + cwd=str(_PROJECT_ROOT), + check=True, + capture_output=True, + text=True, + ) + last_line = result.stdout.strip().splitlines()[-1] + assert last_line == "768 pgvector False" diff --git a/tests/test_models_vector_dim.py b/tests/test_models_vector_dim.py index 2233b840..539e359d 100644 --- a/tests/test_models_vector_dim.py +++ b/tests/test_models_vector_dim.py @@ -54,16 +54,5 @@ def test_models_uses_default_1536_when_no_env_override() -> None: def test_models_honors_explicit_embedding_vector_dimensions() -> None: - # Phase 1 still respects the dim-vs-MIGRATED guard at src/config.py:1278. - # Set VECTOR_STORE_TYPE=lancedb + VECTOR_STORE_MIGRATED=true to satisfy it. - # lancedb is chosen over turbopuffer because turbopuffer requires an - # additional VECTOR_STORE_TURBOPUFFER_API_KEY env var. Phase 2 deletes - # the guard and these escape-hatch envs become unnecessary. - dims = _run_in_fresh_interpreter( - { - "EMBEDDING_VECTOR_DIMENSIONS": "768", - "VECTOR_STORE_TYPE": "lancedb", - "VECTOR_STORE_MIGRATED": "true", - } - ) + dims = _run_in_fresh_interpreter({"EMBEDDING_VECTOR_DIMENSIONS": "768"}) assert dims == {"message_embedding_dim": 768, "document_dim": 768}