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,82 @@
1
+ """Safety-gated protocol export, the digital→physical bridge.
2
+
3
+ A cleared, legal design becomes a runnable protocol, emitted as a reviewed DRAFT carrying its immune-risk
4
+ profile in the metadata, never auto-run, and refused outright if the safety gate flags it. Protocols are
5
+ drafts for human/lab review, never auto-executed.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+
11
+ from pen_stack.verify import verify
12
+
13
+ _TARGETS = ("opentrons", "pylabrobot", "cloudlab")
14
+
15
+
16
+ class ProtocolExportError(RuntimeError):
17
+ """Raised when a design cannot be exported (safety-refused or illegal)."""
18
+
19
+
20
+ def _to_protocol_ir(design: dict, experiment: dict) -> dict:
21
+ """Minimal, platform-agnostic protocol intermediate representation (a DRAFT plan, not lab-tuned steps)."""
22
+ return {
23
+ "assay": experiment.get("assay", "cassette_expression_readout"),
24
+ "cell_type": design.get("cell_type", "k562"),
25
+ "delivery_vehicle": design.get("delivery_vehicle"),
26
+ "writer_family": design.get("writer_family"),
27
+ "cargo_bp": design.get("cargo_bp"),
28
+ "round": experiment.get("round"),
29
+ "steps": ["seed cells", "prepare delivery vehicle + cargo", "deliver", "incubate", "assay readout"],
30
+ }
31
+
32
+
33
+ def _emit_opentrons(ir: dict) -> str:
34
+ return (f"# Opentrons Python Protocol API v2 (DRAFT)\nmetadata = {{'apiLevel': '2.15'}}\n"
35
+ f"def run(protocol):\n"
36
+ f" # assay: {ir['assay']} | cell_type: {ir['cell_type']} | vehicle: {ir['delivery_vehicle']}\n"
37
+ f" # steps: {ir['steps']}\n"
38
+ f" pass # DRAFT - human/lab review required; fill in labware + liquid handling\n")
39
+
40
+
41
+ def _emit_pylabrobot(ir: dict) -> str:
42
+ return (f"# PyLabRobot protocol (DRAFT)\nfrom pylabrobot.liquid_handling import LiquidHandler\n"
43
+ f"async def run(lh: LiquidHandler):\n"
44
+ f" # assay: {ir['assay']} | vehicle: {ir['delivery_vehicle']} | steps: {ir['steps']}\n"
45
+ f" ... # DRAFT - human/lab review required\n")
46
+
47
+
48
+ def _emit_cloudlab(ir: dict) -> str:
49
+ return ("# Cloud-lab protocol request (DRAFT)\n"
50
+ + json.dumps({"assay": ir["assay"], "cell_type": ir["cell_type"], "steps": ir["steps"],
51
+ "status": "DRAFT - human/lab review required"}, indent=2) + "\n")
52
+
53
+
54
+ _EMITTERS = {"opentrons": _emit_opentrons, "pylabrobot": _emit_pylabrobot, "cloudlab": _emit_cloudlab}
55
+
56
+
57
+ def _stamp_draft(code: str, *, provenance: dict) -> str:
58
+ header = ("# ============================================================\n"
59
+ "# DRAFT, human/lab review required. NOT auto-executed.\n"
60
+ f"# provenance: {json.dumps(provenance, default=str)}\n"
61
+ "# ============================================================\n")
62
+ return header + code
63
+
64
+
65
+ def export_protocol(design: dict, experiment: dict, *, target: str = "opentrons",
66
+ actor: str = "anonymous") -> str:
67
+ """Cleared design + experiment -> a runnable protocol DRAFT, with the immune-risk profile in the
68
+ metadata. HARD GATE: refuses anything the safety gate flags, or any illegal design."""
69
+ if target not in _TARGETS:
70
+ raise ProtocolExportError(f"unknown target {target!r}; choose from {_TARGETS}")
71
+ v = verify(dict(design), actor=actor)
72
+ if v.safety is not None and v.safety.decision == "refuse":
73
+ raise ProtocolExportError(f"export blocked by safety gate: {v.safety.reason}")
74
+ if v.legal is not True:
75
+ raise ProtocolExportError(f"export blocked: design not legal ({v.summary()})")
76
+ ir = _to_protocol_ir(design, experiment)
77
+ code = _EMITTERS[target](ir)
78
+ return _stamp_draft(code, provenance={
79
+ "verify": v.summary(), "safety": (v.safety.decision if v.safety else None),
80
+ "immune_profile": v.immune_profile, # metadata travels with the protocol
81
+ "target": target, "actor": actor,
82
+ "note": "immune profile is a screen carrying its known-unknowns, not a patient prediction"})
@@ -0,0 +1,30 @@
1
+ """Simulated-lab harness, run the closed loop without hardware.
2
+
3
+ Executes a protocol in silico: samples an "observed" result from the twin + measurement noise, clearly
4
+ labelled SIMULATED. This lets the closed loop run end-to-end before any hardware exists. Sim outcomes
5
+ inherit the twin's limits and NEVER enter the curated world-model as measured truth, they are for development /
6
+ loop-validation only.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import random
11
+
12
+
13
+ def run_simulated(protocol_ir: dict, design: dict, cell_state: str, *, seed: int = 0) -> dict:
14
+ """Execute a protocol in silico: sample an 'observed' readout from the twin + measurement noise. Labelled
15
+ SIMULATED; the loop (export -> sim-run -> ingest) completes without a wet lab."""
16
+ from pen_stack.twin.outcome import predict_outcome
17
+ out = predict_outcome(design, cell_state)
18
+ truth = out["predicted_outcome"]
19
+ rng = random.Random(seed)
20
+ base = truth.get("relative_expression")
21
+ readout = None if base is None else round(base + rng.gauss(0.0, 0.05), 4)
22
+ return {
23
+ "assay": protocol_ir.get("assay", "cassette_expression_readout"),
24
+ "readout": readout,
25
+ "units": truth.get("units"),
26
+ "design_id": design.get("design_id", "design:sim"),
27
+ "confidence": out.get("interval"),
28
+ "provenance": {"source": "simlab", "seed": seed, "label": "SIMULATED",
29
+ "note": "sampled from the twin + measurement noise; NOT measured truth"},
30
+ }
pen_stack/cli.py ADDED
@@ -0,0 +1,126 @@
1
+ """PEN-STACK unified CLI (subcommands: atlas, score, writable, crosslink, monitor).
2
+
3
+ One entry point - ``pen-stack`` - over the whole stack. Heavy data (the writability atlas) is
4
+ loaded lazily and degrades gracefully when absent, so ``info`` / ``atlas`` work from a clean install.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import click
9
+
10
+ from pen_stack import __version__
11
+
12
+
13
+ @click.group()
14
+ @click.version_option(__version__, prog_name="pen-stack")
15
+ def main():
16
+ """PEN-STACK - open infrastructure for genome writing."""
17
+
18
+
19
+ @main.command()
20
+ def info():
21
+ """Show stack status and module map."""
22
+ click.echo(f"PEN-STACK v{__version__}")
23
+ click.echo("Pillar B (flagship): wgenome - Writable Genome (safety x durability x reachability)")
24
+ click.echo("Pillar A (companion): atlas, mech, score - Writer Atlas + WT-KB")
25
+ click.echo("Engine: planner - Write Planner (inverse design)")
26
+ click.echo("Beachhead: bridge - bridge-recombinase off-target engine")
27
+ click.echo("Services: monitor, rag, agent, ui, server")
28
+
29
+
30
+ @main.command()
31
+ @click.option("--family", default=None, help="Filter to one writer family.")
32
+ @click.option("--coverage", is_flag=True, help="Show per-family coverage + confidence breakdown.")
33
+ @click.option("--limit", default=10, help="Max rows to print.")
34
+ def atlas(family, coverage, limit):
35
+ """Query the Writer Atlas."""
36
+ import pandas as pd
37
+
38
+ from pen_stack.atlas.crosslink import _ATLAS
39
+ df = pd.read_parquet(_ATLAS)
40
+ if coverage:
41
+ cov = (df.groupby("family")
42
+ .agg(n=("representative_system", "size"),
43
+ measured=("confidence", lambda s: (s == "measured").sum()),
44
+ tier=("reachability_tier", "first"))
45
+ .reset_index())
46
+ click.echo(cov.to_string(index=False))
47
+ click.echo(f"\nTOTAL systems: {len(df):,} across {df['family'].nunique()} families")
48
+ return
49
+ if family:
50
+ df = df[df["family"] == family]
51
+ cols = [c for c in ["representative_system", "family", "confidence", "deliv_class",
52
+ "readiness", "reachability_tier"] if c in df.columns]
53
+ click.echo(df[cols].head(limit).to_string(index=False))
54
+
55
+
56
+ @main.command()
57
+ @click.option("--gene", required=True, help="Target gene symbol.")
58
+ @click.option("--ct", default="k562", help="Cell type (k562/hepg2/hspc).")
59
+ @click.option("--top", default=10, help="Top writable bins to show.")
60
+ def writable(gene, ct, top):
61
+ """Rank writable loci overlapping a gene."""
62
+ from pen_stack.atlas.crosslink import loci_for_gene
63
+ try:
64
+ g = loci_for_gene(gene, ct)
65
+ except FileNotFoundError as e:
66
+ raise click.ClickException(f"writability atlas not available: {e}") from e
67
+ if g.empty:
68
+ click.echo(f"No writable bins found for {gene} in {ct}.")
69
+ return
70
+ click.echo(g[["chrom", "bin", "safety", "p_durable", "writability"]].head(top).to_string(index=False))
71
+
72
+
73
+ @main.command()
74
+ @click.option("--family", help="Writer family -> ranked reachable loci.")
75
+ @click.option("--chrom", help="Locus chrom (with --bin) -> reachable writers.")
76
+ @click.option("--bin", "bin_idx", type=int, help="Locus 1kb bin index.")
77
+ @click.option("--ct", default="k562")
78
+ @click.option("--top", default=10)
79
+ def crosslink(family, chrom, bin_idx, ct, top):
80
+ """Writer<->locus cross-link queries."""
81
+ from pen_stack.atlas import crosslink as cl
82
+ try:
83
+ if family:
84
+ click.echo(cl.loci_for_writer(family, ct, top=top).to_string(index=False))
85
+ elif chrom and bin_idx is not None:
86
+ click.echo(cl.writers_for_locus(chrom, bin_idx, ct).head(top).to_string(index=False))
87
+ else:
88
+ raise click.UsageError("provide --family OR (--chrom and --bin)")
89
+ except FileNotFoundError as e:
90
+ raise click.ClickException(f"writability atlas not available: {e}") from e
91
+
92
+
93
+ @main.command()
94
+ @click.option("--gene", required=True, help="Target gene symbol.")
95
+ @click.option("--intent", required=True,
96
+ type=click.Choice(["safe_harbour_insertion", "knock_in_with_disruption",
97
+ "high_durability_insertion", "regulatory_excision", "repeat_excision"]))
98
+ @click.option("--cargo-bp", default=2000, help="Payload size (bp).")
99
+ @click.option("--ct", default="k562", help="Cell type (k562/hepg2/hspc).")
100
+ @click.option("--k", default=3, help="Number of ranked plans.")
101
+ def plan(gene, intent, cargo_bp, ct, k):
102
+ """Write Planner: goal + edit_intent -> ranked, traceable plans."""
103
+ from pen_stack.planner.optimize import EditIntent
104
+ from pen_stack.planner.pipeline import plan_write
105
+ from pen_stack.planner.report import render_plans
106
+ try:
107
+ plans = plan_write(gene, EditIntent(intent), cargo_bp, ct, k=k)
108
+ except FileNotFoundError as e:
109
+ raise click.ClickException(f"writability atlas not available: {e}") from e
110
+ click.echo(render_plans(plans))
111
+
112
+
113
+ @main.command()
114
+ @click.option("--since", default="2026-01-01", help="Earliest publication date (YYYY-MM-DD).")
115
+ @click.option("--back-test", is_flag=True, help="Run the ISPpu10 back-test window.")
116
+ def monitor(since, back_test):
117
+ """Run the living-literature monitor (Europe PMC scan -> curation queue)."""
118
+ from pen_stack.monitor.run import run_monitor
119
+ res = run_monitor(since=since, back_test=back_test)
120
+ click.echo(f"Monitor: {res['n_hits']} hits, {res['n_candidates']} candidates -> {res['queue']}")
121
+ if res.get("isppu10_found") is not None:
122
+ click.echo(f"ISPpu10 back-test: {'FOUND' if res['isppu10_found'] else 'not found'}")
123
+
124
+
125
+ if __name__ == "__main__":
126
+ main()
@@ -0,0 +1 @@
1
+ """pen_stack.data - see PEN-STACK program doc."""
@@ -0,0 +1,84 @@
1
+ """ENCODE REST resolver.
2
+
3
+ Resolves released hg38 bigWig SIGNAL files for a (biosample, assay/target) pair via the ENCODE
4
+ Portal REST API - so we never hard-code possibly-wrong file accessions. Returns accession + href.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import requests
9
+
10
+ ENCODE = "https://www.encodeproject.org"
11
+ HEADERS = {"accept": "application/json"}
12
+
13
+ # preferred processed signal output per assay (fold-change over control where available)
14
+ _PREF_OUTPUT = [
15
+ "fold change over control",
16
+ "signal p-value",
17
+ "read-depth normalized signal",
18
+ "signal",
19
+ ]
20
+
21
+
22
+ def _search(params: dict) -> list[dict]:
23
+ r = requests.get(f"{ENCODE}/search/", params=params, headers=HEADERS, timeout=60)
24
+ if r.status_code == 404:
25
+ return [] # ENCODE returns 404 for zero-result searches with some param combos
26
+ r.raise_for_status()
27
+ return r.json().get("@graph", [])
28
+
29
+
30
+ def find_bigwig(biosample: str, assay_title: str, target: str | None = None,
31
+ assembly: str = "GRCh38") -> dict | None:
32
+ """Find one released bigWig signal file for a biosample + assay (+ histone target).
33
+
34
+ biosample e.g. 'K562'; assay_title e.g. 'Histone ChIP-seq' / 'ATAC-seq' / 'DNase-seq';
35
+ target e.g. 'H3K27ac' (None for ATAC/DNase).
36
+ """
37
+ params = {
38
+ "type": "File",
39
+ "file_format": "bigWig",
40
+ "output_type": _PREF_OUTPUT,
41
+ "assembly": assembly,
42
+ "status": "released",
43
+ "biosample_ontology.term_name": biosample,
44
+ "assay_title": assay_title,
45
+ "format": "json",
46
+ "limit": "50",
47
+ }
48
+ if target:
49
+ params["target.label"] = target
50
+ files = _search(params)
51
+ if not files:
52
+ return None
53
+ # rank by preferred output_type order, prefer non-isogenic-replicate consensus where present
54
+ def rank(f):
55
+ ot = f.get("output_type", "")
56
+ return _PREF_OUTPUT.index(ot) if ot in _PREF_OUTPUT else len(_PREF_OUTPUT)
57
+ f = sorted(files, key=rank)[0]
58
+ return {"accession": f["accession"], "href": ENCODE + f["href"],
59
+ "output_type": f.get("output_type"), "assembly": assembly,
60
+ "biosample": biosample, "assay": assay_title, "target": target}
61
+
62
+
63
+ # default track panel per the prereg (durability features)
64
+ DEFAULT_PANEL = [
65
+ ("ATAC-seq", None),
66
+ ("DNase-seq", None),
67
+ ("Histone ChIP-seq", "H3K27ac"),
68
+ ("Histone ChIP-seq", "H3K4me1"),
69
+ ("Histone ChIP-seq", "H3K4me3"),
70
+ ("Histone ChIP-seq", "H3K9me3"),
71
+ ("Histone ChIP-seq", "H3K27me3"),
72
+ ]
73
+
74
+
75
+ def resolve_panel(biosample: str, panel=DEFAULT_PANEL, assembly: str = "GRCh38") -> dict[str, dict]:
76
+ """Return {track_name: file_record} for the panel, skipping assays with no released bigWig.
77
+ Partial panels are returned as-is (e.g. a cell type lacking some histone marks) - graceful."""
78
+ out = {}
79
+ for assay, target in panel:
80
+ rec = find_bigwig(biosample, assay, target, assembly=assembly)
81
+ name = target or assay.split("-")[0].lower() # H3K27ac / atac / dnase
82
+ if rec:
83
+ out[name] = rec
84
+ return out
@@ -0,0 +1,71 @@
1
+ """hg38 genome scaffolding.
2
+
3
+ Fetches hg38 chromosome sizes and builds the canonical 1 kb bin grid (autosomes + X) that every
4
+ feature store is keyed on. Pure-CPU, small; runs in any container.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+
10
+ import pandas as pd
11
+ import requests
12
+
13
+ UCSC_CHROM_SIZES = "https://hgdownload.soe.ucsc.edu/goldenPath/hg38/bigZips/hg38.chrom.sizes"
14
+ MAIN_CHROMS = [f"chr{i}" for i in range(1, 23)] + ["chrX"]
15
+ BIN_BP = 1000
16
+
17
+
18
+ def fetch_chrom_sizes(out_tsv: str | Path, url: str = UCSC_CHROM_SIZES,
19
+ chroms: list[str] = MAIN_CHROMS) -> dict[str, int]:
20
+ txt = requests.get(url, timeout=60).text
21
+ sizes = {}
22
+ for line in txt.splitlines():
23
+ if not line.strip():
24
+ continue
25
+ c, n = line.split("\t")[:2]
26
+ if c in chroms:
27
+ sizes[c] = int(n)
28
+ sizes = {c: sizes[c] for c in chroms if c in sizes} # canonical order
29
+ Path(out_tsv).parent.mkdir(parents=True, exist_ok=True)
30
+ Path(out_tsv).write_text("".join(f"{c}\t{n}\n" for c, n in sizes.items()))
31
+ return sizes
32
+
33
+
34
+ def build_bin_grid(chrom_sizes: dict[str, int], out_parquet: str | Path | None = None,
35
+ bin_bp: int = BIN_BP) -> pd.DataFrame:
36
+ rows = []
37
+ for c, n in chrom_sizes.items():
38
+ nbins = n // bin_bp
39
+ starts = range(0, nbins * bin_bp, bin_bp)
40
+ rows.append(pd.DataFrame({"chrom": c, "start": starts}))
41
+ grid = pd.concat(rows, ignore_index=True)
42
+ grid["end"] = grid["start"] + bin_bp
43
+ grid["bin"] = grid["start"] // bin_bp
44
+ if out_parquet:
45
+ Path(out_parquet).parent.mkdir(parents=True, exist_ok=True)
46
+ grid.to_parquet(out_parquet, index=False)
47
+ return grid
48
+
49
+
50
+ def load_chrom_sizes(tsv: str | Path) -> dict[str, int]:
51
+ out = {}
52
+ for line in Path(tsv).read_text().splitlines():
53
+ if line.strip():
54
+ c, n = line.split("\t")[:2]
55
+ out[c] = int(n)
56
+ return out
57
+
58
+
59
+ def main() -> None:
60
+ import argparse
61
+ ap = argparse.ArgumentParser()
62
+ ap.add_argument("--sizes-out", default="/data/raw/hg38.chrom.sizes")
63
+ ap.add_argument("--grid-out", default="/data/features/bin_grid_1kb.parquet")
64
+ a = ap.parse_args()
65
+ sizes = fetch_chrom_sizes(a.sizes_out)
66
+ grid = build_bin_grid(sizes, a.grid_out)
67
+ print(f"chroms={len(sizes)} total_bins={len(grid)} -> {a.grid_out}")
68
+
69
+
70
+ if __name__ == "__main__":
71
+ main()
@@ -0,0 +1,119 @@
1
+ """Chromatin feature store.
2
+
3
+ Resolves the ENCODE bigWig panel for a cell type, downloads tracks IN PARALLEL, bins each to the
4
+ canonical 1 kb grid (mean signal per bin) IN PARALLEL across cores, merges into one feature-store
5
+ parquet, and deletes the raw bigWigs (500 GB discipline). Run in Docker on the VM.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import os
11
+ from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
12
+ from pathlib import Path
13
+
14
+ import numpy as np
15
+ import pandas as pd
16
+ import pyBigWig
17
+ import requests
18
+
19
+ from pen_stack.data.encode import resolve_panel
20
+ from pen_stack.data.genome import MAIN_CHROMS, load_chrom_sizes
21
+
22
+ BIN_BP = 1000
23
+
24
+
25
+ def download(href: str, dest: str) -> str:
26
+ dest = str(dest)
27
+ if os.path.exists(dest) and os.path.getsize(dest) > 0:
28
+ return dest
29
+ Path(dest).parent.mkdir(parents=True, exist_ok=True)
30
+ with requests.get(href, stream=True, timeout=900) as r:
31
+ r.raise_for_status()
32
+ with open(dest, "wb") as fh:
33
+ for chunk in r.iter_content(chunk_size=1 << 20):
34
+ fh.write(chunk)
35
+ return dest
36
+
37
+
38
+ def bin_one(args) -> tuple[str, pd.DataFrame]:
39
+ """Bin one bigWig to 1 kb mean per bin (module-level for ProcessPool picklability)."""
40
+ name, path, sizes = args
41
+ bw = pyBigWig.open(path)
42
+ bw_chroms = set(bw.chroms().keys())
43
+ frames = []
44
+ for c in MAIN_CHROMS:
45
+ if c not in sizes:
46
+ continue
47
+ n = sizes[c] // BIN_BP
48
+ key = c if c in bw_chroms else c.replace("chr", "")
49
+ if key not in bw_chroms:
50
+ frames.append(pd.DataFrame({"chrom": c, "bin": range(n), name: np.zeros(n, "float32")}))
51
+ continue
52
+ vals = bw.stats(key, 0, n * BIN_BP, nBins=n, type="mean")
53
+ v = np.array([0.0 if x is None else float(x) for x in vals], dtype="float32")
54
+ frames.append(pd.DataFrame({"chrom": c, "bin": range(n), name: v}))
55
+ bw.close()
56
+ return name, pd.concat(frames, ignore_index=True)
57
+
58
+
59
+ def build_feature_store(biosample: str, chrom_sizes_tsv: str, raw_dir: str, out_parquet: str,
60
+ max_dl: int = 7, max_bin: int = 7) -> pd.DataFrame:
61
+ sizes = load_chrom_sizes(chrom_sizes_tsv)
62
+ panel = resolve_panel(biosample)
63
+ print(f"[{biosample}] resolved tracks: {list(panel.keys())}", flush=True)
64
+ if not panel:
65
+ raise SystemExit(f"no ENCODE bigWig tracks resolved for {biosample}")
66
+
67
+ # 1) parallel download
68
+ paths = {}
69
+ with ThreadPoolExecutor(max_workers=max_dl) as ex:
70
+ futs = {ex.submit(download, rec["href"],
71
+ os.path.join(raw_dir, f"{biosample}_{name}_{rec['accession']}.bigWig")): name
72
+ for name, rec in panel.items()}
73
+ for fut in futs:
74
+ name = futs[fut]
75
+ paths[name] = fut.result()
76
+ print(f" downloaded {name}", flush=True)
77
+
78
+ # 2) parallel bin
79
+ binned = {}
80
+ with ProcessPoolExecutor(max_workers=max_bin) as ex:
81
+ for name, df in ex.map(bin_one, [(n, paths[n], sizes) for n in panel]):
82
+ binned[name] = df
83
+ print(f" binned {name}", flush=True)
84
+
85
+ base = None
86
+ for name in panel:
87
+ base = binned[name] if base is None else base.merge(binned[name], on=["chrom", "bin"])
88
+ base["biosample"] = biosample
89
+
90
+ # 3) clean raws
91
+ for p in paths.values():
92
+ try:
93
+ os.remove(p)
94
+ except OSError:
95
+ pass
96
+
97
+ Path(out_parquet).parent.mkdir(parents=True, exist_ok=True)
98
+ base.to_parquet(out_parquet, index=False)
99
+ pd.DataFrame([{"track": n, "accession": panel[n]["accession"],
100
+ "output_type": panel[n]["output_type"]} for n in panel]
101
+ ).to_csv(out_parquet.replace(".parquet", "_manifest.csv"), index=False)
102
+ return base
103
+
104
+
105
+ def main() -> None:
106
+ ap = argparse.ArgumentParser()
107
+ ap.add_argument("--biosample", required=True)
108
+ ap.add_argument("--sizes", default="/data/raw/hg38.chrom.sizes")
109
+ ap.add_argument("--raw-dir", default="/data/raw/encode")
110
+ ap.add_argument("--out", default=None)
111
+ a = ap.parse_args()
112
+ out = a.out or f"/data/features/chromatin_{a.biosample.lower()}.parquet"
113
+ df = build_feature_store(a.biosample, a.sizes, a.raw_dir, out)
114
+ cols = [c for c in df.columns if c not in ("chrom", "bin", "biosample")]
115
+ print(f"feature store {out}: bins={len(df)} tracks={cols}", flush=True)
116
+
117
+
118
+ if __name__ == "__main__":
119
+ main()
@@ -0,0 +1,112 @@
1
+ """Integration-propensity features.
2
+
3
+ Builds per-1 kb-bin retroviral integration density from VISDB integration tables (HIV, HTLV, MLV;
4
+ coordinates already lifted to hg38 in VISDB). Integration propensity reflects accessible/active
5
+ chromatin and is a feature for both the safety layer and "where insertions land".
6
+
7
+ NOTE (scope): VISDB's MLV set is tiny (~32 sites); the large >3.7M MLV-in-K562/HepG2 sets
8
+ referenced in the plan live in specific papers'/GEO supplements and are sourced separately. The
9
+ GENOTOXIC labels (clonal-outcome CIS) come from the clinical gene list - this module
10
+ supplies the integration-DENSITY feature, not the danger label.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import glob
16
+ import os
17
+ from pathlib import Path
18
+
19
+ import numpy as np
20
+ import pandas as pd
21
+
22
+ from pen_stack.data.genome import MAIN_CHROMS
23
+
24
+ BIN_BP = 1000
25
+
26
+
27
+ def load_visdb(csv_dir: str) -> pd.DataFrame:
28
+ frames = []
29
+ for f in sorted(glob.glob(os.path.join(csv_dir, "*.csv"))):
30
+ virus = Path(f).stem
31
+ df = pd.read_csv(f, dtype=str)
32
+ cols = {c.lower().strip(): c for c in df.columns}
33
+ chrom_c = cols.get("human chromosome")
34
+ start_c = cols.get("hg38_start")
35
+ if not chrom_c or not start_c:
36
+ continue
37
+ sub = pd.DataFrame({
38
+ "chrom": df[chrom_c].astype(str).map(lambda c: c if c.startswith("chr") else f"chr{c}"),
39
+ "pos": pd.to_numeric(df[start_c], errors="coerce"),
40
+ "virus": virus,
41
+ }).dropna(subset=["pos"])
42
+ frames.append(sub)
43
+ out = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame(columns=["chrom", "pos", "virus"])
44
+ out = out[out["chrom"].isin(MAIN_CHROMS)].copy()
45
+ out["pos"] = out["pos"].astype(int)
46
+ return out
47
+
48
+
49
+ def density_per_bin(integ: pd.DataFrame, bin_grid: str, out_parquet: str) -> pd.DataFrame:
50
+ grid = pd.read_parquet(bin_grid)[["chrom", "bin"]]
51
+ integ["bin"] = integ["pos"] // BIN_BP
52
+ dens = integ.groupby(["chrom", "bin"]).size().rename("integ_density").reset_index()
53
+ out = grid.merge(dens, on=["chrom", "bin"], how="left")
54
+ out["integ_density"] = out["integ_density"].fillna(0).astype("int32")
55
+ out["integ_log_density"] = np.log1p(out["integ_density"]).astype("float32")
56
+ Path(out_parquet).parent.mkdir(parents=True, exist_ok=True)
57
+ out.to_parquet(out_parquet, index=False)
58
+ return out
59
+
60
+
61
+ def lafave_density(bed_gz: str, chain_file: str, bin_grid: str, out_parquet: str) -> pd.DataFrame:
62
+ """Cell-type-specific MLV integration density from a LaFave et al. 2014 BED (hg19 -> hg38 lift).
63
+
64
+ The LaFave K562/HepG2 MLV integration BEDs are on hg19; lift each site to hg38 with the UCSC
65
+ chain, then bin to 1 kb. This is the plan's >3.7M MLV-in-K562/HepG2 supervision (Bushman/NHGRI).
66
+ """
67
+ from pyliftover import LiftOver
68
+ lo = LiftOver(chain_file)
69
+ sites = []
70
+ with __import__("gzip").open(bed_gz, "rt") as fh:
71
+ for line in fh:
72
+ if line.startswith("track") or not line.strip():
73
+ continue
74
+ f = line.split("\t")
75
+ chrom, start = f[0], int(f[1])
76
+ conv = lo.convert_coordinate(chrom, start)
77
+ if conv:
78
+ nc, npos = conv[0][0], conv[0][1]
79
+ if nc in MAIN_CHROMS:
80
+ sites.append((nc, npos))
81
+ integ = pd.DataFrame(sites, columns=["chrom", "pos"])
82
+ print(f"lifted {len(integ)} / sites to hg38")
83
+ out = density_per_bin(integ, bin_grid, out_parquet)
84
+ out = out.rename(columns={"integ_density": "integ_mlv_density",
85
+ "integ_log_density": "integ_mlv_log_density"})
86
+ out.to_parquet(out_parquet, index=False)
87
+ return out
88
+
89
+
90
+ def main() -> None:
91
+ ap = argparse.ArgumentParser()
92
+ ap.add_argument("--mode", choices=["visdb", "lafave"], default="visdb")
93
+ ap.add_argument("--visdb-dir", default="/data/external/visdb")
94
+ ap.add_argument("--lafave-bed", default=None)
95
+ ap.add_argument("--chain", default="/data/external/hg19ToHg38.over.chain.gz")
96
+ ap.add_argument("--bin-grid", default="/data/features/bin_grid_1kb.parquet")
97
+ ap.add_argument("--out", default="/data/features/integration_density.parquet")
98
+ a = ap.parse_args()
99
+ if a.mode == "lafave":
100
+ out = lafave_density(a.lafave_bed, a.chain, a.bin_grid, a.out)
101
+ nz = int((out["integ_mlv_density"] > 0).sum())
102
+ print(f"MLV density: bins={len(out)} nonzero={nz} max={int(out['integ_mlv_density'].max())} -> {a.out}")
103
+ return
104
+ integ = load_visdb(a.visdb_dir)
105
+ print(f"loaded {len(integ)} integration sites; by virus: {integ['virus'].value_counts().to_dict()}")
106
+ out = density_per_bin(integ, a.bin_grid, a.out)
107
+ nz = int((out["integ_density"] > 0).sum())
108
+ print(f"integration density: bins={len(out)} nonzero={nz} max={int(out['integ_density'].max())}")
109
+
110
+
111
+ if __name__ == "__main__":
112
+ main()