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.
- pen_stack/__init__.py +2 -0
- pen_stack/_resources.py +34 -0
- pen_stack/active/__init__.py +20 -0
- pen_stack/active/acquire.py +165 -0
- pen_stack/active/brains.py +74 -0
- pen_stack/active/campaign.py +109 -0
- pen_stack/active/design.py +66 -0
- pen_stack/active/validate.py +104 -0
- pen_stack/adapt/__init__.py +14 -0
- pen_stack/adapt/finetune.py +33 -0
- pen_stack/adapt/ingest.py +86 -0
- pen_stack/adapt/pipeline.py +101 -0
- pen_stack/adapt/recalibrate.py +58 -0
- pen_stack/adapt/report.py +130 -0
- pen_stack/agent/__init__.py +1 -0
- pen_stack/agent/cite.py +175 -0
- pen_stack/agent/co_scientist.py +262 -0
- pen_stack/agent/epistemic.py +102 -0
- pen_stack/agent/guardrails.py +67 -0
- pen_stack/agent/mcp_server.py +215 -0
- pen_stack/agent/orchestrator.py +112 -0
- pen_stack/agent/orchestrator_live.py +56 -0
- pen_stack/agent/pen_agent.py +242 -0
- pen_stack/agent/scope.py +60 -0
- pen_stack/agent/tools.py +130 -0
- pen_stack/api/__init__.py +12 -0
- pen_stack/api/manifest.py +160 -0
- pen_stack/atlas/__init__.py +1 -0
- pen_stack/atlas/atlas.parquet +0 -0
- pen_stack/atlas/build_wtkb.py +80 -0
- pen_stack/atlas/crosslink.py +179 -0
- pen_stack/atlas/expand.py +190 -0
- pen_stack/atlas/guide_design.py +178 -0
- pen_stack/atlas/schema.py +59 -0
- pen_stack/atlas/scorecard.py +134 -0
- pen_stack/atlas/scorecard_v3.parquet +0 -0
- pen_stack/atlas/universe.py +75 -0
- pen_stack/atlas/universe_v3.parquet +0 -0
- pen_stack/atlas/variant_propose.py +155 -0
- pen_stack/atlas/writer_efficiency.py +184 -0
- pen_stack/atlas/writer_predict.py +229 -0
- pen_stack/atlas/writer_recommend.py +170 -0
- pen_stack/atlas/writer_verify.py +167 -0
- pen_stack/atlas/wtkb.parquet +0 -0
- pen_stack/bridge/__init__.py +1 -0
- pen_stack/bridge/activity.py +52 -0
- pen_stack/bridge/cli.py +65 -0
- pen_stack/bridge/fold_qc.py +53 -0
- pen_stack/bridge/guide_qc.py +87 -0
- pen_stack/bridge/ingest.py +139 -0
- pen_stack/bridge/offtarget.py +191 -0
- pen_stack/bridge/offtarget_energetics.py +105 -0
- pen_stack/bridge/ortholog_screen.py +73 -0
- pen_stack/bridge/pipeline.py +83 -0
- pen_stack/build/__init__.py +16 -0
- pen_stack/build/cloudlab.py +74 -0
- pen_stack/build/ingest.py +47 -0
- pen_stack/build/protocol.py +82 -0
- pen_stack/build/simlab.py +30 -0
- pen_stack/cli.py +126 -0
- pen_stack/data/__init__.py +1 -0
- pen_stack/data/encode.py +84 -0
- pen_stack/data/genome.py +71 -0
- pen_stack/data/ingest_chromatin.py +119 -0
- pen_stack/data/ingest_integration.py +112 -0
- pen_stack/data/ingest_safety_annot.py +201 -0
- pen_stack/data/ingest_trip.py +76 -0
- pen_stack/design/__init__.py +14 -0
- pen_stack/design/capsid_generate.py +62 -0
- pen_stack/design/generate.py +70 -0
- pen_stack/design/pareto.py +70 -0
- pen_stack/design/space.py +137 -0
- pen_stack/design/writer_variants.py +121 -0
- pen_stack/env/__init__.py +1 -0
- pen_stack/env/genome_writing_env.py +248 -0
- pen_stack/env/policies.py +94 -0
- pen_stack/graph/__init__.py +21 -0
- pen_stack/graph/build.py +133 -0
- pen_stack/graph/cell_types.py +58 -0
- pen_stack/graph/ingest.py +132 -0
- pen_stack/graph/query.py +148 -0
- pen_stack/graph/schema.py +100 -0
- pen_stack/loop/__init__.py +15 -0
- pen_stack/loop/continual.py +61 -0
- pen_stack/loop/cycle.py +84 -0
- pen_stack/loop/drift.py +41 -0
- pen_stack/mech/__init__.py +1 -0
- pen_stack/mech/classify_atlas.py +71 -0
- pen_stack/mech/pfam_whitelist.yaml +247 -0
- pen_stack/mech/whitelist.py +66 -0
- pen_stack/monitor/__init__.py +1 -0
- pen_stack/monitor/europepmc.py +32 -0
- pen_stack/monitor/run.py +57 -0
- pen_stack/monitor/triage.py +63 -0
- pen_stack/oracles/__init__.py +65 -0
- pen_stack/oracles/affinity.py +116 -0
- pen_stack/oracles/cache.py +53 -0
- pen_stack/oracles/energetics.py +33 -0
- pen_stack/oracles/genome.py +167 -0
- pen_stack/oracles/protein_design.py +136 -0
- pen_stack/oracles/reliability.py +64 -0
- pen_stack/oracles/rna.py +28 -0
- pen_stack/oracles/schema.py +77 -0
- pen_stack/oracles/status.py +123 -0
- pen_stack/oracles/structure.py +42 -0
- pen_stack/oracles/structure_run.py +76 -0
- pen_stack/oracles/vcell.py +74 -0
- pen_stack/planner/__init__.py +1 -0
- pen_stack/planner/ada_risk.py +64 -0
- pen_stack/planner/antipeg_oracle.py +75 -0
- pen_stack/planner/capsid_epitope_oracle.py +135 -0
- pen_stack/planner/cargo.py +56 -0
- pen_stack/planner/cargo_polish.py +146 -0
- pen_stack/planner/chromosome.py +106 -0
- pen_stack/planner/delivery.py +55 -0
- pen_stack/planner/delivery_constraints.py +110 -0
- pen_stack/planner/delivery_immune.py +61 -0
- pen_stack/planner/delivery_immunology.py +222 -0
- pen_stack/planner/delivery_predict.py +196 -0
- pen_stack/planner/delivery_vehicles.py +37 -0
- pen_stack/planner/genotoxicity_oracle.py +112 -0
- pen_stack/planner/immune_mhc2.py +154 -0
- pen_stack/planner/immune_profile.py +292 -0
- pen_stack/planner/innate_sensing.py +135 -0
- pen_stack/planner/multiplex.py +110 -0
- pen_stack/planner/optimize.py +278 -0
- pen_stack/planner/pipeline.py +87 -0
- pen_stack/planner/report.py +26 -0
- pen_stack/planner/router.py +57 -0
- pen_stack/planner/seroprevalence_oracle.py +92 -0
- pen_stack/planner/target_site.py +118 -0
- pen_stack/rag/__init__.py +1 -0
- pen_stack/rag/corpus.py +133 -0
- pen_stack/rag/embed.py +98 -0
- pen_stack/rag/ground.py +131 -0
- pen_stack/rag/index.py +53 -0
- pen_stack/rag/llm.py +215 -0
- pen_stack/rag/qa.py +105 -0
- pen_stack/rag/retrieve.py +48 -0
- pen_stack/rules/__init__.py +9 -0
- pen_stack/rules/evaluators.py +318 -0
- pen_stack/rules/loader.py +31 -0
- pen_stack/rules/schema.py +99 -0
- pen_stack/rules/solver.py +43 -0
- pen_stack/rules/spec.py +78 -0
- pen_stack/safety/__init__.py +21 -0
- pen_stack/safety/audit.py +90 -0
- pen_stack/safety/gate.py +58 -0
- pen_stack/safety/pfam_scan.py +157 -0
- pen_stack/safety/policy.py +69 -0
- pen_stack/safety/redteam.py +71 -0
- pen_stack/safety/registry.py +255 -0
- pen_stack/safety/screen.py +59 -0
- pen_stack/safety/standards.py +141 -0
- pen_stack/score/__init__.py +1 -0
- pen_stack/score/recalibrate.py +77 -0
- pen_stack/score/therapeutic.py +85 -0
- pen_stack/server/__init__.py +1 -0
- pen_stack/server/api.py +647 -0
- pen_stack/spec/__init__.py +18 -0
- pen_stack/spec/clarify.py +42 -0
- pen_stack/spec/extract.py +406 -0
- pen_stack/spec/resolvers/__init__.py +17 -0
- pen_stack/spec/resolvers/cell.py +51 -0
- pen_stack/spec/resolvers/chem.py +32 -0
- pen_stack/spec/resolvers/feature.py +36 -0
- pen_stack/spec/resolvers/gene.py +43 -0
- pen_stack/spec/resolvers/locus.py +29 -0
- pen_stack/spec/resolvers/phenotype.py +37 -0
- pen_stack/spec/satisfy.py +114 -0
- pen_stack/spec/service.py +33 -0
- pen_stack/spec/writespec.py +252 -0
- pen_stack/twin/__init__.py +14 -0
- pen_stack/twin/calibrate.py +61 -0
- pen_stack/twin/data/__init__.py +12 -0
- pen_stack/twin/data/position_effect.py +245 -0
- pen_stack/twin/mechanistic.py +147 -0
- pen_stack/twin/outcome.py +132 -0
- pen_stack/twin/position_effect.py +454 -0
- pen_stack/ui/__init__.py +1 -0
- pen_stack/ui/app.py +713 -0
- pen_stack/validate/__init__.py +1 -0
- pen_stack/validate/adapt_demo.py +69 -0
- pen_stack/validate/agent_eval.py +117 -0
- pen_stack/validate/bench_adversarial_tasks.py +118 -0
- pen_stack/validate/bench_coscientist_tasks.py +60 -0
- pen_stack/validate/bench_graph_tasks.py +64 -0
- pen_stack/validate/bench_rule_tasks.py +84 -0
- pen_stack/validate/bench_trust_tasks.py +92 -0
- pen_stack/validate/bench_writetype_tasks.py +101 -0
- pen_stack/validate/blind_gsh_discovery.py +261 -0
- pen_stack/validate/cargo_directionality.py +57 -0
- pen_stack/validate/closed_loop.py +63 -0
- pen_stack/validate/durability_baselines.py +185 -0
- pen_stack/validate/experiment_design.py +65 -0
- pen_stack/validate/expr_controls.py +39 -0
- pen_stack/validate/forward_hypotheses.py +104 -0
- pen_stack/validate/generative_design.py +62 -0
- pen_stack/validate/guide_qc_demo.py +69 -0
- pen_stack/validate/heldout_celltype_expr.py +32 -0
- pen_stack/validate/immune_calibration.py +133 -0
- pen_stack/validate/intent_specification.py +82 -0
- pen_stack/validate/known_biology_expr.py +38 -0
- pen_stack/validate/offtarget_energetics_eval.py +144 -0
- pen_stack/validate/out_of_scope_refusal.py +82 -0
- pen_stack/validate/outcome_calibration.py +194 -0
- pen_stack/validate/outcome_prediction.py +76 -0
- pen_stack/validate/paper3_benchmark.py +165 -0
- pen_stack/validate/paper4_real_validation.py +144 -0
- pen_stack/validate/paper4_validation.py +82 -0
- pen_stack/validate/protocol_safety.py +62 -0
- pen_stack/validate/safety_screening.py +72 -0
- pen_stack/validate/selective_prediction.py +104 -0
- pen_stack/validate/seq_vs_measured.py +134 -0
- pen_stack/validate/target_site_controls.py +65 -0
- pen_stack/validate/uncertainty_eval.py +244 -0
- pen_stack/validate/ungrounded_baseline.py +234 -0
- pen_stack/validate/within_locus_ranking.py +84 -0
- pen_stack/validate/writer_recovery.py +91 -0
- pen_stack/verify/__init__.py +5 -0
- pen_stack/verify/proof.py +206 -0
- pen_stack/verify/schema.py +53 -0
- pen_stack/verify/service.py +191 -0
- pen_stack/web/__init__.py +18 -0
- pen_stack/web/guide.py +110 -0
- pen_stack/web/llm.py +393 -0
- pen_stack/web/llm_provider.py +119 -0
- pen_stack/web/router.py +119 -0
- pen_stack/web/server.py +96 -0
- pen_stack/web/tools.py +197 -0
- pen_stack/wgenome/__init__.py +1 -0
- pen_stack/wgenome/chromatin_seq.py +83 -0
- pen_stack/wgenome/durability.py +108 -0
- pen_stack/wgenome/export_tracks.py +52 -0
- pen_stack/wgenome/features.py +82 -0
- pen_stack/wgenome/genotoxic_blocklist.py +88 -0
- pen_stack/wgenome/gsh_baseline.py +154 -0
- pen_stack/wgenome/mesh_features.py +61 -0
- pen_stack/wgenome/offtarget_assay.py +80 -0
- pen_stack/wgenome/offtarget_bridge.py +47 -0
- pen_stack/wgenome/offtarget_cast.py +97 -0
- pen_stack/wgenome/offtarget_data.py +148 -0
- pen_stack/wgenome/offtarget_enumerate.py +274 -0
- pen_stack/wgenome/offtarget_integrase.py +155 -0
- pen_stack/wgenome/offtarget_nuclease.py +123 -0
- pen_stack/wgenome/offtarget_paste.py +41 -0
- pen_stack/wgenome/offtarget_predict.py +282 -0
- pen_stack/wgenome/ood.py +135 -0
- pen_stack/wgenome/providers.py +278 -0
- pen_stack/wgenome/safety.py +69 -0
- pen_stack/wgenome/structure3d.py +212 -0
- pen_stack/wgenome/uncertainty.py +250 -0
- pen_stack/wgenome/writability.py +72 -0
- pen_stack-0.1.0.dist-info/METADATA +401 -0
- pen_stack-0.1.0.dist-info/RECORD +259 -0
- pen_stack-0.1.0.dist-info/WHEEL +5 -0
- pen_stack-0.1.0.dist-info/entry_points.txt +3 -0
- pen_stack-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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"}
|