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,154 @@
1
+ """CD4 / MHC-II epitope-load axis for the writer enzyme AND the capsid.
2
+
3
+ The dominant immunogenicity driver for a protein therapeutic is **MHC-II / CD4 help -> anti-drug antibodies
4
+ (ADA)**, exactly what the earlier immune profile omitted (it did CD8/MHC-I via MHCflurry only). And the **editor
5
+ protein itself is immunogenic** (Cas9 elicits MHC-II-presented CD4 responses, Simhadri et al., Nat Commun 2021,
6
+ 10.1038/s41467-021-25414-9; bridge recombinases / serine integrases are bacterial/phage), yet the immune-risk
7
+ profile scored only the capsid. This module adds an MHC-II epitope-load axis over the **writer** as a distinct antigen.
8
+
9
+ PRODUCTION method: the **real, licensed NetMHCIIpan-4.0** eluted-ligand %Rank over a frequent HLA-II
10
+ panel (residue-coverage metric), run on the VM with only the derived fractions cached (`configs/mhc_epitope_oracle.yaml`);
11
+ `mhc2_epitope_load(seq, name)` returns the real value for a cached antigen and otherwise **abstains** (no production
12
+ proxy). A population-level proxy, never a patient-HLA-specific magnitude (a known-unknown). Whether the epitopes
13
+ drive ADA depends on self-tolerance (foreign vs self), handled in `ada_risk`.
14
+
15
+ OFFLINE-ONLY fallback (`mhc2_proxy_estimate`, NOT the production axis): a documented, dependency-free PROMISCUOUS-
16
+ binder density, MHC-II presents a 9-mer core in an open groove whose **P1 pocket** is the dominant hydrophobic
17
+ anchor (M/F/Y/W/L/I/V; Stern & Wiley, Nature 1994) with secondary pockets at P4/P6/P9 (Southwood 1998). Provided for
18
+ offline triage where NetMHCIIpan cannot be run; the production axis uses the real tool and abstains otherwise.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from functools import lru_cache
23
+
24
+ from pen_stack._resources import resource
25
+
26
+ _AA = set("ACDEFGHIKLMNPQRSTVWY")
27
+ P1_ANCHOR = set("MFYWLIV") # large hydrophobic, the dominant MHC-II P1 anchor (Stern & Wiley 1994)
28
+ _FAVORABLE = set("AVLIMFYWSTNQGC") # favorable at secondary pockets (hydrophobic / small / polar-uncharged)
29
+ _DISFAVORED = set("DEKRP") # charged / proline disfavored at anchor positions
30
+ MHC2_DOIS = ["10.1038/35030019", "10.1038/s41467-021-25414-9"] # Stern&Wiley groove; Cas9 MHC-II (Simhadri 2021)
31
+
32
+
33
+ def _clean(seq: str) -> str:
34
+ return "".join(c for c in str(seq).upper() if c in _AA)
35
+
36
+
37
+ def _core_is_binder(core: str) -> tuple[bool, float]:
38
+ """A 9-mer core is a promiscuous-binder candidate iff P1 (pos0) is a strong hydrophobic anchor AND >=2 of the
39
+ secondary pockets P4/P6/P9 are favorable with no disfavored residue at an anchor position."""
40
+ if core[0] not in P1_ANCHOR:
41
+ return False, 0.0
42
+ sec = sum(1 for p in (3, 5, 8) if core[p] in _FAVORABLE)
43
+ pen = sum(1 for p in (0, 3, 5, 8) if core[p] in _DISFAVORED)
44
+ score = 1.0 + 0.5 * sec - 0.5 * pen
45
+ return (sec >= 2 and pen == 0), round(score, 3)
46
+
47
+
48
+ def mhc2_binder_cores(seq: str) -> list[tuple[int, str, float]]:
49
+ """All promiscuous-binder 9-mer cores in the sequence: (start_index, core, score)."""
50
+ s = _clean(seq)
51
+ out = []
52
+ for i in range(len(s) - 8):
53
+ ok, sc = _core_is_binder(s[i:i + 9])
54
+ if ok:
55
+ out.append((i, s[i:i + 9], sc))
56
+ return out
57
+
58
+
59
+ @lru_cache(maxsize=1)
60
+ def _real_cache() -> dict:
61
+ """The REAL NetMHCIIpan-4.0 EL %Rank epitope-load cache (configs/mhc_epitope_oracle.yaml), computed over a
62
+ frequent HLA-II panel on the VM. Only the derived fractions are shipped (the licensed binary is never
63
+ distributed). Empty dict if the cache is absent (then the documented proxy is used)."""
64
+ try:
65
+ import yaml
66
+ return yaml.safe_load(resource("configs/mhc_epitope_oracle.yaml").read_text(encoding="utf-8")) or {}
67
+ except Exception: # noqa: BLE001
68
+ return {}
69
+
70
+
71
+ def real_mhc2_load(name: str) -> dict | None:
72
+ """The real NetMHCIIpan-4.0 epitope load for a bundled antigen by name, or None when not cached."""
73
+ rec = (_real_cache().get("mhc2") or {}).get(name)
74
+ if not rec:
75
+ return None
76
+ panel = (_real_cache().get("method") or {}).get("hla2_panel", [])
77
+ return {"epitope_density": rec["epitope_fraction_strong"], "mhc2_immune_score": rec["immune_score"],
78
+ "n_covered": rec.get("n_covered"), "length": rec.get("length"),
79
+ "method": f"NetMHCIIpan-4.0 EL %Rank<=2, {rec.get('metric', 'residue coverage')}, frequent HLA-II "
80
+ f"panel ({len(panel)} alleles): {', '.join(panel)}",
81
+ "status": "population-level (frequent-HLA panel; NetMHCIIpan-4.0 eluted-ligand), coverage-gated "
82
+ "(abstains for uncached antigens, NOT a distributional OOD gate); NOT a patient-HLA-specific "
83
+ "magnitude (known-unknown)", "backend": "netmhciipan_cache"}
84
+
85
+
86
+ def mhc2_epitope_load(seq: str, name: str | None = None) -> dict:
87
+ """MHC-II epitope load, the REAL NetMHCIIpan-4.0 cache when the antigen `name` is cached; otherwise ABSTAINS
88
+ (no proxy: an uncached sequence is a known-unknown, not a guessed number). To ground a new sequence,
89
+ add it to configs/writer_sequences.fasta and re-run scripts/p1_build_mhc.py on the VM. Score convention:
90
+ 1 = least presentable (matches the MHC-I axis)."""
91
+ if name:
92
+ real = real_mhc2_load(name)
93
+ if real:
94
+ return real
95
+ return {"epitope_density": None, "mhc2_immune_score": None, "backend": "abstain",
96
+ "method": "NetMHCIIpan-4.0 (real)", "available": False,
97
+ "status": "NetMHCIIpan-4.0 not run for this sequence, NO proxy (abstains, known-unknown). Run "
98
+ "scripts/p1_build_mhc.py over its FASTA on the VM to ground it."}
99
+
100
+
101
+ def mhc2_proxy_estimate(seq: str) -> dict:
102
+ """OFFLINE-ONLY estimate (NOT used by the profile): a documented promiscuous-binder density (P1 hydrophobic
103
+ anchor + P4/P6/P9 pockets; Stern & Wiley 1994; Southwood 1998). Provided for offline triage where NetMHCIIpan
104
+ cannot be run; the production axis uses the real tool and abstains otherwise."""
105
+ s = _clean(seq)
106
+ n_cores = max(0, len(s) - 8)
107
+ binders = mhc2_binder_cores(s)
108
+ density = (len(binders) / n_cores) if n_cores else 0.0
109
+ return {"epitope_density": round(density, 4), "mhc2_immune_score": round(1.0 - min(density, 1.0), 4),
110
+ "n_promiscuous_binders": len(binders), "backend": "proxy_offline_only",
111
+ "method": "DOCUMENTED PROXY, offline triage only, NOT the production axis (which uses NetMHCIIpan-4.0)"}
112
+
113
+
114
+ # ---- bundled writer / control sequences (real UniProt, configs/writer_sequences.fasta) --------
115
+ @lru_cache(maxsize=1)
116
+ def writer_sequences() -> dict:
117
+ """Parse the bundled writer/control FASTA -> {name: {seq, origin, family, accession}}."""
118
+ txt = resource("configs/writer_sequences.fasta").read_text(encoding="utf-8")
119
+ out: dict = {}
120
+ name = seq = origin = family = acc = None
121
+
122
+ def _flush():
123
+ if name:
124
+ out[name] = {"seq": seq, "origin": origin, "family": family, "accession": acc}
125
+ for line in txt.splitlines():
126
+ if line.startswith("#"):
127
+ continue
128
+ if line.startswith(">"):
129
+ _flush()
130
+ head = line[1:].strip()
131
+ tok = head.split()
132
+ name, acc = (tok[0].split("|") + [None])[:2]
133
+ kv = dict(p.split("=", 1) for p in tok[1:] if "=" in p)
134
+ origin, family, seq = kv.get("origin"), kv.get("family"), ""
135
+ else:
136
+ seq = (seq or "") + line.strip()
137
+ _flush()
138
+ return out
139
+
140
+
141
+ def writer_family_to_sequence(writer_family: str) -> dict | None:
142
+ """Map a design's writer family (e.g. 'bridge_IS110', 'serine_integrase', 'Cas9') to a bundled writer
143
+ sequence record, or None when no representative sequence is bundled (then the axis abstains)."""
144
+ fam = (writer_family or "").lower()
145
+ seqs = writer_sequences()
146
+ for name, rec in seqs.items():
147
+ f = (rec.get("family") or "").lower()
148
+ if rec.get("origin") == "self":
149
+ continue
150
+ if fam and (fam in f or f in fam or fam in name.lower()):
151
+ return {"name": name, **rec}
152
+ if "cas9" in fam or "nuclease" in fam:
153
+ return {"name": "SpCas9", **seqs.get("SpCas9", {})} if "SpCas9" in seqs else None
154
+ return None
@@ -0,0 +1,292 @@
1
+ """Unified per-design immune-risk PROFILE.
2
+
3
+ One object across every immune-risk axis (genotoxicity, CD8 epitope load, innate sensing, pre-existing
4
+ anti-vector NAb, anti-PEG), each carrying its OWN value + native uncertainty + scope + calibration validation
5
+ label. The axes are reported as a VECTOR, never fused into a single number, which would fake confidence and
6
+ is forbidden (asserted by test). Abstaining axes report ``None``, not a guess. The in-vivo response magnitude
7
+ and the patient-specific titer are listed as declared known-unknowns.
8
+
9
+ This is the "immune-risk screen" the closed-loop arc consumes, a relative screen across axes,
10
+ not a patient-level prediction.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from pen_stack.planner.antipeg_oracle import antipeg_oracle
15
+ from pen_stack.planner.ada_risk import ada_risk
16
+ from pen_stack.planner.capsid_epitope_oracle import capsid_epitope_oracle
17
+ from pen_stack.planner.genotoxicity_oracle import genotoxicity_oracle
18
+ from pen_stack.planner.immune_mhc2 import mhc2_epitope_load, writer_family_to_sequence, writer_sequences
19
+ from pen_stack.planner.innate_sensing import innate_sensing
20
+ from pen_stack.planner.seroprevalence_oracle import seroprevalence_oracle
21
+ from pen_stack.validate.immune_calibration import axis_label
22
+
23
+ # magnitude and patient-level state are NEVER predicted, listed so every consumer sees the boundary.
24
+ # Also registers cd4_mhcii_help / preexisting_capsid_tcell / complement_carpa as known-unknowns
25
+ # (configs/known_unknowns.yaml), mechanistically distinct axes PEN-STACK does not model.
26
+ KNOWN_UNKNOWNS = ["patient_specific_titer", "in_vivo_response_magnitude", "induced_immunity_post_dose1",
27
+ "cd4_mhcii_help", "preexisting_capsid_tcell", "complement_carpa"]
28
+
29
+ # Route/dose modifier. Immune-privileged delivery sites (eye, CNS) materially LOWER the realized
30
+ # immunogenicity of the SAME vector vs systemic delivery (Streilein 2003, 10.1038/nri1224). This is a
31
+ # DOCUMENTED, QUALITATIVE modifier on the realized response, never a fabricated magnitude.
32
+ _IMMUNE_PRIVILEGED = {"subretinal", "intravitreal", "intraocular", "cns", "intrathecal", "intracranial",
33
+ "intraparenchymal", "eye", "retina", "brain"}
34
+ ROUTE_MODIFIER_DOI = "10.1038/nri1224" # Streilein 2003, ocular/CNS immune privilege
35
+
36
+
37
+ def _route_modifier(route: str | None) -> dict | None:
38
+ if not route:
39
+ return None
40
+ r = str(route).strip().lower()
41
+ privileged = any(k in r for k in _IMMUNE_PRIVILEGED)
42
+ return {"route": route,
43
+ "immune_privileged": privileged,
44
+ "effect": ("immune-privileged site: realized immunogenicity is materially LOWER than systemic "
45
+ "delivery of the same vector (qualitative)" if privileged
46
+ else "systemic / non-privileged route: no immune-privilege reduction applied"),
47
+ "doi": "10.1038/nri1224",
48
+ "note": "DOCUMENTED qualitative modifier on the realized response, NOT a quantified magnitude "
49
+ "(the magnitude stays a known-unknown)."}
50
+
51
+
52
+ # Administration-context (in-vivo vs ex-vivo) modifier on the VECTOR-FACING immune axes.
53
+ # Ex-vivo delivery (cells transduced in a dish and washed before transplant) does NOT expose the vector to the
54
+ # patient's circulating antibodies, so the pre-existing anti-vector NAb axis - a humoral, bloodstream eligibility
55
+ # barrier - does not gate ex-vivo use (it is muted to "no barrier"). The systemic anti-capsid CD8 response is also
56
+ # muted ex vivo (the host barely sees the capsid), but transduced cells can still present capsid epitopes (a real,
57
+ # residual concern), so that axis is FLAGGED muted without overwriting its intrinsic value. This is the SAME
58
+ # in-vivo/ex-vivo distinction the delivery_immunology profile already encodes ("computed_ex_vivo_muted").
59
+ # A DOCUMENTED, mechanistic modifier - never a fabricated magnitude; the realized response stays a known-unknown.
60
+ _EX_VIVO_MUTED_AXES = ("preexisting_nab", "cd8_epitope")
61
+
62
+
63
+ def _is_ex_vivo(in_vivo) -> bool:
64
+ return str(in_vivo).strip().lower() in ("false", "0", "no", "ex_vivo", "ex vivo", "exvivo")
65
+
66
+
67
+ def _administration_modifier(in_vivo) -> dict | None:
68
+ """Documented administration-context modifier (in-vivo / ex-vivo) on the vector-facing axes, or None when the
69
+ design does not state an administration context. Qualitative, never a fabricated magnitude."""
70
+ if in_vivo is None:
71
+ return None
72
+ if _is_ex_vivo(in_vivo):
73
+ return {"context": "ex_vivo", "muted_axes": list(_EX_VIVO_MUTED_AXES),
74
+ "effect": "ex-vivo administration: the vector contacts cells in a dish and is washed before "
75
+ "transplant, so it is not exposed to the patient's circulating antibodies. Pre-existing "
76
+ "anti-vector NAb does not gate eligibility (humoral barrier bypassed); the systemic "
77
+ "anti-capsid CD8 response is muted, though transduced cells may still present capsid "
78
+ "epitopes (a residual, separate consideration).",
79
+ "note": "DOCUMENTED qualitative administration modifier (the same in-vivo/ex-vivo distinction as the "
80
+ "delivery profile); the realized immune MAGNITUDE remains a known-unknown."}
81
+ return {"context": "in_vivo", "muted_axes": [],
82
+ "effect": "in-vivo (systemic / local) administration: the vector is exposed to the patient's "
83
+ "circulating antibodies and immune system, so the pre-existing NAb and anti-capsid CD8 "
84
+ "axes apply as reported.",
85
+ "note": "DOCUMENTED qualitative administration modifier; the realized magnitude is a known-unknown."}
86
+
87
+
88
+ def _apply_administration(axes: dict, admin: dict | None) -> None:
89
+ """Mutate the vector-facing axes in place for an ex-vivo administration context. Pre-existing NAb is muted to
90
+ 'no barrier' (eligibility not gated when the vector never meets circulating antibody); CD8 capsid is flagged
91
+ muted but its intrinsic value is kept (transduced cells can still present). No-op for in-vivo / unspecified."""
92
+ if not admin or admin.get("context") != "ex_vivo":
93
+ return
94
+ na = axes.get("preexisting_nab")
95
+ if na and na.get("available") and na.get("value") is not None:
96
+ na["pre_admin_value"] = na["value"]
97
+ na["value"] = 1.0
98
+ na["uncertainty"] = 0.0
99
+ na["administration_muted"] = True
100
+ na["note"] = (f"ex-vivo administration: pre-existing circulating anti-vector antibodies do not reach the "
101
+ f"vector (transduction in a dish), so pre-existing NAb does not gate eligibility "
102
+ f"(muted from {na['pre_admin_value']} to 1.0 = no barrier). " + (na.get("note") or ""))
103
+ cd = axes.get("cd8_epitope")
104
+ if cd and cd.get("available") and cd.get("value") is not None:
105
+ cd["administration_muted"] = True
106
+ cd["note"] = ("ex-vivo administration: the systemic anti-capsid CD8 response is muted (minimal host capsid "
107
+ "exposure); transduced cells may still present capsid epitopes, so the intrinsic value is "
108
+ "kept. " + (cd.get("note") or ""))
109
+
110
+ # headline score key inside each oracle's value dict, per axis.
111
+ _SCORE_KEY = {"genotoxicity": "genotox_score", "cd8_epitope": "capsid_immune_score",
112
+ "innate": "innate_score", "preexisting_nab": "preexisting_score",
113
+ "anti_peg": "preexisting_antipeg_score"}
114
+
115
+ # Canonical cargo nucleic-acid FORM per delivery vehicle, used to drive the innate-sensing pathway (DNA -> TLR9
116
+ # CpG; mRNA -> TLR7/8 + RIG-I/MDA5; RNP -> transient) when a design does not state ``cargo_form`` explicitly.
117
+ # This is the vehicle's defining cargo class, NOT a fabricated quantity: an LNP-mRNA vehicle delivers mRNA, a
118
+ # DNA-virus / electroporated-plasmid vehicle delivers DNA, an (e)VLP delivers a transient ribonucleoprotein. A
119
+ # lentivirus is packaged as RNA but the persistent, innate-relevant cassette is the integrated proviral DNA.
120
+ # An unknown vehicle returns None so the innate axis ABSTAINS rather than guesses a form.
121
+ _VEHICLE_CARGO_FORM = {
122
+ "lnp_mrna": "mRNA",
123
+ "aav_single": "DNA", "aav_dual": "DNA", "lentivirus": "DNA",
124
+ "helper_dependent_adenovirus": "DNA", "hsv_amplicon": "DNA", "electroporation": "DNA",
125
+ "evlp": "RNP", "vlp": "RNP",
126
+ }
127
+
128
+
129
+ def _vehicle_cargo_form(vehicle: str | None) -> str | None:
130
+ """Derive the cargo nucleic-acid form (DNA / mRNA / RNP) from the delivery vehicle, or None when unknown.
131
+ Used only when the design does not supply ``cargo_form`` / ``writer_output_form`` explicitly."""
132
+ if not vehicle:
133
+ return None
134
+ v = str(vehicle).strip().lower()
135
+ if v in _VEHICLE_CARGO_FORM:
136
+ return _VEHICLE_CARGO_FORM[v]
137
+ if "mrna" in v or "lnp" in v:
138
+ return "mRNA"
139
+ if "vlp" in v:
140
+ return "RNP"
141
+ return None
142
+
143
+
144
+ def _axis(result, axis: str) -> dict:
145
+ """One axis record: value (headline score or None when abstaining) + native uncertainty + scope + the
146
+ calibration validation label. Never fabricates, an abstaining oracle yields value None."""
147
+ val = None
148
+ if result.available and result.value:
149
+ val = result.value.get(_SCORE_KEY[axis])
150
+ return {"value": val,
151
+ "uncertainty": result.native_uncertainty,
152
+ "in_scope": result.in_scope,
153
+ "available": result.available,
154
+ "validation": axis_label(axis), # 'mechanistic/population proxy' until calibration validates
155
+ "scope_card": result.scope_card,
156
+ "note": result.note}
157
+
158
+
159
+ def _proxy_axis(value, note: str, axis: str = "mhc2_writer") -> dict:
160
+ """An axis record for the sequence-intrinsic proxies (MHC-II / ADA). Value None = abstained (no writer
161
+ sequence). Population-level proxy until calibration validates, never a patient magnitude."""
162
+ return {"value": value, "uncertainty": None, "in_scope": value is not None, "available": value is not None,
163
+ "validation": axis_label(axis), "scope_card": axis, "note": note}
164
+
165
+
166
+ def _writer_antigen_card(design: dict) -> dict | None:
167
+ """The WRITER enzyme as a distinct antigen: MHC-II epitope load + ADA-risk over the writer's real
168
+ sequence. Returns None when no representative writer sequence is bundled (axis then abstains)."""
169
+ wf = design.get("writer_family") or design.get("writer")
170
+ rec = writer_family_to_sequence(wf) if wf else None
171
+ if not rec or not rec.get("seq"):
172
+ return None
173
+ nm = rec.get("name")
174
+ el = mhc2_epitope_load(rec["seq"], nm) # real NetMHCIIpan-4.0 when cached, else proxy
175
+ ad = ada_risk(rec["seq"], rec.get("origin"), name=nm)
176
+ return {"writer_family": wf, "representative": rec.get("name"), "accession": rec.get("accession"),
177
+ "origin": rec.get("origin"), "is_foreign": rec.get("origin") == "foreign",
178
+ "mhc2_immune_score": el["mhc2_immune_score"], "epitope_density": el["epitope_density"],
179
+ "ada_risk_score": ad["ada_risk_score"], "ada_immune_score": ad["ada_immune_score"],
180
+ "foreignness": ad.get("foreignness"),
181
+ "self_match_human_proteome": ad.get("self_match_human_proteome"),
182
+ "ada_backend": ad.get("backend"), "mhc2_backend": el.get("backend"),
183
+ "note": "the WRITER enzyme scored as a distinct antigen (real NetMHCIIpan-4.0 MHC-II/CD4 + ADA, "
184
+ "origin-authoritative foreignness with a real human-proteome 9-mer self-match cross-check); "
185
+ "bacterial/phage writers are foreign -> ADA-driving (Cas9 MHC-II: Simhadri 2021). "
186
+ "Population-level proxy; realized CD4 response / ADA titer is a known-unknown."}
187
+
188
+
189
+ # Genome-WRITER families surfaced in the Writer Atlas immunogenicity view: the bundled writers that are actual
190
+ # genome writers (integrase / recombinase), each with a committed real NetMHCIIpan-4.0 MHC-II load + ADA-risk cache.
191
+ # The Cas9 nuclease (an editor, not a large-cargo writer) is excluded; the human self control IS surfaced as a
192
+ # labelled reference row (see below).
193
+ _WRITER_IMMUNE_FAMILIES = ("serine_integrase", "bridge_IS110")
194
+ _SELF_CONTROL = "HumanAlbumin" # a human self protein: MHC-II epitopes present, yet tolerated (foreignness 0)
195
+
196
+
197
+ def writer_immunogenicity_table() -> list[dict]:
198
+ """Per-writer immunogenicity (MHC-II/CD4 epitope load + ADA risk) for the bundled genome-writer families, read
199
+ from the committed NetMHCIIpan-4.0 cache (no recomputation). Surfaced in the Writer Atlas as the writer's
200
+ antigen profile, PLUS the human self control as a labelled reference row: a self protein carries MHC-II
201
+ epitopes (density > 0) yet is TOLERATED (origin=self -> foreignness 0 -> ADA-risk 0), so ADA decouples from the
202
+ MHC-II load, the row that shows ADA-risk = MHC-II density x foreignness(origin) is genuinely multiplicative and
203
+ not a pass-through of the load. Excludes the Cas9 nuclease."""
204
+ out = []
205
+ for fam in _WRITER_IMMUNE_FAMILIES:
206
+ card = _writer_antigen_card({"writer_family": fam})
207
+ if card:
208
+ out.append({"writer_family": fam, **card})
209
+ ctrl = writer_sequences().get(_SELF_CONTROL)
210
+ if ctrl and ctrl.get("seq"):
211
+ el = mhc2_epitope_load(ctrl["seq"], _SELF_CONTROL)
212
+ ad = ada_risk(ctrl["seq"], ctrl.get("origin"), name=_SELF_CONTROL)
213
+ out.append({"writer_family": ctrl.get("family"), "representative": _SELF_CONTROL,
214
+ "accession": ctrl.get("accession"), "origin": ctrl.get("origin"),
215
+ "is_foreign": ctrl.get("origin") == "foreign", "is_control": True,
216
+ "mhc2_immune_score": el["mhc2_immune_score"], "epitope_density": el["epitope_density"],
217
+ "ada_risk_score": ad["ada_risk_score"], "ada_immune_score": ad["ada_immune_score"],
218
+ "foreignness": ad.get("foreignness"),
219
+ "self_match_human_proteome": ad.get("self_match_human_proteome"),
220
+ "ada_backend": ad.get("backend"), "mhc2_backend": el.get("backend"),
221
+ "note": ("human self control (reference): a self protein carries MHC-II epitopes (density > 0) "
222
+ "yet is TOLERATED, origin=self makes foreignness 0, so ADA-risk = density x 0 = 0 "
223
+ "(central tolerance). This is the row that shows the foreignness term does real "
224
+ "multiplicative work, not a pass-through of the MHC-II load.")})
225
+ return out
226
+
227
+
228
+ def immune_profile(design: dict) -> dict:
229
+ """Per-design immune-risk profile across all axes. ``design`` keys: ``delivery_vehicle`` (or ``vehicle``),
230
+ ``serotype``, ``cargo_seq``, ``writer_output_form`` (or ``cargo_form``), ``pegylated``.
231
+
232
+ Returns the axes vector with per-axis uncertainty + validation label, an explicit ``collapsed_score: None``
233
+ (no fused number), the known-unknowns, and ``no_fabrication: True``."""
234
+ veh = design.get("delivery_vehicle") or design.get("vehicle")
235
+ sero = design.get("serotype")
236
+ cargo_seq = design.get("cargo_seq") or ""
237
+ # cargo form: honour an explicit design field, else derive it from the vehicle's defining cargo class so the
238
+ # innate axis computes for any caller (chat / MCP / REST) once a cargo sequence is supplied. Still abstains
239
+ # with no sequence and for an unknown vehicle (form None) - never fabricates.
240
+ form = (design.get("writer_output_form") or design.get("cargo_form")
241
+ or _vehicle_cargo_form(veh) or "")
242
+ peg = design.get("pegylated")
243
+
244
+ capsid_cd8 = capsid_epitope_oracle(veh)
245
+ writer_card = _writer_antigen_card(design) # the writer enzyme as a distinct antigen
246
+
247
+ axes = {
248
+ "genotoxicity": _axis(genotoxicity_oracle(veh), "genotoxicity"),
249
+ "cd8_epitope": _axis(capsid_cd8, "cd8_epitope"), # capsid CD8/MHC-I
250
+ "innate": _axis(innate_sensing(cargo_seq, form), "innate"),
251
+ "preexisting_nab": _axis(seroprevalence_oracle(veh, sero), "preexisting_nab"),
252
+ "anti_peg": _axis(antipeg_oracle(veh, peg), "anti_peg"),
253
+ # CD4/MHC-II + ADA over the WRITER enzyme (the dominant immunogenicity driver, previously omitted)
254
+ "mhc2_writer": _proxy_axis(writer_card["mhc2_immune_score"] if writer_card else None,
255
+ (writer_card or {}).get("note", "no bundled writer sequence -> abstains"),
256
+ "mhc2_writer"),
257
+ "ada_writer": _proxy_axis(writer_card["ada_immune_score"] if writer_card else None,
258
+ "ADA-risk (higher ada_immune_score = safer) over the writer enzyme, self-"
259
+ "tolerance filtered; population proxy" if writer_card else
260
+ "no bundled writer sequence -> abstains", "ada_writer"),
261
+ }
262
+
263
+ # Administration context: ex-vivo delivery bypasses the patient's circulating antibodies, so the
264
+ # vector-facing axes (pre-existing NAb, capsid CD8) are muted - documented, not a fabricated magnitude.
265
+ admin = _administration_modifier(design.get("in_vivo"))
266
+ _apply_administration(axes, admin)
267
+
268
+ # writer-as-antigen comparison: for non-viral delivery (no foreign capsid) or a foreign writer outscoring the
269
+ # capsid, the WRITER is the dominant antigen, never collapsed into the other axes.
270
+ dominant = None
271
+ writer_dominant_risk = False
272
+ if writer_card:
273
+ capsid_present = bool(capsid_cd8.available and capsid_cd8.value
274
+ and (capsid_cd8.value.get("capsid_immune_score") or 1.0) < 1.0)
275
+ writer_risk = writer_card["ada_risk_score"]
276
+ capsid_risk = (1.0 - (capsid_cd8.value or {}).get("capsid_immune_score", 1.0)) if capsid_present else 0.0
277
+ writer_dominant_risk = bool(writer_card["is_foreign"] and (not capsid_present or writer_risk >= capsid_risk))
278
+ dominant = "writer" if writer_dominant_risk else ("capsid" if capsid_present else "writer")
279
+
280
+ return {
281
+ "axes": axes,
282
+ "collapsed_score": None, # deliberately None, a profile, never a fused number
283
+ "writer_as_antigen": ({**writer_card, "dominant_antigen": dominant,
284
+ "writer_dominant_risk": writer_dominant_risk} if writer_card else None),
285
+ "route_modifier": _route_modifier(design.get("route")), # documented route modifier (or None)
286
+ "administration_modifier": admin, # in-vivo / ex-vivo documented modifier on the vector-facing axes
287
+ "known_unknowns": KNOWN_UNKNOWNS,
288
+ "no_fabrication": True,
289
+ "note": ("relative immune-risk SCREEN across axes (now incl. CD4/MHC-II + ADA over the writer enzyme); each "
290
+ "axis keeps its own value + uncertainty + scope + validation label; NEVER fused. NOT a patient-"
291
+ "level prediction. The realized CD4 response / ADA titer / in-vivo magnitude are known-unknowns."),
292
+ }
@@ -0,0 +1,135 @@
1
+ """Computed innate-immune-sensing scorer for nucleic-acid cargo.
2
+
3
+ Innate sensing of a delivered nucleic acid is a property of the CARGO SEQUENCE (and its form), computed here
4
+ directly from sequence - the third computed delivery-immunology signal (after genotoxicity and capsid
5
+ epitope load). It covers every cargo form the palette carries:
6
+
7
+ * DNA (AAV / HDAd / HSV / electroporated plasmid) -> TLR9 / cGAS sensing of unmethylated CpG. The standard
8
+ sequence statistic is the CpG observed/expected ratio (Gardiner-Garden & Frommer): vertebrate genomes are
9
+ CpG-DEPLETED (O/E ~ 0.2) and tolerated, while non-depleted plasmid/viral DNA (O/E -> 1) is TLR9-stimulatory;
10
+ CpG-DEPLETED vectors evade detection. innate_score = max(0, 1 - CpG_O/E).
11
+ * mRNA (LNP-mRNA / electroporated mRNA) -> TLR7/8 (U-rich ssRNA) + RIG-I / MDA5 / PKR (dsRNA). Computed from
12
+ uridine fraction + ViennaRNA base-pairing. This signal is PARTIAL and flagged `extrapolating`: the dominant
13
+ innate-evasion lever for mRNA is NUCLEOSIDE MODIFICATION (m1-pseudouridine), which is NOT derivable from the
14
+ nucleotide sequence (a manufacturing choice) - a stated known-limitation.
15
+ * RNP / protein -> minimal, transient nucleic-acid exposure (no DNA; short-lived gRNA): score ~ high.
16
+
17
+ Answers through the OracleResult contract (output_kind="baseline"). SCOPE: this is a sequence-intrinsic
18
+ motif-LOAD signal; the realized in-vivo innate RESPONSE magnitude in a patient is NOT modelled and stays a
19
+ known-unknown; DNA methylation state and RNA nucleoside modification are out of sequence scope.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from pen_stack.oracles.schema import OracleResult, Provenance
24
+
25
+ _SCOPE_CARD = "innate_sensing"
26
+ _DNA = set("ACGT")
27
+ _RNA = set("ACGU")
28
+ # CpG-TLR9 (Krieg 1995 / Bauer 2001), CpG-depleted AAV evasion (Faust 2013), RNA nucleoside modification
29
+ # (Kariko 2005), 5'ppp dsRNA RIG-I (Hornung 2006).
30
+ PROVENANCE_DOIS = ["10.1038/374546a0", "10.1073/pnas.161293498", "10.1172/JCI68205",
31
+ "10.1016/j.immuni.2005.06.008", "10.1126/science.1132505"]
32
+
33
+
34
+ def _clean(seq: str) -> str:
35
+ return "".join(c for c in (seq or "").upper() if c.isalpha())
36
+
37
+
38
+ def cpg_observed_expected(dna: str) -> dict:
39
+ """CpG observed/expected ratio (Gardiner-Garden & Frommer): (n_CpG / (n_C * n_G)) * length. Vertebrate
40
+ genome ~0.2 (depleted); non-depleted plasmid/viral DNA approaches 1; engineered CpG-free -> 0."""
41
+ s = _clean(dna).replace("U", "T")
42
+ L = len(s)
43
+ nC, nG = s.count("C"), s.count("G")
44
+ n_cpg = s.count("CG")
45
+ oe = (n_cpg / (nC * nG) * L) if (nC and nG) else 0.0
46
+ gc = (nC + nG) / L if L else 0.0
47
+ return {"length": L, "cpg_count": n_cpg, "cpg_oe": round(oe, 4), "gc": round(gc, 4)}
48
+
49
+
50
+ def _dsrna_paired_fraction(rna: str) -> float | None:
51
+ """Fraction of bases paired in the ViennaRNA MFE structure (dsRNA -> RIG-I/MDA5/PKR). None if ViennaRNA
52
+ absent (graceful degradation, as in bridge/fold_qc.py)."""
53
+ try:
54
+ import RNA
55
+ except Exception: # noqa: BLE001
56
+ return None
57
+ s = _clean(rna).replace("T", "U")
58
+ if not s:
59
+ return None
60
+ struct, _ = RNA.fold_compound(s).mfe()
61
+ paired = sum(1 for c in struct if c in "()")
62
+ return round(paired / len(struct), 4) if struct else None
63
+
64
+
65
+ def _prov() -> Provenance:
66
+ return Provenance(model="cargo_innate_sensing", version="1.0", source="adapter",
67
+ extra={"provenance_dois": PROVENANCE_DOIS})
68
+
69
+
70
+ def innate_sensing(seq: str, cargo_form: str) -> OracleResult:
71
+ """Computed innate-sensing score for a cargo sequence + form, as an OracleResult.
72
+
73
+ `cargo_form` in {DNA, mRNA, RNP}. Returns innate_score in [0,1] (1 = least innate-stimulatory). Abstains
74
+ (available=False) on an empty sequence or an unrecognised/uncomputable form. Never fabricates."""
75
+ s = _clean(seq)
76
+ form = (cargo_form or "").strip()
77
+ if not s:
78
+ return OracleResult(oracle="genome", value=None, provenance=_prov(), scope_card=_SCOPE_CARD,
79
+ in_scope=False, available=False, output_kind="baseline",
80
+ note="no cargo sequence supplied")
81
+
82
+ if form == "DNA":
83
+ c = cpg_observed_expected(s)
84
+ score = max(0.0, min(1.0, 1.0 - c["cpg_oe"]))
85
+ return OracleResult(
86
+ oracle="genome",
87
+ value={"innate_score": round(score, 3), "pathway": "TLR9/cGAS (unmethylated CpG)",
88
+ "cpg_oe": c["cpg_oe"], "cpg_count": c["cpg_count"], "gc": c["gc"], "length": c["length"]},
89
+ provenance=_prov(), native_uncertainty=None, scope_card=_SCOPE_CARD, in_scope=True,
90
+ extrapolating=False, output_kind="baseline", available=True,
91
+ note=(f"CpG O/E={c['cpg_oe']} ({c['cpg_count']} CpG, {c['length']} bp); innate_score=max(0,1-O/E). "
92
+ "Vertebrate genome O/E~0.2 (tolerated), non-depleted DNA ->1 (TLR9-stimulatory). DNA "
93
+ "methylation state and the realized in-vivo innate RESPONSE are known-unknowns (not modelled)."))
94
+
95
+ if form == "mRNA":
96
+ u_frac = s.replace("T", "U").count("U") / len(s)
97
+ paired = _dsrna_paired_fraction(s)
98
+ # partial sequence-only signal: U-richness (TLR7/8) + dsRNA pairing (RIG-I/PKR). The dominant evasion
99
+ # lever (nucleoside modification, m1-pseudouridine) is NOT sequence-derivable -> flagged extrapolating.
100
+ if paired is None:
101
+ score = max(0.0, min(1.0, 1.0 - u_frac))
102
+ note_ds = "ViennaRNA absent: dsRNA term omitted; score from U-fraction only."
103
+ else:
104
+ score = max(0.0, min(1.0, 1.0 - 0.5 * u_frac - 0.5 * paired))
105
+ note_ds = f"dsRNA paired_fraction={paired} (RIG-I/MDA5/PKR)."
106
+ return OracleResult(
107
+ oracle="rna",
108
+ value={"innate_score": round(score, 3), "pathway": "TLR7/8 (U-rich ssRNA) + RIG-I/MDA5/PKR (dsRNA)",
109
+ "u_fraction": round(u_frac, 4), "dsrna_paired_fraction": paired, "length": len(s)},
110
+ provenance=_prov(), native_uncertainty=None, scope_card=_SCOPE_CARD, in_scope=True,
111
+ extrapolating=True, output_kind="baseline", available=True,
112
+ note=("PARTIAL sequence-only signal. " + note_ds + " The dominant mRNA innate-evasion lever - "
113
+ "NUCLEOSIDE MODIFICATION (m1-pseudouridine) - is NOT sequence-derivable and is out of scope; "
114
+ "the realized in-vivo innate response is a known-unknown."))
115
+
116
+ if form == "RNP":
117
+ return OracleResult(
118
+ oracle="rna",
119
+ value={"innate_score": 0.9, "pathway": "minimal (transient gRNA; no DNA)", "length": len(s)},
120
+ provenance=_prov(), native_uncertainty=None, scope_card=_SCOPE_CARD, in_scope=True,
121
+ extrapolating=True, output_kind="baseline", available=True,
122
+ note=("RNP cargo: transient, no DNA -> minimal nucleic-acid innate sensing (synthetic gRNA may "
123
+ "trigger RIG-I via 5'-triphosphate; modification mitigates - not sequence-derivable). "
124
+ "Realized response is a known-unknown."))
125
+
126
+ return OracleResult(oracle="genome", value=None, provenance=_prov(), scope_card=_SCOPE_CARD,
127
+ in_scope=False, available=False, output_kind="baseline",
128
+ note=f"unrecognised cargo_form {cargo_form!r} (expected DNA / mRNA / RNP)")
129
+
130
+
131
+ def computed_innate_score(seq: str, cargo_form: str) -> tuple[float | None, OracleResult]:
132
+ """Convenience: (innate_score or None, full OracleResult). None when the scorer abstains. Never fabricates."""
133
+ r = innate_sensing(seq, cargo_form)
134
+ val = (r.value or {}).get("innate_score") if (r.available and r.value) else None
135
+ return val, r