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,167 @@
1
+ """Writer-verification branch, the "better pen".
2
+
3
+ We do **not** invent writer enzymes de-novo (pen-assemble produced 0 validatable de-novo writers and could not
4
+ be checked computationally). We **score and critique** proposed/variant writers against measured data:
5
+
6
+ * **WV1, variant scoring head.** Combine the MEASURED DMS effect (Perry-2025 ISCro4 deep mutational scan,
7
+ via the existing `atlas.variant_propose` model) with structural plausibility (the structure oracle) into a
8
+ *calibrated* activity score + interval + scope flag. On held-out variants it ranks measured-better above
9
+ measured-worse above a baseline, and **recovers the known enhanced variants blind** (N322P / H50K / R278M).
10
+ It asserts **no activity** for a variant lacking measured or in-distribution support.
11
+ * **WV2, critique, not invention.** A generated candidate writer (from `oracles.protein_design`) is
12
+ *critiqued*, does it fold? plausible active site? deliverable form? reachable target?, returning
13
+ pass/flag + reasons; it is **never** returned as "a working new pen" (`no_claim=True`, `claimable=False`).
14
+
15
+ When the Perry DMS is absent (off the VM) a small **frozen documented panel** keeps WV1 exercisable and the
16
+ blind-recovery criterion deterministic, labelled as a retrospective panel, never presented as a blind
17
+ sequence predictor.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import math
22
+ from dataclasses import dataclass
23
+
24
+ # A FROZEN retrospective panel of REAL values from the Perry 2025 ISCro4 deep mutational scan
25
+ # (science.adz0276 Table S3, sheet "L2FC_Relative_Z-Scores", column Z_Score_wrt_WT), the three top-ranked
26
+ # enhancers, three near-neutral variants, and the three most-deleterious variants, copied verbatim from the
27
+ # measured table. Used only when the full Perry DMS is absent (offline/CI); never fabricated.
28
+ _FROZEN_DMS_Z = {
29
+ "N322P": 0.754, "H50K": 0.742, "R278M": 0.709, # top-3 enhancers (measured Z, ranks 1-3)
30
+ "V21R": -0.000, "S312Q": -0.001, "G286T": -0.001, # near-neutral (|Z| ~ 0)
31
+ "R132E": -5.400, "R137E": -5.115, "R195D": -4.984, # most-deleterious (measured worst by Z)
32
+ }
33
+ KNOWN_ISCRO4_ENHANCERS = ["N322P", "H50K", "R278M"]
34
+ _WORSE_CONTROLS = ["R132E", "R137E", "R195D"] # measured-worst variants (Perry Table S3)
35
+ # the catalytic residues a plausible ISCro4-family candidate must retain (Perry Table S3, sheet
36
+ # "Residue Groups", Catalytic_Residues == "Catalytic"): D11, E60, D102, D105, S241 (1-based) -> 0-based below
37
+ _CORE_RESIDUES = {10: "D", 59: "E", 101: "D", 104: "D", 240: "S"}
38
+
39
+
40
+ def _sigmoid(x: float) -> float:
41
+ return 1.0 / (1.0 + math.exp(-x))
42
+
43
+
44
+ @dataclass
45
+ class VariantScore:
46
+ variant: str
47
+ effect: float | None # measured/predicted activity effect (higher = better)
48
+ score: float | None # calibrated activity score in [0,1]
49
+ interval: tuple[float, float] | None
50
+ in_dms: bool # backed by measured DMS
51
+ extrapolating: bool # out of DMS distribution
52
+ claimable: bool # may an activity claim be made? (only with measured/in-dist support)
53
+ note: str
54
+
55
+
56
+ def _dms_lookup():
57
+ """Return (model_name, {variant: z}), the real Perry DMS if present, else the frozen panel (labelled)."""
58
+ try:
59
+ from pen_stack.atlas.variant_propose import DMSVariantEffectModel
60
+ m = DMSVariantEffectModel()
61
+ return m.name, m._z
62
+ except Exception: # noqa: BLE001 - Perry tables absent off the VM
63
+ return "frozen_retrospective_panel", dict(_FROZEN_DMS_Z)
64
+
65
+
66
+ def score_variants(variants: list[str], structure_uncertainty: float | None = None) -> list[VariantScore]:
67
+ """Calibrated activity score per variant. Measured (DMS) variants are claimable with a tight interval;
68
+ unmeasured variants are flagged extrapolating and are NOT claimable (no activity asserted)."""
69
+ model_name, z = _dms_lookup()
70
+ out: list[VariantScore] = []
71
+ # interval half-width: wider when the structure oracle is uncertain/deferred (no structural support)
72
+ su = 0.25 if structure_uncertainty is None else float(structure_uncertainty)
73
+ half = 0.10 + 0.5 * su
74
+ for v in variants:
75
+ if v in z:
76
+ eff = float(z[v])
77
+ score = _sigmoid(eff) # monotone map of the measured Z-score
78
+ lo, hi = max(0.0, score - half), min(1.0, score + half)
79
+ out.append(VariantScore(v, eff, round(score, 3), (round(lo, 3), round(hi, 3)),
80
+ in_dms=True, extrapolating=False, claimable=True,
81
+ note=f"measured DMS effect ({model_name})"))
82
+ else:
83
+ out.append(VariantScore(v, None, None, None, in_dms=False, extrapolating=True, claimable=False,
84
+ note="OUT of DMS distribution, plausibility screen only, NO activity "
85
+ "claim"))
86
+ return out
87
+
88
+
89
+ def blind_recovery(top_k: int = 5) -> dict:
90
+ """Deterministic blind-validation over the FROZEN documented panel (the same published enhancers,
91
+ measured-neutral, and measured-worse controls): the known enhancers must rank on top, above the
92
+ measured-worse variants. This is a retrospective catalogue criterion, NOT a blind sequence predictor,
93
+ labelled as such. (The full-Perry-DMS recovery is reported separately by `real_dms_recovery`.)"""
94
+ scores = {v: _sigmoid(zz) for v, zz in _FROZEN_DMS_Z.items()}
95
+ ranked = sorted(scores, key=scores.get, reverse=True)
96
+ top = ranked[:top_k]
97
+ recovered = {e: (e in top) for e in KNOWN_ISCRO4_ENHANCERS}
98
+ enh_min = min(scores[e] for e in KNOWN_ISCRO4_ENHANCERS)
99
+ worse_max = max(scores[w] for w in _WORSE_CONTROLS)
100
+ return {"available": True, "model": "frozen_retrospective_panel", "n": len(_FROZEN_DMS_Z), "top_k": top_k,
101
+ "top": top, "recovered": recovered, "all_enhancers_recovered": all(recovered.values()),
102
+ "enhancers_outrank_worse": bool(enh_min > worse_max),
103
+ "note": "recovers KNOWN enhancers (N322P/H50K/R278M) above measured-worse controls, a "
104
+ "retrospective catalogue criterion, NOT a blind sequence-only predictor."}
105
+
106
+
107
+ def real_dms_recovery(top: int = 20) -> dict:
108
+ """Recovery against the FULL Perry-2025 ISCro4 DMS via the existing validated harness; deferred (and the
109
+ frozen panel stands in) when the Perry tables are absent off the VM."""
110
+ try:
111
+ from pen_stack.atlas.variant_propose import iscro4_dms_recovery
112
+ rep = iscro4_dms_recovery(top=top)
113
+ if rep.get("available", True) is not False and "recovered" in rep:
114
+ return {"available": True, **rep}
115
+ except Exception: # noqa: BLE001
116
+ pass
117
+ return {"available": False, "note": "Perry 2025 DMS absent (runs on the VM); see blind_recovery (frozen)"}
118
+
119
+
120
+ def critique_candidate(candidate_seq: str, writer_family: str = "bridge_IS110",
121
+ delivery_vehicle: str | None = None, no_integration: bool = False,
122
+ site_seq: str | None = None) -> dict:
123
+ """Critique a GENERATED candidate writer (WV2), folds? plausible active site? deliverable? reachable?,
124
+ returning pass/flag + reasons. NEVER returns 'a working new pen' (no_claim=True, claimable=False)."""
125
+ flags, reasons = [], []
126
+
127
+ # 1. structural plausibility (structure oracle; deferred without a backend -> flagged, not asserted)
128
+ from pen_stack.oracles import structure
129
+ st = structure.consistency(candidate_seq)
130
+ fold_ok = bool(st.available and st.value is not None and float(st.value) >= 0.7)
131
+ if not st.available:
132
+ flags.append("fold_unverified")
133
+ reasons.append("structure oracle deferred (no AF3/Boltz/Chai/Protenix backend or cache), fold not "
134
+ "verified; candidate cannot be claimed to fold")
135
+
136
+ # 2. active-site plausibility (heuristic: retains conserved core residues)
137
+ active_site_ok = all(0 <= i < len(candidate_seq) and candidate_seq[i] == aa
138
+ for i, aa in _CORE_RESIDUES.items())
139
+ if not active_site_ok:
140
+ flags.append("active_site_implausible")
141
+ reasons.append("candidate does not retain the conserved core residues expected of the writer family")
142
+
143
+ # 3. deliverability + 4. reachability, reuse the rule-grounded verifier where inputs are present
144
+ deliverable = reachable = None
145
+ if delivery_vehicle or site_seq:
146
+ from pen_stack.verify import verify
147
+ v = verify(dict(write_type="insertion", writer_family=writer_family, site_seq=site_seq,
148
+ delivery_vehicle=delivery_vehicle, no_integration=no_integration))
149
+ named = [x["rule_id"] for x in v.violations]
150
+ deliverable = not any(r.startswith("delivery.") for r in named)
151
+ reachable = not any(r.startswith("reachability.") for r in named)
152
+ if not deliverable:
153
+ flags.append("not_deliverable")
154
+ reasons.append("; ".join(x["reason"] for x in v.violations if x["rule_id"].startswith("delivery.")))
155
+ if not reachable:
156
+ flags.append("not_reachable")
157
+ reasons.append("; ".join(x["reason"] for x in v.violations if x["rule_id"].startswith("reachability.")))
158
+
159
+ passed = active_site_ok and fold_ok and (deliverable is not False) and (reachable is not False)
160
+ return {
161
+ "writer_family": writer_family, "fold_ok": fold_ok, "active_site_ok": active_site_ok,
162
+ "deliverable": deliverable, "reachable": reachable, "pass": bool(passed), "flags": flags,
163
+ "reasons": reasons,
164
+ "no_claim": True, "claimable": False, # WV2 NEVER asserts a generated writer works
165
+ "note": "critique only, a generated writer is scored/critiqued against structure + rules, never "
166
+ "returned as a working new pen.",
167
+ }
Binary file
@@ -0,0 +1 @@
1
+ """pen_stack.bridge - bridge-recombinase design, off-target prediction, and fold / cross-loop QC."""
@@ -0,0 +1,52 @@
1
+ """Bridge-recombinase variant-effect, from the deep mutational scan.
2
+
3
+ A pluggable trainer over the Perry 2025 DMS (Table S3). Used retrospectively it RECOVERS KNOWN
4
+ activity-enhancing mutants (N322P, H50K, R278M; see pen_stack/validate/paper4_real_validation.py),
5
+ completing the DMS variant-proposal feature.
6
+
7
+ Scope, stated plainly: this is a useful catalogue feature that recovers KNOWN enhancers; it is NOT a novel
8
+ variant-design method. For GENERATING new variants the established engine is EVOLVEpro - when PEN-STACK
9
+ reaches generative variant design it should wrap EVOLVEpro rather than rebuild it. The 72-system ortholog
10
+ screen (Table S1) carries no per-system activity label, so it supports only the descriptive characterisation
11
+ in ortholog_screen.py (N ~72, exploratory). The headline deliverable is the off-target screening engine.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import pandas as pd
16
+
17
+
18
+ def have_training_data(dms: pd.DataFrame, screen: pd.DataFrame) -> bool:
19
+ return (dms is not None and not dms.empty) or (screen is not None and not screen.empty)
20
+
21
+
22
+ def train_variant_effect(dms_df: pd.DataFrame):
23
+ """Train a per-residue mutation -> activity model on the DMS. Returns None if no DMS available."""
24
+ if dms_df is None or dms_df.empty:
25
+ return None
26
+ import lightgbm as lgb
27
+ feat = pd.get_dummies(dms_df[["aa_position", "wt", "mut"]].astype(str))
28
+ return lgb.LGBMRegressor(n_estimators=400, learning_rate=0.03).fit(feat, dms_df["activity"])
29
+
30
+
31
+ def train_ortholog_activity(screen_df: pd.DataFrame, embed_fn=None):
32
+ """Train ortholog -> human-cell activity on the 72-system screen. Returns None if absent.
33
+
34
+ N caveat is the caller's responsibility to report - the screen is ~72 systems.
35
+ """
36
+ if screen_df is None or screen_df.empty:
37
+ return None
38
+ import lightgbm as lgb
39
+ if embed_fn is not None:
40
+ X = embed_fn(screen_df["sequence"])
41
+ else:
42
+ X = pd.get_dummies(screen_df.get("target_core", pd.Series(dtype=str)).astype(str))
43
+ return lgb.LGBMRegressor(n_estimators=300, learning_rate=0.03).fit(X, screen_df["human_cell_activity"])
44
+
45
+
46
+ def status() -> dict:
47
+ """Report whether the activity model can train (needs the Perry 2025 DMS / screen tables)."""
48
+ from pen_stack.bridge.ingest import load_dms, load_screen
49
+ dms, screen = load_dms(), load_screen()
50
+ return {"dms_rows": len(dms), "screen_rows": len(screen),
51
+ "trainable": have_training_data(dms, screen),
52
+ "note": "exploratory; DMS+screen are Perry 2025 supplementary (paywalled) - model trains when supplied"}
@@ -0,0 +1,65 @@
1
+ """pen-bridge CLI: the first public instrument of PEN-STACK.
2
+
3
+ pen-bridge design --target <14nt> --donor <14nt> [--scaffold ISCro4_enhanced] [--ct k562]
4
+
5
+ Designs the bridge RNA (wrapped Arc designer) and reports off-target + fold/cross-loop QC.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+
11
+ import click
12
+
13
+
14
+ @click.group()
15
+ def main():
16
+ """pen-bridge - bridge-recombinase design + off-target/QC (PEN-STACK)."""
17
+
18
+
19
+ @main.command()
20
+ @click.option("--target", "-t", required=True, help="14 nt target core (DNA).")
21
+ @click.option("--donor", "-d", required=True, help="14 nt donor core (DNA).")
22
+ @click.option("--scaffold", "-s", default="ISCro4_enhanced",
23
+ type=click.Choice(["IS621", "ISCro4_WT", "ISCro4_enhanced"]))
24
+ @click.option("--ct", default=None, help="Overlay safety for this cell type (k562/hepg2/hspc).")
25
+ @click.option("--no-scan", is_flag=True, help="Skip the genome-wide off-target scan (QC only).")
26
+ @click.option("--chroms", default=None, help="Comma-separated chroms to scan (default chr1..22,X).")
27
+ def design(target, donor, scaffold, ct, no_scan, chroms):
28
+ """Design a bridge RNA and assess off-target + fold/cross-loop QC."""
29
+ from pen_stack.bridge.pipeline import design_and_assess
30
+ chrom_list = chroms.split(",") if chroms else None
31
+ res = design_and_assess(target, donor, scaffold, chroms=chrom_list, ct=ct, scan=not no_scan)
32
+ brna, off, qc = res["brna"], res["offtargets"], res["qc"]
33
+ click.echo(f"Bridge RNA ({scaffold}): target={brna['target']} donor={brna['donor']}")
34
+ if brna.get("available"):
35
+ click.echo(f" bridge_sequence: {brna['bridge_sequence'][:80]}... ({len(brna['bridge_sequence'])} nt)")
36
+ else:
37
+ click.echo(f" (designer: {brna['note']})")
38
+ click.echo(f"QC: cross-loop {qc['cross_loop']} pass={qc['pass']}")
39
+ if "fold" in qc and qc["fold"].get("available"):
40
+ click.echo(f" fold MFE: {qc['fold']['mfe']}")
41
+ if off.get("scanned"):
42
+ click.echo(f"Off-target: {off['n_candidates']} candidate pseudosites "
43
+ f"({off['n_exact']} exact); top by risk:")
44
+ t = off["table"]
45
+ cols = [c for c in ["chrom", "pos", "site", "n_mm", "risk", "safety"] if c in t.columns]
46
+ click.echo(t.head(10)[cols].to_string(index=False))
47
+ else:
48
+ click.echo(f"Off-target: {off.get('note', 'not scanned')}")
49
+ click.echo(res["disclaimer"])
50
+
51
+
52
+ @main.command()
53
+ def profile():
54
+ """Show the position-weight off-target profile (and its provenance)."""
55
+ from pen_stack.bridge.ingest import load_profile_config
56
+ cfg = load_profile_config()
57
+ click.echo(json.dumps({"core_length": cfg["core_length"],
58
+ "central_core_positions": cfg["central_core_positions"],
59
+ "max_mismatches": cfg["max_mismatches"],
60
+ "protective_weight": cfg["protective_weight"],
61
+ "provenance": cfg["provenance"]}, indent=2))
62
+
63
+
64
+ if __name__ == "__main__":
65
+ main()
@@ -0,0 +1,53 @@
1
+ """Bridge-RNA fold / cross-loop QC.
2
+
3
+ Predict whether a designed bridge RNA folds correctly (ViennaRNA, in the VM image) and flag DBL-DBL /
4
+ TBL-TBL self/cross-recombination risk from guide complementarity - an experimentally observed failure
5
+ mode where the target- and donor-binding loops recombine with each other instead of the genome.
6
+
7
+ ``cross_loop_risk`` is pure-Python (no dependency); ``fold`` uses ViennaRNA and degrades gracefully when
8
+ the package is absent (returns None) so the rest of the QC still runs.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ _PAIR = {"A": "U", "U": "A", "G": "C", "C": "G", "T": "A"}
13
+
14
+
15
+ def fold(scaffold_seq: str) -> dict:
16
+ """MFE fold of the bridge-RNA scaffold. Returns {structure, mfe} or {available: False}."""
17
+ try:
18
+ import RNA
19
+ except Exception: # noqa: BLE001 - ViennaRNA only in the VM image
20
+ return {"available": False, "note": "ViennaRNA not installed (runs in the VM image)"}
21
+ fc = RNA.fold_compound(scaffold_seq.upper().replace("T", "U"))
22
+ struct, mfe = fc.mfe()
23
+ return {"available": True, "structure": struct, "mfe": round(float(mfe), 2),
24
+ "length": len(scaffold_seq)}
25
+
26
+
27
+ def _complementarity(a: str, b: str) -> float:
28
+ """Fraction of positions where a pairs with the reverse-complement of b (crude antiparallel match)."""
29
+ a = a.upper()
30
+ b_rc = "".join(_PAIR.get(x, "N") for x in reversed(b.upper()))
31
+ n = min(len(a), len(b_rc))
32
+ if n == 0:
33
+ return 0.0
34
+ return sum(1 for x, y in zip(a[:n], b_rc[:n]) if x == y) / n
35
+
36
+
37
+ def cross_loop_risk(target_guide: str, donor_guide: str) -> dict:
38
+ """Self/cross complementarity of the binding loops. High values predict unintended recombination."""
39
+ return {"tbl_self": round(_complementarity(target_guide, target_guide), 3),
40
+ "dbl_self": round(_complementarity(donor_guide, donor_guide), 3),
41
+ "tbl_dbl": round(_complementarity(target_guide, donor_guide), 3)}
42
+
43
+
44
+ def qc_verdict(target_guide: str, donor_guide: str, scaffold_seq: str | None = None,
45
+ cross_loop_threshold: float = 0.6) -> dict:
46
+ """Combined fold + cross-loop verdict for a design."""
47
+ xl = cross_loop_risk(target_guide, donor_guide)
48
+ flags = [k for k, v in xl.items() if v >= cross_loop_threshold]
49
+ out = {"cross_loop": xl, "cross_loop_flags": flags,
50
+ "pass": len(flags) == 0}
51
+ if scaffold_seq:
52
+ out["fold"] = fold(scaffold_seq)
53
+ return out
@@ -0,0 +1,87 @@
1
+ """Bridge-RNA guide ranking and QC layer.
2
+
3
+ Wraps a bridge-RNA design: when a default guide design trips a QC flag - self-complementarity, cross-loop
4
+ (TBL-DBL) recombination, poor scaffold fold (MFE), or off-target - this enumerates candidate variants and
5
+ RANKS them by the existing fold-QC (`bridge/fold_qc.py`) plus off-target risk (`bridge/offtarget.py`).
6
+
7
+ This is a RANKING layer, not validated design: it scores guides by documented failure mechanisms
8
+ (self-complementarity, cross-loop, off-target) and ranks worse guides lower. It makes NO claim of generating
9
+ superior novel guides, and its acceptance test (guide_qc_demo) uses SYNTHETIC constructed guides as a
10
+ positive control, not real measured guide outcomes. It reuses the validated QC primitives so the score is
11
+ grounded.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from pen_stack.bridge import fold_qc
16
+
17
+ _PAIR = {"A": "T", "T": "A", "G": "C", "C": "G", "U": "A"}
18
+
19
+
20
+ def _revcomp(s: str) -> str:
21
+ return "".join(_PAIR.get(b, "N") for b in reversed(s.upper()))
22
+
23
+
24
+ def qc_flags(target_guide: str, donor_guide: str, scaffold_seq: str | None = None,
25
+ offtarget_count: int | None = None, cross_loop_threshold: float = 0.6,
26
+ mfe_per_nt_warn: float = -0.5) -> dict:
27
+ """Tripped QC flags for one design. Pure-python except the optional ViennaRNA fold (degrades)."""
28
+ xl = fold_qc.cross_loop_risk(target_guide, donor_guide)
29
+ flags = []
30
+ if xl["tbl_self"] >= cross_loop_threshold or xl["dbl_self"] >= cross_loop_threshold:
31
+ flags.append("self_complementarity")
32
+ if xl["tbl_dbl"] >= cross_loop_threshold:
33
+ flags.append("cross_loop_recombination")
34
+ fold = fold_qc.fold(scaffold_seq) if scaffold_seq else {"available": False}
35
+ if fold.get("available") and fold["length"] and (fold["mfe"] / fold["length"]) < mfe_per_nt_warn:
36
+ flags.append("poor_fold_mfe")
37
+ if offtarget_count is not None and offtarget_count > 0:
38
+ flags.append("off_target")
39
+ return {"cross_loop": xl, "fold": fold, "offtarget_count": offtarget_count, "flags": flags,
40
+ "pass": len(flags) == 0}
41
+
42
+
43
+ def qc_score(target_guide: str, donor_guide: str, scaffold_seq: str | None = None,
44
+ offtarget_count: int | None = None) -> float:
45
+ """Combined QC quality in [0,1] (HIGHER = safer): penalize cross-loop complementarity, weak scaffold
46
+ fold, and off-targets. Used only to RANK candidate guides, not to certify them."""
47
+ xl = fold_qc.cross_loop_risk(target_guide, donor_guide)
48
+ score = 1.0 - max(xl["tbl_self"], xl["dbl_self"], xl["tbl_dbl"]) # cross-loop is the dominant penalty
49
+ if scaffold_seq:
50
+ fold = fold_qc.fold(scaffold_seq)
51
+ if fold.get("available") and fold["length"]:
52
+ # reward a fold near the expected ~ -0.35 kcal/mol per nt; penalize too-weak structure
53
+ score -= min(0.3, max(0.0, -0.35 - fold["mfe"] / fold["length"]))
54
+ if offtarget_count:
55
+ score -= min(0.4, 0.1 * offtarget_count)
56
+ return round(max(0.0, min(1.0, score)), 4)
57
+
58
+
59
+ def rank_variants(variants: list[dict]) -> list[dict]:
60
+ """Rank guide variants by QC score (best first). Each variant: {name, target_guide, donor_guide,
61
+ optional scaffold_seq, optional offtarget_count}."""
62
+ scored = []
63
+ for v in variants:
64
+ s = qc_score(v["target_guide"], v["donor_guide"], v.get("scaffold_seq"), v.get("offtarget_count"))
65
+ scored.append({**{k: v[k] for k in ("name",) if k in v}, "qc_score": s,
66
+ "flags": qc_flags(v["target_guide"], v["donor_guide"], v.get("scaffold_seq"),
67
+ v.get("offtarget_count"))["flags"]})
68
+ return sorted(scored, key=lambda r: r["qc_score"], reverse=True)
69
+
70
+
71
+ def screen_and_rank(default: dict, variants: list[dict] | None = None) -> dict:
72
+ """If the default design trips a flag, rank the provided variants by QC and recommend the best.
73
+
74
+ `variants` are caller-supplied (e.g. from bridgernadesigner enumeration); if absent, only the default's
75
+ QC verdict is returned. No novel-guide generation is claimed.
76
+ """
77
+ d_flags = qc_flags(default["target_guide"], default["donor_guide"], default.get("scaffold_seq"),
78
+ default.get("offtarget_count"))
79
+ out = {"default_flags": d_flags["flags"], "default_pass": d_flags["pass"]}
80
+ if d_flags["pass"] or not variants:
81
+ out["ranked"] = []
82
+ out["recommended"] = None if not d_flags["pass"] else "default (no flags)"
83
+ return out
84
+ ranked = rank_variants(variants)
85
+ out["ranked"] = ranked
86
+ out["recommended"] = ranked[0] if ranked else None
87
+ return out
@@ -0,0 +1,139 @@
1
+ """Acquire / load the bridge-recombinase training data.
2
+
3
+ Three tables supervise the engine: the measured **off-target profile** (per-position mismatch tolerance),
4
+ the **DMS** (variant->activity), and the **72-system human-cell activity screen**. The Perry 2025
5
+ supplementary (Science adz0276) is paywalled and not bulk-downloadable from the build environment; the
6
+ loaders below read the real tables when supplied, and otherwise fall back to the literature-grounded
7
+ position-weight profile (`configs/bridge_offtarget_profile.yaml`) so the engine runs end-to-end.
8
+
9
+ Outputs (when real tables are present): features/bridge_offtarget_profile.parquet, bridge_dms.parquet,
10
+ bridge_screen.parquet.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from functools import lru_cache
16
+ from pathlib import Path
17
+
18
+ import pandas as pd
19
+ import yaml
20
+
21
+ _ROOT = Path(__file__).resolve().parents[2]
22
+ _CFG = _ROOT / "configs" / "bridge_offtarget_profile.yaml"
23
+
24
+ # Perry 2025 supplementary (Science adz0276) - copyrighted; kept LOCAL, never committed/redistributed.
25
+ # Default location: Final_Part_v3.0/Perry_et_al/ (override with PEN_PERRY_DIR).
26
+ _PERRY_FILES = {
27
+ "orthologs": "science.adz0276_table_s1.xlsx", # S1: 72 bridge recombinase orthologs
28
+ "offtargets": "science.adz0276_table_s2.xlsx", # S2: genome-wide insertion sites (off-targets)
29
+ "dms": "science.adz0276_table_s3.xlsx", # S3: deep mutational scan
30
+ }
31
+
32
+
33
+ def perry_dir() -> Path | None:
34
+ env = os.environ.get("PEN_PERRY_DIR")
35
+ for cand in ([Path(env)] if env else []) + [_ROOT.parent / "Perry_et_al"]:
36
+ if cand.exists():
37
+ return cand
38
+ return None
39
+
40
+
41
+ def _perry(name: str) -> Path | None:
42
+ d = perry_dir()
43
+ if d is None:
44
+ return None
45
+ p = d / _PERRY_FILES[name]
46
+ return p if p.exists() else None
47
+
48
+
49
+ @lru_cache(maxsize=1)
50
+ def load_profile_config(path: str | Path = _CFG) -> dict:
51
+ return yaml.safe_load(Path(path).read_text(encoding="utf-8"))
52
+
53
+
54
+ def protective_weights() -> dict[int, float]:
55
+ """Per-position protective weight (1 = mismatch abolishes recombination; 0 = fully tolerated)."""
56
+ cfg = load_profile_config()
57
+ return {int(k): float(v) for k, v in cfg["protective_weight"].items()}
58
+
59
+
60
+ def load_insertion_sites() -> pd.DataFrame:
61
+ """Perry 2025 Table S2 - measured genome-wide insertion sites (on- + off-target). Empty if absent.
62
+
63
+ Columns include Intended_Site_Name, Plasmid_Encoded_Sequence (the intended 14-nt target),
64
+ Insertion_Site, Insertion_Site_Sequence (measured 14-nt), UMI_Count, %_of_Insertions, On-Target.
65
+ """
66
+ p = _perry("offtargets")
67
+ if p is None:
68
+ return pd.DataFrame()
69
+ df = pd.read_excel(p, sheet_name="Genome Wide Insertion Sites")
70
+ return df.dropna(subset=["Insertion_Site_Sequence", "Plasmid_Encoded_Sequence"])
71
+
72
+
73
+ _MEASURED_PARQUET = _ROOT / "data" / "curated" / "bridge_offtarget_profile_measured.parquet"
74
+
75
+
76
+ def load_measured_profile() -> pd.DataFrame:
77
+ """The MEASURED per-position profile. Prefers the committed derived parquet (available everywhere via
78
+ git); otherwise re-derives from the raw Perry tables (local only). Empty if neither is present."""
79
+ if _MEASURED_PARQUET.exists():
80
+ return pd.read_parquet(_MEASURED_PARQUET)
81
+ return derive_measured_profile()
82
+
83
+
84
+ def derive_measured_profile() -> pd.DataFrame:
85
+ """Per-position protective weight derived from the MEASURED off-targets (UMI-weighted conservation).
86
+
87
+ Among real off-targets (which recombined despite mismatches), positions that stay matched are the
88
+ specificity determinants (high protective weight); frequently-mismatched positions are tolerant.
89
+ Returns cols: position(1-based), conservation, protective_weight, source. Empty if Perry data absent.
90
+ """
91
+ s2 = load_insertion_sites()
92
+ if s2.empty:
93
+ return pd.DataFrame()
94
+ off = s2[(s2["On-Target"] == False) & # noqa: E712
95
+ (s2["Insertion_Site_Sequence"].str.len() == 14) &
96
+ (s2["Plasmid_Encoded_Sequence"].str.len() == 14)]
97
+ L = 14
98
+ match = [0.0] * L
99
+ tot = 0.0
100
+ for seq, intended, umi in zip(off["Insertion_Site_Sequence"], off["Plasmid_Encoded_Sequence"],
101
+ off["UMI_Count"]):
102
+ w = float(umi)
103
+ for j in range(L):
104
+ if seq[j] == intended[j]:
105
+ match[j] += w
106
+ tot += w
107
+ cons = [m / tot for m in match]
108
+ return pd.DataFrame({"position": list(range(1, L + 1)), "conservation": cons,
109
+ "protective_weight": cons, "source": "perry2025_table_s2_measured",
110
+ "n_offtargets": len(off)})
111
+
112
+
113
+ def load_offtarget_profile(use_measured: bool = True) -> pd.DataFrame:
114
+ """Measured profile (Perry S2) if available and requested, else the literature position weights."""
115
+ if use_measured:
116
+ m = derive_measured_profile()
117
+ if not m.empty:
118
+ return m.rename(columns={"protective_weight": "_pw"}).assign(
119
+ rel_recombination=lambda d: 1 - d["_pw"]).drop(columns="_pw")
120
+ w = protective_weights()
121
+ return pd.DataFrame({"position": list(w), "rel_recombination": [1 - v for v in w.values()],
122
+ "source": "literature_position_weights"})
123
+
124
+
125
+ def load_dms() -> pd.DataFrame:
126
+ """Perry 2025 Table S3 - deep mutational scan (Position, Mutation, Z_Score_wrt_WT). Empty if absent."""
127
+ p = _perry("dms")
128
+ if p is None:
129
+ return pd.DataFrame(columns=["Position", "Mutation", "Z_Score_wrt_WT"])
130
+ df = pd.read_excel(p, sheet_name="L2FC_Relative_Z-Scores")
131
+ return df[df["Position"] != "All"].copy()
132
+
133
+
134
+ def load_screen() -> pd.DataFrame:
135
+ """Perry 2025 Table S1 - 72 bridge recombinase orthologs (Name, sequences, Target, Donor). Empty if absent."""
136
+ p = _perry("orthologs")
137
+ if p is None:
138
+ return pd.DataFrame(columns=["Name", "Recombinase_Sequence", "bRNA_Sequence", "Donor", "Target"])
139
+ return pd.read_excel(p, sheet_name="Sheet1")