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,96 @@
1
+ """The PEN-STACK web gateway, one typed HTTP surface over the engine + the grounded chat.
2
+
3
+ This is a *gateway, not a rewrite*. It mounts the AI-surface app (``/verify``, ``/safety``, ``/immune``,
4
+ ``/generate``, ``/predict``, ``/suggest``, ``/session``, ``/capabilities``, ``/scope`` + the atlas/crosslink
5
+ routes) and adds the human-facing pieces: the grounded ``/chat`` (and a streaming ``/chat/stream``), CORS for
6
+ the React frontend, and static serving of the built frontend when present. Every quantity still comes from the
7
+ engine; the chat's LLM only narrates (see ``pen_stack.web.llm``).
8
+
9
+ Run: ``uvicorn pen_stack.web.server:app --host 0.0.0.0 --port 8000`` (needs the ``server`` extra).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from pathlib import Path
15
+
16
+ try:
17
+ from fastapi import FastAPI, HTTPException
18
+ from fastapi.middleware.cors import CORSMiddleware
19
+ from fastapi.responses import StreamingResponse
20
+ except ImportError as e: # pragma: no cover - server extra optional
21
+ raise ImportError("FastAPI not installed: pip install 'pen-stack[server]'") from e
22
+
23
+ from pen_stack import __version__
24
+ from pen_stack.server.api import app as _engine_app # the typed engine surface (reused verbatim)
25
+
26
+
27
+ def _require_message(req: dict) -> str:
28
+ msg = (req or {}).get("message")
29
+ if not isinstance(msg, str) or not msg.strip():
30
+ raise HTTPException(422, "field 'message' is required and must be a non-empty string")
31
+ return msg
32
+
33
+ app = FastAPI(
34
+ title="PEN-STACK, Web Platform",
35
+ version=__version__,
36
+ description=("The human surface for genome writing: a grounded co-scientist chat + every engine feature, "
37
+ "with provenance-first UX (confidence bands, provenance, scope ledger, safety badges). The LLM narrates "
38
+ "and routes but never sources a number."),
39
+ )
40
+
41
+ # CORS, the Vite dev server (5173) and the bundled static frontend both talk to this gateway.
42
+ app.add_middleware(
43
+ CORSMiddleware,
44
+ allow_origins=["*"], # self-hosted, single-tenant; tighten via PEN_STACK_CORS if deployed
45
+ allow_methods=["*"],
46
+ allow_headers=["*"],
47
+ )
48
+
49
+ # Mount the entire engine surface under the same app so the frontend has ONE base URL/one OpenAPI.
50
+ app.mount("/api", _engine_app)
51
+
52
+
53
+ @app.get("/health")
54
+ def health() -> dict:
55
+ return {"status": "ok", "version": __version__, "surface": "web"}
56
+
57
+
58
+ @app.post("/chat", tags=["co-scientist"])
59
+ def chat_route(req: dict) -> dict:
60
+ """The grounded co-scientist: the LLM narrates over ENGINE tool outputs; every number comes from the tools
61
+ and the grounding guard strikes any the model invents. Body: {message, history?}. Returns
62
+ {reply, tool_results, grounded, backend}."""
63
+ from pen_stack.web.llm import grounded_reply
64
+
65
+ return grounded_reply(_require_message(req), history=req.get("history", []),
66
+ allow_llm=bool(req.get("allow_llm", True)))
67
+
68
+
69
+ @app.post("/chat/stream", tags=["co-scientist"])
70
+ def chat_stream_route(req: dict) -> StreamingResponse:
71
+ """Same grounded reply, streamed as Server-Sent Events. The reply is computed grounded first (guard already
72
+ applied), then emitted word-by-word so the UI can render progressively; a final event carries the dossier."""
73
+ from pen_stack.web.llm import grounded_reply
74
+
75
+ result = grounded_reply(_require_message(req), history=req.get("history", []),
76
+ allow_llm=bool(req.get("allow_llm", True)))
77
+
78
+ def _events():
79
+ for word in result["reply"].split(" "):
80
+ yield f"data: {json.dumps({'token': word + ' '})}\n\n"
81
+ yield ("event: done\ndata: " + json.dumps(
82
+ {"tool_results": result.get("tool_results"), "backend": result["backend"],
83
+ "grounded": result["grounded"], "mode": result.get("mode"),
84
+ "provenance": result.get("provenance"), "angles": result.get("angles"),
85
+ "facts": result.get("facts"), "sources": result.get("sources"),
86
+ "status": result.get("status")}, default=str) + "\n\n")
87
+
88
+ return StreamingResponse(_events(), media_type="text/event-stream")
89
+
90
+
91
+ # Serve the built React frontend (web/dist) when present, one-command self-host serves UI + API together.
92
+ _DIST = Path(__file__).resolve().parents[2] / "web" / "dist"
93
+ if _DIST.exists(): # pragma: no cover - only when the frontend has been built
94
+ from fastapi.staticfiles import StaticFiles
95
+
96
+ app.mount("/", StaticFiles(directory=str(_DIST), html=True), name="frontend")
pen_stack/web/tools.py ADDED
@@ -0,0 +1,197 @@
1
+ """Engine tool-runner for the grounded co-scientist (PEN-STACK).
2
+
3
+ The chat NEVER sources a number. This module is where the ENGINE computes everything: it parses a plain-language
4
+ goal, runs the validated tools (verify -> legality + safety + calibrated confidence + immune profile; the scope
5
+ matcher for known-unknowns), and returns a structured "dossier" of grounded facts. `extract_grounded_numbers`
6
+ returns the allow-list of values the LLM is permitted to cite, anything else it emits is stripped by the
7
+ grounding guard. No LLM is involved here; this is deterministic.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from typing import Any
13
+
14
+ # tiny, transparent keyword maps (the engine grounds everything; this only routes a plain-language goal).
15
+ _VEHICLES = {"aav": "AAV_single", "aav single": "AAV_single", "aav dual": "AAV_dual", "dual aav": "AAV_dual",
16
+ "lentivir": "lentivirus", "lnp": "lnp_mrna", "mrna": "lnp_mrna", "adenovir": "helper_dependent_adenovirus",
17
+ "hsv": "hsv_amplicon", "electroporat": "electroporation"}
18
+ _INTENTS = {"safe harbour": "safe_harbour_insertion", "safe harbor": "safe_harbour_insertion",
19
+ "knock-in": "knock_in_with_disruption", "knock in": "knock_in_with_disruption",
20
+ "knockin": "knock_in_with_disruption", "durab": "high_durability_insertion",
21
+ "excis": "regulatory_element_excision", "regulator": "regulatory_element_excision",
22
+ "landing pad": "landing_pad_insertion", "repeat": "repeat_excision"}
23
+ _CELLS = {"liver": "hepg2", "hepato": "hepg2", "hepg2": "hepg2", "hspc": "hspc", "stem cell": "h1_hesc",
24
+ "ipsc": "ipsc", "k562": "k562", "t cell": "cd8_t", "t-cell": "cd8_t", "car-t": "cd8_t", "pbmc": "pbmc"}
25
+ _GENE_RE = re.compile(r"\b([A-Z][A-Z0-9]{1,7})\b") # crude gene-symbol token (AAVS1, TRAC, FIX, ...)
26
+ _KB_RE = re.compile(r"(\d+(?:\.\d+)?)\s*kb", re.I)
27
+ _BP_RE = re.compile(r"(\d{3,6})\s*bp", re.I)
28
+ # uppercase tokens that look like a gene but are not one (jargon / vehicle / form abbreviations)
29
+ _GENE_STOP = {"DNA", "RNA", "MRNA", "AAV", "LNP", "HSV", "CAR", "RNP", "PCR", "WT", "KO", "KI", "ITR",
30
+ "ORF", "UTR", "CDS", "GFP", "ID", "QC", "VG", "MOI", "HLA", "MHC", "CRISPR", "CAS", "TF"}
31
+ # safe-harbour locus nicknames whose text collides with a vehicle keyword (AAVS1 ⊃ "aav"), stripped before
32
+ # vehicle matching so the user's stated vehicle (e.g. lentivirus) is not overridden by the locus name.
33
+ _SAFE_HARBOUR_RE = re.compile(r"\b(aavs1|h11|hipp11|rosa26)\b", re.I)
34
+
35
+
36
+ def _first(text: str, table: dict, default):
37
+ low = text.lower()
38
+ for key, val in table.items():
39
+ if key in low:
40
+ return val
41
+ return default
42
+
43
+
44
+ def _resolve_chrom(gene: str) -> str | None:
45
+ """The real chromosome for a gene symbol (or a safe-harbour nickname like AAVS1); None if not resolvable
46
+ offline. So a chat goal about ITGB2 carries chr21, not a hardcoded default. Atlas-gated, never fabricates."""
47
+ try:
48
+ from pen_stack.planner.optimize import gene_region, resolve_gene
49
+ reg = gene_region(resolve_gene(gene))
50
+ return reg[0] if reg else None
51
+ except Exception: # noqa: BLE001 - data/atlas absent (CI/offline) -> caller falls back to the default
52
+ return None
53
+
54
+
55
+ def parse_goal(message: str) -> dict:
56
+ """Best-effort parse of a plain-language goal into a Design/Goal dict. The engine grounds everything; this
57
+ just picks a starting point (with sensible defaults) so the tools can run."""
58
+ cargo = 3000
59
+ if (m := _KB_RE.search(message)):
60
+ cargo = int(float(m.group(1)) * 1000)
61
+ elif (m := _BP_RE.search(message)):
62
+ cargo = int(m.group(1))
63
+ genes = [g for g in _GENE_RE.findall(message) if g not in _GENE_STOP]
64
+ gene = genes[0] if genes else "AAVS1"
65
+ # Vehicle matching is substring-based (so "lentivir" catches "lentiviral"); but the safe-harbour nickname
66
+ # "AAVS1" contains "aav", which would wrongly match the AAV vehicle even when the user said lentivirus/LNP.
67
+ # Strip those nicknames from the vehicle-search text first so the stated vehicle wins.
68
+ veh_text = _SAFE_HARBOUR_RE.sub(" ", message.lower())
69
+ return {"write_type": "insertion", "gene": gene, "chrom": _resolve_chrom(gene) or "chr19",
70
+ "edit_intent": _first(message, _INTENTS, "safe_harbour_insertion"),
71
+ "delivery_vehicle": _first(veh_text, _VEHICLES, "AAV_single"), "cargo_bp": cargo,
72
+ "cell_type": _first(message, _CELLS, "k562"),
73
+ # the user's plain-language goal IS the cargo-function description the Guardian must screen, so a
74
+ # message like "express a ricin toxin" is biosecurity-screened, not silently passed as benign.
75
+ "cargo_function": message.strip()}
76
+
77
+
78
+ # the chat's free-text intents map onto the planner's EditIntent enum (landing-pad/regulatory have nearest valid).
79
+ _INTENT_TO_ENUM = {"safe_harbour_insertion": "safe_harbour_insertion",
80
+ "high_durability_insertion": "high_durability_insertion",
81
+ "knock_in_with_disruption": "knock_in_with_disruption",
82
+ "regulatory_element_excision": "regulatory_excision",
83
+ "repeat_excision": "repeat_excision",
84
+ "landing_pad_insertion": "safe_harbour_insertion"}
85
+
86
+ # plain-language reading of each immune axis (0-1, higher = safer), so a value is self-explanatory in the reply.
87
+ _AXIS_DIRECTION = {
88
+ "genotoxicity": "higher = safer (less integration-site oncogene risk; 1.0 = episomal / non-integrating)",
89
+ "cd8_epitope": "higher = fewer strong CD8/MHC-I capsid epitopes (1.0 = non-viral / none)",
90
+ "innate": "higher = less innate (CpG/TLR9, RIG-I) sensing of the delivered cargo",
91
+ "preexisting_nab": "higher = fewer patients excluded by pre-existing neutralizing antibodies to the vector",
92
+ "anti_peg": "higher = lower pre-existing anti-PEG barrier to re-dosing (PEGylated vehicles only)",
93
+ }
94
+
95
+
96
+ def _band_word(v: float) -> str:
97
+ return "favourable" if v >= 0.7 else ("moderate" if v >= 0.4 else "a concern")
98
+
99
+
100
+ def axis_meaning(name: str, value, validation: str | None) -> str:
101
+ """A self-explanatory, plain-language reading of one immune axis: what the number means + the proxy caveat."""
102
+ if value is None:
103
+ return "out of scope for this design, not predicted (no applicable mechanism)."
104
+ direction = _AXIS_DIRECTION.get(name, "0-1, higher = safer")
105
+ s = f"{float(value):.2f} on a 0-1 scale, {_band_word(float(value))}; {direction}."
106
+ if validation and ("proxy" in validation.lower() or "not outcome-validated" in validation.lower()):
107
+ s += (" This is a mechanistically/population-computed PROXY, it is not validated against a measured "
108
+ "clinical outcome, so read it as a directional estimate, not a guaranteed result.")
109
+ return s
110
+
111
+
112
+ def _run_planner(design: dict) -> dict[str, Any]:
113
+ """The ACTUAL writer/site recommendation for the goal (atlas-gated). This is what makes a 'which writer can
114
+ integrate N kb in GENE' question real: a named writer family, the top site, cargo-capacity fit, delivery,
115
+ all engine-computed. Computed when the gene isn't in the atlas or the atlas isn't mounted; NEVER fabricates."""
116
+ try:
117
+ from pen_stack.planner.optimize import EditIntent
118
+ from pen_stack.planner.pipeline import plan_write
119
+ except Exception: # noqa: BLE001
120
+ return {"available": False, "why": "planner unavailable in this environment"}
121
+ intent = _INTENT_TO_ENUM.get(design.get("edit_intent"), "safe_harbour_insertion")
122
+ try:
123
+ plans = plan_write(design["gene"], EditIntent(intent), int(design["cargo_bp"]),
124
+ design.get("cell_type", "k562"), k=3)
125
+ except FileNotFoundError:
126
+ return {"available": False, "why": "writability atlas not mounted (planner runs on the live app only)"}
127
+ except Exception as e: # noqa: BLE001 - never let a planner error break the chat dossier
128
+ return {"available": False, "why": f"planner error: {type(e).__name__}"}
129
+ if not plans:
130
+ return {"available": True, "found": False, "gene": design["gene"],
131
+ "why": (f"no writable plan for '{design['gene']}', it is not in the writability atlas. Check it "
132
+ "is an HGNC gene symbol or a known safe-harbour nickname (AAVS1, H11/HIPP11).")}
133
+ top = plans[0]
134
+ cargo = top.get("cargo") or {}
135
+ return {"available": True, "found": True, "n_plans": len(plans),
136
+ "recommended_writer": top.get("writer"), "site": top.get("site"),
137
+ "safety": top.get("safety"), "durability": top.get("durability"), "score": top.get("score"),
138
+ "writer_activity": top.get("writer_activity"), "reachability_tier": top.get("reachability_tier"),
139
+ "cargo_capacity_bp": cargo.get("cargo_capacity_bp"), "assembled_bp": cargo.get("assembled_bp"),
140
+ "cargo_fits_single_vector": cargo.get("size_ok"),
141
+ "delivery": (top.get("delivery") or {}).get("delivery"),
142
+ # distinct OTHER writer families (the top-k can repeat one family across sites, don't list it twice)
143
+ "alternative_writers": sorted({p.get("writer") for p in plans[1:]
144
+ if p.get("writer") and p.get("writer") != top.get("writer")})}
145
+
146
+
147
+ def run_tools(message: str, history: list | None = None) -> dict[str, Any]:
148
+ """Run the validated engine over a plain-language message and return a grounded dossier. EVERY number here
149
+ is computed by the engine (verify / planner / scope), no fabrication, no LLM."""
150
+ from pen_stack.agent.scope import match_scope
151
+ from pen_stack.verify import verify
152
+
153
+ design = parse_goal(message)
154
+ v = verify(dict(design), question=message)
155
+ imm = v.immune_profile or {}
156
+ axes = {k: {"value": a.get("value"), "uncertainty": a.get("uncertainty"),
157
+ "validation": a.get("validation"), "in_scope": a.get("in_scope"),
158
+ "meaning": axis_meaning(k, a.get("value"), a.get("validation"))}
159
+ for k, a in (imm.get("axes") or {}).items()}
160
+ oos = match_scope(message) # is the QUESTION out of scope (a known-unknown)?
161
+ return {
162
+ "parsed_design": design,
163
+ "plan": _run_planner(design), # the actual writer/site recommendation (atlas-gated)
164
+ "verdict": {"legal": v.legal, "confidence": v.confidence, "interval": v.interval,
165
+ "epistemic_status": v.epistemic_status,
166
+ "violations": [x.get("rule_id") for x in v.violations]},
167
+ "safety": {"decision": (v.safety.decision if v.safety else None),
168
+ "reason": (v.safety.reason if v.safety else None)},
169
+ "immune_profile": {"axes": axes, "collapsed_score": imm.get("collapsed_score"),
170
+ "known_unknowns": imm.get("known_unknowns")},
171
+ "scope": ({"out_of_scope": True, "id": oos["id"], "title": oos["title"], "why": oos.get("deferral")}
172
+ if oos else {"out_of_scope": False}),
173
+ "disclaimer": "Decision-support only; not a clinical directive. Every number is tool-sourced.",
174
+ }
175
+
176
+
177
+ _NUM_RE = re.compile(r"-?\d+(?:\.\d+)?")
178
+
179
+
180
+ def extract_grounded_numbers(tool_results: dict) -> set[str]:
181
+ """The allow-list: every numeric string that appears in the engine's tool results. The grounding guard
182
+ permits the LLM to cite ONLY these; any other number it emits is stripped."""
183
+ import json
184
+ text = json.dumps(tool_results, default=str)
185
+ grounded = set(_NUM_RE.findall(text))
186
+ # also allow the common normalised forms (e.g. 0.5 / .5 / 50%) of each grounded value
187
+ extra = set()
188
+ for n in list(grounded):
189
+ try:
190
+ f = float(n)
191
+ except ValueError:
192
+ continue
193
+ extra.add(str(int(f)) if f.is_integer() else str(f))
194
+ extra.add(f"{f:.2f}")
195
+ if 0 <= f <= 1:
196
+ extra.add(str(round(f * 100))) # percent form of a [0,1] score
197
+ return grounded | extra
@@ -0,0 +1 @@
1
+ """pen_stack.wgenome - see the PEN-STACK program doc."""
@@ -0,0 +1,83 @@
1
+ """Sequence-derived chromatin tracks: map AlphaGenome predictions onto the measured-atlas schema
2
+ and recompute writability/safety/durability from predicted tracks.
3
+
4
+ Two details:
5
+ * Unit handling. AlphaGenome track outputs are in the model's own units, not the measured ENCODE scale the
6
+ safety/durability models were trained on. Per-track agreement is therefore reported with Spearman (rank,
7
+ unit-free) alongside Pearson. For the *score-level* recompute we quantile-map each predicted track onto
8
+ the measured track's marginal (a standard rank-preserving calibration), so the recomputed scores test
9
+ whether AlphaGenome's RANKING of the epigenome recovers the measured-track scores - not a unit accident.
10
+ * Coverage. AlphaGenome predicts H3K9me3 for HepG2 but NOT K562; missing marks come back NaN and are
11
+ excluded from per-track correlation and passed as NaN to the (NaN-native) durability model.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+
17
+ import numpy as np
18
+ import pandas as pd
19
+
20
+ from pen_stack.wgenome.providers import AlphaGenomeProvider, _HISTONES, TRACK_NAMES
21
+
22
+ _ROOT = Path(__file__).resolve().parents[2]
23
+ _P1_FEAT = _ROOT.parent / "phase_1" / "features"
24
+ _P1_OUT = _ROOT.parent / "phase_1" / "out"
25
+
26
+
27
+ def predicted_tracks_frame(ct: str, bins: pd.DataFrame, provider: AlphaGenomeProvider | None = None,
28
+ offline: bool = False) -> pd.DataFrame:
29
+ """Predicted 7-track values for the given (chrom, bin) rows. Cached per bin in the provider."""
30
+ provider = provider or AlphaGenomeProvider(assembly="hg38")
31
+ rows = []
32
+ for r in bins.itertuples():
33
+ rec = provider.tracks(r.chrom, int(r.bin), ct, offline=offline)
34
+ if rec.get("available"):
35
+ rows.append({"chrom": r.chrom, "bin": int(r.bin),
36
+ **{t: rec.get(t, np.nan) for t in TRACK_NAMES}})
37
+ return pd.DataFrame(rows)
38
+
39
+
40
+ def quantile_map(pred: pd.Series, measured: pd.Series) -> pd.Series:
41
+ """Map `pred` onto `measured`'s marginal by matching ranks (rank-preserving calibration)."""
42
+ pred = pred.astype(float)
43
+ if pred.notna().sum() < 2 or measured.notna().sum() < 2:
44
+ return pred
45
+ ranks = pred.rank(pct=True, na_option="keep")
46
+ q = np.nanpercentile(measured.to_numpy(dtype=float), np.clip(ranks.to_numpy() * 100, 0, 100))
47
+ return pd.Series(q, index=pred.index)
48
+
49
+
50
+ def _load_models(ct: str):
51
+ from pen_stack.wgenome.writability import load_pickle
52
+ safety = load_pickle(str(_P1_OUT / f"safety_{ct}.pkl"))
53
+ dur = load_pickle(str(_P1_OUT / "durability.pkl"))
54
+ return safety, dur
55
+
56
+
57
+ def recompute_scores(matrix: pd.DataFrame, ct: str) -> pd.DataFrame:
58
+ """Apply the trained safety + durability models to a feature matrix; return writability components."""
59
+ from pen_stack.wgenome.writability import build_writability
60
+ safety, dur = _load_models(ct)
61
+ return build_writability(matrix, safety, dur)
62
+
63
+
64
+ def build_predicted_matrix(measured_matrix: pd.DataFrame, predicted: pd.DataFrame, ct: str) -> pd.DataFrame:
65
+ """Substitute quantile-mapped predicted tracks into a copy of the measured feature matrix.
66
+
67
+ Distance/integration features are genomic (not predicted) and are kept as-is; only the chromatin tracks
68
+ (atac/dnase/5 histones -> accessibility + marks) are replaced, then `accessibility` is rederived.
69
+ """
70
+ from pen_stack.wgenome.features import add_accessibility
71
+ m = measured_matrix.merge(predicted, on=["chrom", "bin"], how="inner", suffixes=("", "_pred"))
72
+ for t in TRACK_NAMES:
73
+ pc = f"{t}_pred"
74
+ if pc in m.columns and t in m.columns:
75
+ m[t] = quantile_map(m[pc], m[t]) # map predicted onto this sample's measured marginal
76
+ m = m.drop(columns=[c for c in m.columns if c.endswith("_pred")])
77
+ m = m.drop(columns=["accessibility"], errors="ignore")
78
+ return add_accessibility(m)
79
+
80
+
81
+ def histone_marks_for(ct: str) -> list[str]:
82
+ """Marks AlphaGenome actually predicts for this cell type (K562 lacks H3K9me3)."""
83
+ return [m for m in _HISTONES if not (ct.lower() == "k562" and m == "H3K9me3")]
@@ -0,0 +1,108 @@
1
+ """Durability layer - the conditional chromatin-context model.
2
+
3
+ Learns ONE function: `local chromatin features -> (expression level, silenced/stable)` on TRIP
4
+ integrations. The model never sees a coordinate, so it is cell-type-agnostic in function: to score a
5
+ new cell type you supply its chromatin tracks. This is the layer no safe-harbour resource provides,
6
+ and TRIP supervises exactly the writing-relevant quantity (position effect on an integrated cassette).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+
13
+ import lightgbm as lgb
14
+ import numpy as np
15
+ import pandas as pd
16
+ import pyBigWig
17
+ from scipy.stats import spearmanr
18
+ from sklearn.metrics import roc_auc_score
19
+ from sklearn.model_selection import GroupKFold
20
+
21
+ # canonical chromatin feature names (must match across mouse training + human application)
22
+ CHROMATIN = ["atac", "dnase", "H3K27ac", "H3K4me1", "H3K4me3", "H3K9me3", "H3K27me3"]
23
+
24
+
25
+ def liftover_positions(df: pd.DataFrame, chain_file: str) -> pd.DataFrame:
26
+ """Lift (chrom,pos) with a UCSC chain (e.g. mm9->mm10). Drops positions that fail to lift."""
27
+ from pyliftover import LiftOver
28
+ lo = LiftOver(chain_file)
29
+ out = []
30
+ for _, r in df.iterrows():
31
+ c = lo.convert_coordinate(r["chrom"], int(r["pos"]))
32
+ if c:
33
+ row = r.to_dict()
34
+ row["chrom"], row["pos"] = c[0][0], c[0][1]
35
+ out.append(row)
36
+ return pd.DataFrame(out)
37
+
38
+
39
+ def extract_chromatin_at(df: pd.DataFrame, panel: dict, raw_dir: str, download_fn,
40
+ window: int = 2500) -> pd.DataFrame:
41
+ """Point-query each bigWig's mean signal in +/-window around each integration position.
42
+ Only the integration sites are queried (no genome-wide binning needed)."""
43
+ out = df.copy()
44
+ for name, rec in panel.items():
45
+ path = download_fn(rec["href"], os.path.join(raw_dir, f"mES_{name}_{rec['accession']}.bigWig"))
46
+ bw = pyBigWig.open(path)
47
+ chroms = set(bw.chroms().keys())
48
+ vals = []
49
+ for chrom, pos in zip(out["chrom"], out["pos"]):
50
+ key = chrom if chrom in chroms else chrom.replace("chr", "")
51
+ if key not in chroms:
52
+ vals.append(0.0)
53
+ continue
54
+ try:
55
+ v = bw.stats(key, max(0, pos - window), pos + window, type="mean")[0]
56
+ except (RuntimeError, IndexError):
57
+ v = None
58
+ vals.append(0.0 if v is None else float(v))
59
+ out[name] = vals
60
+ bw.close()
61
+ os.remove(path)
62
+ print(f" extracted {name} at {len(out)} sites", flush=True)
63
+ return out
64
+
65
+
66
+ def train_durability(trip_df: pd.DataFrame, seed: int = 42) -> dict:
67
+ feats = [c for c in CHROMATIN if c in trip_df.columns]
68
+ df = trip_df.dropna(subset=feats + ["expression"]).copy()
69
+ X = df[feats].astype("float32").fillna(0.0)
70
+ y_expr = df["expression"].to_numpy()
71
+ y_sil = df["silenced"].astype(int).to_numpy()
72
+ groups = df["chrom"].astype("category").cat.codes.to_numpy()
73
+
74
+ gkf = GroupKFold(n_splits=min(5, len(np.unique(groups))))
75
+ oof_expr = np.zeros(len(df))
76
+ oof_sil = np.zeros(len(df))
77
+ for tr, te in gkf.split(X, y_expr, groups):
78
+ reg = lgb.LGBMRegressor(n_estimators=500, learning_rate=0.03, num_leaves=31,
79
+ subsample=0.8, random_state=seed, n_jobs=-1, verbosity=-1).fit(X.iloc[tr], y_expr[tr])
80
+ oof_expr[te] = reg.predict(X.iloc[te])
81
+ clf = lgb.LGBMClassifier(n_estimators=500, learning_rate=0.03, num_leaves=31,
82
+ subsample=0.8, random_state=seed, n_jobs=-1, verbosity=-1).fit(X.iloc[tr], y_sil[tr])
83
+ oof_sil[te] = clf.predict_proba(X.iloc[te])[:, 1]
84
+
85
+ rho = float(spearmanr(oof_expr, y_expr).statistic)
86
+ auroc = float(roc_auc_score(y_sil, oof_sil))
87
+ # baseline: H3K9me3 (heterochromatin) alone as a silencing predictor, and LAD-like (low ATAC) for expression
88
+ base_sil = roc_auc_score(y_sil, df["H3K9me3"].fillna(0)) if "H3K9me3" in df else float("nan")
89
+ base_expr = spearmanr(df.get("atac", pd.Series(0, index=df.index)).fillna(0), y_expr).statistic
90
+
91
+ final_reg = lgb.LGBMRegressor(n_estimators=500, learning_rate=0.03, num_leaves=31,
92
+ random_state=seed, n_jobs=-1, verbosity=-1).fit(X, y_expr)
93
+ imp = dict(sorted(zip(feats, final_reg.feature_importances_.tolist()), key=lambda kv: kv[1], reverse=True))
94
+ return {
95
+ "n": int(len(df)), "features": feats,
96
+ "expr_spearman": rho, "expr_baseline_atac_spearman": float(base_expr),
97
+ "silenced_auroc": auroc, "silenced_baseline_h3k9me3_auroc": float(base_sil),
98
+ "feature_importance": imp, "reg": final_reg,
99
+ "clf": lgb.LGBMClassifier(n_estimators=500, learning_rate=0.03, num_leaves=31,
100
+ random_state=seed, n_jobs=-1, verbosity=-1).fit(X, y_sil),
101
+ }
102
+
103
+
104
+ def save_models(res: dict, out_dir: str, tag: str = "durability") -> None:
105
+ import pickle
106
+ Path(out_dir).mkdir(parents=True, exist_ok=True)
107
+ with open(f"{out_dir}/{tag}.pkl", "wb") as fh:
108
+ pickle.dump({"reg": res["reg"], "clf": res["clf"], "features": res["features"]}, fh)
@@ -0,0 +1,52 @@
1
+ """Export the Writable Genome atlas as genome-browser tracks.
2
+
3
+ Writes per-cell-type BigWig tracks (writability, safety, p_durable) loadable in IGV/UCSC, plus a BED
4
+ of the top-writable loci. The atlas parquet stays the queryable source; these are the shareable tracks.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+
10
+ import pandas as pd
11
+ import pyBigWig
12
+
13
+ from pen_stack.data.genome import MAIN_CHROMS, load_chrom_sizes
14
+
15
+ BIN_BP = 1000
16
+ TRACKS = ["writability", "safety", "p_durable"]
17
+
18
+
19
+ def write_bigwig(df: pd.DataFrame, col: str, chrom_sizes: dict[str, int], out_bw: str) -> None:
20
+ bw = pyBigWig.open(out_bw, "w")
21
+ # header must be sorted; keep canonical chrom order with sizes
22
+ chroms = [(c, chrom_sizes[c]) for c in MAIN_CHROMS if c in chrom_sizes]
23
+ bw.addHeader(chroms)
24
+ for chrom, _ in chroms:
25
+ g = df[df["chrom"] == chrom].sort_values("bin")
26
+ if g.empty:
27
+ continue
28
+ starts = (g["bin"].to_numpy() * BIN_BP).astype("int64")
29
+ vals = g[col].astype("float64").fillna(0.0).to_numpy()
30
+ bw.addEntries(chrom, list(starts), values=list(vals), span=BIN_BP, step=BIN_BP)
31
+ bw.close()
32
+
33
+
34
+ def export_atlas(atlas_parquet: str, chrom_sizes_tsv: str, out_dir: str, ct: str,
35
+ top_n: int = 5000) -> dict:
36
+ df = pd.read_parquet(atlas_parquet)
37
+ sizes = load_chrom_sizes(chrom_sizes_tsv)
38
+ Path(out_dir).mkdir(parents=True, exist_ok=True)
39
+ written = {}
40
+ for col in TRACKS:
41
+ if col in df.columns:
42
+ out_bw = f"{out_dir}/atlas_{ct}_{col}.bw"
43
+ write_bigwig(df, col, sizes, out_bw)
44
+ written[col] = out_bw
45
+ # top-writable loci BED
46
+ top = df.nlargest(top_n, "writability")[["chrom", "bin", "writability"]].copy()
47
+ top["start"] = top["bin"] * BIN_BP
48
+ top["end"] = top["start"] + BIN_BP
49
+ bed = f"{out_dir}/atlas_{ct}_top{top_n}.bed"
50
+ top[["chrom", "start", "end", "writability"]].to_csv(bed, sep="\t", header=False, index=False)
51
+ written["top_bed"] = bed
52
+ return written
@@ -0,0 +1,82 @@
1
+ """Assemble the per-cell-type training/scoring matrix.
2
+
3
+ Joins the cell-type chromatin feature store + the (cell-type-agnostic) safety-annotation store
4
+ (+ integration-outcome store when available) on (chrom, bin) into one matrix the safety and
5
+ durability layers consume. Keeps feature provenance explicit.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ import numpy as np
12
+ import pandas as pd
13
+
14
+ # Unified chromatin feature set: ONE accessibility feature (ATAC where present, else DNase) +
15
+ # the 5 core histone marks. This makes every cell type share an IDENTICAL schema, so a cell type
16
+ # that lacks a specific accessibility assay (e.g. CD34+ HSPC has DNase but no ATAC) is fully
17
+ # specified rather than "partial" - ATAC and DNase are interchangeable open-chromatin assays.
18
+ CHROMATIN_TRACKS = ["accessibility", "H3K27ac", "H3K4me1", "H3K4me3", "H3K9me3", "H3K27me3"]
19
+ ACCESS_SOURCES = ["atac", "dnase"]
20
+ SAFETY_DIST = ["dist_oncogene", "dist_tsg", "dist_essential", "dist_tss"]
21
+
22
+
23
+ def add_accessibility(m: pd.DataFrame) -> pd.DataFrame:
24
+ """Derive the unified `accessibility` column: prefer ATAC, fall back to DNase."""
25
+ if "accessibility" not in m.columns:
26
+ if "atac" in m.columns:
27
+ m["accessibility"] = m["atac"]
28
+ if "dnase" in m.columns: # fill any ATAC gaps with DNase
29
+ m["accessibility"] = m["accessibility"].fillna(m["dnase"])
30
+ elif "dnase" in m.columns:
31
+ m["accessibility"] = m["dnase"]
32
+ return m
33
+
34
+
35
+ def _log_dist(s: pd.Series) -> pd.Series:
36
+ # large/Inf for "no feature on chromosome" -> log1p of a capped distance; NaN -> max
37
+ v = s.fillna(s.max() if s.notna().any() else 1e8).clip(lower=0)
38
+ return np.log1p(v)
39
+
40
+
41
+ def assemble_matrix(chromatin_parquet: str, safety_parquet: str,
42
+ integration_parquet: str | None = None,
43
+ out_parquet: str | None = None) -> pd.DataFrame:
44
+ chrom = pd.read_parquet(chromatin_parquet)
45
+ safe = pd.read_parquet(safety_parquet)
46
+ m = chrom.merge(safe, on=["chrom", "bin"], how="inner")
47
+ m = add_accessibility(m) # unify ATAC/DNase -> accessibility
48
+
49
+ # log-scaled distance features (raw kept too, for transparency)
50
+ for d in SAFETY_DIST:
51
+ if d in m.columns:
52
+ m[f"log_{d}"] = _log_dist(m[d])
53
+
54
+ if integration_parquet and Path(integration_parquet).exists():
55
+ integ = pd.read_parquet(integration_parquet)
56
+ m = m.merge(integ, on=["chrom", "bin"], how="left")
57
+ for c in [c for c in integ.columns if c not in ("chrom", "bin")]:
58
+ m[c] = m[c].fillna(0)
59
+
60
+ if out_parquet:
61
+ Path(out_parquet).parent.mkdir(parents=True, exist_ok=True)
62
+ m.to_parquet(out_parquet, index=False)
63
+ return m
64
+
65
+
66
+ def resolve_integration(feat_dir: str, ct: str) -> str | None:
67
+ """Integration-feature parquet for a cell type: prefer the cell-type-specific MLV set
68
+ (LaFave K562/HepG2); fall back to the cell-type-agnostic VISDB retroviral-propensity track so a
69
+ cell type without its own integration assay (e.g. CD34+ HSPC) still gets an integration feature."""
70
+ ct_specific = Path(feat_dir) / f"integration_{ct}.parquet"
71
+ if ct_specific.exists():
72
+ return str(ct_specific)
73
+ fallback = Path(feat_dir) / "integration_density.parquet"
74
+ return str(fallback) if fallback.exists() else None
75
+
76
+
77
+ def feature_columns(df: pd.DataFrame) -> list[str]:
78
+ """The model feature set: chromatin marks + log-distances + any integration features."""
79
+ feats = [c for c in CHROMATIN_TRACKS if c in df.columns]
80
+ feats += [c for c in df.columns if c.startswith("log_dist_")]
81
+ feats += [c for c in df.columns if c.startswith("integ_")]
82
+ return feats