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,148 @@
|
|
|
1
|
+
"""Off-target ground-truth + provenance for the cross-family nomination engine.
|
|
2
|
+
|
|
3
|
+
The ground truth is harmonized, PUBLISHED, unbiased genome-wide off-target assays over canonical Cas9 guides;
|
|
4
|
+
the derived risk calibration and the Off-Target-Bench headline (the REAL CRISOT predictor vs the homology
|
|
5
|
+
baseline) are embedded in-code so the axis is available everywhere (CI / bare install / live app), while the
|
|
6
|
+
full sealed fixture + per-guide metrics live under ``benchmarks/offtarget/`` for the bench harness.
|
|
7
|
+
|
|
8
|
+
LICENSING / SCOPE: the CRISOT predictor (Chen et al., Nat Commun 2023) is CC-BY-NC and runs ONLY on the VM,
|
|
9
|
+
its binaries/weights are NEVER redistributed; only DERIVED scores are cached (exactly like the licensed MHC
|
|
10
|
+
tools). Off-target SITE sequences are facts from the public assay supplements (cited below). A nominated
|
|
11
|
+
off-target is a CANDIDATE, never a claim.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from functools import lru_cache
|
|
16
|
+
|
|
17
|
+
# ---- validated assay provenance (independently verified 2026-06-19) -----------------------------
|
|
18
|
+
ASSAY_PROVENANCE = {
|
|
19
|
+
"guideseq": {"name": "GUIDE-seq", "kind": "cell-based, unbiased, genome-wide",
|
|
20
|
+
"citation": "Tsai et al., Nat Biotechnol 2015", "doi": "10.1038/nbt.3117"},
|
|
21
|
+
"circleseq": {"name": "CIRCLE-seq", "kind": "in vitro (cell-free), unbiased, genome-wide",
|
|
22
|
+
"citation": "Tsai et al., Nat Methods 2017", "doi": "10.1038/nmeth.4278"},
|
|
23
|
+
"changeseq": {"name": "CHANGE-seq", "kind": "in vitro, unbiased, high-throughput (110 sgRNAs)",
|
|
24
|
+
"citation": "Lazzarotto et al., Nat Biotechnol 2020", "doi": "10.1038/s41587-020-0555-7"},
|
|
25
|
+
"siteseq": {"name": "SITE-seq", "kind": "in vitro biochemical, unbiased",
|
|
26
|
+
"citation": "Cameron et al., Nat Methods 2017", "doi": "10.1038/nmeth.4284"},
|
|
27
|
+
}
|
|
28
|
+
PREDICTOR_PROVENANCE = {
|
|
29
|
+
"crisot": {"name": "CRISOT-Score", "approach": "XGBoost RNA-DNA interaction fingerprint",
|
|
30
|
+
"citation": "Chen et al., Nat Commun 2023", "doi": "10.1038/s41467-023-42695-4",
|
|
31
|
+
"license": "CC-BY-NC (run on the VM; only derived scores cached, weights never redistributed)"},
|
|
32
|
+
}
|
|
33
|
+
# integrase off-target assays (large serine integrases), for the assay recommender; data-thin, preprints
|
|
34
|
+
INTEGRASE_ASSAY_PROVENANCE = {
|
|
35
|
+
"cryptic_seq": {"name": "Cryptic-seq / HIDE-seq", "kind": "unbiased LSI off-target discovery",
|
|
36
|
+
"citation": "Hazelbaker et al. (Tome Biosciences), bioRxiv 2024",
|
|
37
|
+
"doi": "10.1101/2024.08.23.609471"},
|
|
38
|
+
"intquery": {"name": "IntQuery", "kind": "LSI off-target deep-learning predictor (paper-only, no public weights)",
|
|
39
|
+
"citation": "Bakalar et al. (Tome Biosciences), bioRxiv 2024", "doi": "10.1101/2024.10.10.617699"},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# canonical Cas9 guides present in the bench (20-nt protospacers; Tsai 2015/2017)
|
|
43
|
+
CANONICAL_GUIDES = {
|
|
44
|
+
"EMX1": "GAGTCCGAGCAGAAGAAGAA", "VEGFA_site1": "GGGTGGGGGGAGTTTGCTCC",
|
|
45
|
+
"VEGFA_site2": "GACCCCCTCCACCCCGCCTC", "VEGFA_site3": "GGTGAGTGAGTGTGTGCGTG",
|
|
46
|
+
"FANCF": "GGAATCCCTTCTGCAGCACC", "HEK293_site2": "GAACACAAAGCATAGACTGC",
|
|
47
|
+
"HEK293_site3": "GGCCCAGACTGAGCACGTGA", "HEK293_site4": "GGCACTGCGGCTGGAGGTGG",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# ---- DERIVED, real-data risk calibration: empirical active fraction by mismatch count -----------
|
|
51
|
+
# (computed on the VM over ALL candidates per assay; the off-target nomination risk band is grounded in how often a
|
|
52
|
+
# candidate at k mismatches was actually validated-active, not a guessed curve. Missing k -> abstain, not extrapolate.)
|
|
53
|
+
MISMATCH_ACTIVE_FRACTION = {
|
|
54
|
+
"guideseq": {0: 1.0, 1: 1.0, 2: 0.76471, 3: 0.23129, 4: 0.033, 5: 0.00276, 6: 0.00014},
|
|
55
|
+
"circleseq": {0: 1.0, 1: 1.0, 2: 1.0, 3: 0.67146, 4: 0.26566, 5: 0.05924, 6: 0.00985},
|
|
56
|
+
"changeseq": {0: 0.95, 2: 0.80851, 3: 0.56625, 4: 0.231, 5: 0.05505, 6: 0.00751},
|
|
57
|
+
"siteseq": {0: 1.0, 1: 1.0, 2: 1.0, 3: 0.67188, 4: 0.2491, 5: 0.03554, 6: 0.00478},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# ---- chromatin-accessibility validation: a CONTROLLED test of whether accessibility predicts off-target activity
|
|
61
|
+
# (Lazzarotto 2020). An earlier revision used a CROSS-CELL K562 proxy (weak/inconsistent); a later revision used a CELL-TYPE-MATCHED
|
|
62
|
+
# ENCODE HEK293T DNase track and SETTLED it. VERDICT: VALIDATED (moderate, cell-type-matched) for WT-Cas9 cell-based
|
|
63
|
+
# off-target activity, GUIDE-seq AUROC rises 0.58 (cross-cell) -> 0.671 (matched), in-vitro control null (method
|
|
64
|
+
# sound). Moderate effect (sequence/CRISOT still dominates); TTISS is the expected outlier (a Cas9-VARIANT assay).
|
|
65
|
+
# Full result: benchmarks/offtarget/chromatin_validation.json.
|
|
66
|
+
CHROMATIN_VALIDATION = {
|
|
67
|
+
"verdict": "validated (moderate, cell-type-matched) for WT-Cas9 cell-based off-target activity",
|
|
68
|
+
"validated": True,
|
|
69
|
+
"effect": "moderate",
|
|
70
|
+
"auroc_accessibility_for_activity": {
|
|
71
|
+
# cell-type-MATCHED HEK293T DNase (ENCFF529BOG); k562 = the earlier cross-cell proxy
|
|
72
|
+
"guideseq": {"modality": "cell-based (WT Cas9)", "auroc": 0.671, "k562_cross_cell": 0.58, "ci95": [0.642, 0.701]},
|
|
73
|
+
"ttiss": {"modality": "cell-based (Cas9 variants, outlier)", "auroc": 0.383, "k562_cross_cell": 0.346,
|
|
74
|
+
"ci95": [0.362, 0.405]},
|
|
75
|
+
"siteseq": {"modality": "in_vitro_control", "auroc": 0.494, "k562_cross_cell": 0.469, "ci95": [0.475, 0.514]},
|
|
76
|
+
},
|
|
77
|
+
"matched_track": "ENCODE HEK293T DNase-seq (ENCFF529BOG, GRCh38)",
|
|
78
|
+
# Does accessibility add INCREMENTAL value over the CRISOT sequence score? Tested at two imbalances:
|
|
79
|
+
# a small REAL conditional signal (logreg acc coef ~0.35, CI excludes 0) but NO held-out ranking improvement
|
|
80
|
+
# (CRISOT+acc AUPRC gap CI includes 0). DECISION: annotation, NOT a re-ranker. Full result:
|
|
81
|
+
# benchmarks/offtarget/chromatin_incremental.json.
|
|
82
|
+
"incremental_over_crisot": {
|
|
83
|
+
"conditional_acc_coef": 0.351, "conditional_acc_coef_ci95": [0.2385, 0.5584], "adds_conditional_signal": True,
|
|
84
|
+
"heldout_auprc_gap": 0.0027, "heldout_auprc_gap_ci95": [-0.014, 0.0214], "improves_ranking": False,
|
|
85
|
+
"decision": "annotation only, accessibility carries a small real conditional signal but does NOT improve "
|
|
86
|
+
"held-out nomination ranking over the CRISOT sequence score (at realistic imbalance); a "
|
|
87
|
+
"re-ranking combiner is NOT wired in (no demonstrated benefit).",
|
|
88
|
+
},
|
|
89
|
+
"changes_numeric_risk_score": False,
|
|
90
|
+
"note": "cell-type-matched accessibility predicts WT-Cas9 cell-based off-target activity (GUIDE-seq AUROC "
|
|
91
|
+
"0.58 cross-cell -> 0.671 matched; in-vitro control null -> method sound). MODERATE effect (the "
|
|
92
|
+
"sequence/CRISOT score dominates nomination) and CELL-TYPE-SPECIFIC. Does NOT transfer to the "
|
|
93
|
+
"Cas9-VARIANT assay TTISS (0.383, the expected outlier). Tested the incremental value over "
|
|
94
|
+
"CRISOT: a small real conditional signal but NO held-out ranking improvement -> chromatin is surfaced "
|
|
95
|
+
"as a VALIDATED ANNOTATION and does NOT change the numeric risk score (CRISOT captures the ranking).",
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
# ---- Off-Target-Bench headline (REAL full-data result; per-guide AUPRC, held-out-guide bootstrap CI) -----
|
|
99
|
+
# CRISOT-Score is the MD-physics, assay-AGNOSTIC scorer (not fit on these labels) -> a leakage-clean held-out
|
|
100
|
+
# evaluation on every assay. GUIDE/CIRCLE-seq use canonical guides; CHANGE/SITE-seq use INDEPENDENT broad guide
|
|
101
|
+
# panels (cross-assay generalization). CRISOT beats the homology baseline on ALL FOUR (per-guide bootstrap CI > 0).
|
|
102
|
+
BENCH_SUMMARY = {
|
|
103
|
+
"guideseq": {"n_guides": 8, "guide_panel": "canonical", "crisot_auprc": 0.6458, "homology_auprc": 0.4668,
|
|
104
|
+
"auprc_gap": 0.179, "gap_ci95": [0.0136, 0.3292], "crisot_beats_homology": True},
|
|
105
|
+
"circleseq": {"n_guides": 8, "guide_panel": "canonical", "crisot_auprc": 0.5197, "homology_auprc": 0.2664,
|
|
106
|
+
"auprc_gap": 0.2533, "gap_ci95": [0.1463, 0.3704], "crisot_beats_homology": True},
|
|
107
|
+
"changeseq": {"n_guides": 20, "guide_panel": "independent_broad", "crisot_auprc": 0.541, "homology_auprc": 0.2486,
|
|
108
|
+
"auprc_gap": 0.2924, "gap_ci95": [0.2349, 0.3477], "crisot_beats_homology": True},
|
|
109
|
+
"siteseq": {"n_guides": 11, "guide_panel": "independent_broad", "crisot_auprc": 0.5207, "homology_auprc": 0.2332,
|
|
110
|
+
"auprc_gap": 0.2874, "gap_ci95": [0.2388, 0.3354], "crisot_beats_homology": True},
|
|
111
|
+
"metric": "per-guide AUPRC; baseline = ascending mismatch count; learned = real CRISOT-Score (assay-agnostic, "
|
|
112
|
+
"VM, cached); CRISOT beats homology on all 4 assays (bootstrap CI excludes 0)",
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def assay_provenance() -> dict:
|
|
117
|
+
"""Validated provenance for every assay + predictor the off-target engine is grounded on."""
|
|
118
|
+
return {"nuclease_assays": ASSAY_PROVENANCE, "nuclease_predictor": PREDICTOR_PROVENANCE,
|
|
119
|
+
"integrase_assays": INTEGRASE_ASSAY_PROVENANCE}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@lru_cache(maxsize=1)
|
|
123
|
+
def bench_records() -> list[dict]:
|
|
124
|
+
"""The committed Off-Target-Bench fixture (real validated off-targets + cached CRISOT scores) as a list of
|
|
125
|
+
dicts, or [] when the data tree is absent (bare wheel). Columns: assay, guide, On, Off, mismatch, active,
|
|
126
|
+
crisot_score."""
|
|
127
|
+
try:
|
|
128
|
+
import csv
|
|
129
|
+
|
|
130
|
+
from pen_stack._resources import resource
|
|
131
|
+
path = resource("benchmarks/offtarget/offtarget_bench_fixture.csv")
|
|
132
|
+
with open(path, encoding="utf-8") as fh:
|
|
133
|
+
rows = list(csv.DictReader(fh))
|
|
134
|
+
for r in rows:
|
|
135
|
+
r["mismatch"] = int(r["mismatch"])
|
|
136
|
+
r["active"] = int(r["active"])
|
|
137
|
+
r["crisot_score"] = float(r["crisot_score"])
|
|
138
|
+
return rows
|
|
139
|
+
except Exception: # noqa: BLE001 (bare wheel / no data tree -> the bench harness is checkout-only)
|
|
140
|
+
return []
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def calibrated_active_fraction(n_mismatch: int, assay: str = "guideseq") -> float | None:
|
|
144
|
+
"""The empirical fraction of candidate sites at ``n_mismatch`` mismatches that were validated-active in the
|
|
145
|
+
named assay (grounded risk). None if the mismatch count is outside the calibrated range (then the axis
|
|
146
|
+
abstains rather than extrapolating)."""
|
|
147
|
+
table = MISMATCH_ACTIVE_FRACTION.get(assay, MISMATCH_ACTIVE_FRACTION["guideseq"])
|
|
148
|
+
return table.get(int(n_mismatch))
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Genome-wide off-target ENUMERATION, the search step of the off-target finder.
|
|
2
|
+
|
|
3
|
+
The off-target scorer previously only SCORED candidate sites a caller supplied. This module adds the missing half: given a guide +
|
|
4
|
+
enzyme, ENUMERATE every genomic site within the mismatch tolerance across GRCh38, returning coordinates, strand,
|
|
5
|
+
matched sequence, and mismatch count, so the downstream scorer ranks a genome-wide candidate set, not a
|
|
6
|
+
hand-typed one (the caller supplies a guide and gets the off-target sites back).
|
|
7
|
+
|
|
8
|
+
**Where it runs.** A full GRCh38 scan is heavy, so enumeration executes ONLY where Cas-OFFinder + the genome are
|
|
9
|
+
present, the VM, via the `casoffinder:tools` Docker image (Bae, Park & Kim, Bioinformatics 2014,
|
|
10
|
+
10.1093/bioinformatics/btu048). A build-time script enumerates the canonical guides on the VM and commits the
|
|
11
|
+
resulting coordinates to `data/offtarget/enumerated_cache.parquet`. Everywhere else (the live app, CI, the laptop)
|
|
12
|
+
this module REPLAYS that cache for a cached guide, or ABSTAINS with a pointer for a novel one, the same
|
|
13
|
+
replay-or-abstain pattern the heavy structure oracles use. The enumerated coordinates are facts derivable from the
|
|
14
|
+
public GRCh38 assembly (no license restriction, unlike the CRISOT weights), so committing the cache is clean.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
from functools import lru_cache
|
|
21
|
+
|
|
22
|
+
# Per-enzyme PAM + protospacer length. IUPAC PAMs are passed to Cas-OFFinder verbatim (it resolves R/V/etc.).
|
|
23
|
+
# pam_side: 3 = PAM 3' of the protospacer (Cas9); 5 = PAM 5' of the protospacer (Cas12a).
|
|
24
|
+
_ENZYME = {
|
|
25
|
+
"SpCas9": {"pam": "NGG", "pam_side": 3, "guide_len": 20},
|
|
26
|
+
"SaCas9": {"pam": "NNGRRT", "pam_side": 3, "guide_len": 21},
|
|
27
|
+
"AsCas12a": {"pam": "TTTV", "pam_side": 5, "guide_len": 23},
|
|
28
|
+
"LbCas12a": {"pam": "TTTV", "pam_side": 5, "guide_len": 23},
|
|
29
|
+
}
|
|
30
|
+
_ALIAS = {"cas9": "SpCas9", "spcas9": "SpCas9", "nuclease": "SpCas9", "nickase": "SpCas9",
|
|
31
|
+
"sacas9": "SaCas9", "ascas12a": "AsCas12a", "lbcas12a": "LbCas12a", "cas12a": "AsCas12a",
|
|
32
|
+
# Cpf1 is the original name for Cas12a and remains in common use, so it resolves to the same enzymes
|
|
33
|
+
# rather than falling through as unrecognized.
|
|
34
|
+
"cpf1": "AsCas12a", "ascpf1": "AsCas12a", "lbcpf1": "LbCas12a"}
|
|
35
|
+
DEFAULT_MAX_MISMATCH = 5
|
|
36
|
+
_CACHE_PATH = "data/offtarget/enumerated_cache.parquet"
|
|
37
|
+
_MOTIF_CACHE_PATH = "data/offtarget/motif_cache.parquet" # att / CAST-spacer genome scans (label-keyed)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def resolve_enzyme(name: str) -> str | None:
|
|
41
|
+
"""Map a free-text enzyme/family to a supported nuclease key, or None if unsupported."""
|
|
42
|
+
n = (name or "").strip()
|
|
43
|
+
if n in _ENZYME:
|
|
44
|
+
return n
|
|
45
|
+
return _ALIAS.get(n.lower())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _strip_pam(guide: str, spec: dict) -> str:
|
|
49
|
+
"""Return just the protospacer (drop a PAM the caller may have appended/prepended, and any spaces)."""
|
|
50
|
+
g = "".join(c for c in (guide or "").upper() if c in "ACGT")
|
|
51
|
+
return g[:spec["guide_len"]] if spec["pam_side"] == 3 else g[-spec["guide_len"]:]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_casoffinder_input(guide: str, enzyme: str, genome_path: str, max_mismatch: int) -> str:
|
|
55
|
+
"""The Cas-OFFinder v2.4 input text: genome path, the pattern (protospacer Ns + PAM in IUPAC), and the query
|
|
56
|
+
(the guide with N in the PAM positions) with the mismatch tolerance. Handles 3' (Cas9) and 5' (Cas12a) PAMs."""
|
|
57
|
+
spec = _ENZYME[enzyme]
|
|
58
|
+
g = _strip_pam(guide, spec)
|
|
59
|
+
pam, gl = spec["pam"], spec["guide_len"]
|
|
60
|
+
if spec["pam_side"] == 3:
|
|
61
|
+
pattern = "N" * gl + pam
|
|
62
|
+
query = g + "N" * len(pam)
|
|
63
|
+
else:
|
|
64
|
+
pattern = pam + "N" * gl
|
|
65
|
+
query = "N" * len(pam) + g
|
|
66
|
+
return f"{genome_path}\n{pattern}\n{query} {int(max_mismatch)}\n"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parse_casoffinder_output(text: str, enzyme: str) -> list[dict]:
|
|
70
|
+
"""Parse Cas-OFFinder v2.4 (no-bulge) output into off-target records. The default output is 6 tab-separated
|
|
71
|
+
columns: query-pattern, chromosome-header, 0-based position, matched-DNA (lowercase = mismatch), strand,
|
|
72
|
+
mismatch-count. GRCh38's FASTA headers are verbose (``chr3 AC:... AS:GRCh38``), so the contig name is the
|
|
73
|
+
first whitespace token of the chromosome field. Skips bulge rows (v2.0 enumerates substitutions only)."""
|
|
74
|
+
spec = _ENZYME[enzyme]
|
|
75
|
+
gl, plen = spec["guide_len"], spec["guide_len"] + len(spec["pam"])
|
|
76
|
+
out: list[dict] = []
|
|
77
|
+
for line in text.splitlines():
|
|
78
|
+
if not line.strip() or line.startswith("#") or line.lower().startswith(("crrna", "id", "bulge")):
|
|
79
|
+
continue
|
|
80
|
+
f = line.split("\t")
|
|
81
|
+
if len(f) < 6:
|
|
82
|
+
continue
|
|
83
|
+
try:
|
|
84
|
+
pos, n_mm = int(f[2]), int(f[5])
|
|
85
|
+
except ValueError:
|
|
86
|
+
continue
|
|
87
|
+
strand, dna = f[4], f[3].upper()
|
|
88
|
+
if strand not in ("+", "-") or "-" in dna: # '-' => a bulge/gap row, skip
|
|
89
|
+
continue
|
|
90
|
+
# the query column (f[0]) encodes the guide; a batch run mixes guides, so tag each record with its guide
|
|
91
|
+
q = "".join(c for c in f[0].upper() if c in "ACGT")
|
|
92
|
+
guide = q[:gl] if spec["pam_side"] == 3 else q[-gl:]
|
|
93
|
+
out.append({"guide": guide, "chrom": f[1].split()[0], "position": pos, "strand": strand,
|
|
94
|
+
"sequence": dna[:plen], "n_mismatch": n_mm})
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _casoffinder_bin() -> str | None:
|
|
99
|
+
return shutil.which("cas-offinder")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def scan_local(guide: str, enzyme: str, genome_path: str, max_mismatch: int = DEFAULT_MAX_MISMATCH,
|
|
103
|
+
device: str = "C", workdir: str | None = None) -> list[dict]:
|
|
104
|
+
"""Run Cas-OFFinder locally (VM / casoffinder:tools) and return the enumerated off-target records. Raises if
|
|
105
|
+
the binary or genome is absent, callers gate on `casoffinder_available()`. Used by the VM cache-build script."""
|
|
106
|
+
import os
|
|
107
|
+
import tempfile
|
|
108
|
+
binary = _casoffinder_bin()
|
|
109
|
+
if not binary:
|
|
110
|
+
raise RuntimeError("cas-offinder not on PATH (run inside casoffinder:tools on the VM)")
|
|
111
|
+
wd = workdir or tempfile.mkdtemp(prefix="offtgt_")
|
|
112
|
+
inp, outp = os.path.join(wd, "in.txt"), os.path.join(wd, "out.txt")
|
|
113
|
+
with open(inp, "w", encoding="utf-8") as fh:
|
|
114
|
+
fh.write(build_casoffinder_input(guide, enzyme, genome_path, max_mismatch))
|
|
115
|
+
subprocess.run([binary, inp, device, outp], check=True, capture_output=True, text=True)
|
|
116
|
+
with open(outp, encoding="utf-8") as fh:
|
|
117
|
+
return parse_casoffinder_output(fh.read(), enzyme)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def casoffinder_available() -> bool:
|
|
121
|
+
return _casoffinder_bin() is not None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@lru_cache(maxsize=1)
|
|
125
|
+
def _cache_df():
|
|
126
|
+
"""The committed genome-wide enumeration cache (enzyme, guide, chrom, position, strand, sequence, n_mismatch),
|
|
127
|
+
or None when absent (bare wheel). Enumerated on the VM over GRCh38; coordinates are public-genome facts."""
|
|
128
|
+
try:
|
|
129
|
+
import pandas as pd
|
|
130
|
+
|
|
131
|
+
from pen_stack._resources import resource
|
|
132
|
+
return pd.read_parquet(resource(_CACHE_PATH))
|
|
133
|
+
except Exception: # noqa: BLE001
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---- generic motif (att / CAST-spacer) genome scan: an all-N pattern finds every L-mer within k mismatches ----
|
|
138
|
+
def build_sequence_input(seq: str, genome_path: str, max_mismatch: int) -> str:
|
|
139
|
+
"""Cas-OFFinder input for a fixed-sequence genome scan (no PAM): an all-N pattern of the sequence length, so
|
|
140
|
+
the query's specific bases match every genomic L-mer within ``max_mismatch``. Used for pseudo-att (integrase)
|
|
141
|
+
and CAST-spacer scans, where recognition is a sequence match rather than a protospacer+PAM."""
|
|
142
|
+
s = "".join(c for c in (seq or "").upper() if c in "ACGT")
|
|
143
|
+
return f"{genome_path}\n{'N' * len(s)}\n{s} {int(max_mismatch)}\n"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def parse_sequence_output(text: str, seq_len: int) -> list[dict]:
|
|
147
|
+
"""Parse a fixed-sequence (all-N pattern) Cas-OFFinder scan: same 6-column layout as the nuclease parser but
|
|
148
|
+
the query column is not enzyme-structured, so there is no guide/PAM split."""
|
|
149
|
+
out: list[dict] = []
|
|
150
|
+
for line in text.splitlines():
|
|
151
|
+
if not line.strip() or line.startswith("#"):
|
|
152
|
+
continue
|
|
153
|
+
f = line.split("\t")
|
|
154
|
+
if len(f) < 6:
|
|
155
|
+
continue
|
|
156
|
+
try:
|
|
157
|
+
pos, n_mm = int(f[2]), int(f[5])
|
|
158
|
+
except ValueError:
|
|
159
|
+
continue
|
|
160
|
+
strand, dna = f[4], f[3].upper()
|
|
161
|
+
if strand not in ("+", "-") or "-" in dna:
|
|
162
|
+
continue
|
|
163
|
+
out.append({"chrom": f[1].split()[0], "position": pos, "strand": strand,
|
|
164
|
+
"sequence": dna[:seq_len], "n_mismatch": n_mm})
|
|
165
|
+
return out
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def scan_sequence_local(seq: str, genome_path: str, max_mismatch: int, device: str = "C",
|
|
169
|
+
workdir: str | None = None) -> list[dict]:
|
|
170
|
+
"""Run a fixed-sequence genome scan via Cas-OFFinder locally (VM). Raises if the binary/genome is absent."""
|
|
171
|
+
import os
|
|
172
|
+
import tempfile
|
|
173
|
+
binary = _casoffinder_bin()
|
|
174
|
+
if not binary:
|
|
175
|
+
raise RuntimeError("cas-offinder not on PATH (run inside casoffinder:tools on the VM)")
|
|
176
|
+
s = "".join(c for c in (seq or "").upper() if c in "ACGT")
|
|
177
|
+
wd = workdir or tempfile.mkdtemp(prefix="motif_")
|
|
178
|
+
inp, outp = os.path.join(wd, "in.txt"), os.path.join(wd, "out.txt")
|
|
179
|
+
with open(inp, "w", encoding="utf-8") as fh:
|
|
180
|
+
fh.write(build_sequence_input(s, genome_path, max_mismatch))
|
|
181
|
+
subprocess.run([binary, inp, device, outp], check=True, capture_output=True, text=True)
|
|
182
|
+
with open(outp, encoding="utf-8") as fh:
|
|
183
|
+
return parse_sequence_output(fh.read(), len(s))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@lru_cache(maxsize=1)
|
|
187
|
+
def _motif_cache_df():
|
|
188
|
+
"""The committed motif (att / CAST-spacer) genome-scan cache, or None when absent. Columns: label, kind, doi,
|
|
189
|
+
query, chrom, position, strand, sequence, n_mismatch."""
|
|
190
|
+
try:
|
|
191
|
+
import pandas as pd
|
|
192
|
+
|
|
193
|
+
from pen_stack._resources import resource
|
|
194
|
+
return pd.read_parquet(resource(_MOTIF_CACHE_PATH))
|
|
195
|
+
except Exception: # noqa: BLE001
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def enumerate_motif(label: str) -> dict:
|
|
200
|
+
"""Replay a cached genome-wide motif scan (pseudo-att for an integrase, or a CAST spacer) by label, or abstain
|
|
201
|
+
(its scan runs on the VM). Coordinates are public-genome facts; the app never fabricates sites."""
|
|
202
|
+
df = _motif_cache_df()
|
|
203
|
+
if df is not None and not df.empty:
|
|
204
|
+
sub = df[df["label"] == label]
|
|
205
|
+
if not sub.empty:
|
|
206
|
+
cols = ["chrom", "position", "strand", "sequence", "n_mismatch"]
|
|
207
|
+
rec = sub.iloc[0]
|
|
208
|
+
return {"available": True, "abstain": False, "label": label, "source": "cache",
|
|
209
|
+
"query": str(rec.get("query", "")), "doi": str(rec.get("doi", "")),
|
|
210
|
+
"n_sites": len(sub), "sites": sub.sort_values("n_mismatch")[cols].to_dict("records")}
|
|
211
|
+
return {"available": False, "abstain": True, "label": label, "source": "none",
|
|
212
|
+
"cached_motifs": sorted(df["label"].unique().tolist()) if df is not None and not df.empty else [],
|
|
213
|
+
"note": "genome-wide motif scan runs on the VM (casoffinder:tools over GRCh38); this surface replays "
|
|
214
|
+
"the committed cache or abstains (no fabricated sites)."}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@lru_cache(maxsize=1)
|
|
218
|
+
def enumerated_guides() -> tuple:
|
|
219
|
+
"""The (enzyme, guide) pairs whose genome-wide enumeration is cached (so the live app behaves like a finder for
|
|
220
|
+
them). Empty when no cache is present."""
|
|
221
|
+
df = _cache_df()
|
|
222
|
+
if df is None or df.empty:
|
|
223
|
+
return ()
|
|
224
|
+
return tuple(sorted({(str(r.enzyme), str(r.guide)) for r in df.itertuples()}))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _replay_cache(guide: str, enzyme: str, max_mismatch: int) -> list[dict] | None:
|
|
228
|
+
df = _cache_df()
|
|
229
|
+
if df is None or df.empty:
|
|
230
|
+
return None
|
|
231
|
+
spec = _ENZYME[enzyme]
|
|
232
|
+
g = _strip_pam(guide, spec)
|
|
233
|
+
sub = df[(df["enzyme"] == enzyme) & (df["guide"] == g) & (df["n_mismatch"] <= int(max_mismatch))]
|
|
234
|
+
if sub.empty:
|
|
235
|
+
return None
|
|
236
|
+
cols = ["chrom", "position", "strand", "sequence", "n_mismatch"]
|
|
237
|
+
return sub.sort_values("n_mismatch")[cols].to_dict("records")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def enumerate_offtargets(guide: str, enzyme: str = "SpCas9", max_mismatch: int = DEFAULT_MAX_MISMATCH,
|
|
241
|
+
allow_scan: bool = False, genome_path: str | None = None) -> dict:
|
|
242
|
+
"""Genome-wide off-target enumeration for a guide. Returns a dict with the enumerated `sites` (coordinates +
|
|
243
|
+
strand + sequence + mismatch count) and the `source` (cache | scan), or an abstention.
|
|
244
|
+
|
|
245
|
+
The live app calls with ``allow_scan=False`` (never runs a genome scan in-request): it replays the committed
|
|
246
|
+
cache for a cached guide, or abstains for a novel one. The VM cache-build script calls with ``allow_scan=True``
|
|
247
|
+
inside ``casoffinder:tools`` to run the real scan. Never fabricates sites."""
|
|
248
|
+
enz = resolve_enzyme(enzyme)
|
|
249
|
+
if enz is None:
|
|
250
|
+
return {"available": False, "abstain": True, "enzyme": enzyme,
|
|
251
|
+
"note": f"enumeration supports {sorted(_ENZYME)}; {enzyme!r} is not a supported nuclease"}
|
|
252
|
+
spec = _ENZYME[enz]
|
|
253
|
+
g = _strip_pam(guide, spec)
|
|
254
|
+
if len(g) < spec["guide_len"]:
|
|
255
|
+
return {"available": False, "abstain": True, "enzyme": enz,
|
|
256
|
+
"note": f"{enz} needs a {spec['guide_len']}-nt protospacer (got {len(g)})"}
|
|
257
|
+
|
|
258
|
+
cached = _replay_cache(g, enz, max_mismatch)
|
|
259
|
+
if cached is not None:
|
|
260
|
+
return {"available": True, "abstain": False, "enzyme": enz, "guide": g, "pam": spec["pam"],
|
|
261
|
+
"source": "cache", "max_mismatch": int(max_mismatch), "n_sites": len(cached), "sites": cached,
|
|
262
|
+
"note": "genome-wide enumeration replayed from the committed GRCh38 Cas-OFFinder cache"}
|
|
263
|
+
|
|
264
|
+
if allow_scan and casoffinder_available() and genome_path:
|
|
265
|
+
sites = scan_local(g, enz, genome_path, max_mismatch)
|
|
266
|
+
return {"available": True, "abstain": False, "enzyme": enz, "guide": g, "pam": spec["pam"],
|
|
267
|
+
"source": "scan", "max_mismatch": int(max_mismatch), "n_sites": len(sites), "sites": sites,
|
|
268
|
+
"note": "genome-wide Cas-OFFinder scan over GRCh38 (VM)"}
|
|
269
|
+
|
|
270
|
+
return {"available": False, "abstain": True, "enzyme": enz, "guide": g, "pam": spec["pam"],
|
|
271
|
+
"source": "none", "cached_guides": [gg for (ee, gg) in enumerated_guides() if ee == enz],
|
|
272
|
+
"note": "genome-wide enumeration for a novel guide runs on the VM (casoffinder:tools over GRCh38); "
|
|
273
|
+
"this surface replays the committed cache or abstains. Cached guides are listed; a VM scan "
|
|
274
|
+
"is required to enumerate a new guide (no fabrication of sites)."}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Serine-integrase pseudo-att genome scan.
|
|
2
|
+
|
|
3
|
+
A serine integrase recombines a donor attB with genomic **pseudo-attP** sites resembling the phage attP. This
|
|
4
|
+
enumerates pseudo-attP genome-wide: a fixed-sequence Cas-OFFinder scan of the attP core window over GRCh38 (heavy;
|
|
5
|
+
runs on the VM, replayed from cache here), scored by att-arm similarity.
|
|
6
|
+
|
|
7
|
+
**Status: mechanism-based, with a SEALED NEGATIVE recall benchmark.** Bxb1 and PhiC31 att are
|
|
8
|
+
independently verified (Bxb1: FlyBase FBto0000359 / Ghosh 2003; PhiC31: PDB 9U2T/9U2S + Groth 2000), and the
|
|
9
|
+
PhiC31 human pseudo-attP set is verified (Thyagarajan 2001, GenBank AF333429/30/31). But a sealed recall benchmark
|
|
10
|
+
on those documented pseudosites is **NEGATIVE**, att-sequence-similarity does not recover them above random
|
|
11
|
+
background (all three at the background median). So the genome-wide pseudo-attP SIMILARITY RANKING is a
|
|
12
|
+
mechanism-based scan that is **not validated** as a predictor (φC31 recognition needs palindrome architecture /
|
|
13
|
+
a learned model, not raw identity). The documented pseudosites are surfaced as verified known-off-target loci.
|
|
14
|
+
Confirming assay: Cryptic-seq / HIDE-seq. Never fabricates sites or a validated status.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from functools import lru_cache
|
|
19
|
+
|
|
20
|
+
from pen_stack.wgenome.offtarget_enumerate import enumerate_motif
|
|
21
|
+
|
|
22
|
+
_STATUS = "mechanism_based_unvalidated" # the similarity ranking; sealed PhiC31 recall benchmark is NEGATIVE
|
|
23
|
+
_ALIAS = {"bxb1": "Bxb1", "serine_integrase": "Bxb1", "pe_integrase": "Bxb1",
|
|
24
|
+
"phic31": "PhiC31", "phic31_integrase": "PhiC31"}
|
|
25
|
+
_CONFIRM = "Cryptic-seq / HIDE-seq (Tome Biosciences, 2024 preprint)"
|
|
26
|
+
|
|
27
|
+
# Capability disclosure: be PRECISE about what the sealed benchmark showed. What failed is a specific
|
|
28
|
+
# METHOD (attP sequence-similarity scanning), NOT the existence of predictable pseudosites, the signal is real
|
|
29
|
+
# (Chalberg's ~30-bp palindromic consensus; IntQuery's success on 410,776 cryptic attB), just not capturable by
|
|
30
|
+
# simple similarity. The claim is narrow: "sequence similarity is insufficient", never "unpredictable".
|
|
31
|
+
# φC31 carries its OWN sealed benchmark (verified documented pseudosites); other serine integrases (e.g. Bxb1) get
|
|
32
|
+
# the GENERIC wording, the φC31 benchmark is cited as CROSS-INTEGRASE evidence, never as if it were their own.
|
|
33
|
+
_CAPABILITY_PHIC31 = {
|
|
34
|
+
"badge": "mechanism-based · not predictive by sequence alone",
|
|
35
|
+
"microcopy": ("phiC31 pseudosite activity is sequence-guided but NOT predictable from similarity alone "
|
|
36
|
+
"(sealed benchmark, negative); confirm empirically (Cryptic-seq / HIDE-seq)."),
|
|
37
|
+
"what_failed": ("attP sequence-SIMILARITY scanning (a specific method), NOT the existence of predictable "
|
|
38
|
+
"pseudosites."),
|
|
39
|
+
"signal_is_real": ("the pseudosite signal IS real, Chalberg's ~30-bp palindromic consensus and IntQuery's "
|
|
40
|
+
"success on 410,776 cryptic attB show it, just not capturable by simple sequence identity."),
|
|
41
|
+
"indicated_next_step": ("a learned / DMS model (as IntQuery uses); this sealed negative is the empirical "
|
|
42
|
+
"justification for it, not evidence that pseudosites are unpredictable."),
|
|
43
|
+
}
|
|
44
|
+
_CAPABILITY_GENERIC = {
|
|
45
|
+
"badge": "mechanism-based · not predictive by sequence alone",
|
|
46
|
+
"microcopy": ("Serine-integrase pseudosite activity is sequence-guided but NOT predictable from att-similarity "
|
|
47
|
+
"alone, so this genome-wide similarity ranking is unvalidated; confirm empirically "
|
|
48
|
+
"(Cryptic-seq / HIDE-seq)."),
|
|
49
|
+
"what_failed": ("att sequence-SIMILARITY scanning (a specific method), NOT the existence of predictable "
|
|
50
|
+
"pseudosites."),
|
|
51
|
+
"signal_is_real": ("the pseudosite signal IS real, Chalberg's ~30-bp palindromic consensus and IntQuery's "
|
|
52
|
+
"success on 410,776 cryptic attB show it, just not capturable by simple sequence identity."),
|
|
53
|
+
"indicated_next_step": ("a learned / DMS model (as IntQuery uses)."),
|
|
54
|
+
"evidence_basis": ("cross-integrase: a sealed φC31 recall benchmark is NEGATIVE, the empirical basis for "
|
|
55
|
+
"treating serine-integrase att-similarity ranking as unvalidated. No integrase-specific "
|
|
56
|
+
"pseudosite benchmark exists here (Bxb1, for one, is highly specific)."),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _capability(integrase: str) -> dict:
|
|
61
|
+
"""φC31 owns its benchmark → φC31-specific wording; every other serine integrase gets the generic wording that
|
|
62
|
+
cites the φC31 result as cross-integrase evidence (never as if the benchmark were their own)."""
|
|
63
|
+
return _CAPABILITY_PHIC31 if integrase == "PhiC31" else _CAPABILITY_GENERIC
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@lru_cache(maxsize=1)
|
|
67
|
+
def integrase_att() -> dict:
|
|
68
|
+
try:
|
|
69
|
+
import yaml
|
|
70
|
+
|
|
71
|
+
from pen_stack._resources import resource
|
|
72
|
+
return yaml.safe_load(resource("data/curated/integrase_att.yaml").read_text(encoding="utf-8")) or {}
|
|
73
|
+
except Exception: # noqa: BLE001
|
|
74
|
+
return {}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def resolve_integrase(name: str) -> str | None:
|
|
78
|
+
n = (name or "").strip()
|
|
79
|
+
if n in (integrase_att().get("integrases") or {}):
|
|
80
|
+
return n
|
|
81
|
+
return _ALIAS.get(n.lower())
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _sealed_benchmark() -> dict | None:
|
|
85
|
+
return ((integrase_att().get("integrases") or {}).get("PhiC31") or {}).get("recall_benchmark")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def nominate_integrase(integrase: str = "Bxb1", top: int = 20) -> dict:
|
|
89
|
+
"""Genome-wide pseudo-attP nomination for a serine integrase. PhiC31 returns its verified documented human
|
|
90
|
+
pseudo-attP (ψA/ψC/ψD) plus the sealed recall benchmark (NEGATIVE, verbatim). Bxb1 (and any integrase with a
|
|
91
|
+
committed genome-scan cache) returns genome-wide similarity candidates carrying the same unvalidated-ranking
|
|
92
|
+
caveat. Never fabricates sites or a validated status."""
|
|
93
|
+
att = integrase_att()
|
|
94
|
+
key = resolve_integrase(integrase)
|
|
95
|
+
bench = _sealed_benchmark()
|
|
96
|
+
if key is None or key not in (att.get("integrases") or {}):
|
|
97
|
+
return {"family": "serine_integrase", "integrase": integrase, "available": False, "abstain": True,
|
|
98
|
+
"status": _STATUS, "note": f"no encoded att for integrase {integrase!r}; encoded: "
|
|
99
|
+
f"{sorted((att.get('integrases') or {}))}", "confirm_assay": _CONFIRM,
|
|
100
|
+
"nomination_is_not_clearance": True}
|
|
101
|
+
rec = att["integrases"][key]
|
|
102
|
+
|
|
103
|
+
if key == "PhiC31":
|
|
104
|
+
# promiscuous integrase: surface the VERIFIED documented pseudo-attP + the sealed NEGATIVE recall benchmark
|
|
105
|
+
return {"family": "serine_integrase", "integrase": "PhiC31", "available": True, "abstain": False,
|
|
106
|
+
"status": _STATUS, "att_core": rec["core"], "central_dinucleotide": rec["central_dinucleotide"],
|
|
107
|
+
"att_doi": rec["doi"], "pdb": rec.get("pdb"),
|
|
108
|
+
"documented_pseudo_attP": rec.get("pseudo_attP_positive_set"),
|
|
109
|
+
"documented_source": rec.get("positive_set_source"), "documented_doi": rec.get("positive_set_doi"),
|
|
110
|
+
"sealed_recall_benchmark": bench, "benchmark_integrase": "PhiC31",
|
|
111
|
+
"benchmark_is_cross_integrase": False,
|
|
112
|
+
"similarity_ranking_validated": False, "capability": _capability("PhiC31"),
|
|
113
|
+
"specificity_note": rec.get("specificity_note"),
|
|
114
|
+
"method": ("PhiC31 documented human pseudo-attP (verified GenBank AF333429/30/31, Thyagarajan 2001) "
|
|
115
|
+
"are surfaced as verified known off-target loci. Separately, the att-SIMILARITY genome "
|
|
116
|
+
"scan is a mechanism-based method whose sealed recall benchmark is NEGATIVE: sequence "
|
|
117
|
+
"similarity is INSUFFICIENT to rank these sites (the signal is real but needs a learned "
|
|
118
|
+
"model, not raw identity). Reported verbatim."),
|
|
119
|
+
"confirm_assay": _CONFIRM,
|
|
120
|
+
"honesty": "verified documented pseudosites (grounded facts) PLUS an explicit capability disclosure: "
|
|
121
|
+
"sequence-similarity scanning does not reliably rank them; NOT a clearance. Confirm "
|
|
122
|
+
"empirically.",
|
|
123
|
+
"nomination_is_not_clearance": True}
|
|
124
|
+
|
|
125
|
+
# Bxb1 (and any integrase with a committed genome-scan cache): genome-wide similarity candidates
|
|
126
|
+
window = rec["scan_window"]
|
|
127
|
+
enum = enumerate_motif(f"{key}_pseudo_attP")
|
|
128
|
+
if enum.get("abstain"):
|
|
129
|
+
return {"family": "serine_integrase", "integrase": key, "available": False, "abstain": True,
|
|
130
|
+
"status": _STATUS, "att_core": rec["core"], "note": enum["note"],
|
|
131
|
+
"cached_motifs": enum.get("cached_motifs", []), "sealed_recall_benchmark": bench,
|
|
132
|
+
"confirm_assay": _CONFIRM, "nomination_is_not_clearance": True}
|
|
133
|
+
wlen = len(window)
|
|
134
|
+
noms = []
|
|
135
|
+
for s in enum["sites"]:
|
|
136
|
+
nmm = int(s["n_mismatch"])
|
|
137
|
+
noms.append({"chrom": s["chrom"], "position": s["position"], "strand": s["strand"], "site": s["sequence"],
|
|
138
|
+
"n_mismatch": nmm, "arm_similarity": round(1.0 - nmm / wlen, 3), "output_kind": "candidate"})
|
|
139
|
+
noms.sort(key=lambda n: n["n_mismatch"])
|
|
140
|
+
return {"family": "serine_integrase", "integrase": key, "available": True, "abstain": False, "mode": "finder",
|
|
141
|
+
"status": _STATUS, "att_core": rec["core"], "central_dinucleotide": rec["central_dinucleotide"],
|
|
142
|
+
"scan_window": window, "source": enum["source"], "att_doi": rec["doi"],
|
|
143
|
+
"n_sites_genome_wide": len(noms), "nominations": noms[:top],
|
|
144
|
+
"specificity_note": rec.get("specificity_note"),
|
|
145
|
+
"sealed_recall_benchmark": bench, "benchmark_integrase": "PhiC31",
|
|
146
|
+
"benchmark_is_cross_integrase": (key != "PhiC31"),
|
|
147
|
+
"similarity_ranking_validated": False, "capability": _capability(key),
|
|
148
|
+
"method": ("fixed-sequence Cas-OFFinder scan of the attP core window over GRCh38, scored by att-arm "
|
|
149
|
+
"similarity. The similarity RANKING is mechanism-based and NOT predictive by sequence alone: "
|
|
150
|
+
"the sealed PhiC31 recall benchmark is NEGATIVE (sequence similarity is insufficient to "
|
|
151
|
+
"rank pseudo-attP; the signal is real but needs a learned model, per IntQuery)."),
|
|
152
|
+
"confirm_assay": _CONFIRM,
|
|
153
|
+
"honesty": "genome-wide pseudo-attP candidates by att similarity; sequence similarity is insufficient "
|
|
154
|
+
"to rank them (sealed-negative benchmark) -> mechanism-based, confirm empirically. NOT a clearance.",
|
|
155
|
+
"nomination_is_not_clearance": True}
|