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,179 @@
1
+ # Copyright 2026 Zhao Li (李钊)
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ """Cell Ontology DAG: is_a edges, cycle check, shortest upward distance, LCA."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import json
10
+ from collections import deque
11
+ from pathlib import Path
12
+
13
+
14
+ class OntologyCycleError(RuntimeError):
15
+ def __init__(self, cycle: list[str]):
16
+ self.cycle = cycle
17
+ shown = " -> ".join(cycle)
18
+ super().__init__(f"ontology has a cycle: {shown}")
19
+
20
+
21
+ class CellOntology:
22
+ def __init__(self, parents: dict[str, set[str]], labels: dict[str, str], source: dict):
23
+ self.labels = dict(labels)
24
+ self.parents = {node: set(items) for node, items in parents.items()}
25
+ self.source = dict(source)
26
+ nodes = set(self.parents)
27
+ for items in self.parents.values():
28
+ nodes.update(items)
29
+ self.nodes = nodes
30
+ self._ancestors: dict[str, set[str]] = {}
31
+ self._depth: dict[str, int | None] = {}
32
+ _reject_cycles(self.parents, nodes)
33
+
34
+ @property
35
+ def sha256(self) -> str:
36
+ return str(self.source.get("sha256") or "")
37
+
38
+ def parents_of(self, node: str) -> set[str]:
39
+ return self.parents.get(node, set())
40
+
41
+ def ancestors(self, node: str) -> set[str]:
42
+ cached = self._ancestors.get(node)
43
+ if cached is not None:
44
+ return cached
45
+ seen: set[str] = set()
46
+ stack = list(self.parents_of(node))
47
+ while stack:
48
+ parent = stack.pop()
49
+ if parent in seen:
50
+ continue
51
+ seen.add(parent)
52
+ stack.extend(self.parents_of(parent))
53
+ self._ancestors[node] = seen
54
+ return seen
55
+
56
+ def depth(self, node: str) -> int | None:
57
+ if node in self._depth:
58
+ return self._depth[node]
59
+ seen = {node}
60
+ queue: deque[tuple[str, int]] = deque([(node, 0)])
61
+ found: int | None = None
62
+ while queue:
63
+ current, dist = queue.popleft()
64
+ if current == "CL:0000000":
65
+ found = dist
66
+ break
67
+ for parent in self.parents_of(current):
68
+ if parent not in seen:
69
+ seen.add(parent)
70
+ queue.append((parent, dist + 1))
71
+ self._depth[node] = found
72
+ return found
73
+
74
+ def up_dist(self, src: str, dst: str) -> tuple[int, list[str]] | None:
75
+ """Shortest directed distance from src up is_a edges to dst, and one path."""
76
+ queue: deque[tuple[str, list[str]]] = deque([(src, [src])])
77
+ seen = {src}
78
+ while queue:
79
+ current, path = queue.popleft()
80
+ if current == dst:
81
+ return len(path) - 1, path
82
+ for parent in sorted(self.parents_of(current)):
83
+ if parent not in seen:
84
+ seen.add(parent)
85
+ queue.append((parent, path + [parent]))
86
+ return None
87
+
88
+ def lca(self, left: str, right: str) -> str | None:
89
+ common = (self.ancestors(left) | {left}) & (self.ancestors(right) | {right})
90
+ if not common:
91
+ return None
92
+
93
+ def key(node: str) -> tuple[int, str]:
94
+ dist = self.depth(node)
95
+ return (-1 if dist is None else -dist, node)
96
+
97
+ return min(common, key=key)
98
+
99
+
100
+ def load_ontology(path: Path) -> CellOntology:
101
+ file = Path(path)
102
+ blob = file.read_bytes()
103
+ data = json.loads(blob)
104
+ if "parents" in data and "labels" in data:
105
+ source = dict(data.get("source") or {})
106
+ source.setdefault("sha256", hashlib.sha256(blob).hexdigest())
107
+ parents = {node: set(items) for node, items in data["parents"].items()}
108
+ return CellOntology(parents, data["labels"], source)
109
+ if "edges" in data and "id_2_lbl" in data:
110
+ parents, labels = _from_popv_snapshot(data)
111
+ source = {
112
+ "name": file.name,
113
+ "sha256": hashlib.sha256(blob).hexdigest(),
114
+ "edges": sum(len(items) for items in parents.values()),
115
+ }
116
+ return CellOntology(parents, labels, source)
117
+ raise ValueError(f"{file} is not a Cell Ontology snapshot")
118
+
119
+
120
+ def _from_popv_snapshot(data: dict) -> tuple[dict[str, set[str]], dict[str, str]]:
121
+ labels = {_norm_id(uri): label for uri, label in data["id_2_lbl"].items()}
122
+ parents: dict[str, set[str]] = {}
123
+ for edge in data["edges"]:
124
+ if edge.get("pred") != "is_a":
125
+ continue
126
+ child = _norm_id(edge["sub"])
127
+ parent = _norm_id(edge["obj"])
128
+ parents.setdefault(child, set()).add(parent)
129
+ used = set(parents)
130
+ for items in parents.values():
131
+ used.update(items)
132
+ return parents, {node: labels[node] for node in used if node in labels}
133
+
134
+
135
+ def _norm_id(uri: str) -> str:
136
+ token = uri.rstrip("/").rsplit("/", 1)[-1]
137
+ if token.startswith("CL_"):
138
+ return "CL:" + token[3:]
139
+ return token
140
+
141
+
142
+ def _reject_cycles(parents: dict[str, set[str]], nodes: set[str]) -> None:
143
+ indegree = {node: 0 for node in nodes}
144
+ for child, items in parents.items():
145
+ indegree.setdefault(child, 0)
146
+ for parent in items:
147
+ indegree[parent] = indegree.get(parent, 0) + 1
148
+ queue = deque(sorted(node for node, degree in indegree.items() if degree == 0))
149
+ seen = 0
150
+ while queue:
151
+ node = queue.popleft()
152
+ seen += 1
153
+ for parent in parents.get(node, ()):
154
+ indegree[parent] -= 1
155
+ if indegree[parent] == 0:
156
+ queue.append(parent)
157
+ if seen == len(indegree):
158
+ return
159
+ remaining = {node for node, degree in indegree.items() if degree > 0}
160
+ raise OntologyCycleError(_one_cycle(parents, remaining))
161
+
162
+
163
+ def _one_cycle(parents: dict[str, set[str]], remaining: set[str]) -> list[str]:
164
+ for start in sorted(remaining):
165
+ path: list[str] = []
166
+ index: dict[str, int] = {}
167
+ node = start
168
+ while node in remaining:
169
+ if node in index:
170
+ return path[index[node] :] + [node]
171
+ index[node] = len(path)
172
+ path.append(node)
173
+ parent = next(
174
+ (item for item in sorted(parents.get(node, ())) if item in remaining), None
175
+ )
176
+ if parent is None:
177
+ break
178
+ node = parent
179
+ return [sorted(remaining)[0]]
@@ -0,0 +1,108 @@
1
+ # Copyright 2026 Zhao Li (李钊)
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ """Versioned raw-label map. The ontology file itself is not edited."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ MATCHES = {"exact", "close", "broader", "narrower", "unmapped"}
13
+ STATUSES = {"proposed", "reviewed", "needs_review"}
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class MapEntry:
18
+ map_version: str
19
+ raw_label: str
20
+ species: str
21
+ cl_id: str | None
22
+ cl_label: str | None
23
+ match: str
24
+ status: str
25
+ reviewer: str | None
26
+ evidence: str
27
+ ontology_sha256: str
28
+
29
+
30
+ class OntologyMap:
31
+ def __init__(self, entries: list[MapEntry]):
32
+ if not entries:
33
+ raise ValueError("ontology map is empty")
34
+ versions = {entry.map_version for entry in entries}
35
+ hashes = {entry.ontology_sha256 for entry in entries}
36
+ if len(versions) != 1:
37
+ raise ValueError(f"ontology map has more than one version: {sorted(versions)}")
38
+ if len(hashes) != 1:
39
+ raise ValueError("ontology map rows do not share one snapshot hash")
40
+ self.version = entries[0].map_version
41
+ self.ontology_sha256 = entries[0].ontology_sha256
42
+ self.entries = list(entries)
43
+ self._rows: dict[tuple[str, str, str], MapEntry] = {}
44
+ for entry in entries:
45
+ key = (entry.map_version, entry.raw_label, entry.species)
46
+ if key in self._rows:
47
+ raise ValueError(f"duplicate map key {key}")
48
+ self._rows[key] = entry
49
+
50
+ def lookup(
51
+ self, raw_label: str, species: str | None, *, formal: bool = False
52
+ ) -> MapEntry | None:
53
+ for candidate in _species_order(species):
54
+ entry = self._rows.get((self.version, raw_label, candidate))
55
+ if entry is None:
56
+ continue
57
+ if formal and entry.status != "reviewed":
58
+ continue
59
+ return entry
60
+ return None
61
+
62
+ @property
63
+ def reviewed_only(self) -> bool:
64
+ return all(entry.status == "reviewed" for entry in self.entries)
65
+
66
+
67
+ def load_map(path: Path) -> OntologyMap:
68
+ entries: list[MapEntry] = []
69
+ for lineno, line in enumerate(Path(path).read_text().splitlines(), start=1):
70
+ if not line.strip():
71
+ continue
72
+ row = json.loads(line)
73
+ match = row.get("match")
74
+ status = row.get("status")
75
+ if match not in MATCHES:
76
+ raise ValueError(f"{path}:{lineno}: bad match {match!r}")
77
+ if status not in STATUSES:
78
+ raise ValueError(f"{path}:{lineno}: bad status {status!r}")
79
+ cl_id = row.get("cl_id")
80
+ if match == "unmapped":
81
+ if cl_id:
82
+ raise ValueError(f"{path}:{lineno}: unmapped row has a CL id")
83
+ elif not cl_id:
84
+ raise ValueError(f"{path}:{lineno}: mapped row has no CL id")
85
+ ontology = row.get("ontology") or {}
86
+ entries.append(
87
+ MapEntry(
88
+ map_version=row["map_version"],
89
+ raw_label=row["raw_label"],
90
+ species=row["species"],
91
+ cl_id=cl_id,
92
+ cl_label=row.get("cl_label"),
93
+ match=match,
94
+ status=status,
95
+ reviewer=row.get("reviewer"),
96
+ evidence=row.get("evidence") or "",
97
+ ontology_sha256=ontology.get("sha256") or "",
98
+ )
99
+ )
100
+ return OntologyMap(entries)
101
+
102
+
103
+ def _species_order(species: str | None) -> list[str]:
104
+ order: list[str] = []
105
+ if species and species != "*":
106
+ order.append(species)
107
+ order.append("*")
108
+ return order
@@ -0,0 +1,96 @@
1
+ # Copyright 2026 Zhao Li (李钊)
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ """Directed Cell Ontology relation between two labels."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from .cl_dag import CellOntology
9
+ from .ontology_map import OntologyMap
10
+
11
+
12
+ def popv_onestep(pred: str, gold: str, dag: CellOntology) -> str:
13
+ """popV `_ontology_accuracy` order: exact, sibling, parent, child, no match.
14
+
15
+ `parent` means the prediction is the gold label's direct parent (one step coarser).
16
+ `child` means the gold label is the prediction's direct parent (one step finer).
17
+ """
18
+ if pred == gold:
19
+ return "exact"
20
+ pred_parents = dag.parents_of(pred)
21
+ gold_parents = dag.parents_of(gold)
22
+ if pred_parents & gold_parents:
23
+ return "sibling"
24
+ if pred in gold_parents:
25
+ return "parent"
26
+ if gold in pred_parents:
27
+ return "child"
28
+ return "no match"
29
+
30
+
31
+ def relation(
32
+ pred_raw: str | None,
33
+ gold_raw: str | None,
34
+ species: str | None,
35
+ dag: CellOntology,
36
+ label_map: OntologyMap,
37
+ *,
38
+ formal: bool = False,
39
+ ) -> dict:
40
+ """Relation of the prediction `pred_raw` to the reference `gold_raw`.
41
+
42
+ Ancestor means the prediction is coarser (negative k). Descendant means finer.
43
+ Distance is directed along is_a. Unmapped labels are not guessed.
44
+ """
45
+ if pred_raw is None or gold_raw is None or pred_raw == "" or gold_raw == "":
46
+ return _blank("abstain")
47
+ pred_entry = label_map.lookup(pred_raw, species, formal=formal)
48
+ gold_entry = label_map.lookup(gold_raw, species, formal=formal)
49
+ pred_id = pred_entry.cl_id if pred_entry is not None else None
50
+ gold_id = gold_entry.cl_id if gold_entry is not None else None
51
+ if pred_id is None or gold_id is None:
52
+ return _blank("unmapped")
53
+ step = popv_onestep(pred_id, gold_id, dag)
54
+ if pred_id == gold_id:
55
+ return {
56
+ "relation": "exact",
57
+ "signed_k": 0,
58
+ "path": [pred_id],
59
+ "popv_onestep": step,
60
+ "same_cl_diff_raw": pred_raw != gold_raw,
61
+ }
62
+ if pred_id in dag.ancestors(gold_id):
63
+ found = dag.up_dist(gold_id, pred_id)
64
+ if found is None:
65
+ raise RuntimeError(f"missing upward path {gold_id} -> {pred_id}")
66
+ dist, path = found
67
+ return _row("ancestor", -dist, path, step)
68
+ if gold_id in dag.ancestors(pred_id):
69
+ found = dag.up_dist(pred_id, gold_id)
70
+ if found is None:
71
+ raise RuntimeError(f"missing upward path {pred_id} -> {gold_id}")
72
+ dist, path = found
73
+ return _row("descendant", dist, path, step)
74
+ if dag.parents_of(pred_id) & dag.parents_of(gold_id):
75
+ return _row("sibling", None, None, step)
76
+ return _row("unrelated", None, None, step)
77
+
78
+
79
+ def _blank(kind: str) -> dict:
80
+ return {
81
+ "relation": kind,
82
+ "signed_k": None,
83
+ "path": None,
84
+ "popv_onestep": None,
85
+ "same_cl_diff_raw": False,
86
+ }
87
+
88
+
89
+ def _row(kind: str, signed_k: int | None, path: list[str] | None, step: str) -> dict:
90
+ return {
91
+ "relation": kind,
92
+ "signed_k": signed_k,
93
+ "path": path,
94
+ "popv_onestep": step,
95
+ "same_cl_diff_raw": False,
96
+ }
@@ -0,0 +1,38 @@
1
+ # Copyright 2026 Zhao Li (李钊)
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ """Caller-supplied cell-type hypotheses."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Hypothesis:
13
+ """One candidate label and the markers that support or oppose it.
14
+
15
+ ``positive`` genes should be enriched in the cluster if this label is right.
16
+ Their mean fold change is the score. A gene detected in fewer than 25% of
17
+ the cluster's cells is left out of that mean when at least two genes are
18
+ detected, so an absent gene does not dilute a high one. With fewer than two
19
+ detected, the whole panel is averaged. ``support`` genes add their fold
20
+ change when enriched and do not lower the score when absent. ``negative``
21
+ genes should not be detected. An enriched negative gene lowers the score
22
+ by its fold change, scaled by the number of positive genes averaged.
23
+ Absence of a negative gene is not evidence for the label.
24
+ """
25
+
26
+ name: str
27
+ positive: tuple[str, ...]
28
+ negative: tuple[str, ...] = ()
29
+ support: tuple[str, ...] = ()
30
+
31
+ def __post_init__(self) -> None:
32
+ if not self.name:
33
+ raise ValueError("hypothesis name is empty")
34
+ if not self.positive:
35
+ raise ValueError(f"{self.name}: at least one positive marker is required")
36
+ object.__setattr__(self, "positive", tuple(self.positive))
37
+ object.__setattr__(self, "negative", tuple(self.negative))
38
+ object.__setattr__(self, "support", tuple(self.support))