diff --git a/CHANGELOG.md b/CHANGELOG.md index 63fa22fa..76c173af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ # Changelog ## [Unreleased] +### Added + +- `haiku-rag rebuild --set-embedder`: adopt the current embedder identity (provider/name) without re-embedding, when the vector dimension is unchanged. + +### Fixed + +- Opening a database no longer writes to it: reads no longer rewrite the stored embedding settings or change the stored version, and the version is never downgraded. Embedding provider/name drift (matching `vector_dim`) warns on read-only opens and raises `ConfigMismatchError` on writable opens; reconcile with `rebuild --set-embedder`. +- Read CLI verbs (`list`, `get`, `search`, `visualize`, `ask`, `analyze`, `inspect`, `chat`, `info`, `history`) open the database read-only. + ## [0.54.0] - 2026-06-04 ### Added diff --git a/docs/cli.md b/docs/cli.md index 25426a2a..7fe283ad 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -373,6 +373,9 @@ haiku-rag rebuild --title-only # Run the VLM over already-stored picture bytes and patch descriptions # into the docling blob. Skips the docling parse entirely. haiku-rag rebuild --descriptions + +# Adopt the current embedder identity without re-embedding (same vector dimension) +haiku-rag rebuild --set-embedder ``` **Rebuild modes:** @@ -384,6 +387,9 @@ haiku-rag rebuild --descriptions | Embed only | `--embed-only` | Changed embedding model or vector dimensions | | Title only | `--title-only` | Generate titles for documents without one | | Descriptions | `--descriptions` | Add VLM picture descriptions to an existing database | +| Set embedder | `--set-embedder` | Same model, different serving stack (e.g. Ollama to vLLM); vector dimension unchanged | + +**`--set-embedder` mode** updates the stored embedding provider/name to match the current config without re-embedding, valid only when the vector dimension is unchanged. Use it when the same model is served by a different stack so the recorded identity stops drifting from the config. A changed vector dimension is rejected; regenerate embeddings with `--embed-only` or a full rebuild instead. **`--descriptions` mode** runs the configured VLM (`processing.conversion_options.picture_description.model`) over the picture bytes already stored in `document_items.picture_data`, patches each description into the stored docling blob's `pictures[i].meta.description.text`, and re-chunks + re-embeds so chunk text reflects the new descriptions. Requires `processing.pictures: description` in the config. Idempotent: pictures that already carry a description are skipped, so the operation is safe to re-run after a partial failure. The docling parse is skipped entirely. Only the VLM time is paid. diff --git a/haiku_rag_slim/haiku/rag/app.py b/haiku_rag_slim/haiku/rag/app.py index 185ee07a..a9887ad1 100644 --- a/haiku_rag_slim/haiku/rag/app.py +++ b/haiku_rag_slim/haiku/rag/app.py @@ -276,7 +276,7 @@ class HaikuRAGApp: # pragma: no cover async with HaikuRAG( db_path=self.db_path, config=self.config, - read_only=self.read_only, + read_only=True, before=self.before, ) as self.client: documents = await self.client.list_documents(filter=filter) @@ -328,7 +328,7 @@ class HaikuRAGApp: # pragma: no cover async with HaikuRAG( db_path=self.db_path, config=self.config, - read_only=self.read_only, + read_only=True, before=self.before, ) as self.client: doc = await self.client.get_document_by_id(doc_id) @@ -385,7 +385,7 @@ class HaikuRAGApp: # pragma: no cover async with HaikuRAG( db_path=self.db_path, config=self.config, - read_only=self.read_only, + read_only=True, before=self.before, ) as self.client: results = await self.client.search( @@ -407,7 +407,7 @@ class HaikuRAGApp: # pragma: no cover async with HaikuRAG( db_path=self.db_path, config=self.config, - read_only=self.read_only, + read_only=True, before=self.before, ) as self.client: chunk = await self.client.get_chunk_by_id(chunk_id) @@ -451,7 +451,7 @@ class HaikuRAGApp: # pragma: no cover async with HaikuRAG( db_path=self.db_path, config=self.config, - read_only=self.read_only, + read_only=True, before=self.before, ) as self.client: answer, citations = await self.client.ask(question, filter=filter) @@ -479,7 +479,7 @@ class HaikuRAGApp: # pragma: no cover async with HaikuRAG( db_path=self.db_path, config=self.config, - read_only=self.read_only, + read_only=True, before=self.before, ) as self.client: self.console.print(f"[bold blue]Question:[/bold blue] {question}") @@ -506,6 +506,14 @@ class HaikuRAGApp: # pragma: no cover read_only=self.read_only, before=self.before, ) as client: + if mode == RebuildMode.SET_EMBEDDER: + async for _ in client.rebuild_database(mode=mode): + pass + self.console.print( + "[bold green]Stored embedder settings updated.[/bold green]" + ) + return + documents = await client.list_documents() total_docs = len(documents) @@ -558,6 +566,7 @@ class HaikuRAGApp: # pragma: no cover config=self.config, skip_validation=True, skip_migration_check=True, + read_only=self.read_only, ) as store: return await store.migrate() diff --git a/haiku_rag_slim/haiku/rag/cli.py b/haiku_rag_slim/haiku/rag/cli.py index 26b3dd1c..9e7e21ab 100644 --- a/haiku_rag_slim/haiku/rag/cli.py +++ b/haiku_rag_slim/haiku/rag/cli.py @@ -406,7 +406,7 @@ def analyze( # pragma: no cover @_cli.command("settings", help="Display current configuration settings") def settings(): # pragma: no cover config = get_config() - app = HaikuRAGApp(db_path=Path(), config=config) + app = HaikuRAGApp(db_path=Path(), config=config, read_only=True) app.show_settings() @@ -476,14 +476,23 @@ def rebuild( "parse entirely. Requires processing.pictures='description'." ), ), + set_embedder: bool = typer.Option( + False, + "--set-embedder", + help=( + "Adopt the current embedder identity without re-embedding, when the " + "vector dimension is unchanged. Use after swapping the serving stack " + "for the same model (e.g. Ollama to vLLM)." + ), + ), ): from haiku.rag.client import RebuildMode - exclusive = sum([embed_only, rechunk, title_only, descriptions]) + exclusive = sum([embed_only, rechunk, title_only, descriptions, set_embedder]) if exclusive > 1: typer.echo( - "Error: --embed-only, --rechunk, --title-only, and --descriptions " - "are mutually exclusive" + "Error: --embed-only, --rechunk, --title-only, --descriptions, and " + "--set-embedder are mutually exclusive" ) raise typer.Exit(1) @@ -495,6 +504,8 @@ def rebuild( mode = RebuildMode.TITLE_ONLY elif descriptions: # pragma: no cover mode = RebuildMode.DESCRIPTIONS + elif set_embedder: # pragma: no cover + mode = RebuildMode.SET_EMBEDDER else: # pragma: no cover mode = RebuildMode.FULL @@ -601,7 +612,7 @@ def history( # pragma: no cover @_cli.command("download-models", help="Download Docling and Ollama models per config") def download_models_cmd(): # pragma: no cover - app = HaikuRAGApp(db_path=Path(), config=get_config()) + app = HaikuRAGApp(db_path=Path(), config=get_config(), read_only=True) try: asyncio.run(app.download_models()) except Exception as e: @@ -625,7 +636,7 @@ def inspect( # pragma: no cover raise typer.Exit(1) from e db_path = db if db else get_config().storage.data_dir / "haiku.rag.lancedb" - run_inspector(db_path, read_only=_read_only, before=_before) + run_inspector(db_path, read_only=True, before=_before) @_cli.command("chat", help="Launch interactive chat TUI for conversational RAG") @@ -655,7 +666,7 @@ def chat( # pragma: no cover run_chat( db_path, - read_only=_read_only, + read_only=True, before=_before, model=model, skills=skills, diff --git a/haiku_rag_slim/haiku/rag/client/__init__.py b/haiku_rag_slim/haiku/rag/client/__init__.py index c4eac92d..61badec5 100644 --- a/haiku_rag_slim/haiku/rag/client/__init__.py +++ b/haiku_rag_slim/haiku/rag/client/__init__.py @@ -49,6 +49,8 @@ class RebuildMode(Enum): TITLE_ONLY = "title_only" # Only generate titles for untitled documents DESCRIPTIONS = "descriptions" # Run the VLM over already-stored picture # bytes, patch descriptions into the docling blob, then re-chunk + re-embed. + SET_EMBEDDER = "set_embedder" # Adopt the current embedder identity without + # re-embedding, when the vector dimension is unchanged. class HaikuRAG: diff --git a/haiku_rag_slim/haiku/rag/client/rebuild.py b/haiku_rag_slim/haiku/rag/client/rebuild.py index f44b6b38..25070eb1 100644 --- a/haiku_rag_slim/haiku/rag/client/rebuild.py +++ b/haiku_rag_slim/haiku/rag/client/rebuild.py @@ -72,6 +72,10 @@ async def rebuild_database( if mode is None: mode = RebuildMode.FULL + if mode == RebuildMode.SET_EMBEDDER: + await _set_embedder(client) + return + # Resolve any leftover staging/marker tables from a previously # interrupted rebuild. Returns True only when phase 1 was already # complete and the current mode is EMBED_ONLY, in which case we resume @@ -131,6 +135,29 @@ async def rebuild_database( logger.warning("Post-rebuild vacuum failed", exc_info=True) +async def _set_embedder(client: "HaikuRAG") -> None: + """Adopt the current embedder identity without re-embedding. + + Only valid when the vector dimension is unchanged — the stored vectors stay + usable, so just the recorded provider/name are updated. A changed dimension + requires regenerating every embedding via a full rebuild. + """ + from haiku.rag.store.repositories.settings import ConfigMismatchError + + settings_repo = SettingsRepository(client.store) + stored = await settings_repo.get_current_settings() + stored_dim = stored.get("embeddings", {}).get("model", {}).get("vector_dim") + current_dim = client._config.embeddings.model.vector_dim + + if stored_dim is not None and current_dim != stored_dim: + raise ConfigMismatchError( + f"Stored vector dimension {stored_dim} differs from current " + f"{current_dim}; embeddings must be regenerated. Run 'haiku-rag rebuild'." + ) + + await settings_repo.save_current_settings() + + async def _hydrate( client: "HaikuRAG", light_docs: list[Document] ) -> AsyncGenerator[Document, None]: diff --git a/haiku_rag_slim/haiku/rag/store/engine.py b/haiku_rag_slim/haiku/rag/store/engine.py index ac4a7b18..2e158a5c 100644 --- a/haiku_rag_slim/haiku/rag/store/engine.py +++ b/haiku_rag_slim/haiku/rag/store/engine.py @@ -12,6 +12,7 @@ import lancedb import pyarrow as pa from lancedb.index import FTS, BTree, IvfPq from lancedb.pydantic import LanceModel, Vector +from packaging.version import parse from pydantic import Field from haiku.rag.config import AppConfig, Config @@ -509,7 +510,7 @@ class Store: await self.set_haiku_version(metadata.version("haiku.rag-slim")) async def _check_migrations(self) -> None: - """Check if migrations are pending and error or update version accordingly. + """Raise if migrations are pending. Opening never writes the version. Raises: MigrationRequiredError: If migrations are pending. @@ -529,10 +530,6 @@ class Store: "Run 'haiku-rag migrate' to upgrade." ) - # No pending migrations - update version silently if needed (writable only) - if not self._read_only and db_version != current_version: - await self.set_haiku_version(current_version) - async def migrate(self) -> list[str]: """Run pending database migrations. @@ -551,8 +548,9 @@ class Store: applied = await run_pending_upgrades(self, db_version) - # Update version after successful migration - if applied or db_version != current_version: + # Advance the schema marker only forward — never downgrade a database + # opened with an older build than last stamped it. + if parse(current_version) > parse(db_version): await self.set_haiku_version(current_version) return applied diff --git a/haiku_rag_slim/haiku/rag/store/repositories/settings.py b/haiku_rag_slim/haiku/rag/store/repositories/settings.py index ee4020d2..5d7fd2e1 100644 --- a/haiku_rag_slim/haiku/rag/store/repositories/settings.py +++ b/haiku_rag_slim/haiku/rag/store/repositories/settings.py @@ -102,20 +102,21 @@ class SettingsRepository: await self.store.settings_table.add([settings_record]) async def validate_config_compatibility(self) -> None: - """Validate that the current configuration is compatible with stored settings. + """Validate the current configuration against stored settings without writing. - ``vector_dim`` mismatches raise — corpus and query vectors must live in - the same dimensional space. ``provider`` and ``name`` mismatches are - treated as soft drift: legitimate when the same model is served by a - different stack (Ollama vs vLLM-via-openai, etc.). Surface the change - once via a warning and overwrite stored settings so the warning does - not fire on every subsequent open. + Opening a database never modifies it. ``vector_dim`` mismatches raise — + corpus and query vectors must live in the same dimensional space. + ``provider`` and ``name`` drift (with matching ``vector_dim``) is soft: + legitimate when the same model is served by a different stack (Ollama vs + vLLM-via-openai, etc.). Drift is surfaced via a warning; a writable open + then raises so a write cannot mix embedding identities in the corpus, + while a read-only open continues. Stored settings are reconciled + explicitly via ``haiku-rag rebuild --set-embedder``, never on open. """ stored_settings = await self.get_current_settings() - # If no stored settings, this is a new database - save current config and return + # Nothing stored to validate against — never write on open. if not stored_settings: - await self.save_current_settings() return current_config = self.store._config.model_dump(mode="json") @@ -155,9 +156,15 @@ class SettingsRepository: if soft_changes: logger.warning( - "Embedding identity changed (vector_dim matches, stored settings " - "will be updated to match current config): %s. If this is not " - "intentional, revert your config to match the stored settings.", + "Embedding identity changed (vector_dim matches): %s. If this is " + "intentional, run 'haiku-rag rebuild --set-embedder' to update the " + "stored settings; otherwise revert your config to match the database.", "; ".join(soft_changes), ) - await self.save_current_settings() + if not self.store.is_read_only: + raise ConfigMismatchError( + "Database embedding identity differs from current config " + f"(vector_dim matches): {'; '.join(soft_changes)}. " + "Run 'haiku-rag rebuild --set-embedder' to adopt the current " + "embedder, or revert your config to match the database." + ) diff --git a/tests/store/test_migrations.py b/tests/store/test_migrations.py index ff8f0f94..763649c4 100644 --- a/tests/store/test_migrations.py +++ b/tests/store/test_migrations.py @@ -38,21 +38,19 @@ class TestMigrationCheck: pass @pytest.mark.asyncio - async def test_version_bump_without_pending_migrations_updates_silently( - self, temp_db_path - ): - """When version is outdated but no migrations pending, update version silently.""" + async def test_open_does_not_change_version(self, temp_db_path): + """Opening a database never writes the version, even when it differs. + + A version newer than all upgrade steps has no pending migrations, so the + open succeeds; the stored version must be left untouched (no downgrade, + no bump) because opening is a pure read. + """ async with Store(temp_db_path, create=True) as store: - # Set an older version that has no pending migrations - # (newer than all current upgrade steps) await store.set_haiku_version("100.0.0") - # Re-open - should update version silently, no error + # Re-open writable - no error, version unchanged async with Store(temp_db_path) as store: - # Version should now be current - version = await store.get_haiku_version() - expected = metadata.version("haiku.rag-slim") - assert version == expected + assert await store.get_haiku_version() == "100.0.0" @pytest.mark.asyncio async def test_pending_migrations_raises_error(self, temp_db_path): @@ -160,6 +158,21 @@ class TestMigrateMethod: applied = await store.migrate() assert applied == [] + @pytest.mark.asyncio + async def test_migrate_does_not_downgrade_future_version(self, temp_db_path): + """migrate() must not move the version backwards. + + Running an older build's migrate against a DB stamped with a newer + version (no pending upgrades) leaves the stored version untouched. + """ + async with Store(temp_db_path, create=True) as store: + await store.set_haiku_version("100.0.0") + + async with Store(temp_db_path, skip_migration_check=True) as store: + applied = await store.migrate() + assert applied == [] + assert await store.get_haiku_version() == "100.0.0" + @pytest.mark.asyncio async def test_migrate_raises_read_only_error(self, temp_db_path): """Store.migrate() should raise ReadOnlyError in read-only mode.""" diff --git a/tests/store/test_read_only.py b/tests/store/test_read_only.py index 9a5e191c..df2fa2cc 100644 --- a/tests/store/test_read_only.py +++ b/tests/store/test_read_only.py @@ -298,3 +298,61 @@ class TestClientReadOnly: async with HaikuRAG(temp_db_path, read_only=True) as client: docs = await client.list_documents() assert len(docs) == 1 + + +class TestAppReadVerbsDoNotWrite: + @pytest.mark.asyncio + async def test_read_verb_leaves_settings_and_version_unchanged_on_drift( + self, temp_db_path + ): + """A read CLI verb opens read-only: drift warns but never writes.""" + from haiku.rag.app import HaikuRAGApp + from haiku.rag.config import AppConfig + + async with Store(temp_db_path, create=True) as store: + stored_name_before = ( + await SettingsRepository(store).get_current_settings() + )["embeddings"]["model"]["name"] + version_before = await store.get_haiku_version() + + drift = AppConfig() + drift.embeddings.model.name = "different-model" + + # list is a read verb — must open read-only and not raise on drift + app = HaikuRAGApp(db_path=temp_db_path, config=drift) + await app.list_documents() + + async with Store(temp_db_path, skip_validation=True, read_only=True) as store: + stored_name_after = ( + await SettingsRepository(store).get_current_settings() + )["embeddings"]["model"]["name"] + version_after = await store.get_haiku_version() + + assert stored_name_after == stored_name_before + assert version_after == version_before + + @pytest.mark.asyncio + async def test_write_verb_raises_on_drift_without_writing(self, temp_db_path): + """A write CLI verb opens writable: drift raises before any write.""" + from haiku.rag.app import HaikuRAGApp + from haiku.rag.config import AppConfig + from haiku.rag.store.repositories.settings import ConfigMismatchError + + async with Store(temp_db_path, create=True) as store: + stored_name_before = ( + await SettingsRepository(store).get_current_settings() + )["embeddings"]["model"]["name"] + + drift = AppConfig() + drift.embeddings.model.name = "different-model" + + app = HaikuRAGApp(db_path=temp_db_path, config=drift) + with pytest.raises(ConfigMismatchError): + await app.add_document_from_text("hello") + + async with Store(temp_db_path, skip_validation=True, read_only=True) as store: + stored_name_after = ( + await SettingsRepository(store).get_current_settings() + )["embeddings"]["model"]["name"] + + assert stored_name_after == stored_name_before diff --git a/tests/test_rebuild.py b/tests/test_rebuild.py index f15aa8bb..1bf9ebd0 100644 --- a/tests/test_rebuild.py +++ b/tests/test_rebuild.py @@ -960,3 +960,84 @@ async def test_rebuild_descriptions_raises_when_blob_is_missing( with pytest.raises(ValueError, match="rebuild --descriptions requires"): async for _ in rag.rebuild_database(mode=RebuildMode.DESCRIPTIONS): pass + + +async def _add_chunk(client: HaikuRAG, vector: list[float]) -> str: + """Insert a chunk row directly, bypassing the embedder.""" + record = client.store.ChunkRecord( + id="chunk-1", + document_id="doc-1", + content="hello", + content_fts="hello", + metadata="{}", + order=0, + vector=vector, + ) + await client.store.chunks_table.add([record]) + return record.id + + +async def _stored_embedding_name(client: HaikuRAG) -> str: + from haiku.rag.store.repositories.settings import SettingsRepository + + settings = await SettingsRepository(client.store).get_current_settings() + return settings["embeddings"]["model"]["name"] + + +async def test_rebuild_set_embedder_adopts_identity_without_reembedding(temp_db_path): + """SET_EMBEDDER updates stored embedder identity and leaves vectors untouched.""" + from haiku.rag.config import AppConfig + + dim = AppConfig().embeddings.model.vector_dim + sentinel = [0.5] * dim + + async with HaikuRAG(temp_db_path, create=True) as client: + await _add_chunk(client, sentinel) + + drift = AppConfig() + drift.embeddings.model.name = "different-model" + + async with HaikuRAG(temp_db_path, config=drift, skip_validation=True) as client: + async for _ in client.rebuild_database(mode=RebuildMode.SET_EMBEDDER): + pass + + assert await _stored_embedding_name(client) == "different-model" + + rows = (await client.store.chunks_table.query().to_arrow()).to_pylist() + assert len(rows) == 1 + assert rows[0]["vector"] == pytest.approx(sentinel) + + +async def test_rebuild_set_embedder_works_on_empty_database(temp_db_path): + """SET_EMBEDDER reconciles even with no documents (preflight must be bypassed).""" + from haiku.rag.app import HaikuRAGApp + from haiku.rag.config import AppConfig + + async with HaikuRAG(temp_db_path, create=True): + pass + + drift = AppConfig() + drift.embeddings.model.name = "different-model" + + app = HaikuRAGApp(db_path=temp_db_path, config=drift) + await app.rebuild(mode=RebuildMode.SET_EMBEDDER) + + async with HaikuRAG(temp_db_path, config=drift, skip_validation=True) as client: + assert await _stored_embedding_name(client) == "different-model" + + +async def test_rebuild_set_embedder_raises_on_vector_dim_mismatch(temp_db_path): + """SET_EMBEDDER refuses when the vector dimension changed — a full rebuild is needed.""" + from haiku.rag.config import AppConfig + from haiku.rag.store.repositories.settings import ConfigMismatchError + + async with HaikuRAG(temp_db_path, create=True): + pass + + drift = AppConfig() + drift.embeddings.model.vector_dim = 9999 + + async with HaikuRAG(temp_db_path, config=drift, skip_validation=True) as client: + with pytest.raises(ConfigMismatchError): + async for _ in client.rebuild_database(mode=RebuildMode.SET_EMBEDDER): + pass diff --git a/tests/test_settings.py b/tests/test_settings.py index 860854b3..b4e1f478 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -48,8 +48,8 @@ class TestValidateConfigCompatibility: """Tests for validate_config_compatibility method.""" @pytest.mark.asyncio - async def test_empty_settings_saves_config(self, temp_db_path): - """When settings row is missing, validation saves current config.""" + async def test_empty_settings_does_not_write(self, temp_db_path): + """Validation never writes on open, even when the settings row is missing.""" from haiku.rag.store.engine import Store from haiku.rag.store.repositories.settings import SettingsRepository @@ -60,14 +60,10 @@ class TestValidateConfigCompatibility: await store.settings_table.delete("id = 'settings'") assert await settings_repo.get_current_settings() == {} - # Validation should save settings + # Validation must not write — nothing to validate against await settings_repo.validate_config_compatibility() - # Now settings should exist - saved = await settings_repo.get_current_settings() - assert ( - saved.get("embeddings", {}).get("model", {}).get("provider") is not None - ) + assert await settings_repo.get_current_settings() == {} @pytest.mark.asyncio async def test_compatible_config_no_error(self, temp_db_path): @@ -82,15 +78,14 @@ class TestValidateConfigCompatibility: await settings_repo.validate_config_compatibility() @pytest.mark.asyncio - async def test_provider_mismatch_warns_and_syncs( + async def test_provider_drift_read_only_warns_without_writing( self, temp_db_path, caplog, monkeypatch ): - """Provider mismatch with matching vector_dim warns and overwrites stored. + """Provider drift (vector_dim matches) on a read-only store warns and continues. Same model served by a different stack (Ollama vs vLLM via openai-compat) - legitimately differs in `provider`. Validation should surface the change - once, then trust the user's config as the new source of truth so the - warning doesn't fire on every subsequent open. + legitimately differs in `provider`. A read-only open surfaces the change + but must never modify the stored settings. """ from haiku.rag.store.engine import Store from haiku.rag.store.repositories.settings import SettingsRepository @@ -107,7 +102,7 @@ class TestValidateConfigCompatibility: new_config.embeddings.model.provider = "openai" async with Store( - temp_db_path, config=new_config, skip_validation=True + temp_db_path, config=new_config, skip_validation=True, read_only=True ) as store2: settings_repo = SettingsRepository(store2) @@ -122,25 +117,57 @@ class TestValidateConfigCompatibility: for r in caplog.records ) - # Stored settings now match current + # Stored settings are untouched saved = await settings_repo.get_current_settings() - assert saved["embeddings"]["model"]["provider"] == "openai" - - # Second open is silent — stored matches current. - caplog.clear() - with caplog.at_level(logging.WARNING): - await settings_repo.validate_config_compatibility() - assert not any("provider" in r.getMessage() for r in caplog.records) + assert saved["embeddings"]["model"]["provider"] == "ollama" @pytest.mark.asyncio - async def test_model_mismatch_warns_and_syncs( + async def test_provider_drift_writable_raises_without_writing( self, temp_db_path, caplog, monkeypatch ): - """Model name mismatch with matching vector_dim warns and overwrites stored.""" + """Provider drift on a writable store warns and raises, without writing.""" + from haiku.rag.store.engine import Store + from haiku.rag.store.repositories.settings import ( + ConfigMismatchError, + SettingsRepository, + ) + + monkeypatch.setattr(logging.getLogger("haiku.rag"), "propagate", True) + + async with Store(temp_db_path, create=True): + pass + + new_config = AppConfig() + new_config.embeddings.model.provider = "openai" + + async with Store( + temp_db_path, config=new_config, skip_validation=True + ) as store2: + settings_repo = SettingsRepository(store2) + + with caplog.at_level(logging.WARNING): + with pytest.raises(ConfigMismatchError): + await settings_repo.validate_config_compatibility() + + assert any( + "provider" in r.getMessage() + and "ollama" in r.getMessage() + and "openai" in r.getMessage() + for r in caplog.records + ) + + # Stored settings are untouched despite the writable open + saved = await settings_repo.get_current_settings() + assert saved["embeddings"]["model"]["provider"] == "ollama" + + @pytest.mark.asyncio + async def test_model_drift_read_only_warns_without_writing( + self, temp_db_path, caplog, monkeypatch + ): + """Model name drift (vector_dim matches) on a read-only store warns, no write.""" from haiku.rag.store.engine import Store from haiku.rag.store.repositories.settings import SettingsRepository - # See test_provider_mismatch_warns_and_syncs for the propagate=True rationale. monkeypatch.setattr(logging.getLogger("haiku.rag"), "propagate", True) async with Store(temp_db_path, create=True): @@ -150,7 +177,7 @@ class TestValidateConfigCompatibility: new_config.embeddings.model.name = "different-model" async with Store( - temp_db_path, config=new_config, skip_validation=True + temp_db_path, config=new_config, skip_validation=True, read_only=True ) as store2: settings_repo = SettingsRepository(store2) @@ -163,7 +190,7 @@ class TestValidateConfigCompatibility: ) saved = await settings_repo.get_current_settings() - assert saved["embeddings"]["model"]["name"] == "different-model" + assert saved["embeddings"]["model"]["name"] != "different-model" @pytest.mark.asyncio async def test_vector_dim_mismatch_raises_error(self, temp_db_path): @@ -189,3 +216,25 @@ class TestValidateConfigCompatibility: assert "vector dimension" in str(exc_info.value) assert "9999" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_vector_dim_mismatch_raises_error_read_only(self, temp_db_path): + """vector_dim mismatch raises even read-only — search cannot work.""" + from haiku.rag.store.engine import Store + from haiku.rag.store.repositories.settings import SettingsRepository + + async with Store(temp_db_path, create=True): + pass + + new_config = AppConfig() + new_config.embeddings.model.vector_dim = 9999 + + async with Store( + temp_db_path, config=new_config, skip_validation=True, read_only=True + ) as store2: + settings_repo = SettingsRepository(store2) + + with pytest.raises(ConfigMismatchError) as exc_info: + await settings_repo.validate_config_compatibility() + + assert "9999" in str(exc_info.value)