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,110 @@
1
+ """Multiplex translocation-risk flag.
2
+
3
+ For a multi-edit plan (2-5 edits), two simultaneous double-strand breaks (DSBs) at different loci can
4
+ mis-join into a TRANSLOCATION. This is a classical, interpretable SCREEN - not a calibrated translocation
5
+ predictor. We gather every edit's DSB sites (on-target + predicted off-targets, each with a cut probability),
6
+ enumerate all site PAIRS exactly (cheap for 2-5 edits), and combine pairwise DSB-join probabilities into a
7
+ `translocation_risk` in [0,1].
8
+
9
+ Key property: **DSB-free writers (bridge / seek recombinases) contribute NO cut sites**, so a plan
10
+ built from them carries ~zero translocation risk - which is the whole point of programmable recombinases.
11
+ The flag is monotonic (more sites / higher cut prob / closer pairs -> higher risk) and reports its top pairs
12
+ so a user can see WHY. A QUBO formulation is provided as a documented OPTIONAL baseline, off by default.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ from itertools import combinations
18
+
19
+ # writer families that cut DNA (DSB) vs DSB-free programmable recombinases / writers.
20
+ _DSB_FREE = {"bridge_is110", "bridge_iscro4", "seek_is1111", "bridge", "seek", "pe_integrase",
21
+ "prime_editor", "recombinase"}
22
+ _DEFAULT_ON_TARGET_CUT = 0.8 # nominal on-target cut efficiency for a DSB nuclease (documented prior)
23
+ _INTRA_CHROM_LENGTH = 1.0e7 # bp decay length for intra-chromosomal join propensity (10 Mb)
24
+
25
+
26
+ def is_dsb_free(family: str | None) -> bool:
27
+ return str(family or "").lower() in _DSB_FREE
28
+
29
+
30
+ def cut_sites(edit: dict) -> list[dict]:
31
+ """DSB sites for one edit. DSB-free writers -> []. Otherwise on-target (+ off-targets if provided).
32
+
33
+ `edit` keys: family, chrom, pos (on-target); optional on_target_cut; optional offtargets=[{chrom,pos,
34
+ p_cut|risk}]. Off-target risk in [0,1] is used directly as a cut probability.
35
+ """
36
+ if is_dsb_free(edit.get("family")):
37
+ return []
38
+ sites = []
39
+ if edit.get("chrom") is not None and edit.get("pos") is not None:
40
+ sites.append({"chrom": edit["chrom"], "pos": int(edit["pos"]),
41
+ "p_cut": float(edit.get("on_target_cut", _DEFAULT_ON_TARGET_CUT)),
42
+ "kind": "on_target", "edit": edit.get("name")})
43
+ for ot in edit.get("offtargets", []) or []:
44
+ p = float(ot.get("p_cut", ot.get("risk", 0.0)))
45
+ if p > 0 and ot.get("chrom") is not None and ot.get("pos") is not None:
46
+ sites.append({"chrom": ot["chrom"], "pos": int(ot["pos"]), "p_cut": min(1.0, p),
47
+ "kind": "off_target", "edit": edit.get("name")})
48
+ return sites
49
+
50
+
51
+ def _join_factor(a: dict, b: dict) -> float:
52
+ """Propensity that two DSBs mis-join: 1.0 inter-chromosomal; distance-decayed intra-chromosomal."""
53
+ if a["chrom"] != b["chrom"]:
54
+ return 1.0
55
+ d = abs(a["pos"] - b["pos"])
56
+ return math.exp(-d / _INTRA_CHROM_LENGTH)
57
+
58
+
59
+ def pairwise_risks(sites: list[dict]) -> list[dict]:
60
+ """Exact pairwise DSB-join probabilities for every unordered site pair (across and within edits)."""
61
+ out = []
62
+ for i, j in combinations(range(len(sites)), 2):
63
+ a, b = sites[i], sites[j]
64
+ jp = a["p_cut"] * b["p_cut"] * _join_factor(a, b)
65
+ out.append({"a": f"{a['edit']}:{a['kind']}@{a['chrom']}:{a['pos']}",
66
+ "b": f"{b['edit']}:{b['kind']}@{b['chrom']}:{b['pos']}",
67
+ "inter_chromosomal": a["chrom"] != b["chrom"], "join_prob": round(jp, 5)})
68
+ return sorted(out, key=lambda r: r["join_prob"], reverse=True)
69
+
70
+
71
+ def translocation_risk(edits: list[dict], low: float = 0.05, moderate: float = 0.2,
72
+ top_k: int = 5) -> dict:
73
+ """Aggregate translocation-risk flag for a multi-edit plan. risk = 1 - prod(1 - pairwise_join_prob).
74
+
75
+ Monotonic in every pairwise probability; interpretable via the top contributing pairs. A SCREEN, not a
76
+ calibrated predictor.
77
+ """
78
+ if not 2 <= len(edits) <= 5:
79
+ # still computes, but the flag is meant for multiplex (2-5 simultaneous edits)
80
+ note = "translocation risk is defined for multiplex plans (2-5 simultaneous edits)"
81
+ else:
82
+ note = None
83
+ sites = [s for e in edits for s in cut_sites(e)]
84
+ pairs = pairwise_risks(sites)
85
+ prod = 1.0
86
+ for p in pairs:
87
+ prod *= (1.0 - p["join_prob"])
88
+ risk = round(1.0 - prod, 5)
89
+ band = "low" if risk < low else ("moderate" if risk < moderate else "high")
90
+ n_dsb_free = sum(1 for e in edits if is_dsb_free(e.get("family")))
91
+ return {"translocation_risk": risk, "band": band, "n_edits": len(edits),
92
+ "n_cut_sites": len(sites), "n_pairs": len(pairs),
93
+ "n_dsb_free_edits": n_dsb_free,
94
+ "all_dsb_free": n_dsb_free == len(edits),
95
+ "top_pairs": pairs[:top_k],
96
+ "note": note,
97
+ "scope": "classical pairwise DSB-join SCREEN, not a calibrated translocation predictor; "
98
+ "DSB-free recombinase plans carry ~zero risk by construction"}
99
+
100
+
101
+ def qubo_baseline(edits: list[dict], variants_per_edit: dict[str, list[dict]] | None = None) -> dict:
102
+ """OPTIONAL, OFF BY DEFAULT - a documented QUBO baseline for selecting per-edit guide variants that
103
+ minimize total pairwise translocation risk. Returns the QUBO Q-matrix terms only; no solver is invoked
104
+ and this is NOT the recommended path (the exact pairwise screen above is exact for 2-5 edits). Provided
105
+ for completeness / external comparison, clearly labeled optional.
106
+ """
107
+ return {"enabled": False, "kind": "QUBO (optional baseline)",
108
+ "note": "exact pairwise enumeration is tractable and exact for 2-5 edits; the QUBO path is an "
109
+ "optional baseline for large multiplex selection problems and is off by default.",
110
+ "n_variant_sets": len(variants_per_edit or {})}
@@ -0,0 +1,278 @@
1
+ """Inverse-design optimiser with edit_intent.
2
+
3
+ Given a goal (gene/locus, edit_intent, cargo, cell type), search destination x writer for the joint
4
+ optimum of safety x durability x reachability x writer-activity, conditioned on an explicit
5
+ ``edit_intent``. The intent is *load-bearing*: its ``target_gene_sign`` decides whether hitting the
6
+ named target gene/element is penalised (safe-harbour: avoid) or rewarded (knock-in / excision: intended)
7
+ - so the same locus ranks high or low depending only on the stated goal.
8
+
9
+ Components are retained on every candidate row; the score is a transparent linear combination read from
10
+ ``configs/intent_weights.yaml``. Reachability is a hard filter (Tier-1 high-confidence; Tier-2 candidate
11
+ flagged). Writer activity comes from the Writer Atlas (measured human-cell axis per family).
12
+
13
+ Inputs : writability atlas (safety/p_durable/reachable_tier1) + atlas.parquet.
14
+ Outputs: ranked (writer, site) candidates with full component provenance.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from enum import Enum
19
+ from functools import lru_cache
20
+ from pathlib import Path
21
+
22
+ import numpy as np
23
+ import pandas as pd
24
+ import yaml
25
+
26
+ _ROOT = Path(__file__).resolve().parents[2]
27
+ _CFG = _ROOT / "configs" / "intent_weights.yaml"
28
+ _ATLAS = _ROOT / "pen_stack" / "atlas" / "atlas.parquet"
29
+ BIN_BP = 1000
30
+
31
+
32
+ class EditIntent(str, Enum):
33
+ SAFE_HARBOUR = "safe_harbour_insertion"
34
+ LANDING_PAD = "landing_pad_insertion"
35
+ KNOCK_IN_DISRUPT = "knock_in_with_disruption"
36
+ HIGH_DURABILITY = "high_durability_insertion"
37
+ REG_EXCISION = "regulatory_element_excision"
38
+ REPEAT_EXCISION = "repeat_excision"
39
+
40
+ @classmethod
41
+ def _missing_(cls, value): # backward-compat aliases so older callers / the earlier value still resolve
42
+ return {"regulatory_excision": cls.REG_EXCISION, "landing_pad": cls.LANDING_PAD}.get(str(value))
43
+
44
+
45
+ @lru_cache(maxsize=1)
46
+ def load_intent_weights(path: str | Path = _CFG) -> dict:
47
+ return yaml.safe_load(Path(path).read_text(encoding="utf-8"))
48
+
49
+
50
+ @lru_cache(maxsize=1)
51
+ def writer_activity_by_family(atlas_path: str | Path = _ATLAS) -> dict:
52
+ """Per-family writer-activity proxy from the Writer Atlas curated cores (measured human-cell axis).
53
+
54
+ Falls back to readiness when S_HumanCell is missing. Used so the optimiser prefers writers that
55
+ actually work in human cells (e.g. bridge ISCro4) over weakly-active families.
56
+ """
57
+ atlas = pd.read_parquet(atlas_path)
58
+ core = atlas[atlas["entry_kind"] == "curated_core"] if "entry_kind" in atlas else atlas
59
+ act = {}
60
+ for fam, sub in core.groupby("family"):
61
+ r = sub.iloc[0]
62
+ a = r.get("S_HumanCell")
63
+ if a is None or pd.isna(a):
64
+ a = r.get("readiness", 0.5)
65
+ act[fam] = float(a) if pd.notna(a) else 0.5
66
+ return act
67
+
68
+
69
+ def _best_writer(reachable_tier1: str, cargo_bp: int, atlas_caps: dict, activity: dict) -> tuple[str, float, bool]:
70
+ """Pick the best reachable writer that fits the cargo: (family, activity, cargo_ok)."""
71
+ fams = [f for f in str(reachable_tier1).split(";") if f]
72
+ best, best_act, best_ok = None, -1.0, False
73
+ for f in fams:
74
+ cap = atlas_caps.get(f)
75
+ ok = (cap is None) or (cargo_bp <= cap)
76
+ a = activity.get(f, 0.4)
77
+ # prefer cargo-fitting writers; among those, highest activity
78
+ rank = (1 if ok else 0, a)
79
+ if rank > (1 if best_ok else 0, best_act):
80
+ best, best_act, best_ok = f, a, ok
81
+ return best or (fams[0] if fams else "unknown"), best_act if best else 0.4, best_ok
82
+
83
+
84
+ def score_candidates(cands: pd.DataFrame, intent: EditIntent | str, cargo_bp: int) -> pd.DataFrame:
85
+ """Score a candidate DataFrame (needs: safety, p_durable, reachable_tier1, on_target[bool]).
86
+
87
+ Adds: writer (family), writer_activity, cargo_ok, score, and the retained components.
88
+ """
89
+ intent = EditIntent(intent) if not isinstance(intent, EditIntent) else intent
90
+ cfg = load_intent_weights()
91
+ w = cfg["intents"][intent.value]
92
+ mag = float(cfg.get("on_target_magnitude", 1.0))
93
+
94
+ atlas = pd.read_parquet(_ATLAS)
95
+ caps = (atlas.dropna(subset=["cargo_capacity_bp"]).groupby("family")["cargo_capacity_bp"].max().to_dict())
96
+ activity = writer_activity_by_family()
97
+
98
+ out = cands.copy()
99
+ picks = out["reachable_tier1"].apply(lambda rt: _best_writer(rt, cargo_bp, caps, activity))
100
+ out["writer"] = [p[0] for p in picks]
101
+ out["writer_activity"] = [p[1] for p in picks]
102
+ out["cargo_ok"] = [p[2] for p in picks]
103
+
104
+ on_target = out.get("on_target", pd.Series(False, index=out.index)).astype(float)
105
+ base = (w["safety"] * out["safety"].astype(float)
106
+ + w["durability"] * out["p_durable"].astype(float)
107
+ + w["activity"] * out["writer_activity"].astype(float))
108
+ # target_gene_sign: +1 -> penalise on-target (avoid the gene); -1 -> reward on-target (hit the gene)
109
+ out["score"] = base - w["target_gene_sign"] * mag * on_target
110
+ # cargo that cannot be delivered by any reachable writer is penalised
111
+ out.loc[~out["cargo_ok"], "score"] -= 0.5
112
+ # HARD GENOTOXICITY GATE (every intent): a locus below the safety floor sits in the oncogene/essential-gene
113
+ # danger zone. Flag it (`genotoxic`) so the UI warns, and DEMOTE it below every safe site -- UNLESS it is the
114
+ # intended on-target of a gene-TARGETING intent (target_gene_sign < 0), where the user chose to hit this locus,
115
+ # so a warning (not a block) is correct. This stops a durability-first intent from top-ranking oncogene-adjacent
116
+ # sites as if they were fine.
117
+ floor = float(cfg.get("safety_floor", 0.5))
118
+ penalty = float(cfg.get("unsafe_penalty", 5.0))
119
+ unsafe = out["safety"].astype(float) < floor
120
+ out["genotoxic"] = unsafe
121
+ intended_hit = on_target.astype(bool) if w["target_gene_sign"] < 0 else pd.Series(False, index=out.index)
122
+ out["score"] = out["score"] - penalty * (unsafe & ~intended_hit).astype(float)
123
+ out["intent"] = intent.value
124
+ # Deterministic ranking: a stable sort with explicit tie-breakers, so tied scores (common when safety
125
+ # saturates) always resolve identically across runs - the default quicksort is NOT stable.
126
+ keys = ["score"] + [c for c in ("chrom", "bin", "gene") if c in out.columns]
127
+ asc = [False] + [True] * (len(keys) - 1)
128
+ return out.sort_values(keys, ascending=asc, kind="stable").reset_index(drop=True)
129
+
130
+
131
+ # ---------------------------------------------------------------- plan-level confidence (uncertainty propagation)
132
+
133
+ # Default per-axis conformal half-widths used when an explicit uncertainty calibration is not supplied. These are
134
+ # documented placeholders, not magic numbers: ``durability`` is the widest because the durability signal is
135
+ # the weakest (silenced-AUROC ~0.65); ``safety`` and ``activity`` are tighter. A caller that has run
136
+ # the conformal calibration (validate/uncertainty_eval) passes the measured half-widths instead.
137
+ DEFAULT_AXIS_HALF_WIDTHS = {"safety": 0.10, "durability": 0.15, "activity": 0.10}
138
+
139
+
140
+ def _axis_intervals(row, half_widths: dict, ood_factor: float = 1.0) -> dict:
141
+ """Per-axis {point, lo, hi} for a candidate, intervals widened by the OOD factor."""
142
+ cols = {"safety": "safety", "durability": "p_durable", "activity": "writer_activity"}
143
+ out = {}
144
+ for axis, col in cols.items():
145
+ p = float(row[col]) if col in row and pd.notna(row[col]) else 0.5
146
+ h = float(half_widths.get(axis, 0.1)) * float(ood_factor)
147
+ out[axis] = {"point": p, "lo": max(0.0, p - h), "hi": min(1.0, p + h)}
148
+ return out
149
+
150
+
151
+ def attach_uncertainty(scored: pd.DataFrame, intent: EditIntent | str,
152
+ half_widths: dict | None = None, ood_factor=None,
153
+ threshold: float = 0.5, abstain_below: float = 0.5) -> pd.DataFrame:
154
+ """Propagate per-axis conformal intervals into a plan-level confidence + epistemic status.
155
+
156
+ Additive: takes the output of :func:`score_candidates`/:func:`plan` and appends ``confidence``,
157
+ ``score_lo``/``score_hi`` (90% plan-score band), ``abstain`` and ``epistemic_status`` per plan, using
158
+ :func:`selective_prediction.propagate_plan_confidence` over the intent's axis weights. ``ood_factor`` may
159
+ be a scalar or a per-row sequence (from :class:`wgenome.ood.OODDetector.widen_factor`) so out-of-
160
+ distribution sites get wider bands and lower confidence - the grounded-confident vs grounded-
161
+ extrapolating distinction. Does not change the existing ranking columns.
162
+ """
163
+ from pen_stack.validate.selective_prediction import propagate_plan_confidence
164
+ intent = EditIntent(intent) if not isinstance(intent, EditIntent) else intent
165
+ w = load_intent_weights()["intents"][intent.value]
166
+ weights = {"safety": w["safety"], "durability": w["durability"], "activity": w["activity"]}
167
+ half_widths = half_widths or DEFAULT_AXIS_HALF_WIDTHS
168
+ out = scored.copy().reset_index(drop=True)
169
+ if ood_factor is None:
170
+ ood_factor = [1.0] * len(out)
171
+ elif np.isscalar(ood_factor):
172
+ ood_factor = [float(ood_factor)] * len(out)
173
+
174
+ conf, lo, hi, status, abst = [], [], [], [], []
175
+ for i, row in out.iterrows():
176
+ of = float(ood_factor[i])
177
+ axes = _axis_intervals(row, half_widths, of)
178
+ prop = propagate_plan_confidence(axes, weights, threshold=threshold)
179
+ c = prop["confidence"]
180
+ conf.append(round(c, 4))
181
+ lo.append(round(prop["lo"], 4))
182
+ hi.append(round(prop["hi"], 4))
183
+ is_abstain = bool(c < abstain_below)
184
+ abst.append(is_abstain)
185
+ if of >= 1.5:
186
+ status.append("grounded-extrapolating")
187
+ elif is_abstain:
188
+ status.append("not-computable")
189
+ else:
190
+ status.append("grounded-confident")
191
+ out["confidence"] = conf
192
+ out["score_lo"] = lo
193
+ out["score_hi"] = hi
194
+ out["abstain"] = abst
195
+ out["epistemic_status"] = status
196
+ return out
197
+
198
+
199
+ def mechanistic_filter(reachable_tier1: str, site_seq: str, installed_att: bool = False) -> dict:
200
+ """Hard sequence-level reachability filter on a concrete site.
201
+
202
+ Given the WT-KB Tier-1 writer list (the ``reachable_tier1`` string) and the site's sequence, drop writers
203
+ whose required targeting element (PAM / core dinucleotide / att) is absent, a physically impossible
204
+ writer, site pairing is rejected. Returns the surviving family list + the rejections (with reasons). Called
205
+ by the Planner/agent only when a concrete site sequence is available; the bulk bin ranking is unchanged.
206
+ """
207
+ from pen_stack.planner.target_site import filter_reachable
208
+ fams = [f for f in str(reachable_tier1).split(";") if f]
209
+ res = filter_reachable(fams, site_seq, installed_att=installed_att)
210
+ res["reachable_str"] = ";".join(res["reachable"])
211
+ return res
212
+
213
+
214
+ def gene_coords_path() -> Path:
215
+ """Locate gene_coords.parquet: packaged copy first (works in any container), then phase_1."""
216
+ for p in (_ROOT / "data" / "curated" / "gene_coords.parquet",
217
+ _ROOT.parent / "phase_1" / "app_data" / "gene_coords.parquet"):
218
+ if p.exists():
219
+ return p
220
+ return _ROOT / "data" / "curated" / "gene_coords.parquet"
221
+
222
+
223
+ @lru_cache(maxsize=8)
224
+ def _gene_coords(path: str | None = None) -> pd.DataFrame:
225
+ return pd.read_parquet(Path(path) if path else gene_coords_path())
226
+
227
+
228
+ # Genomic safe-harbour *locus nicknames* are not HGNC gene symbols, so they are absent from gene_coords; map the
229
+ # well-documented ones to their host gene so a user who types the common name still gets a plan. AAVS1 = intron 1
230
+ # of PPP1R12C (19q13.42); H11/Hipp11 = an intron of EIF4ENIF1 (22q12). (CCR5, CLYBL, HPRT1 are real symbols.)
231
+ _GSH_ALIASES = {"AAVS1": "PPP1R12C", "H11": "EIF4ENIF1", "HIPP11": "EIF4ENIF1"}
232
+
233
+
234
+ def resolve_gene(gene: str) -> str:
235
+ """Map a safe-harbour locus nickname (e.g. AAVS1) to its HGNC host gene, and normalise a real symbol to the
236
+ exact casing stored in the coordinate table so 'lmo2' resolves like 'LMO2' (case-insensitive match). This
237
+ preserves legitimately mixed-case symbols (e.g. C1orf43) by returning the stored spelling, not a blind upper().
238
+ An unknown symbol -- or an absent coords table -- passes through with surrounding whitespace stripped."""
239
+ sym = _GSH_ALIASES.get(str(gene).strip().upper(), str(gene).strip())
240
+ try:
241
+ gc = _gene_coords()
242
+ hit = gc.loc[gc["gene"].str.upper() == sym.upper(), "gene"]
243
+ if len(hit):
244
+ return str(hit.iloc[0])
245
+ except Exception: # noqa: BLE001 - coords table absent -> fall back to the stripped symbol
246
+ pass
247
+ return sym
248
+
249
+
250
+ def gene_region(gene: str, flank_kb: int = 50) -> tuple[str, int, int] | None:
251
+ gc = _gene_coords()
252
+ g = gc[gc["gene"] == resolve_gene(gene)]
253
+ if g.empty:
254
+ return None
255
+ r = g.iloc[0]
256
+ return r["chrom"], max(0, int(r["start"]) - flank_kb * 1000), int(r["end"]) + flank_kb * 1000
257
+
258
+
259
+ def plan(gene: str, intent: EditIntent | str, cargo_bp: int, writable_df: pd.DataFrame,
260
+ k: int = 10, flank_kb: int = 50) -> pd.DataFrame:
261
+ """Rank (writer, site) candidates near a gene for the given intent. Components retained."""
262
+ intent = EditIntent(intent) if not isinstance(intent, EditIntent) else intent
263
+ reg = gene_region(gene, flank_kb)
264
+ if reg is None:
265
+ return pd.DataFrame()
266
+ chrom, lo, hi = reg
267
+ sub = writable_df[(writable_df["chrom"] == chrom)
268
+ & (writable_df["bin"].between(lo // BIN_BP, hi // BIN_BP))].copy()
269
+ if sub.empty:
270
+ return pd.DataFrame()
271
+ # on_target = bin overlaps the gene body (not just the flank)
272
+ g = _gene_coords()
273
+ gr = g[g["gene"] == resolve_gene(gene)].iloc[0]
274
+ sub["on_target"] = sub["bin"].between(int(gr["start"]) // BIN_BP, int(gr["end"]) // BIN_BP)
275
+ scored = score_candidates(sub, intent, cargo_bp)
276
+ cols = ["chrom", "bin", "writer", "safety", "p_durable", "writer_activity",
277
+ "on_target", "genotoxic", "cargo_ok", "reachable_tier1", "score", "intent"]
278
+ return scored[[c for c in cols if c in scored.columns]].head(k)
@@ -0,0 +1,87 @@
1
+ """End-to-end Write Planner.
2
+
3
+ One call - ``plan_write(gene, intent, payload_bp, ct)`` - composes the inverse-design optimiser,
4
+ cargo/donor design, and delivery recommendation into ranked, fully traceable plans. Every
5
+ numeric field is tagged with the module/dataset that produced it (provenance), so nothing is asserted
6
+ without a source. Heavy data (the writability atlas) is loaded lazily via the cross-link.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from functools import lru_cache
11
+ from pathlib import Path
12
+
13
+ import pandas as pd
14
+
15
+ from pen_stack.planner.cargo import design_cargo
16
+ from pen_stack.planner.delivery import recommend_delivery
17
+ from pen_stack.planner.optimize import EditIntent, plan
18
+
19
+ _ATLAS = Path(__file__).resolve().parents[1] / "atlas" / "atlas.parquet"
20
+ BIN_BP = 1000
21
+
22
+
23
+ @lru_cache(maxsize=1)
24
+ def _writer_meta() -> dict:
25
+ """family -> {length_aa, cargo_capacity_bp, deliv_class, reachability_tier} from the Writer Atlas."""
26
+ atlas = pd.read_parquet(_ATLAS)
27
+ core = atlas[atlas["entry_kind"] == "curated_core"] if "entry_kind" in atlas else atlas
28
+ meta = {}
29
+ for fam, sub in core.groupby("family"):
30
+ r = sub.iloc[0]
31
+ meta[fam] = {
32
+ "length_aa": (int(r["length_aa"]) if pd.notna(r.get("length_aa")) else None),
33
+ "cargo_capacity_bp": (int(r["cargo_capacity_bp"]) if pd.notna(r.get("cargo_capacity_bp")) else None),
34
+ "deliv_class": r.get("deliv_class"),
35
+ "reachability_tier": r.get("reachability_tier"),
36
+ }
37
+ return meta
38
+
39
+
40
+ def plan_write(gene: str, intent: EditIntent | str, payload_bp: int, ct: str = "k562",
41
+ k: int = 5, writable_df: pd.DataFrame | None = None) -> list[dict]:
42
+ """Return ranked, traceable write plans for a goal. Each plan = site + writer + cargo + delivery."""
43
+ if writable_df is None:
44
+ from pen_stack.atlas.crosslink import load_writability
45
+ writable_df = load_writability(ct)
46
+ cands = plan(gene, intent, payload_bp, writable_df, k=k)
47
+ meta = _writer_meta()
48
+ plans = []
49
+ for _, row in cands.iterrows():
50
+ fam = row["writer"]
51
+ wm = meta.get(fam, {})
52
+ writer_row = {"family": fam, "cargo_capacity_bp": wm.get("cargo_capacity_bp"),
53
+ "deliv_class": wm.get("deliv_class")}
54
+ site = (row["chrom"], int(row["bin"]) * BIN_BP)
55
+ cargo = design_cargo(payload_bp, writer_row, site, ct)
56
+ eff_bp = (wm.get("length_aa") or 0) * 3
57
+ deliv = recommend_delivery(eff_bp, payload_bp, ct)
58
+ plans.append({
59
+ "gene": gene, "intent": EditIntent(intent).value if not isinstance(intent, EditIntent) else intent.value,
60
+ "site": {"chrom": row["chrom"], "bin": int(row["bin"]), "pos": site[1]},
61
+ "writer": fam,
62
+ "reachability_tier": wm.get("reachability_tier"),
63
+ "safety": round(float(row["safety"]), 4),
64
+ "durability": round(float(row["p_durable"]), 4),
65
+ "writer_activity": round(float(row["writer_activity"]), 4),
66
+ "on_target": bool(row["on_target"]),
67
+ "genotoxic": bool(row["genotoxic"]) if "genotoxic" in row else False, # safety below the floor -> onco/essential zone
68
+ "score": round(float(row["score"]), 4),
69
+ "cargo": cargo,
70
+ "delivery": deliv,
71
+ "provenance": {
72
+ "safety": "wgenome.safety (LightGBM, COSMIC/DepMap/MLV)",
73
+ "durability": "wgenome.durability (TRIP conditional chromatin model)",
74
+ "writer_activity": "atlas.score.therapeutic (measured human-cell axis)",
75
+ "reachability": "atlas.crosslink (reachable_tier1 + WT-KB tier)",
76
+ "delivery": "planner.delivery (configs/delivery_rules.yaml)",
77
+ "offtargets": "planner.cargo (bridge engine)",
78
+ },
79
+ "disclaimer": "Decision-support only; not a clinical directive. Tier-2/3 reachability is candidate.",
80
+ })
81
+ return plans
82
+
83
+
84
+ if __name__ == "__main__": # pragma: no cover
85
+ import json
86
+ ps = plan_write("TRAC", EditIntent.KNOCK_IN_DISRUPT, 2000, "k562", k=3)
87
+ print(json.dumps(ps[0], indent=2, default=str)[:1200])
@@ -0,0 +1,26 @@
1
+ """Human-readable Write Planner report."""
2
+ from __future__ import annotations
3
+
4
+
5
+ def render_plan(p: dict) -> str:
6
+ s = p["site"]
7
+ lines = [
8
+ f"Write plan for {p['gene']} (intent: {p['intent']})",
9
+ f" Site : {s['chrom']}:{s['pos']:,} (bin {s['bin']}, on_target={p['on_target']})",
10
+ f" Writer : {p['writer']} [reachability {p['reachability_tier']}]",
11
+ f" Scores : safety {p['safety']} | durability {p['durability']} | "
12
+ f"writer-activity {p['writer_activity']} | score {p['score']}",
13
+ f" Cargo : payload {p['cargo']['payload_bp']} bp -> assembled {p['cargo']['assembled_bp']} bp "
14
+ f"(size_ok={p['cargo']['size_ok']}, codon-optimised, insulated)",
15
+ f" Delivery : {p['delivery']['delivery']} ({p['delivery']['rationale']})",
16
+ ]
17
+ if "offtargets" in p["cargo"]:
18
+ lines.append(f" Off-target : {p['cargo']['offtargets'].get('status', p['cargo']['offtargets'])}")
19
+ lines.append(f" Note : {p['disclaimer']}")
20
+ return "\n".join(lines)
21
+
22
+
23
+ def render_plans(plans: list[dict]) -> str:
24
+ if not plans:
25
+ return "No plan found (gene not in the atlas, or no reachable site)."
26
+ return f"\n{'='*72}\n".join(f"[rank {i+1}]\n{render_plan(p)}" for i, p in enumerate(plans))
@@ -0,0 +1,57 @@
1
+ """Write-type router.
2
+
3
+ The earlier pipeline handled one workflow, single insertion. ``route(design)`` makes that the *insertion
4
+ sub-graph* of a router that dispatches on ``write_type`` (insertion / excision / inversion / replacement /
5
+ regulatory_rewrite / landing_pad_install / multiplex), selecting the relevant rule subset + steps per type.
6
+ An unsupported or ambiguous write type **defers** (scope flag), it never guesses. Rarer types ship as
7
+ legality/reachability coverage first (status `coverage_only`), stated.
8
+
9
+ The router selects *which rules apply*; the solver still evaluates them. Routing + legality compose
10
+ into the verifier.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from functools import lru_cache
15
+
16
+ import yaml
17
+
18
+ from pen_stack._resources import resource
19
+ from pen_stack.rules import Design, load_ruleset
20
+ from pen_stack.rules.solver import evaluate, is_legal
21
+
22
+
23
+ @lru_cache(maxsize=1)
24
+ def load_write_types(path=None) -> dict:
25
+ p = resource("configs/write_types.yaml") if path is None else path
26
+ return yaml.safe_load(open(p, encoding="utf-8").read())["write_types"]
27
+
28
+
29
+ def route(design: Design) -> dict:
30
+ """Dispatch a design to its write-type sub-graph. Unsupported/ambiguous -> deferred (scope flag)."""
31
+ wt = (design.write_type or "").strip()
32
+ table = load_write_types()
33
+ if wt not in table:
34
+ return {"write_type": wt, "supported": False, "deferred": True,
35
+ "reason": f"unsupported/ambiguous write type {wt!r}; supported: {sorted(table)}",
36
+ "rule_categories": [], "steps": []}
37
+ spec = table[wt]
38
+ return {"write_type": wt, "supported": True, "deferred": False,
39
+ "status": spec["status"], "rule_categories": spec["rule_categories"],
40
+ "steps": spec["steps"], "writer_classes": spec.get("writer_classes", []),
41
+ "coverage_only": spec["status"] == "coverage_only",
42
+ "scope_note": spec.get("scope_note"),
43
+ "description": spec["description"]}
44
+
45
+
46
+ def route_and_evaluate(design: Design) -> dict:
47
+ """Route, then evaluate ONLY the routed rule categories against the design. Returns routing + legality."""
48
+ routing = route(design)
49
+ if routing["deferred"]:
50
+ return {"routing": routing, "legal": None, "deferred": True, "rule_results": []}
51
+ cats = set(routing["rule_categories"])
52
+ rs = load_ruleset()
53
+ sub = [r for r in rs.rules if r.category in cats]
54
+ from pen_stack.rules.schema import Ruleset
55
+ results = evaluate(design, Ruleset(version=rs.version, rules=sub))
56
+ return {"routing": routing, "legal": is_legal(results), "deferred": False,
57
+ "rule_results": [r.model_dump() for r in results]}
@@ -0,0 +1,92 @@
1
+ """Anti-vector neutralizing-antibody seroprevalence oracle.
2
+
3
+ The last computable delivery-immunology axis: PRE-EXISTING humoral immunity (B-cell / neutralizing antibody)
4
+ to a viral capsid. Unlike genotoxicity, capsid T-cell epitope load and innate sensing,
5
+ this CANNOT be computed from sequence - it is the prevalence, in a population, of people who already carry
6
+ NAbs against the vector from natural exposure. The grounding is published serosurvey DATA
7
+ (configs/seroprevalence.yaml), curated as ranges with provenance.
8
+
9
+ preexisting_score = 1 - midpoint(seroprevalence_pct) / 100 # 1 = fewest patients excluded by NAb
10
+
11
+ Answers through the OracleResult contract (output_kind="baseline"). Non-viral vehicles carry no foreign
12
+ capsid -> no pre-existing ANTI-VECTOR humoral immunity (score 1.0 by mechanism). SCOPE: a POPULATION
13
+ prevalence, NOT a given patient's NAb titer (a known-unknown); region/age/assay-dependent (a range, surfaced);
14
+ the humoral (B-cell) axis only - distinct from the T-cell epitope load.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from functools import lru_cache
19
+
20
+ import yaml
21
+
22
+ from pen_stack._resources import resource
23
+ from pen_stack.oracles.schema import OracleResult, Provenance
24
+
25
+ _SCOPE_CARD = "seroprevalence"
26
+
27
+
28
+ @lru_cache(maxsize=1)
29
+ def _table() -> dict:
30
+ return yaml.safe_load(resource("configs/seroprevalence.yaml").read_text(encoding="utf-8"))
31
+
32
+
33
+ def _all_dois() -> list[str]:
34
+ dois: set[str] = set()
35
+ for rec in (_table().get("serotypes") or {}).values():
36
+ dois.update(rec.get("dois", []) or [])
37
+ return sorted(dois)
38
+
39
+
40
+ def _prov(**extra) -> Provenance:
41
+ return Provenance(model="anti_vector_seroprevalence", version=str(_table().get("version", "1.0")),
42
+ source="cache", extra=extra)
43
+
44
+
45
+ def seroprevalence_oracle(vehicle_name: str, serotype: str | None = None) -> OracleResult:
46
+ """Pre-existing anti-vector NAb seroprevalence for a vehicle (or an explicit serotype), as an OracleResult.
47
+
48
+ - viral vehicle (or serotype) with curated data -> preexisting_score = 1 - midpoint(seroprevalence)/100.
49
+ - non-viral vehicle -> 1.0 by mechanism (no foreign capsid).
50
+ - unknown vehicle / no curated serotype -> available=False (caller falls back to the documented tier).
51
+ Never fabricates a number."""
52
+ t = _table()
53
+ sero = t.get("serotypes") or {}
54
+ key = serotype or (t.get("vehicle_serotype") or {}).get(vehicle_name)
55
+
56
+ if key and key in sero:
57
+ rec = sero[key]
58
+ lo, hi = rec["nab_seroprevalence_pct"]
59
+ mid = (lo + hi) / 2.0
60
+ score = max(0.0, min(1.0, 1.0 - mid / 100.0))
61
+ return OracleResult(
62
+ oracle="genome",
63
+ value={"preexisting_score": round(score, 3), "serotype": key,
64
+ "nab_seroprevalence_pct": [lo, hi], "midpoint_pct": mid, "dois": rec.get("dois", [])},
65
+ provenance=_prov(serotype=key, dois=rec.get("dois", [])), native_uncertainty=round((hi - lo) / 200.0, 4),
66
+ scope_card=_SCOPE_CARD, in_scope=True, extrapolating=False, output_kind="baseline", available=True,
67
+ note=(f"{key}: documented NAb seroprevalence {lo}-{hi}% (population); preexisting_score="
68
+ f"1-midpoint/100={score:.3f}. " + (rec.get("note", "") + " " if rec.get("note") else "")
69
+ + "A POPULATION prevalence, region/age/assay-dependent - NOT a given patient's NAb titer "
70
+ "(a known-unknown)."))
71
+
72
+ if vehicle_name in (t.get("non_viral") or []):
73
+ return OracleResult(
74
+ oracle="genome",
75
+ value={"preexisting_score": 1.0, "serotype": None, "mechanism": "non-viral"},
76
+ provenance=_prov(), native_uncertainty=0.0, scope_card=_SCOPE_CARD, in_scope=True,
77
+ extrapolating=False, output_kind="baseline", available=True,
78
+ note="non-viral vehicle: no foreign capsid -> no pre-existing ANTI-VECTOR humoral immunity (1.0). "
79
+ "Anti-PEG immunity for LNP is an emerging, separate exception (not a vector seroprevalence).")
80
+
81
+ return OracleResult(oracle="genome", value=None, provenance=_prov(), scope_card=_SCOPE_CARD,
82
+ in_scope=False, available=False, output_kind="baseline",
83
+ note=f"no curated seroprevalence for {vehicle_name!r}; fall back to the documented "
84
+ "preexisting_immunity tier.")
85
+
86
+
87
+ def computed_preexisting_score(vehicle_name: str, serotype: str | None = None) -> tuple[float | None, OracleResult]:
88
+ """Convenience: (preexisting_score or None, full OracleResult). None when the oracle abstains. Never
89
+ fabricates."""
90
+ r = seroprevalence_oracle(vehicle_name, serotype)
91
+ val = (r.value or {}).get("preexisting_score") if (r.available and r.value) else None
92
+ return val, r