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,178 @@
1
+ """Guide and attachment-site design for genome writers.
2
+
3
+ Auto-designs the targeting component each writer family needs, from the DOCUMENTED reprogramming rules:
4
+
5
+ * **bridge RNA** (IS110 / IS621 / ISCro4), a non-coding RNA with a **target-binding loop (TBL)** and a
6
+ **donor-binding loop (DBL)** that are reprogrammed independently to base-pair the target and donor, around a
7
+ conserved central **core dinucleotide** that must match between target and donor (Durrant et al., Nature 2024,
8
+ 10.1038/s41586-024-07552-4). We compute the loop guide sequences (reverse-complement of the specificity arms,
9
+ core preserved) and validate by round-trip recovery.
10
+ * **pegRNA + attB** (PASTE / PASSIGE), a prime-editing guide whose 3' extension WRITES a serine-integrase
11
+ attachment site at the nick, so the integrase can then place large cargo (Yarnall et al., Nat Biotechnol 2023,
12
+ 10.1038/s41587-022-01527-4; Pandey/Liu, Nat Biomed Eng 2025). We write the **REAL documented Bxb1 minimal
13
+ attB** (FlyBase FBto0000359; Ghosh, Kim & Hatfull, Mol Cell 2003, 10.1016/S1097-2765(03)00444-1), not a
14
+ schematic, with the 8-bp common core **GCGGTCTC** around the central **GT** crossover dinucleotide.
15
+ * **orthogonal att-pair selection**, serine-integrase att sites with distinct central dinucleotides
16
+ (e.g. **GA vs GT**) recombine only with their cognate partner, enabling multiplexed/orthogonal landing pads
17
+ (Roelle, Kamath & Matreyek, ACS Synth Biol 2023, 10.1021/acssynbio.3c00355).
18
+
19
+ These are GROUNDED design heuristics from the published rules, auto-designed guides are **candidates** that
20
+ require empirical validation; nothing about activity is claimed. Recovery tests check the logic reproduces known
21
+ constructs (round-trip + documented invariants), never that a designed guide "works".
22
+ """
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass
26
+
27
+ _COMP = {"A": "T", "T": "A", "G": "C", "C": "G", "N": "N",
28
+ "a": "t", "t": "a", "g": "c", "c": "g", "n": "n"}
29
+
30
+ # documented serine-integrase attachment-site CENTRAL DINUCLEOTIDES (the recombination crossover core)
31
+ _INTEGRASE_CORE = {
32
+ "Bxb1": "GT", # canonical Bxb1 att central dinucleotide (the recombined 2 bp)
33
+ "PhiC31": "TT", # PhiC31 att core
34
+ }
35
+ # orthogonal Bxb1 att variants by engineered central dinucleotide (Roelle 2023), matched cores recombine,
36
+ # mismatched cores are ~orthogonal.
37
+ _ORTHOGONAL_BXB1_CORES = ["GT", "GA", "GC", "CT", "TA"]
38
+
39
+ # REAL, documented serine-integrase attachment sites (verbatim, never fabricated). Bxb1: FlyBase FBto0000359
40
+ # (attB) / FBto0000358 (attP); Ghosh, Kim & Hatfull, Mol Cell 2003 (10.1016/S1097-2765(03)00444-1), attB/attP
41
+ # share the 8-bp common core GCGGTCTC around the central GT crossover dinucleotide (the sole determinant of
42
+ # integration orientation). Only integrases with a documented site bundled here get a full attB written.
43
+ _INTEGRASE_ATT = {
44
+ "Bxb1": {
45
+ "attB": "TCGGCCGGCTTGTCGACGACGGCGGTCTCCGTCGTCAGGATCATCCGGGC",
46
+ "attP": "GTCGTGGTTTGTCTGGTCAACCACCGCGGTCTCAGTGGTGTACGGTACAAACCCCGAC",
47
+ "core": "GCGGTCTC", "central_dinucleotide": "GT",
48
+ "source": "FlyBase FBto0000359/358; Ghosh, Kim & Hatfull, Mol Cell 2003",
49
+ "doi": "10.1016/S1097-2765(03)00444-1",
50
+ },
51
+ }
52
+
53
+
54
+ def revcomp(seq: str) -> str:
55
+ return "".join(_COMP.get(b, "N") for b in reversed(seq))
56
+
57
+
58
+ @dataclass
59
+ class BridgeRNADesign:
60
+ target: str
61
+ donor: str
62
+ core: str
63
+ target_binding_loop: str # TBL guide (base-pairs the target arms)
64
+ donor_binding_loop: str # DBL guide (base-pairs the donor arms)
65
+ core_matched: bool
66
+ output_kind: str = "candidate"
67
+ note: str = ""
68
+
69
+
70
+ def design_bridge_rna(target_seq: str, donor_seq: str, core_len: int = 2) -> BridgeRNADesign:
71
+ """Design an IS110/IS621 bridge-RNA's two reprogrammable loops for a `(target, donor)` pair.
72
+
73
+ The bipartite target/donor each carry a central core (default 2 nt); the loops are reprogrammed to base-pair
74
+ the flanking specificity arms (reverse-complement). The bridge mechanism REQUIRES the target and donor cores
75
+ to match, flagged when they do not (the design is then infeasible).
76
+ """
77
+ t, d = target_seq.upper(), donor_seq.upper()
78
+ mid_t, mid_d = len(t) // 2, len(d) // 2
79
+ core_t = t[mid_t - core_len // 2: mid_t - core_len // 2 + core_len]
80
+ core_d = d[mid_d - core_len // 2: mid_d - core_len // 2 + core_len]
81
+ return BridgeRNADesign(
82
+ target=t, donor=d, core=core_t,
83
+ target_binding_loop=revcomp(t), # the guide loop base-pairs the target (specificity arms reprogrammed)
84
+ donor_binding_loop=revcomp(d),
85
+ core_matched=bool(core_t == core_d),
86
+ note=("bridge RNA loops reprogrammed to the target/donor; core dinucleotide "
87
+ + ("matched (feasible)" if core_t == core_d else f"MISMATCH ({core_t} vs {core_d}) -> infeasible, "
88
+ "the IS110/IS621 mechanism requires matching target/donor cores (Durrant 2024)")))
89
+
90
+
91
+ def recover_bridge_rna(target_seq: str, donor_seq: str) -> bool:
92
+ """Round-trip recovery: the TBL must reverse-complement back to the target (and DBL to the donor), the
93
+ documented reprogramming invariant. Deterministic logic check, not an activity claim."""
94
+ des = design_bridge_rna(target_seq, donor_seq)
95
+ return revcomp(des.target_binding_loop) == target_seq.upper() and \
96
+ revcomp(des.donor_binding_loop) == donor_seq.upper()
97
+
98
+
99
+ @dataclass
100
+ class PegRNAAttDesign:
101
+ integrase: str
102
+ att_core: str
103
+ target_site: str
104
+ pegrna_spacer: str # the spacer (protospacer) for the prime-edit nick
105
+ pe_3prime_extension: str # the 3' extension encoding the attB to be written at the nick
106
+ written_att: str
107
+ output_kind: str = "candidate"
108
+ note: str = ""
109
+
110
+
111
+ def design_pegrna_attb(target_site: str, integrase: str = "Bxb1") -> PegRNAAttDesign:
112
+ """Design a PASTE/PASSIGE pegRNA that writes a serine-integrase **attB** at `target_site`.
113
+
114
+ The prime-edit installs the integrase's minimal attB; the integrase then recombines cargo flanked by the
115
+ cognate attP. We return the spacer + the 3' extension (revcomp of the att template, the PE convention) and the
116
+ att site written. For integrases with a documented site bundled (`_INTEGRASE_ATT`, e.g. Bxb1) the REAL minimal
117
+ attB is written verbatim, never a schematic; integrases without a bundled documented site expose only the
118
+ central core (the full sequence is NOT fabricated). Sequences are DESIGN CANDIDATES, empirical validation
119
+ required.
120
+ """
121
+ integrase = integrase if integrase in _INTEGRASE_CORE else "Bxb1"
122
+ core = _INTEGRASE_CORE[integrase]
123
+ ts = target_site.upper()
124
+ spacer = ts[:20] if len(ts) >= 20 else ts
125
+ att = _INTEGRASE_ATT.get(integrase)
126
+ if att:
127
+ attb = att["attB"] # REAL documented minimal attB (verbatim), no schematic arms
128
+ note = (f"pegRNA 3' extension writes the REAL documented {integrase} minimal attB ({len(attb)} bp; central "
129
+ f"crossover {att['central_dinucleotide']}, 8-bp common core {att['core']}; {att['source']}) at the "
130
+ f"nick; cargo is delivered flanked by the cognate attP. Candidate scaffold, empirical validation "
131
+ "required (Yarnall 2023).")
132
+ else:
133
+ attb = core # no bundled documented site: expose the core only, never fabricate
134
+ note = (f"no bundled documented minimal att sequence for {integrase!r}: only the central core {core} is "
135
+ "asserted (the full site is NOT fabricated). Add a documented attB to design the full scaffold.")
136
+ return PegRNAAttDesign(
137
+ integrase=integrase, att_core=core, target_site=ts, pegrna_spacer=spacer,
138
+ pe_3prime_extension=revcomp(attb), written_att=attb, note=note)
139
+
140
+
141
+ def select_orthogonal_att_pairs(n: int, integrase: str = "Bxb1") -> dict:
142
+ """Select up to `n` mutually-orthogonal serine-integrase att pairs by distinct central dinucleotide
143
+ (Roelle 2023): matched cores recombine, mismatched cores are ~orthogonal -> usable for multiplexed landing
144
+ pads. Returns the selected cores + a cross-reactivity matrix (1 on the diagonal = cognate, ~0 off-diagonal)."""
145
+ cores = _ORTHOGONAL_BXB1_CORES[:max(1, min(n, len(_ORTHOGONAL_BXB1_CORES)))]
146
+ matrix = {a: {b: (1 if a == b else 0) for b in cores} for a in cores}
147
+ return {"integrase": integrase, "selected_cores": cores, "n": len(cores),
148
+ "cross_reactivity": matrix, "output_kind": "candidate",
149
+ "orthogonal": all(matrix[a][b] == 0 for a in cores for b in cores if a != b),
150
+ "note": "orthogonality by central-dinucleotide identity (Roelle, Kamath & Matreyek, ACS Synth Biol "
151
+ "2023); cores must be empirically confirmed orthogonal in the target context.",
152
+ "capped": n > len(_ORTHOGONAL_BXB1_CORES)}
153
+
154
+
155
+ def design_guide_for_writer(writer_family: str, target_seq: str | None = None,
156
+ donor_seq: str | None = None, integrase: str = "Bxb1") -> dict:
157
+ """Dispatch to the right guide design for a writer family. Returns a candidate design + the design type, or an
158
+ abstention when required inputs are missing or the family has no programmable-guide design here."""
159
+ fam = (writer_family or "").lower()
160
+ if "bridge" in fam or "is110" in fam or "is621" in fam or "seek" in fam:
161
+ if not (target_seq and donor_seq):
162
+ return {"design_type": "bridge_rna", "available": False,
163
+ "reason": "bridge-RNA design needs both target and donor sequences"}
164
+ d = design_bridge_rna(target_seq, donor_seq)
165
+ return {"design_type": "bridge_rna", "available": True, "design": d.__dict__,
166
+ "feasible": d.core_matched}
167
+ if "pe_integrase" in fam or "paste" in fam or "passige" in fam or "serine" in fam:
168
+ if not target_seq:
169
+ return {"design_type": "pegrna_attb", "available": False,
170
+ "reason": "pegRNA+attB design needs a target site sequence"}
171
+ d = design_pegrna_attb(target_seq, integrase=integrase)
172
+ has_full_att = d.written_att != d.att_core # a full documented attB vs core-only (the site is never fabricated)
173
+ return {"design_type": "pegrna_attb", "available": True, "design": d.__dict__,
174
+ "has_full_att": has_full_att,
175
+ # real per-input feasibility: a spacer needs >=20 nt and we must hold the documented att verbatim
176
+ "feasible": bool(len(target_seq) >= 20 and has_full_att)}
177
+ return {"design_type": None, "available": False,
178
+ "reason": f"no programmable-guide design for family '{writer_family}' (e.g. fixed-att or DSB nuclease)"}
@@ -0,0 +1,59 @@
1
+ """WT-KB schema - the Writer-Targeting Knowledge Base row model.
2
+
3
+ One row per writer family/representative system: its targeting requirements and a reachability tier.
4
+ This is the spine of the Writer Atlas and the reachability layer of the Writable Genome. Every
5
+ targeting field must carry at least one DOI in ``key_dois`` - nothing is asserted without a citation.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from enum import Enum
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
12
+
13
+
14
+ class MechBucket(str, Enum):
15
+ DSB_NUCLEASE = "DSB_NUCLEASE"
16
+ DSB_FREE_RECOMBINASE = "DSB_FREE_TRANSEST_RECOMBINASE"
17
+ TRANSPOSASE = "TRANSPOSASE"
18
+
19
+
20
+ class Tier(str, Enum):
21
+ T1 = "Tier1_scannable" # bridge/seek cores, PE-installable att
22
+ T2 = "Tier2_context_candidate" # CAST, native pseudo-att integrases (candidate - requires validation)
23
+ T3 = "Tier3_not_predictable" # retroelement preferences
24
+
25
+
26
+ class Confidence(str, Enum):
27
+ MEASURED = "measured"
28
+ INFERRED = "inferred"
29
+ PREDICTED = "predicted"
30
+
31
+
32
+ class WriterEntry(BaseModel):
33
+ model_config = ConfigDict(use_enum_values=True)
34
+
35
+ family: str
36
+ representative_system: str
37
+ uniprot: str | None = None
38
+ mechanism_bucket: MechBucket
39
+ pfam_signature: list[str]
40
+ targeting_modality: str # RNA-guided | fixed-att | DDE-spacing | PE-installable
41
+ target_site_spec: str # e.g. "bipartite ~14 nt, central CT dinucleotide core"
42
+ guide_architecture: str # e.g. "bridge RNA: TBL(LTG/RTG)+DBL(LDG/RDG)"
43
+ cargo_mechanism: str # intrinsic | fixed-donor | templated
44
+ cargo_capacity_bp: int | None = None
45
+ dsb_free: bool
46
+ length_aa: int | None = None
47
+ human_cell_activity: str | None = None # measured value + source, or "not measured"
48
+ deliverability: str # AAV | split-AAV | mRNA-RNP
49
+ reachability_tier: Tier
50
+ reachability_constraints: str # rules a genome scan must apply
51
+ confidence: Confidence = Confidence.MEASURED
52
+ key_dois: list[str] = Field(min_length=1)
53
+
54
+ @field_validator("key_dois")
55
+ @classmethod
56
+ def _nonempty_dois(cls, v: list[str]) -> list[str]:
57
+ if not v or not all(d.strip() for d in v):
58
+ raise ValueError("every WT-KB row must carry >=1 non-empty DOI (sourcing rule)")
59
+ return v
@@ -0,0 +1,134 @@
1
+ """Descriptive writer scorecard.
2
+
3
+ Reframes the prior 5-gate "TRUE_WRITER certification" (circular - it pre-registered ISCro4 *by name*
4
+ and depended on hand-set scores) into a transparent, DESCRIPTIVE scorecard computed from the
5
+ re-grounded axes. No enzyme is named in any pre-registered prediction. We additionally report a
6
+ *blind concordance* outcome: does the ranking place ISCro4 at the top of the bridge family using only
7
+ generic measured axes (cell-based evidence, DSB-freeness, programmability, cargo) - without any
8
+ ISCro4-specific value being asserted? This is reported, never asserted as an input.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ import numpy as np
15
+ import pandas as pd
16
+ import yaml
17
+
18
+ _AXES_CFG = Path(__file__).resolve().parents[2] / "configs" / "score_axes.yaml"
19
+
20
+ _EVIDENCE_COLS = ["has_biochemical", "has_structural", "has_computational", "has_cell_based"]
21
+
22
+ # descriptive tier labels (NOT a certification; no enzyme is pre-named)
23
+ T_DSB_DEPENDENT = "DSB_dependent" # fails the necessary DSB-free gate (a "scissor")
24
+ T_EMERGING = "emerging_writer"
25
+ T_PROBABLE = "probable_writer"
26
+ T_ESTABLISHED = "established_writer" # DSB-free + fully programmable + native cargo + human-cell evidence
27
+
28
+
29
+ def _thresholds(path: Path = _AXES_CFG) -> dict:
30
+ """Read the gate thresholds (defined on the RE-GROUNDED axis scales) from score_axes.yaml."""
31
+ if Path(path).exists():
32
+ g = yaml.safe_load(Path(path).read_text(encoding="utf-8")).get("gates_v3_0", {})
33
+ return {
34
+ "g1": g.get("g1_dsb_min", 0.95),
35
+ "g2": g.get("g2_prog_min", 0.95),
36
+ "g3": g.get("g3_cargo_min", 0.65),
37
+ "g4": g.get("g4_max_length_aa", 900),
38
+ "g5": g.get("g5_min_evidence", 2),
39
+ }
40
+ return {"g1": 0.95, "g2": 0.95, "g3": 0.65, "g4": 900, "g5": 2}
41
+
42
+
43
+ def _evidence_count(row) -> int:
44
+ return int(sum(bool(row.get(c, False)) for c in _EVIDENCE_COLS))
45
+
46
+
47
+ def _gates(row, th) -> dict:
48
+ g1 = float(row.get("s_dsb", 0) or 0) >= th["g1"]
49
+ g2 = float(row.get("S_Prog", 0) or 0) >= th["g2"]
50
+ g3 = (float(row.get("S_Cargo", 0) or 0) >= th["g3"]) and bool(row.get("intrinsic_cargo_mechanism", False))
51
+ length = row.get("length_aa")
52
+ g4 = (length is not None and not pd.isna(length) and float(length) <= th["g4"])
53
+ g5 = _evidence_count(row) >= th["g5"]
54
+ return {"g1": g1, "g2": g2, "g3": g3, "g4": g4, "g5": g5}
55
+
56
+
57
+ def _descriptive_tier(row, th) -> str:
58
+ g = _gates(row, th)
59
+ if not g["g1"]:
60
+ return T_DSB_DEPENDENT # necessary gate (DSB-free) failed
61
+ qualifying = sum([g["g2"], g["g3"], g["g4"], g["g5"]])
62
+ has_cell = bool(row.get("has_cell_based", False))
63
+ if qualifying == 4 and has_cell:
64
+ return T_ESTABLISHED
65
+ if qualifying == 4 or (qualifying == 3 and has_cell):
66
+ return T_PROBABLE
67
+ if qualifying >= 1:
68
+ return T_EMERGING
69
+ return T_DSB_DEPENDENT
70
+
71
+
72
+ def composite(row) -> float:
73
+ """Transparent composite; components stay visible on the scorecard.
74
+
75
+ Includes human-cell evidence (``has_cell_based``) as a generic readiness axis - this is the
76
+ signal that distinguishes the standout human-cell bridge recombinase. It is a generic column
77
+ present for every editor, NOT an ISCro4-specific asserted value (so the concordance stays blind).
78
+ """
79
+ parts = [
80
+ float(row.get("s_dsb", 0) or 0),
81
+ float(row.get("S_Prog", 0) or 0),
82
+ float(row.get("S_Cargo", 0) or 0),
83
+ _evidence_count(row) / 4.0,
84
+ float(bool(row.get("has_cell_based", False))),
85
+ ]
86
+ return float(np.mean(parts))
87
+
88
+
89
+ def scorecard(universe_df: pd.DataFrame) -> pd.DataFrame:
90
+ th = _thresholds()
91
+ df = universe_df.copy()
92
+ df["evidence_count"] = df.apply(_evidence_count, axis=1)
93
+ df["S_composite"] = df.apply(composite, axis=1)
94
+ df["tier"] = df.apply(lambda r: _descriptive_tier(r, th), axis=1)
95
+ return df.sort_values("S_composite", ascending=False).reset_index(drop=True)
96
+
97
+
98
+ def blind_concordance(scorecard_df: pd.DataFrame, family: str = "bridge_IS110",
99
+ expected_top: str = "ISCro4") -> dict:
100
+ """Report (do NOT assert) whether the ranking places `expected_top` first within `family`,
101
+ using only generic measured axes. Returns the observed top + whether it matches.
102
+ Restricted to NATURAL editors (the concordance question is about natural systems)."""
103
+ sub = scorecard_df.query("family == @family")
104
+ if "source" in sub.columns:
105
+ sub = sub[sub["source"] == "natural"]
106
+ sub = sub.sort_values("S_composite", ascending=False)
107
+ if sub.empty:
108
+ return {"family": family, "top": None, "matches": False, "n": 0}
109
+ top = sub.iloc[0]["entity_id"]
110
+ return {"family": family, "top": top, "matches": (top == expected_top), "n": int(len(sub)),
111
+ "ranking": sub[["entity_id", "S_composite", "evidence_count"]].to_dict("records")}
112
+
113
+
114
+ def ranking_stability(universe_df: pd.DataFrame, family: str = "bridge_IS110",
115
+ expected_top: str = "ISCro4", n: int = 200, seed: int = 42) -> float:
116
+ """Fraction of randomly re-weighted composites under which `expected_top` stays family-top
117
+ (a lightweight sensitivity check, mirroring the prior ranking-stability analysis)."""
118
+ rng = np.random.default_rng(seed)
119
+ sub = universe_df.query("family == @family").copy()
120
+ if "source" in sub.columns:
121
+ sub = sub[sub["source"] == "natural"]
122
+ if sub.empty:
123
+ return 0.0
124
+ cols = ["s_dsb", "S_Prog", "S_Cargo"]
125
+ ev = sub.apply(_evidence_count, axis=1).to_numpy() / 4.0
126
+ cell = sub.get("has_cell_based", pd.Series([False] * len(sub))).fillna(False).astype(float).to_numpy()
127
+ X = np.column_stack([sub[c].fillna(0).astype(float).to_numpy() for c in cols] + [ev, cell])
128
+ wins = 0
129
+ for _ in range(n):
130
+ w = rng.dirichlet(np.ones(X.shape[1]))
131
+ scores = X @ w
132
+ if sub.iloc[int(scores.argmax())]["entity_id"] == expected_top:
133
+ wins += 1
134
+ return wins / n
Binary file
@@ -0,0 +1,75 @@
1
+ """Canonical universe assembly.
2
+
3
+ THE single entry point that joins the upstream editor universe + the WT-KB + the crosswalk and
4
+ applies the re-grounded axes. The classifier, the scorer, and the scorecard must all consume the
5
+ output of ``assemble()`` - never re-derive metadata independently (the prior gate inconsistency between the discovery and comparison paths
6
+ must not recur). Cross-module consistency is asserted by
7
+ ``tests/unit/test_universe_consistency.py``.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ import pandas as pd
14
+ import yaml
15
+
16
+ from pen_stack.score.recalibrate import load_axes_config, recalibrate_all
17
+
18
+ _ROOT = Path(__file__).resolve().parents[2]
19
+ _UNIVERSE = _ROOT / "data" / "curated" / "unified_editor_universe.parquet"
20
+ _WTKB = _ROOT / "pen_stack" / "atlas" / "wtkb.parquet"
21
+ _CROSSWALK = _ROOT / "configs" / "universe_crosswalk.yaml"
22
+
23
+
24
+ def _load_crosswalk(path: Path = _CROSSWALK) -> pd.DataFrame:
25
+ cw = yaml.safe_load(path.read_text(encoding="utf-8"))["entity_to_family"]
26
+ return pd.DataFrame(
27
+ [{"entity_id": k, "family": v["family"], "targeting_modality": v["targeting_modality"]}
28
+ for k, v in cw.items()]
29
+ )
30
+
31
+
32
+ def assemble(
33
+ universe_parquet: str | Path = _UNIVERSE,
34
+ wtkb_parquet: str | Path = _WTKB,
35
+ crosswalk_path: str | Path = _CROSSWALK,
36
+ out_parquet: str | Path | None = None,
37
+ ) -> pd.DataFrame:
38
+ uni = pd.read_parquet(universe_parquet)
39
+ wt = pd.read_parquet(wtkb_parquet)
40
+ cw = _load_crosswalk(Path(crosswalk_path))
41
+
42
+ # 1) attach family + modality to natural editors via the crosswalk
43
+ uni = uni.merge(cw, on="entity_id", how="left")
44
+
45
+ # 2) designs inherit their parent_editor's family + modality
46
+ if "parent_editor" in uni.columns:
47
+ parent_map = cw.set_index("entity_id")[["family", "targeting_modality"]]
48
+ need = uni["family"].isna() & uni["parent_editor"].notna()
49
+ for col in ("family", "targeting_modality"):
50
+ uni.loc[need, col] = uni.loc[need, "parent_editor"].map(parent_map[col])
51
+
52
+ # 3) bring WT-KB measured fields (cargo bp, reachability tier, dsb_free) in by family - single source
53
+ wt_fields = wt[["family", "cargo_capacity_bp", "reachability_tier", "dsb_free"]].drop_duplicates("family")
54
+ uni = uni.merge(wt_fields, on="family", how="left")
55
+
56
+ # 4) apply the re-grounded axes (length backfill + cargo + prog); NO per-enzyme overrides
57
+ uni = recalibrate_all(uni, load_axes_config())
58
+
59
+ if out_parquet:
60
+ Path(out_parquet).parent.mkdir(parents=True, exist_ok=True)
61
+ uni.to_parquet(out_parquet, index=False)
62
+ return uni
63
+
64
+
65
+ # Axis/gate inputs that every downstream module must read from the canonical universe (not re-derive).
66
+ CANONICAL_INPUTS = [
67
+ "entity_id", "source", "mechanism_class", "s_dsb", "S_Prog", "S_Cargo",
68
+ "length_aa", "intrinsic_cargo_mechanism", "cell_based_evidence",
69
+ "family", "targeting_modality", "reachability_tier",
70
+ ]
71
+
72
+
73
+ def canonical_inputs(df: pd.DataFrame) -> pd.DataFrame:
74
+ """The exact metadata slice the classifier/scorer/scorecard must share."""
75
+ return df[[c for c in CANONICAL_INPUTS if c in df.columns]].copy()
Binary file
@@ -0,0 +1,155 @@
1
+ """DMS-grounded variant proposal - replaces the failed de-novo chimera generation.
2
+
3
+ Instead of speculative chimeras (PEN-ASSEMBLE produced 0 TRUE_WRITERs and was HPC-hungry/unvalidatable),
4
+ propose *single/double point mutations* with a measured activity effect. **No chimeras are ever produced**
5
+ - only point substitutions.
6
+
7
+ The activity predictor is a pluggable ``VariantEffectModel``. The real model is ``DMSVariantEffectModel``,
8
+ backed by the Perry 2025 deep mutational scan of ISCro4 (Table S3): it scores each
9
+ substitution by its MEASURED activity Z-score. Fed that model, the framework's top proposals ARE the
10
+ experimentally enhancing mutations - N322P (rank 1), H50K (rank 2), R278M - so it RECOVERS the known
11
+ enhancers. Per the program's framing: this is a useful catalogue feature that recovers
12
+ KNOWN enhancers from the DMS; it is NOT a novel variant-design method and it is NOT a blind sequence-only
13
+ prediction. For GENERATING new variants the established engine is EVOLVEpro - wrap it, do not rebuild.
14
+
15
+ When the DMS is absent, a transparent physico-chemical *baseline* keeps the framework runnable (it makes
16
+ no activity claim and must never be presented as the DMS model).
17
+
18
+ Inputs : enzyme sequence; a VariantEffectModel (DMSVariantEffectModel, or the labelled baseline).
19
+ Outputs: out/variant_proposals_<enzyme>.csv (ranked point mutations + measured effect).
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from pathlib import Path
24
+ from typing import Protocol, runtime_checkable
25
+
26
+ import pandas as pd
27
+
28
+ _AA = "ACDEFGHIKLMNPQRSTVWY"
29
+ _OUT = Path(__file__).resolve().parents[2] / "out"
30
+
31
+ # Kyte-Doolittle hydropathy + a coarse charge/volume signal, for the labelled baseline ONLY.
32
+ _HYDRO = {"A": 1.8, "R": -4.5, "N": -3.5, "D": -3.5, "C": 2.5, "Q": -3.5, "E": -3.5, "G": -0.4,
33
+ "H": -3.2, "I": 4.5, "L": 3.8, "K": -3.9, "M": 1.9, "F": 2.8, "P": -1.6, "S": -0.8,
34
+ "T": -0.7, "W": -0.9, "Y": -1.3, "V": 4.2}
35
+
36
+
37
+ @runtime_checkable
38
+ class VariantEffectModel(Protocol):
39
+ """Predict a per-mutation activity gain. (i, wt, mut) -> predicted effect (higher = better)."""
40
+
41
+ name: str
42
+
43
+ def predict(self, seq: str, variants: list[tuple[int, str, str]]) -> list[float]: ...
44
+
45
+
46
+ class BaselinePhysicoChemical:
47
+ """A transparent, NON-DMS placeholder predictor (a stand-in until the real DMS model is available).
48
+
49
+ Scores a substitution by *conservativeness* (small hydropathy change ranks higher) - a deliberately
50
+ weak, documented heuristic so the proposal/validation framework is exercisable before the DMS model is available.
51
+ It makes no activity claim and must never be presented as the DMS predictor.
52
+ """
53
+
54
+ name = "baseline_physicochemical_placeholder"
55
+
56
+ def predict(self, seq: str, variants: list[tuple[int, str, str]]) -> list[float]:
57
+ return [-abs(_HYDRO.get(mut, 0.0) - _HYDRO.get(wt, 0.0)) for (_, wt, mut) in variants]
58
+
59
+
60
+ class DMSVariantEffectModel:
61
+ """The REAL model: scores a substitution by its MEASURED activity Z-score from the Perry 2025 ISCro4
62
+ deep mutational scan (Table S3). Substitutions not present in the scan get a strongly
63
+ negative score (treated as unmeasured/non-enhancing). This recovers known enhancers; it is not a blind
64
+ sequence predictor (see module docstring). Requires the Perry tables locally (PEN_PERRY_DIR)."""
65
+
66
+ name = "perry2025_dms_iscro4"
67
+
68
+ def __init__(self) -> None:
69
+ from pen_stack.bridge.ingest import load_dms
70
+ dms = load_dms()
71
+ if dms.empty:
72
+ raise FileNotFoundError("Perry 2025 DMS (Table S3) not available; set PEN_PERRY_DIR")
73
+ z = pd.to_numeric(dms["Z_Score_wrt_WT"], errors="coerce")
74
+ self._z = dict(zip(dms["Mutation"].astype(str), z))
75
+
76
+ def predict(self, seq: str, variants: list[tuple[int, str, str]]) -> list[float]:
77
+ # variant key is wt + 1-based position + mut, e.g. "N322P"
78
+ return [self._z.get(f"{wt}{i + 1}{mut}", -9.9) for (i, wt, mut) in variants]
79
+
80
+
81
+ def iscro4_sequence() -> str | None:
82
+ """ISCro4 recombinase sequence from Perry 2025 Table S1 (326 aa). None if absent."""
83
+ from pen_stack.bridge.ingest import load_screen
84
+ s1 = load_screen()
85
+ row = s1[s1["Name"].astype(str) == "ISCro4"] if not s1.empty else s1
86
+ return row.iloc[0]["Recombinase_Sequence"] if len(row) else None
87
+
88
+
89
+ def propose_variants(seq: str, model: VariantEffectModel, top: int = 20,
90
+ positions: list[int] | None = None) -> pd.DataFrame:
91
+ """Rank single point mutations by predicted activity gain. No chimeras - substitutions only."""
92
+ idxs = positions if positions is not None else range(len(seq))
93
+ cand = [(i, seq[i], aa) for i in idxs for aa in _AA if aa != seq[i]]
94
+ pred = model.predict(seq, cand)
95
+ df = pd.DataFrame({
96
+ "pos": [c[0] for c in cand],
97
+ "wt": [c[1] for c in cand],
98
+ "mut": [c[2] for c in cand],
99
+ "variant": [f"{c[1]}{c[0] + 1}{c[2]}" for c in cand], # 1-based, e.g. A123V
100
+ "pred_gain": pred,
101
+ "model": model.name,
102
+ })
103
+ return df.sort_values("pred_gain", ascending=False).head(top).reset_index(drop=True)
104
+
105
+
106
+ def retrospective_recovery(proposals: pd.DataFrame, known_variants: list[str], k: int = 20) -> dict:
107
+ """Blind-validation harness: does the top-k proposal set recover a published enhanced variant?
108
+
109
+ ``known_variants`` are 1-based strings like "A123V". Returns recovery flags per known variant and an
110
+ overall hit. With the DMS model this is the headline retrospective criterion; with the
111
+ baseline it merely demonstrates the harness runs (recovery is not expected from the placeholder).
112
+ """
113
+ topk = set(proposals.head(k)["variant"])
114
+ hits = {v: (v in topk) for v in known_variants}
115
+ return {"k": k, "model": proposals["model"].iloc[0] if len(proposals) else None,
116
+ "known": known_variants, "recovered": hits, "any_recovered": any(hits.values())}
117
+
118
+
119
+ def run(enzyme: str, seq: str, model: VariantEffectModel | None = None, top: int = 20,
120
+ out_dir: str | Path = _OUT) -> pd.DataFrame:
121
+ model = model or BaselinePhysicoChemical()
122
+ props = propose_variants(seq, model, top=top)
123
+ out = Path(out_dir) / f"variant_proposals_{enzyme}.csv"
124
+ out.parent.mkdir(parents=True, exist_ok=True)
125
+ props.to_csv(out, index=False)
126
+ return props
127
+
128
+
129
+ # published enhancing single mutations identified by the Perry 2025 ISCro4 DMS (the known enhancers)
130
+ KNOWN_ISCRO4_ENHANCERS = ["N322P", "H50K", "R278M"]
131
+
132
+
133
+ def iscro4_dms_recovery(top: int = 20, out_dir: str | Path = _OUT) -> dict:
134
+ """Feed the REAL Perry DMS model to the proposal framework and confirm it recovers
135
+ the known enhancing ISCro4 mutations in its top proposals. Framing: recovers KNOWN enhancers
136
+ (a catalogue feature), not a blind prediction. Returns the recovery report; writes the proposals CSV.
137
+ Empty/None when the Perry tables are absent."""
138
+ seq = iscro4_sequence()
139
+ if seq is None:
140
+ return {"available": False, "note": "Perry 2025 Table S1 (ISCro4 sequence) not present"}
141
+ props = run("ISCro4", seq, model=DMSVariantEffectModel(), top=top, out_dir=out_dir)
142
+ rec = retrospective_recovery(props, KNOWN_ISCRO4_ENHANCERS, k=top)
143
+ rec["available"] = True
144
+ rec["top_proposals"] = props.head(5)[["variant", "pred_gain"]].to_dict("records")
145
+ rec["framing"] = "recovers KNOWN enhancers from the measured DMS (catalogue feature); not a blind " \
146
+ "sequence predictor and not a generative method (EVOLVEpro is the engine to wrap)."
147
+ return rec
148
+
149
+
150
+ if __name__ == "__main__": # pragma: no cover
151
+ # ISCro4 is 326 aa; without the protein sequence on hand we demo the harness on a short stub.
152
+ demo = "MSEQNKI" * 5
153
+ p = run("DEMO_stub", demo, top=10)
154
+ print(p.to_string(index=False))
155
+ print("\nNOTE: uses the labelled placeholder model; the DMS-trained predictor is a planned deliverable.")