annotuneagent 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. annotuneagent/__init__.py +68 -0
  2. annotuneagent/__main__.py +6 -0
  3. annotuneagent/_version.py +19 -0
  4. annotuneagent/annotate.py +93 -0
  5. annotuneagent/arbitrate.py +141 -0
  6. annotuneagent/cli.py +366 -0
  7. annotuneagent/decide.py +725 -0
  8. annotuneagent/evaluate.py +121 -0
  9. annotuneagent/evidence.py +606 -0
  10. annotuneagent/granularity/__init__.py +28 -0
  11. annotuneagent/granularity/cl_dag.py +179 -0
  12. annotuneagent/granularity/ontology_map.py +108 -0
  13. annotuneagent/granularity/relations.py +96 -0
  14. annotuneagent/hypotheses.py +38 -0
  15. annotuneagent/local_lora.py +706 -0
  16. annotuneagent/marker_plot.py +172 -0
  17. annotuneagent/markers.py +206 -0
  18. annotuneagent/markers.tsv +378 -0
  19. annotuneagent/mltools.py +128 -0
  20. annotuneagent/ontology/cl_isa.json +1 -0
  21. annotuneagent/ontology/granularity_policy.yaml +8 -0
  22. annotuneagent/ontology/ontology_map.jsonl +198 -0
  23. annotuneagent/prepare.py +1385 -0
  24. annotuneagent/py.typed +0 -0
  25. annotuneagent/read.py +76 -0
  26. annotuneagent/tokenizer/LICENSE +202 -0
  27. annotuneagent/tokenizer/merges.txt +247587 -0
  28. annotuneagent/tokenizer/tokenizer.json +495920 -0
  29. annotuneagent/tokenizer/tokenizer_config.json +305 -0
  30. annotuneagent/tokenizer/vocab.json +248046 -0
  31. annotuneagent/votes.py +83 -0
  32. annotuneagent/workflow.py +2037 -0
  33. annotuneagent-0.1.0.dist-info/METADATA +208 -0
  34. annotuneagent-0.1.0.dist-info/RECORD +38 -0
  35. annotuneagent-0.1.0.dist-info/WHEEL +5 -0
  36. annotuneagent-0.1.0.dist-info/entry_points.txt +2 -0
  37. annotuneagent-0.1.0.dist-info/licenses/LICENSE +201 -0
  38. annotuneagent-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2037 @@
