diff --git a/cyteonto/cyteonto.py b/cyteonto/cyteonto.py index ffdb5ed..11cf620 100644 --- a/cyteonto/cyteonto.py +++ b/cyteonto/cyteonto.py @@ -40,6 +40,10 @@ def _api_key_for_provider(provider: str, fallback: str = "") -> str | None: return fallback or None +def _is_empty(label: str) -> bool: + return not label or not label.strip() + + class CyteOnto: """Compare two sets of cell type annotations against the Cell Ontology. @@ -345,7 +349,9 @@ async def _embed_user_labels( lbl: d for lbl, d in (raw_existing or {}).items() if not d.is_blank() } - all_real = use_cache and all(lbl in existing for lbl in labels) + all_real = use_cache and all( + _is_empty(lbl) or lbl in existing for lbl in labels + ) if all_real: cached = storage.load_user_embeddings(emb_path) if cached is not None and cached[1] == labels: @@ -355,7 +361,9 @@ async def _embed_user_labels( return cached[0] unique_labels = list(dict.fromkeys(labels)) - missing = [lbl for lbl in unique_labels if lbl not in existing] + missing = [ + lbl for lbl in unique_labels if not _is_empty(lbl) and lbl not in existing + ] if missing: logger.info( f"Generating {len(missing)} new descriptions for '{identifier}' " @@ -369,13 +377,17 @@ async def _embed_user_labels( existing[lbl] = desc storage.save_descriptions(desc_path, existing, self.llm_key) - # Build the text to embed for every label position. Blanks are not - # cached, so the raw label text is used as a fallback to keep the - # array aligned. Next compare(...) run will retry description - # generation for those labels and overwrite. - texts: list[str] = [] + # Build the text to embed for every label position. Empty labels are + # skipped entirely (no description, no embedding) and get a zero + # vector below. Blanks are not cached, so the raw label text is used + # as a fallback to keep the array aligned. Next compare(...) run will + # retry description generation for those labels and overwrite. + texts: list[str | None] = [] fallback_count = 0 for lbl in labels: + if _is_empty(lbl): + texts.append(None) + continue desc = existing.get(lbl, CellDescription.blank(label=lbl)) if desc is not None and not desc.is_blank(): texts.append(desc.to_sentence()) @@ -389,30 +401,37 @@ async def _embed_user_labels( "label text as a fallback; they will be retried on the next run." ) - # Only embed each unique text once, then fan results back out so the - # final array stays aligned with the original `labels` order/length. + # Only embed each unique non-empty text once, then fan results back + # out so the final array stays aligned with `labels` order/length. text_to_idx: dict[str, int] = {} unique_texts: list[str] = [] for t in texts: + if t is None: + continue if t not in text_to_idx: text_to_idx[t] = len(unique_texts) unique_texts.append(t) - if len(unique_texts) < len(texts): + if len(unique_texts) < len([t for t in texts if t is not None]): logger.info( f"Embedding {len(unique_texts)} unique texts for '{identifier}' " f"({len(texts)} total label positions)" ) - unique_embeddings = await self._embed_with_failover(unique_texts) - if unique_embeddings is None: - raise RuntimeError(f"Failed to embed labels for '{identifier}'") + if unique_texts: + unique_embeddings = await self._embed_with_failover(unique_texts) + if unique_embeddings is None: + raise RuntimeError(f"Failed to embed labels for '{identifier}'") + dim = unique_embeddings.shape[1] + else: + dim = self._load_ontology_embeddings()[0].shape[1] + emb_path = self.paths.user_embeddings( run_id, kind, identifier, self.llm_key, self.embd_key ) - fan_out_idx = np.fromiter( - (text_to_idx[t] for t in texts), dtype=np.int64, count=len(texts) - ) - embeddings = unique_embeddings[fan_out_idx] + embeddings = np.zeros((len(labels), dim), dtype=np.float32) + for j, t in enumerate(texts): + if t is not None: + embeddings[j] = unique_embeddings[text_to_idx[t]] storage.save_user_embeddings( emb_path, @@ -530,6 +549,7 @@ async def compare( use_cache=use_cache, ) author_matches = self._match(author_emb, min_similarity=min_match_similarity) + author_empty = [_is_empty(lbl) for lbl in author_labels] similarity = self._ensure_similarity() rows: list[dict[str, Any]] = [] @@ -548,10 +568,11 @@ async def compare( use_cache=use_cache, ) algo_matches = self._match(algo_emb, min_similarity=min_match_similarity) + algo_empty = [_is_empty(lbl) for lbl in algo_labels] for i, (a_lbl, g_lbl) in enumerate(zip(author_labels, algo_labels)): - a_id, a_sim = author_matches[i] - g_id, g_sim = algo_matches[i] + a_id, a_sim = ("", 0.0) if author_empty[i] else author_matches[i] + g_id, g_sim = ("", 0.0) if algo_empty[i] else algo_matches[i] hier = ( similarity.similarity( a_id, g_id, metric=metric, metric_params=metric_params @@ -559,7 +580,11 @@ async def compare( if a_id and g_id else 0.0 ) - method = self._method_for(a_id, g_id, hier) + method = ( + "empty" + if (author_empty[i] or algo_empty[i]) + else self._method_for(a_id, g_id, hier) + ) rows.append( { "run_id": run_id, diff --git a/modal_app/api.py b/modal_app/api.py index 229d4b1..61264bf 100644 --- a/modal_app/api.py +++ b/modal_app/api.py @@ -133,7 +133,7 @@ def get_result(run_id: str, format: str = "json"): if status["state"] != "completed": raise HTTPException( 409, - f"Run is '{status['state']}', not completed", + f"Job is '{status['state']}', not completed", ) rel = status["resultJsonPath"] if format == "json" else status["resultCsvPath"] diff --git a/tests/test_cyteonto.py b/tests/test_cyteonto.py index 1c94ef6..2e3fdc3 100644 --- a/tests/test_cyteonto.py +++ b/tests/test_cyteonto.py @@ -1,10 +1,14 @@ """Tests for cyteonto.cyteonto pure units (no live agents or network).""" from pathlib import Path +from unittest.mock import AsyncMock, Mock import numpy as np +import pytest -from cyteonto.cyteonto import CyteOnto, _api_key_for_provider +from cyteonto import storage +from cyteonto.cyteonto import CyteOnto, _api_key_for_provider, _is_empty +from cyteonto.models import AgentUsage, CellDescription class TestApiKeyForProvider: @@ -43,9 +47,7 @@ class TestMatch: def _instance(self): # Bypass __init__ to test the pure matching logic in isolation. inst = object.__new__(CyteOnto) - inst._ontology_embeddings = np.array( - [[1.0, 0.0], [0.0, 1.0]], dtype=np.float32 - ) + inst._ontology_embeddings = np.array([[1.0, 0.0], [0.0, 1.0]], dtype=np.float32) inst._ontology_ids = ["CL:0000001", "CL:0000002"] return inst @@ -57,9 +59,7 @@ def test_exact_match(self): def test_below_threshold_returns_none(self): inst = self._instance() - out = inst._match( - np.array([[0.7, 0.7]], dtype=np.float32), min_similarity=0.99 - ) + out = inst._match(np.array([[0.7, 0.7]], dtype=np.float32), min_similarity=0.99) assert out[0][0] is None def test_one_dimensional_query_reshaped(self): @@ -76,3 +76,122 @@ def test_counts_only_files(self, temp_dir: Path): nested.mkdir() (nested / "b.txt").write_text("y") assert CyteOnto._count_files(temp_dir) == 2 + + +class TestIsEmpty: + def test_empty_string(self): + assert _is_empty("") is True + + def test_whitespace_only(self): + assert _is_empty(" ") is True + assert _is_empty("\t\n") is True + + def test_non_empty(self): + assert _is_empty("T cell") is False + assert _is_empty(" NK cell ") is False + + +class TestEmbedUserLabelsSkipsEmpty: + @pytest.mark.asyncio + async def test_empty_labels_not_described_and_zero_vectors(self, monkeypatch): + inst = object.__new__(CyteOnto) + inst.paths = Mock() + inst.paths.user_embeddings.return_value = Path("/tmp/emb.npz") + inst.paths.user_descriptions.return_value = Path("/tmp/desc.json") + inst.llm_key = Mock() + inst.embd_key = Mock() + inst.reasoning = False + inst.usage = AgentUsage(agentName="CyteOnto") + + described = CellDescription( + initialLabel="T cell", + descriptiveName="CD4+ helper T lymphocyte", + function="Coordinates immune responses", + diseaseRelevance="Autoimmune disease", + developmentalStage="Mature", + ) + + describe_mock = AsyncMock( + return_value=([described], AgentUsage(agentName="CellDescriptionAgent")) + ) + inst._describe_labels = describe_mock + inst._embed_with_failover = AsyncMock( + return_value=np.array([[1.0, 2.0]], dtype=np.float32) + ) + + monkeypatch.setattr(storage, "save_descriptions", lambda *a, **k: None) + monkeypatch.setattr(storage, "save_user_embeddings", lambda *a, **k: None) + + result = await inst._embed_user_labels( + labels=["T cell", "", " "], + run_id="run-test", + kind="author", + identifier="author", + use_cache=False, + ) + + # Only the single non-empty label is sent for description generation. + describe_mock.assert_awaited_once_with(["T cell"]) + # Only the non-empty description sentence is embedded. + embed_arg = inst._embed_with_failover.await_args.args[0] + assert embed_arg == [described.to_sentence()] + + assert result.shape == (3, 2) + np.testing.assert_array_equal(result[0], np.array([1.0, 2.0], dtype=np.float32)) + np.testing.assert_array_equal(result[1], np.zeros(2, dtype=np.float32)) + np.testing.assert_array_equal(result[2], np.zeros(2, dtype=np.float32)) + + +class TestCompareEmptyHandling: + @pytest.mark.asyncio + async def test_empty_positions_get_blank_id_zero_score_empty_method(self): + inst = object.__new__(CyteOnto) + inst._embed_user_labels = AsyncMock( + return_value=np.zeros((2, 2), dtype=np.float32) + ) + inst._match = Mock(return_value=[("CL:0000001", 0.95), ("CL:0000002", 0.80)]) + sim = Mock() + sim.similarity.return_value = 0.9 + inst._ensure_similarity = Mock(return_value=sim) + + df = await inst.compare( + author_labels=["T cell", ""], + algorithms={"algo0": ["B cell", ""], "algo1": ["", "NK cell"]}, + run_id="run-test", + ) + + def row(algo: str, idx: int) -> dict: + return df[(df.algorithm == algo) & (df.pair_index == idx)].iloc[0].to_dict() + + # Both labels present -> normal cytescore path. + r = row("algo0", 0) + assert r["author_ontology_id"] == "CL:0000001" + assert r["algorithm_ontology_id"] == "CL:0000001" + assert r["cytescore_similarity"] == 0.9 + assert r["similarity_method"] == "cytescore" + + # Both labels empty -> blank ids, zero scores, empty method. + r = row("algo0", 1) + assert r["author_ontology_id"] == "" + assert r["algorithm_ontology_id"] == "" + assert r["author_embedding_similarity"] == 0.0 + assert r["algorithm_embedding_similarity"] == 0.0 + assert r["cytescore_similarity"] == 0.0 + assert r["similarity_method"] == "empty" + + # Only algorithm label empty -> author side kept, algorithm blanked. + r = row("algo1", 0) + assert r["author_ontology_id"] == "CL:0000001" + assert r["author_embedding_similarity"] == 0.95 + assert r["algorithm_ontology_id"] == "" + assert r["algorithm_embedding_similarity"] == 0.0 + assert r["cytescore_similarity"] == 0.0 + assert r["similarity_method"] == "empty" + + # Only author label empty -> algorithm side kept, author blanked. + r = row("algo1", 1) + assert r["author_ontology_id"] == "" + assert r["algorithm_ontology_id"] == "CL:0000002" + assert r["algorithm_embedding_similarity"] == 0.80 + assert r["cytescore_similarity"] == 0.0 + assert r["similarity_method"] == "empty"