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
pen_stack/ui/app.py ADDED
@@ -0,0 +1,713 @@
1
+ """PEN-STACK - The Writable Genome | Streamlit atlas browser.
2
+
3
+ A scientific front-end over the 3M-locus Writable Genome atlas. Two core queries:
4
+ - Forward: a gene/coordinate -> is it safe + durable to WRITE here? (decomposed verdict)
5
+ - Inverse: a disease gene -> the top-N safest, most durable writable loci within a window.
6
+ Plus an atlas genome browser, the blind-validation dashboard, and cross-cell-type comparison.
7
+
8
+ Run: streamlit run pen_stack/ui/app.py
9
+ Data: set PEN_ATLAS_DIR (default: ./data or Final_Part_v3.0/phase_1/out). Needs atlas_<ct>.parquet,
10
+ gene_coords.parquet, and (optional) validation_report.json.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ from pathlib import Path
17
+
18
+ import pandas as pd
19
+ import plotly.graph_objects as go
20
+ import streamlit as st
21
+
22
+ # ----------------------------------------------------------------------------- config / theme
23
+ st.set_page_config(page_title="PEN-STACK | The Writable Genome", page_icon="",
24
+ layout="wide", initial_sidebar_state="expanded")
25
+
26
+ CSS = """
27
+ <style>
28
+ :root { --ink:#05080f; --panel:#0c1322; --line:#1c2840; --cyan:#37e6e0; --green:#3dffa2;
29
+ --amber:#ffc857; --red:#ff5d6c; --txt:#dfe9ff; --mut:#7e8db5; }
30
+ .stApp { background: radial-gradient(1200px 700px at 80% -10%, #10203f 0%, var(--ink) 55%); color:var(--txt); }
31
+ section[data-testid="stSidebar"] { background:#070c17; border-right:1px solid var(--line); }
32
+ h1,h2,h3,h4 { color:var(--txt); font-family:'Segoe UI',system-ui,sans-serif; letter-spacing:.2px; }
33
+ .mono { font-family:'JetBrains Mono','Consolas',monospace; }
34
+ .hero { font-size:2.5rem; font-weight:800; line-height:1.05;
35
+ background:linear-gradient(90deg,var(--cyan),var(--green)); -webkit-background-clip:text;
36
+ -webkit-text-fill-color:transparent; }
37
+ .sub { color:var(--mut); font-size:1.02rem; }
38
+ .card { background:linear-gradient(180deg,var(--panel),#0a1020); border:1px solid var(--line);
39
+ border-radius:16px; padding:18px 20px; box-shadow:0 0 0 1px rgba(55,230,224,.04), 0 18px 40px -28px #000; }
40
+ .kpi { font-size:2.1rem; font-weight:800; }
41
+ .kpi-l { color:var(--mut); font-size:.78rem; text-transform:uppercase; letter-spacing:.16em; }
42
+ .verdict { border-radius:16px; padding:18px 24px; font-weight:800; font-size:1.5rem; border:1px solid; }
43
+ .v-go { background:rgba(61,255,162,.08); border-color:var(--green); color:var(--green); }
44
+ .v-cau { background:rgba(255,200,87,.08); border-color:var(--amber); color:var(--amber); }
45
+ .v-no { background:rgba(255,93,108,.08); border-color:var(--red); color:var(--red); }
46
+ .badge { display:inline-block; padding:2px 10px; border:1px solid var(--line); border-radius:999px;
47
+ color:var(--cyan); font-size:.72rem; margin-right:6px; }
48
+ .stDataFrame { border:1px solid var(--line); border-radius:12px; }
49
+ hr { border-color:var(--line); }
50
+ </style>
51
+ """
52
+ st.markdown(CSS, unsafe_allow_html=True)
53
+ PLOTLY = dict(template="plotly_dark", paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
54
+ font=dict(color="#dfe9ff"), margin=dict(l=10, r=10, t=30, b=10))
55
+ CT_LABEL = {"k562": "K562 (erythroleukemia)", "hepg2": "HepG2 (hepatocellular)",
56
+ "hspc": "HSPC (CD34+ progenitor)"}
57
+
58
+
59
+ # ----------------------------------------------------------------------------- data loading
60
+ def _data_dir() -> Path:
61
+ for c in [os.environ.get("PEN_ATLAS_DIR"), "data", "Final_Part_v3.0/phase_1/out",
62
+ str(Path(__file__).resolve().parents[2] / ".." / "phase_1" / "out")]:
63
+ if c and (Path(c) / "atlas_k562.parquet").exists():
64
+ return Path(c)
65
+ return Path(os.environ.get("PEN_ATLAS_DIR", "data"))
66
+
67
+
68
+ DATA = _data_dir()
69
+ BIN_BP = 1000
70
+
71
+
72
+ @st.cache_data(show_spinner=False)
73
+ def load_atlas(ct: str) -> pd.DataFrame:
74
+ df = pd.read_parquet(DATA / f"atlas_{ct}.parquet")
75
+ for col in ("writability", "safety", "p_durable"):
76
+ df[f"{col}_pct"] = df[col].rank(pct=True)
77
+ return df
78
+
79
+
80
+ @st.cache_data(show_spinner=False)
81
+ def load_genes() -> pd.DataFrame:
82
+ for p in [DATA / "gene_coords.parquet", DATA.parent / "app_data" / "gene_coords.parquet"]:
83
+ if p.exists():
84
+ return pd.read_parquet(p)
85
+ return pd.DataFrame(columns=["chrom", "start", "end", "strand", "gene"])
86
+
87
+
88
+ @st.cache_data(show_spinner=False)
89
+ def load_validation() -> dict | None:
90
+ p = DATA / "validation_report.json"
91
+ return json.loads(p.read_text()) if p.exists() else None
92
+
93
+
94
+ @st.cache_data(show_spinner=False)
95
+ def load_writer_atlas() -> pd.DataFrame:
96
+ """Writer Atlas (33k systems x measured axes). Ships inside the package."""
97
+ p = Path(__file__).resolve().parents[1] / "atlas" / "atlas.parquet"
98
+ return pd.read_parquet(p) if p.exists() else pd.DataFrame()
99
+
100
+
101
+ def region_bins(df, chrom, start, end):
102
+ return df[(df.chrom == chrom) & (df.bin * BIN_BP >= start) & (df.bin * BIN_BP <= end)]
103
+
104
+
105
+ def verdict(writ_pct, safety_pct):
106
+ if safety_pct < 0.15 or writ_pct < 0.20:
107
+ return "AVOID - high genotoxic risk / poor durability", "v-no"
108
+ if writ_pct < 0.55:
109
+ return "CAUTION - sub-optimal; consider nearby alternatives", "v-cau"
110
+ return "WRITABLE - safe & durable insertion locus", "v-go"
111
+
112
+
113
+ # ----------------------------------------------------------------------------- viz helpers
114
+ def gauge(value, title, color):
115
+ fig = go.Figure(go.Indicator(
116
+ mode="gauge+number", value=value * 100, number={"suffix": "%", "font": {"size": 34}},
117
+ title={"text": title, "font": {"size": 14}},
118
+ gauge={"axis": {"range": [0, 100], "tickcolor": "#7e8db5"},
119
+ "bar": {"color": color}, "bgcolor": "rgba(0,0,0,0)",
120
+ "borderwidth": 1, "bordercolor": "#1c2840",
121
+ "steps": [{"range": [0, 20], "color": "rgba(255,93,108,.18)"},
122
+ {"range": [20, 55], "color": "rgba(255,200,87,.14)"},
123
+ {"range": [55, 100], "color": "rgba(61,255,162,.14)"}]}))
124
+ fig.update_layout(height=230, **PLOTLY)
125
+ return fig
126
+
127
+
128
+ def track_fig(sub, center=None):
129
+ fig = go.Figure()
130
+ x = sub.bin * BIN_BP / 1e6
131
+ fig.add_trace(go.Scatter(x=x, y=sub.writability, name="writability", mode="lines",
132
+ line=dict(color="#3dffa2", width=1.5), fill="tozeroy",
133
+ fillcolor="rgba(61,255,162,.12)"))
134
+ fig.add_trace(go.Scatter(x=x, y=sub.safety, name="safety", mode="lines",
135
+ line=dict(color="#37e6e0", width=1)))
136
+ fig.add_trace(go.Scatter(x=x, y=sub.p_durable, name="durability", mode="lines",
137
+ line=dict(color="#ffc857", width=1)))
138
+ if center is not None:
139
+ fig.add_vline(x=center / 1e6, line_color="#ff5d6c", line_dash="dot")
140
+ fig.update_layout(height=300, xaxis_title="position (Mb)", yaxis_title="score",
141
+ legend=dict(orientation="h", y=1.15), **PLOTLY)
142
+ return fig
143
+
144
+
145
+ # ----------------------------------------------------------------------------- sidebar
146
+ st.sidebar.markdown("## PEN-STACK")
147
+ st.sidebar.caption("The Writable Genome | v0.1.0")
148
+ page = st.sidebar.radio("Navigate", ["Overview", "Forward query", "Site finder (inverse)",
149
+ "Atlas browser", "Validation", "Cross-cell-type",
150
+ "Writer Atlas", "Write Planner", "Bridge design", "Guide QC",
151
+ "Cargo Polish", "Multiplex risk", "Verify", "PEN-Agent",
152
+ "Genome-Writing Bench", "Ask (RAG)", "Agent"])
153
+ st.sidebar.caption("Includes Cargo Polish, Multiplex risk, Guide QC, the grounded PEN-Agent, and the "
154
+ "Genome-Writing Bench.")
155
+ _available_cts = sorted(p.stem.replace("atlas_", "") for p in DATA.glob("atlas_*.parquet")
156
+ if p.stem.replace("atlas_", "") in CT_LABEL) or ["k562"]
157
+ ct = st.sidebar.selectbox("Cell type", _available_cts, format_func=lambda c: CT_LABEL.get(c, c.upper()))
158
+ st.sidebar.markdown("---")
159
+ st.sidebar.caption("Writability = **safety x durability x reachability**, learned blind on public data.")
160
+ if not (DATA / "atlas_k562.parquet").exists():
161
+ st.sidebar.error(f"Atlas not found in {DATA}. Set PEN_ATLAS_DIR.")
162
+ st.stop()
163
+
164
+ genes = load_genes()
165
+
166
+
167
+ def gene_row(name):
168
+ r = genes[genes.gene.str.upper() == name.strip().upper()]
169
+ return None if r.empty else r.iloc[0]
170
+
171
+
172
+ # ----------------------------------------------------------------------------- pages
173
+ if page == "Overview":
174
+ st.markdown('<div class="hero">The Writable Genome</div>', unsafe_allow_html=True)
175
+ st.markdown('<p class="sub">A predictive, writer-aware atlas of <b>where in the genome you can '
176
+ 'safely and durably write new DNA</b> - and which enzyme can write it there.</p>',
177
+ unsafe_allow_html=True)
178
+ df = load_atlas(ct)
179
+ c = st.columns(4)
180
+ kpis = [("loci scored", f"{len(df):,}"), ("cell type", ct.upper()),
181
+ ("mean writability", f"{df.writability.mean():.2f}"),
182
+ ("median safety", f"{df.safety.median():.2f}")]
183
+ for col, (lab, val) in zip(c, kpis):
184
+ col.markdown(f'<div class="card"><div class="kpi-l">{lab}</div>'
185
+ f'<div class="kpi mono">{val}</div></div>', unsafe_allow_html=True)
186
+ st.markdown("####")
187
+ left, right = st.columns([2, 1])
188
+ with left:
189
+ st.markdown("##### Genome-wide writability distribution")
190
+ h = go.Figure(go.Histogram(x=df.writability, nbinsx=60, marker_color="#37e6e0"))
191
+ h.update_layout(height=320, xaxis_title="writability score", yaxis_title="loci", **PLOTLY)
192
+ st.plotly_chart(h, use_container_width=True)
193
+ with right:
194
+ st.markdown("##### Three learned layers")
195
+ st.markdown('<div class="card mono">'
196
+ '<span class="badge">SAFETY</span> genotoxicity risk<br>'
197
+ '<span style="color:#7e8db5">COSMIC | DepMap | 3.7M MLV sites</span><br><br>'
198
+ '<span class="badge">DURABILITY</span> will it stay expressed<br>'
199
+ '<span style="color:#7e8db5">TRIP position-effect model</span><br><br>'
200
+ '<span class="badge">REACHABILITY</span> which writer reaches it<br>'
201
+ '<span style="color:#7e8db5">Writer-Targeting KB (8 families)</span></div>',
202
+ unsafe_allow_html=True)
203
+ v = load_validation()
204
+ if v:
205
+ st.markdown("##### Blind validation - all pre-registered checks")
206
+ cols = st.columns(len(v.get("prereg_checks", {})) or 1)
207
+ for col, (k, ok) in zip(cols, v.get("prereg_checks", {}).items()):
208
+ col.markdown(f'<div class="card"><div class="kpi-l">{k}</div>'
209
+ f'<div class="kpi" style="color:{"#3dffa2" if ok else "#ff5d6c"}">'
210
+ f'{"PASS" if ok else "FAIL"}</div></div>', unsafe_allow_html=True)
211
+
212
+ elif page == "Forward query":
213
+ st.markdown("### Forward query - *is it safe to write here?*")
214
+ df = load_atlas(ct)
215
+ c1, c2, c3 = st.columns([2, 1, 1])
216
+ q = c1.text_input("Gene symbol or coordinate (chr:pos)", "AAVS1")
217
+ win = c2.number_input("window (kb)", 1, 200, 20)
218
+ go_btn = c3.button("Evaluate", type="primary", use_container_width=True)
219
+ alias = {"AAVS1": "PPP1R12C"}
220
+ if go_btn or q:
221
+ chrom = start = end = None
222
+ if ":" in q:
223
+ chrom, pos = q.split(":")[0], int(q.split(":")[1].replace(",", ""))
224
+ start, end = pos - win * 1000, pos + win * 1000
225
+ else:
226
+ gr = gene_row(alias.get(q.strip().upper(), q))
227
+ if gr is not None:
228
+ chrom, start, end = gr.chrom, gr.start - win * 1000, gr.end + win * 1000
229
+ if chrom is None:
230
+ st.warning("Gene/coordinate not found.")
231
+ else:
232
+ sub = region_bins(df, chrom, max(0, start), end)
233
+ if sub.empty:
234
+ st.warning("No atlas bins in that region.")
235
+ else:
236
+ wr, sf, du = sub.writability.mean(), sub.safety.mean(), sub.p_durable.mean()
237
+ wrp = float((df.writability < wr).mean())
238
+ sfp = float((df.safety < sf).mean())
239
+ msg, cls = verdict(wrp, sfp)
240
+ st.markdown(f'<div class="verdict {cls}">{msg}</div>', unsafe_allow_html=True)
241
+ st.caption(f"{chrom}:{max(0,start):,}-{end:,} | {CT_LABEL[ct]} | {len(sub)} loci")
242
+ g = st.columns(3)
243
+ g[0].plotly_chart(gauge(wr, "Writability", "#3dffa2"), use_container_width=True)
244
+ g[1].plotly_chart(gauge(sf, "Safety", "#37e6e0"), use_container_width=True)
245
+ g[2].plotly_chart(gauge(du, "Durability", "#ffc857"), use_container_width=True)
246
+ st.markdown("##### Local writability landscape")
247
+ st.plotly_chart(track_fig(sub, center=(start + end) // 2), use_container_width=True)
248
+ st.markdown('<span class="badge">reachable writers</span> '
249
+ f'<span class="mono">{sub.reachable_tier1.iloc[0]}</span> '
250
+ '<span style="color:#7e8db5">(Tier-1, locus-level)</span>',
251
+ unsafe_allow_html=True)
252
+
253
+ elif page == "Site finder (inverse)":
254
+ st.markdown("### Site finder - *the safest writable loci near a target*")
255
+ df = load_atlas(ct)
256
+ c1, c2, c3, c4 = st.columns([2, 1, 1, 1])
257
+ gname = c1.text_input("Disease / target gene", "HBB")
258
+ span = c2.number_input("search +/- (Mb)", 0.1, 5.0, 1.0)
259
+ topn = c3.number_input("top N", 5, 200, 50)
260
+ find = c4.button("Find sites", type="primary", use_container_width=True)
261
+ if find or gname:
262
+ gr = gene_row(gname)
263
+ if gr is None:
264
+ st.warning("Gene not found.")
265
+ else:
266
+ lo, hi = gr.start - int(span * 1e6), gr.end + int(span * 1e6)
267
+ sub = region_bins(df, gr.chrom, max(0, lo), hi).copy()
268
+ top = sub.nlargest(int(topn), "writability")
269
+ st.caption(f"{gname} ({gr.chrom}:{gr.start:,}) | searching +/- {span} Mb | {len(sub)} loci scanned")
270
+ k = st.columns(3)
271
+ k[0].markdown(f'<div class="card"><div class="kpi-l">candidate loci</div>'
272
+ f'<div class="kpi mono">{len(sub):,}</div></div>', unsafe_allow_html=True)
273
+ k[1].markdown(f'<div class="card"><div class="kpi-l">best writability</div>'
274
+ f'<div class="kpi mono" style="color:#3dffa2">{top.writability.max():.2f}</div></div>',
275
+ unsafe_allow_html=True)
276
+ k[2].markdown(f'<div class="card"><div class="kpi-l">target locus writability</div>'
277
+ f'<div class="kpi mono">{sub[(sub.bin*BIN_BP>=gr.start)&(sub.bin*BIN_BP<=gr.end)].writability.mean():.2f}'
278
+ '</div></div>', unsafe_allow_html=True)
279
+ fig = go.Figure()
280
+ fig.add_trace(go.Scatter(x=sub.bin * BIN_BP / 1e6, y=sub.writability, mode="markers",
281
+ marker=dict(size=4, color=sub.writability, colorscale="Tealgrn",
282
+ showscale=False), name="loci"))
283
+ fig.add_trace(go.Scatter(x=top.bin * BIN_BP / 1e6, y=top.writability, mode="markers",
284
+ marker=dict(size=9, color="#3dffa2", line=dict(color="#fff", width=.5)),
285
+ name=f"top {topn}"))
286
+ fig.add_vrect(x0=gr.start / 1e6, x1=gr.end / 1e6, fillcolor="rgba(255,93,108,.25)",
287
+ line_width=0, annotation_text=gname)
288
+ fig.update_layout(height=320, xaxis_title="position (Mb)", yaxis_title="writability",
289
+ legend=dict(orientation="h", y=1.15), **PLOTLY)
290
+ st.plotly_chart(fig, use_container_width=True)
291
+ out = top[["chrom", "bin", "writability", "safety", "p_durable", "reachable_tier1"]].copy()
292
+ out["position"] = out.bin * BIN_BP
293
+ out = out[["chrom", "position", "writability", "safety", "p_durable", "reachable_tier1"]]
294
+ st.markdown(f"##### Top {topn} writable loci")
295
+ st.dataframe(out.round(3), use_container_width=True, height=360)
296
+ st.download_button("v Download ranked loci (CSV)", out.to_csv(index=False),
297
+ f"writable_loci_{gname}_{ct}.csv", "text/csv")
298
+
299
+ elif page == "Atlas browser":
300
+ st.markdown("### Atlas browser - *genome-wide tracks*")
301
+ df = load_atlas(ct)
302
+ c1, c2, c3 = st.columns([1, 2, 2])
303
+ chrom = c1.selectbox("chromosome", sorted(df.chrom.unique(), key=lambda x: (len(x), x)))
304
+ cmax = int(df[df.chrom == chrom].bin.max() * BIN_BP)
305
+ rng = c2.slider("region (Mb)", 0.0, cmax / 1e6, (0.0, min(5.0, cmax / 1e6)), step=0.5)
306
+ c3.markdown("####")
307
+ sub = region_bins(df, chrom, int(rng[0] * 1e6), int(rng[1] * 1e6))
308
+ if len(sub) > 8000:
309
+ sub = sub.iloc[:: len(sub) // 8000]
310
+ st.plotly_chart(track_fig(sub), use_container_width=True)
311
+ st.caption(f"{chrom}:{int(rng[0]*1e6):,}-{int(rng[1]*1e6):,} | {len(sub):,} bins shown | {CT_LABEL[ct]}")
312
+
313
+ elif page == "Validation":
314
+ st.markdown("### Blind validation - *recovering known truth*")
315
+ v = load_validation()
316
+ if not v:
317
+ st.info("validation_report.json not found in the data directory.")
318
+ else:
319
+ d = v.get("durability") or {}
320
+ a = v.get("atlas", {})
321
+ c = st.columns(3)
322
+ c[0].markdown(f'<div class="card"><div class="kpi-l">durability Spearman rho</div>'
323
+ f'<div class="kpi mono" style="color:#3dffa2">{d.get("expr_spearman",0):.2f}</div></div>',
324
+ unsafe_allow_html=True)
325
+ c[1].markdown(f'<div class="card"><div class="kpi-l">silenced/stable AUROC</div>'
326
+ f'<div class="kpi mono">{d.get("silenced_auroc",0):.2f}</div>'
327
+ f'<div class="kpi-l">baseline {d.get("silenced_baseline_h3k9me3_auroc",0):.2f}</div></div>',
328
+ unsafe_allow_html=True)
329
+ allok = v.get("all_prereg_checks_pass")
330
+ c[2].markdown(f'<div class="card"><div class="kpi-l">pre-registered checks</div>'
331
+ f'<div class="kpi" style="color:{"#3dffa2" if allok else "#ff5d6c"}">'
332
+ f'{"ALL PASS" if allok else "REVIEW"}</div></div>', unsafe_allow_html=True)
333
+ st.markdown("##### Safe harbours vs genotoxic CIS - writability percentile")
334
+ rows = []
335
+ for cell, av in a.items():
336
+ for name, (cls, pct) in av.get("loci", {}).items():
337
+ rows.append({"cell": cell.upper(), "locus": name, "class": cls, "pct": pct})
338
+ if rows:
339
+ rdf = pd.DataFrame(rows)
340
+ fig = go.Figure()
341
+ for cls, color in [("SAFE", "#3dffa2"), ("GTOX", "#ff5d6c")]:
342
+ s = rdf[rdf["class"] == cls]
343
+ fig.add_trace(go.Bar(x=s.locus + " | " + s.cell, y=s.pct, name=cls, marker_color=color))
344
+ fig.update_layout(height=360, yaxis_title="writability percentile",
345
+ barmode="group", **PLOTLY)
346
+ st.plotly_chart(fig, use_container_width=True)
347
+ st.caption("Validated safe harbours (green) score high; clinical genotoxic loci (red) score "
348
+ "near zero - recovered blind, never trained on these labels.")
349
+
350
+ elif page == "Cross-cell-type":
351
+ st.markdown("### Cross-cell-type - *function transfer, reported*")
352
+ a = load_atlas("k562")[["chrom", "bin", "writability"]].rename(columns={"writability": "k562"})
353
+ b = load_atlas("hepg2")[["chrom", "bin", "writability"]].rename(columns={"writability": "hepg2"})
354
+ m = a.merge(b, on=["chrom", "bin"]).sample(min(40000, len(a)), random_state=0)
355
+ rho = float(pd.Series(m.k562).corr(pd.Series(m.hepg2), method="spearman"))
356
+ st.markdown(f'<div class="card"><div class="kpi-l">K562 <-> HepG2 writability Spearman</div>'
357
+ f'<div class="kpi mono" style="color:#37e6e0">{rho:.2f}</div></div>', unsafe_allow_html=True)
358
+ fig = go.Figure(go.Histogram2d(x=m.k562, y=m.hepg2, colorscale="Tealgrn", nbinsx=50, nbinsy=50))
359
+ fig.update_layout(height=420, xaxis_title="K562 writability", yaxis_title="HepG2 writability", **PLOTLY)
360
+ st.plotly_chart(fig, use_container_width=True)
361
+ st.caption("The model is cell-type-specific in inputs, agnostic in function: writability correlates "
362
+ "across cell types yet differs locus-by-locus - the quantified transfer, not a footnote.")
363
+
364
+ elif page == "Writer Atlas":
365
+ st.markdown("### Writer Atlas - *every genome-writing family on common, measured axes*")
366
+ wa = load_writer_atlas()
367
+ if wa.empty:
368
+ st.info("atlas.parquet not found - run `python scripts/p2_build_atlas.py`.")
369
+ else:
370
+ cov = (wa.groupby("family")
371
+ .agg(systems=("representative_system", "size"),
372
+ measured=("confidence", lambda s: int((s == "measured").sum())),
373
+ tier=("reachability_tier", "first"),
374
+ mechanism=("mechanism_bucket", "first"),
375
+ deliv=("deliv_class", "first"),
376
+ cargo_bp=("cargo_capacity_bp", "max"))
377
+ .reset_index().sort_values("systems", ascending=False))
378
+ k = st.columns(3)
379
+ k[0].markdown(f'<div class="card"><div class="kpi-l">writer families</div>'
380
+ f'<div class="kpi mono">{wa.family.nunique()}</div></div>', unsafe_allow_html=True)
381
+ k[1].markdown(f'<div class="card"><div class="kpi-l">catalogued systems</div>'
382
+ f'<div class="kpi mono">{len(wa):,}</div></div>', unsafe_allow_html=True)
383
+ k[2].markdown(f'<div class="card"><div class="kpi-l">IS110 orthologs</div>'
384
+ f'<div class="kpi mono" style="color:#3dffa2">{int((wa.family=="bridge_IS110").sum()):,}</div></div>',
385
+ unsafe_allow_html=True)
386
+ st.markdown("##### Family coverage (measured axes + reachability tier)")
387
+ st.dataframe(cov, use_container_width=True, height=320)
388
+ fams = st.multiselect("Compare families", sorted(wa.family.unique()),
389
+ default=["bridge_IS110", "CAST_VK", "serine_integrase", "PE_integrase"])
390
+ comp = wa[wa.family.isin(fams) & wa.entry_kind.eq("curated_core")] if "entry_kind" in wa else wa[wa.family.isin(fams)]
391
+ if not comp.empty and "readiness" in comp:
392
+ fig = go.Figure(go.Bar(x=comp.representative_system, y=comp.readiness,
393
+ marker_color="#37e6e0", text=comp.deliv_class))
394
+ fig.update_layout(height=320, yaxis_title="therapeutic readiness",
395
+ xaxis_title="representative system", **PLOTLY)
396
+ st.plotly_chart(fig, use_container_width=True)
397
+ st.caption("Reachability tiers: Tier-1 directly scannable | Tier-2 candidate (requires validation) "
398
+ ". Tier-3 not yet predictable. Every system carries a confidence tag + source DOI.")
399
+
400
+ elif page == "Bridge design":
401
+ st.markdown("### Bridge design + off-target - *the first instrument of PEN-STACK*")
402
+ st.caption("Design a bridge RNA (wraps the Arc BridgeRNADesigner) and assess fold + cross-loop QC and "
403
+ "genome-wide off-target risk (position-weight model; measured profile from Perry 2025).")
404
+ c1, c2 = st.columns(2)
405
+ target = c1.text_input("Target core (14 nt)", "ACGTGTCTACGTGA")
406
+ donor = c2.text_input("Donor core (14 nt)", "TTGCATCTAGGCAC")
407
+ scaffold = st.selectbox("Scaffold", ["ISCro4_enhanced", "ISCro4_WT", "IS621"])
408
+ scan_chrom = st.selectbox("Off-target scan", ["none (QC only)", "chr22", "chr21", "chrX"])
409
+ if st.button("Design + assess", type="primary"):
410
+ from pen_stack.bridge.fold_qc import qc_verdict
411
+ from pen_stack.bridge.ingest import load_measured_profile
412
+ from pen_stack.bridge.pipeline import design_brna
413
+ brna = design_brna(target, donor, scaffold)
414
+ st.markdown(f'<div class="card"><b>Bridge RNA</b> ({scaffold}) - target {brna["target"]} | '
415
+ f'donor {brna["donor"]}' +
416
+ (f' | scaffold {len(brna["bridge_sequence"])} nt' if brna.get("available")
417
+ else f' | <i>{brna["note"]}</i>') + '</div>', unsafe_allow_html=True)
418
+ qc = qc_verdict(brna["target"], brna["donor"], brna.get("bridge_sequence"))
419
+ vclass = "v-yes" if qc["pass"] else "v-cau"
420
+ st.markdown(f'<div class="verdict {vclass}">QC {"PASS" if qc["pass"] else "REVIEW"} - '
421
+ f'cross-loop {qc["cross_loop"]}' +
422
+ (f' | fold MFE {qc["fold"]["mfe"]}' if qc.get("fold", {}).get("available") else "") +
423
+ '</div>', unsafe_allow_html=True)
424
+ mp = load_measured_profile()
425
+ if not mp.empty:
426
+ st.caption("Measured off-target position profile (Perry 2025, 6,856 real off-targets) - "
427
+ "central core (7-9) is the specificity determinant:")
428
+ st.bar_chart(mp.set_index("position")["protective_weight"])
429
+ if scan_chrom != "none (QC only)":
430
+ from pen_stack.bridge.pipeline import _hg38
431
+ fa = _hg38()
432
+ if fa is None:
433
+ st.warning("hg38 fasta not found on this host (set PEN_HG38); QC shown above.")
434
+ else:
435
+ from pen_stack.bridge.offtarget import scan_offtargets
436
+ with st.spinner(f"scanning {scan_chrom} for off-target pseudosites..."):
437
+ df = scan_offtargets(fa, brna["target"], [scan_chrom])
438
+ st.caption(f"{len(df)} off-target pseudosites on {scan_chrom} "
439
+ f"({int((df.risk>0.5).sum()) if len(df) else 0} high-risk):")
440
+ if len(df):
441
+ st.dataframe(df.head(15)[["chrom", "pos", "site", "n_mm", "risk"]].round(3),
442
+ use_container_width=True)
443
+ st.caption("Decision-support only; predicted off-targets require experimental validation.")
444
+
445
+ elif page == "Write Planner":
446
+ st.markdown("### Write Planner - *inverse design*")
447
+ st.caption("goal + edit_intent -> ranked, traceable site x writer x cargo x delivery plans. "
448
+ "edit_intent is load-bearing (an in-gene site ranks high for knock-in, low for safe-harbour).")
449
+ gene = st.text_input("Target gene", "TRAC")
450
+ intent = st.selectbox("Edit intent", ["knock_in_with_disruption", "safe_harbour_insertion",
451
+ "high_durability_insertion", "regulatory_excision", "repeat_excision"])
452
+ cargo_bp = int(st.number_input("Cargo size (bp)", 100, 40000, 2000))
453
+ if st.button("Plan", type="primary"):
454
+ from pen_stack.planner.optimize import EditIntent
455
+ from pen_stack.planner.pipeline import plan_write
456
+ try:
457
+ with st.spinner("optimising destination x writer x cargo x delivery..."):
458
+ plans = plan_write(gene, EditIntent(intent), cargo_bp, ct, k=5)
459
+ except FileNotFoundError as e:
460
+ st.error(str(e))
461
+ plans = []
462
+ if not plans:
463
+ st.warning("No plan found (gene not in the atlas, or no reachable site).")
464
+ for i, p in enumerate(plans, 1):
465
+ s = p["site"]
466
+ st.markdown(f'<div class="card"><b>Plan {i}</b> - {s["chrom"]}:{s["pos"]:,} '
467
+ f'(on_target={p["on_target"]}) | writer <b>{p["writer"]}</b> '
468
+ f'[{p["reachability_tier"]}]<br>safety {p["safety"]} | durability {p["durability"]} '
469
+ f'. writer-activity {p["writer_activity"]} | score {p["score"]}<br>'
470
+ f'cargo {p["cargo"]["payload_bp"]}bp->{p["cargo"]["assembled_bp"]}bp '
471
+ f'(size_ok={p["cargo"]["size_ok"]}) | delivery <b>{p["delivery"]["delivery"]}</b> | '
472
+ f'off-target {p["cargo"].get("offtargets",{}).get("status","n/a")}</div>',
473
+ unsafe_allow_html=True)
474
+ if plans:
475
+ st.caption(plans[0]["disclaimer"])
476
+
477
+ elif page == "Ask (RAG)":
478
+ st.markdown("### Ask - *grounded, cited Q&A over the platform*")
479
+ st.caption("Numbers come from validated tool calls (never guessed); clinical-directive questions are refused.")
480
+ q = st.text_input("Ask a question",
481
+ "Which bridge recombinase works in human cells, and where can I write into CCR5?")
482
+ if st.button("Ask", type="primary") or q:
483
+ from pen_stack.rag.qa import answer as rag_answer
484
+ a = rag_answer(q, ct=ct)
485
+ if a.get("refused"):
486
+ st.markdown(f'<div class="verdict v-no">{a["answer"]}</div>', unsafe_allow_html=True)
487
+ else:
488
+ st.markdown(f'<div class="card">{a["answer"]}</div>', unsafe_allow_html=True)
489
+ if a.get("provenance"):
490
+ st.markdown("##### Tool provenance (every number traces here)")
491
+ st.json(a["provenance"])
492
+ if a.get("citations"):
493
+ st.markdown("##### Citations")
494
+ st.write(", ".join(a["citations"]))
495
+ st.caption(a.get("disclaimer", ""))
496
+
497
+ elif page == "Agent":
498
+ st.markdown("### Agent - *natural-language goal -> cited, auditable write plan*")
499
+ st.caption("The PEN-STACK agent orchestrates every validated tool. It obtains numbers ONLY from tool "
500
+ "calls (no fabrication), refuses clinical directives, and logs an auditable trace.")
501
+ goal = st.text_input("Goal", "Knock a CAR into TRAC, disrupting the TCR for allogeneic CAR-T.")
502
+ if st.button("Plan with agent", type="primary"):
503
+ from pen_stack.agent.orchestrator import run_agent
504
+ with st.spinner("Agent calling validated tools..."):
505
+ res = run_agent(goal)
506
+ if res.get("refused"):
507
+ st.markdown(f'<div class="verdict v-no">{res["plan"]}</div>', unsafe_allow_html=True)
508
+ else:
509
+ mode = "LLM tool-calling" if res.get("llm") else "deterministic fallback (no LLM reachable)"
510
+ st.caption(f"mode: {mode}")
511
+ st.markdown(f'<div class="card">{res["plan"]}</div>', unsafe_allow_html=True)
512
+ if res.get("trace"):
513
+ st.markdown("##### Auditable trace (every number traces to a tool call)")
514
+ for i, step in enumerate(res["trace"], 1):
515
+ with st.expander(f"step {i}: {step['tool']}({step['args']})"):
516
+ st.json(step["result"])
517
+ st.caption(res.get("disclaimer", ""))
518
+
519
+ elif page == "Cargo Polish":
520
+ st.markdown("### Cargo Polish - *score the insert, not just the site*")
521
+ st.caption("The locus model scores WHERE to write; Cargo Polish scores WHAT is written. It scans a "
522
+ "cassette for silencing/instability triggers - CpG islands, GC extremes, cryptic splice "
523
+ "sites, strong mRNA structure (ViennaRNA), silencer motifs - and gives a concrete fix per "
524
+ "flag. A heuristic flag, not a supervised silencing predictor.")
525
+ seq = st.text_area("Cargo / insert sequence (DNA)", "GCGCGGCGGCGCGCGGCGG" * 8, height=120)
526
+ if st.button("Scan cargo", type="primary"):
527
+ from pen_stack.planner.cargo_polish import scan_cargo
528
+ r = scan_cargo(seq)
529
+ cls = {"low": "v-go", "moderate": "v-cau", "high": "v-no"}[r["band"]]
530
+ st.markdown(f'<div class="verdict {cls}">cargo_durability_risk {r["cargo_durability_risk"]} '
531
+ f'({r["band"]}) | {r["n_flags"]} flag(s) | GC {r["gc"]} | {r["length_bp"]} nt</div>',
532
+ unsafe_allow_html=True)
533
+ for f in r["flags"]:
534
+ st.markdown(f'<div class="card"><span class="badge">{f["category"]}</span> {f["detail"]}<br>'
535
+ f'<span class="kpi-l">suggestion</span> {f["suggestion"]}</div>',
536
+ unsafe_allow_html=True)
537
+ if not r["flags"]:
538
+ st.success("No silencing/instability triggers flagged in this cassette.")
539
+ st.caption(r["scope"])
540
+
541
+ elif page == "Multiplex risk":
542
+ st.markdown("### Multiplex translocation-risk - *pairwise DSB-join screen*")
543
+ st.caption("For a 2-5 edit plan, two simultaneous double-strand breaks can mis-join into a "
544
+ "translocation. DSB-free recombinase writers (bridge / seek / PE) carry ~zero risk by "
545
+ "construction. A screen, not a calibrated predictor.")
546
+ n = st.slider("Number of simultaneous edits", 2, 5, 3)
547
+ fams = ["cas9", "cas12a", "bridge_IS110", "seek_IS1111", "PE_integrase"]
548
+ edits = []
549
+ for i in range(n):
550
+ c = st.columns([2, 2, 2, 2])
551
+ edits.append({
552
+ "name": c[0].text_input("name", f"edit{i + 1}", key=f"mx_n{i}"),
553
+ "family": c[1].selectbox("writer family", fams, index=0, key=f"mx_f{i}"),
554
+ "chrom": c[2].text_input("chrom", f"chr{i + 2}", key=f"mx_c{i}"),
555
+ "pos": int(c[3].number_input("pos", 1, 250_000_000, 1000 * (i + 1), key=f"mx_p{i}"))})
556
+ if st.button("Assess translocation risk", type="primary"):
557
+ from pen_stack.planner.multiplex import translocation_risk
558
+ r = translocation_risk(edits)
559
+ cls = {"low": "v-go", "moderate": "v-cau", "high": "v-no"}[r["band"]]
560
+ st.markdown(f'<div class="verdict {cls}">translocation_risk {r["translocation_risk"]} ({r["band"]})'
561
+ f' | {r["n_cut_sites"]} cut sites | {r["n_pairs"]} pairs | {r["n_dsb_free_edits"]}/'
562
+ f'{r["n_edits"]} DSB-free</div>', unsafe_allow_html=True)
563
+ if r["all_dsb_free"]:
564
+ st.success("All edits use DSB-free writers - no double-strand breaks, so ~zero translocation "
565
+ "risk by construction. This is the safety advantage of programmable recombinases.")
566
+ if r["top_pairs"]:
567
+ st.markdown("##### Top contributing DSB pairs")
568
+ st.dataframe(pd.DataFrame(r["top_pairs"]), use_container_width=True, height=240)
569
+ st.caption(r["scope"])
570
+
571
+ elif page == "Guide QC":
572
+ st.markdown("### Bridge-RNA guide QC - *rank variants by documented failure modes*")
573
+ st.caption("Reuses the validated fold-QC (cross-loop self / TBL-DBL complementarity, ViennaRNA MFE) and "
574
+ "off-target counts to score a design. When a default trips a flag, rank caller-supplied "
575
+ "variants. Ranking, not validated novel design.")
576
+ c1, c2 = st.columns(2)
577
+ tg = c1.text_input("Target guide", "ACAAGCTGGAAGAACTGAAG")
578
+ dg = c2.text_input("Donor guide", "GACATCTACAAGGACATCGA")
579
+ ot = int(st.number_input("Predicted off-target count (0 if unknown)", 0, 50, 0))
580
+ if st.button("QC this guide", type="primary"):
581
+ from pen_stack.bridge.guide_qc import qc_flags, qc_score
582
+ f = qc_flags(tg, dg, offtarget_count=ot or None)
583
+ s = qc_score(tg, dg, offtarget_count=ot or None)
584
+ cls = "v-go" if f["pass"] else "v-no"
585
+ st.markdown(f'<div class="verdict {cls}">QC {"PASS" if f["pass"] else "REVIEW"} | qc_score {s} | '
586
+ f'flags: {", ".join(f["flags"]) or "none"}</div>', unsafe_allow_html=True)
587
+ st.json(f["cross_loop"])
588
+ st.markdown("##### Synthetic positive-control panel *(hand-constructed guides, not real outcomes)* - "
589
+ "each constructed failure mode must rank below the clean control")
590
+ from pen_stack.validate.guide_qc_demo import run as _gqc
591
+ demo = _gqc()
592
+ st.dataframe(pd.DataFrame(demo["ranking"]), use_container_width=True, height=200)
593
+ st.caption(f"best is the good guide: {demo['best_is_good']} | every bad guide flagged: "
594
+ f"{demo['every_bad_flagged']} | {demo['scope']}")
595
+
596
+ elif page == "Verify":
597
+ st.markdown("### Verify - *a type checker for genome writes*")
598
+ st.caption("Submit a proposed write; get back legal/illegal + the named violated rule + a calibrated "
599
+ "confidence + scope flags. Legality and confidence are distinct axes. Also callable over "
600
+ "POST /verify and the MCP tool verify_write.")
601
+ cwt = st.selectbox("Write type", ["insertion", "excision", "inversion", "replacement",
602
+ "regulatory_rewrite", "landing_pad_install", "multiplex"])
603
+ cfam = st.selectbox("Writer family", ["bridge_IS110", "seek_IS1111", "CAST_VK", "serine_integrase",
604
+ "PE_integrase", "Cas9", "Cas12a"])
605
+ from pen_stack.planner.delivery_vehicles import names as _veh_names
606
+ cveh = st.selectbox("Delivery vehicle", _veh_names())
607
+ ccargo = int(st.number_input("Cargo size (bp)", 0, 200000, 3000, key="vf_cargo"))
608
+ cseq = st.text_input("Site sequence window (optional, for reachability)", "")
609
+ cnoint = st.checkbox("Goal forbids genomic integration", value=False)
610
+ if st.button("Verify design", type="primary"):
611
+ from pen_stack.verify import verify
612
+ v = verify({"write_type": cwt, "writer_family": cfam, "delivery_vehicle": cveh, "cargo_bp": ccargo,
613
+ "site_seq": cseq or None, "no_integration": cnoint})
614
+ if v.deferred:
615
+ cls = "v-cau"
616
+ else:
617
+ cls = "v-go" if v.legal else "v-no"
618
+ st.markdown(f'<div class="verdict {cls}">{v.summary()}</div>', unsafe_allow_html=True)
619
+ if v.violations:
620
+ st.markdown("**Violated rules:**")
621
+ for vi in v.violations:
622
+ st.error(f"`{vi['rule_id']}`, {vi['reason']}"
623
+ + (f" _(cite: {', '.join(vi['citation'])})_" if vi.get("citation") else ""))
624
+ if v.soft_flags:
625
+ for s in v.soft_flags:
626
+ st.warning(f"soft: `{s['rule_id']}`, {s['reason']}")
627
+ if v.scope_flags:
628
+ st.info("scope: " + "; ".join(s.get("reason", s.get("kind", "")) for s in v.scope_flags))
629
+ st.caption(f"no_fabrication={v.no_fabrication} | epistemic={v.epistemic_status} | "
630
+ f"rules v{v.provenance.get('rules_version')}")
631
+
632
+ elif page == "PEN-Agent":
633
+ st.markdown("### PEN-Agent - *grounded, uncertainty-aware write-planning state machine*")
634
+ st.caption("goal -> site -> writer -> cargo (+Cargo Polish) -> off-target -> 3D structural risk -> "
635
+ "report. Every number is copied from a validated tool with provenance; a step that cannot "
636
+ "ground a value is degraded or refused, never invented. The planner also provides a calibrated plan "
637
+ "confidence, a three-tier epistemic status per step, abstention, and out-of-scope deferral.")
638
+ gene = st.text_input("Target gene", "TRAC")
639
+ intent = st.selectbox("Edit intent", ["knock_in_with_disruption", "safe_harbour_insertion",
640
+ "high_durability_insertion", "regulatory_excision",
641
+ "repeat_excision"])
642
+ cargo_bp = int(st.number_input("Cargo size (bp)", 100, 40000, 2000, key="pa_cargo"))
643
+ payload = st.text_area("Payload sequence (optional - adds Cargo Polish)", "", height=80)
644
+ question = st.text_input("Optional question (checked against the known-unknowns scope registry)", "")
645
+ if st.button("Run PEN-Agent", type="primary"):
646
+ from pen_stack.agent.pen_agent import plan_write_session
647
+ with st.spinner("sequencing validated tools..."):
648
+ r = plan_write_session(gene, intent, cargo_bp, ct, payload_seq=payload or None,
649
+ question=question or None)
650
+ cls = "v-go" if r["no_fabrication"] and r["completed"] else (
651
+ "v-cau" if r["no_fabrication"] else "v-no")
652
+ st.markdown(f'<div class="verdict {cls}">no_fabrication {r["no_fabrication"]} | completed '
653
+ f'{r["completed"]} | {len(r["degraded_modes"])} degraded | {len(r["refusals"])} '
654
+ f'refused</div>', unsafe_allow_html=True)
655
+ if r.get("out_of_scope"):
656
+ st.error(f"OUT OF SCOPE (deferred, zero fabrication): {r['out_of_scope']['deferral']}")
657
+ # trust line: calibrated plan confidence + abstention + epistemic-status rollup
658
+ es = r.get("epistemic_summary", {}).get("counts", {})
659
+ conf = r.get("plan_confidence")
660
+ st.markdown(
661
+ f'<div class="card">plan confidence <b>{conf if conf is not None else "n/a"}</b> | '
662
+ f'abstained <b>{r.get("abstained")}</b> | epistemic: '
663
+ f'grounded-confident <b>{es.get("grounded-confident", 0)}</b>, '
664
+ f'grounded-extrapolating <b>{es.get("grounded-extrapolating", 0)}</b>, '
665
+ f'not-computable <b>{es.get("not-computable", 0)}</b></div>', unsafe_allow_html=True)
666
+ _icon = {"ok": "[ok]", "degraded": "[degraded]", "refused": "[refused]"}
667
+ for step in r["steps"]:
668
+ epi = (step.get("epistemic") or {}).get("epistemic_status", "")
669
+ with st.expander(f"{_icon.get(step['status'], '')} {step['name']} "
670
+ f"(tool: {step.get('tool') or '-'}) - {epi}"):
671
+ if step.get("provenance"):
672
+ st.caption(f"provenance: {step['provenance']}")
673
+ if step.get("epistemic"):
674
+ st.caption(f"epistemic: {step['epistemic'].get('epistemic_status')} - "
675
+ f"{step['epistemic'].get('reason')}")
676
+ if step.get("reason"):
677
+ st.warning(step["reason"])
678
+ if step.get("result"):
679
+ st.json(step["result"])
680
+ st.caption(r["disclaimer"])
681
+
682
+ elif page == "Genome-Writing Bench":
683
+ st.markdown("### Genome-Writing Bench v0.2 - *the writing-side benchmark*")
684
+ st.caption("Tasks with deterministic scorers and documented ground truth; no task is scored against "
685
+ "a circular label. v0.2 adds the TRUST tasks - calibration (T8), selective prediction (T9), "
686
+ "OOD honesty (T10), out-of-scope refusal (T11) - contrasting the uncertainty-aware agent with "
687
+ "an over-confident baseline. The planner is also compared to a naive baseline and a grounded "
688
+ "LLM agent that cannot fabricate.")
689
+ from pen_stack._resources import project_root
690
+ lb = project_root() / "benchmarks" / "genome_writing_bench" / "LEADERBOARD.md"
691
+ res = project_root() / "out" / "bench_results.json"
692
+ if res.exists():
693
+ d = json.loads(res.read_text(encoding="utf-8"))
694
+ b = d.get("bench", {})
695
+ st.markdown(f'<div class="card">tasks available <b>{b.get("n_available")}/{b.get("n_tasks")}</b> | '
696
+ f'planner beats naive on <b>{b.get("planner_beats_baseline")}/'
697
+ f'{b.get("n_with_baseline")}</b> grounded tasks</div>', unsafe_allow_html=True)
698
+ rows = [{"task": r["id"], "family": r["family"], "available": r["available"],
699
+ "planner": r["planner_score"], "naive": r["baseline_score"],
700
+ "gate": r.get("gate_pass")} for r in b.get("results", [])]
701
+ st.dataframe(pd.DataFrame(rows), use_container_width=True, height=260)
702
+ if lb.exists():
703
+ with st.expander("Full leaderboard (LEADERBOARD.md)"):
704
+ st.markdown(lb.read_text(encoding="utf-8"))
705
+ else:
706
+ st.info("Run `python bench/run.py --agent` to generate the leaderboard.")
707
+ st.caption("Reproduce: `python bench/run.py --agent` (or `docker compose run --rm bench`). "
708
+ "Submissions: benchmarks/genome_writing_bench/SUBMISSIONS.md.")
709
+
710
+ st.markdown("---")
711
+ st.caption("PEN-STACK v0.1.0 | Writable Genome + Writer Atlas + Write Planner + Genome-Writing Bench + "
712
+ "PEN-Agent | decision-support, not a clinical directive | every score traceable to public data "
713
+ "+ a pre-registered model.")