From 5d554ec58614224447eb35d1726df6a12f8be1c6 Mon Sep 17 00:00:00 2001 From: Varun Sahni Date: Thu, 20 Aug 2026 08:17:46 +0530 Subject: [PATCH] fix: cherry-pick recursion guard and doctor embedding checks from #1005, #1006 Consolidates the remaining #994 fixes into this PR so it can fully close the issue, per maintainer request. From #1005 (yzxcj797): - EmbeddingGeneratorWithProvenance.__getattr__ self-recursion guard: accessing self._generator via attribute syntax re-entered __getattr__ forever when _generator was absent (failed __init__, pickle/copy probes like __deepcopy__). Private-name lookups now raise AttributeError. - 4 regression tests in TestMethodDispatchRecursion: default dispatch no longer self-recurses for generation/text, a user-registered custom method still takes precedence, and a bare provenance wrapper raises AttributeError instead of RecursionError. (The methods.py identity guards from #1005 are already present here.) From #1006 (yzxcj797): - doctor gains two embedding backend checks, "Embeddings (sentence-transformers)" and "Embeddings (fastembed)". Default is a cheap import+version check (uninstalled backend now reports fail with a pip hint instead of invisible). --deep-embeddings (or SEMANTICA_DOCTOR_DEEP_EMBEDDINGS=1) instantiates via TextEmbedder and embeds a probe, catching backends that import cleanly but cannot load (the #994 failure mode) via the hash-fallback-active signal. _DeepEmbeddingFailure marks post-import runtime/model-load failures so they get a remediation hint instead of a misleading pip-install hint. - 7 tests in TestDoctorEmbeddings and TestDoctorEmbeddingHintsAndEnv. Validation: - tests/test_cli_commands.py: 237 passed (7 new) - tests/test_embedding_providers.py: 9 passed (4 new) - AST parse + import of all four modules OK --- semantica/cli.py | 68 ++++++++++- semantica/embeddings/embeddings_provenance.py | 9 ++ tests/test_cli_commands.py | 115 ++++++++++++++++++ tests/test_embedding_providers.py | 46 +++++++ 4 files changed, 237 insertions(+), 1 deletion(-) diff --git a/semantica/cli.py b/semantica/cli.py index 7f886a82..805bccdc 100644 --- a/semantica/cli.py +++ b/semantica/cli.py @@ -773,10 +773,22 @@ def changelog(cli_ctx: CLIContext, local_json: bool) -> None: _run_with_error_handling(_action) +class _DeepEmbeddingFailure(Exception): + """A deep-probe failure from doctor's embedding checks. + + Marks failures that happened AFTER the backend imported cleanly — model + load, probe, or runtime problems — so the check's hint can point at the + real remediation instead of `pip install`. + """ + + @main.command() @click.option("--json", "local_json", is_flag=True, default=False) +@click.option("--deep-embeddings", "deep_embeddings", is_flag=True, default=False, + help="Also instantiate the local embedding backends and embed a probe " + "text (catches backends that import cleanly but cannot load).") @click.pass_obj -def doctor(cli_ctx: CLIContext, local_json: bool) -> None: +def doctor(cli_ctx: CLIContext, local_json: bool, deep_embeddings: bool) -> None: """Run a health check on all Semantica components and backends.""" import importlib.metadata cli_ctx = _require_ctx(cli_ctx) @@ -787,6 +799,16 @@ def doctor(cli_ctx: CLIContext, local_json: bool) -> None: try: note = fn() return label, "ok", note, None + except _DeepEmbeddingFailure as exc: + # A deep-probe failure means the package IMPORTED fine: the pip + # hint would be the wrong remediation for what is actually a + # runtime/model-load problem (broken torch, failed model + # download, missing shared libs). + return label, "fail", str(exc), ( + "runtime/model-load failure — reinstalling the package usually " + "does not help; check the warnings above (torch install, model " + "download, disk space)" + ) except Exception as exc: return label, "fail", str(exc), hint @@ -827,6 +849,50 @@ def doctor(cli_ctx: CLIContext, local_json: bool) -> None: return f"{backend} importable" checks.append(_check("Vector store", _vector, hint="pip install semantica[vectorstore-…]")) + # Embedding backends (#994): `doctor` used to report all green while + # every local embedding backend was non-functional — import success + # says nothing about model loading. Default checks stay cheap + # (import + version); --deep-embeddings (or + # SEMANTICA_DOCTOR_DEEP_EMBEDDINGS=1) instantiates the backend through + # TextEmbedder and embeds a probe, which is the only level that + # catches a backend that imports cleanly but cannot actually load. + deep = deep_embeddings or os.environ.get("SEMANTICA_DOCTOR_DEEP_EMBEDDINGS", "").strip().lower() in ("1", "true", "yes", "on") + + def _embedding_backend(method: str) -> str: + if method == "sentence_transformers": + import sentence_transformers # noqa: F401 + note = f"importable ({importlib.metadata.version('sentence-transformers')})" + else: + import fastembed # noqa: F401 + note = f"importable ({importlib.metadata.version('fastembed')})" + if not deep: + return note + try: + from .embeddings import TextEmbedder + embedder = TextEmbedder(method=method) + if embedder.model is None and embedder.fastembed_model is None: + raise RuntimeError( + "model failed to load — the hash fallback is active " + "(see warnings above); embedding quality is degraded" + ) + probe = embedder.embed_text("semantica doctor embedding probe") + except _DeepEmbeddingFailure: + raise + except Exception as exc: + raise _DeepEmbeddingFailure(str(exc)) from exc + return f"{note}; deep probe ok ({len(probe)}-dim)" + + checks.append(_check( + "Embeddings (sentence-transformers)", + lambda: _embedding_backend("sentence_transformers"), + hint="pip install sentence-transformers", + )) + checks.append(_check( + "Embeddings (fastembed)", + lambda: _embedding_backend("fastembed"), + hint="pip install fastembed", + )) + # LLM provider keys for provider, var in [("OpenAI", "OPENAI_API_KEY"), ("Anthropic", "ANTHROPIC_API_KEY"), ("Groq", "GROQ_API_KEY")]: diff --git a/semantica/embeddings/embeddings_provenance.py b/semantica/embeddings/embeddings_provenance.py index 03d7adbd..886fd8c1 100644 --- a/semantica/embeddings/embeddings_provenance.py +++ b/semantica/embeddings/embeddings_provenance.py @@ -69,6 +69,15 @@ class EmbeddingGeneratorWithProvenance: return embeddings def __getattr__(self, name): + # __getattr__ only runs when normal lookup fails. Accessing + # self._generator by attribute syntax HERE would re-enter + # __getattr__ for ever when _generator itself is missing — the shape + # pickle/copy protocol probes hit when __init__ never completed + # (#994's RecursionError family). Fail fast on private probes. + if name.startswith("_"): + raise AttributeError( + f"{type(self).__name__!r} object has no attribute {name!r}" + ) return getattr(self._generator, name) diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 44e6d2a9..f2b626f7 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -1851,3 +1851,118 @@ class TestExitCodes: assert "Traceback" not in result.output, ( f"Traceback found for {argv}: {result.output}" ) + + +class TestDoctorEmbeddings: + """#994: doctor must surface non-functional embedding backends instead of + reporting all green. Default = import-level check; --deep-embeddings (or + SEMANTICA_DOCTOR_DEEP_EMBEDDINGS=1) instantiates via TextEmbedder.""" + + def _doctor_checks(self, runner, *extra): + result = runner.invoke(cli_module.main, ["doctor", "--json", *extra]) + _ok(result) + import json as _json + return {c["check"]: c for c in _json.loads(result.output)} + + def _with_fake_st(self, monkeypatch, **embedder_attrs): + fake_st = _fake_module( + __version__="9.9.9", + SentenceTransformer=object, + ) + monkeypatch.setitem(__import__("sys").modules, "sentence_transformers", fake_st) + + def test_doctor_reports_embedding_checks(self, runner): + checks = self._doctor_checks(runner) + assert "Embeddings (sentence-transformers)" in checks + assert "Embeddings (fastembed)" in checks + + def test_import_failure_is_fail_status_with_hint(self, runner): + checks = self._doctor_checks(runner) + st = checks["Embeddings (sentence-transformers)"] + if st["status"] == "fail": + assert st["hint"] == "pip install sentence-transformers" + + def test_deep_probe_detects_fallback_active(self, runner, monkeypatch): + self._with_fake_st(monkeypatch) + fake_embedder = types.SimpleNamespace(model=None, fastembed_model=None) + + fake_emb_mod = _fake_module(TextEmbedder=lambda **k: fake_embedder) + monkeypatch.setitem(__import__("sys").modules, "semantica.embeddings", fake_emb_mod) + + checks = self._doctor_checks(runner, "--deep-embeddings") + st = checks["Embeddings (sentence-transformers)"] + assert st["status"] == "fail" + assert "hash fallback" in st["note"] + + def test_deep_probe_ok_when_model_loads(self, runner, monkeypatch): + self._with_fake_st(monkeypatch) + import numpy as np + fake_embedder = types.SimpleNamespace( + model=object(), + fastembed_model=None, + embed_text=lambda text: np.zeros(384, dtype=np.float32), + ) + fake_emb_mod = _fake_module(TextEmbedder=lambda **k: fake_embedder) + monkeypatch.setitem(__import__("sys").modules, "semantica.embeddings", fake_emb_mod) + + checks = self._doctor_checks(runner, "--deep-embeddings") + st = checks["Embeddings (sentence-transformers)"] + assert st["status"] == "ok" + assert "384-dim" in st["note"] + + def test_env_var_enables_deep_mode(self, runner, monkeypatch): + monkeypatch.setenv("SEMANTICA_DOCTOR_DEEP_EMBEDDINGS", "1") + self._with_fake_st(monkeypatch) + fake_embedder = types.SimpleNamespace(model=None, fastembed_model=None) + fake_emb_mod = _fake_module(TextEmbedder=lambda **k: fake_embedder) + monkeypatch.setitem(__import__("sys").modules, "semantica.embeddings", fake_emb_mod) + + checks = self._doctor_checks(runner) + st = checks["Embeddings (sentence-transformers)"] + assert st["status"] == "fail" + assert "hash fallback" in st["note"] + + +class TestDoctorEmbeddingHintsAndEnv: + """Review follow-ups: deep failures must not carry the pip-install hint, + and the env toggle tolerates case/whitespace variants.""" + + def _doctor_checks(self, runner, *extra): + result = runner.invoke(cli_module.main, ["doctor", "--json", *extra]) + _ok(result) + import json as _json + return {c["check"]: c for c in _json.loads(result.output)} + + def _with_fake_st(self, monkeypatch): + fake_st = _fake_module( + __version__="9.9.9", + SentenceTransformer=object, + ) + monkeypatch.setitem(__import__("sys").modules, "sentence_transformers", fake_st) + + def test_deep_failure_hint_is_not_pip_install(self, runner, monkeypatch): + self._with_fake_st(monkeypatch) + fake_embedder = types.SimpleNamespace(model=None, fastembed_model=None) + fake_emb_mod = _fake_module(TextEmbedder=lambda **k: fake_embedder) + monkeypatch.setitem(__import__("sys").modules, "semantica.embeddings", fake_emb_mod) + + checks = self._doctor_checks(runner, "--deep-embeddings") + st = checks["Embeddings (sentence-transformers)"] + assert st["status"] == "fail" + assert "pip install" not in (st["hint"] or ""), ( + "a deep probe failure means the package imported fine — pointing " + "users at pip sends them to reinstall for a runtime/model problem" + ) + assert "runtime/model-load" in st["hint"] + + def test_env_var_tolerates_case_and_whitespace(self, runner, monkeypatch): + monkeypatch.setenv("SEMANTICA_DOCTOR_DEEP_EMBEDDINGS", " TRUE ") + self._with_fake_st(monkeypatch) + fake_embedder = types.SimpleNamespace(model=None, fastembed_model=None) + fake_emb_mod = _fake_module(TextEmbedder=lambda **k: fake_embedder) + monkeypatch.setitem(__import__("sys").modules, "semantica.embeddings", fake_emb_mod) + + checks = self._doctor_checks(runner) + st = checks["Embeddings (sentence-transformers)"] + assert st["status"] == "fail" + assert "hash fallback" in st["note"], "padded/caps env value must enable deep mode" diff --git a/tests/test_embedding_providers.py b/tests/test_embedding_providers.py index e41de0db..a3e02576 100644 --- a/tests/test_embedding_providers.py +++ b/tests/test_embedding_providers.py @@ -85,3 +85,49 @@ if __name__ == '__main__': runner = unittest.TextTestRunner(stream=f, verbosity=2) unittest.main(testRunner=runner, exit=False) + +class TestMethodDispatchRecursion(unittest.TestCase): + """#994: built-in aliases are registered in the method registry onto the + wrapper functions themselves, so dispatching through the registry called a + wrapper back into itself with the same default method — a recursion storm + that surfaced as `maximum recursion depth exceeded` during model loading.""" + + def test_generate_embeddings_default_does_not_self_recurse(self): + from semantica.embeddings.methods import generate_embeddings + emb = generate_embeddings("recursion probe") + self.assertIsNotNone(emb) + + def test_embed_text_default_does_not_self_recurse(self): + from semantica.embeddings.methods import embed_text + emb = embed_text("recursion probe", method="sentence_transformers") + self.assertIsNotNone(emb) + + def test_custom_registered_method_still_wins(self): + from semantica.embeddings.methods import method_registry + calls = [] + + def spy(data, *a, **k): + calls.append(data) + return {"custom": True} + + method_registry.register("generation", "my_custom_gen", spy) + try: + from semantica.embeddings.methods import generate_embeddings + out = generate_embeddings("payload", method="my_custom_gen") + self.assertEqual(out, {"custom": True}) + self.assertEqual(calls, ["payload"]) + finally: + method_registry.unregister("generation", "my_custom_gen") + + def test_provenance_wrapper_missing_generator_raises_attribute_error(self): + # Partially-initialised wrappers (failed __init__, pickle/copy probes) + # must raise AttributeError, not RecursionError via __getattr__. + from semantica.embeddings.embeddings_provenance import ( + EmbeddingGeneratorWithProvenance, + ) + bare = EmbeddingGeneratorWithProvenance.__new__( + EmbeddingGeneratorWithProvenance + ) + with self.assertRaises(AttributeError): + getattr(bare, "model") +