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,725 @@
1
+ # Copyright 2026 Zhao Li (李钊)
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ """Name and ML results for one cluster.
5
+
6
+ The name model copies a cell-type name from the marker dictionary. The ML
7
+ model reads measured differential expression and candidate marker panels, and
8
+ copies a name from those candidates. Both return a result when a model chain
9
+ is supplied. The workflow judgment reads those results together with the
10
+ marker score and writes the title.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ import random
18
+ import urllib.request
19
+ import weakref
20
+ from dataclasses import dataclass
21
+
22
+ import numpy as np
23
+ from scipy import sparse
24
+
25
+ from .evidence import NEGATIVE_PRESENT_PCT
26
+ from .hypotheses import Hypothesis
27
+
28
+ # Each task has its own prompt.
29
+ SYSTEM_NAME = (
30
+ "You name one cell cluster. dictionary_hits is not in rank order. "
31
+ "negative lists genes that type should not express when they are high in this cluster. "
32
+ "Reply with JSON only. "
33
+ '{"action": "retest", "names": ["dictionary name"]} '
34
+ "Name a type only when at least two of its markers are near the top of the gene list "
35
+ "and its negative list is empty. If two types list the same genes and only one has an empty "
36
+ "negative list, name that type only when it is the one supported near the top. "
37
+ "Copy the name from dictionary_hits."
38
+ )
39
+
40
+ SYSTEM_DECIDE = SYSTEM_NAME
41
+
42
+ SYSTEM_ML = (
43
+ "You review one cell cluster using measured differential expression and candidate marker panels. "
44
+ "genes is the full gene list retained by the cluster cache. marker_measurements contains "
45
+ "every positive and negative marker for the listed candidates that was measured; null means "
46
+ "the cache has no measurement, not zero expression. The columns are gene, logfc, pct_in, pct_out. "
47
+ "Each candidate lists all its positive and negative marker names and statistics in the order "
48
+ "specified by candidate_stat_columns. Use expression direction, detection rates, competing "
49
+ 'candidates, species and tissue. Reply with JSON only: {"names":["candidate name"]} '
50
+ 'or {"action":"escalate"}. Copy a name from candidates; do not invent one.'
51
+ )
52
+
53
+ ML_MEASUREMENT_COLUMNS = ["gene", "logfc", "pct_in", "pct_out"]
54
+ ML_STAT_COLUMNS = [
55
+ "n_positive",
56
+ "n_positive_supported",
57
+ "n_positive_downregulated",
58
+ "n_positive_detected",
59
+ "n_negative",
60
+ "n_negative_high",
61
+ "n_negative_unmeasured",
62
+ "mean_positive_logfc",
63
+ ]
64
+
65
+ _ACTIONS = frozenset({"adopt_subcluster", "retest", "refuse", "escalate"})
66
+
67
+ # A marker counts as support only above this log2 fold change.
68
+ SUPPORT_LOGFC = 0.5
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class Decision:
73
+ action: str
74
+ names: tuple[str, ...] = ()
75
+ markers: tuple[Hypothesis, ...] = ()
76
+
77
+ def as_dict(self) -> dict[str, object]:
78
+ return {
79
+ "action": self.action,
80
+ "names": list(self.names),
81
+ "markers": [
82
+ {
83
+ "name": item.name,
84
+ "positive": list(item.positive),
85
+ "negative": list(item.negative),
86
+ }
87
+ for item in self.markers
88
+ ],
89
+ }
90
+
91
+
92
+ def _gene_list(value: object) -> tuple[str, ...]:
93
+ if value is None:
94
+ return ()
95
+ if not isinstance(value, list):
96
+ raise ValueError("marker genes must be a list")
97
+ return tuple(str(gene) for gene in value)
98
+
99
+
100
+ def parse_decision(text: str, allowed: set[str] | None = None) -> Decision:
101
+ """Parse a model reply. Names outside ``allowed`` are dropped.
102
+
103
+ An empty ``allowed`` set drops every name. ``None`` keeps them all.
104
+ """
105
+ try:
106
+ payload = json.loads(text)
107
+ except json.JSONDecodeError as exc:
108
+ raise ValueError("decision is not JSON") from exc
109
+ if not isinstance(payload, dict):
110
+ raise ValueError("decision must be an object")
111
+ action = payload.get("action")
112
+ if action not in _ACTIONS:
113
+ raise ValueError(f"unknown action {action!r}")
114
+ raw_names = payload.get("names") or []
115
+ raw_markers = payload.get("markers") or []
116
+ if not isinstance(raw_names, list) or not isinstance(raw_markers, list):
117
+ raise ValueError("names and markers must be lists")
118
+ names = tuple(str(name) for name in raw_names if allowed is None or str(name) in allowed)
119
+ markers: list[Hypothesis] = []
120
+ for item in raw_markers:
121
+ if not isinstance(item, dict):
122
+ raise ValueError("marker entry must be an object")
123
+ positive = _gene_list(item.get("positive"))
124
+ negative = _gene_list(item.get("negative"))
125
+ if not positive:
126
+ continue
127
+ name = item.get("name")
128
+ if not isinstance(name, str) or not name:
129
+ raise ValueError("marker entry needs a name")
130
+ markers.append(Hypothesis(name, positive=positive, negative=negative))
131
+ if action == "retest" and not names and not markers:
132
+ # A reply that only names types outside the hit list abstains.
133
+ if allowed is not None and raw_names:
134
+ return Decision("escalate")
135
+ raise ValueError("retest needs a dictionary name or markers")
136
+ if action != "retest":
137
+ names = ()
138
+ markers = ()
139
+ return Decision(action, names, tuple(markers))
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class MlResult:
144
+ """ML adapter result. ``named`` copied a candidate. ``escalate`` abstains. ``failed`` is not a prediction."""
145
+
146
+ status: str
147
+ names: tuple[str, ...] = ()
148
+ error: str | None = None
149
+
150
+
151
+ def parse_ml(text: str, allowed: set[str] | None = None) -> MlResult:
152
+ """Parse an ML reply. A ``names`` list is success. ``action=escalate`` abstains.
153
+
154
+ A reply that is not a names list or an escalate action is rejected.
155
+ """
156
+ try:
157
+ payload = json.loads(text)
158
+ except json.JSONDecodeError as exc:
159
+ raise ValueError("ml reply is not JSON") from exc
160
+ if not isinstance(payload, dict):
161
+ raise ValueError("ml reply must be an object")
162
+ if payload.get("action") == "escalate":
163
+ return MlResult("escalate")
164
+ raw_names = payload.get("names")
165
+ if not isinstance(raw_names, list) or not raw_names:
166
+ raise ValueError("ml success needs a names list")
167
+ names = tuple(str(name) for name in raw_names if allowed is None or str(name) in allowed)
168
+ if not names:
169
+ if allowed is not None:
170
+ return MlResult("escalate")
171
+ raise ValueError("ml names are not in the candidate list")
172
+ return MlResult("named", names)
173
+
174
+
175
+ def propose_hypotheses(
176
+ catalog: list[Hypothesis],
177
+ enriched: list[str],
178
+ used: set[str],
179
+ *,
180
+ limit: int = 5,
181
+ ) -> list[Hypothesis]:
182
+ """Dictionary types whose positive markers overlap undeclared enriched genes."""
183
+ genes = set(enriched)
184
+ ranked: list[tuple[int, str, Hypothesis]] = []
185
+ for item in catalog:
186
+ if item.name in used:
187
+ continue
188
+ overlap = len(set(item.positive) & genes)
189
+ if overlap:
190
+ ranked.append((overlap, item.name, item))
191
+ ranked.sort(key=lambda row: (-row[0], row[1]))
192
+ return [item for _, _, item in ranked[:limit]]
193
+
194
+
195
+ # The weak reference prevents a later AnnData object from reusing a dead one's id.
196
+ _WILCOXON: dict[int, tuple[weakref.ReferenceType, tuple, dict, dict]] = {}
197
+
198
+
199
+ def _gene_name(value: object) -> str:
200
+ if isinstance(value, bytes):
201
+ return value.decode()
202
+ return str(value)
203
+
204
+
205
+ def _buffer_digest(values) -> bytes:
206
+ arr = np.asarray(values)
207
+ digest = hashlib.blake2b(digest_size=8)
208
+ digest.update(arr.dtype.str.encode())
209
+ digest.update(np.asarray(arr.shape, dtype=np.int64).tobytes())
210
+ if arr.size == 0 or arr.dtype.kind == "O":
211
+ return digest.digest()
212
+ flat = arr.reshape(-1)
213
+ if not flat.flags.c_contiguous:
214
+ flat = np.ascontiguousarray(flat)
215
+ digest.update(memoryview(flat).cast("B"))
216
+ return digest.digest()
217
+
218
+
219
+ def _matrix_token(matrix) -> tuple:
220
+ """Content token so an in-place edit of ``X`` does not reuse an old table."""
221
+ if sparse.issparse(matrix):
222
+ return (
223
+ "sparse",
224
+ tuple(matrix.shape),
225
+ str(matrix.format),
226
+ int(matrix.nnz),
227
+ _buffer_digest(matrix.data),
228
+ _buffer_digest(matrix.indices),
229
+ _buffer_digest(matrix.indptr),
230
+ )
231
+ values = np.asarray(matrix)
232
+ return ("dense", tuple(values.shape), str(values.dtype), _buffer_digest(values))
233
+
234
+
235
+ def wilcoxon_order(adata, cluster_key: str) -> dict[str, list[tuple[str, float]]]:
236
+ """Positive genes in ``rank_genes_groups`` score order.
237
+
238
+ Writes ``uns['rank_genes_groups']``. Repeated calls reuse the table when
239
+ the cluster labels and the values in ``X`` are unchanged.
240
+ """
241
+ import scanpy as sc
242
+
243
+ labels = adata.obs[cluster_key].astype(str)
244
+ counts = labels.value_counts()
245
+ matrix = adata.X
246
+ signature = (cluster_key, tuple(labels), id(matrix), _matrix_token(matrix))
247
+ cache_key = id(adata)
248
+ cached = _WILCOXON.get(cache_key)
249
+ if cached is not None and cached[0]() is adata and cached[1] == signature:
250
+ adata.uns["rank_genes_groups"] = cached[3]
251
+ return cached[2]
252
+ if adata.obs[cluster_key].nunique() < 2:
253
+ raise ValueError("wilcoxon needs at least two clusters")
254
+ import warnings
255
+
256
+ import pandas as pd
257
+
258
+ # Scanpy inserts one column per cluster into a single frame.
259
+ with warnings.catch_warnings():
260
+ warnings.simplefilter("ignore", pd.errors.PerformanceWarning)
261
+ sc.tl.rank_genes_groups(
262
+ adata,
263
+ groupby=cluster_key,
264
+ reference="rest",
265
+ method="wilcoxon",
266
+ use_raw=False,
267
+ pts=True,
268
+ )
269
+ result = adata.uns["rank_genes_groups"]
270
+ tables: dict[str, list[tuple[str, float]]] = {}
271
+ for cluster in counts.index:
272
+ key = str(cluster)
273
+ try:
274
+ order = [_gene_name(gene) for gene in result["names"][key]]
275
+ changes = [float(value) for value in result["logfoldchanges"][key]]
276
+ except (KeyError, IndexError, TypeError, ValueError):
277
+ continue
278
+ rows: list[tuple[str, float]] = []
279
+ seen: set[str] = set()
280
+ for gene, change in zip(order, changes, strict=True):
281
+ if gene in seen or change <= 0:
282
+ continue
283
+ seen.add(gene)
284
+ rows.append((gene, change))
285
+ tables[key] = rows
286
+ _WILCOXON[cache_key] = (
287
+ weakref.ref(adata, lambda _ref: _WILCOXON.pop(cache_key, None)),
288
+ signature,
289
+ tables,
290
+ result,
291
+ )
292
+ return tables
293
+
294
+
295
+ def _detection(adata, cluster: str) -> dict[str, float]:
296
+ result = adata.uns.get("rank_genes_groups")
297
+ if not result or "pts" not in result:
298
+ return {}
299
+ frame = result["pts"]
300
+ if cluster not in frame.columns:
301
+ return {}
302
+ return {_gene_name(gene): float(frame.loc[gene, cluster]) for gene in frame.index}
303
+
304
+
305
+ def ranked_payload(
306
+ order: list[tuple[str, float]],
307
+ pct: dict[str, float],
308
+ hypotheses: list[Hypothesis],
309
+ *,
310
+ species: str = "",
311
+ tissue: str = "",
312
+ stage: str = "",
313
+ previous: dict[str, object] | None = None,
314
+ source: str | None = None,
315
+ donor: str | None = None,
316
+ cluster: str | None = None,
317
+ ) -> dict[str, object]:
318
+ """Action and name user object from a Wilcoxon-ordered gene list."""
319
+ by_gene = dict(order)
320
+ genes: list[dict[str, object]] = [
321
+ {"gene": gene, "logfc": round(change, 1)} for gene, change in order[:25]
322
+ ]
323
+ scored: list[tuple[float, str, list[str]]] = []
324
+ for item in hypotheses:
325
+ chosen: list[str] = []
326
+ total = 0.0
327
+ for gene in item.positive:
328
+ value = by_gene.get(gene)
329
+ # Wilcoxon logFC explodes when a handful of cells carry the gene.
330
+ # The same detection cut as a negative marker keeps those genes
331
+ # out of the names the model is allowed to copy.
332
+ if value is None or value <= SUPPORT_LOGFC:
333
+ continue
334
+ if pct.get(gene, 0.0) < NEGATIVE_PRESENT_PCT:
335
+ continue
336
+ chosen.append(gene)
337
+ total += value
338
+ if chosen:
339
+ scored.append((total, item.name, chosen, item))
340
+ scored.sort(key=lambda row: (-row[0], row[1]))
341
+ hits = []
342
+ for _, name, chosen, item in scored[:10]:
343
+ forbidden = [
344
+ gene
345
+ for gene in item.negative
346
+ if by_gene.get(gene, 0.0) >= 1 and pct.get(gene, 0.0) >= 0.25
347
+ ]
348
+ hits.append({"name": name, "genes": chosen, "negative": forbidden})
349
+ have = {item["gene"] for item in genes}
350
+ for hit in hits:
351
+ for gene in [*hit["genes"], *hit["negative"]]:
352
+ if gene in have or gene not in by_gene:
353
+ continue
354
+ genes.append({"gene": gene, "logfc": round(by_gene[gene], 1)})
355
+ have.add(gene)
356
+ if source is not None and cluster is not None:
357
+ random.Random(f"{source}|{donor}|{cluster}").shuffle(hits)
358
+ payload: dict[str, object] = {
359
+ "species": species,
360
+ "tissue": tissue,
361
+ "stage": stage,
362
+ "genes": genes,
363
+ "dictionary_hits": hits,
364
+ }
365
+ if previous is not None:
366
+ payload["previous"] = previous
367
+ return payload
368
+
369
+
370
+ def cluster_payload(
371
+ adata,
372
+ cluster_key: str,
373
+ cluster: str,
374
+ hypotheses: list[Hypothesis],
375
+ *,
376
+ species: str = "",
377
+ tissue: str = "",
378
+ stage: str = "",
379
+ previous: dict[str, object] | None = None,
380
+ source: str | None = None,
381
+ donor: str | None = None,
382
+ tables: dict[str, list[tuple[str, float]]] | None = None,
383
+ ) -> dict[str, object]:
384
+ """User message: top genes plus dictionary hits.
385
+
386
+ Gene order is the Wilcoxon score order. Marker genes with logfc>0 that
387
+ fall outside the top 25 are appended. A caller that builds many clusters
388
+ passes the ``wilcoxon_order`` result as ``tables``, so the matrix is not
389
+ checked again for every cluster.
390
+ """
391
+ if tables is None:
392
+ tables = wilcoxon_order(adata, cluster_key)
393
+ order = tables.get(str(cluster), [])
394
+ return ranked_payload(
395
+ order,
396
+ _detection(adata, str(cluster)),
397
+ hypotheses,
398
+ species=species,
399
+ tissue=tissue,
400
+ stage=stage,
401
+ previous=previous,
402
+ source=source,
403
+ donor=donor,
404
+ cluster=str(cluster),
405
+ )
406
+
407
+
408
+ def wilcoxon_payload(
409
+ adata,
410
+ cluster_key: str,
411
+ cluster: str,
412
+ hypotheses: list[Hypothesis],
413
+ *,
414
+ species: str = "",
415
+ tissue: str = "",
416
+ stage: str = "",
417
+ previous: dict[str, object] | None = None,
418
+ ) -> dict[str, object]:
419
+ """Training-order payload. ``cluster_payload`` uses this ranking."""
420
+ return cluster_payload(
421
+ adata,
422
+ cluster_key,
423
+ cluster,
424
+ hypotheses,
425
+ species=species,
426
+ tissue=tissue,
427
+ stage=stage,
428
+ previous=previous,
429
+ )
430
+
431
+
432
+ def rank_measurements(adata, cluster: str) -> tuple[list[dict], dict[str, dict]]:
433
+ """Positive genes in Wilcoxon order, plus a measurement for every ranked gene.
434
+
435
+ ``logfc`` is scanpy ``logfoldchanges``. It is not replaced by a detection-aware score.
436
+ """
437
+ result = adata.uns.get("rank_genes_groups")
438
+ if not result:
439
+ raise ValueError("rank_genes_groups is missing")
440
+ key = str(cluster)
441
+ names = [_gene_name(gene) for gene in result["names"][key]]
442
+ changes = [float(value) for value in result["logfoldchanges"][key]]
443
+ pct_in = detection_fractions(result["pts"], key, names)
444
+ pct_out = detection_fractions(result["pts_rest"], key, names)
445
+ measurements: dict[str, dict] = {}
446
+ positive: list[dict] = []
447
+ seen: set[str] = set()
448
+ for gene, change, inside, outside in zip(names, changes, pct_in, pct_out, strict=True):
449
+ row = {"gene": gene, "logfc": change, "pct_in": float(inside), "pct_out": float(outside)}
450
+ measurements[gene] = row
451
+ if change > 0 and gene not in seen:
452
+ seen.add(gene)
453
+ positive.append(row)
454
+ return positive, measurements
455
+
456
+
457
+ def detection_fractions(frame, cluster: str, names: list[str]) -> np.ndarray:
458
+ """Detection fraction of each gene in ``names``. A gene or cluster the table lacks is 0."""
459
+ if cluster not in frame.columns:
460
+ return np.zeros(len(names))
461
+ return frame[cluster].reindex(names).fillna(0.0).to_numpy(dtype=float)
462
+
463
+
464
+ def _rounded_measurement(gene: str, row: dict) -> dict:
465
+ return {
466
+ "gene": gene,
467
+ "logfc": round(float(row["logfc"]), 3),
468
+ "pct_in": round(float(row["pct_in"]), 3),
469
+ "pct_out": round(float(row["pct_out"]), 3),
470
+ }
471
+
472
+
473
+ # The ML message keeps this many leading positive genes, then every other
474
+ # positive dictionary marker.
475
+ CACHE_HEAD = 24
476
+
477
+
478
+ def retained_measurements(
479
+ positive: list[dict],
480
+ hypotheses: list[Hypothesis],
481
+ measurements: dict[str, dict],
482
+ ) -> list[dict]:
483
+ """Gene rows kept for the ML message, rounded to 3 decimals."""
484
+ rows: list[dict] = []
485
+ have: set[str] = set()
486
+ for row in positive:
487
+ if float(row["logfc"]) <= 0:
488
+ continue
489
+ gene = str(row["gene"])
490
+ if gene in have:
491
+ continue
492
+ rows.append(_rounded_measurement(gene, row))
493
+ have.add(gene)
494
+ if len(rows) >= CACHE_HEAD:
495
+ break
496
+ for item in hypotheses:
497
+ for gene in item.positive:
498
+ if gene in have:
499
+ continue
500
+ src = measurements.get(gene)
501
+ if src is None or float(src["logfc"]) <= 0:
502
+ continue
503
+ rows.append(_rounded_measurement(gene, src))
504
+ have.add(gene)
505
+ return rows
506
+
507
+
508
+ def ml_user_from_hits(
509
+ action_payload: dict[str, object],
510
+ hypotheses: list[Hypothesis],
511
+ measurements: dict[str, dict],
512
+ positive: list[dict],
513
+ ) -> dict[str, object]:
514
+ """ML user message. No marker field.
515
+
516
+ ``positive`` is the Wilcoxon-ordered positive gene list. The emitted gene
517
+ list is the retained list, not that whole list. Candidate order follows
518
+ ``dictionary_hits``. Statistics use the unrounded measurement of each
519
+ positive and negative marker.
520
+ """
521
+ by_name = {item.name: item for item in hypotheses}
522
+ genes = retained_measurements(positive, hypotheses, measurements)
523
+ precise = {str(row["gene"]): row for row in genes}
524
+ for item in hypotheses:
525
+ for gene in (*item.positive, *item.negative):
526
+ src = measurements.get(gene)
527
+ if src is None:
528
+ continue
529
+ precise[gene] = {
530
+ "gene": gene,
531
+ "logfc": float(src["logfc"]),
532
+ "pct_in": float(src["pct_in"]),
533
+ "pct_out": float(src["pct_out"]),
534
+ }
535
+ panels = []
536
+ for hit in action_payload.get("dictionary_hits") or []:
537
+ item = by_name.get(str(hit["name"]))
538
+ if item is None:
539
+ continue
540
+ measured = [gene for gene in item.positive if gene in measurements]
541
+ panels.append((item.name, measured, list(item.negative)))
542
+ return ml_user(
543
+ str(action_payload.get("species") or ""),
544
+ str(action_payload.get("tissue") or ""),
545
+ str(action_payload.get("stage") or ""),
546
+ genes,
547
+ panels,
548
+ precise,
549
+ )
550
+
551
+
552
+ def payload_agreement(runtime: dict[str, object], wilcoxon: dict[str, object]) -> dict[str, object]:
553
+ """Overlap of the two rankings. A mismatch is a contract gap, not a model error."""
554
+
555
+ def ranked(payload: dict[str, object]) -> list[str]:
556
+ return [str(item["gene"]) for item in list(payload["genes"])[:25]]
557
+
558
+ def hits(payload: dict[str, object]) -> list[tuple[str, tuple[str, ...]]]:
559
+ return [
560
+ (str(item["name"]), tuple(str(gene) for gene in item["genes"]))
561
+ for item in list(payload["dictionary_hits"])
562
+ ]
563
+
564
+ left = ranked(runtime)
565
+ right = ranked(wilcoxon)
566
+ left_hits = hits(runtime)
567
+ right_hits = hits(wilcoxon)
568
+ return {
569
+ "top25_shared": len(set(left) & set(right)),
570
+ "top25_runtime": len(left),
571
+ "top25_wilcoxon": len(right),
572
+ "same_top25_order": left == right,
573
+ "hit_names_runtime": [name for name, _ in left_hits],
574
+ "hit_names_wilcoxon": [name for name, _ in right_hits],
575
+ "same_hit_names": [name for name, _ in left_hits] == [name for name, _ in right_hits],
576
+ "same_hit_genes": left_hits == right_hits,
577
+ }
578
+
579
+
580
+ def user_text(task: str, payload: dict | str) -> str:
581
+ """Serialize one user message.
582
+
583
+ The name task uses spaced JSON. ML uses compact JSON.
584
+ A string is sent unchanged.
585
+ """
586
+ if isinstance(payload, str):
587
+ return payload
588
+ separators = (",", ":") if task == "ml" else (", ", ": ")
589
+ return json.dumps(payload, ensure_ascii=False, separators=separators)
590
+
591
+
592
+ def post_chat(
593
+ url: str,
594
+ system: str,
595
+ payload: dict | str,
596
+ timeout: float = 60,
597
+ *,
598
+ task: str = "name",
599
+ ) -> str:
600
+ """POST one chat completion. ``system`` is the task prompt, not a shared default."""
601
+ endpoint = url.rstrip("/")
602
+ if not endpoint.endswith("/v1/chat/completions"):
603
+ endpoint += "/v1/chat/completions"
604
+ body = {
605
+ "temperature": 0,
606
+ "max_tokens": 32,
607
+ "chat_template_kwargs": {"enable_thinking": False},
608
+ "messages": [
609
+ {"role": "system", "content": system},
610
+ {"role": "user", "content": user_text(task, payload)},
611
+ ],
612
+ }
613
+ request = urllib.request.Request(
614
+ endpoint,
615
+ data=json.dumps(body).encode(),
616
+ headers={"Content-Type": "application/json"},
617
+ method="POST",
618
+ )
619
+ with urllib.request.urlopen(request, timeout=timeout) as response:
620
+ data = json.loads(response.read().decode())
621
+ try:
622
+ return str(data["choices"][0]["message"]["content"])
623
+ except (KeyError, IndexError, TypeError) as exc:
624
+ raise RuntimeError(f"chat reply has no message content: {str(data)[:200]}") from exc
625
+
626
+
627
+ def hit_names(payload: dict[str, object]) -> set[str]:
628
+ return {str(item["name"]) for item in payload.get("dictionary_hits") or []}
629
+
630
+
631
+ def fetch_name(url: str, payload: dict[str, object], timeout: float = 60) -> Decision:
632
+ return parse_decision(
633
+ post_chat(url, SYSTEM_NAME, payload, timeout, task="name"), hit_names(payload)
634
+ )
635
+
636
+
637
+ def candidate_names(payload: dict[str, object]) -> set[str]:
638
+ names: set[str] = set()
639
+ for item in payload.get("candidates") or []:
640
+ if isinstance(item, list) and item:
641
+ names.add(str(item[0]))
642
+ elif isinstance(item, dict) and item.get("name"):
643
+ names.add(str(item["name"]))
644
+ return names
645
+
646
+
647
+ def fetch_ml(url: str, payload: dict[str, object], timeout: float = 120) -> MlResult:
648
+ return parse_ml(
649
+ post_chat(url, SYSTEM_ML, payload, timeout, task="ml"),
650
+ candidate_names(payload),
651
+ )
652
+
653
+
654
+ def _negative_high(row: dict | None) -> bool:
655
+ return row is not None and float(row["logfc"]) >= 1 and float(row["pct_in"]) >= 0.25
656
+
657
+
658
+ def ml_stat_row(positive: list[dict], negative: list[str], measurements: dict[str, dict]) -> list:
659
+ """Training stat columns. Raw logfc is unchanged, including exploded scanpy values."""
660
+ values = [float(row["logfc"]) for row in positive]
661
+ return [
662
+ len(positive),
663
+ sum(float(row["logfc"]) > SUPPORT_LOGFC for row in positive),
664
+ sum(float(row["logfc"]) < 0 for row in positive),
665
+ sum(float(row["pct_in"]) >= 0.05 for row in positive),
666
+ len(negative),
667
+ sum(_negative_high(measurements.get(gene)) for gene in negative),
668
+ sum(gene not in measurements for gene in negative),
669
+ (sum(values) / len(values)) if values else None,
670
+ ]
671
+
672
+
673
+ def trial_support(positive: list[dict], negative: list[str], measurements: dict[str, dict]) -> dict:
674
+ """Detection-aware support. This is not written into the ML user message."""
675
+ detected = [
676
+ row
677
+ for row in positive
678
+ if float(row["logfc"]) > SUPPORT_LOGFC and float(row["pct_in"]) >= 0.25
679
+ ]
680
+ blocked = sum(_negative_high(measurements.get(gene)) for gene in negative)
681
+ mean = sum(float(row["logfc"]) for row in detected) / len(detected) if detected else None
682
+ return {
683
+ "n_positive_supported_detected": len(detected),
684
+ "mean_detected_logfc": mean,
685
+ "n_negative_high": blocked,
686
+ "blocked_by_negative": blocked > 0,
687
+ }
688
+
689
+
690
+ def _measured(row: dict | None, gene: str) -> list:
691
+ if row is None:
692
+ return [gene, None, None, None]
693
+ return [gene, row["logfc"], row["pct_in"], row["pct_out"]]
694
+
695
+
696
+ def ml_user(
697
+ species: str,
698
+ tissue: str,
699
+ stage: str,
700
+ genes: list[dict],
701
+ panels: list[tuple[str, list[str], list[str]]],
702
+ measurements: dict[str, dict],
703
+ ) -> dict[str, object]:
704
+ """ML user object. ``panels`` is ``(name, positive genes, negative genes)``."""
705
+ selected: set[str] = set()
706
+ candidates = []
707
+ for name, positive, negative in panels:
708
+ selected.update(positive)
709
+ selected.update(negative)
710
+ rows = [measurements[gene] for gene in positive if gene in measurements]
711
+ candidates.append(
712
+ [name, list(positive), list(negative), ml_stat_row(rows, negative, measurements)]
713
+ )
714
+ return {
715
+ "species": species,
716
+ "tissue": tissue,
717
+ "stage": stage,
718
+ "measurement_columns": list(ML_MEASUREMENT_COLUMNS),
719
+ "genes": [_measured(row, str(row["gene"])) for row in genes],
720
+ "marker_measurements": [
721
+ _measured(measurements.get(gene), gene) for gene in sorted(selected)
722
+ ],
723
+ "candidate_stat_columns": list(ML_STAT_COLUMNS),
724
+ "candidates": candidates,
725
+ }