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.
- pen_stack/__init__.py +2 -0
- pen_stack/_resources.py +34 -0
- pen_stack/active/__init__.py +20 -0
- pen_stack/active/acquire.py +165 -0
- pen_stack/active/brains.py +74 -0
- pen_stack/active/campaign.py +109 -0
- pen_stack/active/design.py +66 -0
- pen_stack/active/validate.py +104 -0
- pen_stack/adapt/__init__.py +14 -0
- pen_stack/adapt/finetune.py +33 -0
- pen_stack/adapt/ingest.py +86 -0
- pen_stack/adapt/pipeline.py +101 -0
- pen_stack/adapt/recalibrate.py +58 -0
- pen_stack/adapt/report.py +130 -0
- pen_stack/agent/__init__.py +1 -0
- pen_stack/agent/cite.py +175 -0
- pen_stack/agent/co_scientist.py +262 -0
- pen_stack/agent/epistemic.py +102 -0
- pen_stack/agent/guardrails.py +67 -0
- pen_stack/agent/mcp_server.py +215 -0
- pen_stack/agent/orchestrator.py +112 -0
- pen_stack/agent/orchestrator_live.py +56 -0
- pen_stack/agent/pen_agent.py +242 -0
- pen_stack/agent/scope.py +60 -0
- pen_stack/agent/tools.py +130 -0
- pen_stack/api/__init__.py +12 -0
- pen_stack/api/manifest.py +160 -0
- pen_stack/atlas/__init__.py +1 -0
- pen_stack/atlas/atlas.parquet +0 -0
- pen_stack/atlas/build_wtkb.py +80 -0
- pen_stack/atlas/crosslink.py +179 -0
- pen_stack/atlas/expand.py +190 -0
- pen_stack/atlas/guide_design.py +178 -0
- pen_stack/atlas/schema.py +59 -0
- pen_stack/atlas/scorecard.py +134 -0
- pen_stack/atlas/scorecard_v3.parquet +0 -0
- pen_stack/atlas/universe.py +75 -0
- pen_stack/atlas/universe_v3.parquet +0 -0
- pen_stack/atlas/variant_propose.py +155 -0
- pen_stack/atlas/writer_efficiency.py +184 -0
- pen_stack/atlas/writer_predict.py +229 -0
- pen_stack/atlas/writer_recommend.py +170 -0
- pen_stack/atlas/writer_verify.py +167 -0
- pen_stack/atlas/wtkb.parquet +0 -0
- pen_stack/bridge/__init__.py +1 -0
- pen_stack/bridge/activity.py +52 -0
- pen_stack/bridge/cli.py +65 -0
- pen_stack/bridge/fold_qc.py +53 -0
- pen_stack/bridge/guide_qc.py +87 -0
- pen_stack/bridge/ingest.py +139 -0
- pen_stack/bridge/offtarget.py +191 -0
- pen_stack/bridge/offtarget_energetics.py +105 -0
- pen_stack/bridge/ortholog_screen.py +73 -0
- pen_stack/bridge/pipeline.py +83 -0
- pen_stack/build/__init__.py +16 -0
- pen_stack/build/cloudlab.py +74 -0
- pen_stack/build/ingest.py +47 -0
- pen_stack/build/protocol.py +82 -0
- pen_stack/build/simlab.py +30 -0
- pen_stack/cli.py +126 -0
- pen_stack/data/__init__.py +1 -0
- pen_stack/data/encode.py +84 -0
- pen_stack/data/genome.py +71 -0
- pen_stack/data/ingest_chromatin.py +119 -0
- pen_stack/data/ingest_integration.py +112 -0
- pen_stack/data/ingest_safety_annot.py +201 -0
- pen_stack/data/ingest_trip.py +76 -0
- pen_stack/design/__init__.py +14 -0
- pen_stack/design/capsid_generate.py +62 -0
- pen_stack/design/generate.py +70 -0
- pen_stack/design/pareto.py +70 -0
- pen_stack/design/space.py +137 -0
- pen_stack/design/writer_variants.py +121 -0
- pen_stack/env/__init__.py +1 -0
- pen_stack/env/genome_writing_env.py +248 -0
- pen_stack/env/policies.py +94 -0
- pen_stack/graph/__init__.py +21 -0
- pen_stack/graph/build.py +133 -0
- pen_stack/graph/cell_types.py +58 -0
- pen_stack/graph/ingest.py +132 -0
- pen_stack/graph/query.py +148 -0
- pen_stack/graph/schema.py +100 -0
- pen_stack/loop/__init__.py +15 -0
- pen_stack/loop/continual.py +61 -0
- pen_stack/loop/cycle.py +84 -0
- pen_stack/loop/drift.py +41 -0
- pen_stack/mech/__init__.py +1 -0
- pen_stack/mech/classify_atlas.py +71 -0
- pen_stack/mech/pfam_whitelist.yaml +247 -0
- pen_stack/mech/whitelist.py +66 -0
- pen_stack/monitor/__init__.py +1 -0
- pen_stack/monitor/europepmc.py +32 -0
- pen_stack/monitor/run.py +57 -0
- pen_stack/monitor/triage.py +63 -0
- pen_stack/oracles/__init__.py +65 -0
- pen_stack/oracles/affinity.py +116 -0
- pen_stack/oracles/cache.py +53 -0
- pen_stack/oracles/energetics.py +33 -0
- pen_stack/oracles/genome.py +167 -0
- pen_stack/oracles/protein_design.py +136 -0
- pen_stack/oracles/reliability.py +64 -0
- pen_stack/oracles/rna.py +28 -0
- pen_stack/oracles/schema.py +77 -0
- pen_stack/oracles/status.py +123 -0
- pen_stack/oracles/structure.py +42 -0
- pen_stack/oracles/structure_run.py +76 -0
- pen_stack/oracles/vcell.py +74 -0
- pen_stack/planner/__init__.py +1 -0
- pen_stack/planner/ada_risk.py +64 -0
- pen_stack/planner/antipeg_oracle.py +75 -0
- pen_stack/planner/capsid_epitope_oracle.py +135 -0
- pen_stack/planner/cargo.py +56 -0
- pen_stack/planner/cargo_polish.py +146 -0
- pen_stack/planner/chromosome.py +106 -0
- pen_stack/planner/delivery.py +55 -0
- pen_stack/planner/delivery_constraints.py +110 -0
- pen_stack/planner/delivery_immune.py +61 -0
- pen_stack/planner/delivery_immunology.py +222 -0
- pen_stack/planner/delivery_predict.py +196 -0
- pen_stack/planner/delivery_vehicles.py +37 -0
- pen_stack/planner/genotoxicity_oracle.py +112 -0
- pen_stack/planner/immune_mhc2.py +154 -0
- pen_stack/planner/immune_profile.py +292 -0
- pen_stack/planner/innate_sensing.py +135 -0
- pen_stack/planner/multiplex.py +110 -0
- pen_stack/planner/optimize.py +278 -0
- pen_stack/planner/pipeline.py +87 -0
- pen_stack/planner/report.py +26 -0
- pen_stack/planner/router.py +57 -0
- pen_stack/planner/seroprevalence_oracle.py +92 -0
- pen_stack/planner/target_site.py +118 -0
- pen_stack/rag/__init__.py +1 -0
- pen_stack/rag/corpus.py +133 -0
- pen_stack/rag/embed.py +98 -0
- pen_stack/rag/ground.py +131 -0
- pen_stack/rag/index.py +53 -0
- pen_stack/rag/llm.py +215 -0
- pen_stack/rag/qa.py +105 -0
- pen_stack/rag/retrieve.py +48 -0
- pen_stack/rules/__init__.py +9 -0
- pen_stack/rules/evaluators.py +318 -0
- pen_stack/rules/loader.py +31 -0
- pen_stack/rules/schema.py +99 -0
- pen_stack/rules/solver.py +43 -0
- pen_stack/rules/spec.py +78 -0
- pen_stack/safety/__init__.py +21 -0
- pen_stack/safety/audit.py +90 -0
- pen_stack/safety/gate.py +58 -0
- pen_stack/safety/pfam_scan.py +157 -0
- pen_stack/safety/policy.py +69 -0
- pen_stack/safety/redteam.py +71 -0
- pen_stack/safety/registry.py +255 -0
- pen_stack/safety/screen.py +59 -0
- pen_stack/safety/standards.py +141 -0
- pen_stack/score/__init__.py +1 -0
- pen_stack/score/recalibrate.py +77 -0
- pen_stack/score/therapeutic.py +85 -0
- pen_stack/server/__init__.py +1 -0
- pen_stack/server/api.py +647 -0
- pen_stack/spec/__init__.py +18 -0
- pen_stack/spec/clarify.py +42 -0
- pen_stack/spec/extract.py +406 -0
- pen_stack/spec/resolvers/__init__.py +17 -0
- pen_stack/spec/resolvers/cell.py +51 -0
- pen_stack/spec/resolvers/chem.py +32 -0
- pen_stack/spec/resolvers/feature.py +36 -0
- pen_stack/spec/resolvers/gene.py +43 -0
- pen_stack/spec/resolvers/locus.py +29 -0
- pen_stack/spec/resolvers/phenotype.py +37 -0
- pen_stack/spec/satisfy.py +114 -0
- pen_stack/spec/service.py +33 -0
- pen_stack/spec/writespec.py +252 -0
- pen_stack/twin/__init__.py +14 -0
- pen_stack/twin/calibrate.py +61 -0
- pen_stack/twin/data/__init__.py +12 -0
- pen_stack/twin/data/position_effect.py +245 -0
- pen_stack/twin/mechanistic.py +147 -0
- pen_stack/twin/outcome.py +132 -0
- pen_stack/twin/position_effect.py +454 -0
- pen_stack/ui/__init__.py +1 -0
- pen_stack/ui/app.py +713 -0
- pen_stack/validate/__init__.py +1 -0
- pen_stack/validate/adapt_demo.py +69 -0
- pen_stack/validate/agent_eval.py +117 -0
- pen_stack/validate/bench_adversarial_tasks.py +118 -0
- pen_stack/validate/bench_coscientist_tasks.py +60 -0
- pen_stack/validate/bench_graph_tasks.py +64 -0
- pen_stack/validate/bench_rule_tasks.py +84 -0
- pen_stack/validate/bench_trust_tasks.py +92 -0
- pen_stack/validate/bench_writetype_tasks.py +101 -0
- pen_stack/validate/blind_gsh_discovery.py +261 -0
- pen_stack/validate/cargo_directionality.py +57 -0
- pen_stack/validate/closed_loop.py +63 -0
- pen_stack/validate/durability_baselines.py +185 -0
- pen_stack/validate/experiment_design.py +65 -0
- pen_stack/validate/expr_controls.py +39 -0
- pen_stack/validate/forward_hypotheses.py +104 -0
- pen_stack/validate/generative_design.py +62 -0
- pen_stack/validate/guide_qc_demo.py +69 -0
- pen_stack/validate/heldout_celltype_expr.py +32 -0
- pen_stack/validate/immune_calibration.py +133 -0
- pen_stack/validate/intent_specification.py +82 -0
- pen_stack/validate/known_biology_expr.py +38 -0
- pen_stack/validate/offtarget_energetics_eval.py +144 -0
- pen_stack/validate/out_of_scope_refusal.py +82 -0
- pen_stack/validate/outcome_calibration.py +194 -0
- pen_stack/validate/outcome_prediction.py +76 -0
- pen_stack/validate/paper3_benchmark.py +165 -0
- pen_stack/validate/paper4_real_validation.py +144 -0
- pen_stack/validate/paper4_validation.py +82 -0
- pen_stack/validate/protocol_safety.py +62 -0
- pen_stack/validate/safety_screening.py +72 -0
- pen_stack/validate/selective_prediction.py +104 -0
- pen_stack/validate/seq_vs_measured.py +134 -0
- pen_stack/validate/target_site_controls.py +65 -0
- pen_stack/validate/uncertainty_eval.py +244 -0
- pen_stack/validate/ungrounded_baseline.py +234 -0
- pen_stack/validate/within_locus_ranking.py +84 -0
- pen_stack/validate/writer_recovery.py +91 -0
- pen_stack/verify/__init__.py +5 -0
- pen_stack/verify/proof.py +206 -0
- pen_stack/verify/schema.py +53 -0
- pen_stack/verify/service.py +191 -0
- pen_stack/web/__init__.py +18 -0
- pen_stack/web/guide.py +110 -0
- pen_stack/web/llm.py +393 -0
- pen_stack/web/llm_provider.py +119 -0
- pen_stack/web/router.py +119 -0
- pen_stack/web/server.py +96 -0
- pen_stack/web/tools.py +197 -0
- pen_stack/wgenome/__init__.py +1 -0
- pen_stack/wgenome/chromatin_seq.py +83 -0
- pen_stack/wgenome/durability.py +108 -0
- pen_stack/wgenome/export_tracks.py +52 -0
- pen_stack/wgenome/features.py +82 -0
- pen_stack/wgenome/genotoxic_blocklist.py +88 -0
- pen_stack/wgenome/gsh_baseline.py +154 -0
- pen_stack/wgenome/mesh_features.py +61 -0
- pen_stack/wgenome/offtarget_assay.py +80 -0
- pen_stack/wgenome/offtarget_bridge.py +47 -0
- pen_stack/wgenome/offtarget_cast.py +97 -0
- pen_stack/wgenome/offtarget_data.py +148 -0
- pen_stack/wgenome/offtarget_enumerate.py +274 -0
- pen_stack/wgenome/offtarget_integrase.py +155 -0
- pen_stack/wgenome/offtarget_nuclease.py +123 -0
- pen_stack/wgenome/offtarget_paste.py +41 -0
- pen_stack/wgenome/offtarget_predict.py +282 -0
- pen_stack/wgenome/ood.py +135 -0
- pen_stack/wgenome/providers.py +278 -0
- pen_stack/wgenome/safety.py +69 -0
- pen_stack/wgenome/structure3d.py +212 -0
- pen_stack/wgenome/uncertainty.py +250 -0
- pen_stack/wgenome/writability.py +72 -0
- pen_stack-0.1.0.dist-info/METADATA +401 -0
- pen_stack-0.1.0.dist-info/RECORD +259 -0
- pen_stack-0.1.0.dist-info/WHEEL +5 -0
- pen_stack-0.1.0.dist-info/entry_points.txt +3 -0
- pen_stack-0.1.0.dist-info/licenses/LICENSE +21 -0
- pen_stack-0.1.0.dist-info/top_level.txt +1 -0
pen_stack/web/server.py
ADDED
|
@@ -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
|