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,18 @@
|
|
|
1
|
+
"""The WriteSpec intent layer.
|
|
2
|
+
|
|
3
|
+
A typed, ontology-backed, machine-checkable representation of a genome-writing request (``WriteRequest``, an
|
|
4
|
+
SBOL3 profile), a grounded prose-to-spec extractor that labels every inference and never fabricates intent, and a
|
|
5
|
+
feasibility (satisfiability) check. This is the agentic front-end: one contract every downstream stage consumes.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pen_stack.spec.writespec import (
|
|
10
|
+
CargoComponent,
|
|
11
|
+
Constraints,
|
|
12
|
+
Resolved,
|
|
13
|
+
Target,
|
|
14
|
+
WriteRequest,
|
|
15
|
+
WRITE_TYPES,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = ["WriteRequest", "CargoComponent", "Target", "Constraints", "Resolved", "WRITE_TYPES"]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Clarifying-question planner.
|
|
2
|
+
|
|
3
|
+
Given a :class:`WriteRequest`, return the minimal set of questions needed to make it actionable: the required
|
|
4
|
+
fields that are unspecified or ambiguous. The extractor already populates ``clarifications`` during parsing;
|
|
5
|
+
this module is the standalone planner an agent calls to decide whether to ask before composing a write, and to
|
|
6
|
+
get a clean, de-duplicated question list. It never guesses a value: a missing required field becomes a question.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pen_stack.spec.writespec import WriteRequest
|
|
11
|
+
|
|
12
|
+
# fields that must be present for a write to be composable (by write-type)
|
|
13
|
+
_REQUIRED_TARGET = {"insertion", "excision", "inversion", "replacement", "regulatory_rewrite",
|
|
14
|
+
"landing_pad_install", "multiplex"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def clarifying_questions(spec: WriteRequest) -> list[str]:
|
|
18
|
+
"""The minimal de-duplicated question list for the underspecified / ambiguous required fields."""
|
|
19
|
+
qs: list[str] = list(spec.clarifications) # carry the extractor's questions
|
|
20
|
+
if spec.write_type in _REQUIRED_TARGET and spec.target.kind == "unspecified":
|
|
21
|
+
q = "Which gene, locus, att/landing site, or disease phenotype should the write target?"
|
|
22
|
+
if q not in qs:
|
|
23
|
+
qs.append(q)
|
|
24
|
+
# an insertion with no cargo at all is underspecified
|
|
25
|
+
if spec.write_type == "insertion" and not spec.cargo:
|
|
26
|
+
qs.append("What cargo should be inserted (size in bp/kb, and any features such as promoter / CDS / polyA)?")
|
|
27
|
+
# an unresolved term needs disambiguation, not a guess
|
|
28
|
+
for term in spec.unresolved:
|
|
29
|
+
qs.append(f"The term '{term}' could not be resolved to a gene / locus / cell / phenotype; please clarify it.")
|
|
30
|
+
# de-duplicate, preserve order
|
|
31
|
+
seen: set[str] = set()
|
|
32
|
+
out: list[str] = []
|
|
33
|
+
for q in qs:
|
|
34
|
+
if q not in seen:
|
|
35
|
+
seen.add(q)
|
|
36
|
+
out.append(q)
|
|
37
|
+
return out
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_actionable(spec: WriteRequest) -> bool:
|
|
41
|
+
"""True when no clarifying question remains (the spec is complete enough to compose a write)."""
|
|
42
|
+
return not clarifying_questions(spec)
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""Grounded prose-to-WriteSpec extractor.
|
|
2
|
+
|
|
3
|
+
Maps a plain-language genome-writing request to a typed :class:`WriteRequest`. The backbone is DETERMINISTIC (so
|
|
4
|
+
the benchmark is reproducible and CI-safe); an LLM pass is optional and never required. Three grounding
|
|
5
|
+
safeguards are mandatory and encoded here:
|
|
6
|
+
|
|
7
|
+
1. assumption surfacing - every field not explicit in the prose is recorded in ``provenance`` as ``inferred``
|
|
8
|
+
with the rationale in ``assumptions`` (never a silent default);
|
|
9
|
+
2. clarifying questions - a required field that is unspecified or ambiguous yields a ``clarifications``
|
|
10
|
+
question rather than a guess;
|
|
11
|
+
3. no fabrication - a term that cannot be resolved is listed in ``unresolved`` and its field stays null.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
from pen_stack.spec.resolvers import (
|
|
18
|
+
resolve_cell,
|
|
19
|
+
resolve_chem,
|
|
20
|
+
resolve_feature,
|
|
21
|
+
resolve_gene,
|
|
22
|
+
resolve_locus,
|
|
23
|
+
resolve_phenotype,
|
|
24
|
+
)
|
|
25
|
+
from pen_stack.spec.resolvers.phenotype import _PHENO
|
|
26
|
+
from pen_stack.spec.writespec import (
|
|
27
|
+
CargoComponent,
|
|
28
|
+
Constraints,
|
|
29
|
+
Target,
|
|
30
|
+
WriteRequest,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Matches the symbol shape the gene resolver itself accepts. A shorter bound here than in the resolver would make
|
|
34
|
+
# a symbol's fate depend on its length: an 8-character symbol reaches the resolver while a 9-character one is never
|
|
35
|
+
# offered to it, so real long symbols (ARHGEF10L, TNFRSF13B) would go unseen.
|
|
36
|
+
_GENE_RE = re.compile(r"\b([A-Z][A-Z0-9]{1,9})\b")
|
|
37
|
+
_WRITE_TYPE_KW = [
|
|
38
|
+
# Multiplex leads: a request that names the modality outright ("multiplex knockout of TRAC and B2M") also
|
|
39
|
+
# carries the verb of the edit it multiplexes, and reading the verb first would report a single-locus write
|
|
40
|
+
# for a request that said the opposite.
|
|
41
|
+
("multiplex", ["multiplex", "multiple loci", "simultaneously edit"]),
|
|
42
|
+
("excision", ["excise", "delete", "knock out", "knockout", "remove ", "disrupt"]),
|
|
43
|
+
("inversion", ["invert", "inversion", "flip "]),
|
|
44
|
+
("replacement", ["replace", "correct the", "correct a", "swap", "base edit", "prime edit", "repair the"]),
|
|
45
|
+
("regulatory_rewrite", ["regulatory", "promoter swap", "rewrite the promoter", "tune expression", "upregulate", "knock down"]),
|
|
46
|
+
("landing_pad_install", ["landing pad", "landing-pad", "install an att", "install a landing", "attp", "bxb1 site"]),
|
|
47
|
+
("insertion", ["insert", "integrate", "knock in", "knock-in", "knockin", "add a", "deliver a transgene", "place a"]),
|
|
48
|
+
]
|
|
49
|
+
_ROLE_KW = {"promoter": "promoter", "cds": "CDS", "coding": "CDS", "transgene": "CDS", "gfp": "CDS",
|
|
50
|
+
"polya": "polyA", "poly-a": "polyA", "insulator": "insulator", "enhancer": "enhancer",
|
|
51
|
+
"terminator": "terminator", "ires": "IRES"}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _detect_write_type(low: str) -> tuple[str, bool]:
|
|
55
|
+
for wt, kws in _WRITE_TYPE_KW:
|
|
56
|
+
if any(k in low for k in kws):
|
|
57
|
+
return wt, True
|
|
58
|
+
return "insertion", False # default, labelled inferred by the caller
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _gene_tokens(prose: str) -> list[str]:
|
|
62
|
+
"""Gene-symbol tokens: what the request could be naming as an edit site.
|
|
63
|
+
|
|
64
|
+
A token is excluded when it is jargon, when it resolves as a cell line or type (HEK293T), or when it lies
|
|
65
|
+
within a term the prose has already used for something else. The CD8 of 'CD8 T cells' names the population and
|
|
66
|
+
a site preposition in front of it ('... in CD8 T cells') would otherwise anchor it as the target; a vehicle
|
|
67
|
+
written in capitals ('deliver with LENTIVIRUS') has the shape of a symbol and would otherwise be read as one.
|
|
68
|
+
"""
|
|
69
|
+
from pen_stack.spec.resolvers.cell import _CELLS
|
|
70
|
+
from pen_stack.spec.resolvers.gene import _STOP
|
|
71
|
+
cell_keys = {k.upper() for k in _CELLS}
|
|
72
|
+
taken = [(s, e) for s, e, _ in _cell_mentions(prose)] + [(s, e) for s, e, _ in _vehicle_mentions(prose.lower())]
|
|
73
|
+
return [m.group(1) for m in _GENE_RE.finditer(prose)
|
|
74
|
+
if m.group(1) not in _STOP and m.group(1).upper() not in cell_keys
|
|
75
|
+
and not any(s < m.end() and m.start() < e for s, e in taken)]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# The EDIT SITE is signalled by a site preposition ("into/at/in/within X") or a suffix ("X locus/site"); the
|
|
79
|
+
# CARGO is what is written ("insert X …"). Preferring a site-anchored token stops a gene-like cargo name from
|
|
80
|
+
# hijacking the target (e.g. "insert a CD19 CAR into TRAC" -> target is TRAC, not CD19).
|
|
81
|
+
_SITE_PREP = re.compile(r"\b(?:into|at|in|to|within|inside)\s+(?:the\s+)?([A-Z][A-Z0-9]{1,9})\b")
|
|
82
|
+
_SITE_SUFFIX = re.compile(r"\b([A-Z][A-Z0-9]{1,9})\b\s+(?:locus|loci|site|safe[- ]?harbou?r)\b")
|
|
83
|
+
# Genes conjoined to the target in one request: "TRAC and B2M", "TRAC, B2M and CIITA".
|
|
84
|
+
_CONJ = re.compile(r"\s*(?:,\s*and|,|and|&|/|\+)\s+([A-Z][A-Z0-9]{1,9})\b")
|
|
85
|
+
|
|
86
|
+
# A delivery vehicle matches as a whole word whose parts all belong to the vehicle: the recombinant /
|
|
87
|
+
# self-complementary prefixes (rAAV, scAAV), the serotype suffixes (AAV9, AAVrh74, AAV-DJ, AAV-PHP.eB) and a
|
|
88
|
+
# plural (AAVs). A locus whose name merely begins with the same letters - AAVS1, the standard safe harbour - is
|
|
89
|
+
# excluded by the SUFFIX boundary, since "S1" is not a serotype; the prefixes therefore cannot re-admit it.
|
|
90
|
+
_AAV = r"(?:scr|sc|ss|r)?aav(?:\d+|rh\d+|-?dj|-?php[\w.]*)?s?"
|
|
91
|
+
_VEH = [(rf"dual[\s-]*{_AAV}", "AAV_dual"),
|
|
92
|
+
(_AAV, "AAV_single"), (r"adeno[\s-]*associated\s*virus(?:es)?", "AAV_single"),
|
|
93
|
+
(r"lentivir\w*", "lentivirus"), (r"lnps?", "LNP"), (r"electroporat\w*", "electroporation"),
|
|
94
|
+
(r"rnps?", "electroporation"), (r"adenovir\w*", "adenovirus")]
|
|
95
|
+
# A vehicle can be named in order to be RULED OUT. The cue sits next to the mention: before it for an exclusion
|
|
96
|
+
# ("instead of AAV", "not lentivirus"), after it for a capacity verdict ("AAV is too small").
|
|
97
|
+
#
|
|
98
|
+
# A cue only counts when it GOVERNS the mention, which needs two guards. It must stand in the same clause, since
|
|
99
|
+
# "this does not matter: use AAV9" negates the matter, not the AAV; and it must be adjacent, reaching the vehicle
|
|
100
|
+
# through nothing but a preposition or an article, since "not only AAV but also lentivirus" negates "only". A bare
|
|
101
|
+
# search of the preceding characters satisfies neither and rules out vehicles the request chose.
|
|
102
|
+
_CLAUSE_SPLIT = re.compile(r"[;:,.]|\bso\b|\bbut\b|\bthen\b")
|
|
103
|
+
_VEH_REJECT_BEFORE_RE = re.compile(
|
|
104
|
+
r"\b(?:instead\s+of|rather\s+than|other\s+than|without|avoid(?:ing)?|rules?\s+out|ruled\s+out|ruling\s+out|"
|
|
105
|
+
r"cannot\s+use|can(?:'|no)?t\s+use|not)\s+"
|
|
106
|
+
r"(?:(?:using|use|with|via|by|deliver(?:ed|ing)?|packag(?:e|ed|ing)|into|a|an|the)\s+){0,2}$")
|
|
107
|
+
_VEH_REJECT_AFTER = ("too small", "too large", "too big", "won't fit", "will not fit", "does not fit",
|
|
108
|
+
"doesn't fit", "cannot carry", "can't carry", "exceeds", "is insufficient")
|
|
109
|
+
# Electroporation carries no cargo of its own, so it imposes no packaging capacity and does not compete with the
|
|
110
|
+
# vector that does. Naming both is the standard ex vivo knock-in ("Cas9 RNP by electroporation with an AAV6
|
|
111
|
+
# donor"), where the constraint that matters belongs to the donor capsid, not to the pulse.
|
|
112
|
+
_NON_PACKAGING = {"electroporation"}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _longest_non_overlapping(hits: list[tuple[int, int, object]]) -> list[tuple[int, int, object]]:
|
|
116
|
+
"""Keep the longest term starting earliest and drop anything its span touches, so one term is never read as
|
|
117
|
+
two. Terms in these tables nest ('293t' inside 'hek293t', 't cell' inside 'cd8 t cell', the AAV inside 'dual
|
|
118
|
+
AAV') and also OVERLAP without nesting: in 'HEK293T cells' the line's trailing t is the t of 't cell', and in
|
|
119
|
+
'Jurkat cells' likewise, so a containment test alone would read one cell line as two different cell types."""
|
|
120
|
+
kept: list[tuple[int, int, object]] = []
|
|
121
|
+
for h in sorted(hits, key=lambda x: (x[0], -(x[1] - x[0]))):
|
|
122
|
+
if not any(h[0] < k[1] and k[0] < h[1] for k in kept):
|
|
123
|
+
kept.append(h)
|
|
124
|
+
return kept
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _vehicle_mentions(low: str) -> list[tuple[int, int, str]]:
|
|
128
|
+
"""Every delivery vehicle named in the prose, with its span, in prose order."""
|
|
129
|
+
hits = [(m.start(), m.end(), veh) for pat, veh in _VEH
|
|
130
|
+
for m in re.finditer(rf"(?<![a-z0-9]){pat}(?![a-z0-9])", low)]
|
|
131
|
+
return sorted(_longest_non_overlapping(hits))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _vehicle_is_ruled_out(low: str, start: int, end: int) -> bool:
|
|
135
|
+
"""True when the text next to a vehicle mention rules it out rather than choosing it.
|
|
136
|
+
|
|
137
|
+
Both sides read only within the mention's own clause, and the exclusion cue must reach the vehicle directly."""
|
|
138
|
+
before = _CLAUSE_SPLIT.split(low[max(0, start - 48):start])[-1]
|
|
139
|
+
if _VEH_REJECT_BEFORE_RE.search(before):
|
|
140
|
+
return True
|
|
141
|
+
after = _CLAUSE_SPLIT.split(low[end:end + 30])[0]
|
|
142
|
+
return any(c in after for c in _VEH_REJECT_AFTER)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _cell_mentions(prose: str) -> list[tuple[int, int, str]]:
|
|
146
|
+
"""Every cell term named, with its span, in prose order."""
|
|
147
|
+
from pen_stack.spec.resolvers.cell import _CELLS
|
|
148
|
+
low = prose.lower()
|
|
149
|
+
hits: list[tuple[int, int, str]] = []
|
|
150
|
+
for phrase in _CELLS:
|
|
151
|
+
i = low.find(phrase)
|
|
152
|
+
while i != -1:
|
|
153
|
+
hits.append((i, i + len(phrase), phrase))
|
|
154
|
+
i = low.find(phrase, i + 1)
|
|
155
|
+
return sorted(_longest_non_overlapping(hits))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _pick_target_token(prose: str, gene_toks: list[str]) -> str | None:
|
|
159
|
+
"""Choose the token naming the edit SITE. Prefer a site-anchored token ('into/at X', 'X locus'); otherwise
|
|
160
|
+
fall back to the first gene token (a bare target like 'knock out PCSK9'). gene_toks is already stop/cell-filtered."""
|
|
161
|
+
if not gene_toks:
|
|
162
|
+
return None
|
|
163
|
+
valid = set(gene_toks)
|
|
164
|
+
anchored = [(m.start(1), m.group(1)) for m in _SITE_PREP.finditer(prose) if m.group(1) in valid]
|
|
165
|
+
anchored += [(m.start(1), m.group(1)) for m in _SITE_SUFFIX.finditer(prose) if m.group(1) in valid]
|
|
166
|
+
if anchored:
|
|
167
|
+
anchored.sort()
|
|
168
|
+
return anchored[0][1]
|
|
169
|
+
return gene_toks[0]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _conjoined_tokens(prose: str, tgt_tok: str, valid: set[str]) -> list[str]:
|
|
173
|
+
"""Gene tokens chained to the target by a conjunction ('TRAC and B2M', 'TRAC, B2M and CIITA').
|
|
174
|
+
|
|
175
|
+
Only a token joined directly to the target counts, so a gene-like CARGO name elsewhere in the prose
|
|
176
|
+
('insert a CD19 CAR into TRAC') is not mistaken for a second target."""
|
|
177
|
+
m = re.search(rf"\b{re.escape(tgt_tok)}\b", prose)
|
|
178
|
+
if not m:
|
|
179
|
+
return []
|
|
180
|
+
out: list[str] = []
|
|
181
|
+
tail = prose[m.end():]
|
|
182
|
+
while (m2 := _CONJ.match(tail)) and m2.group(1) in valid and m2.group(1) not in out and m2.group(1) != tgt_tok:
|
|
183
|
+
out.append(m2.group(1))
|
|
184
|
+
tail = tail[m2.end():]
|
|
185
|
+
return out
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _detect_bp(low: str) -> int | None:
|
|
189
|
+
m = re.search(r"(\d+(?:\.\d+)?)\s*kb", low)
|
|
190
|
+
if m:
|
|
191
|
+
return int(float(m.group(1)) * 1000)
|
|
192
|
+
m = re.search(r"(\d{2,6})\s*(?:bp|base pairs)", low)
|
|
193
|
+
if m:
|
|
194
|
+
return int(m.group(1))
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _detect_phenotype(prose: str):
|
|
199
|
+
low = prose.lower()
|
|
200
|
+
for phrase in sorted(_PHENO, key=len, reverse=True):
|
|
201
|
+
if phrase in low:
|
|
202
|
+
return resolve_phenotype(phrase)
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _detect_cell(prose: str):
|
|
207
|
+
from pen_stack.spec.resolvers.cell import _CELLS
|
|
208
|
+
low = prose.lower()
|
|
209
|
+
for phrase in sorted(_CELLS, key=len, reverse=True):
|
|
210
|
+
if phrase in low:
|
|
211
|
+
return resolve_cell(phrase)
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _detect_inducer(prose: str):
|
|
216
|
+
from pen_stack.spec.resolvers.chem import _CHEM
|
|
217
|
+
low = prose.lower()
|
|
218
|
+
for phrase in sorted(_CHEM, key=len, reverse=True):
|
|
219
|
+
if phrase in low:
|
|
220
|
+
return resolve_chem(phrase)
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def extract_writespec(prose: str, *, overrides: dict | None = None, allow_llm: bool = False) -> WriteRequest:
|
|
225
|
+
"""Extract a typed WriteRequest from prose, deterministically, with grounding safeguards.
|
|
226
|
+
|
|
227
|
+
``overrides`` lets a caller (or the web builder) supply a field directly; those are marked provenance ``user``.
|
|
228
|
+
``allow_llm`` is accepted for surface parity but the deterministic backbone is what runs and is benchmarked;
|
|
229
|
+
an LLM pass, when wired, may only PROPOSE values that still pass the resolvers (it can never set an
|
|
230
|
+
unresolved id).
|
|
231
|
+
"""
|
|
232
|
+
prose = prose or ""
|
|
233
|
+
low = prose.lower()
|
|
234
|
+
prov: dict[str, str] = {}
|
|
235
|
+
assumptions: list[str] = []
|
|
236
|
+
unresolved: list[str] = []
|
|
237
|
+
clarifications: list[str] = []
|
|
238
|
+
overrides = overrides or {}
|
|
239
|
+
|
|
240
|
+
# --- write type ---
|
|
241
|
+
if "write_type" in overrides:
|
|
242
|
+
wt = overrides["write_type"]
|
|
243
|
+
prov["write_type"] = "user"
|
|
244
|
+
else:
|
|
245
|
+
wt, explicit = _detect_write_type(low)
|
|
246
|
+
prov["write_type"] = "explicit" if explicit else "inferred"
|
|
247
|
+
if not explicit:
|
|
248
|
+
assumptions.append("write_type inferred as 'insertion' (no explicit write verb; the default for a "
|
|
249
|
+
"cargo-placement request)")
|
|
250
|
+
|
|
251
|
+
# --- target: a named gene/locus or att site is the EDIT SITE (target_kind); a disease is the GOAL ---
|
|
252
|
+
target = Target()
|
|
253
|
+
pheno = _detect_phenotype(prose)
|
|
254
|
+
gene_toks = _gene_tokens(prose)
|
|
255
|
+
att = None
|
|
256
|
+
for k in ("attb", "attp", "landing pad", "landing-pad", "landing site"):
|
|
257
|
+
if k in low:
|
|
258
|
+
att = k.replace(" ", "_").replace("-", "_")
|
|
259
|
+
break
|
|
260
|
+
gene_resolved = None
|
|
261
|
+
tgt_tok = _pick_target_token(prose, gene_toks)
|
|
262
|
+
if tgt_tok:
|
|
263
|
+
g = resolve_gene(tgt_tok)
|
|
264
|
+
if g.resolved:
|
|
265
|
+
gene_resolved = g
|
|
266
|
+
else:
|
|
267
|
+
unresolved.append(tgt_tok)
|
|
268
|
+
if gene_resolved is not None:
|
|
269
|
+
loc = resolve_locus(tgt_tok)
|
|
270
|
+
target = Target(kind="gene", gene=gene_resolved, locus=loc if loc.resolved else None)
|
|
271
|
+
prov["target.gene"] = "explicit"
|
|
272
|
+
# Genes conjoined to the target ("knock out TRAC and B2M") are further targets, not cargo: keeping only the
|
|
273
|
+
# first would design against half the request. The primary stays in ``gene`` for every existing consumer.
|
|
274
|
+
for tok in _conjoined_tokens(prose, tgt_tok, set(gene_toks)):
|
|
275
|
+
g2 = resolve_gene(tok)
|
|
276
|
+
if g2.resolved:
|
|
277
|
+
target.additional_genes.append(g2)
|
|
278
|
+
elif tok not in unresolved:
|
|
279
|
+
unresolved.append(tok)
|
|
280
|
+
if target.additional_genes:
|
|
281
|
+
names = [gene_resolved.id or tgt_tok] + [g.id or "?" for g in target.additional_genes]
|
|
282
|
+
prov["target.additional_genes"] = "explicit"
|
|
283
|
+
assumptions.append(f"the request names {len(names)} target genes ({', '.join(names)}); "
|
|
284
|
+
f"{names[0]} is recorded as the primary target and the rest as additional targets")
|
|
285
|
+
if wt != "multiplex":
|
|
286
|
+
clarifications.append(f"The request names {len(names)} target genes ({', '.join(names)}) but reads "
|
|
287
|
+
f"as a single {wt} write. Should every listed gene be edited?")
|
|
288
|
+
# A symbol shaped like a gene but absent from the atlas is carried through unvalidated, by design, so that a
|
|
289
|
+
# genuinely novel gene still works. Say so on the spec: without it, an unconfirmed symbol is indistinguishable
|
|
290
|
+
# from a grounded one at a glance, and whether it is carried at all would rest on its length.
|
|
291
|
+
if (gene_resolved.confidence or 0) < 0.5:
|
|
292
|
+
assumptions.append(f"target symbol '{tgt_tok}' is not in the writable-genome atlas; it is carried "
|
|
293
|
+
f"unvalidated, with no coordinates resolved for it")
|
|
294
|
+
clarifications.append(f"Target symbol '{tgt_tok}' could not be confirmed against the writable-genome "
|
|
295
|
+
f"atlas. Is it the intended gene?")
|
|
296
|
+
if pheno is not None and pheno.resolved: # the disease is the goal, attached to the gene target
|
|
297
|
+
target.phenotype = pheno
|
|
298
|
+
prov["target.phenotype"] = "explicit"
|
|
299
|
+
elif att:
|
|
300
|
+
target = Target(kind="att_site", att_site=att)
|
|
301
|
+
prov["target.att_site"] = "explicit"
|
|
302
|
+
if pheno is not None and pheno.resolved:
|
|
303
|
+
target.phenotype = pheno
|
|
304
|
+
prov["target.phenotype"] = "explicit"
|
|
305
|
+
elif pheno is not None and pheno.resolved:
|
|
306
|
+
target = Target(kind="phenotype", phenotype=pheno)
|
|
307
|
+
prov["target.phenotype"] = "explicit"
|
|
308
|
+
if target.kind == "unspecified":
|
|
309
|
+
clarifications.append("Which gene, locus, att/landing site, or disease phenotype should the write target?")
|
|
310
|
+
|
|
311
|
+
# --- cell type (optional but recommended) ---
|
|
312
|
+
# A request may offer a choice ("in HeLa or Jurkat"). Resolving the terms in table order would commit the spec
|
|
313
|
+
# to one of them and label it explicit, so distinct resolved cells are counted first and a choice is asked.
|
|
314
|
+
cell = None
|
|
315
|
+
named: list = []
|
|
316
|
+
for _s, _e, phrase in _cell_mentions(prose):
|
|
317
|
+
r = resolve_cell(phrase)
|
|
318
|
+
if r is not None and r.resolved and r.id not in {x.id for x in named}:
|
|
319
|
+
named.append(r)
|
|
320
|
+
if len(named) > 1:
|
|
321
|
+
clarifications.append(f"The request names more than one cell type "
|
|
322
|
+
f"({', '.join(c.label or c.text or str(c.id) for c in named)}). Which is the target?")
|
|
323
|
+
elif len(named) == 1:
|
|
324
|
+
cell = named[0]
|
|
325
|
+
prov["cell_type"] = "explicit"
|
|
326
|
+
else:
|
|
327
|
+
ct = _detect_cell(prose)
|
|
328
|
+
if ct is not None and ct.candidates:
|
|
329
|
+
cell = ct
|
|
330
|
+
prov["cell_type"] = "explicit"
|
|
331
|
+
clarifications.append(f"Cell term '{ct.text}' is ambiguous: did you mean "
|
|
332
|
+
f"{', '.join(c['label'] for c in ct.candidates)}?")
|
|
333
|
+
else:
|
|
334
|
+
clarifications.append("Which cell type or cell line is the target (e.g. HEK293T, primary T cells, "
|
|
335
|
+
"HSPCs)?")
|
|
336
|
+
|
|
337
|
+
# --- cargo (size + feature roles) ---
|
|
338
|
+
cargo: list[CargoComponent] = []
|
|
339
|
+
bp = _detect_bp(low)
|
|
340
|
+
seen_roles: set[str] = set()
|
|
341
|
+
for kw, label in _ROLE_KW.items():
|
|
342
|
+
if kw in low and label not in seen_roles:
|
|
343
|
+
seen_roles.add(label)
|
|
344
|
+
role = resolve_feature(label)
|
|
345
|
+
cargo.append(CargoComponent(name=kw, role=role if role.resolved else None))
|
|
346
|
+
prov[f"cargo[{len(cargo) - 1}].role"] = "explicit"
|
|
347
|
+
if bp is not None and cargo:
|
|
348
|
+
cargo[0].length_bp = bp
|
|
349
|
+
elif bp is not None:
|
|
350
|
+
cargo.append(CargoComponent(name="cargo", length_bp=bp))
|
|
351
|
+
|
|
352
|
+
# --- constraints ---
|
|
353
|
+
cons = Constraints()
|
|
354
|
+
if "scarless" in low or "seamless" in low:
|
|
355
|
+
cons.scarless = True
|
|
356
|
+
prov["constraints.scarless"] = "explicit"
|
|
357
|
+
if "safety switch" in low or "kill switch" in low or "icasp9" in low or "suicide gene" in low:
|
|
358
|
+
cons.safety_switch_required = True
|
|
359
|
+
prov["constraints.safety_switch_required"] = "explicit"
|
|
360
|
+
# The named delivery vehicle, so the legality / capacity check can run. A request may mention several vehicles,
|
|
361
|
+
# and may name one only to rule it out, so the vehicle is read from what the prose says ABOUT each mention
|
|
362
|
+
# rather than from the order of the table: taking the first entry that matched anywhere would record the
|
|
363
|
+
# vehicle the user rejected, and label it as though they had asked for it.
|
|
364
|
+
mentions = _vehicle_mentions(low)
|
|
365
|
+
chosen: list[str] = []
|
|
366
|
+
for s, e, veh in mentions:
|
|
367
|
+
if not _vehicle_is_ruled_out(low, s, e) and veh not in chosen:
|
|
368
|
+
chosen.append(veh)
|
|
369
|
+
if len(chosen) > 1 and len([v for v in chosen if v not in _NON_PACKAGING]) == 1:
|
|
370
|
+
chosen = [v for v in chosen if v not in _NON_PACKAGING] # the pulse and the capsid are not rivals
|
|
371
|
+
if len(chosen) == 1:
|
|
372
|
+
cons.delivery_limit = chosen[0]
|
|
373
|
+
prov["constraints.delivery_limit"] = "explicit"
|
|
374
|
+
elif len(chosen) > 1: # two vehicles, no cue to separate them: ask rather than pick one and call it stated
|
|
375
|
+
clarifications.append(f"The request names more than one delivery vehicle ({', '.join(chosen)}). "
|
|
376
|
+
f"Which should the design assume?")
|
|
377
|
+
elif mentions: # every vehicle named was ruled out, and none was put in its place
|
|
378
|
+
clarifications.append("Every delivery vehicle named in the request is ruled out and no replacement is "
|
|
379
|
+
"named. Which vehicle should the design assume?")
|
|
380
|
+
if cons.delivery_limit is None and ("non-integrating" in low or "nonintegrating" in low
|
|
381
|
+
or "episomal" in low or "transient" in low):
|
|
382
|
+
cons.delivery_limit = "non_integrating"
|
|
383
|
+
prov["constraints.delivery_limit"] = "explicit"
|
|
384
|
+
if "germline" in low or "embryo" in low or "heritable" in low:
|
|
385
|
+
cons.germline = True
|
|
386
|
+
prov["constraints.germline"] = "explicit"
|
|
387
|
+
m = re.search(r"(?:at least|>=|over|above)\s*(\d{1,3})\s*%\s*(?:efficien|edit|integrat)", low)
|
|
388
|
+
if m:
|
|
389
|
+
cons.efficiency_floor = float(m.group(1)) / 100.0
|
|
390
|
+
prov["constraints.efficiency_floor"] = "explicit"
|
|
391
|
+
inducer = _detect_inducer(prose)
|
|
392
|
+
if inducer is not None and inducer.resolved:
|
|
393
|
+
cons.inducer = inducer
|
|
394
|
+
prov["constraints.inducer"] = "explicit"
|
|
395
|
+
if bp is not None:
|
|
396
|
+
cons.max_cargo_bp = bp
|
|
397
|
+
prov["constraints.max_cargo_bp"] = "explicit"
|
|
398
|
+
|
|
399
|
+
# apply structured overrides (provenance=user) last
|
|
400
|
+
for k, v in overrides.items():
|
|
401
|
+
if k != "write_type":
|
|
402
|
+
prov[k] = "user"
|
|
403
|
+
|
|
404
|
+
return WriteRequest(write_type=wt, cargo=cargo, target=target, cell_type=cell, constraints=cons,
|
|
405
|
+
provenance=prov, assumptions=assumptions, clarifications=clarifications,
|
|
406
|
+
unresolved=unresolved, free_text_note=prose.strip() or None)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Vocabulary resolvers: free text -> canonical ontology id.
|
|
2
|
+
|
|
3
|
+
Each resolver returns a :class:`pen_stack.spec.writespec.Resolved` with a canonical id + confidence + (when
|
|
4
|
+
ambiguous) a ranked candidate set, or a result with ``id=None`` for an unresolved term. Unresolved stays null:
|
|
5
|
+
a resolver never invents an id. All curated ids were verified against the live ontology services (Cellosaurus,
|
|
6
|
+
EBI OLS for SO / MONDO / CL / ChEBI) before they were committed.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pen_stack.spec.resolvers.cell import resolve_cell
|
|
11
|
+
from pen_stack.spec.resolvers.chem import resolve_chem
|
|
12
|
+
from pen_stack.spec.resolvers.feature import resolve_feature
|
|
13
|
+
from pen_stack.spec.resolvers.gene import resolve_gene
|
|
14
|
+
from pen_stack.spec.resolvers.locus import resolve_locus
|
|
15
|
+
from pen_stack.spec.resolvers.phenotype import resolve_phenotype
|
|
16
|
+
|
|
17
|
+
__all__ = ["resolve_gene", "resolve_cell", "resolve_feature", "resolve_phenotype", "resolve_chem", "resolve_locus"]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Cell-line / cell-type resolver: free text -> Cellosaurus (cell lines) or Cell Ontology (cell types).
|
|
2
|
+
|
|
3
|
+
Curated offline cache (ids verified against the Cellosaurus API and EBI OLS). Cell lines carry the irreducible
|
|
4
|
+
subline / karyotype-drift caveat as a note. An unrecognized term stays unresolved (id=None), never invented.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pen_stack.spec.writespec import Resolved
|
|
9
|
+
|
|
10
|
+
# (id, canonical label, ontology) - all ids verified live before commit
|
|
11
|
+
_CELLS: dict[str, tuple[str, str, str]] = {
|
|
12
|
+
# cell lines (Cellosaurus)
|
|
13
|
+
"hek293t": ("CVCL_0063", "HEK293T", "Cellosaurus"), "293t": ("CVCL_0063", "HEK293T", "Cellosaurus"),
|
|
14
|
+
"k562": ("CVCL_0004", "K-562", "Cellosaurus"), "k-562": ("CVCL_0004", "K-562", "Cellosaurus"),
|
|
15
|
+
"hepg2": ("CVCL_0027", "Hep-G2", "Cellosaurus"), "hep-g2": ("CVCL_0027", "Hep-G2", "Cellosaurus"),
|
|
16
|
+
"jurkat": ("CVCL_0065", "Jurkat", "Cellosaurus"),
|
|
17
|
+
"hela": ("CVCL_0030", "HeLa", "Cellosaurus"),
|
|
18
|
+
# cell types (Cell Ontology)
|
|
19
|
+
"t cell": ("CL:0000084", "T cell", "CL"), "t-cell": ("CL:0000084", "T cell", "CL"),
|
|
20
|
+
"cd8 t cell": ("CL:0000084", "T cell", "CL"), "car-t": ("CL:0000084", "T cell", "CL"),
|
|
21
|
+
"primary t cell": ("CL:0000084", "T cell", "CL"),
|
|
22
|
+
"hspc": ("CL:0000037", "hematopoietic stem cell", "CL"), "hsc": ("CL:0000037", "hematopoietic stem cell", "CL"),
|
|
23
|
+
"cd34+": ("CL:0000037", "hematopoietic stem cell", "CL"), "cd34": ("CL:0000037", "hematopoietic stem cell", "CL"),
|
|
24
|
+
"neuron": ("CL:0000540", "neuron", "CL"),
|
|
25
|
+
}
|
|
26
|
+
_LINE_CAVEAT = ("cell-line identity carries irreducible subline / karyotype drift (e.g. HEK293T); the canonical "
|
|
27
|
+
"id does not resolve that away")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def resolve_cell(text: str) -> Resolved:
|
|
31
|
+
"""Resolve a cell line / type to Cellosaurus or Cell Ontology; unrecognized stays null."""
|
|
32
|
+
if not text:
|
|
33
|
+
return Resolved(text=text, note="empty")
|
|
34
|
+
key = text.strip().lower()
|
|
35
|
+
hit = _CELLS.get(key)
|
|
36
|
+
if hit is None:
|
|
37
|
+
# try a loose contains-match for a ranked candidate set (never a silent pick)
|
|
38
|
+
cands = [{"id": v[0], "label": v[1], "ontology": v[2]}
|
|
39
|
+
for k, v in _CELLS.items() if k in key or key in k]
|
|
40
|
+
uniq = {c["id"]: c for c in cands}
|
|
41
|
+
if len(uniq) == 1:
|
|
42
|
+
v = next(iter(uniq.values()))
|
|
43
|
+
hit = (v["id"], v["label"], v["ontology"])
|
|
44
|
+
elif len(uniq) > 1:
|
|
45
|
+
return Resolved(text=text, ontology=None, confidence=0.3, candidates=list(uniq.values()),
|
|
46
|
+
note="ambiguous cell term; resolve to one of the candidates")
|
|
47
|
+
else:
|
|
48
|
+
return Resolved(text=text, confidence=None, note="unresolved cell term")
|
|
49
|
+
cid, label, onto = hit
|
|
50
|
+
note = _LINE_CAVEAT if onto == "Cellosaurus" else None
|
|
51
|
+
return Resolved(text=text, id=cid, label=label, ontology=onto, confidence=0.95, note=note)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Small-molecule resolver: free text (inducer / selection agent) -> ChEBI. Ids verified against EBI OLS."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pen_stack.spec.writespec import Resolved
|
|
5
|
+
|
|
6
|
+
# (ChEBI id, canonical label) - verified live before commit
|
|
7
|
+
_CHEM: dict[str, tuple[str, str]] = {
|
|
8
|
+
"doxycycline": ("CHEBI:50845", "doxycycline"), "dox": ("CHEBI:50845", "doxycycline"),
|
|
9
|
+
"tamoxifen": ("CHEBI:41774", "tamoxifen"),
|
|
10
|
+
"4-hydroxytamoxifen": ("CHEBI:231618", "4-hydroxytamoxifen"), "4-oht": ("CHEBI:231618", "4-hydroxytamoxifen"),
|
|
11
|
+
"afimoxifene": ("CHEBI:44616", "afimoxifene"),
|
|
12
|
+
"rapamycin": ("CHEBI:9168", "sirolimus"), "sirolimus": ("CHEBI:9168", "sirolimus"),
|
|
13
|
+
"puromycin": ("CHEBI:17939", "puromycin"),
|
|
14
|
+
"blasticidin": ("CHEBI:15353", "blasticidin S"), "blasticidin s": ("CHEBI:15353", "blasticidin S"),
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def resolve_chem(text: str) -> Resolved:
|
|
19
|
+
"""Resolve a small-molecule inducer / selection agent to ChEBI; unrecognized stays null."""
|
|
20
|
+
if not text:
|
|
21
|
+
return Resolved(text=text, ontology="ChEBI", note="empty")
|
|
22
|
+
key = text.strip().lower()
|
|
23
|
+
hit = _CHEM.get(key)
|
|
24
|
+
if hit is None:
|
|
25
|
+
for k, v in _CHEM.items():
|
|
26
|
+
if k in key:
|
|
27
|
+
hit = v
|
|
28
|
+
break
|
|
29
|
+
if hit is None:
|
|
30
|
+
return Resolved(text=text, ontology="ChEBI", confidence=None, note="unresolved molecule term")
|
|
31
|
+
cid, label = hit
|
|
32
|
+
return Resolved(text=text, id=cid, label=label, ontology="ChEBI", confidence=0.9)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Sequence-feature resolver: free text -> Sequence Ontology role. Ids verified against EBI OLS."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pen_stack.spec.writespec import Resolved
|
|
5
|
+
|
|
6
|
+
# (SO id, canonical label) - verified live before commit
|
|
7
|
+
_FEATURES: dict[str, tuple[str, str]] = {
|
|
8
|
+
"promoter": ("SO:0000167", "promoter"),
|
|
9
|
+
"cds": ("SO:0000316", "CDS"), "coding sequence": ("SO:0000316", "CDS"), "orf": ("SO:0000316", "CDS"),
|
|
10
|
+
"polya": ("SO:0000551", "polyA_signal_sequence"), "poly-a": ("SO:0000551", "polyA_signal_sequence"),
|
|
11
|
+
"polyadenylation signal": ("SO:0000551", "polyA_signal_sequence"), "pa": ("SO:0000551", "polyA_signal_sequence"),
|
|
12
|
+
"insulator": ("SO:0000627", "insulator"),
|
|
13
|
+
"enhancer": ("SO:0000165", "enhancer"),
|
|
14
|
+
"terminator": ("SO:0000141", "terminator"),
|
|
15
|
+
"5'utr": ("SO:0000204", "five_prime_UTR"), "5utr": ("SO:0000204", "five_prime_UTR"),
|
|
16
|
+
"3'utr": ("SO:0000205", "three_prime_UTR"), "3utr": ("SO:0000205", "three_prime_UTR"),
|
|
17
|
+
"ires": ("SO:0000139", "ribosome_entry_site"), "ribosome entry site": ("SO:0000139", "ribosome_entry_site"),
|
|
18
|
+
"gene": ("SO:0000704", "gene"),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def resolve_feature(text: str) -> Resolved:
|
|
23
|
+
"""Resolve a sequence-feature term to a Sequence-Ontology role; unrecognized stays null."""
|
|
24
|
+
if not text:
|
|
25
|
+
return Resolved(text=text, ontology="SO", note="empty")
|
|
26
|
+
key = text.strip().lower()
|
|
27
|
+
hit = _FEATURES.get(key)
|
|
28
|
+
if hit is None:
|
|
29
|
+
for k, v in _FEATURES.items():
|
|
30
|
+
if k in key:
|
|
31
|
+
hit = v
|
|
32
|
+
break
|
|
33
|
+
if hit is None:
|
|
34
|
+
return Resolved(text=text, ontology="SO", confidence=None, note="unresolved feature term")
|
|
35
|
+
sid, label = hit
|
|
36
|
+
return Resolved(text=text, id=sid, label=label, ontology="SO", confidence=0.9)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Gene resolver: free text -> HGNC symbol, grounded against the writable-genome atlas.
|
|
2
|
+
|
|
3
|
+
Wraps :func:`pen_stack.planner.optimize.resolve_gene` (which maps safe-harbour nicknames such as AAVS1 ->
|
|
4
|
+
PPP1R12C) and validates against the atlas gene-coordinate table: a symbol the atlas knows is high-confidence; an
|
|
5
|
+
unknown but plausible symbol is a low-confidence, explicitly-unvalidated pass-through; a jargon / non-gene token
|
|
6
|
+
stays unresolved (id=None). Nothing is invented.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from pen_stack.spec.writespec import Resolved
|
|
13
|
+
|
|
14
|
+
_SYMBOL = re.compile(r"^[A-Z][A-Z0-9-]{1,9}$")
|
|
15
|
+
# tokens that look like a gene symbol but are not one (vehicle / form / jargon abbreviations + a few stop words)
|
|
16
|
+
_STOP = {"DNA", "RNA", "MRNA", "AAV", "LNP", "HSV", "CAR", "RNP", "PCR", "WT", "KO", "KI", "ITR", "PAM", "GFP",
|
|
17
|
+
"RFP", "YFP", "BFP", "THE", "AND", "FOR", "WITH", "INTO", "GENE", "CELL", "CELLS", "DOX", "USA"}
|
|
18
|
+
_NICKNAMES = {"AAVS1", "CCR5", "ROSA26", "CLYBL", "HPRT1"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def resolve_gene(text: str) -> Resolved:
|
|
22
|
+
"""Resolve a gene symbol or safe-harbour nickname to its canonical HGNC symbol, grounded against the atlas."""
|
|
23
|
+
if not text:
|
|
24
|
+
return Resolved(text=text, ontology="HGNC", note="empty")
|
|
25
|
+
sym = text.strip().upper()
|
|
26
|
+
if sym in _STOP:
|
|
27
|
+
return Resolved(text=text, ontology="HGNC", confidence=None, note="not a gene (jargon / stop token)")
|
|
28
|
+
canon = sym
|
|
29
|
+
in_atlas = False
|
|
30
|
+
try:
|
|
31
|
+
from pen_stack.planner.optimize import gene_region, resolve_gene as _rg
|
|
32
|
+
canon = (_rg(sym) or sym).upper()
|
|
33
|
+
in_atlas = gene_region(canon) is not None
|
|
34
|
+
except Exception: # noqa: BLE001
|
|
35
|
+
canon = sym
|
|
36
|
+
if not _SYMBOL.match(canon):
|
|
37
|
+
return Resolved(text=text, ontology="HGNC", confidence=None, note="unresolved (not a gene-symbol token)")
|
|
38
|
+
if in_atlas:
|
|
39
|
+
note = f"safe-harbour nickname -> {canon}" if (sym in _NICKNAMES and canon != sym) else "in writable-genome atlas"
|
|
40
|
+
return Resolved(text=text, id=canon, label=canon, ontology="HGNC", confidence=0.95, note=note)
|
|
41
|
+
# plausible but unknown to the atlas: a low-confidence pass-through, explicitly flagged unvalidated
|
|
42
|
+
return Resolved(text=text, id=canon, label=canon, ontology="HGNC", confidence=0.4,
|
|
43
|
+
note="symbol not confirmed against the writable-genome atlas; unvalidated (reachability checked downstream)")
|