Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 45 additions & 20 deletions cyteonto/cyteonto.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand All @@ -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}' "
Expand All @@ -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())
Expand All @@ -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,
Expand Down Expand Up @@ -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]] = []
Expand All @@ -548,18 +568,23 @@ 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
)
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,
Expand Down
2 changes: 1 addition & 1 deletion modal_app/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
133 changes: 126 additions & 7 deletions tests/test_cyteonto.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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

Expand All @@ -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):
Expand All @@ -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"
Loading