1
+ # Copyright 2026 Zhao Li (李钊)
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ """Cluster workflow: marker, name, and ML results, then a judgment writes the title."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import time
9
+ from collections import defaultdict
10
+ from collections.abc import Callable, Collection
11
+ from dataclasses import dataclass
12
+ from functools import lru_cache
13
+ from pathlib import Path
14
+
15
+ import numpy as np
16
+ import pandas as pd
17
+ import scanpy as sc
18
+ from anndata import AnnData
19
+ from scipy import sparse
20
+
21
+ from .annotate import annotate
22
+ from .arbitrate import DEFAULT_MIN_MARGIN
23
+ from .decide import (
24
+ SYSTEM_ML,
25
+ SYSTEM_NAME,
26
+ Decision,
27
+ MlResult,
28
+ candidate_names,
29
+ cluster_payload,
30
+ detection_fractions,
31
+ fetch_ml,
32
+ fetch_name,
33
+ hit_names,
34
+ ml_user_from_hits,
35
+ parse_decision,
36
+ parse_ml,
37
+ propose_hypotheses,
38
+ rank_measurements,
39
+ trial_support,
40
+ wilcoxon_order,
41
+ )
42
+ from .evidence import NEGATIVE_PRESENT_PCT, cluster_mask, require_unique_var_names
43
+ from .hypotheses import Hypothesis
44
+ from .marker_plot import (
45
+ MAX_MARKERS_PER_TYPE,
46
+ MAX_PVALUE,
47
+ MIN_LOGFC,
48
+ MIN_PCT_IN,
49
+ plot_marker_panels,
50
+ select_marker_panels,
51
+ )
52
+ from .markers import hypotheses_from_names, marker_names, parents_for, tissue_pages
53
+ from .prepare import _timed, doublet_summary, format_doublets
54
+ from .votes import agreed_name, gene_vote, panel_vote, subcluster_vote
55
+
56
+ SUBCLUSTER_KEY = "annotune_sub"
57
+ LABEL_KEY = "annotune_label"
58
+ MERGED_KEY = "annotune_cluster"
59
+ MIN_SUBCLUSTER_CELLS = 40
60
+ MIN_AUTOLABEL_CELLS = 20
61
+ SUBCLUSTER_RESOLUTIONS = (0.6, 1.0, 1.4)
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class TaskEndpoint:
66
+ """One adapter. ``ask`` overrides HTTP and local weights. ``system`` overrides the task prompt."""
67
+
68
+ url: str | None = None
69
+ adapter: str | None = None
70
+ system: str = ""
71
+ ask: Callable | None = None
72
+ maxlen: int = 2048
73
+ base: str = ""
74
+ tokenizer: str | None = None
75
+ server: str | None = None
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class ModelChain:
80
+ name: TaskEndpoint
81
+ ml: TaskEndpoint
82
+
83
+
84
+ def local_models(
85
+ root: str | Path, *, url: str | None = None, tokenizer: str | None = None
86
+ ) -> ModelChain:
87
+ """Q6_K base and the name and ML adapters.
88
+
89
+ ``url`` is the server root. The default is ``http://127.0.0.1:8080``.
90
+ A server is reused only when its base and both adapters match.
91
+ The tokenizer is the one installed with the package.
92
+ """
93
+ from .local_lora import packaged_tokenizer, service_url
94
+
95
+ root = Path(root).expanduser().resolve()
96
+ base = str(root / "base" / "Qwen3.5-4B-base.Q6_K.gguf")
97
+ chosen = url or service_url()
98
+ tokenizer = packaged_tokenizer() if tokenizer is None else tokenizer
99
+
100
+ def endpoint(name: str, maxlen: int) -> TaskEndpoint:
101
+ return TaskEndpoint(
102
+ adapter=str(root / "lora" / name),
103
+ base=base,
104
+ maxlen=maxlen,
105
+ tokenizer=tokenizer,
106
+ server=chosen,
107
+ )
108
+
109
+ return ModelChain(
110
+ name=endpoint("name/annotune-v9h-name-checkpoint-412.lora.f16.gguf", 2048),
111
+ ml=endpoint("ml/annotune-v9e-ml-checkpoint-231.lora.f16.gguf", 8192),
112
+ )
113
+
114
+
115
+ _HINTS = {
116
+ "small_cluster": f"fewer than {MIN_AUTOLABEL_CELLS} cells",
117
+ "model_failure": "model call failed",
118
+ }
119
+ _LINEAGE = {"exact", "ancestor", "descendant"}
120
+ _SOURCE_TITLES = (("marker", "marker"), ("name", "name"), ("ML", "ml"))
121
+
122
+
123
+ def ontology_raw_labels() -> frozenset[str] | None:
124
+ """Names mapped to a CL id in any species. None if the map is absent."""
125
+ _dag, label_map, _names = _lineage_resources()
126
+ if label_map is None:
127
+ return None
128
+ return frozenset(entry.raw_label for entry in label_map.entries if entry.cl_id)
129
+
130
+
131
+ def _lineage_resources(species: str | None = None):
132
+ """DAG, map, and the names the map sends to a CL id for ``species``.
133
+
134
+ All three are None when either file is missing. A name mapped only for
135
+ the other species is not in the set.
136
+ """
137
+ from .granularity import ontology_dir
138
+
139
+ return _load_lineage(ontology_dir(), species)
140
+
141
+
142
+ @lru_cache(maxsize=8)
143
+ def _load_lineage(directory: Path, species: str | None):
144
+ from .granularity.cl_dag import load_ontology
145
+ from .granularity.ontology_map import load_map
146
+
147
+ dag_path = directory / "cl_isa.json"
148
+ map_path = directory / "ontology_map.jsonl"
149
+ if not dag_path.is_file() or not map_path.is_file():
150
+ return None, None, None
151
+ label_map = load_map(map_path)
152
+ names = set()
153
+ for raw in {entry.raw_label for entry in label_map.entries}:
154
+ found = label_map.lookup(raw, species)
155
+ if found is not None and found.cl_id:
156
+ names.add(raw)
157
+ # The result is cached and shared, so callers get a set they cannot change.
158
+ return load_ontology(dag_path), label_map, frozenset(names)
159
+
160
+
161
+ def settle_cluster(
162
+ *,
163
+ marker: str | None,
164
+ name: str | None,
165
+ ml: MlResult | None,
166
+ added: list[str],
167
+ cells: int | None = None,
168
+ failed: bool = False,
169
+ ontology_names: set[str] | None = None,
170
+ species: str | None = None,
171
+ ranking: list[dict] | None = None,
172
+ margin: float | None = None,
173
+ marker_labeled: bool = False,
174
+ min_margin: float = DEFAULT_MIN_MARGIN,
175
+ dag=None,
176
+ label_map=None,
177
+ ) -> dict[str, object]:
178
+ """Write one name from marker, name, and ML.
179
+
180
+ Exact, ancestor, and descendant Cell Ontology relations are one lineage.
181
+ Siblings are not. All three calls on one lineage write the finer name when
182
+ its marker score passes, with no hint. Two calls on one lineage write the
183
+ ontology name on that lineage and name the dissenter in the hint. Three
184
+ lineages still write the higher-scoring ontology name and add a conflict
185
+ hint. A marker margin below ``min_margin`` does the same, unless name and
186
+ ML agree on a descendant of the marker call: that descendant is written
187
+ and the close-score hint stays. Platelet or megakaryocyte yields to a
188
+ monocyte program that is also on. A cluster under 40 cells with two
189
+ programs on is not named when marker, name, and ML do not all give the
190
+ same name. When they do, that name is written and the hint lists every
191
+ detected program from high score to low. A failed marker score with no
192
+ ontology name writes nothing. Agreement does not pick the name.
193
+ """
194
+ ml_name = ml.names[0] if ml is not None and ml.status == "named" and ml.names else None
195
+ proposals: list[dict[str, str]] = []
196
+ if name:
197
+ proposals.append({"source": "name", "name": name})
198
+ if ml_name:
199
+ proposals.append({"source": "ml", "name": ml_name})
200
+ spoken = {"marker": marker, "name": name, "ml": ml_name}
201
+ if species and dag is None:
202
+ dag, label_map, loaded = _lineage_resources(species)
203
+ if ontology_names is None:
204
+ ontology_names = loaded
205
+ label, other, close = _choose_label(
206
+ spoken,
207
+ ontology_names,
208
+ species=species,
209
+ ranking=ranking,
210
+ margin=margin,
211
+ marker_labeled=marker_labeled,
212
+ min_margin=min_margin,
213
+ dag=dag,
214
+ label_map=label_map,
215
+ )
216
+ leukocyte = _monocyte_over_platelet(label, ranking, species, dag, label_map)
217
+ if leukocyte:
218
+ if label and label != leukocyte:
219
+ other = [f"marker {label}", *[item for item in other if item != f"marker {label}"]]
220
+ label = leukocyte
221
+ mixed = (
222
+ cells is not None
223
+ and cells < MIN_SUBCLUSTER_CELLS
224
+ and _mixed_programs(ranking, species, dag, label_map)
225
+ )
226
+ unanimous = bool(marker and name and ml_name and marker == name == ml_name)
227
+ if mixed and not unanimous:
228
+ label = None
229
+ model_failed = failed or (ml is not None and ml.status == "failed")
230
+ hint = None
231
+ if mixed and label is None:
232
+ status, reason = "review", "mixed_programs"
233
+ hint = _program_hint(ranking, species, dag, label_map)
234
+ elif label is None:
235
+ if model_failed:
236
+ status, reason = "review", "model_failure"
237
+ elif cells is not None and cells < MIN_AUTOLABEL_CELLS:
238
+ status, reason = "review", "small_cluster"
239
+ else:
240
+ status, reason = "escalate", "no_supported_name"
241
+ else:
242
+ caveats: list[str] = []
243
+ if other or close:
244
+ caveats.append("evidence_conflict")
245
+ if cells is not None and cells < MIN_AUTOLABEL_CELLS:
246
+ caveats.append("small_cluster")
247
+ if model_failed:
248
+ caveats.append("model_failure")
249
+ status = "named"
250
+ reason = "+".join(caveats) if caveats else "evidence_agreement"
251
+ notes = []
252
+ if other:
253
+ notes.append("conflict: " + ", ".join(other))
254
+ if close:
255
+ notes.append("close marker scores")
256
+ notes.extend(_HINTS[item] for item in caveats if item != "evidence_conflict")
257
+ if mixed:
258
+ notes.append(_program_hint(ranking, species, dag, label_map))
259
+ if reason == "evidence_agreement":
260
+ reason = "mixed_programs"
261
+ if notes:
262
+ hint = "; ".join(notes)
263
+ return {
264
+ "proposals": proposals,
265
+ "added": list(added),
266
+ "written_label": label,
267
+ "hint": hint,
268
+ "arbitration": status != "named",
269
+ "final_status": status,
270
+ "final_reason": reason,
271
+ }
272
+
273
+
274
+ def _choose_label(
275
+ spoken: dict[str, str | None],
276
+ ontology_names: set[str] | None,
277
+ *,
278
+ species: str | None,
279
+ ranking: list[dict] | None,
280
+ margin: float | None,
281
+ marker_labeled: bool,
282
+ min_margin: float,
283
+ dag,
284
+ label_map,
285
+ ) -> tuple[str | None, list[str], bool]:
286
+ items = [(key, spoken[key]) for key in ("marker", "name", "ml") if spoken[key]]
287
+ close = bool(ranking) and len(ranking) >= 2 and margin is not None and margin < min_margin
288
+ if not items:
289
+ return None, [], close
290
+
291
+ def accepted(value: str) -> bool:
292
+ return ontology_names is None or value in ontology_names
293
+
294
+ components = _components(items, species, dag, label_map)
295
+ components.sort(key=len, reverse=True)
296
+ agreed_model = spoken["name"] if spoken["name"] and spoken["name"] == spoken["ml"] else None
297
+ marker_name = spoken["marker"]
298
+ if (
299
+ close
300
+ and agreed_model
301
+ and marker_name
302
+ and agreed_model != marker_name
303
+ and accepted(agreed_model)
304
+ and _is_descendant(agreed_model, marker_name, species, dag, label_map)
305
+ ):
306
+ return agreed_model, _outside(components, agreed_model), close
307
+ agreed = (
308
+ components[0] if len(components) == 1 or len(components[0]) > len(components[1]) else None
309
+ )
310
+ pool = agreed if agreed is not None else items
311
+ names = list(dict.fromkeys(value for _key, value in pool if accepted(value)))
312
+ if not names:
313
+ names = list(dict.fromkeys(value for _key, value in items if accepted(value)))
314
+ if not names:
315
+ if marker_labeled and spoken["marker"]:
316
+ return spoken["marker"], _outside(components, spoken["marker"]), close
317
+ return None, [], close
318
+ scores = {value: _marker_score(value, ranking) for value in names}
319
+ finest = _finest(names, species, dag, label_map)
320
+ marker = spoken["marker"]
321
+ if marker_labeled and marker in finest:
322
+ chosen = marker
323
+ elif any(score is not None for score in scores.values()):
324
+ chosen = max(
325
+ names,
326
+ key=lambda value: (
327
+ scores[value] is not None,
328
+ scores[value] if scores[value] is not None else 0.0,
329
+ value == marker,
330
+ value,
331
+ ),
332
+ )
333
+ elif len(names) == 1:
334
+ chosen = names[0]
335
+ else:
336
+ return None, [], close
337
+ return chosen, _outside(components, chosen), close
338
+
339
+
340
+ def _components(
341
+ items: list[tuple[str, str]], species, dag, label_map
342
+ ) -> list[list[tuple[str, str]]]:
343
+ parent = list(range(len(items)))
344
+
345
+ def find(index: int) -> int:
346
+ while parent[index] != index:
347
+ parent[index] = parent[parent[index]]
348
+ index = parent[index]
349
+ return index
350
+
351
+ for left in range(len(items)):
352
+ for right in range(left + 1, len(items)):
353
+ if _same_lineage(items[left][1], items[right][1], species, dag, label_map):
354
+ parent[find(right)] = find(left)
355
+ groups: dict[int, list[tuple[str, str]]] = {}
356
+ for index, item in enumerate(items):
357
+ groups.setdefault(find(index), []).append(item)
358
+ return list(groups.values())
359
+
360
+
361
+ def _same_lineage(left: str, right: str, species, dag, label_map) -> bool:
362
+ if left == right:
363
+ return True
364
+ if not species or dag is None or label_map is None:
365
+ return False
366
+ from .granularity.relations import relation
367
+
368
+ found = relation(left, right, species, dag, label_map, formal=False)
369
+ return found["relation"] in _LINEAGE
370
+
371
+
372
+ def _finest(names: list[str], species, dag, label_map) -> list[str]:
373
+ kept: list[str] = []
374
+ for name in names:
375
+ if any(
376
+ other != name and _is_descendant(other, name, species, dag, label_map)
377
+ for other in names
378
+ ):
379
+ continue
380
+ kept.append(name)
381
+ return kept
382
+
383
+
384
+ def _is_descendant(finer: str, coarser: str, species, dag, label_map) -> bool:
385
+ if not species or dag is None or label_map is None:
386
+ return False
387
+ from .granularity.relations import relation
388
+
389
+ return (
390
+ relation(finer, coarser, species, dag, label_map, formal=False)["relation"] == "descendant"
391
+ )
392
+
393
+
394
+ def _marker_score(name: str, ranking: list[dict] | None) -> float | None:
395
+ for row in ranking or []:
396
+ if row.get("name") == name:
397
+ return float(row["score"])
398
+ return None
399
+
400
+
401
+ def _program_hold(record, cells, species, dag, label_map) -> dict[str, object] | None:
402
+ """Apply the platelet and mixed-program rules when no model call settled the cluster."""
403
+ if record.get("status") != "label" or dag is None or label_map is None:
404
+ return None
405
+ ranking = [dict(row) for row in record.get("ranking") or []]
406
+ label = str(record["cell_type"]) if record.get("cell_type") else None
407
+ if (
408
+ cells is not None
409
+ and cells < MIN_SUBCLUSTER_CELLS
410
+ and _mixed_programs(ranking, species, dag, label_map)
411
+ ):
412
+ return {
413
+ "written_label": None,
414
+ "hint": _program_hint(ranking, species, dag, label_map),
415
+ "final_status": "review",
416
+ "final_reason": "mixed_programs",
417
+ "arbitration": True,
418
+ }
419
+ leukocyte = _monocyte_over_platelet(label, ranking, species, dag, label_map)
420
+ if leukocyte and leukocyte != label:
421
+ return {
422
+ "written_label": leukocyte,
423
+ "hint": f"conflict: marker {label}",
424
+ "final_status": "named",
425
+ "final_reason": "evidence_conflict",
426
+ "arbitration": True,
427
+ }
428
+ return None
429
+
430
+
431
+ def _monocyte_over_platelet(chosen, ranking, species, dag, label_map) -> str | None:
432
+ """Platelet RNA on a monocyte program is an aggregate, not a platelet cluster."""
433
+ if chosen not in {"platelet", "megakaryocyte"} or dag is None or label_map is None:
434
+ return None
435
+ best_name = None
436
+ best_score = None
437
+ for row in ranking or []:
438
+ if not row.get("program_on"):
439
+ continue
440
+ name = str(row["name"])
441
+ if not _same_lineage(name, "monocyte", species, dag, label_map):
442
+ continue
443
+ score = float(row["score"])
444
+ if best_name is None or score > best_score:
445
+ best_name = name
446
+ best_score = score
447
+ return best_name
448
+
449
+
450
+ def _detected_programs(ranking, species, dag, label_map) -> list[tuple[str, float]]:
451
+ """Ontology programs whose positive panel is on, highest score first."""
452
+ if dag is None or label_map is None:
453
+ return []
454
+ found: list[tuple[str, float]] = []
455
+ seen: set[str] = set()
456
+ for row in ranking or []:
457
+ name = row.get("name")
458
+ if not name or not row.get("program_on") or name in seen:
459
+ continue
460
+ entry = label_map.lookup(str(name), species, formal=False)
461
+ if entry is None or not entry.cl_id:
462
+ continue
463
+ seen.add(str(name))
464
+ found.append((str(name), float(row["score"])))
465
+ found.sort(key=lambda item: (-item[1], item[0]))
466
+ return found
467
+
468
+
469
+ def _needs_query(record: dict) -> bool:
470
+ """Unnamed clusters and named clusters that still carry a question."""
471
+ return not record.get("written_label") or bool(record.get("hint"))
472
+
473
+
474
+ def _print_queries(records: list[dict]) -> None:
475
+ rows = [record for record in records if record.get("query")]
476
+ if not rows:
477
+ return
478
+ print("Queries", flush=True)
479
+ for record in rows:
480
+ label = record.get("written_label") or "no label"
481
+ hint = record.get("hint") or record.get("final_reason") or ""
482
+ print(f"cluster {record['cluster']} {label} {hint}", flush=True)
483
+ print(record["query"], flush=True)
484
+ print(flush=True)
485
+
486
+
487
+ def _attach_queries(adata, cluster_key, records, hypotheses, species, tissue) -> None:
488
+ """Pasteable gene features for unnamed clusters and clusters with a question."""
489
+ pending = [record for record in records if _needs_query(record)]
490
+ if not pending:
491
+ return
492
+ if adata.obs[cluster_key].nunique() >= 2:
493
+ try:
494
+ wilcoxon_order(adata, cluster_key)
495
+ except ValueError:
496
+ return
497
+ dag = label_map = None
498
+ if species:
499
+ dag, label_map, _known = _lineage_resources(species)
500
+ by_name = {item.name: item for item in hypotheses}
501
+ for record in pending:
502
+ cluster = str(record["cluster"])
503
+ cells = int((adata.obs[cluster_key].astype(str) == cluster).sum())
504
+ programs = [
505
+ name
506
+ for name, _score in _detected_programs(record.get("ranking"), species, dag, label_map)
507
+ ]
508
+ text = _cluster_query(
509
+ adata,
510
+ cluster,
511
+ species=species or "",
512
+ tissue=tissue or "",
513
+ cells=cells,
514
+ programs=programs,
515
+ by_name=by_name,
516
+ )
517
+ if text:
518
+ record["query"] = text
519
+
520
+
521
+ def _cluster_query(
522
+ adata,
523
+ cluster: str,
524
+ *,
525
+ species: str,
526
+ tissue: str,
527
+ cells: int,
528
+ programs: list[str],
529
+ by_name: dict[str, Hypothesis],
530
+ ) -> str | None:
531
+ """Gene list a person can paste into a model and ask for a cell type."""
532
+ result = adata.uns.get("rank_genes_groups")
533
+ if not result or "names" not in result or "pts" not in result:
534
+ return None
535
+ key = str(cluster)
536
+ try:
537
+ names = [str(gene) for gene in result["names"][key]]
538
+ changes = [float(value) for value in result["logfoldchanges"][key]]
539
+ pct_in = result["pts"][key]
540
+ pct_out = result["pts_rest"][key]
541
+ except (KeyError, IndexError, TypeError, ValueError):
542
+ return None
543
+ change_of = dict(zip(names, changes, strict=True))
544
+
545
+ def feature(gene: str) -> str | None:
546
+ if gene not in change_of:
547
+ return None
548
+ inside = float(pct_in.loc[gene])
549
+ outside = float(pct_out.loc[gene])
550
+ return f"{gene} (in {inside:.2f}, out {outside:.2f}, logfc {change_of[gene]:.2f})"
551
+
552
+ enriched: list[str] = []
553
+ seen: set[str] = set()
554
+ for gene, change in zip(names, changes, strict=True):
555
+ if gene in seen or change <= 0:
556
+ continue
557
+ seen.add(gene)
558
+ text = feature(gene)
559
+ if text:
560
+ enriched.append(text)
561
+ if len(enriched) >= 10:
562
+ break
563
+ present: list[str] = []
564
+ for name in programs:
565
+ item = by_name.get(name)
566
+ if item is None:
567
+ continue
568
+ for gene in (*item.positive, *item.support):
569
+ if gene in seen or gene not in change_of:
570
+ continue
571
+ if float(pct_in.loc[gene]) < 0.5:
572
+ continue
573
+ seen.add(gene)
574
+ text = feature(gene)
575
+ if text:
576
+ present.append(text)
577
+ if len(present) >= 16:
578
+ break
579
+ if len(present) >= 16:
580
+ break
581
+ if not enriched and not present:
582
+ return None
583
+ subject = species or "unknown species"
584
+ if tissue:
585
+ subject = f"{subject}, {tissue}"
586
+ lines = [f"{subject}, {cells} cells. What cell type is this cluster?"]
587
+ if enriched:
588
+ lines.append("Enriched versus other cells: " + "; ".join(enriched) + ".")
589
+ if present:
590
+ lines.append("Also detected: " + "; ".join(present) + ".")
591
+ return "\n".join(lines)
592
+
593
+
594
+ def _program_hint(ranking, species, dag, label_map) -> str:
595
+ names = [name for name, _score in _detected_programs(ranking, species, dag, label_map)]
596
+ return "cell programs: " + ", ".join(names)
597
+
598
+
599
+ def _mixed_programs(ranking, species, dag, label_map) -> bool:
600
+ """Two ontology lineages each have a detected positive program."""
601
+ found = _detected_programs(ranking, species, dag, label_map)
602
+ if len(found) < 2:
603
+ return False
604
+ groups = _components([("marker", name) for name, _score in found], species, dag, label_map)
605
+ return len(groups) >= 2
606
+
607
+
608
+ def _outside(components: list[list[tuple[str, str]]], chosen: str) -> list[str]:
609
+ home = next((group for group in components if any(name == chosen for _key, name in group)), [])
610
+ home_keys = {key for key, _name in home}
611
+ spoken = {key: name for group in components for key, name in group}
612
+ return [
613
+ f"{title} {spoken[key]}"
614
+ for title, key in _SOURCE_TITLES
615
+ if key in spoken and key not in home_keys
616
+ ]
617
+
618
+
619
+ def dictionary_hypotheses(
620
+ species: str,
621
+ tissue: str | None = None,
622
+ user: list[Hypothesis] | None = None,
623
+ ) -> list[Hypothesis]:
624
+ """Core and tissue dictionary, with user rows replacing the same name."""
625
+ names = marker_names(species, tissue)
626
+ merged = {item.name: item for item in hypotheses_from_names(names, species, tissue)}
627
+ for item in user or []:
628
+ merged[item.name] = item
629
+ return list(merged.values())
630
+
631
+
632
+ def _in_matrix(
633
+ hypotheses: list[Hypothesis], var_names: set[str]
634
+ ) -> tuple[list[Hypothesis], list[dict[str, object]]]:
635
+ """Keep a type whose positive markers are all measured.
636
+
637
+ A negative gene absent from the matrix is omitted. Absence is not evidence
638
+ for the type, and it does not drop the type. A missing positive marker
639
+ still drops the type. Each dropped row names those genes.
640
+ """
641
+ kept: list[Hypothesis] = []
642
+ dropped: list[dict[str, object]] = []
643
+ for item in hypotheses:
644
+ missing = [gene for gene in item.positive if gene not in var_names]
645
+ if missing:
646
+ dropped.append(
647
+ {"name": item.name, "reason": "missing positive markers", "missing": missing}
648
+ )
649
+ continue
650
+ negatives = tuple(gene for gene in item.negative if gene in var_names)
651
+ support = tuple(gene for gene in item.support if gene in var_names)
652
+ if negatives == item.negative and support == item.support:
653
+ kept.append(item)
654
+ else:
655
+ kept.append(Hypothesis(item.name, item.positive, negatives, support))
656
+ return kept, dropped
657
+
658
+
659
+ def _variable_genes(part: AnnData) -> int:
660
+ matrix = part.X
661
+ if sparse.issparse(matrix):
662
+ mean = np.asarray(matrix.mean(axis=0)).ravel()
663
+ second = np.asarray(matrix.multiply(matrix).mean(axis=0)).ravel()
664
+ variance = second - mean**2
665
+ else:
666
+ variance = np.asarray(matrix).var(axis=0)
667
+ return int(np.count_nonzero(variance > 1e-8))
668
+
669
+
670
+ SILHOUETTE_SAMPLE = 2000
671
+
672
+
673
+ def _subcluster(part: AnnData) -> bool:
674
+ if _variable_genes(part) < 2:
675
+ return False
676
+ n_comps = min(10, part.n_obs - 1, part.n_vars - 1)
677
+ if n_comps < 2:
678
+ return False
679
+ sc.pp.pca(part, n_comps=n_comps, svd_solver="full")
680
+ sc.pp.neighbors(part, n_pcs=n_comps)
681
+ from sklearn.metrics import silhouette_score
682
+
683
+ best_labels = None
684
+ best_score = -1.0
685
+ for resolution in SUBCLUSTER_RESOLUTIONS:
686
+ sc.tl.leiden(
687
+ part,
688
+ resolution=resolution,
689
+ key_added="annotune_sub_try",
690
+ flavor="igraph",
691
+ n_iterations=2,
692
+ random_state=0,
693
+ )
694
+ labels = part.obs["annotune_sub_try"].astype(str)
695
+ if int(labels.nunique()) < 2 or int(labels.nunique()) >= part.n_obs:
696
+ continue
697
+ if part.n_obs > SILHOUETTE_SAMPLE:
698
+ groups = labels.groupby(labels).indices
699
+ if len(groups) >= SILHOUETTE_SAMPLE:
700
+ continue
701
+ rng = np.random.default_rng(0)
702
+ chosen = np.array([rng.choice(indices) for indices in groups.values()])
703
+ remaining = np.setdiff1d(np.arange(part.n_obs), chosen, assume_unique=True)
704
+ extra = rng.choice(remaining, SILHOUETTE_SAMPLE - len(chosen), replace=False)
705
+ sample = np.concatenate((chosen, extra))
706
+ score = float(silhouette_score(part.obsm["X_pca"][sample], labels.iloc[sample]))
707
+ else:
708
+ score = float(silhouette_score(part.obsm["X_pca"], labels))
709
+ if score > best_score:
710
+ best_score = score
711
+ best_labels = labels.to_numpy()
712
+ if best_labels is None:
713
+ return False
714
+ part.obs[SUBCLUSTER_KEY] = best_labels
715
+ return True
716
+
717
+
718
+ def _cluster_order(cluster: str) -> tuple[int, object]:
719
+ try:
720
+ return (0, int(cluster))
721
+ except ValueError:
722
+ return (1, cluster)
723
+
724
+
725
+ def annotation_code(
726
+ adata: AnnData,
727
+ records: list[dict[str, object]],
728
+ cluster_key: str,
729
+ label_key: str,
730
+ marker_dict: dict[str, list[str]],
731
+ type_order: list[str],
732
+ ) -> str:
733
+ """Editable code for labels, type order, UMAP, and checked marker dotplot."""
734
+ group_key = SUBCLUSTER_KEY if SUBCLUSTER_KEY in adata.obs else cluster_key
735
+ units = adata.obs[group_key].astype(str)
736
+ assigned: dict[str, str] = {}
737
+ choices: dict[str, list[str]] = {}
738
+ for unit in units.unique():
739
+ labels = adata.obs.loc[units == unit, label_key]
740
+ names = labels.dropna().astype(str).unique()
741
+ if not labels.isna().any() and len(names) == 1:
742
+ assigned[str(unit)] = str(names[0])
743
+ for record in records:
744
+ cluster = str(record["cluster"])
745
+ if cluster in assigned:
746
+ continue
747
+ names: list[str] = []
748
+ for item in list(record.get("choices") or []):
749
+ if item not in names:
750
+ names.append(str(item))
751
+ for item in record.get("ranking") or []:
752
+ candidate = str(item["name"])
753
+ if candidate not in names:
754
+ names.append(candidate)
755
+ if len(names) >= 2:
756
+ break
757
+ if names:
758
+ choices[cluster] = names[:2]
759
+ order = sorted(assigned, key=_cluster_order)
760
+ mapping = {cluster: assigned[cluster] for cluster in order}
761
+ lines = [
762
+ "import pandas as pd",
763
+ "import scanpy as sc",
764
+ "",
765
+ "# cluster2celltype maps each cluster id to its name.",
766
+ "# fine_order is the UMAP legend and the dotplot row order.",
767
+ "# marker_dict is the dotplot. Gene order inside a list is the column order.",
768
+ "cluster2celltype = {",
769
+ ]
770
+ for cluster, name in mapping.items():
771
+ lines.append(f" {cluster!r}: {name!r},")
772
+ lines.append("}")
773
+ if choices:
774
+ lines.extend(["", "# unresolved clusters", "cluster_choices = {"])
775
+ for cluster in sorted(choices, key=_cluster_order):
776
+ lines.append(f" {cluster!r}: {choices[cluster]!r},")
777
+ lines.append("}")
778
+ lines.extend(
779
+ [
780
+ "",
781
+ "# Change this order to control the UMAP legend and dotplot rows.",
782
+ "fine_order = [",
783
+ ]
784
+ )
785
+ for name in type_order:
786
+ lines.append(f" {name!r},")
787
+ lines.extend(
788
+ [
789
+ "]",
790
+ "",
791
+ f"assert {group_key!r} in adata.obs, 'run this on the annotated h5ad written by annotune'",
792
+ f"adata.obs['celltype'] = adata.obs[{group_key!r}].astype(str).map(cluster2celltype)",
793
+ "adata.obs['celltype'] = pd.Categorical(",
794
+ " adata.obs['celltype'], categories=fine_order, ordered=True",
795
+ ")",
796
+ "print(adata.obs['celltype'].isna().sum(), 'cells without a label')",
797
+ "print(adata.obs['celltype'].value_counts())",
798
+ "if 'X_umap' in adata.obsm:",
799
+ " sc.pl.umap(adata, color='celltype', frameon=False)",
800
+ "",
801
+ "# Cited dictionary markers. Types flagged no_marker_passed show their best measured genes.",
802
+ "# A shared gene is kept only when that type would otherwise have no marker.",
803
+ "# Edit these lists to customize the figure; marker_validation in the report has the measurements.",
804
+ "marker_dict = {",
805
+ ]
806
+ )
807
+ for name in type_order:
808
+ if name in marker_dict:
809
+ lines.append(f" {name!r}: {marker_dict[name]!r},")
810
+ lines.append("}")
811
+ if marker_dict:
812
+ lines.extend(
813
+ [
814
+ "# Keep marker columns in the same order as the cell type rows.",
815
+ "ordered_marker_dict = {name: marker_dict[name] for name in fine_order if name in marker_dict}",
816
+ "sc.pl.dotplot(",
817
+ " adata[adata.obs['celltype'].notna()].copy(), ordered_marker_dict,",
818
+ " groupby='celltype', categories_order=fine_order,",
819
+ " dendrogram=False, use_raw=False, standard_scale='var',",
820
+ ")",
821
+ ]
822
+ )
823
+ lines.append("")
824
+ return "\n".join(lines)
825
+
826
+
827
+ def run(
828
+ adata: AnnData,
829
+ cluster_key: str,
830
+ *,
831
+ species: str | None = None,
832
+ tissue: str | None = None,
833
+ hypotheses: list[Hypothesis] | None = None,
834
+ user_hypotheses: list[Hypothesis] | None = None,
835
+ min_margin: float = DEFAULT_MIN_MARGIN,
836
+ ranker: object | None = None,
837
+ conformal_q: float | None = None,
838
+ catalog: list[Hypothesis] | None = None,
839
+ models: ModelChain | None = None,
840
+ stage: str = "",
841
+ source: str | None = None,
842
+ donor: str | None = None,
843
+ model_clusters: Collection[str] | None = None,
844
+ subcluster: bool = True,
845
+ min_subcluster_cells: int = MIN_SUBCLUSTER_CELLS,
846
+ label_key: str = LABEL_KEY,
847
+ dotplot_path: Path | str | None = None,
848
+ show: bool = False,
849
+ ) -> dict[str, object]:
850
+ """Collect the marker, name, and ML results, then write the title.
851
+
852
+ The marker score, the name model, and the ML model each return a result.
853
+ A judgment reads the three results and writes the title. The title is a
854
+ dictionary name. A cluster can stay unnamed. The name and ML models run
855
+ when ``models`` is set. The name model copies a cell-type name from the
856
+ marker dictionary. The ML model is trained on machine-learning measurements
857
+ of the cluster, differential expression and marker-panel statistics, and
858
+ copies a name from those candidates. Name and ML each receive their own
859
+ payload. Clear clusters are written to ``label_key``. Uncertain clusters
860
+ are subclustered once. Each step prints when it starts and how many seconds
861
+ it took. A cluster with no written name, or a written name that still has a
862
+ hint, prints a gene query that can be pasted into another model. A local
863
+ server started for the adapters stays loaded after this call.
864
+
865
+ With ``models``, the name and ML models are asked for every cluster, or
866
+ only those in ``model_clusters``. That includes clusters the marker score
867
+ already named. The judgment reads all three results and can write a
868
+ different title.
869
+
870
+ ``adata`` is changed in place. This writes ``obs`` columns ``label_key``,
871
+ ``annotune_cluster``, ``annotune_final_status`` and ``annotune_hint``, and
872
+ ``annotune_sub`` when a cluster is split. An ``annotune_sub`` column from
873
+ an earlier run is removed first. It also sets ``uns['log1p']`` when the
874
+ matrix passes the log1p check, and ``uns['rank_genes_groups']`` when it
875
+ runs Wilcoxon.
876
+ """
877
+ if hypotheses is None:
878
+ if species is None:
879
+ raise ValueError("species is required when hypotheses are omitted")
880
+ hypotheses = dictionary_hypotheses(species, tissue, user_hypotheses)
881
+ elif user_hypotheses:
882
+ merged = {item.name: item for item in hypotheses}
883
+ for item in user_hypotheses:
884
+ merged[item.name] = item
885
+ hypotheses = list(merged.values())
886
+ require_unique_var_names(adata)
887
+ if SUBCLUSTER_KEY in adata.obs.columns:
888
+ del adata.obs[SUBCLUSTER_KEY]
889
+ kept, dropped = _in_matrix(hypotheses, set(map(str, adata.var_names)))
890
+ if len(kept) < 1:
891
+ raise ValueError("no hypothesis has its positive markers in var_names")
892
+ pool = list(catalog) if catalog is not None else list(kept)
893
+ started = time.perf_counter()
894
+ with _timed("run score markers") as note:
895
+ records = _annotate(adata, cluster_key, kept, min_margin, ranker, conformal_q)
896
+ labeled = sum(record["status"] == "label" for record in records)
897
+ note["detail"] = f"{labeled} of {len(records)} clusters labeled"
898
+ used = {item.name for item in kept}
899
+ extras: list[Hypothesis] = []
900
+ seen = set(used)
901
+ for record in records:
902
+ if record["status"] == "label":
903
+ continue
904
+ for item in propose_hypotheses(pool, list(record["evidence"]["de_not_in_markers"]), seen):
905
+ if item.name not in seen:
906
+ extras.append(item)
907
+ seen.add(item.name)
908
+ extras, _ = _in_matrix(extras, set(map(str, adata.var_names)))
909
+ if extras:
910
+ kept = [*kept, *extras]
911
+ with _timed("run score added markers") as note:
912
+ records = _annotate(adata, cluster_key, kept, min_margin, ranker, conformal_q)
913
+ note["detail"] = f"{len(extras)} added"
914
+ children: list[dict[str, object]] = []
915
+ if subcluster:
916
+ with _timed("run subcluster") as note:
917
+ split_any = False
918
+ for record in records:
919
+ if record["status"] == "label":
920
+ continue
921
+ mask = cluster_mask(adata, cluster_key, str(record["cluster"]))
922
+ if int(mask.sum()) < min_subcluster_cells:
923
+ continue
924
+ part = adata[mask].copy()
925
+ if not _subcluster(part):
926
+ continue
927
+ child_records = _annotate(
928
+ part, SUBCLUSTER_KEY, kept, min_margin, ranker, conformal_q
929
+ )
930
+ for child in child_records:
931
+ child["parent"] = str(record["cluster"])
932
+ child["cluster"] = f"{record['cluster']}.{child['cluster']}"
933
+ if not split_any:
934
+ adata.obs[SUBCLUSTER_KEY] = adata.obs[cluster_key].astype(str)
935
+ split_any = True
936
+ parent_labels = adata.obs.loc[mask, cluster_key].astype(str)
937
+ child_labels = part.obs[SUBCLUSTER_KEY].astype(str)
938
+ adata.obs.loc[mask, SUBCLUSTER_KEY] = [
939
+ f"{parent}.{child}"
940
+ for parent, child in zip(parent_labels, child_labels, strict=True)
941
+ ]
942
+ children.append({"parent": str(record["cluster"]), "records": child_records})
943
+ note["detail"] = f"{len(children)} clusters"
944
+ by_parent = {str(item["parent"]): item["records"] for item in children}
945
+ resolved = _resolved_labels(adata, cluster_key, records, by_parent)
946
+ with _timed("run Wilcoxon"):
947
+ _apply_votes(adata, cluster_key, records, by_parent, kept, resolved, species, tissue)
948
+ arbitration: list[str] = []
949
+ added_hypotheses: list[Hypothesis] = []
950
+ if models is not None:
951
+ with _timed("run load models"):
952
+ _open_local(models)
953
+ added_hypotheses = _run_models(
954
+ adata,
955
+ cluster_key,
956
+ models,
957
+ records,
958
+ kept,
959
+ pool,
960
+ resolved,
961
+ arbitration,
962
+ species=species or "",
963
+ tissue=tissue or "",
964
+ stage=stage,
965
+ source=source,
966
+ donor=_one_donor(adata, donor),
967
+ model_clusters=model_clusters,
968
+ min_margin=min_margin,
969
+ ranker=ranker,
970
+ conformal_q=conformal_q,
971
+ )
972
+ dag = label_map = None
973
+ if species:
974
+ dag, label_map, _known = _lineage_resources()
975
+ for record in records:
976
+ if "final_status" in record or dag is None:
977
+ continue
978
+ cluster = str(record["cluster"])
979
+ current = resolved.get(cluster)
980
+ if current is not None and set(current) != {cluster}:
981
+ continue
982
+ cells = int((adata.obs[cluster_key].astype(str) == cluster).sum())
983
+ revised = _program_hold(record, cells, species, dag, label_map)
984
+ if revised is None:
985
+ continue
986
+ record.update(revised)
987
+ if revised["written_label"]:
988
+ resolved[cluster] = {cluster: str(revised["written_label"])}
989
+ else:
990
+ resolved.pop(cluster, None)
991
+ for record in records:
992
+ if "final_status" in record:
993
+ continue
994
+ cluster = str(record["cluster"])
995
+ mapping = resolved.get(cluster, {})
996
+ record["final_status"] = "named" if mapping else "review"
997
+ record["final_reason"] = (
998
+ "subcluster_labels"
999
+ if mapping and set(mapping) != {cluster}
1000
+ else "votes"
1001
+ if mapping and record.get("vote_label") == mapping.get(cluster)
1002
+ else "marker_rule"
1003
+ if mapping
1004
+ else "unresolved_evidence"
1005
+ )
1006
+ record["written_label"] = mapping.get(cluster)
1007
+ record["hint"] = None
1008
+ _apply_neighbor_titles(adata, cluster_key, records, resolved, species, dag, label_map)
1009
+ _attach_queries(adata, cluster_key, records, [*kept, *added_hypotheses], species, tissue)
1010
+ labels = _assign_labels(adata, cluster_key, resolved)
1011
+ written = bool(labels.notna().any())
1012
+ adata.obs[label_key] = labels.astype("category")
1013
+ status_by_cluster = {str(record["cluster"]): record["final_status"] for record in records}
1014
+ hint_by_cluster = {str(record["cluster"]): record.get("hint") for record in records}
1015
+ adata.obs["annotune_final_status"] = (
1016
+ adata.obs[cluster_key].astype(str).map(status_by_cluster).astype("category")
1017
+ )
1018
+ adata.obs["annotune_hint"] = (
1019
+ adata.obs[cluster_key].astype(str).map(hint_by_cluster).astype("string")
1020
+ )
1021
+ all_labeled = bool(written) and bool(labels.notna().all())
1022
+ with _timed("run marker panels") as note:
1023
+ marker_dict, marker_validation, marker_status = select_marker_panels(
1024
+ adata, label_key, species, tissue
1025
+ )
1026
+ while _flag_labels_without_markers(records, resolved, marker_status, by_parent):
1027
+ labels = _assign_labels(adata, cluster_key, resolved)
1028
+ written = bool(labels.notna().any())
1029
+ adata.obs[label_key] = labels.astype("category")
1030
+ all_labeled = bool(written) and bool(labels.notna().all())
1031
+ marker_dict, marker_validation, marker_status = select_marker_panels(
1032
+ adata, label_key, species, tissue
1033
+ )
1034
+ # The loop above also flags names with no passing marker; publish both.
1035
+ status_by_cluster = {str(record["cluster"]): record["final_status"] for record in records}
1036
+ hint_by_cluster = {str(record["cluster"]): record.get("hint") for record in records}
1037
+ adata.obs["annotune_final_status"] = (
1038
+ adata.obs[cluster_key].astype(str).map(status_by_cluster).astype("category")
1039
+ )
1040
+ adata.obs["annotune_hint"] = (
1041
+ adata.obs[cluster_key].astype(str).map(hint_by_cluster).astype("string")
1042
+ )
1043
+ _attach_queries(adata, cluster_key, records, [*kept, *added_hypotheses], species, tissue)
1044
+ note["detail"] = f"{len(marker_dict)} types"
1045
+ type_order = list(dict.fromkeys(adata.obs[label_key].dropna().astype(str)))
1046
+ plot = (
1047
+ plot_marker_panels(adata, label_key, marker_dict, type_order, dotplot_path)
1048
+ if dotplot_path is not None
1049
+ else None
1050
+ )
1051
+ code = annotation_code(
1052
+ adata,
1053
+ [*records, *(child for item in children for child in item["records"])],
1054
+ cluster_key,
1055
+ label_key,
1056
+ marker_dict,
1057
+ type_order,
1058
+ )
1059
+ merged = merge_same_labels(adata, cluster_key, label_key)
1060
+ clusters = _cluster_rows(adata, cluster_key, records, species)
1061
+ report = {
1062
+ "records": records,
1063
+ "clusters": clusters,
1064
+ "review": _review_rows(clusters),
1065
+ "dropped_hypotheses": dropped,
1066
+ "subclusters": children,
1067
+ "queue": [
1068
+ str(record["cluster"])
1069
+ for record in records
1070
+ if labels[adata.obs[cluster_key].astype(str) == str(record["cluster"])].isna().any()
1071
+ ],
1072
+ "arbitration": arbitration,
1073
+ "merged": merged,
1074
+ "all_labeled": all_labeled,
1075
+ "written": written,
1076
+ "label_key": label_key if written else None,
1077
+ "dotplot": plot,
1078
+ "marker_dict": marker_dict,
1079
+ "marker_validation": marker_validation,
1080
+ "marker_status": marker_status,
1081
+ "cluster_key": cluster_key,
1082
+ "doublets": doublet_summary(adata.uns.get("annotune_prep")),
1083
+ "tissue": tissue,
1084
+ "tissues": tissue_pages(species) if species else [],
1085
+ "marker_thresholds": {
1086
+ "min_pct_in": MIN_PCT_IN,
1087
+ "min_logfc": MIN_LOGFC,
1088
+ "max_pvalue": MAX_PVALUE,
1089
+ "max_markers_per_type": MAX_MARKERS_PER_TYPE,
1090
+ },
1091
+ "annotation_code": code,
1092
+ }
1093
+ _print_queries(records)
1094
+ print(
1095
+ f"run done {time.perf_counter() - started:.1f}s "
1096
+ f"{sum(bool(record.get('written_label')) for record in records)} of {len(records)} clusters named",
1097
+ flush=True,
1098
+ )
1099
+ if show:
1100
+ _show_annotation(adata, cluster_key, label_key, report)
1101
+ if marker_dict:
1102
+ plot_marker_panels(adata, label_key, marker_dict, type_order)
1103
+ print(code)
1104
+ return report
1105
+
1106
+
1107
+ def _model_name(record: dict, step: str):
1108
+ for item in record.get("rounds") or []:
1109
+ if item.get("step") != step:
1110
+ continue
1111
+ if item.get("status") == "failed":
1112
+ return "failed"
1113
+ names = item.get("names") or []
1114
+ if names:
1115
+ return names[0]
1116
+ return item.get("action")
1117
+ return None
1118
+
1119
+
1120
+ def _ontology_note(label_map, label: object, species: str | None) -> dict[str, object] | None:
1121
+ """Exact map row for a written label. Unreviewed ids stay pending."""
1122
+ if label is None or label_map is None:
1123
+ return None
1124
+ entry = label_map.lookup(str(label), species)
1125
+ if entry is None or not entry.cl_id:
1126
+ return {"status": "unmapped"}
1127
+ return {
1128
+ "cl_id": entry.cl_id,
1129
+ "cl_label": entry.cl_label,
1130
+ "match": entry.match,
1131
+ "status": entry.status,
1132
+ "pending": entry.status != "reviewed",
1133
+ }
1134
+
1135
+
1136
+ def _cluster_rows(
1137
+ adata: AnnData, cluster_key: str, records: list[dict], species: str | None
1138
+ ) -> list[dict[str, object]]:
1139
+ """One row per cluster. Sources stay separate. Ontology is an exact map lookup."""
1140
+ label_map = _lineage_resources(species)[1] if species else None
1141
+ rows = []
1142
+ for record in sorted(records, key=lambda row: _cluster_order(str(row["cluster"]))):
1143
+ cluster = str(record["cluster"])
1144
+ label = record.get("written_label")
1145
+ rows.append(
1146
+ {
1147
+ "cluster": cluster,
1148
+ "cells": int((adata.obs[cluster_key].astype(str) == cluster).sum()),
1149
+ "marker": record.get("cell_type"),
1150
+ "name": _model_name(record, "name"),
1151
+ "ml": _model_name(record, "ml"),
1152
+ "label": label,
1153
+ "hint": record.get("hint"),
1154
+ "query": record.get("query"),
1155
+ "reason": record.get("final_reason"),
1156
+ "ontology": _ontology_note(label_map, label, species),
1157
+ }
1158
+ )
1159
+ return rows
1160
+
1161
+
1162
+ def _review_rows(clusters: list[dict[str, object]]) -> list[dict[str, object]]:
1163
+ """Clusters to read first. The rank is not an accuracy score."""
1164
+ ranked = []
1165
+ for row in clusters:
1166
+ priority = _review_priority(row)
1167
+ if priority is None:
1168
+ continue
1169
+ ranked.append(
1170
+ {
1171
+ "cluster": row["cluster"],
1172
+ "label": row["label"],
1173
+ "reason": row["reason"],
1174
+ "hint": row["hint"],
1175
+ "query": row.get("query"),
1176
+ "ontology": row["ontology"],
1177
+ "priority": priority,
1178
+ }
1179
+ )
1180
+ ranked.sort(key=lambda row: (row["priority"], _cluster_order(str(row["cluster"]))))
1181
+ return ranked
1182
+
1183
+
1184
+ def _review_priority(row: dict[str, object]) -> int | None:
1185
+ if not row.get("label"):
1186
+ return 0
1187
+ reason = str(row.get("reason") or "")
1188
+ hint = str(row.get("hint") or "")
1189
+ if "evidence_conflict" in reason or hint.startswith("conflict") or "close marker" in hint:
1190
+ return 1
1191
+ if "model_failure" in reason or "model call failed" in hint:
1192
+ return 2
1193
+ if "small_cluster" in reason or hint.startswith("fewer than"):
1194
+ return 3
1195
+ if hint:
1196
+ return 4
1197
+ return None
1198
+
1199
+
1200
+ def _show_annotation(adata, cluster_key: str, label_key: str, report: dict) -> None:
1201
+ import pandas as pd
1202
+
1203
+ shown = [
1204
+ {
1205
+ "cluster": row["cluster"],
1206
+ "cells": row["cells"],
1207
+ "marker": row["marker"],
1208
+ "name": row["name"],
1209
+ "ml": row["ml"],
1210
+ "label": row["label"],
1211
+ "hint": row["hint"],
1212
+ "reason": row["reason"],
1213
+ }
1214
+ for row in report["clusters"]
1215
+ ]
1216
+ table = pd.DataFrame(shown)
1217
+ try:
1218
+ from IPython.display import display
1219
+
1220
+ display(table)
1221
+ except Exception:
1222
+ print(table.to_string(index=False))
1223
+ print(format_doublets(report["doublets"]))
1224
+ _print_dropped(report["dropped_hypotheses"])
1225
+ _print_review(report["review"])
1226
+ print("merged", report["merged"])
1227
+ sc.pl.umap(adata, color=label_key, legend_loc="right margin", frameon=False, show=True)
1228
+ sc.pl.umap(adata, color=MERGED_KEY, legend_loc="on data", title=MERGED_KEY, show=True)
1229
+
1230
+
1231
+ def _print_dropped(dropped: list[dict[str, object]]) -> None:
1232
+ if not dropped:
1233
+ return
1234
+ print("Dropped hypotheses")
1235
+ for item in dropped:
1236
+ missing = ", ".join(str(gene) for gene in item["missing"])
1237
+ print(f" {item['name']}: {item['reason']}: {missing}")
1238
+
1239
+
1240
+ def _print_review(review: list[dict[str, object]]) -> None:
1241
+ print("Review")
1242
+ if not review:
1243
+ print(" none")
1244
+ return
1245
+ print(" Order is for manual review. It is not an accuracy score.")
1246
+ for item in review:
1247
+ label = item["label"] or "no label"
1248
+ ontology = item.get("ontology") or {}
1249
+ suffix = ""
1250
+ if ontology.get("cl_id"):
1251
+ state = "pending" if ontology.get("pending") else ontology.get("status")
1252
+ suffix = f" [{ontology['cl_id']}, {state}]"
1253
+ detail = item["hint"] or item["reason"]
1254
+ print(f" {item['cluster']} {label}{suffix} {detail}")
1255
+
1256
+
1257
+ def merge_same_labels(adata: AnnData, cluster_key: str, label_key: str) -> list[dict[str, object]]:
1258
+ """Give cells that share a written name one cluster id.
1259
+
1260
+ The original ``cluster_key`` stays. ``annotune_cluster`` is the merged id.
1261
+ A cluster that still holds more than one written name is left as it is.
1262
+ Cells with no name keep their own id. Hints stay on the original records.
1263
+ """
1264
+ units = adata.obs[cluster_key].astype(str)
1265
+ if SUBCLUSTER_KEY in adata.obs:
1266
+ sub = adata.obs[SUBCLUSTER_KEY]
1267
+ sub_text = sub.astype(str)
1268
+ use_sub = sub.notna() & ~sub_text.isin(["", "nan", "None", "<NA>"])
1269
+ units = sub_text.where(use_sub, units)
1270
+ text = adata.obs[label_key].astype(str)
1271
+ named = (
1272
+ adata.obs[label_key].notna().to_numpy() & ~text.isin(["", "nan", "None", "<NA>"]).to_numpy()
1273
+ )
1274
+ unit = units.to_numpy()
1275
+ names = text.to_numpy()
1276
+ names_per_unit: dict[str, set[str]] = defaultdict(set)
1277
+ for uid, name, ok in zip(unit, names, named, strict=True):
1278
+ if ok:
1279
+ names_per_unit[str(uid)].add(str(name))
1280
+ by_name: dict[str, list[str]] = defaultdict(list)
1281
+ for uid, found in names_per_unit.items():
1282
+ if len(found) == 1:
1283
+ by_name[next(iter(found))].append(uid)
1284
+ chosen_for: dict[str, str] = {}
1285
+ merged: list[dict[str, object]] = []
1286
+ for name, members in by_name.items():
1287
+ ordered = sorted(members, key=_unit_key)
1288
+ chosen = ordered[0]
1289
+ for uid in ordered:
1290
+ chosen_for[uid] = chosen
1291
+ if len(ordered) > 1:
1292
+ merged.append({"name": name, "cluster": chosen, "members": ordered})
1293
+ new_ids = [
1294
+ chosen_for[str(uid)] if ok and str(uid) in chosen_for else str(uid)
1295
+ for uid, ok in zip(unit, named, strict=True)
1296
+ ]
1297
+ adata.obs[MERGED_KEY] = new_ids
1298
+ return merged
1299
+
1300
+
1301
+ def _unit_key(value: str) -> tuple[int, int | str]:
1302
+ return (0, int(value)) if value.isdigit() else (1, value)
1303
+
1304
+
1305
+ def _one_donor(adata, donor: str | None) -> str | None:
1306
+ if donor is not None:
1307
+ return donor
1308
+ if "donor_id" in adata.obs.columns:
1309
+ values = adata.obs["donor_id"].astype(str).unique()
1310
+ if len(values) == 1:
1311
+ return str(values[0])
1312
+ return None
1313
+
1314
+
1315
+ def _failure_text(exc: BaseException) -> str:
1316
+ if type(exc).__name__ == "OutOfMemoryError":
1317
+ return "OutOfMemoryError"
1318
+ return str(exc)
1319
+
1320
+
1321
+ def _is_runtime_failure(exc: BaseException) -> bool:
1322
+ return isinstance(exc, (OSError, ValueError, RuntimeError))
1323
+
1324
+
1325
+ def _open_local(models: ModelChain) -> bool:
1326
+ """Start one base when both tasks have a local GGUF adapter."""
1327
+ ends = (models.name, models.ml)
1328
+ if not any(item.adapter for item in ends):
1329
+ return False
1330
+ if any(item.ask is not None for item in ends):
1331
+ raise ValueError("local adapters cannot be combined with ask callbacks")
1332
+ if not all(item.adapter and not item.url for item in ends):
1333
+ raise ValueError("name and ml share one base; pass both GGUF adapters")
1334
+ from .local_lora import open_adapters, packaged_tokenizer
1335
+
1336
+ base = models.name.base
1337
+ if models.ml.base != base:
1338
+ raise ValueError("name and ml share one GGUF base")
1339
+ if not str(base).endswith(".gguf"):
1340
+ raise ValueError("local adapters require a GGUF --base")
1341
+ gguf = base
1342
+ chosen = [item.tokenizer for item in ends if item.tokenizer]
1343
+ if len(set(chosen)) > 1:
1344
+ raise ValueError("name and ml share one tokenizer")
1345
+ tokenizer = chosen[0] if chosen else packaged_tokenizer()
1346
+ servers = {item.server for item in ends if item.server}
1347
+ if len(servers) > 1:
1348
+ raise ValueError("name and ml share one server")
1349
+ kwargs = {"url": next(iter(servers))} if servers else {}
1350
+ open_adapters(
1351
+ {"name": models.name.adapter, "ml": models.ml.adapter},
1352
+ gguf=gguf,
1353
+ tokenizer=tokenizer,
1354
+ **kwargs,
1355
+ )
1356
+ return True
1357
+
1358
+
1359
+ def _run_models(
1360
+ adata,
1361
+ cluster_key: str,
1362
+ models: ModelChain,
1363
+ records: list[dict],
1364
+ kept: list[Hypothesis],
1365
+ pool: list[Hypothesis],
1366
+ resolved: dict,
1367
+ arbitration: list[str],
1368
+ *,
1369
+ species: str,
1370
+ tissue: str,
1371
+ stage: str,
1372
+ source: str | None,
1373
+ donor: str | None,
1374
+ model_clusters,
1375
+ min_margin: float,
1376
+ ranker,
1377
+ conformal_q,
1378
+ ) -> list[Hypothesis]:
1379
+ """Score model replies. A retest adds types for that cluster only."""
1380
+ chosen = None if model_clusters is None else {str(item) for item in model_clusters}
1381
+ found: list[Hypothesis] = []
1382
+ dag, label_map, known_names = _lineage_resources(species or None)
1383
+ tables = wilcoxon_order(adata, cluster_key)
1384
+ for index, record in enumerate(records):
1385
+ cluster = str(record["cluster"])
1386
+ if chosen is not None and cluster not in chosen:
1387
+ continue
1388
+ print(f"run models cluster {cluster} ...", flush=True)
1389
+ model_started = time.perf_counter()
1390
+ payload = cluster_payload(
1391
+ adata,
1392
+ cluster_key,
1393
+ cluster,
1394
+ kept,
1395
+ species=species,
1396
+ tissue=tissue,
1397
+ stage=stage,
1398
+ source=source,
1399
+ donor=donor,
1400
+ tables=tables,
1401
+ )
1402
+ if "marker" in payload:
1403
+ raise RuntimeError("model payload must not contain a marker field")
1404
+ rounds: list[dict[str, object]] = []
1405
+ decision, name_error = _invoke(
1406
+ models.name, payload, fetch_name, _parse_name, SYSTEM_NAME, "name"
1407
+ )
1408
+ name: str | None = None
1409
+ if name_error is not None:
1410
+ record["model_error"] = str(name_error)
1411
+ rounds.append({"step": "name", "status": "failed", "error": str(name_error)})
1412
+ else:
1413
+ name = decision.names[0] if decision is not None and decision.names else None
1414
+ rounds.append(
1415
+ {
1416
+ "step": "name",
1417
+ "action": None if decision is None else decision.action,
1418
+ "names": [] if decision is None else list(decision.names),
1419
+ }
1420
+ )
1421
+ try:
1422
+ positive, measurements = rank_measurements(adata, cluster)
1423
+ except (KeyError, TypeError, ValueError) as exc:
1424
+ measurements = None
1425
+ ml_result, ml_error = None, exc
1426
+ else:
1427
+ ml_result, ml_error = _call_ml(models.ml, payload, kept, positive, measurements)
1428
+ if ml_error is not None:
1429
+ ml_result = MlResult("failed", error=_failure_text(ml_error))
1430
+ rounds.append(
1431
+ {
1432
+ "step": "ml",
1433
+ "status": ml_result.status,
1434
+ "names": list(ml_result.names),
1435
+ "error": ml_result.error,
1436
+ }
1437
+ )
1438
+ added: list[Hypothesis] = []
1439
+ if name is not None and record.get("status") != "label":
1440
+ proposal = Decision("retest", (name,))
1441
+ added = _retest_hypotheses(proposal, pool, set(kept_names(kept)))
1442
+ added, _ = _in_matrix(added, set(map(str, adata.var_names)))
1443
+ if added:
1444
+ found.extend(added)
1445
+ updated = _annotate(
1446
+ adata,
1447
+ cluster_key,
1448
+ [*kept, *added],
1449
+ min_margin,
1450
+ ranker,
1451
+ conformal_q,
1452
+ clusters=[cluster],
1453
+ )[0]
1454
+ # The vote views were recorded on the record this one replaces.
1455
+ for field in ("votes", "vote_label", "escalate"):
1456
+ if field in record:
1457
+ updated[field] = record[field]
1458
+ records[index] = updated
1459
+ record = updated
1460
+ if record.get("status") == "label":
1461
+ resolved[cluster] = {cluster: str(record["cell_type"])}
1462
+ ranking = [dict(row) for row in record.get("ranking") or []]
1463
+ top = str(ranking[0]["name"]) if ranking else None
1464
+ labeled = bool(
1465
+ record.get("status") == "label"
1466
+ and record.get("cell_type")
1467
+ and top == str(record["cell_type"])
1468
+ )
1469
+ if top is None and record.get("status") == "label" and record.get("cell_type"):
1470
+ top = str(record["cell_type"])
1471
+ labeled = True
1472
+ settled = settle_cluster(
1473
+ marker=top,
1474
+ name=name,
1475
+ ml=ml_result,
1476
+ added=[item.name for item in added],
1477
+ cells=int(cluster_mask(adata, cluster_key, cluster).sum()),
1478
+ failed=name_error is not None,
1479
+ ontology_names=known_names,
1480
+ species=species or None,
1481
+ ranking=ranking,
1482
+ margin=None if record.get("margin") is None else float(record["margin"]),
1483
+ marker_labeled=labeled,
1484
+ min_margin=min_margin,
1485
+ dag=dag,
1486
+ label_map=label_map,
1487
+ )
1488
+ if settled["written_label"]:
1489
+ resolved[cluster] = {cluster: str(settled["written_label"])}
1490
+ elif settled["final_reason"] == "mixed_programs":
1491
+ resolved.pop(cluster, None)
1492
+ else:
1493
+ mapping = resolved.get(cluster) or {}
1494
+ if mapping and set(mapping) != {cluster}:
1495
+ settled = {
1496
+ **settled,
1497
+ "written_label": None,
1498
+ "hint": None,
1499
+ "arbitration": False,
1500
+ "final_status": "named",
1501
+ "final_reason": "subcluster_labels",
1502
+ }
1503
+ else:
1504
+ resolved.pop(cluster, None)
1505
+ record["rounds"] = rounds
1506
+ record["proposals"] = settled["proposals"]
1507
+ record["added"] = settled["added"]
1508
+ record["written_label"] = settled["written_label"]
1509
+ record["hint"] = settled["hint"]
1510
+ record["arbitration"] = settled["arbitration"]
1511
+ record["final_status"] = settled["final_status"]
1512
+ record["final_reason"] = settled["final_reason"]
1513
+ record["ml_trial"] = [] if measurements is None else _trial_for(payload, kept, measurements)
1514
+ if settled["arbitration"]:
1515
+ arbitration.append(cluster)
1516
+ written = settled.get("written_label") or "no label"
1517
+ print(
1518
+ f"run models cluster {cluster} {time.perf_counter() - model_started:.1f}s {written}",
1519
+ flush=True,
1520
+ )
1521
+ return found
1522
+
1523
+
1524
+ def _invoke(endpoint: TaskEndpoint, payload: dict, fetch, parse, system: str, task: str):
1525
+ """Ask one task. ``fetch(url, payload)`` serves a URL. ``parse(text, payload)`` reads local text."""
1526
+ try:
1527
+ if endpoint.ask is not None:
1528
+ return endpoint.ask(payload), None
1529
+ if endpoint.adapter:
1530
+ from .local_lora import complete
1531
+
1532
+ text = complete(task, endpoint.system or system, payload, maxlen=endpoint.maxlen)
1533
+ return parse(text, payload), None
1534
+ if endpoint.url:
1535
+ return fetch(endpoint.url, payload), None
1536
+ raise ValueError("task needs a url, an adapter, or an ask function")
1537
+ except Exception as exc:
1538
+ if _is_runtime_failure(exc):
1539
+ return None, exc
1540
+ raise
1541
+
1542
+
1543
+ def _flag_labels_without_markers(
1544
+ records: list[dict], resolved: dict, marker_status: dict, by_parent: dict
1545
+ ) -> bool:
1546
+ """Flag a written name when none of its cited markers passed.
1547
+
1548
+ The name stays written and the record keeps a hint, so it is still queued
1549
+ for review. The marker call replaces the name only when that call is a
1550
+ different name that did pass. Returns True when a name was replaced.
1551
+ """
1552
+ blocked = {
1553
+ name
1554
+ for name, status in marker_status.items()
1555
+ if status == "no_marker_passed_expression_check"
1556
+ }
1557
+ if not blocked:
1558
+ return False
1559
+ changed = False
1560
+ for record in records:
1561
+ label = record.get("written_label")
1562
+ cluster = str(record["cluster"])
1563
+ mapping = resolved.get(cluster)
1564
+ if mapping and set(mapping) != {cluster}:
1565
+ failed = {unit for unit, name in mapping.items() if name in blocked}
1566
+ if not failed:
1567
+ continue
1568
+ for child in by_parent.get(cluster, []):
1569
+ if str(child["cluster"]) in failed:
1570
+ child["final_reason"] = "no_marker_passed"
1571
+ child["hint"] = "no marker passed"
1572
+ record["hint"] = "one or more subclusters have no marker that passed validation"
1573
+ continue
1574
+ if label not in blocked or mapping != {cluster: label}:
1575
+ continue
1576
+ marker = record.get("cell_type") if record.get("status") == "label" else None
1577
+ if marker and marker != label and marker not in blocked:
1578
+ changed = True
1579
+ record["written_label"] = marker
1580
+ record["final_status"] = "named"
1581
+ record["final_reason"] = "marker_rule"
1582
+ record["hint"] = None
1583
+ resolved[cluster] = {cluster: str(marker)}
1584
+ continue
1585
+ if record.get("final_reason") == "no_marker_passed":
1586
+ continue
1587
+ record["final_status"] = "named"
1588
+ record["final_reason"] = "no_marker_passed"
1589
+ hint = record.get("hint")
1590
+ record["hint"] = "no marker passed" if not hint else f"{hint}; no marker passed"
1591
+ return changed
1592
+
1593
+
1594
+ def _parse_name(text: str, payload: dict):
1595
+ return parse_decision(text, hit_names(payload))
1596
+
1597
+
1598
+ def _parse_ml(text: str, payload: dict):
1599
+ return parse_ml(text, candidate_names(payload))
1600
+
1601
+
1602
+ def _call_ml(endpoint: TaskEndpoint, payload: dict, hypotheses, positive, measurements):
1603
+ user = ml_user_from_hits(payload, hypotheses, measurements, positive)
1604
+ if "marker" in user:
1605
+ raise RuntimeError("ml payload must not contain a marker field")
1606
+ return _invoke(endpoint, user, fetch_ml, _parse_ml, SYSTEM_ML, "ml")
1607
+
1608
+
1609
+ def _trial_for(payload: dict, hypotheses, measurements: dict[str, dict]) -> list[dict]:
1610
+ by_name = {item.name: item for item in hypotheses}
1611
+ rows = []
1612
+ for hit in payload.get("dictionary_hits") or []:
1613
+ item = by_name.get(str(hit["name"]))
1614
+ if item is None:
1615
+ continue
1616
+ positive = [measurements[gene] for gene in item.positive if gene in measurements]
1617
+ rows.append(
1618
+ {
1619
+ "name": item.name,
1620
+ **trial_support(positive, list(item.negative), measurements),
1621
+ }
1622
+ )
1623
+ return rows
1624
+
1625
+
1626
+ def _annotate(adata, cluster_key, hypotheses, min_margin, ranker, conformal_q, clusters=None):
1627
+ return annotate(
1628
+ adata,
1629
+ cluster_key,
1630
+ hypotheses,
1631
+ min_margin=min_margin,
1632
+ ranker=ranker,
1633
+ conformal_q=conformal_q,
1634
+ clusters=clusters,
1635
+ )
1636
+
1637
+
1638
+ def kept_names(hypotheses: list[Hypothesis]) -> list[str]:
1639
+ return [item.name for item in hypotheses]
1640
+
1641
+
1642
+ def _children_labeled(children: list[dict[str, object]]) -> bool:
1643
+ return bool(children) and all(row["status"] == "label" for row in children)
1644
+
1645
+
1646
+ def _child_label_map(children: list[dict[str, object]]) -> dict[str, str]:
1647
+ return {str(row["cluster"]): str(row["cell_type"]) for row in children}
1648
+
1649
+
1650
+ def _apply_votes(
1651
+ adata, cluster_key, records, by_parent, hypotheses, resolved, species, tissue
1652
+ ) -> None:
1653
+ """Write a label when three views agree. Record every vote either way."""
1654
+ pending = [record for record in records if str(record["cluster"]) not in resolved]
1655
+ if not pending:
1656
+ return
1657
+ parents = parents_for(species=species, tissue=tissue) if species else {}
1658
+ known_names = _lineage_resources(species or None)[2]
1659
+ logfc = _logfc_by_cluster(adata, cluster_key)
1660
+ labeled = {
1661
+ str(record["cluster"]): str(record["cell_type"])
1662
+ for record in records
1663
+ if record["status"] == "label" and record.get("cell_type")
1664
+ }
1665
+ for record in pending:
1666
+ cluster = str(record["cluster"])
1667
+ children = list(by_parent.get(cluster, []))
1668
+ votes = {
1669
+ "panel": panel_vote(record),
1670
+ "genes": gene_vote(hypotheses, logfc.get(cluster, {})),
1671
+ "subcluster": subcluster_vote(children),
1672
+ "neighbors": _neighbor_vote(adata, cluster_key, cluster, labeled),
1673
+ }
1674
+ record["votes"] = votes
1675
+ name = agreed_name(votes, parents)
1676
+ if name is None:
1677
+ record["escalate"] = "views_disagree"
1678
+ continue
1679
+ # A shared parent can be a coarse name the dictionary and ontology do not have.
1680
+ if known_names is not None and name not in known_names:
1681
+ record["escalate"] = "vote_name_not_in_ontology"
1682
+ continue
1683
+ resolved[cluster] = {cluster: name}
1684
+ record["vote_label"] = name
1685
+
1686
+
1687
+ def _logfc_by_cluster(adata: AnnData, cluster_key: str) -> dict[str, dict[str, float]]:
1688
+ """Log fold change of every gene, including genes that are lower inside.
1689
+
1690
+ The name payload reads this same Wilcoxon table. A gene detected in fewer
1691
+ than ``NEGATIVE_PRESENT_PCT`` of the cluster's cells is never reported as
1692
+ higher inside, because its fold change is unstable.
1693
+ """
1694
+ if adata.obs[cluster_key].nunique() < 2 or _variable_genes(adata) < 2:
1695
+ return {}
1696
+ try:
1697
+ wilcoxon_order(adata, cluster_key)
1698
+ except ValueError:
1699
+ return {}
1700
+ result = adata.uns.get("rank_genes_groups")
1701
+ if not result or "names" not in result:
1702
+ return {}
1703
+ tables: dict[str, dict[str, float]] = {}
1704
+ for cluster in adata.obs[cluster_key].astype(str).unique():
1705
+ key = str(cluster)
1706
+ try:
1707
+ names = [str(gene) for gene in result["names"][key]]
1708
+ changes = [float(value) for value in result["logfoldchanges"][key]]
1709
+ except (KeyError, IndexError, TypeError, ValueError):
1710
+ continue
1711
+ detected = detection_fractions(result["pts"], key, names) >= NEGATIVE_PRESENT_PCT
1712
+ tables[key] = {
1713
+ gene: change if seen or change < 0 else 0.0
1714
+ for gene, change, seen in zip(names, changes, detected, strict=True)
1715
+ }
1716
+ return tables
1717
+
1718
+
1719
+ def _apply_neighbor_titles(adata, cluster_key, records, resolved, species, dag, label_map) -> None:
1720
+ """Give an unclear cluster the title of one clear cluster it touches.
1721
+
1722
+ A title is clear when the name and ML calls both wrote it, or when no model
1723
+ ran and the marker rule wrote it without a hint. An unclear cluster takes
1724
+ that title when the neighbor graph or the UMAP places it with exactly one
1725
+ such lineage. A title the model already wrote on that lineage stays. Two
1726
+ clear lineages do not choose a title; a one-sided name from another lineage
1727
+ is withdrawn and the clear clusters are named in the hint.
1728
+ """
1729
+ if "connectivities" not in adata.obsp and "X_umap" not in adata.obsm:
1730
+ return
1731
+ by_id = {str(record["cluster"]): record for record in records}
1732
+ clear = {
1733
+ cluster: str(record["written_label"])
1734
+ for cluster, record in by_id.items()
1735
+ if _title_is_clear(record)
1736
+ }
1737
+ if not clear:
1738
+ return
1739
+ linked = _linked_clusters(adata, cluster_key)
1740
+ for record in records:
1741
+ cluster = str(record["cluster"])
1742
+ if cluster in clear or record.get("final_reason") == "subcluster_labels":
1743
+ continue
1744
+ mapping = resolved.get(cluster)
1745
+ if mapping and set(mapping) != {cluster}:
1746
+ continue
1747
+ neighbors = []
1748
+ seen: set[str] = set()
1749
+ for other in sorted(linked.get(cluster, ()), key=_cluster_order):
1750
+ if other not in clear or other in seen:
1751
+ continue
1752
+ seen.add(other)
1753
+ neighbors.append((other, clear[other]))
1754
+ if neighbors:
1755
+ _title_from_clear_neighbors(
1756
+ record, cluster, neighbors, resolved, species, dag, label_map
1757
+ )
1758
+
1759
+
1760
+ def _title_is_clear(record: dict) -> bool:
1761
+ label = record.get("written_label")
1762
+ if not label or record.get("final_status") != "named":
1763
+ return False
1764
+ if record.get("final_reason") == "subcluster_labels":
1765
+ return False
1766
+ name = _proposal_name(record, "name")
1767
+ ml = _proposal_name(record, "ml")
1768
+ if name == label and ml == label:
1769
+ return True
1770
+ return (
1771
+ not record.get("rounds")
1772
+ and not record.get("hint")
1773
+ and record.get("final_reason") in {"marker_rule", "evidence_agreement", "votes"}
1774
+ )
1775
+
1776
+
1777
+ def _proposal_name(record: dict, step: str):
1778
+ value = _model_name(record, step)
1779
+ if value in (None, "escalate", "failed", "refuse"):
1780
+ return None
1781
+ return str(value)
1782
+
1783
+
1784
+ def _title_from_clear_neighbors(record, cluster, neighbors, resolved, species, dag, label_map):
1785
+ labels = list(dict.fromkeys(label for _other, label in neighbors))
1786
+ groups = _label_groups(labels, species, dag, label_map)
1787
+ near = "near " + ", ".join(f"{other} {label}" for other, label in neighbors)
1788
+ written = record.get("written_label")
1789
+ written_fits = bool(written) and any(
1790
+ _same_lineage(str(written), label, species, dag, label_map) for label in labels
1791
+ )
1792
+ if len(groups) != 1:
1793
+ if written and not written_fits:
1794
+ _withdraw_title(record, cluster, near, resolved)
1795
+ else:
1796
+ _join_hint(record, near)
1797
+ return
1798
+ if written_fits:
1799
+ _join_hint(record, near)
1800
+ return
1801
+ chosen = _coarsest(groups[0], species, dag, label_map)
1802
+ if chosen is None:
1803
+ if written and not written_fits:
1804
+ _withdraw_title(record, cluster, near, resolved)
1805
+ else:
1806
+ _join_hint(record, near)
1807
+ return
1808
+ note = _other_calls(record, chosen)
1809
+ record["written_label"] = chosen
1810
+ record["final_status"] = "named"
1811
+ record["final_reason"] = "near_clear_cluster"
1812
+ record["hint"] = "; ".join(part for part in (note, near) if part)
1813
+ resolved[cluster] = {cluster: chosen}
1814
+
1815
+
1816
+ def _withdraw_title(record, cluster, near, resolved) -> None:
1817
+ note = _other_calls(record, None)
1818
+ record["written_label"] = None
1819
+ record["final_status"] = "review"
1820
+ record["final_reason"] = "near_clear_cluster"
1821
+ record["hint"] = "; ".join(part for part in (note, near) if part)
1822
+ resolved.pop(cluster, None)
1823
+
1824
+
1825
+ def _other_calls(record: dict, title: str | None) -> str:
1826
+ parts = []
1827
+ marker = record.get("cell_type") if record.get("status") == "label" else None
1828
+ if marker and marker != title:
1829
+ parts.append(f"marker {marker}")
1830
+ name = _proposal_name(record, "name")
1831
+ if name and name != title:
1832
+ parts.append(f"name {name}")
1833
+ ml = _proposal_name(record, "ml")
1834
+ if ml and ml != title:
1835
+ parts.append(f"ml {ml}")
1836
+ return ", ".join(parts)
1837
+
1838
+
1839
+ def _join_hint(record: dict, extra: str) -> None:
1840
+ current = record.get("hint")
1841
+ record["hint"] = extra if not current else f"{current}; {extra}"
1842
+
1843
+
1844
+ def _label_groups(names: list[str], species, dag, label_map) -> list[list[str]]:
1845
+ parent = list(range(len(names)))
1846
+
1847
+ def find(index: int) -> int:
1848
+ while parent[index] != index:
1849
+ parent[index] = parent[parent[index]]
1850
+ index = parent[index]
1851
+ return index
1852
+
1853
+ for left in range(len(names)):
1854
+ for right in range(left + 1, len(names)):
1855
+ if _same_lineage(names[left], names[right], species, dag, label_map):
1856
+ parent[find(right)] = find(left)
1857
+ groups: dict[int, list[str]] = {}
1858
+ for index, name in enumerate(names):
1859
+ groups.setdefault(find(index), []).append(name)
1860
+ return list(groups.values())
1861
+
1862
+
1863
+ def _coarsest(names: list[str], species, dag, label_map) -> str | None:
1864
+ unique = list(dict.fromkeys(names))
1865
+ if len(unique) == 1:
1866
+ return unique[0]
1867
+ parents = [
1868
+ name
1869
+ for name in unique
1870
+ if all(
1871
+ other == name or _is_descendant(other, name, species, dag, label_map)
1872
+ for other in unique
1873
+ )
1874
+ ]
1875
+ if len(parents) == 1:
1876
+ return parents[0]
1877
+ return None
1878
+
1879
+
1880
+ def _linked_clusters(adata: AnnData, cluster_key: str) -> dict[str, set[str]]:
1881
+ """Clusters joined by the neighbor graph or by touching on the UMAP."""
1882
+ labels = adata.obs[cluster_key].astype(str).to_numpy()
1883
+ linked = {str(cluster): set() for cluster in pd.unique(labels)}
1884
+ if "connectivities" in adata.obsp:
1885
+ _add_links(linked, _connectivity_ratios(labels, adata.obsp["connectivities"]))
1886
+ if "X_umap" in adata.obsm:
1887
+ adjacency = _umap_adjacency(np.asarray(adata.obsm["X_umap"], dtype=float))
1888
+ _add_links(linked, _connectivity_ratios(labels, adjacency))
1889
+ return linked
1890
+
1891
+
1892
+ def _add_links(linked: dict[str, set[str]], ratios: dict[str, list[tuple[str, float]]]) -> None:
1893
+ for cluster, pairs in ratios.items():
1894
+ linked.setdefault(cluster, set()).update(other for other, _ratio in pairs)
1895
+
1896
+
1897
+ def _connectivity_ratios(labels: np.ndarray, adjacency) -> dict[str, list[tuple[str, float]]]:
1898
+ """PAGA's observed-over-expected edge ratio for each pair of clusters.
1899
+
1900
+ A pair is returned only when the ratio is at least 1, the same comparison
1901
+ scanpy uses before capping the value for display.
1902
+ """
1903
+ matrix = sparse.csr_matrix(adjacency)
1904
+ n = matrix.shape[0]
1905
+ empty: dict[str, list[tuple[str, float]]] = {}
1906
+ if n == 0 or n != len(labels) or matrix.shape[1] != n:
1907
+ return empty
1908
+ ones = matrix.copy()
1909
+ ones.data = np.ones(len(ones.data), dtype=float)
1910
+ ones.setdiag(0)
1911
+ ones.eliminate_zeros()
1912
+ clusters = [str(cluster) for cluster in pd.unique(labels)]
1913
+ index = {name: pos for pos, name in enumerate(clusters)}
1914
+ codes = np.array([index[str(label)] for label in labels])
1915
+ size = len(clusters)
1916
+ outgoing = np.zeros((size, size), dtype=float)
1917
+ internal = np.zeros(size, dtype=float)
1918
+ coo = ones.tocoo()
1919
+ for row, col in zip(coo.row, coo.col, strict=True):
1920
+ left = codes[row]
1921
+ right = codes[col]
1922
+ if left == right:
1923
+ internal[left] += 1.0
1924
+ else:
1925
+ outgoing[left, right] += 1.0
1926
+ counts = np.bincount(codes, minlength=size).astype(float)
1927
+ strength = internal + outgoing.sum(axis=1)
1928
+ between = outgoing + outgoing.T
1929
+ result = {name: [] for name in clusters}
1930
+ denom = max(n - 1, 1)
1931
+ for left in range(size):
1932
+ for right in range(left + 1, size):
1933
+ observed = between[left, right]
1934
+ if observed <= 0:
1935
+ continue
1936
+ expected = (strength[left] * counts[right] + strength[right] * counts[left]) / denom
1937
+ ratio = observed / expected if expected else 1.0
1938
+ if ratio >= 1:
1939
+ result[clusters[left]].append((clusters[right], float(ratio)))
1940
+ result[clusters[right]].append((clusters[left], float(ratio)))
1941
+ return result
1942
+
1943
+
1944
+ def _umap_adjacency(coords: np.ndarray):
1945
+ """Cells within one typical UMAP neighbor-spacing of each other."""
1946
+ from scipy.spatial import cKDTree
1947
+
1948
+ n = coords.shape[0]
1949
+ if n < 2:
1950
+ return sparse.csr_matrix((n, n))
1951
+ distances, _indexes = cKDTree(coords).query(coords, k=2)
1952
+ spacing = float(np.median(distances[:, 1]))
1953
+ if not np.isfinite(spacing) or spacing <= 0:
1954
+ return sparse.csr_matrix((n, n))
1955
+ rows, cols = cKDTree(coords).query_pairs(spacing, output_type="ndarray").T
1956
+ if len(rows) == 0:
1957
+ return sparse.csr_matrix((n, n))
1958
+ data = np.ones(len(rows) * 2, dtype=float)
1959
+ return sparse.csr_matrix(
1960
+ (data, (np.concatenate([rows, cols]), np.concatenate([cols, rows]))),
1961
+ shape=(n, n),
1962
+ )
1963
+
1964
+
1965
+ def _neighbor_vote(
1966
+ adata: AnnData, cluster_key: str, cluster: str, labeled: dict[str, str]
1967
+ ) -> str | None:
1968
+ if "connectivities" not in adata.obsp:
1969
+ return None
1970
+ labels = adata.obs[cluster_key].astype(str).to_numpy()
1971
+ inside = np.flatnonzero(labels == str(cluster))
1972
+ if len(inside) == 0:
1973
+ return None
1974
+ weights = np.asarray(adata.obsp["connectivities"][inside].sum(axis=0)).ravel()
1975
+ names = pd.Series(labels).map(labeled).to_numpy(dtype=object)
1976
+ usable = (weights > 0) & (labels != str(cluster)) & pd.notna(names)
1977
+ if not usable.any():
1978
+ return None
1979
+ totals = pd.Series(weights[usable]).groupby(names[usable]).sum()
1980
+ if totals.sum() <= 0:
1981
+ return None
1982
+ name = str(totals.idxmax())
1983
+ if totals.max() / totals.sum() < 0.15:
1984
+ return None
1985
+ return name
1986
+
1987
+
1988
+ def _resolved_labels(adata, cluster_key, records, by_parent) -> dict[str, dict[str, str]]:
1989
+ resolved: dict[str, dict[str, str]] = {}
1990
+ for record in records:
1991
+ cluster = str(record["cluster"])
1992
+ if record["status"] == "label":
1993
+ resolved[cluster] = {cluster: str(record["cell_type"])}
1994
+ continue
1995
+ children = list(by_parent.get(cluster, []))
1996
+ if _children_labeled(children):
1997
+ resolved[cluster] = _child_label_map(children)
1998
+ return resolved
1999
+
2000
+
2001
+ def _retest_hypotheses(decision, pool: list[Hypothesis], used: set[str]) -> list[Hypothesis]:
2002
+ by_name = {item.name: item for item in pool}
2003
+ added: list[Hypothesis] = []
2004
+ for name in decision.names:
2005
+ item = by_name.get(name)
2006
+ if item is not None and name not in used:
2007
+ added.append(item)
2008
+ used.add(name)
2009
+ for item in decision.markers:
2010
+ if item.name not in used:
2011
+ added.append(item)
2012
+ used.add(item.name)
2013
+ return added
2014
+
2015
+
2016
+ def _replace_record(records: list[dict[str, object]], updated: dict[str, object]) -> None:
2017
+ for index, record in enumerate(records):
2018
+ if str(record["cluster"]) == str(updated["cluster"]):
2019
+ records[index] = updated
2020
+ return
2021
+
2022
+
2023
+ def _assign_labels(adata, cluster_key, resolved: dict[str, dict[str, str]]):
2024
+ import pandas as pd
2025
+
2026
+ labels = pd.Series(pd.NA, index=adata.obs_names, dtype="string")
2027
+ clusters = adata.obs[cluster_key].astype(str)
2028
+ sub = adata.obs[SUBCLUSTER_KEY].astype(str) if SUBCLUSTER_KEY in adata.obs.columns else None
2029
+ for cluster, mapping in resolved.items():
2030
+ mask = clusters == cluster
2031
+ if set(mapping) == {cluster}:
2032
+ labels.loc[mask] = mapping[cluster]
2033
+ continue
2034
+ if sub is None:
2035
+ continue
2036
+ labels.loc[mask] = sub.loc[mask].map(mapping)
2037
+ return labels