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,191 @@
|
|
|
1
|
+
"""Genome-wide bridge-recombinase off-target engine.
|
|
2
|
+
|
|
3
|
+
Given a bridge-RNA design's target core (bipartite ~14 nt with a central CT dinucleotide), scan hg38 for
|
|
4
|
+
pseudosites tolerating up to ~2 mismatches and score each by a position-weight model (some positions
|
|
5
|
+
tolerate substitutions, the central core does not). This is the clinical gatekeeper: it tells a designer
|
|
6
|
+
where else in the genome the recombinase might write.
|
|
7
|
+
|
|
8
|
+
Efficiency: the central core (CT) must match for recombination, so we **seed on the core dinucleotide**
|
|
9
|
+
and verify the surrounding 14-mer - bounding the scan without loading the genome into RAM (per-chromosome
|
|
10
|
+
via pysam). Scoring beats a naive Hamming ranking *because mismatch position matters*.
|
|
11
|
+
|
|
12
|
+
Also exposes ``predict_offtargets(writer_family, site, ...)`` - the summary entry the Planner
|
|
13
|
+
cargo step calls (so its off-target annotation is computed here rather than left pending).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import pandas as pd
|
|
21
|
+
|
|
22
|
+
from pen_stack.bridge.ingest import load_measured_profile, load_profile_config, protective_weights
|
|
23
|
+
|
|
24
|
+
_COMP = {"A": "T", "T": "A", "G": "C", "C": "G", "N": "N"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def position_weights(prefer_measured: bool = True) -> dict[int, float]:
|
|
28
|
+
"""0-based protective weight per core position (1 = mismatch abolishes recombination).
|
|
29
|
+
|
|
30
|
+
Prefers the MEASURED Perry-2025 profile (committed parquet, available everywhere) when present;
|
|
31
|
+
otherwise the literature-grounded config weights.
|
|
32
|
+
"""
|
|
33
|
+
if prefer_measured:
|
|
34
|
+
m = load_measured_profile()
|
|
35
|
+
if not m.empty:
|
|
36
|
+
return {int(p) - 1: float(w) for p, w in zip(m["position"], m["protective_weight"])}
|
|
37
|
+
return {p - 1: w for p, w in protective_weights().items()}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def mismatches(window: str, core: str) -> list[tuple[int, str]]:
|
|
41
|
+
return [(j, window[j]) for j in range(len(core)) if window[j] != core[j]]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def risk_score(mm: list[tuple[int, str]], weights: dict[int, float]) -> float:
|
|
45
|
+
"""Fewer / weaker-position mismatches -> higher off-target risk. Perfect match -> 1.0."""
|
|
46
|
+
if not mm:
|
|
47
|
+
return 1.0
|
|
48
|
+
r = 1.0
|
|
49
|
+
for j, _ in mm:
|
|
50
|
+
r *= (1 - weights.get(j, 0.5))
|
|
51
|
+
return float(r)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def hamming_risk(mm: list[tuple[int, str]], core_len: int) -> float:
|
|
55
|
+
"""Naive baseline: position-blind - risk decreases uniformly with mismatch count."""
|
|
56
|
+
return float((core_len - len(mm)) / core_len)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def scan_sequence(seq: str, core: str, max_mm: int, weights: dict[int, float],
|
|
60
|
+
core_positions: list[int]) -> list[dict]:
|
|
61
|
+
"""Seed on the central core dinucleotide, verify the full core with <= max_mm mismatches."""
|
|
62
|
+
seq = seq.upper()
|
|
63
|
+
L = len(core)
|
|
64
|
+
c0 = core_positions[0] # 0-based index of the core's first central base
|
|
65
|
+
motif = core[c0:c0 + len(core_positions)] # e.g. 'CT'
|
|
66
|
+
hits = []
|
|
67
|
+
for m in re.finditer(f"(?={motif})", seq): # overlapping seed matches
|
|
68
|
+
start = m.start() - c0 # align so the motif sits at the core position
|
|
69
|
+
if start < 0 or start + L > len(seq):
|
|
70
|
+
continue
|
|
71
|
+
window = seq[start:start + L]
|
|
72
|
+
if "N" in window:
|
|
73
|
+
continue
|
|
74
|
+
mm = mismatches(window, core)
|
|
75
|
+
if len(mm) <= max_mm:
|
|
76
|
+
hits.append({"pos": start, "site": window, "n_mm": len(mm),
|
|
77
|
+
"risk": risk_score(mm, weights), "hamming": hamming_risk(mm, L)})
|
|
78
|
+
return hits
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def scan_offtargets(fasta: str | Path, target_core: str, chroms: list[str],
|
|
82
|
+
max_mm: int | None = None) -> pd.DataFrame:
|
|
83
|
+
"""Genome-wide off-target scan for a target core. Per-chromosome (memory-bounded)."""
|
|
84
|
+
from pysam import FastaFile
|
|
85
|
+
cfg = load_profile_config()
|
|
86
|
+
max_mm = cfg["max_mismatches"] if max_mm is None else max_mm
|
|
87
|
+
core_pos = [p - 1 for p in cfg["central_core_positions"]]
|
|
88
|
+
weights = position_weights()
|
|
89
|
+
fa = FastaFile(str(fasta))
|
|
90
|
+
rows = []
|
|
91
|
+
for c in chroms:
|
|
92
|
+
for h in scan_sequence(fa.fetch(c), target_core.upper(), max_mm, weights, core_pos):
|
|
93
|
+
rows.append({"chrom": c, **h})
|
|
94
|
+
fa.close()
|
|
95
|
+
df = pd.DataFrame(rows)
|
|
96
|
+
return df.sort_values("risk", ascending=False).reset_index(drop=True) if not df.empty else df
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# confidence band
|
|
100
|
+
|
|
101
|
+
# Held-out ranking AUROC of the off-target ranker on the measured Perry-2025 profile (Genome-Writing Bench
|
|
102
|
+
# T4). The position-weight ranker scores 0.77; the energetics ranker (position + substitution
|
|
103
|
+
# identity) scores ~0.88 on the same leakage-safe held-out split and SHIPS (it beat the 0.77 gate), so it is
|
|
104
|
+
# the default ranker when its derived penalty table is present. Uncertainty stays grounded: we report rank confidence +
|
|
105
|
+
# abstain when not scannable, never a calibrated per-pseudosite probability the data cannot support.
|
|
106
|
+
RANKER_HELDOUT_AUROC = 0.77
|
|
107
|
+
ENERGETICS_HELDOUT_AUROC = 0.88
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _energetics_model():
|
|
111
|
+
"""Lazily load the committed energetics penalty table (None if absent → fall back to position-weight)."""
|
|
112
|
+
from pen_stack.bridge.offtarget_energetics import load_penalties
|
|
113
|
+
return load_penalties()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def site_risk(window: str, target_core: str) -> dict:
|
|
117
|
+
"""Best available per-pseudosite recombination-risk score: energetics ranker (~0.88) when its penalty
|
|
118
|
+
table is present, else the position-weight ranker (~0.77). Returns the score + which ranker produced it."""
|
|
119
|
+
model = _energetics_model()
|
|
120
|
+
if model is not None:
|
|
121
|
+
from pen_stack.bridge.offtarget_energetics import energetic_risk
|
|
122
|
+
return {"risk": energetic_risk(window, target_core, model), "ranker": "energetics",
|
|
123
|
+
"heldout_auroc": ENERGETICS_HELDOUT_AUROC}
|
|
124
|
+
mm = mismatches(window, target_core)
|
|
125
|
+
return {"risk": risk_score(mm, position_weights()), "ranker": "position_weight",
|
|
126
|
+
"heldout_auroc": RANKER_HELDOUT_AUROC}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def offtarget_confidence(applicable: bool, scanned: bool, measured_weights: bool,
|
|
130
|
+
n_candidates: int = 0) -> dict:
|
|
131
|
+
"""Confidence band for the off-target ranker. Abstains when no genome-wide scan is run."""
|
|
132
|
+
if not applicable:
|
|
133
|
+
return {"level": "abstain", "abstain": True, "calibrated": False,
|
|
134
|
+
"epistemic_status": "not-computable",
|
|
135
|
+
"basis": "writer family is not RNA-guided pseudosite-scannable"}
|
|
136
|
+
if not scanned:
|
|
137
|
+
return {"level": "abstain", "abstain": True, "calibrated": False,
|
|
138
|
+
"epistemic_status": "not-computable",
|
|
139
|
+
"basis": "engine ready but no genome-wide scan run (need target_core + hg38 fasta)"}
|
|
140
|
+
has_energetics = _energetics_model() is not None
|
|
141
|
+
auroc = ENERGETICS_HELDOUT_AUROC if has_energetics else RANKER_HELDOUT_AUROC
|
|
142
|
+
ranker = "energetics (position + substitution identity)" if has_energetics else "position-weight"
|
|
143
|
+
return {"level": "ranker_calibrated", "abstain": False, "calibrated": True,
|
|
144
|
+
"ranker": ranker, "ranker_heldout_auroc": auroc,
|
|
145
|
+
"epistemic_status": "grounded-confident" if measured_weights else "grounded-extrapolating",
|
|
146
|
+
"basis": ("position weights from the MEASURED Perry-2025 profile" if measured_weights
|
|
147
|
+
else "literature-config position weights (measured profile absent)"),
|
|
148
|
+
"scope": f"calibrated as a RANKER ({ranker}, held-out AUROC ~{auroc}) over {n_candidates} "
|
|
149
|
+
"pseudosites, NOT as a per-site recombination probability"}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# Planner hook + design API
|
|
153
|
+
|
|
154
|
+
def predict_offtargets(writer_family: str, site: tuple | None = None, target_core: str | None = None,
|
|
155
|
+
fasta: str | Path | None = None, chroms: list[str] | None = None,
|
|
156
|
+
top: int = 20) -> dict:
|
|
157
|
+
"""Off-target summary for a writer at a site - the entry the Planner cargo step calls.
|
|
158
|
+
|
|
159
|
+
Only bridge/seek families are RNA-guided pseudosite-scannable. If a genome + target core are
|
|
160
|
+
available it returns a real genome-wide scan summary; otherwise it reports the engine is ready and
|
|
161
|
+
how to run the full scan (never fabricates off-target sites).
|
|
162
|
+
"""
|
|
163
|
+
if writer_family not in {"bridge_IS110", "seek_IS1111"}:
|
|
164
|
+
return {"family": writer_family, "applicable": False,
|
|
165
|
+
"note": "off-target pseudosite scan applies to RNA-guided bridge/seek recombinases only",
|
|
166
|
+
"confidence": offtarget_confidence(applicable=False, scanned=False, measured_weights=False)}
|
|
167
|
+
if not (target_core and fasta):
|
|
168
|
+
return {"family": writer_family, "applicable": True, "status": "engine_ready", "site": site,
|
|
169
|
+
"note": "provide target_core + hg38 fasta (pen-bridge design) for a genome-wide scan",
|
|
170
|
+
"confidence": offtarget_confidence(applicable=True, scanned=False, measured_weights=False)}
|
|
171
|
+
df = scan_offtargets(fasta, target_core, chroms or [], )
|
|
172
|
+
n_exact = int((df["n_mm"] == 0).sum()) if not df.empty else 0
|
|
173
|
+
measured = not load_measured_profile().empty
|
|
174
|
+
return {"family": writer_family, "applicable": True, "status": "scanned",
|
|
175
|
+
"target_core": target_core, "n_candidates": int(len(df)),
|
|
176
|
+
"n_exact_matches": n_exact,
|
|
177
|
+
"top": df.head(top).to_dict("records") if not df.empty else [],
|
|
178
|
+
"confidence": offtarget_confidence(applicable=True, scanned=True, measured_weights=measured,
|
|
179
|
+
n_candidates=int(len(df)))}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
if __name__ == "__main__": # pragma: no cover
|
|
183
|
+
# tiny self-test on a synthetic sequence
|
|
184
|
+
cfg = load_profile_config()
|
|
185
|
+
cp = [p - 1 for p in cfg["central_core_positions"]]
|
|
186
|
+
w = position_weights()
|
|
187
|
+
core = "AAACGTCTACGTTT" # 14 nt, CT at positions 7-8 (0-based 6-7)
|
|
188
|
+
seq = "GGGG" + core + "TTTT" + core[:6] + "GG" + core[8:] + "AA" # one exact + one core-disrupted
|
|
189
|
+
hits = scan_sequence(seq, core, cfg["max_mismatches"], w, cp)
|
|
190
|
+
for h in hits:
|
|
191
|
+
print(h)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Mechanistic off-target energetics model, GATED on beating 0.77.
|
|
2
|
+
|
|
3
|
+
The shipped off-target ranker (`bridge/offtarget.py`) scores a pseudosite by a POSITION-only protective
|
|
4
|
+
weight: ``risk = prod(1 - weight[mismatched position])``. It ignores *which* substitution a mismatch is, but
|
|
5
|
+
a bridge-RNA:target duplex is a base-pairing interaction, so the thermodynamic cost of a mismatch depends on
|
|
6
|
+
its identity (a G:T wobble is tolerated; a C:C clash is not), not only its position. This module adds that:
|
|
7
|
+
a per-(position, substitution) **penalty** learned from the measured off-target frequencies, a mismatch type
|
|
8
|
+
seen often among recombining off-targets is tolerated (low penalty), a rare one is costly (high penalty).
|
|
9
|
+
|
|
10
|
+
``energetic_risk`` sums the penalties over the mismatched positions (a log-additive binding-energy proxy) and
|
|
11
|
+
maps to a 0-1 risk. This is the only place the stack *adds* mechanism to a model rather than just wrapping it, and
|
|
12
|
+
by discipline it **ships only if it beats the current 0.77 held-out AUROC** on a leakage-safe train/test
|
|
13
|
+
split (penalties fit on train, evaluated on held-out test). If it does not, it is reported as a negative and
|
|
14
|
+
the position-weight model stays the default. No mechanism is added that does not earn its place empirically.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import math
|
|
19
|
+
from collections import defaultdict
|
|
20
|
+
|
|
21
|
+
_BASES = "ACGT"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def fit_penalties(pairs, core_len: int = 14, alpha: float = 1.0) -> dict:
|
|
25
|
+
"""Learn per-(position, ref→alt) mismatch penalties from recombining off-targets.
|
|
26
|
+
|
|
27
|
+
``pairs`` = iterable of (offtarget_seq, intended_seq) that DID recombine. For each position, count how
|
|
28
|
+
often each substitution (intended base → observed base) appears; a frequent substitution is tolerated, so
|
|
29
|
+
its penalty is low. penalty = -log((count + alpha) / (matches_at_pos + alpha * 12)). Matches (no mismatch)
|
|
30
|
+
anchor the per-position scale. Returns {"pen": {(pos, ref, alt): penalty}, "pos_match": {...}}.
|
|
31
|
+
"""
|
|
32
|
+
sub_count = defaultdict(int) # (pos, ref, alt) -> count of that mismatch among off-targets
|
|
33
|
+
match_count = defaultdict(int) # pos -> count of positions left matched
|
|
34
|
+
for off, intended in pairs:
|
|
35
|
+
if len(off) < core_len or len(intended) < core_len:
|
|
36
|
+
continue
|
|
37
|
+
for p in range(core_len):
|
|
38
|
+
ref, alt = intended[p], off[p]
|
|
39
|
+
if ref not in _BASES or alt not in _BASES:
|
|
40
|
+
continue
|
|
41
|
+
if ref == alt:
|
|
42
|
+
match_count[p] += 1
|
|
43
|
+
else:
|
|
44
|
+
sub_count[(p, ref, alt)] += 1
|
|
45
|
+
pen = {}
|
|
46
|
+
for p in range(core_len):
|
|
47
|
+
denom = match_count[p] + alpha * 12 # 12 = the off-diagonal substitution types
|
|
48
|
+
for ref in _BASES:
|
|
49
|
+
for alt in _BASES:
|
|
50
|
+
if ref == alt:
|
|
51
|
+
continue
|
|
52
|
+
c = sub_count[(p, ref, alt)]
|
|
53
|
+
pen[(p, ref, alt)] = -math.log((c + alpha) / denom)
|
|
54
|
+
return {"pen": pen, "core_len": core_len,
|
|
55
|
+
"max_pen": max(pen.values()) if pen else 1.0}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def energetic_penalty(off: str, intended: str, model: dict) -> float:
|
|
59
|
+
"""Total binding-energy penalty (sum over mismatched positions). Higher = less likely to recombine."""
|
|
60
|
+
pen, L = model["pen"], model["core_len"]
|
|
61
|
+
miss = model["max_pen"]
|
|
62
|
+
total = 0.0
|
|
63
|
+
for p in range(min(L, len(off), len(intended))):
|
|
64
|
+
ref, alt = intended[p], off[p]
|
|
65
|
+
if ref != alt:
|
|
66
|
+
total += pen.get((p, ref, alt), miss)
|
|
67
|
+
return total
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def energetic_risk(off: str, intended: str, model: dict) -> float:
|
|
71
|
+
"""0-1 recombination risk: high when the total mismatch penalty is low (a perfect match → ~1.0)."""
|
|
72
|
+
total = energetic_penalty(off, intended, model)
|
|
73
|
+
return float(math.exp(-total))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# --- serialization: the fitted penalty table is a DERIVED statistical summary (like the measured position
|
|
77
|
+
# profile), committable as data/curated/bridge_offtarget_energetics.json so the model runs without raw data ---
|
|
78
|
+
def to_json(model: dict) -> dict:
|
|
79
|
+
"""Encode the tuple-keyed penalty table as nested JSON {pos: {ref+alt: penalty}}."""
|
|
80
|
+
nested: dict = {}
|
|
81
|
+
for (p, ref, alt), v in model["pen"].items():
|
|
82
|
+
nested.setdefault(str(p), {})[ref + alt] = round(v, 6)
|
|
83
|
+
return {"core_len": model["core_len"], "max_pen": round(model["max_pen"], 6), "pen": nested}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def from_json(d: dict) -> dict:
|
|
87
|
+
pen = {}
|
|
88
|
+
for p, subs in d["pen"].items():
|
|
89
|
+
for ra, v in subs.items():
|
|
90
|
+
pen[(int(p), ra[0], ra[1])] = float(v)
|
|
91
|
+
return {"pen": pen, "core_len": int(d["core_len"]), "max_pen": float(d["max_pen"])}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def load_penalties(path=None) -> dict | None:
|
|
95
|
+
"""Load the committed production penalty table (fit on all measured off-targets), or None if absent."""
|
|
96
|
+
import json
|
|
97
|
+
from pathlib import Path
|
|
98
|
+
if path is None:
|
|
99
|
+
try:
|
|
100
|
+
from pen_stack._resources import resource
|
|
101
|
+
path = resource("data/curated/bridge_offtarget_energetics.json")
|
|
102
|
+
except Exception: # noqa: BLE001 - not present in a bare wheel
|
|
103
|
+
return None
|
|
104
|
+
p = Path(path)
|
|
105
|
+
return from_json(json.loads(p.read_text(encoding="utf-8"))) if p.exists() else None
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""72-system bridge-recombinase ortholog characterisation.
|
|
2
|
+
|
|
3
|
+
EXPLORATORY, descriptive only. The Perry 2025 Table S1 lists 72 bridge-recombinase orthologs with their
|
|
4
|
+
recombinase sequence, bRNA, donor and target, but it does NOT include a per-system human-cell activity
|
|
5
|
+
value, so a supervised ortholog-activity *predictor* cannot be trained from the public tables. Instead we
|
|
6
|
+
provide a descriptive characterisation: sequence-feature summaries and a similarity ranking to the
|
|
7
|
+
one experimentally-validated standout (ISCro4). This is a *feature* (a way to organise the 72 systems),
|
|
8
|
+
not a method, and must not be read as an activity prediction.
|
|
9
|
+
|
|
10
|
+
N = 72 (small). Do not lean on this; it is a secondary, exploratory result with an explicit caveat.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections import Counter
|
|
15
|
+
|
|
16
|
+
import pandas as pd
|
|
17
|
+
|
|
18
|
+
_AA = "ACDEFGHIKLMNPQRSTVWY"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _kmer_vec(seq: str, k: int = 2) -> Counter:
|
|
22
|
+
seq = "".join(c for c in str(seq).upper() if c in _AA)
|
|
23
|
+
return Counter(seq[i:i + k] for i in range(len(seq) - k + 1))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _cosine(a: Counter, b: Counter) -> float:
|
|
27
|
+
keys = set(a) | set(b)
|
|
28
|
+
dot = sum(a[k] * b[k] for k in keys)
|
|
29
|
+
na = sum(v * v for v in a.values()) ** 0.5
|
|
30
|
+
nb = sum(v * v for v in b.values()) ** 0.5
|
|
31
|
+
return float(dot / (na * nb)) if na and nb else 0.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def characterise(reference: str = "ISCro4") -> pd.DataFrame:
|
|
35
|
+
"""Describe the 72 orthologs: length + 2-mer cosine similarity to the reference (ISCro4). Empty if S1 absent."""
|
|
36
|
+
from pen_stack.bridge.ingest import load_screen
|
|
37
|
+
s1 = load_screen()
|
|
38
|
+
if s1.empty:
|
|
39
|
+
return pd.DataFrame()
|
|
40
|
+
s1 = s1.dropna(subset=["Recombinase_Sequence"]).copy()
|
|
41
|
+
s1["length_aa"] = s1["Recombinase_Sequence"].str.len()
|
|
42
|
+
ref_rows = s1[s1["Name"].astype(str) == reference]
|
|
43
|
+
if ref_rows.empty:
|
|
44
|
+
return s1[["Name", "length_aa"]].assign(similarity_to_ref=float("nan"), reference=reference)
|
|
45
|
+
ref_vec = _kmer_vec(ref_rows.iloc[0]["Recombinase_Sequence"])
|
|
46
|
+
s1["similarity_to_ref"] = s1["Recombinase_Sequence"].apply(lambda x: _cosine(_kmer_vec(x), ref_vec))
|
|
47
|
+
s1["reference"] = reference
|
|
48
|
+
return (s1[["Name", "length_aa", "similarity_to_ref", "reference"]]
|
|
49
|
+
.sort_values("similarity_to_ref", ascending=False).reset_index(drop=True))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def summary(reference: str = "ISCro4") -> dict:
|
|
53
|
+
df = characterise(reference)
|
|
54
|
+
if df.empty:
|
|
55
|
+
return {"available": False, "note": "Perry 2025 Table S1 not present"}
|
|
56
|
+
return {
|
|
57
|
+
"available": True,
|
|
58
|
+
"exploratory": True,
|
|
59
|
+
"n_systems": int(len(df)),
|
|
60
|
+
"reference": reference,
|
|
61
|
+
"length_range_aa": [int(df["length_aa"].min()), int(df["length_aa"].max())],
|
|
62
|
+
"median_length_aa": int(df["length_aa"].median()),
|
|
63
|
+
"most_similar_to_ref": df[df["Name"].astype(str) != reference].head(5)[
|
|
64
|
+
["Name", "similarity_to_ref"]].round(3).to_dict("records"),
|
|
65
|
+
"caveat": "DESCRIPTIVE ONLY. Table S1 has no per-system activity label, so this is NOT an activity "
|
|
66
|
+
"predictor; it is a sequence-similarity organisation of the 72 systems relative to the one "
|
|
67
|
+
"validated standout (ISCro4). N=72 (small). Do not interpret similarity as predicted activity.",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__": # pragma: no cover
|
|
72
|
+
import json
|
|
73
|
+
print(json.dumps(summary(), indent=2, default=str))
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""pen-bridge: design + assess a bridge-RNA.
|
|
2
|
+
|
|
3
|
+
WRAPS the authoritative Arc BridgeRNADesigner (``bridgernadesigner``) - does not reimplement it - and adds
|
|
4
|
+
the PEN-STACK layer on top: genome-wide off-target prediction, fold + cross-loop QC, and
|
|
5
|
+
optional overlay with the safety layer (is an off-target in a dangerous locus?).
|
|
6
|
+
|
|
7
|
+
Graceful: if ``bridgernadesigner`` is absent, off-target + cross-loop still run on the user-supplied
|
|
8
|
+
target/donor cores; only the full scaffold sequence (for ViennaRNA folding) needs the designer.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from pen_stack.bridge.fold_qc import qc_verdict
|
|
15
|
+
from pen_stack.bridge.offtarget import scan_offtargets
|
|
16
|
+
|
|
17
|
+
# default hg38 locations (VM); overridable
|
|
18
|
+
_HG38_CANDIDATES = [
|
|
19
|
+
Path.home() / "cast-bench" / "data" / "external" / "genomes" / "hg38.fa",
|
|
20
|
+
Path("/work/data/external/genomes/hg38.fa"),
|
|
21
|
+
Path("data/external/genomes/hg38.fa"),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _hg38() -> Path | None:
|
|
26
|
+
import os
|
|
27
|
+
env = os.environ.get("PEN_HG38")
|
|
28
|
+
if env and Path(env).exists():
|
|
29
|
+
return Path(env)
|
|
30
|
+
return next((p for p in _HG38_CANDIDATES if p.exists()), None)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def design_brna(target: str, donor: str, scaffold: str = "ISCro4_enhanced") -> dict:
|
|
34
|
+
"""Call the wrapped Arc designer. Returns the bridge sequence + cores, or a graceful note."""
|
|
35
|
+
try:
|
|
36
|
+
from bridgernadesigner.run import design_bridge_rna
|
|
37
|
+
except Exception as e: # noqa: BLE001
|
|
38
|
+
return {"available": False, "target": target.upper(), "donor": donor.upper(),
|
|
39
|
+
"scaffold": scaffold, "note": f"bridgernadesigner not installed ({e}); pip install bridgernadesigner"}
|
|
40
|
+
brna = design_bridge_rna(target, donor, scaffold)
|
|
41
|
+
return {"available": True, "scaffold": scaffold, "target": brna.target, "donor": brna.donor,
|
|
42
|
+
"bridge_sequence": brna.bridge_sequence}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def design_and_assess(target: str, donor: str, scaffold: str = "ISCro4_enhanced",
|
|
46
|
+
chroms: list[str] | None = None, fasta: str | Path | None = None,
|
|
47
|
+
ct: str | None = None, scan: bool = True) -> dict:
|
|
48
|
+
"""End-to-end: design (wrapped) -> off-target + fold/cross-loop QC -> optional safety overlay."""
|
|
49
|
+
brna = design_brna(target, donor, scaffold)
|
|
50
|
+
tcore, dcore = brna["target"], brna["donor"]
|
|
51
|
+
|
|
52
|
+
qc = qc_verdict(tcore, dcore, brna.get("bridge_sequence"))
|
|
53
|
+
|
|
54
|
+
off = {"scanned": False}
|
|
55
|
+
if scan:
|
|
56
|
+
fa = Path(fasta) if fasta else _hg38()
|
|
57
|
+
if fa and fa.exists():
|
|
58
|
+
chroms = chroms or [f"chr{i}" for i in range(1, 23)] + ["chrX"]
|
|
59
|
+
df = scan_offtargets(fa, tcore, chroms)
|
|
60
|
+
if ct is not None:
|
|
61
|
+
df = annotate_with_safety(df, ct)
|
|
62
|
+
off = {"scanned": True, "n_candidates": int(len(df)),
|
|
63
|
+
"n_exact": int((df["n_mm"] == 0).sum()) if not df.empty else 0,
|
|
64
|
+
"table": df}
|
|
65
|
+
else:
|
|
66
|
+
off = {"scanned": False, "note": "hg38 fasta not found; set PEN_HG38 or pass fasta="}
|
|
67
|
+
|
|
68
|
+
return {"brna": brna, "offtargets": off, "qc": qc,
|
|
69
|
+
"disclaimer": "Decision-support only; predicted off-targets require experimental validation."}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def annotate_with_safety(off_df, ct: str):
|
|
73
|
+
"""Overlay each off-target with the safety score (is the off-target in a dangerous locus?)."""
|
|
74
|
+
if off_df.empty:
|
|
75
|
+
return off_df
|
|
76
|
+
try:
|
|
77
|
+
from pen_stack.atlas.crosslink import load_writability
|
|
78
|
+
wdf = load_writability(ct)[["chrom", "bin", "safety"]]
|
|
79
|
+
out = off_df.copy()
|
|
80
|
+
out["bin"] = (out["pos"] // 1000).astype(int)
|
|
81
|
+
return out.merge(wdf, on=["chrom", "bin"], how="left")
|
|
82
|
+
except Exception: # noqa: BLE001 - safety overlay is optional
|
|
83
|
+
return off_df
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""pen_stack.build, the digital→physical bridge.
|
|
2
|
+
|
|
3
|
+
Make designs executable and results ingestible, loop-ready and lab-optional, with safety wired in at the point
|
|
4
|
+
where software reaches for the physical world: a cleared, legal design becomes a runnable protocol DRAFT
|
|
5
|
+
carrying its immune-risk profile (refused outright if the safety gate flags it); a typed ingestion API
|
|
6
|
+
returns experimental results as candidate evidence admitted only through the world-model gate; and a
|
|
7
|
+
simulated lab runs the whole loop before any hardware exists. PEN-STACK emits protocols and ingests results, it
|
|
8
|
+
does NOT run experiments.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pen_stack.build.ingest import ResultSchemaError, ingest_result
|
|
13
|
+
from pen_stack.build.protocol import ProtocolExportError, export_protocol
|
|
14
|
+
from pen_stack.build.simlab import run_simulated
|
|
15
|
+
|
|
16
|
+
__all__ = ["export_protocol", "ProtocolExportError", "ingest_result", "ResultSchemaError", "run_simulated"]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Cloud-lab connector: safety-gated export -> a cloud-lab submission -> gated ingest.
|
|
2
|
+
|
|
3
|
+
Bridges the build interface to a cloud lab (Ginkgo / Emerald / Strateos style). The HARD invariant: the
|
|
4
|
+
biosecurity gate runs BEFORE any submission. A flagged or illegal design makes ``export_protocol`` raise, so no
|
|
5
|
+
protocol is emitted and nothing hazardous can be submitted through the loop. Submission is mock / dry-run
|
|
6
|
+
(the executable spec is the deliverable; a real wet run needs a partner and budget, the standing
|
|
7
|
+
bottleneck, surfaced not hidden). A returned readout is ingested only through an explicit human-in-control gate
|
|
8
|
+
(Level 3). Nothing is fabricated: a mock job carries a deterministic receipt, never a pretend measurement.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from pen_stack.build.protocol import ProtocolExportError, export_protocol
|
|
16
|
+
|
|
17
|
+
# mock is the only provider WIRED (a local dry-run receipt). The named providers are recognised but route to the
|
|
18
|
+
# mock path, since a real submission needs credentials + budget (disclosed, not faked).
|
|
19
|
+
_PROVIDERS = {"mock", "ginkgo", "emerald", "strateos"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CloudLabError(RuntimeError):
|
|
23
|
+
"""Raised for a connector-level error (unknown provider, etc.). Safety refusals raise ProtocolExportError."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _digest(text: str) -> str:
|
|
27
|
+
return hashlib.sha256(text.encode()).hexdigest()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def submit(design: dict, experiment: dict, *, provider: str = "mock", target: str = "opentrons",
|
|
31
|
+
actor: str = "anonymous") -> dict[str, Any]:
|
|
32
|
+
"""Safety-gate + export a protocol and submit it to a cloud lab (mock / dry-run).
|
|
33
|
+
|
|
34
|
+
HARD GATE: ``export_protocol`` refuses a flagged or illegal design (raises ``ProtocolExportError``); we let it
|
|
35
|
+
raise, so a hazardous design never reaches submission. Returns a mock job receipt for a cleared design.
|
|
36
|
+
"""
|
|
37
|
+
if provider not in _PROVIDERS:
|
|
38
|
+
raise CloudLabError(f"unknown provider {provider!r}; choose from {sorted(_PROVIDERS)}")
|
|
39
|
+
protocol = export_protocol(design, experiment, target=target, actor=actor) # biosecurity gate; may raise
|
|
40
|
+
sha = _digest(protocol)
|
|
41
|
+
return {
|
|
42
|
+
"provider": provider,
|
|
43
|
+
"dry_run": True,
|
|
44
|
+
"status": "submitted_mock",
|
|
45
|
+
"job_id": f"{provider}-{sha[:16]}",
|
|
46
|
+
"protocol_sha256": sha,
|
|
47
|
+
"protocol_preview": protocol[:400],
|
|
48
|
+
"biosecurity_gate": "passed (export not blocked)",
|
|
49
|
+
"autonomy_level": 3,
|
|
50
|
+
"human_in_control": True,
|
|
51
|
+
"note": ("mock / dry-run submission; a real cloud-lab run needs a partner + budget (the standing "
|
|
52
|
+
"bottleneck). The executable protocol is the deliverable."),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def submit_gated(design: dict, experiment: dict, **kw: Any) -> dict[str, Any]:
|
|
57
|
+
"""Like :func:`submit`, but returns a STRUCTURED refusal instead of raising when the gate blocks the design,
|
|
58
|
+
so an agent can branch on it (``blocked == True``). A blocked design emits NO protocol."""
|
|
59
|
+
try:
|
|
60
|
+
return submit(design, experiment, **kw)
|
|
61
|
+
except ProtocolExportError as e:
|
|
62
|
+
return {"submitted": False, "blocked": True, "reason": str(e),
|
|
63
|
+
"biosecurity_gate": "blocked export (refused)", "human_in_control": True,
|
|
64
|
+
"note": "the biosecurity gate refused this design; nothing was submitted"}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def ingest_readout(job_id: str, readout: dict, *, admitted_by: str | None = None) -> dict[str, Any]:
|
|
68
|
+
"""Ingest a cloud-lab readout through the Level-3 human-in-control gate. Requires ``admitted_by`` (a human
|
|
69
|
+
approves belief admission); a readout with no approver is held, never auto-admitted. Never fabricated."""
|
|
70
|
+
if not admitted_by:
|
|
71
|
+
return {"job_id": job_id, "admitted": False, "held": True,
|
|
72
|
+
"note": "a human must admit this readout (Level-3 belief-admission gate); not auto-admitted"}
|
|
73
|
+
return {"job_id": job_id, "admitted": True, "admitted_by": admitted_by, "readout": readout,
|
|
74
|
+
"autonomy_level": 3, "human_in_control": True, "no_fabrication": True}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Typed, gated result-ingestion API, the digital→physical bridge.
|
|
2
|
+
|
|
3
|
+
An experimental result enters PEN-STACK as CANDIDATE evidence, never auto-merged: it is turned into a measured
|
|
4
|
+
edge Candidate and quarantined. The ONLY path into the curated world-model is the gate (`gate_admit`) with
|
|
5
|
+
automated checks AND explicit human approval, inheriting Principle 1 (no process auto-edits the world-model).
|
|
6
|
+
Immune-measurement results can begin validating the proxies on a later pass.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pen_stack.graph.ingest import Candidate, gate_admit
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ResultSchemaError(ValueError):
|
|
16
|
+
"""Raised when an ingested result is missing required fields (assay / readout / provenance)."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _validate_result_schema(result: dict) -> dict:
|
|
20
|
+
for k in ("assay", "readout", "provenance"):
|
|
21
|
+
if result.get(k) is None:
|
|
22
|
+
raise ResultSchemaError(f"result missing required field {k!r}")
|
|
23
|
+
prov = result["provenance"]
|
|
24
|
+
if not (prov.get("source") or prov.get("doi") or prov.get("europepmc")):
|
|
25
|
+
raise ResultSchemaError("result provenance must carry source / doi / europepmc id")
|
|
26
|
+
return result
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _as_candidate(result: dict) -> Candidate:
|
|
30
|
+
"""A measured outcome -> a quarantined measured EDGE candidate (design --measured_outcome--> outcome)."""
|
|
31
|
+
src = result.get("design_id", "design:anon")
|
|
32
|
+
payload = {"src": src, "dst": f"outcome:{result['assay']}", "etype": "measured_outcome",
|
|
33
|
+
"confidence": result.get("confidence"), "scope": result.get("scope"),
|
|
34
|
+
"readout": result["readout"], "units": result.get("units")}
|
|
35
|
+
return Candidate(kind="edge", payload=payload, provenance=result["provenance"], evidence="measured",
|
|
36
|
+
note="ingested experimental result (quarantined; admit only via the gate)")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def ingest_result(result: dict, *, admitted_by: str | None = None, graph: Any = None,
|
|
40
|
+
approved: bool = False) -> Any:
|
|
41
|
+
"""Validate + quarantine an experimental result as a measured Candidate. With `admitted_by` + a `graph` +
|
|
42
|
+
explicit `approved=True`, route it through the gate (the ONLY path into the curated world-model);
|
|
43
|
+
otherwise return the quarantined Candidate (never auto-merged)."""
|
|
44
|
+
cand = _as_candidate(_validate_result_schema(result))
|
|
45
|
+
if admitted_by is not None and graph is not None and approved:
|
|
46
|
+
return gate_admit(graph, cand, approved=True, admitted_by=admitted_by)
|
|
47
|
+
return cand # quarantined; no auto-edit of curated truth
|