Skip to content

Commit c254912

Browse files
authored
Null fix (#41)
* fix empty input
1 parent 340ea3a commit c254912

3 files changed

Lines changed: 172 additions & 28 deletions

File tree

cyteonto/cyteonto.py

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ def _api_key_for_provider(provider: str, fallback: str = "") -> str | None:
4040
return fallback or None
4141

4242

43+
def _is_empty(label: str) -> bool:
44+
return not label or not label.strip()
45+
46+
4347
class CyteOnto:
4448
"""Compare two sets of cell type annotations against the Cell Ontology.
4549
@@ -345,7 +349,9 @@ async def _embed_user_labels(
345349
lbl: d for lbl, d in (raw_existing or {}).items() if not d.is_blank()
346350
}
347351

348-
all_real = use_cache and all(lbl in existing for lbl in labels)
352+
all_real = use_cache and all(
353+
_is_empty(lbl) or lbl in existing for lbl in labels
354+
)
349355
if all_real:
350356
cached = storage.load_user_embeddings(emb_path)
351357
if cached is not None and cached[1] == labels:
@@ -355,7 +361,9 @@ async def _embed_user_labels(
355361
return cached[0]
356362

357363
unique_labels = list(dict.fromkeys(labels))
358-
missing = [lbl for lbl in unique_labels if lbl not in existing]
364+
missing = [
365+
lbl for lbl in unique_labels if not _is_empty(lbl) and lbl not in existing
366+
]
359367
if missing:
360368
logger.info(
361369
f"Generating {len(missing)} new descriptions for '{identifier}' "
@@ -369,13 +377,17 @@ async def _embed_user_labels(
369377
existing[lbl] = desc
370378
storage.save_descriptions(desc_path, existing, self.llm_key)
371379

372-
# Build the text to embed for every label position. Blanks are not
373-
# cached, so the raw label text is used as a fallback to keep the
374-
# array aligned. Next compare(...) run will retry description
375-
# generation for those labels and overwrite.
376-
texts: list[str] = []
380+
# Build the text to embed for every label position. Empty labels are
381+
# skipped entirely (no description, no embedding) and get a zero
382+
# vector below. Blanks are not cached, so the raw label text is used
383+
# as a fallback to keep the array aligned. Next compare(...) run will
384+
# retry description generation for those labels and overwrite.
385+
texts: list[str | None] = []
377386
fallback_count = 0
378387
for lbl in labels:
388+
if _is_empty(lbl):
389+
texts.append(None)
390+
continue
379391
desc = existing.get(lbl, CellDescription.blank(label=lbl))
380392
if desc is not None and not desc.is_blank():
381393
texts.append(desc.to_sentence())
@@ -389,30 +401,37 @@ async def _embed_user_labels(
389401
"label text as a fallback; they will be retried on the next run."
390402
)
391403

392-
# Only embed each unique text once, then fan results back out so the
393-
# final array stays aligned with the original `labels` order/length.
404+
# Only embed each unique non-empty text once, then fan results back
405+
# out so the final array stays aligned with `labels` order/length.
394406
text_to_idx: dict[str, int] = {}
395407
unique_texts: list[str] = []
396408
for t in texts:
409+
if t is None:
410+
continue
397411
if t not in text_to_idx:
398412
text_to_idx[t] = len(unique_texts)
399413
unique_texts.append(t)
400-
if len(unique_texts) < len(texts):
414+
if len(unique_texts) < len([t for t in texts if t is not None]):
401415
logger.info(
402416
f"Embedding {len(unique_texts)} unique texts for '{identifier}' "
403417
f"({len(texts)} total label positions)"
404418
)
405419

406-
unique_embeddings = await self._embed_with_failover(unique_texts)
407-
if unique_embeddings is None:
408-
raise RuntimeError(f"Failed to embed labels for '{identifier}'")
420+
if unique_texts:
421+
unique_embeddings = await self._embed_with_failover(unique_texts)
422+
if unique_embeddings is None:
423+
raise RuntimeError(f"Failed to embed labels for '{identifier}'")
424+
dim = unique_embeddings.shape[1]
425+
else:
426+
dim = self._load_ontology_embeddings()[0].shape[1]
427+
409428
emb_path = self.paths.user_embeddings(
410429
run_id, kind, identifier, self.llm_key, self.embd_key
411430
)
412-
fan_out_idx = np.fromiter(
413-
(text_to_idx[t] for t in texts), dtype=np.int64, count=len(texts)
414-
)
415-
embeddings = unique_embeddings[fan_out_idx]
431+
embeddings = np.zeros((len(labels), dim), dtype=np.float32)
432+
for j, t in enumerate(texts):
433+
if t is not None:
434+
embeddings[j] = unique_embeddings[text_to_idx[t]]
416435

417436
storage.save_user_embeddings(
418437
emb_path,
@@ -530,6 +549,7 @@ async def compare(
530549
use_cache=use_cache,
531550
)
532551
author_matches = self._match(author_emb, min_similarity=min_match_similarity)
552+
author_empty = [_is_empty(lbl) for lbl in author_labels]
533553
similarity = self._ensure_similarity()
534554

535555
rows: list[dict[str, Any]] = []
@@ -548,18 +568,23 @@ async def compare(
548568
use_cache=use_cache,
549569
)
550570
algo_matches = self._match(algo_emb, min_similarity=min_match_similarity)
571+
algo_empty = [_is_empty(lbl) for lbl in algo_labels]
551572

552573
for i, (a_lbl, g_lbl) in enumerate(zip(author_labels, algo_labels)):
553-
a_id, a_sim = author_matches[i]
554-
g_id, g_sim = algo_matches[i]
574+
a_id, a_sim = ("", 0.0) if author_empty[i] else author_matches[i]
575+
g_id, g_sim = ("", 0.0) if algo_empty[i] else algo_matches[i]
555576
hier = (
556577
similarity.similarity(
557578
a_id, g_id, metric=metric, metric_params=metric_params
558579
)
559580
if a_id and g_id
560581
else 0.0
561582
)
562-
method = self._method_for(a_id, g_id, hier)
583+
method = (
584+
"empty"
585+
if (author_empty[i] or algo_empty[i])
586+
else self._method_for(a_id, g_id, hier)
587+
)
563588
rows.append(
564589
{
565590
"run_id": run_id,

modal_app/api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ def get_result(run_id: str, format: str = "json"):
133133
if status["state"] != "completed":
134134
raise HTTPException(
135135
409,
136-
f"Run is '{status['state']}', not completed",
136+
f"Job is '{status['state']}', not completed",
137137
)
138138

139139
rel = status["resultJsonPath"] if format == "json" else status["resultCsvPath"]

tests/test_cyteonto.py

Lines changed: 126 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
"""Tests for cyteonto.cyteonto pure units (no live agents or network)."""
22

33
from pathlib import Path
4+
from unittest.mock import AsyncMock, Mock
45

56
import numpy as np
7+
import pytest
68

7-
from cyteonto.cyteonto import CyteOnto, _api_key_for_provider
9+
from cyteonto import storage
10+
from cyteonto.cyteonto import CyteOnto, _api_key_for_provider, _is_empty
11+
from cyteonto.models import AgentUsage, CellDescription
812

913

1014
class TestApiKeyForProvider:
@@ -43,9 +47,7 @@ class TestMatch:
4347
def _instance(self):
4448
# Bypass __init__ to test the pure matching logic in isolation.
4549
inst = object.__new__(CyteOnto)
46-
inst._ontology_embeddings = np.array(
47-
[[1.0, 0.0], [0.0, 1.0]], dtype=np.float32
48-
)
50+
inst._ontology_embeddings = np.array([[1.0, 0.0], [0.0, 1.0]], dtype=np.float32)
4951
inst._ontology_ids = ["CL:0000001", "CL:0000002"]
5052
return inst
5153

@@ -57,9 +59,7 @@ def test_exact_match(self):
5759

5860
def test_below_threshold_returns_none(self):
5961
inst = self._instance()
60-
out = inst._match(
61-
np.array([[0.7, 0.7]], dtype=np.float32), min_similarity=0.99
62-
)
62+
out = inst._match(np.array([[0.7, 0.7]], dtype=np.float32), min_similarity=0.99)
6363
assert out[0][0] is None
6464

6565
def test_one_dimensional_query_reshaped(self):
@@ -76,3 +76,122 @@ def test_counts_only_files(self, temp_dir: Path):
7676
nested.mkdir()
7777
(nested / "b.txt").write_text("y")
7878
assert CyteOnto._count_files(temp_dir) == 2
79+
80+
81+
class TestIsEmpty:
82+
def test_empty_string(self):
83+
assert _is_empty("") is True
84+
85+
def test_whitespace_only(self):
86+
assert _is_empty(" ") is True
87+
assert _is_empty("\t\n") is True
88+
89+
def test_non_empty(self):
90+
assert _is_empty("T cell") is False
91+
assert _is_empty(" NK cell ") is False
92+
93+
94+
class TestEmbedUserLabelsSkipsEmpty:
95+
@pytest.mark.asyncio
96+
async def test_empty_labels_not_described_and_zero_vectors(self, monkeypatch):
97+
inst = object.__new__(CyteOnto)
98+
inst.paths = Mock()
99+
inst.paths.user_embeddings.return_value = Path("/tmp/emb.npz")
100+
inst.paths.user_descriptions.return_value = Path("/tmp/desc.json")
101+
inst.llm_key = Mock()
102+
inst.embd_key = Mock()
103+
inst.reasoning = False
104+
inst.usage = AgentUsage(agentName="CyteOnto")
105+
106+
described = CellDescription(
107+
initialLabel="T cell",
108+
descriptiveName="CD4+ helper T lymphocyte",
109+
function="Coordinates immune responses",
110+
diseaseRelevance="Autoimmune disease",
111+
developmentalStage="Mature",
112+
)
113+
114+
describe_mock = AsyncMock(
115+
return_value=([described], AgentUsage(agentName="CellDescriptionAgent"))
116+
)
117+
inst._describe_labels = describe_mock
118+
inst._embed_with_failover = AsyncMock(
119+
return_value=np.array([[1.0, 2.0]], dtype=np.float32)
120+
)
121+
122+
monkeypatch.setattr(storage, "save_descriptions", lambda *a, **k: None)
123+
monkeypatch.setattr(storage, "save_user_embeddings", lambda *a, **k: None)
124+
125+
result = await inst._embed_user_labels(
126+
labels=["T cell", "", " "],
127+
run_id="run-test",
128+
kind="author",
129+
identifier="author",
130+
use_cache=False,
131+
)
132+
133+
# Only the single non-empty label is sent for description generation.
134+
describe_mock.assert_awaited_once_with(["T cell"])
135+
# Only the non-empty description sentence is embedded.
136+
embed_arg = inst._embed_with_failover.await_args.args[0]
137+
assert embed_arg == [described.to_sentence()]
138+
139+
assert result.shape == (3, 2)
140+
np.testing.assert_array_equal(result[0], np.array([1.0, 2.0], dtype=np.float32))
141+
np.testing.assert_array_equal(result[1], np.zeros(2, dtype=np.float32))
142+
np.testing.assert_array_equal(result[2], np.zeros(2, dtype=np.float32))
143+
144+
145+
class TestCompareEmptyHandling:
146+
@pytest.mark.asyncio
147+
async def test_empty_positions_get_blank_id_zero_score_empty_method(self):
148+
inst = object.__new__(CyteOnto)
149+
inst._embed_user_labels = AsyncMock(
150+
return_value=np.zeros((2, 2), dtype=np.float32)
151+
)
152+
inst._match = Mock(return_value=[("CL:0000001", 0.95), ("CL:0000002", 0.80)])
153+
sim = Mock()
154+
sim.similarity.return_value = 0.9
155+
inst._ensure_similarity = Mock(return_value=sim)
156+
157+
df = await inst.compare(
158+
author_labels=["T cell", ""],
159+
algorithms={"algo0": ["B cell", ""], "algo1": ["", "NK cell"]},
160+
run_id="run-test",
161+
)
162+
163+
def row(algo: str, idx: int) -> dict:
164+
return df[(df.algorithm == algo) & (df.pair_index == idx)].iloc[0].to_dict()
165+
166+
# Both labels present -> normal cytescore path.
167+
r = row("algo0", 0)
168+
assert r["author_ontology_id"] == "CL:0000001"
169+
assert r["algorithm_ontology_id"] == "CL:0000001"
170+
assert r["cytescore_similarity"] == 0.9
171+
assert r["similarity_method"] == "cytescore"
172+
173+
# Both labels empty -> blank ids, zero scores, empty method.
174+
r = row("algo0", 1)
175+
assert r["author_ontology_id"] == ""
176+
assert r["algorithm_ontology_id"] == ""
177+
assert r["author_embedding_similarity"] == 0.0
178+
assert r["algorithm_embedding_similarity"] == 0.0
179+
assert r["cytescore_similarity"] == 0.0
180+
assert r["similarity_method"] == "empty"
181+
182+
# Only algorithm label empty -> author side kept, algorithm blanked.
183+
r = row("algo1", 0)
184+
assert r["author_ontology_id"] == "CL:0000001"
185+
assert r["author_embedding_similarity"] == 0.95
186+
assert r["algorithm_ontology_id"] == ""
187+
assert r["algorithm_embedding_similarity"] == 0.0
188+
assert r["cytescore_similarity"] == 0.0
189+
assert r["similarity_method"] == "empty"
190+
191+
# Only author label empty -> algorithm side kept, author blanked.
192+
r = row("algo1", 1)
193+
assert r["author_ontology_id"] == ""
194+
assert r["algorithm_ontology_id"] == "CL:0000002"
195+
assert r["algorithm_embedding_similarity"] == 0.80
196+
assert r["cytescore_similarity"] == 0.0
197+
assert r["similarity_method"] == "empty"

0 commit comments

Comments
 (0)