pen-stack 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 (259) hide show
  1. pen_stack/__init__.py +2 -0
  2. pen_stack/_resources.py +34 -0
  3. pen_stack/active/__init__.py +20 -0
  4. pen_stack/active/acquire.py +165 -0
  5. pen_stack/active/brains.py +74 -0
  6. pen_stack/active/campaign.py +109 -0
  7. pen_stack/active/design.py +66 -0
  8. pen_stack/active/validate.py +104 -0
  9. pen_stack/adapt/__init__.py +14 -0
  10. pen_stack/adapt/finetune.py +33 -0
  11. pen_stack/adapt/ingest.py +86 -0
  12. pen_stack/adapt/pipeline.py +101 -0
  13. pen_stack/adapt/recalibrate.py +58 -0
  14. pen_stack/adapt/report.py +130 -0
  15. pen_stack/agent/__init__.py +1 -0
  16. pen_stack/agent/cite.py +175 -0
  17. pen_stack/agent/co_scientist.py +262 -0
  18. pen_stack/agent/epistemic.py +102 -0
  19. pen_stack/agent/guardrails.py +67 -0
  20. pen_stack/agent/mcp_server.py +215 -0
  21. pen_stack/agent/orchestrator.py +112 -0
  22. pen_stack/agent/orchestrator_live.py +56 -0
  23. pen_stack/agent/pen_agent.py +242 -0
  24. pen_stack/agent/scope.py +60 -0
  25. pen_stack/agent/tools.py +130 -0
  26. pen_stack/api/__init__.py +12 -0
  27. pen_stack/api/manifest.py +160 -0
  28. pen_stack/atlas/__init__.py +1 -0
  29. pen_stack/atlas/atlas.parquet +0 -0
  30. pen_stack/atlas/build_wtkb.py +80 -0
  31. pen_stack/atlas/crosslink.py +179 -0
  32. pen_stack/atlas/expand.py +190 -0
  33. pen_stack/atlas/guide_design.py +178 -0
  34. pen_stack/atlas/schema.py +59 -0
  35. pen_stack/atlas/scorecard.py +134 -0
  36. pen_stack/atlas/scorecard_v3.parquet +0 -0
  37. pen_stack/atlas/universe.py +75 -0
  38. pen_stack/atlas/universe_v3.parquet +0 -0
  39. pen_stack/atlas/variant_propose.py +155 -0
  40. pen_stack/atlas/writer_efficiency.py +184 -0
  41. pen_stack/atlas/writer_predict.py +229 -0
  42. pen_stack/atlas/writer_recommend.py +170 -0
  43. pen_stack/atlas/writer_verify.py +167 -0
  44. pen_stack/atlas/wtkb.parquet +0 -0
  45. pen_stack/bridge/__init__.py +1 -0
  46. pen_stack/bridge/activity.py +52 -0
  47. pen_stack/bridge/cli.py +65 -0
  48. pen_stack/bridge/fold_qc.py +53 -0
  49. pen_stack/bridge/guide_qc.py +87 -0
  50. pen_stack/bridge/ingest.py +139 -0
  51. pen_stack/bridge/offtarget.py +191 -0
  52. pen_stack/bridge/offtarget_energetics.py +105 -0
  53. pen_stack/bridge/ortholog_screen.py +73 -0
  54. pen_stack/bridge/pipeline.py +83 -0
  55. pen_stack/build/__init__.py +16 -0
  56. pen_stack/build/cloudlab.py +74 -0
  57. pen_stack/build/ingest.py +47 -0
  58. pen_stack/build/protocol.py +82 -0
  59. pen_stack/build/simlab.py +30 -0
  60. pen_stack/cli.py +126 -0
  61. pen_stack/data/__init__.py +1 -0
  62. pen_stack/data/encode.py +84 -0
  63. pen_stack/data/genome.py +71 -0
  64. pen_stack/data/ingest_chromatin.py +119 -0
  65. pen_stack/data/ingest_integration.py +112 -0
  66. pen_stack/data/ingest_safety_annot.py +201 -0
  67. pen_stack/data/ingest_trip.py +76 -0
  68. pen_stack/design/__init__.py +14 -0
  69. pen_stack/design/capsid_generate.py +62 -0
  70. pen_stack/design/generate.py +70 -0
  71. pen_stack/design/pareto.py +70 -0
  72. pen_stack/design/space.py +137 -0
  73. pen_stack/design/writer_variants.py +121 -0
  74. pen_stack/env/__init__.py +1 -0
  75. pen_stack/env/genome_writing_env.py +248 -0
  76. pen_stack/env/policies.py +94 -0
  77. pen_stack/graph/__init__.py +21 -0
  78. pen_stack/graph/build.py +133 -0
  79. pen_stack/graph/cell_types.py +58 -0
  80. pen_stack/graph/ingest.py +132 -0
  81. pen_stack/graph/query.py +148 -0
  82. pen_stack/graph/schema.py +100 -0
  83. pen_stack/loop/__init__.py +15 -0
  84. pen_stack/loop/continual.py +61 -0
  85. pen_stack/loop/cycle.py +84 -0
  86. pen_stack/loop/drift.py +41 -0
  87. pen_stack/mech/__init__.py +1 -0
  88. pen_stack/mech/classify_atlas.py +71 -0
  89. pen_stack/mech/pfam_whitelist.yaml +247 -0
  90. pen_stack/mech/whitelist.py +66 -0
  91. pen_stack/monitor/__init__.py +1 -0
  92. pen_stack/monitor/europepmc.py +32 -0
  93. pen_stack/monitor/run.py +57 -0
  94. pen_stack/monitor/triage.py +63 -0
  95. pen_stack/oracles/__init__.py +65 -0
  96. pen_stack/oracles/affinity.py +116 -0
  97. pen_stack/oracles/cache.py +53 -0
  98. pen_stack/oracles/energetics.py +33 -0
  99. pen_stack/oracles/genome.py +167 -0
  100. pen_stack/oracles/protein_design.py +136 -0
  101. pen_stack/oracles/reliability.py +64 -0
  102. pen_stack/oracles/rna.py +28 -0
  103. pen_stack/oracles/schema.py +77 -0
  104. pen_stack/oracles/status.py +123 -0
  105. pen_stack/oracles/structure.py +42 -0
  106. pen_stack/oracles/structure_run.py +76 -0
  107. pen_stack/oracles/vcell.py +74 -0
  108. pen_stack/planner/__init__.py +1 -0
  109. pen_stack/planner/ada_risk.py +64 -0
  110. pen_stack/planner/antipeg_oracle.py +75 -0
  111. pen_stack/planner/capsid_epitope_oracle.py +135 -0
  112. pen_stack/planner/cargo.py +56 -0
  113. pen_stack/planner/cargo_polish.py +146 -0
  114. pen_stack/planner/chromosome.py +106 -0
  115. pen_stack/planner/delivery.py +55 -0
  116. pen_stack/planner/delivery_constraints.py +110 -0
  117. pen_stack/planner/delivery_immune.py +61 -0
  118. pen_stack/planner/delivery_immunology.py +222 -0
  119. pen_stack/planner/delivery_predict.py +196 -0
  120. pen_stack/planner/delivery_vehicles.py +37 -0
  121. pen_stack/planner/genotoxicity_oracle.py +112 -0
  122. pen_stack/planner/immune_mhc2.py +154 -0
  123. pen_stack/planner/immune_profile.py +292 -0
  124. pen_stack/planner/innate_sensing.py +135 -0
  125. pen_stack/planner/multiplex.py +110 -0
  126. pen_stack/planner/optimize.py +278 -0
  127. pen_stack/planner/pipeline.py +87 -0
  128. pen_stack/planner/report.py +26 -0
  129. pen_stack/planner/router.py +57 -0
  130. pen_stack/planner/seroprevalence_oracle.py +92 -0
  131. pen_stack/planner/target_site.py +118 -0
  132. pen_stack/rag/__init__.py +1 -0
  133. pen_stack/rag/corpus.py +133 -0
  134. pen_stack/rag/embed.py +98 -0
  135. pen_stack/rag/ground.py +131 -0
  136. pen_stack/rag/index.py +53 -0
  137. pen_stack/rag/llm.py +215 -0
  138. pen_stack/rag/qa.py +105 -0
  139. pen_stack/rag/retrieve.py +48 -0
  140. pen_stack/rules/__init__.py +9 -0
  141. pen_stack/rules/evaluators.py +318 -0
  142. pen_stack/rules/loader.py +31 -0
  143. pen_stack/rules/schema.py +99 -0
  144. pen_stack/rules/solver.py +43 -0
  145. pen_stack/rules/spec.py +78 -0
  146. pen_stack/safety/__init__.py +21 -0
  147. pen_stack/safety/audit.py +90 -0
  148. pen_stack/safety/gate.py +58 -0
  149. pen_stack/safety/pfam_scan.py +157 -0
  150. pen_stack/safety/policy.py +69 -0
  151. pen_stack/safety/redteam.py +71 -0
  152. pen_stack/safety/registry.py +255 -0
  153. pen_stack/safety/screen.py +59 -0
  154. pen_stack/safety/standards.py +141 -0
  155. pen_stack/score/__init__.py +1 -0
  156. pen_stack/score/recalibrate.py +77 -0
  157. pen_stack/score/therapeutic.py +85 -0
  158. pen_stack/server/__init__.py +1 -0
  159. pen_stack/server/api.py +647 -0
  160. pen_stack/spec/__init__.py +18 -0
  161. pen_stack/spec/clarify.py +42 -0
  162. pen_stack/spec/extract.py +406 -0
  163. pen_stack/spec/resolvers/__init__.py +17 -0
  164. pen_stack/spec/resolvers/cell.py +51 -0
  165. pen_stack/spec/resolvers/chem.py +32 -0
  166. pen_stack/spec/resolvers/feature.py +36 -0
  167. pen_stack/spec/resolvers/gene.py +43 -0
  168. pen_stack/spec/resolvers/locus.py +29 -0
  169. pen_stack/spec/resolvers/phenotype.py +37 -0
  170. pen_stack/spec/satisfy.py +114 -0
  171. pen_stack/spec/service.py +33 -0
  172. pen_stack/spec/writespec.py +252 -0
  173. pen_stack/twin/__init__.py +14 -0
  174. pen_stack/twin/calibrate.py +61 -0
  175. pen_stack/twin/data/__init__.py +12 -0
  176. pen_stack/twin/data/position_effect.py +245 -0
  177. pen_stack/twin/mechanistic.py +147 -0
  178. pen_stack/twin/outcome.py +132 -0
  179. pen_stack/twin/position_effect.py +454 -0
  180. pen_stack/ui/__init__.py +1 -0
  181. pen_stack/ui/app.py +713 -0
  182. pen_stack/validate/__init__.py +1 -0
  183. pen_stack/validate/adapt_demo.py +69 -0
  184. pen_stack/validate/agent_eval.py +117 -0
  185. pen_stack/validate/bench_adversarial_tasks.py +118 -0
  186. pen_stack/validate/bench_coscientist_tasks.py +60 -0
  187. pen_stack/validate/bench_graph_tasks.py +64 -0
  188. pen_stack/validate/bench_rule_tasks.py +84 -0
  189. pen_stack/validate/bench_trust_tasks.py +92 -0
  190. pen_stack/validate/bench_writetype_tasks.py +101 -0
  191. pen_stack/validate/blind_gsh_discovery.py +261 -0
  192. pen_stack/validate/cargo_directionality.py +57 -0
  193. pen_stack/validate/closed_loop.py +63 -0
  194. pen_stack/validate/durability_baselines.py +185 -0
  195. pen_stack/validate/experiment_design.py +65 -0
  196. pen_stack/validate/expr_controls.py +39 -0
  197. pen_stack/validate/forward_hypotheses.py +104 -0
  198. pen_stack/validate/generative_design.py +62 -0
  199. pen_stack/validate/guide_qc_demo.py +69 -0
  200. pen_stack/validate/heldout_celltype_expr.py +32 -0
  201. pen_stack/validate/immune_calibration.py +133 -0
  202. pen_stack/validate/intent_specification.py +82 -0
  203. pen_stack/validate/known_biology_expr.py +38 -0
  204. pen_stack/validate/offtarget_energetics_eval.py +144 -0
  205. pen_stack/validate/out_of_scope_refusal.py +82 -0
  206. pen_stack/validate/outcome_calibration.py +194 -0
  207. pen_stack/validate/outcome_prediction.py +76 -0
  208. pen_stack/validate/paper3_benchmark.py +165 -0
  209. pen_stack/validate/paper4_real_validation.py +144 -0
  210. pen_stack/validate/paper4_validation.py +82 -0
  211. pen_stack/validate/protocol_safety.py +62 -0
  212. pen_stack/validate/safety_screening.py +72 -0
  213. pen_stack/validate/selective_prediction.py +104 -0
  214. pen_stack/validate/seq_vs_measured.py +134 -0
  215. pen_stack/validate/target_site_controls.py +65 -0
  216. pen_stack/validate/uncertainty_eval.py +244 -0
  217. pen_stack/validate/ungrounded_baseline.py +234 -0
  218. pen_stack/validate/within_locus_ranking.py +84 -0
  219. pen_stack/validate/writer_recovery.py +91 -0
  220. pen_stack/verify/__init__.py +5 -0
  221. pen_stack/verify/proof.py +206 -0
  222. pen_stack/verify/schema.py +53 -0
  223. pen_stack/verify/service.py +191 -0
  224. pen_stack/web/__init__.py +18 -0
  225. pen_stack/web/guide.py +110 -0
  226. pen_stack/web/llm.py +393 -0
  227. pen_stack/web/llm_provider.py +119 -0
  228. pen_stack/web/router.py +119 -0
  229. pen_stack/web/server.py +96 -0
  230. pen_stack/web/tools.py +197 -0
  231. pen_stack/wgenome/__init__.py +1 -0
  232. pen_stack/wgenome/chromatin_seq.py +83 -0
  233. pen_stack/wgenome/durability.py +108 -0
  234. pen_stack/wgenome/export_tracks.py +52 -0
  235. pen_stack/wgenome/features.py +82 -0
  236. pen_stack/wgenome/genotoxic_blocklist.py +88 -0
  237. pen_stack/wgenome/gsh_baseline.py +154 -0
  238. pen_stack/wgenome/mesh_features.py +61 -0
  239. pen_stack/wgenome/offtarget_assay.py +80 -0
  240. pen_stack/wgenome/offtarget_bridge.py +47 -0
  241. pen_stack/wgenome/offtarget_cast.py +97 -0
  242. pen_stack/wgenome/offtarget_data.py +148 -0
  243. pen_stack/wgenome/offtarget_enumerate.py +274 -0
  244. pen_stack/wgenome/offtarget_integrase.py +155 -0
  245. pen_stack/wgenome/offtarget_nuclease.py +123 -0
  246. pen_stack/wgenome/offtarget_paste.py +41 -0
  247. pen_stack/wgenome/offtarget_predict.py +282 -0
  248. pen_stack/wgenome/ood.py +135 -0
  249. pen_stack/wgenome/providers.py +278 -0
  250. pen_stack/wgenome/safety.py +69 -0
  251. pen_stack/wgenome/structure3d.py +212 -0
  252. pen_stack/wgenome/uncertainty.py +250 -0
  253. pen_stack/wgenome/writability.py +72 -0
  254. pen_stack-0.1.0.dist-info/METADATA +401 -0
  255. pen_stack-0.1.0.dist-info/RECORD +259 -0
  256. pen_stack-0.1.0.dist-info/WHEEL +5 -0
  257. pen_stack-0.1.0.dist-info/entry_points.txt +3 -0
  258. pen_stack-0.1.0.dist-info/licenses/LICENSE +21 -0
  259. pen_stack-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,278 @@
