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