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
This commit is contained in:
Varun Sahni
2026-08-20 08:17:46 +05:30
parent 2ac3eaffd7
commit 5d554ec586
4 changed files with 237 additions and 1 deletions
+67 -1
View File
@@ -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")]:
@@ -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)
+115
View File
@@ -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"
+46
View File
@@ -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")