1
+ """External sequence-to-function providers.
2
+
3
+ AlphaGenomeProvider wraps Google DeepMind's AlphaGenome (free, non-commercial) behind a small, cached,
4
+ provider-agnostic interface so the rest of PEN-STACK never imports `alphagenome` directly. It supplies:
5
+
6
+ * tracks(interval, outputs, ontology) -> per-base predictions (ATAC/DNASE/RNA_SEQ/CHIP_HISTONE/...)
7
+ * expression(interval, ontology) -> scalar endogenous expression proxy (mean RNA_SEQ over interval)
8
+ * contact_map(interval) -> predicted Hi-C contact matrix (3D structural-risk feature)
9
+
10
+ Design rules (match the rest of the stack):
11
+ * The LLM and the provider are NON-load-bearing for reproducibility - every cached value is keyed by an
12
+ explicit (assembly, interval, output, ontology) tuple and written to disk, so a run is reproducible
13
+ offline from the cache without re-querying the API.
14
+ * The API key is read from env (ALPHAGENOME_API_KEY) or a gitignored file; NEVER committed.
15
+ * `alphagenome` is an optional dependency. If the package or key is absent, `available()` is False and the
16
+ dependent baselines report `pending` rather than crashing - the core stack is unaffected.
17
+
18
+ Caching: predictions are large (up to ~1M rows). We cache the *reduced* features we actually consume
19
+ (scalar expression, mean track signal, contact-map summary statistics) as small JSON/parquet, not the raw
20
+ 1 Mb tensors, keyed by a content hash of the request.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import json
26
+ import os
27
+ from pathlib import Path
28
+
29
+ _ROOT = Path(__file__).resolve().parents[2]
30
+ _CACHE = _ROOT / "data" / "alphagenome_cache"
31
+ _KEY_FILE = _ROOT / "configs" / "alphagenome_api_key.txt"
32
+ _KEY_ENV = "ALPHAGENOME_API_KEY"
33
+
34
+ # 1 Mb is AlphaGenome's max; expression/structural features use it for full regulatory context.
35
+ SEQ_LEN_1MB = 1_048_576
36
+
37
+ # Model version recorded in track-cache keys + artifacts (C1 reproducibility). Bump when the served model
38
+ # changes so stale predictions are not silently reused.
39
+ MODEL_VERSION = "alphagenome-2025-06"
40
+
41
+ # The seven measured-atlas tracks and their AlphaGenome sources. The five histone marks come from the single
42
+ # CHIP_HISTONE output selected by its `histone_mark` metadata column.
43
+ _HISTONES = ["H3K27ac", "H3K4me1", "H3K4me3", "H3K9me3", "H3K27me3"]
44
+ TRACK_NAMES = ["atac", "dnase", *_HISTONES]
45
+
46
+ # K562 / HepG2 cell-type ontologies (verified against AlphaGenome human output_metadata).
47
+ CT_ONTOLOGY = {"k562": "EFO:0002067", "hepg2": "EFO:0001187"}
48
+
49
+
50
+ def _resolve_key() -> str | None:
51
+ """API key from env first, then a gitignored file. Returns None if neither is present."""
52
+ k = os.environ.get(_KEY_ENV)
53
+ if k:
54
+ return k.strip()
55
+ if _KEY_FILE.exists():
56
+ for line in _KEY_FILE.read_text(encoding="utf-8").splitlines():
57
+ s = line.strip().rstrip('",; ')
58
+ if s and not s.lower().startswith("alphagenome") and len(s) > 20:
59
+ return s
60
+ return None
61
+
62
+
63
+ def package_available() -> bool:
64
+ try:
65
+ import alphagenome # noqa: F401
66
+ return True
67
+ except Exception: # noqa: BLE001
68
+ return False
69
+
70
+
71
+ def _cache_key(*parts) -> str:
72
+ return hashlib.sha256("|".join(str(p) for p in parts).encode()).hexdigest()[:24]
73
+
74
+
75
+ class AlphaGenomeProvider:
76
+ """Cached wrapper around AlphaGenome's dna_client. Construct with `AlphaGenomeProvider()`."""
77
+
78
+ def __init__(self, api_key: str | None = None, assembly: str = "hg38", cache_dir: Path = _CACHE):
79
+ self.assembly = assembly
80
+ self.cache_dir = Path(cache_dir)
81
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
82
+ self._key = api_key or _resolve_key()
83
+ self._model = None # lazily created on first live call
84
+
85
+ # -- availability ------------------------------------------------------
86
+ def available(self) -> bool:
87
+ """True when both the package and a key are present (a live call is possible)."""
88
+ return package_available() and self._key is not None
89
+
90
+ def _client(self):
91
+ if self._model is None:
92
+ from alphagenome.models import dna_client
93
+ self._model = dna_client.create(self._key)
94
+ return self._model
95
+
96
+ # -- cache helpers -----------------------------------------------------
97
+ def _load(self, key: str):
98
+ f = self.cache_dir / f"{key}.json"
99
+ if f.exists():
100
+ return json.loads(f.read_text(encoding="utf-8"))
101
+ return None
102
+
103
+ def _store(self, key: str, value: dict) -> None:
104
+ (self.cache_dir / f"{key}.json").write_text(json.dumps(value, default=str), encoding="utf-8")
105
+
106
+ # -- features ----------------------------------------------------------
107
+ def expression(self, chrom: str, start: int, end: int, ontology: str, organism: str = "human",
108
+ center_bp: int = 20_000, offline: bool = False) -> dict:
109
+ """Scalar endogenous-expression proxy: mean predicted RNA_SEQ in a central window (cached).
110
+
111
+ The 1 Mb model context is needed for regulatory reach, but the proxy averages only the central
112
+ `center_bp` (host-locus expression at the integration site) rather than the whole 1 Mb, which would
113
+ wash out the local signal.
114
+ """
115
+ key = _cache_key("expr", self.assembly, organism, chrom, start, end, ontology, center_bp)
116
+ hit = self._load(key)
117
+ if hit is not None:
118
+ return hit
119
+ if offline:
120
+ return {"available": False, "reason": "offline: not in cache", "key": key}
121
+ if not self.available():
122
+ return {"available": False, "reason": "alphagenome package or key absent", "key": key}
123
+ from alphagenome.data import genome
124
+ from alphagenome.models import dna_client
125
+ org = (dna_client.Organism.MUS_MUSCULUS if organism == "mouse"
126
+ else dna_client.Organism.HOMO_SAPIENS)
127
+ interval = genome.Interval(chromosome=chrom, start=start, end=end).resize(SEQ_LEN_1MB)
128
+ out = self._client().predict_interval(
129
+ interval=interval, organism=org,
130
+ requested_outputs=[dna_client.OutputType.RNA_SEQ], ontology_terms=[ontology])
131
+ import numpy as np
132
+ arr = np.asarray(out.rna_seq.values) # (1_048_576, n_tracks)
133
+ mid = arr.shape[0] // 2
134
+ half = max(1, center_bp // 2)
135
+ central = arr[max(0, mid - half):mid + half]
136
+ rec = {"available": True, "rna_seq_mean": float(central.mean()),
137
+ "rna_seq_max": float(central.max()), "center_bp": center_bp,
138
+ "chrom": chrom, "start": start, "end": end,
139
+ "ontology": ontology, "organism": organism, "key": key}
140
+ self._store(key, rec)
141
+ return rec
142
+
143
+ def tracks(self, chrom: str, bin: int, ct: str, bin_size: int = 1000, center_bp: int = 1000,
144
+ offline: bool = False) -> dict:
145
+ """Predicted values of the seven measured-atlas tracks at a 1 kb bin (central-window mean, cached).
146
+
147
+ `ct` is "k562" or "hepg2"; the bin centre is `bin*bin_size + bin_size/2`, predicted in 1 Mb context.
148
+ Returns {atac, dnase, H3K27ac, H3K4me1, H3K4me3, H3K9me3, H3K27me3, model_version, ...}.
149
+ """
150
+ ontology = CT_ONTOLOGY.get(ct.lower(), ct)
151
+ key = _cache_key("tracks", self.assembly, MODEL_VERSION, chrom, bin, ontology, bin_size, center_bp)
152
+ hit = self._load(key)
153
+ if hit is not None:
154
+ return hit
155
+ if offline:
156
+ return {"available": False, "reason": "offline: not in cache", "key": key}
157
+ if not self.available():
158
+ return {"available": False, "reason": "alphagenome package or key absent", "key": key}
159
+ import numpy as np
160
+ from alphagenome.data import genome
161
+ from alphagenome.models import dna_client
162
+ pos = bin * bin_size + bin_size // 2
163
+ interval = genome.Interval(chromosome=chrom, start=pos, end=pos).resize(SEQ_LEN_1MB)
164
+ out = self._client().predict_interval(
165
+ interval=interval,
166
+ requested_outputs=[dna_client.OutputType.ATAC, dna_client.OutputType.DNASE,
167
+ dna_client.OutputType.CHIP_HISTONE],
168
+ ontology_terms=[ontology])
169
+
170
+ def central(values) -> np.ndarray:
171
+ arr = np.asarray(values)
172
+ mid = arr.shape[0] // 2
173
+ half = max(1, center_bp // 2)
174
+ return arr[max(0, mid - half):mid + half]
175
+
176
+ rec = {"available": True, "chrom": chrom, "bin": int(bin), "ct": ct, "ontology": ontology,
177
+ "model_version": MODEL_VERSION, "center_bp": center_bp, "key": key,
178
+ "atac": float(central(out.atac.values).mean()),
179
+ "dnase": float(central(out.dnase.values).mean())}
180
+ ch = out.chip_histone
181
+ md, vals = ch.metadata.reset_index(drop=True), central(ch.values)
182
+ for mark in _HISTONES:
183
+ cols = md.index[md["histone_mark"] == mark].to_numpy()
184
+ rec[mark] = float(vals[:, cols].mean()) if len(cols) else float("nan")
185
+ self._store(key, rec)
186
+ return rec
187
+
188
+ def score_variant(self, chrom: str, position: int, ref: str, alt: str, ontology: str | None = None,
189
+ output: str = "RNA_SEQ", offline: bool = False) -> dict:
190
+ """Variant-effect magnitude via AlphaGenome's `score_variant` (REF vs ALT, recommended scorer, cached).
191
+
192
+ Returns a scalar effect summary (max & mean |score| over the predicted tracks) for the requested output
193
+ type, a real regulatory variant-effect prediction, not a fabricated tier. Cached by (variant, output,
194
+ ontology). When the package/key is absent it reports `available=False` (the oracle then defers)."""
195
+ key = _cache_key("variant", self.assembly, MODEL_VERSION, chrom, position, ref, alt, output, ontology)
196
+ hit = self._load(key)
197
+ if hit is not None:
198
+ return hit
199
+ if offline:
200
+ return {"available": False, "reason": "offline: not in cache", "key": key}
201
+ if not self.available():
202
+ return {"available": False, "reason": "alphagenome package or key absent", "key": key}
203
+ import numpy as np
204
+ from alphagenome.data import genome
205
+ from alphagenome.models import dna_client, variant_scorers
206
+ variant = genome.Variant(chromosome=chrom, position=int(position),
207
+ reference_bases=ref, alternate_bases=alt)
208
+ interval = variant.reference_interval.resize(dna_client.SEQUENCE_LENGTH_1MB)
209
+ scorer = variant_scorers.RECOMMENDED_VARIANT_SCORERS[output]
210
+ scores = self._client().score_variant(interval=interval, variant=variant, variant_scorers=[scorer])
211
+ x = np.asarray(scores[0].X, dtype=float) # (n_tracks/genes, ...) effect scores
212
+ finite = x[np.isfinite(x)]
213
+ rec = {"available": True, "output": output, "scorer": str(scorer),
214
+ "effect_max_abs": float(np.abs(finite).max()) if finite.size else 0.0,
215
+ "effect_mean_abs": float(np.abs(finite).mean()) if finite.size else 0.0,
216
+ "n_scores": int(finite.size), "chrom": chrom, "position": int(position),
217
+ "ref": ref, "alt": alt, "ontology": ontology, "model_version": MODEL_VERSION, "key": key}
218
+ self._store(key, rec)
219
+ return rec
220
+
221
+ def contact_map_summary(self, chrom: str, start: int, end: int, ontology: str) -> dict:
222
+ """3D structural-risk summary: variance + mean of the predicted contact map (cached)."""
223
+ key = _cache_key("contact", self.assembly, chrom, start, end, ontology)
224
+ hit = self._load(key)
225
+ if hit is not None:
226
+ return hit
227
+ if not self.available():
228
+ return {"available": False, "reason": "alphagenome package or key absent", "key": key}
229
+ from alphagenome.data import genome
230
+ from alphagenome.models import dna_client
231
+ interval = genome.Interval(chromosome=chrom, start=start, end=end).resize(SEQ_LEN_1MB)
232
+ out = self._client().predict_interval(
233
+ interval=interval, requested_outputs=[dna_client.OutputType.CONTACT_MAPS],
234
+ ontology_terms=[ontology])
235
+ import numpy as np
236
+ m = np.asarray(out.contact_maps.values)
237
+ rec = {"available": True, "contact_mean": float(m.mean()), "contact_var": float(m.var()),
238
+ "chrom": chrom, "start": start, "end": end, "ontology": ontology, "key": key}
239
+ self._store(key, rec)
240
+ return rec
241
+
242
+
243
+ class MeasuredTrackProvider:
244
+ """The existing measured-ENCODE backbone: reads `phase_1/features/chromatin_{ct}.parquet` per bin.
245
+
246
+ Same `tracks()` signature as AlphaGenomeProvider so C2 can compare predicted vs measured on identical
247
+ bins, and so downstream code can swap providers without branching.
248
+ """
249
+
250
+ _P1 = _ROOT.parent / "phase_1" / "features"
251
+
252
+ def __init__(self, ct: str):
253
+ import pandas as pd
254
+ self.ct = ct.lower()
255
+ self._df = pd.read_parquet(self._P1 / f"chromatin_{self.ct}.parquet").set_index(["chrom", "bin"])
256
+
257
+ def available(self) -> bool:
258
+ return True
259
+
260
+ def tracks(self, chrom: str, bin: int, ct: str | None = None, **_: object) -> dict:
261
+ try:
262
+ row = self._df.loc[(chrom, int(bin))]
263
+ except KeyError:
264
+ return {"available": False, "reason": "bin not in measured grid"}
265
+ return {"available": True, "chrom": chrom, "bin": int(bin), "ct": self.ct,
266
+ **{t: float(row[t]) for t in TRACK_NAMES if t in row}}
267
+
268
+
269
+ def smoke() -> dict:
270
+ """Lightweight readiness probe used by tests/CLI - reports availability without a live call."""
271
+ p = AlphaGenomeProvider()
272
+ return {"package_available": package_available(), "key_present": _resolve_key() is not None,
273
+ "available": p.available(), "model_version": MODEL_VERSION, "track_names": TRACK_NAMES,
274
+ "cache_dir": str(_CACHE)}
275
+
276
+
277
+ if __name__ == "__main__": # pragma: no cover
278
+ print(json.dumps(smoke(), indent=2))
@@ -0,0 +1,69 @@
1
+ """Safety layer - calibrated genotoxicity-risk model.
2
+
3
+ Position features -> P(genotoxic) with isotonic calibration and CHROMOSOME-BLOCK cross-validation
4
+ (so adjacent 1 kb bins never leak between train/test). Always reported against the baseline:
5
+ distance-to-nearest-oncogene. Output is a calibrated risk per bin.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import lightgbm as lgb
10
+ import numpy as np
11
+ import pandas as pd
12
+ from sklearn.isotonic import IsotonicRegression
13
+ from sklearn.metrics import average_precision_score, roc_auc_score
14
+ from sklearn.model_selection import GroupKFold
15
+
16
+ from pen_stack.wgenome.features import feature_columns
17
+
18
+
19
+ def _blocks(chrom: pd.Series) -> np.ndarray:
20
+ """Chromosome-block groups for leakage-free CV."""
21
+ return chrom.astype("category").cat.codes.to_numpy()
22
+
23
+
24
+ def train_safety(df: pd.DataFrame, label: str = "genotoxic_cis", n_splits: int = 5,
25
+ seed: int = 42) -> dict:
26
+ feats = feature_columns(df)
27
+ X = df[feats].astype("float32").fillna(0.0)
28
+ y = df[label].astype(int).to_numpy()
29
+ groups = _blocks(df["chrom"])
30
+
31
+ gkf = GroupKFold(n_splits=min(n_splits, len(np.unique(groups))))
32
+ oof = np.zeros(len(df), dtype="float64")
33
+ for tr, te in gkf.split(X, y, groups):
34
+ pos = max(1, int(y[tr].sum()))
35
+ spw = max(1.0, (len(tr) - pos) / pos) # class imbalance
36
+ clf = lgb.LGBMClassifier(n_estimators=400, learning_rate=0.03, num_leaves=63,
37
+ subsample=0.8, colsample_bytree=0.8, scale_pos_weight=spw,
38
+ random_state=seed, n_jobs=-1, verbosity=-1)
39
+ clf.fit(X.iloc[tr], y[tr])
40
+ raw = clf.predict_proba(X.iloc[te])[:, 1]
41
+ # isotonic calibration fit on the training fold's OOB-ish raw scores
42
+ iso = IsotonicRegression(out_of_bounds="clip")
43
+ raw_tr = clf.predict_proba(X.iloc[tr])[:, 1]
44
+ iso.fit(raw_tr, y[tr])
45
+ oof[te] = iso.transform(raw)
46
+
47
+ auroc = roc_auc_score(y, oof)
48
+ auprc = average_precision_score(y, oof)
49
+
50
+ # baseline: closer to oncogene => riskier
51
+ base = -df["dist_oncogene"].fillna(df["dist_oncogene"].max()).to_numpy()
52
+ auroc_base = roc_auc_score(y, base)
53
+ auprc_base = average_precision_score(y, base)
54
+
55
+ # final model on all data (for scoring), + feature importance
56
+ pos = max(1, int(y.sum()))
57
+ spw = max(1.0, (len(y) - pos) / pos)
58
+ final = lgb.LGBMClassifier(n_estimators=400, learning_rate=0.03, num_leaves=63,
59
+ subsample=0.8, colsample_bytree=0.8, scale_pos_weight=spw,
60
+ random_state=seed, n_jobs=-1, verbosity=-1).fit(X, y)
61
+ imp = dict(sorted(zip(feats, final.feature_importances_.tolist()),
62
+ key=lambda kv: kv[1], reverse=True))
63
+ return {
64
+ "n": int(len(df)), "n_pos": int(y.sum()), "features": feats,
65
+ "auroc_model": float(auroc), "auprc_model": float(auprc),
66
+ "auroc_baseline": float(auroc_base), "auprc_baseline": float(auprc_base),
67
+ "auroc_delta": float(auroc - auroc_base),
68
+ "feature_importance": imp, "model": final, "oof": oof,
69
+ }
@@ -0,0 +1,212 @@
1
+ """3D structural-risk via AlphaGenome contact-map deltas.
2
+
3
+ A cassette insertion can rewire 3D contacts and bring a distal enhancer into contact with an oncogene
4
+ promoter (enhancer hijacking). We simulate the insertion with AlphaGenome's `predict_variant` (the cassette
5
+ is the alternate allele - an insertion - so the model applies it to its own reference and handles the
6
+ coordinate shift server-side; no local FASTA needed). We predict the reference and edited 1 Mb contact maps
7
+ and compute:
8
+
9
+ * insulation change at the insertion site (diamond insulation score, ref vs edited);
10
+ * aberrant contact gain between the insertion site and a target oncogene promoter bin.
11
+
12
+ To isolate the *regulatory* effect from the pure coordinate-shift artifact, every metric is reported for a
13
+ strong-enhancer insert AND a length-matched neutral insert; the strong-minus-neutral difference is the
14
+ signal. Output is a `structural_risk` score + flag with a confidence field.
15
+
16
+ GATE G-C: this ships as a FLAG WITH CONFIDENCE, never a hard pass/fail. No ground-truth dataset of
17
+ insertion-induced hijacking exists, so this is NOT validated as a predictor - only sanity-checked on 11 known
18
+ enhancer-hijacking oncogenes (TAL1/LMO1/LMO2/TLX3/BCL11B/MYB in T-ALL, MECOM-GATA2 in AML, MYCN in
19
+ neuroblastoma, GFI1B, MYC) where a strong-enhancer insert should raise aberrant
20
+ contacts above a matched neutral insert. Contacts are cell-type-specific (default GM12878, EFO:0002784 -
21
+ K562 has no AlphaGenome Hi-C track); insertion changes coordinates in ways the model was not trained on.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import json
27
+ import urllib.request
28
+ from pathlib import Path
29
+
30
+ import numpy as np
31
+
32
+ from pen_stack.wgenome.providers import SEQ_LEN_1MB, AlphaGenomeProvider
33
+
34
+ _ROOT = Path(__file__).resolve().parents[2]
35
+ _CACHE = _ROOT / "data" / "alphagenome_cache"
36
+ HIC_ONTOLOGY = "EFO:0002784" # GM12878 - canonical deep Hi-C; K562 has no AlphaGenome contact track
37
+ CONTACT_BINS = 512 # 1 Mb / 512 ~ 2048 bp per contact bin
38
+
39
+
40
+ def _ucsc_ref(chrom: str, pos: int, length: int = 1) -> str:
41
+ """Reference bases [pos, pos+length) on hg38 via the UCSC REST API (cached on disk)."""
42
+ key = f"ucsc_{chrom}_{pos}_{length}"
43
+ f = _CACHE / f"{key}.json"
44
+ if f.exists():
45
+ return json.loads(f.read_text(encoding="utf-8"))["dna"].upper()
46
+ u = (f"https://api.genome.ucsc.edu/getData/sequence?genome=hg38;chrom={chrom};"
47
+ f"start={pos};end={pos + length}")
48
+ d = json.load(urllib.request.urlopen(u)) # noqa: S310
49
+ _CACHE.mkdir(parents=True, exist_ok=True)
50
+ f.write_text(json.dumps({"dna": d["dna"]}), encoding="utf-8")
51
+ return d["dna"].upper()
52
+
53
+
54
+ def strong_enhancer_insert(n: int = 1600) -> str:
55
+ """Simulated strong enhancer: tiled clusters of active-enhancer TF motif cores (ETS/GATA/AP-1/RUNX)."""
56
+ motif = "GGAAGTGATAAGTGACTCAGGAAGTGACCACA" # GGAA(ETS) / GATA / TGACTCA(AP-1) / TGTGGT(RUNX-rc)
57
+ return (motif * (n // len(motif) + 1))[:n]
58
+
59
+
60
+ def neutral_insert(n: int = 1600) -> str:
61
+ """Length-matched neutral insert: low-complexity AT-rich filler (poor regulatory potential)."""
62
+ return ("ATATATTAATTATAAT" * (n // 16 + 1))[:n]
63
+
64
+
65
+ def _contact_matrices(provider: AlphaGenomeProvider, chrom: str, pos: int, insert: str,
66
+ ontology: str = HIC_ONTOLOGY):
67
+ """Reference + edited (insertion) 1 Mb contact matrices via predict_variant."""
68
+ from alphagenome.data import genome
69
+ from alphagenome.models import dna_client
70
+ anchor = _ucsc_ref(chrom, pos, 1)
71
+ var = genome.Variant(chromosome=chrom, position=pos, reference_bases=anchor,
72
+ alternate_bases=anchor + insert)
73
+ interval = var.reference_interval.resize(SEQ_LEN_1MB)
74
+ out = provider._client().predict_variant( # noqa: SLF001
75
+ interval=interval, variant=var,
76
+ requested_outputs=[dna_client.OutputType.CONTACT_MAPS], ontology_terms=[ontology])
77
+ ref = np.asarray(out.reference.contact_maps.values)
78
+ alt = np.asarray(out.alternate.contact_maps.values)
79
+ return ref[..., 0] if ref.ndim == 3 else ref, alt[..., 0] if alt.ndim == 3 else alt
80
+
81
+
82
+ def insulation_score(mat: np.ndarray, idx: int, w: int = 10) -> float:
83
+ """Diamond insulation: mean contact in the w x w square straddling position `idx`."""
84
+ n = mat.shape[0]
85
+ a, b = max(0, idx - w), min(n, idx + w)
86
+ if a >= idx or b <= idx:
87
+ return float("nan")
88
+ return float(mat[a:idx, idx:b].mean())
89
+
90
+
91
+ def _bin_of(offset_bp: int) -> int:
92
+ """Contact-map bin index for a genomic offset (bp) from the 1 Mb interval centre."""
93
+ return int(round(CONTACT_BINS / 2 + offset_bp / (SEQ_LEN_1MB / CONTACT_BINS)))
94
+
95
+
96
+ # calibrated confidence and abstention for the qualitative 3D flag. No calibrated probability is
97
+ # claimed. The only computable confidence signal is the MAGNITUDE of the strong-minus-neutral
98
+ # separation: when it is within +/-ABSTAIN_EPS the strong and neutral inserts are indistinguishable, so the
99
+ # flag ABSTAINS rather than emit a direction it cannot justify. Larger separations are a low-confidence
100
+ # qualitative flag, never a calibrated risk.
101
+ ABSTAIN_EPS = 0.01
102
+
103
+
104
+ def uq4_confidence(aberrant: float) -> dict:
105
+ """Structured confidence/abstention for the 3D structural-risk flag (UQ4). Heuristic, not calibrated."""
106
+ mag = abs(float(aberrant))
107
+ if mag <= ABSTAIN_EPS:
108
+ level, abstain = "abstain", True
109
+ elif mag <= 0.05:
110
+ level, abstain = "low", False
111
+ else:
112
+ level, abstain = "qualitative_flag", False
113
+ return {"calibrated": False, "level": level, "abstain": abstain,
114
+ "epistemic_status": "not-computable" if abstain else "grounded-extrapolating",
115
+ "basis": f"strong-minus-neutral separation magnitude {mag:.4f} vs abstain_eps {ABSTAIN_EPS}",
116
+ "scope": "qualitative flag with confidence; no calibrated probability, no coverage "
117
+ "guarantee - no ground-truth enhancer-hijacking dataset exists to calibrate against"}
118
+
119
+
120
+ def structural_risk(chrom: str, site_pos: int, oncogene_pos: int, ontology: str = HIC_ONTOLOGY,
121
+ provider: AlphaGenomeProvider | None = None, offline: bool = False) -> dict:
122
+ """Strong-enhancer vs neutral insertion at `site_pos`; aberrant contact gain toward `oncogene_pos`."""
123
+ provider = provider or AlphaGenomeProvider(assembly="hg38")
124
+ ins_strong, ins_neutral = strong_enhancer_insert(), neutral_insert()
125
+ key_src = f"struct3d|{chrom}|{site_pos}|{oncogene_pos}|{ontology}|{hashlib.sha256((ins_strong+ins_neutral).encode()).hexdigest()[:8]}"
126
+ key = hashlib.sha256(key_src.encode()).hexdigest()[:24]
127
+ cf = _CACHE / f"{key}.json"
128
+ if cf.exists():
129
+ cached = json.loads(cf.read_text(encoding="utf-8"))
130
+ if cached.get("available") and not isinstance(cached.get("confidence"), dict):
131
+ # upgrade legacy string-confidence cache entries to the UQ4 structured form (no recompute)
132
+ cached["confidence"] = uq4_confidence(
133
+ cached.get("aberrant_contact_gain_strong_minus_neutral", 0.0))
134
+ return cached
135
+ if offline or not provider.available():
136
+ return {"available": False, "reason": "offline or AlphaGenome key absent", "key": key}
137
+
138
+ site_idx = CONTACT_BINS // 2
139
+ tgt_idx = _bin_of(oncogene_pos - site_pos)
140
+ res = {}
141
+ for label, insert in (("strong_enhancer", ins_strong), ("neutral", ins_neutral)):
142
+ ref, alt = _contact_matrices(provider, chrom, site_pos, insert, ontology)
143
+ ins_ref, ins_alt = insulation_score(ref, site_idx), insulation_score(alt, site_idx)
144
+ t = min(tgt_idx, CONTACT_BINS - 1)
145
+ contact_ref = float(ref[site_idx, t]) if 0 <= t < CONTACT_BINS else float("nan")
146
+ contact_alt = float(alt[site_idx, t]) if 0 <= t < CONTACT_BINS else float("nan")
147
+ res[label] = {"insulation_change": round(ins_alt - ins_ref, 5),
148
+ "oncogene_contact_gain": round(contact_alt - contact_ref, 5)}
149
+ gain_strong = res["strong_enhancer"]["oncogene_contact_gain"]
150
+ gain_neutral = res["neutral"]["oncogene_contact_gain"]
151
+ aberrant = gain_strong - gain_neutral
152
+ out = {"available": True, "chrom": chrom, "site_pos": site_pos, "oncogene_pos": oncogene_pos,
153
+ "ontology": ontology, "target_bin_offset": tgt_idx - site_idx,
154
+ "per_insert": res, "aberrant_contact_gain_strong_minus_neutral": round(aberrant, 5),
155
+ "structural_risk": round(float(max(0.0, aberrant)), 5),
156
+ "flag": bool(aberrant > 0),
157
+ "confidence": uq4_confidence(aberrant),
158
+ "key": key}
159
+ _CACHE.mkdir(parents=True, exist_ok=True)
160
+ cf.write_text(json.dumps(out, default=str), encoding="utf-8")
161
+ return out
162
+
163
+
164
+ # Known enhancer-hijacking oncogenes (hg38) for the qualitative sanity check. Insertion site placed ~120 kb
165
+ # from the oncogene promoter (within a 1 Mb window / typical TAD reach). Scaled from 4 to 11 loci;
166
+ # oncogene_pos for the added loci is the GENCODE TSS (data/curated/gene_coords.parquet) - no hand-transcribed
167
+ # coordinates. These are canonical enhancer-hijacking oncogenes from the leukaemia/neuroblastoma literature
168
+ # (e.g. TAL1/LMO1/LMO2/TLX3/BCL11B/MYB in T-ALL, MECOM-GATA2 in AML, MYCN in neuroblastoma).
169
+ def _hj(chrom: str, pos: int) -> dict:
170
+ return {"chrom": chrom, "oncogene_pos": pos, "site_pos": pos - 120_000}
171
+
172
+
173
+ HIJACK_LOCI = {
174
+ "TAL1": _hj("chr1", 47_209_257),
175
+ "LMO2": _hj("chr11", 33_859_520),
176
+ "GFI1B": _hj("chr9", 132_990_996),
177
+ "MYC": _hj("chr8", 127_735_434),
178
+ "MYCN": _hj("chr2", 15_940_550), # neuroblastoma
179
+ "MECOM": _hj("chr3", 169_083_499), # AML 3q26 (GATA2 distal enhancer hijacking)
180
+ "GATA2": _hj("chr3", 128_479_427), # AML inv(3)/t(3;3)
181
+ "LMO1": _hj("chr11", 8_224_309), # T-ALL
182
+ "BCL11B": _hj("chr14", 99_169_287), # T-ALL (t(5;14))
183
+ "MYB": _hj("chr6", 135_181_308), # T-ALL
184
+ "TLX3": _hj("chr5", 171_309_248), # T-ALL (HOX11L2, t(5;14) BCL11B enhancer)
185
+ }
186
+
187
+
188
+ def sanity(ontology: str = HIC_ONTOLOGY, offline: bool = False, out: str | Path | None = None) -> dict:
189
+ """C3 sanity check across the known hijacking loci: strong-enhancer insert should raise aberrant
190
+ contacts above a matched neutral insert at more loci than not (qualitative, not a validated predictor)."""
191
+ provider = AlphaGenomeProvider(assembly="hg38")
192
+ rows = {}
193
+ for name, c in HIJACK_LOCI.items():
194
+ r = structural_risk(c["chrom"], c["site_pos"], c["oncogene_pos"], ontology, provider, offline)
195
+ rows[name] = r
196
+ scored = [v["aberrant_contact_gain_strong_minus_neutral"] for v in rows.values() if v.get("available")]
197
+ report = {"available": bool(scored), "ontology": ontology, "n_loci": len(rows),
198
+ "n_strong_gt_neutral": int(sum(1 for s in scored if s > 0)),
199
+ "per_locus": {k: (v.get("aberrant_contact_gain_strong_minus_neutral") if v.get("available")
200
+ else v.get("reason")) for k, v in rows.items()},
201
+ "sanity_pass": bool(scored and sum(1 for s in scored if s > 0) > len(scored) / 2),
202
+ "scope": "qualitative sanity check only; ships as a flag with confidence, never "
203
+ "a hard pass/fail; contacts are cell-type-specific (GM12878)."}
204
+ if out:
205
+ Path(out).parent.mkdir(parents=True, exist_ok=True)
206
+ Path(out).write_text(json.dumps({"loci": rows, "summary": report}, indent=2, default=str),
207
+ encoding="utf-8")
208
+ return report
209
+
210
+
211
+ if __name__ == "__main__": # pragma: no cover
212
+ print(json.dumps(sanity(out=_ROOT / "out" / "structure3d_sanity.json"), indent=2, default=str))