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,135 @@
1
+ """Computed capsid/envelope T-cell epitope-load oracle for viral delivery vectors, using **NetMHCpan-4.1**.
2
+
3
+ Refines the documented `adaptive_immune` (CD8 T-cell) tier with a DATA-COMPUTED signal for VIRAL vectors: the
4
+ fraction of the capsid/envelope antigen presentable across a frequent HLA-I panel. **The PRIMARY
5
+ predictor is the gold-standard licensed NetMHCpan-4.1** (eluted-ligand %Rank_EL <= 0.5, residue coverage union
6
+ over a 12-allele frequent-HLA panel), computed on the VM and cached as derived fractions in
7
+ `configs/mhc_epitope_oracle.yaml` (`mhc1`). The earlier MHCflurry %rank<=0.5 cache
8
+ (`configs/capsid_epitope_oracle.yaml`) is retained as an explicit, reported **cross-check** (same residue-coverage
9
+ metric, same panel), never silently substituted.
10
+
11
+ capsid_immune_score = 1 - epitope_fraction_strong # 1 = least presentable / least immunogenic
12
+
13
+ Population-level (averaged over a frequent-allele panel) so it is NOT a patient-HLA-specific magnitude. Answers
14
+ through the OracleResult contract (output_kind="baseline"). The vehicle -> antigen(s) mapping lives in
15
+ `configs/capsid_epitope_oracle.yaml` (`vehicles`); the strong-binder fractions come from the NetMHCpan-4.1 cache.
16
+
17
+ Coverage of the whole palette: VIRAL vectors (AAV, lentivirus[VSV-G], HDAd, HSV) get a computed score;
18
+ NON-VIRAL vectors (LNP-mRNA, eVLP, electroporation) have no foreign capsid protein -> score 1.0 by mechanism;
19
+ a viral vector with no committed antigen sequence -> ABSTAINS (never fabricates).
20
+
21
+ SCOPE: this is a population-level, sequence-intrinsic PRESENTATION signal (does the capsid contain HLA
22
+ binders), NOT the realized in-vivo / patient-HLA-specific T-cell response (a known-unknown); it is also CD8
23
+ (MHC-I) only - it does not model antibody / neutralizing-antibody (B-cell) immunity. NetMHCpan-4.1 and MHCflurry
24
+ are different models and disagree on absolute load (both agree AAV is the least CD8-immunogenic capsid); the
25
+ cross-check makes that disagreement visible rather than hiding it.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ from functools import lru_cache
30
+
31
+ import yaml
32
+
33
+ from pen_stack._resources import resource
34
+ from pen_stack.oracles.schema import OracleResult, Provenance
35
+ from pen_stack.planner.delivery_vehicles import vehicle
36
+
37
+ _SCOPE_CARD = "capsid_epitope"
38
+ # non-viral vehicles have no foreign capsid/envelope protein -> no capsid CD8 epitope load (1.0 by mechanism).
39
+ _NON_VIRAL = {"lnp_mrna", "evlp", "electroporation"}
40
+
41
+
42
+ @lru_cache(maxsize=1)
43
+ def _mhcflurry_artifact() -> dict:
44
+ """The MHCflurry capsid cache, used for the vehicle->antigen mapping AND as the reported cross-check."""
45
+ return yaml.safe_load(resource("configs/capsid_epitope_oracle.yaml").read_text(encoding="utf-8"))
46
+
47
+
48
+ @lru_cache(maxsize=1)
49
+ def _netmhcpan_mhc1() -> dict:
50
+ """The REAL NetMHCpan-4.1 MHC-I residue-coverage cache (configs/mhc_epitope_oracle.yaml `mhc1`), keyed by
51
+ antigen. Only derived fractions are shipped (the licensed binary is never distributed). {} if absent."""
52
+ try:
53
+ art = yaml.safe_load(resource("configs/mhc_epitope_oracle.yaml").read_text(encoding="utf-8")) or {}
54
+ return art.get("mhc1") or {}
55
+ except Exception: # noqa: BLE001
56
+ return {}
57
+
58
+
59
+ def _netmhc_version() -> str:
60
+ try:
61
+ art = yaml.safe_load(resource("configs/mhc_epitope_oracle.yaml").read_text(encoding="utf-8")) or {}
62
+ return str(art.get("version", "6.9.2"))
63
+ except Exception: # noqa: BLE001
64
+ return "6.9.2"
65
+
66
+
67
+ def _prov(**extra) -> Provenance:
68
+ fl = _mhcflurry_artifact()
69
+ return Provenance(model="netmhcpan4.1_capsid_epitope", version=_netmhc_version(), source="cache",
70
+ extra={"primary_predictor": "NetMHCpan-4.1 (%Rank_EL<=0.5, residue coverage, 12-allele panel)",
71
+ "cross_check_predictor": fl.get("method", {}).get("predictor"),
72
+ "provenance_dois": ["10.1093/nar/gkac1029", *fl.get("provenance_dois", [])], **extra})
73
+
74
+
75
+ def _antigens_for(vehicle_name: str) -> list[str] | None:
76
+ rec = (_mhcflurry_artifact().get("vehicles") or {}).get(vehicle_name)
77
+ return rec.get("antigens") if rec else None
78
+
79
+
80
+ def capsid_epitope_oracle(vehicle_name: str) -> OracleResult:
81
+ """Computed capsid CD8 epitope load for a delivery vehicle, as an OracleResult.
82
+
83
+ - viral vehicle with committed antigen(s) -> computed `capsid_immune_score` from **NetMHCpan-4.1** x HLA
84
+ panel (primary), with the MHCflurry value reported as a cross-check.
85
+ - non-viral vehicle -> 1.0 by mechanism (no foreign capsid protein).
86
+ - unknown vehicle / viral-without-sequence -> available=False (caller falls back to the documented
87
+ adaptive_immune tier; no number fabricated)."""
88
+ rec = vehicle(vehicle_name)
89
+ if rec is None:
90
+ return OracleResult(oracle="protein_design", value=None, provenance=_prov(), scope_card=_SCOPE_CARD,
91
+ in_scope=False, available=False, output_kind="baseline",
92
+ note=f"unknown vehicle {vehicle_name!r}")
93
+
94
+ antigens = _antigens_for(vehicle_name)
95
+ mhc1 = _netmhcpan_mhc1()
96
+ if antigens and all(a in mhc1 for a in antigens):
97
+ fracs = [mhc1[a]["epitope_fraction_strong"] for a in antigens]
98
+ ef = round(sum(fracs) / len(fracs), 4)
99
+ score = round(1.0 - ef, 4)
100
+ # MHCflurry cross-check (same vehicle, from its dedicated cache)
101
+ fl_veh = (_mhcflurry_artifact().get("vehicles") or {}).get(vehicle_name, {})
102
+ cross = {"predictor": "MHCflurry 2.0 (%rank<=0.5)",
103
+ "epitope_fraction_strong": fl_veh.get("epitope_fraction_strong"),
104
+ "capsid_immune_score": fl_veh.get("capsid_immune_score")}
105
+ return OracleResult(
106
+ oracle="protein_design",
107
+ value={"capsid_immune_score": score, "epitope_fraction_strong": ef, "antigens": antigens,
108
+ "predictor": "NetMHCpan-4.1", "hla_panel_size": 12, "cross_check_mhcflurry": cross},
109
+ provenance=_prov(antigens=antigens), native_uncertainty=None,
110
+ scope_card=_SCOPE_CARD, in_scope=True, extrapolating=False, output_kind="baseline", available=True,
111
+ note=(f"{ef:.1%} of the {'/'.join(antigens)} antigen is presentable (NetMHCpan-4.1 %Rank_EL<=0.5, "
112
+ f"residue coverage over a 12-allele frequent HLA-I panel); capsid_immune_score=1-epitope_fraction. "
113
+ f"MHCflurry cross-check capsid_immune_score={cross['capsid_immune_score']}. Patient-HLA-specific "
114
+ "T-cell response is a known-unknown (not modelled); CD8/MHC-I only (not antibody/NAb)."))
115
+
116
+ if vehicle_name in _NON_VIRAL:
117
+ return OracleResult(
118
+ oracle="protein_design",
119
+ value={"capsid_immune_score": 1.0, "epitope_fraction_strong": 0.0, "mechanism": "non-viral"},
120
+ provenance=_prov(), native_uncertainty=0.0, scope_card=_SCOPE_CARD, in_scope=True,
121
+ extrapolating=False, output_kind="baseline", available=True,
122
+ note="non-viral vehicle: no foreign capsid/envelope protein -> no capsid CD8 epitope load (1.0).")
123
+
124
+ return OracleResult(oracle="protein_design", value=None, provenance=_prov(), scope_card=_SCOPE_CARD,
125
+ in_scope=False, available=False, output_kind="baseline",
126
+ note=f"viral vehicle {vehicle_name!r} has no committed antigen sequence in the NetMHCpan-4.1 "
127
+ "cache; fall back to the documented adaptive_immune tier.")
128
+
129
+
130
+ def computed_capsid_immune_score(vehicle_name: str) -> tuple[float | None, OracleResult]:
131
+ """Convenience: (capsid_immune_score or None, full OracleResult). None when the oracle abstains (caller
132
+ then uses the documented adaptive_immune tier). Never fabricates."""
133
+ r = capsid_epitope_oracle(vehicle_name)
134
+ val = (r.value or {}).get("capsid_immune_score") if (r.available and r.value) else None
135
+ return val, r
@@ -0,0 +1,56 @@
1
+ """Cargo / donor design.
2
+
3
+ Assemble a donor construct spec for a chosen writer + site: insulators (protect durability), promoter +
4
+ polyA, codon optimisation flag for the host cell type, and a size check against the writer's
5
+ deliverability/cargo class. For bridge/seek writers, attach the off-target prediction *if the
6
+ bridge engine is available* - otherwise the field is marked pending, so the Planner runs
7
+ end-to-end now and the off-target annotation drops in once the bridge off-target engine lands.
8
+
9
+ We design at the level of construct *elements + sizes* (the payload sequence is the user's CDS/regulatory
10
+ cassette); element lengths are nominal, documented constants.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ # nominal element sizes (bp) for the assembled donor; documented, not hidden
15
+ _ELEMENTS = {"insulator_5": 250, "promoter": 600, "polyA": 250, "insulator_3": 250}
16
+
17
+
18
+ def _bridge_offtarget(writer_family: str, site: tuple) -> dict:
19
+ """Optional bridge off-target hook. Returns the off-target prediction if the bridge engine exists, else pending."""
20
+ try:
21
+ from pen_stack.bridge.offtarget import predict_offtargets
22
+ except Exception: # noqa: BLE001 - engine not built yet
23
+ return {"status": "pending_phase_1_5", "note": "the bridge off-target engine is not yet available"}
24
+ return predict_offtargets(writer_family, site)
25
+
26
+
27
+ def design_cargo(payload_bp: int, writer_row: dict, site: tuple, ct: str,
28
+ payload_seq: str | None = None) -> dict:
29
+ """Assemble a donor construct spec. writer_row needs: family, cargo_capacity_bp, deliv_class.
30
+
31
+ If `payload_seq` is given, attach the Cargo Polish sequence-risk scan (cargo_durability_risk +
32
+ actionable suggestions) - the locus model scores the site, Cargo Polish scores the insert.
33
+ """
34
+ fam = writer_row.get("family")
35
+ cap = writer_row.get("cargo_capacity_bp")
36
+ elements = dict(_ELEMENTS)
37
+ assembled_bp = int(payload_bp) + sum(elements.values())
38
+ size_ok = (cap is None) or (assembled_bp <= cap)
39
+
40
+ out = {
41
+ "host": ct,
42
+ "payload_bp": int(payload_bp),
43
+ "elements": elements, # insulators + promoter + polyA
44
+ "assembled_bp": assembled_bp,
45
+ "codon_optimised": True,
46
+ "writer_family": fam,
47
+ "cargo_capacity_bp": cap,
48
+ "size_ok": size_ok,
49
+ "deliverability": writer_row.get("deliv_class"),
50
+ }
51
+ if fam in {"bridge_IS110", "seek_IS1111"}:
52
+ out["offtargets"] = _bridge_offtarget(fam, site)
53
+ if payload_seq:
54
+ from pen_stack.planner.cargo_polish import scan_cargo
55
+ out["cargo_polish"] = scan_cargo(payload_seq)
56
+ return out
@@ -0,0 +1,146 @@
1
+ """Cargo Polish - cargo-sequence durability-risk scan.
2
+
3
+ The locus model scores WHERE to write; this scores WHAT is written. It scans the insert (the user's
4
+ cassette sequence) for known sequence triggers of transgene silencing/instability and emits a
5
+ `cargo_durability_risk` score in [0,1] with a band and, for every flag, a concrete remedy.
6
+
7
+ This is a HEURISTIC flag, not a supervised silencing predictor: it catches documented sequence triggers
8
+ (CpG-island density -> de novo methylation; GC extremes; cryptic splice consensus; strong mRNA secondary
9
+ structure; known silencer motifs), not all silencing causes. Thresholds are documented constants
10
+ (configs/cargo_polish.yaml) from the silencing literature. ViennaRNA (MFE) is optional and degrades
11
+ gracefully (the structure term is skipped, noted) so the scan runs anywhere; the other terms are pure-Python.
12
+
13
+ Acceptance (prereg/ws_d.yaml): reproduces established directionality - high-CpG bacterial-style cassettes
14
+ score above CpG-depleted / insulator-flanked constructs on a small curated set - and every flag carries a
15
+ concrete suggestion.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from functools import lru_cache
21
+
22
+ import yaml
23
+
24
+
25
+ @lru_cache(maxsize=1)
26
+ def _cfg() -> dict:
27
+ from pen_stack._resources import resource
28
+ return yaml.safe_load(resource("configs/cargo_polish.yaml").read_text(encoding="utf-8"))
29
+
30
+
31
+ def _clean(seq: str) -> str:
32
+ return re.sub(r"[^ACGT]", "", (seq or "").upper())
33
+
34
+
35
+ def gc_fraction(seq: str) -> float:
36
+ s = _clean(seq)
37
+ return (s.count("G") + s.count("C")) / len(s) if s else 0.0
38
+
39
+
40
+ def cpg_islands(seq: str) -> list[dict]:
41
+ """Gardiner-Garden & Frommer sliding window: obs/exp CpG > threshold AND GC > threshold over the window."""
42
+ c = _cfg()["cpg_island"]
43
+ s = _clean(seq)
44
+ w, step = c["window_bp"], max(1, c["window_bp"] // 4)
45
+ out = []
46
+ for i in range(0, max(1, len(s) - w + 1), step):
47
+ win = s[i:i + w]
48
+ if len(win) < w:
49
+ break
50
+ nC, nG = win.count("C"), win.count("G")
51
+ gc = (nC + nG) / w
52
+ exp = (nC * nG) / w if nC and nG else 0.0
53
+ obs_exp = (win.count("CG") / exp) if exp else 0.0
54
+ if obs_exp > c["obs_exp_min"] and gc > c["gc_min"]:
55
+ out.append({"start": i, "obs_exp": round(obs_exp, 3), "gc": round(gc, 3)})
56
+ # merge overlapping windows into island count
57
+ merged, last_end = 0, -1
58
+ for isl in out:
59
+ if isl["start"] > last_end:
60
+ merged += 1
61
+ last_end = isl["start"] + w
62
+ return [{"n_islands": merged, "windows": out}] if merged else []
63
+
64
+
65
+ def cryptic_splice_sites(seq: str) -> dict:
66
+ c = _cfg()["cryptic_splice"]
67
+ s = _clean(seq)
68
+ donors = len(re.findall(c["donor_motif"], s))
69
+ acceptors = len(re.findall(c["acceptor_motif"], s))
70
+ return {"donor": donors, "acceptor": acceptors, "total": donors + acceptors}
71
+
72
+
73
+ def silencer_motifs(seq: str) -> list[dict]:
74
+ s = _clean(seq)
75
+ hits = []
76
+ for m in _cfg()["silencer_motifs"]["motifs"]:
77
+ n = len(re.findall(m["pattern"], s))
78
+ if n:
79
+ hits.append({"name": m["name"], "count": n, "note": m["note"]})
80
+ return hits
81
+
82
+
83
+ def mfe_per_nt(seq: str) -> dict:
84
+ """ViennaRNA minimum-free-energy per nucleotide of the transcribed insert; graceful if RNA is absent."""
85
+ s = _clean(seq)
86
+ if len(s) < 10:
87
+ return {"available": False, "note": "sequence too short"}
88
+ try:
89
+ import RNA
90
+ except Exception: # noqa: BLE001 - ViennaRNA only in the bio extra / VM image
91
+ return {"available": False, "note": "ViennaRNA not installed (bio extra / VM image)"}
92
+ fc = RNA.fold_compound(s.replace("T", "U"))
93
+ _struct, mfe = fc.mfe()
94
+ return {"available": True, "mfe": round(float(mfe), 2), "mfe_per_nt": round(float(mfe) / len(s), 4)}
95
+
96
+
97
+ def scan_cargo(seq: str) -> dict:
98
+ """Aggregate the cargo durability-risk scan: score in [0,1], band, and per-flag concrete suggestions."""
99
+ cfg = _cfg()
100
+ s = _clean(seq)
101
+ flags, risk = [], 0.0
102
+ sug = cfg["suggestions"]
103
+
104
+ isl = cpg_islands(s)
105
+ if isl:
106
+ n = isl[0]["n_islands"]
107
+ risk += min(0.5, n * cfg["cpg_island"]["risk_per_island"])
108
+ flags.append({"category": "cpg_island", "detail": f"{n} CpG island(s)", "suggestion": sug["cpg_island"]})
109
+
110
+ gc = gc_fraction(s)
111
+ if gc and (gc < cfg["gc_extremes"]["gc_low"] or gc > cfg["gc_extremes"]["gc_high"]):
112
+ risk += cfg["gc_extremes"]["risk"]
113
+ flags.append({"category": "gc_extremes", "detail": f"GC={gc:.2f}", "suggestion": sug["gc_extremes"]})
114
+
115
+ cs = cryptic_splice_sites(s)
116
+ if cs["total"]:
117
+ risk += min(cfg["cryptic_splice"]["risk_per_site_capped"], 0.05 * cs["total"])
118
+ flags.append({"category": "cryptic_splice", "detail": f"{cs['total']} splice consensus site(s)",
119
+ "suggestion": sug["cryptic_splice"]})
120
+
121
+ sm = silencer_motifs(s)
122
+ if sm:
123
+ risk += min(cfg["silencer_motifs"]["risk_per_motif_capped"], 0.07 * sum(h["count"] for h in sm))
124
+ flags.append({"category": "silencer_motifs", "detail": ", ".join(h["name"] for h in sm),
125
+ "suggestion": sug["silencer_motifs"]})
126
+
127
+ mfe = mfe_per_nt(s)
128
+ if mfe.get("available") and mfe["mfe_per_nt"] < cfg["secondary_structure"]["mfe_per_nt_warn"]:
129
+ risk += cfg["secondary_structure"]["risk"]
130
+ flags.append({"category": "secondary_structure", "detail": f"MFE/nt={mfe['mfe_per_nt']}",
131
+ "suggestion": sug["secondary_structure"]})
132
+
133
+ risk = round(min(1.0, risk), 4)
134
+ b = cfg["bands"]
135
+ band = "low" if risk < b["low"] else ("moderate" if risk < b["moderate"] else "high")
136
+ return {"cargo_durability_risk": risk, "band": band, "length_bp": len(s),
137
+ "gc": round(gc, 4), "n_flags": len(flags), "flags": flags,
138
+ "components": {"cpg_islands": isl, "cryptic_splice": cs, "silencer_motifs": sm,
139
+ "secondary_structure": mfe},
140
+ "scope": "heuristic sequence-trigger scan, not a supervised silencing predictor"}
141
+
142
+
143
+ if __name__ == "__main__": # pragma: no cover
144
+ import json
145
+ demo = "CGCGCGCGGCGGCGCGCGGCGGCGCGCGGCGGCGCG" * 8
146
+ print(json.dumps(scan_cargo(demo), indent=2, default=str))
@@ -0,0 +1,106 @@
1
+ """Chromosome validation, gene/chromosome concordance, and chromosome-context advisories.
2
+
3
+ The `chrom` field on a design was previously a free-text pass-through with no validation and no downstream
4
+ effect. This module makes it meaningful WITHOUT fabricating a score: the engine's per-locus safety / durability /
5
+ accessibility / off-target numbers are indexed by the GENE's resolved genomic coordinates via the writability
6
+ atlas (`planner.optimize.gene_region`), not by a free-text chromosome string - a bare "chrX" with no position
7
+ cannot index a specific locus value. So the grounded use of the field is:
8
+
9
+ * `canonical_chromosome` - validate / normalise to a standard human chromosome (chr1-chr22, chrX, chrY, chrM);
10
+ * `gene_chromosome` - the gene's canonical chromosome from the gene-coordinate table;
11
+ * `chromosome_concordance` - flag when the entered chromosome does not match the named gene's real location
12
+ (the "BRCA1 is on chr17, not chr1" case), making explicit that scoring uses the gene's canonical locus;
13
+ * `chromosome_context` - chromosome-driven advisories that ARE grounded in biology: chrM is not addressable by
14
+ nuclear genome-writing tools (mtDNA needs DdCBE/TALED - out of scope), chrY is male-specific with ampliconic
15
+ repeats, chrX is hemizygous in 46,XY vs X-inactivated in 46,XX.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import re
20
+
21
+ STANDARD_CHROMOSOMES = [f"chr{i}" for i in range(1, 23)] + ["chrX", "chrY", "chrM"]
22
+
23
+
24
+ def canonical_chromosome(chrom: str | None) -> str | None:
25
+ """Normalise a chromosome string to canonical UCSC form (chr1..chr22, chrX, chrY, chrM), or None if it is not
26
+ a standard human chromosome. Accepts 'chrX'/'X'/'x', 'chr1'/'1', 'chrM'/'chrMT'/'MT', '23'->X, '24'->Y."""
27
+ if chrom is None:
28
+ return None
29
+ c = re.sub(r"\s+", "", str(chrom)).lower()
30
+ if not c:
31
+ return None
32
+ if c.startswith("chr"):
33
+ c = c[3:]
34
+ c = {"mt": "m", "23": "x", "24": "y", "25": "m"}.get(c, c)
35
+ if c in {"x", "y", "m"}:
36
+ return "chr" + c.upper()
37
+ if c.isdigit() and 1 <= int(c) <= 22:
38
+ return "chr" + c
39
+ return None
40
+
41
+
42
+ def gene_chromosome(gene: str | None) -> str | None:
43
+ """The canonical chromosome of a gene (or safe-harbour locus nickname), from the gene-coordinate table; None
44
+ if the gene is unknown."""
45
+ if not gene:
46
+ return None
47
+ try:
48
+ from pen_stack.planner.optimize import gene_region
49
+ reg = gene_region(gene)
50
+ except Exception: # noqa: BLE001 - coords table absent (bare checkout) -> cannot resolve, do not fabricate
51
+ return None
52
+ return canonical_chromosome(reg[0]) if reg else None
53
+
54
+
55
+ def chromosome_concordance(gene: str | None, chrom: str | None) -> dict:
56
+ """Validate the entered chromosome and check it against the named gene's canonical location.
57
+
58
+ status is one of:
59
+ * 'invalid' - the entered string is not a standard human chromosome;
60
+ * 'mismatch' - valid, but does not match the gene's canonical chromosome (the silent-mismatch case);
61
+ * 'match' - matches the gene's canonical chromosome;
62
+ * 'unverifiable' - valid chromosome but the gene's location is unknown (cannot confirm);
63
+ * 'none' - no chromosome supplied.
64
+ """
65
+ entered = canonical_chromosome(chrom)
66
+ gchrom = gene_chromosome(gene)
67
+ out = {"entered_raw": chrom, "entered": entered, "valid": entered is not None,
68
+ "gene": gene, "gene_chrom": gchrom}
69
+ if chrom in (None, "") or str(chrom).strip() == "":
70
+ out["status"] = "none"
71
+ out["message"] = None
72
+ elif entered is None:
73
+ out["status"] = "invalid"
74
+ out["message"] = (f"'{chrom}' is not a standard human chromosome. Use chr1-chr22, chrX, chrY, or chrM.")
75
+ elif gchrom and entered != gchrom:
76
+ out["status"] = "mismatch"
77
+ out["message"] = (f"{gene} is on {gchrom}, not {entered}. Locus scoring uses {gene}'s canonical location "
78
+ f"({gchrom}); the entered chromosome does not move the locus.")
79
+ elif gchrom and entered == gchrom:
80
+ out["status"] = "match"
81
+ out["message"] = f"{entered} matches the canonical location of {gene}."
82
+ else:
83
+ out["status"] = "unverifiable"
84
+ out["message"] = (f"{entered} is a valid chromosome, but {gene or 'the gene'} is not in the coordinate "
85
+ "table, so the gene/chromosome match cannot be confirmed.")
86
+ return out
87
+
88
+
89
+ def chromosome_context(chrom: str | None) -> dict | None:
90
+ """A grounded, chromosome-driven advisory (or None for an autosome). These are real biological constraints
91
+ the chromosome identity alone implies; magnitudes remain locus/subject-dependent (not predicted here)."""
92
+ c = canonical_chromosome(chrom)
93
+ if c == "chrM":
94
+ return {"chrom": "chrM", "severity": "high",
95
+ "note": ("the mitochondrial genome is NOT addressable by nuclear genome-writing tools "
96
+ "(integrases / recombinases / nuclear-targeted Cas9); mtDNA editing requires "
97
+ "mitochondrially-targeted base editors (DdCBE / TALED) - out of scope for this tool.")}
98
+ if c == "chrY":
99
+ return {"chrom": "chrY", "severity": "medium",
100
+ "note": ("the Y chromosome is male-specific (absent in 46,XX) and rich in ampliconic / palindromic "
101
+ "repeats, so confirm the target is single-copy before trusting a unique-site assumption.")}
102
+ if c == "chrX":
103
+ return {"chrom": "chrX", "severity": "low",
104
+ "note": ("the X chromosome is hemizygous in 46,XY (one copy) but subject to X-inactivation in "
105
+ "46,XX (mosaic expression); dosage and durability are sex- and locus-dependent.")}
106
+ return None
@@ -0,0 +1,55 @@
1
+ """Delivery recommendation.
2
+
3
+ Recommend a delivery modality from the total payload (writer effector + cargo) and the target cell type,
4
+ using the documented rule table in configs/delivery_rules.yaml (no hidden constants).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from functools import lru_cache
9
+ from pathlib import Path
10
+
11
+ import yaml
12
+
13
+ _CFG = Path(__file__).resolve().parents[2] / "configs" / "delivery_rules.yaml"
14
+
15
+
16
+ @lru_cache(maxsize=1)
17
+ def _rules(path: str | Path = _CFG) -> dict:
18
+ return yaml.safe_load(Path(path).read_text(encoding="utf-8"))
19
+
20
+
21
+ def _vehicle_of(delivery: str) -> str:
22
+ """Map a delivery-rule label to a constraint-scan vehicle key (configs/delivery_constraints.yaml)."""
23
+ d = (delivery or "").lower()
24
+ if "aav" in d:
25
+ return "AAV"
26
+ if "lenti" in d:
27
+ return "lentiviral"
28
+ if "plasmid" in d:
29
+ return "plasmid"
30
+ return d # mRNA-RNP / LNP-mRNA -> no DNA-vector packaging checks
31
+
32
+
33
+ def recommend_delivery(effector_bp: int, cargo_bp: int, ct: str, cargo_seq: str | None = None) -> dict:
34
+ """Return {delivery, total_bp, rationale}. effector_bp ~= writer length_aa * 3.
35
+
36
+ When ``cargo_seq`` is supplied, attaches the delivery-vehicle sequence-constraint scan
37
+ (vehicle-specific soft penalties + fixes) under ``delivery_constraints``.
38
+ """
39
+ cfg = _rules()
40
+ total = int(effector_bp) + int(cargo_bp)
41
+ chosen = None
42
+ for rule in cfg["rules"]:
43
+ if total <= rule["max_total_bp"]:
44
+ chosen = {"delivery": rule["delivery"], "total_bp": total,
45
+ "rationale": f"total payload {total} bp <= {rule['max_total_bp']} bp"}
46
+ break
47
+ if chosen is None:
48
+ fallback = (cfg["ex_vivo_fallback"] if ct in cfg.get("ex_vivo_cell_types", [])
49
+ else cfg["in_vivo_fallback"])
50
+ chosen = {"delivery": fallback, "total_bp": total,
51
+ "rationale": f"total payload {total} bp exceeds dual-AAV; cell type {ct}"}
52
+ if cargo_seq:
53
+ from pen_stack.planner.delivery_constraints import scan_delivery
54
+ chosen["delivery_constraints"] = scan_delivery(cargo_seq, _vehicle_of(chosen["delivery"]))
55
+ return chosen
@@ -0,0 +1,110 @@
1
+ """Delivery-vehicle sequence constraints.
2
+
3
+ Cargo Polish scans WHAT is written for silencing triggers; this scans the construct for VEHICLE-specific
4
+ sequence problems that hurt the chosen delivery vector: lentiviral internal poly(A) signals (truncate the
5
+ genomic RNA), AAV ITR-interfering inverted repeats / homopolymer slippage, recombinogenic direct repeats,
6
+ and packaging-hostile GC extremes. Output is a soft ``delivery_constraint_risk`` in [0,1] with a band and a
7
+ concrete fix per flag.
8
+
9
+ These are LABELED HEURISTIC penalties (config: configs/delivery_constraints.yaml), validated only
10
+ directionally, a construct carrying a known problematic element scores higher than a clean one, not a
11
+ predictor of titre. Vehicles that deliver the effector as RNA/protein (mRNA-RNP, LNP-mRNA) have no DNA-vector
12
+ packaging constraints on the cargo here.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from functools import lru_cache
18
+
19
+ import yaml
20
+
21
+ from pen_stack._resources import resource
22
+
23
+
24
+ @lru_cache(maxsize=1)
25
+ def _cfg() -> dict:
26
+ return yaml.safe_load(resource("configs/delivery_constraints.yaml").read_text(encoding="utf-8"))
27
+
28
+
29
+ def _clean(seq: str) -> str:
30
+ return re.sub(r"[^ACGT]", "", (seq or "").upper())
31
+
32
+
33
+ def _rc(s: str) -> str:
34
+ return s.translate(str.maketrans("ACGT", "TGCA"))[::-1]
35
+
36
+
37
+ def _gc(s: str) -> float:
38
+ return (s.count("G") + s.count("C")) / len(s) if s else 0.0
39
+
40
+
41
+ def _longest_homopolymer(s: str) -> int:
42
+ best = run = 1
43
+ for i in range(1, len(s)):
44
+ run = run + 1 if s[i] == s[i - 1] else 1
45
+ best = max(best, run)
46
+ return best if s else 0
47
+
48
+
49
+ def _has_direct_repeat(s: str, unit: int) -> bool:
50
+ """A >= `unit`-nt substring that occurs at least twice (non-overlapping-ish) → recombinogenic direct repeat."""
51
+ seen = {}
52
+ for i in range(len(s) - unit + 1):
53
+ sub = s[i:i + unit]
54
+ if sub in seen and i - seen[sub] >= unit:
55
+ return True
56
+ seen.setdefault(sub, i)
57
+ return False
58
+
59
+
60
+ def _has_inverted_repeat(s: str, arm: int) -> bool:
61
+ """A >= `arm`-nt substring whose reverse-complement also occurs → hairpin / ITR-like inverted repeat."""
62
+ kmers = {s[i:i + arm] for i in range(len(s) - arm + 1)}
63
+ for i in range(len(s) - arm + 1):
64
+ if _rc(s[i:i + arm]) in kmers:
65
+ return True
66
+ return False
67
+
68
+
69
+ def scan_delivery(seq: str, vehicle: str) -> dict:
70
+ """Vehicle-specific construct scan → soft risk + per-flag suggestions. Unknown/RNA vehicles → no checks."""
71
+ cfg = _cfg()
72
+ s = _clean(seq)
73
+ checks = cfg["vehicles"].get(vehicle, [])
74
+ flags, risk = [], 0.0
75
+
76
+ for name in checks:
77
+ c = cfg["checks"][name]
78
+ if name == "internal_polyA":
79
+ n = len(re.findall(c["pattern"], s))
80
+ if n:
81
+ risk += min(c["cap"], c["risk_per_hit"] * n)
82
+ flags.append({"check": name, "detail": f"{n} internal poly(A) signal(s)",
83
+ "suggestion": c["suggestion"]})
84
+ elif name == "homopolymer_run":
85
+ if _longest_homopolymer(s) >= c["min_len"]:
86
+ risk += c["risk_per_run"]
87
+ flags.append({"check": name, "detail": f"homopolymer run >= {c['min_len']} nt",
88
+ "suggestion": c["suggestion"]})
89
+ elif name == "direct_repeat":
90
+ if _has_direct_repeat(s, c["unit_len"]):
91
+ risk += c["risk"]
92
+ flags.append({"check": name, "detail": f">= {c['unit_len']} nt direct repeat",
93
+ "suggestion": c["suggestion"]})
94
+ elif name == "inverted_repeat":
95
+ if _has_inverted_repeat(s, c["arm_len"]):
96
+ risk += c["risk"]
97
+ flags.append({"check": name, "detail": f">= {c['arm_len']} nt inverted repeat (hairpin)",
98
+ "suggestion": c["suggestion"]})
99
+ elif name == "gc_extreme":
100
+ gc = _gc(s)
101
+ if s and (gc < c["gc_low"] or gc > c["gc_high"]):
102
+ risk += c["risk"]
103
+ flags.append({"check": name, "detail": f"GC={gc:.2f}", "suggestion": c["suggestion"]})
104
+
105
+ risk = round(min(1.0, risk), 4)
106
+ b = cfg["bands"]
107
+ band = "low" if risk < b["low"] else ("moderate" if risk < b["moderate"] else "high")
108
+ return {"vehicle": vehicle, "delivery_constraint_risk": risk, "band": band,
109
+ "checks_applied": checks, "n_flags": len(flags), "flags": flags,
110
+ "scope": "labeled heuristic vehicle-packaging penalties (directional), not a titre predictor"}