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.
Files changed (259) hide show
  1. pen_stack/__init__.py +2 -0
  2. pen_stack/_resources.py +34 -0
  3. pen_stack/active/__init__.py +20 -0
  4. pen_stack/active/acquire.py +165 -0
  5. pen_stack/active/brains.py +74 -0
  6. pen_stack/active/campaign.py +109 -0
  7. pen_stack/active/design.py +66 -0
  8. pen_stack/active/validate.py +104 -0
  9. pen_stack/adapt/__init__.py +14 -0
  10. pen_stack/adapt/finetune.py +33 -0
  11. pen_stack/adapt/ingest.py +86 -0
  12. pen_stack/adapt/pipeline.py +101 -0
  13. pen_stack/adapt/recalibrate.py +58 -0
  14. pen_stack/adapt/report.py +130 -0
  15. pen_stack/agent/__init__.py +1 -0
  16. pen_stack/agent/cite.py +175 -0
  17. pen_stack/agent/co_scientist.py +262 -0
  18. pen_stack/agent/epistemic.py +102 -0
  19. pen_stack/agent/guardrails.py +67 -0
  20. pen_stack/agent/mcp_server.py +215 -0
  21. pen_stack/agent/orchestrator.py +112 -0
  22. pen_stack/agent/orchestrator_live.py +56 -0
  23. pen_stack/agent/pen_agent.py +242 -0
  24. pen_stack/agent/scope.py +60 -0
  25. pen_stack/agent/tools.py +130 -0
  26. pen_stack/api/__init__.py +12 -0
  27. pen_stack/api/manifest.py +160 -0
  28. pen_stack/atlas/__init__.py +1 -0
  29. pen_stack/atlas/atlas.parquet +0 -0
  30. pen_stack/atlas/build_wtkb.py +80 -0
  31. pen_stack/atlas/crosslink.py +179 -0
  32. pen_stack/atlas/expand.py +190 -0
  33. pen_stack/atlas/guide_design.py +178 -0
  34. pen_stack/atlas/schema.py +59 -0
  35. pen_stack/atlas/scorecard.py +134 -0
  36. pen_stack/atlas/scorecard_v3.parquet +0 -0
  37. pen_stack/atlas/universe.py +75 -0
  38. pen_stack/atlas/universe_v3.parquet +0 -0
  39. pen_stack/atlas/variant_propose.py +155 -0
  40. pen_stack/atlas/writer_efficiency.py +184 -0
  41. pen_stack/atlas/writer_predict.py +229 -0
  42. pen_stack/atlas/writer_recommend.py +170 -0
  43. pen_stack/atlas/writer_verify.py +167 -0
  44. pen_stack/atlas/wtkb.parquet +0 -0
  45. pen_stack/bridge/__init__.py +1 -0
  46. pen_stack/bridge/activity.py +52 -0
  47. pen_stack/bridge/cli.py +65 -0
  48. pen_stack/bridge/fold_qc.py +53 -0
  49. pen_stack/bridge/guide_qc.py +87 -0
  50. pen_stack/bridge/ingest.py +139 -0
  51. pen_stack/bridge/offtarget.py +191 -0
  52. pen_stack/bridge/offtarget_energetics.py +105 -0
  53. pen_stack/bridge/ortholog_screen.py +73 -0
  54. pen_stack/bridge/pipeline.py +83 -0
  55. pen_stack/build/__init__.py +16 -0
  56. pen_stack/build/cloudlab.py +74 -0
  57. pen_stack/build/ingest.py +47 -0
  58. pen_stack/build/protocol.py +82 -0
  59. pen_stack/build/simlab.py +30 -0
  60. pen_stack/cli.py +126 -0
  61. pen_stack/data/__init__.py +1 -0
  62. pen_stack/data/encode.py +84 -0
  63. pen_stack/data/genome.py +71 -0
  64. pen_stack/data/ingest_chromatin.py +119 -0
  65. pen_stack/data/ingest_integration.py +112 -0
  66. pen_stack/data/ingest_safety_annot.py +201 -0
  67. pen_stack/data/ingest_trip.py +76 -0
  68. pen_stack/design/__init__.py +14 -0
  69. pen_stack/design/capsid_generate.py +62 -0
  70. pen_stack/design/generate.py +70 -0
  71. pen_stack/design/pareto.py +70 -0
  72. pen_stack/design/space.py +137 -0
  73. pen_stack/design/writer_variants.py +121 -0
  74. pen_stack/env/__init__.py +1 -0
  75. pen_stack/env/genome_writing_env.py +248 -0
  76. pen_stack/env/policies.py +94 -0
  77. pen_stack/graph/__init__.py +21 -0
  78. pen_stack/graph/build.py +133 -0
  79. pen_stack/graph/cell_types.py +58 -0
  80. pen_stack/graph/ingest.py +132 -0
  81. pen_stack/graph/query.py +148 -0
  82. pen_stack/graph/schema.py +100 -0
  83. pen_stack/loop/__init__.py +15 -0
  84. pen_stack/loop/continual.py +61 -0
  85. pen_stack/loop/cycle.py +84 -0
  86. pen_stack/loop/drift.py +41 -0
  87. pen_stack/mech/__init__.py +1 -0
  88. pen_stack/mech/classify_atlas.py +71 -0
  89. pen_stack/mech/pfam_whitelist.yaml +247 -0
  90. pen_stack/mech/whitelist.py +66 -0
  91. pen_stack/monitor/__init__.py +1 -0
  92. pen_stack/monitor/europepmc.py +32 -0
  93. pen_stack/monitor/run.py +57 -0
  94. pen_stack/monitor/triage.py +63 -0
  95. pen_stack/oracles/__init__.py +65 -0
  96. pen_stack/oracles/affinity.py +116 -0
  97. pen_stack/oracles/cache.py +53 -0
  98. pen_stack/oracles/energetics.py +33 -0
  99. pen_stack/oracles/genome.py +167 -0
  100. pen_stack/oracles/protein_design.py +136 -0
  101. pen_stack/oracles/reliability.py +64 -0
  102. pen_stack/oracles/rna.py +28 -0
  103. pen_stack/oracles/schema.py +77 -0
  104. pen_stack/oracles/status.py +123 -0
  105. pen_stack/oracles/structure.py +42 -0
  106. pen_stack/oracles/structure_run.py +76 -0
  107. pen_stack/oracles/vcell.py +74 -0
  108. pen_stack/planner/__init__.py +1 -0
  109. pen_stack/planner/ada_risk.py +64 -0
  110. pen_stack/planner/antipeg_oracle.py +75 -0
  111. pen_stack/planner/capsid_epitope_oracle.py +135 -0
  112. pen_stack/planner/cargo.py +56 -0
  113. pen_stack/planner/cargo_polish.py +146 -0
  114. pen_stack/planner/chromosome.py +106 -0
  115. pen_stack/planner/delivery.py +55 -0
  116. pen_stack/planner/delivery_constraints.py +110 -0
  117. pen_stack/planner/delivery_immune.py +61 -0
  118. pen_stack/planner/delivery_immunology.py +222 -0
  119. pen_stack/planner/delivery_predict.py +196 -0
  120. pen_stack/planner/delivery_vehicles.py +37 -0
  121. pen_stack/planner/genotoxicity_oracle.py +112 -0
  122. pen_stack/planner/immune_mhc2.py +154 -0
  123. pen_stack/planner/immune_profile.py +292 -0
  124. pen_stack/planner/innate_sensing.py +135 -0
  125. pen_stack/planner/multiplex.py +110 -0
  126. pen_stack/planner/optimize.py +278 -0
  127. pen_stack/planner/pipeline.py +87 -0
  128. pen_stack/planner/report.py +26 -0
  129. pen_stack/planner/router.py +57 -0
  130. pen_stack/planner/seroprevalence_oracle.py +92 -0
  131. pen_stack/planner/target_site.py +118 -0
  132. pen_stack/rag/__init__.py +1 -0
  133. pen_stack/rag/corpus.py +133 -0
  134. pen_stack/rag/embed.py +98 -0
  135. pen_stack/rag/ground.py +131 -0
  136. pen_stack/rag/index.py +53 -0
  137. pen_stack/rag/llm.py +215 -0
  138. pen_stack/rag/qa.py +105 -0
  139. pen_stack/rag/retrieve.py +48 -0
  140. pen_stack/rules/__init__.py +9 -0
  141. pen_stack/rules/evaluators.py +318 -0
  142. pen_stack/rules/loader.py +31 -0
  143. pen_stack/rules/schema.py +99 -0
  144. pen_stack/rules/solver.py +43 -0
  145. pen_stack/rules/spec.py +78 -0
  146. pen_stack/safety/__init__.py +21 -0
  147. pen_stack/safety/audit.py +90 -0
  148. pen_stack/safety/gate.py +58 -0
  149. pen_stack/safety/pfam_scan.py +157 -0
  150. pen_stack/safety/policy.py +69 -0
  151. pen_stack/safety/redteam.py +71 -0
  152. pen_stack/safety/registry.py +255 -0
  153. pen_stack/safety/screen.py +59 -0
  154. pen_stack/safety/standards.py +141 -0
  155. pen_stack/score/__init__.py +1 -0
  156. pen_stack/score/recalibrate.py +77 -0
  157. pen_stack/score/therapeutic.py +85 -0
  158. pen_stack/server/__init__.py +1 -0
  159. pen_stack/server/api.py +647 -0
  160. pen_stack/spec/__init__.py +18 -0
  161. pen_stack/spec/clarify.py +42 -0
  162. pen_stack/spec/extract.py +406 -0
  163. pen_stack/spec/resolvers/__init__.py +17 -0
  164. pen_stack/spec/resolvers/cell.py +51 -0
  165. pen_stack/spec/resolvers/chem.py +32 -0
  166. pen_stack/spec/resolvers/feature.py +36 -0
  167. pen_stack/spec/resolvers/gene.py +43 -0
  168. pen_stack/spec/resolvers/locus.py +29 -0
  169. pen_stack/spec/resolvers/phenotype.py +37 -0
  170. pen_stack/spec/satisfy.py +114 -0
  171. pen_stack/spec/service.py +33 -0
  172. pen_stack/spec/writespec.py +252 -0
  173. pen_stack/twin/__init__.py +14 -0
  174. pen_stack/twin/calibrate.py +61 -0
  175. pen_stack/twin/data/__init__.py +12 -0
  176. pen_stack/twin/data/position_effect.py +245 -0
  177. pen_stack/twin/mechanistic.py +147 -0
  178. pen_stack/twin/outcome.py +132 -0
  179. pen_stack/twin/position_effect.py +454 -0
  180. pen_stack/ui/__init__.py +1 -0
  181. pen_stack/ui/app.py +713 -0
  182. pen_stack/validate/__init__.py +1 -0
  183. pen_stack/validate/adapt_demo.py +69 -0
  184. pen_stack/validate/agent_eval.py +117 -0
  185. pen_stack/validate/bench_adversarial_tasks.py +118 -0
  186. pen_stack/validate/bench_coscientist_tasks.py +60 -0
  187. pen_stack/validate/bench_graph_tasks.py +64 -0
  188. pen_stack/validate/bench_rule_tasks.py +84 -0
  189. pen_stack/validate/bench_trust_tasks.py +92 -0
  190. pen_stack/validate/bench_writetype_tasks.py +101 -0
  191. pen_stack/validate/blind_gsh_discovery.py +261 -0
  192. pen_stack/validate/cargo_directionality.py +57 -0
  193. pen_stack/validate/closed_loop.py +63 -0
  194. pen_stack/validate/durability_baselines.py +185 -0
  195. pen_stack/validate/experiment_design.py +65 -0
  196. pen_stack/validate/expr_controls.py +39 -0
  197. pen_stack/validate/forward_hypotheses.py +104 -0
  198. pen_stack/validate/generative_design.py +62 -0
  199. pen_stack/validate/guide_qc_demo.py +69 -0
  200. pen_stack/validate/heldout_celltype_expr.py +32 -0
  201. pen_stack/validate/immune_calibration.py +133 -0
  202. pen_stack/validate/intent_specification.py +82 -0
  203. pen_stack/validate/known_biology_expr.py +38 -0
  204. pen_stack/validate/offtarget_energetics_eval.py +144 -0
  205. pen_stack/validate/out_of_scope_refusal.py +82 -0
  206. pen_stack/validate/outcome_calibration.py +194 -0
  207. pen_stack/validate/outcome_prediction.py +76 -0
  208. pen_stack/validate/paper3_benchmark.py +165 -0
  209. pen_stack/validate/paper4_real_validation.py +144 -0
  210. pen_stack/validate/paper4_validation.py +82 -0
  211. pen_stack/validate/protocol_safety.py +62 -0
  212. pen_stack/validate/safety_screening.py +72 -0
  213. pen_stack/validate/selective_prediction.py +104 -0
  214. pen_stack/validate/seq_vs_measured.py +134 -0
  215. pen_stack/validate/target_site_controls.py +65 -0
  216. pen_stack/validate/uncertainty_eval.py +244 -0
  217. pen_stack/validate/ungrounded_baseline.py +234 -0
  218. pen_stack/validate/within_locus_ranking.py +84 -0
  219. pen_stack/validate/writer_recovery.py +91 -0
  220. pen_stack/verify/__init__.py +5 -0
  221. pen_stack/verify/proof.py +206 -0
  222. pen_stack/verify/schema.py +53 -0
  223. pen_stack/verify/service.py +191 -0
  224. pen_stack/web/__init__.py +18 -0
  225. pen_stack/web/guide.py +110 -0
  226. pen_stack/web/llm.py +393 -0
  227. pen_stack/web/llm_provider.py +119 -0
  228. pen_stack/web/router.py +119 -0
  229. pen_stack/web/server.py +96 -0
  230. pen_stack/web/tools.py +197 -0
  231. pen_stack/wgenome/__init__.py +1 -0
  232. pen_stack/wgenome/chromatin_seq.py +83 -0
  233. pen_stack/wgenome/durability.py +108 -0
  234. pen_stack/wgenome/export_tracks.py +52 -0
  235. pen_stack/wgenome/features.py +82 -0
  236. pen_stack/wgenome/genotoxic_blocklist.py +88 -0
  237. pen_stack/wgenome/gsh_baseline.py +154 -0
  238. pen_stack/wgenome/mesh_features.py +61 -0
  239. pen_stack/wgenome/offtarget_assay.py +80 -0
  240. pen_stack/wgenome/offtarget_bridge.py +47 -0
  241. pen_stack/wgenome/offtarget_cast.py +97 -0
  242. pen_stack/wgenome/offtarget_data.py +148 -0
  243. pen_stack/wgenome/offtarget_enumerate.py +274 -0
  244. pen_stack/wgenome/offtarget_integrase.py +155 -0
  245. pen_stack/wgenome/offtarget_nuclease.py +123 -0
  246. pen_stack/wgenome/offtarget_paste.py +41 -0
  247. pen_stack/wgenome/offtarget_predict.py +282 -0
  248. pen_stack/wgenome/ood.py +135 -0
  249. pen_stack/wgenome/providers.py +278 -0
  250. pen_stack/wgenome/safety.py +69 -0
  251. pen_stack/wgenome/structure3d.py +212 -0
  252. pen_stack/wgenome/uncertainty.py +250 -0
  253. pen_stack/wgenome/writability.py +72 -0
  254. pen_stack-0.1.0.dist-info/METADATA +401 -0
  255. pen_stack-0.1.0.dist-info/RECORD +259 -0
  256. pen_stack-0.1.0.dist-info/WHEEL +5 -0
  257. pen_stack-0.1.0.dist-info/entry_points.txt +3 -0
  258. pen_stack-0.1.0.dist-info/licenses/LICENSE +21 -0
  259. pen_stack-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,90 @@
1
+ """Append-only, hash-chained safety audit log, the Guardian.
2
+
3
+ Every safety decision is recorded as a JSON line whose hash chains to the previous record, so the log is
4
+ tamper-evident: altering any past record breaks the chain from that point forward (`verify_chain`). The log
5
+ stores a design DIGEST (sha256), not the design itself, it is an accountability trail, not a hazard store.
6
+
7
+ Path resolution: `PEN_STACK_SAFETY_AUDIT` env var, else `<project_root>/out/safety_audit.log`.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import os
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from pen_stack._resources import project_root
19
+
20
+ _GENESIS = "0" * 64
21
+
22
+
23
+ def audit_path() -> Path:
24
+ env = os.environ.get("PEN_STACK_SAFETY_AUDIT")
25
+ if env:
26
+ return Path(env).expanduser()
27
+ return project_root() / "out" / "safety_audit.log"
28
+
29
+
30
+ def _digest(payload: Any) -> str:
31
+ return hashlib.sha256(json.dumps(payload, sort_keys=True, default=str).encode("utf-8")).hexdigest()
32
+
33
+
34
+ def _last_hash(path: Path) -> str:
35
+ if not path.exists():
36
+ return _GENESIS
37
+ last = _GENESIS
38
+ for line in path.read_text(encoding="utf-8").splitlines():
39
+ if line.strip():
40
+ try:
41
+ last = json.loads(line)["this_hash"]
42
+ except (json.JSONDecodeError, KeyError):
43
+ continue
44
+ return last
45
+
46
+
47
+ def audit_log(*, actor: str, design_digest: str, verdict, path: Path | None = None) -> dict:
48
+ """Append one hash-chained record for a SafetyVerdict. Returns the written record."""
49
+ p = path or audit_path()
50
+ p.parent.mkdir(parents=True, exist_ok=True)
51
+ prev = _last_hash(p)
52
+ body = {
53
+ "ts": datetime.now(timezone.utc).isoformat(),
54
+ "actor": actor,
55
+ "design_digest": design_digest,
56
+ "decision": verdict.decision,
57
+ "reason": verdict.reason,
58
+ "n_hits": len(verdict.hits),
59
+ "hit_ids": [h.provenance.get("signature_id") for h in verdict.hits],
60
+ "registry_version": next((h.provenance.get("registry_version") for h in verdict.hits), None),
61
+ "prev_hash": prev,
62
+ }
63
+ record = {**body, "this_hash": _digest({**body})}
64
+ with p.open("a", encoding="utf-8", newline="\n") as fh:
65
+ fh.write(json.dumps(record, sort_keys=True) + "\n")
66
+ return record
67
+
68
+
69
+ def verify_chain(path: Path | None = None) -> dict:
70
+ """Verify the hash chain is intact (tamper-evident). Returns {ok, n, broken_at}."""
71
+ p = path or audit_path()
72
+ if not p.exists():
73
+ return {"ok": True, "n": 0, "broken_at": None, "reason": "no log yet"}
74
+ prev = _GENESIS
75
+ n = 0
76
+ for i, line in enumerate(p.read_text(encoding="utf-8").splitlines()):
77
+ if not line.strip():
78
+ continue
79
+ rec = json.loads(line)
80
+ body = {k: rec[k] for k in rec if k != "this_hash"}
81
+ if rec.get("prev_hash") != prev or _digest(body) != rec.get("this_hash"):
82
+ return {"ok": False, "n": n, "broken_at": i, "reason": "hash chain broken"}
83
+ prev = rec["this_hash"]
84
+ n += 1
85
+ return {"ok": True, "n": n, "broken_at": None}
86
+
87
+
88
+ def digest_design(design: dict) -> str:
89
+ """Stable digest of a design for the audit trail (does not store the design itself)."""
90
+ return _digest(design)
@@ -0,0 +1,58 @@
1
+ """The safety gate, screen -> decide -> audit, the Guardian's single entry point.
2
+
3
+ `safety_gate(design)` returns a `SafetyVerdict` (clear / flag / refuse / escalate) and writes a hash-chained
4
+ audit record. It screens the DESIGN ARTIFACT only, framing/justification fields are stripped first, so
5
+ re-framing a hazardous design as "defensive research" cannot flip a refuse to a clear (the artifact decides,
6
+ not the prompt). No number is fabricated: every hit is sourced from the version-pinned registry.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from pen_stack.safety.audit import audit_log, digest_design
11
+ from pen_stack.safety.policy import SafetyVerdict, decide, load_policy
12
+ from pen_stack.safety.screen import screen_design
13
+
14
+
15
+ def _strip_framing(design: dict, policy: dict) -> dict:
16
+ """Remove free-text framing fields the screen must ignore (the artifact decides, not the wording)."""
17
+ drop = set(policy.get("ignore_framing_fields", []))
18
+ return {k: v for k, v in design.items() if k not in drop}
19
+
20
+
21
+ _SIGNAL_TEXT_FIELDS = ("cargo_function", "function_annotation", "goal_function", "source_taxon", "organism",
22
+ "host_taxon", "cargo_seq", "cargo_sequence")
23
+ _SIGNAL_LIST_FIELDS = ("function_tags", "pfam_domains", "annotations")
24
+
25
+
26
+ def _has_declared_signal(design: dict) -> bool:
27
+ """Whether the artifact carries anything the hazard screens actually read (declared function, domain
28
+ tags, taxon, OR a cargo sequence -- a real Pfam/HMMER scan runs over cargo_seq by default, so a
29
+ sequence-only submission is genuinely screened, not skipped). Lets a "clear" tell "genuinely screened, no
30
+ hazard found" apart from "nothing was submitted, so nothing could be checked" -- otherwise an empty
31
+ Cargo function box reads exactly like a real benign design was cleared."""
32
+ if any(str(design.get(k) or "").strip() for k in _SIGNAL_TEXT_FIELDS):
33
+ return True
34
+ return any((design.get(k) or []) for k in _SIGNAL_LIST_FIELDS)
35
+
36
+
37
+ def safety_gate(design: dict, *, actor: str = "anonymous", registry=None) -> SafetyVerdict:
38
+ """Screen a design, decide, and log a tamper-evident audit record. The design digest is taken over the
39
+ ORIGINAL design (full accountability), while screening runs on the framing-stripped artifact."""
40
+ if not isinstance(design, dict):
41
+ design = dict(design)
42
+ policy = load_policy()
43
+ artifact = _strip_framing(design, policy)
44
+ hits = screen_design(artifact, registry=registry)
45
+ decision, reason = decide(hits, policy)
46
+ signal = _has_declared_signal(artifact)
47
+ if decision == "clear" and not signal:
48
+ reason = ("no cargo function, sequence, domain tag, or taxon was declared, nothing was screened "
49
+ "(not a clearance)")
50
+ verdict = SafetyVerdict(
51
+ decision=decision, hits=hits, reason=reason,
52
+ provenance={"registry_version": next((h.provenance.get("registry_version") for h in hits), None),
53
+ "policy_version": policy.get("policy_version"), "actor": actor,
54
+ "screened_kinds": sorted({h.kind for h in hits}),
55
+ "declared_signal": signal,
56
+ "note": "screening reduces, not eliminates, dual-use risk; not a substitute for IBC review"})
57
+ audit_log(actor=actor, design_digest=digest_design(design), verdict=verdict)
58
+ return verdict
@@ -0,0 +1,157 @@
1
+ """Local Pfam/HMMER domain screen over a raw cargo sequence.
2
+
3
+ Wires the sequence-homology slot the Guardian has always had (`HazardRegistry`'s `external_hook`), using
4
+ PUBLIC Pfam profile HMMs for the SAME curated toxin families already listed in
5
+ `configs/safety/hazard_registry.yaml`. No hazard sequences are bundled -- only statistical family models (the
6
+ same class of public artifact the registry's own accessions already reference; a profile HMM is built from
7
+ many aligned public sequences, it is not any one organism's sequence). A DNA/RNA cargo_seq is translated in
8
+ all 6 reading frames (protein cargo is scanned directly) and scored against the bundled HMM file using each
9
+ profile's own Pfam-recommended gathering (GA) cutoff -- the same threshold real Pfam annotation pipelines use
10
+ to decide genuine family membership.
11
+
12
+ Scope: this closes the "raw sequence, no declared function" gap for the SAME curated family list the
13
+ function screen already covers (see hazard_registry.yaml's `toxin_functions`). It is not a substitute for a
14
+ full external homology screener (e.g. IBBIS Common Mechanism / SecureDNA) over a much larger reference set --
15
+ an integrator wanting that coverage can still override via `HazardRegistry.load(external_hook=...)`.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from functools import lru_cache
20
+
21
+ from pen_stack._resources import resource
22
+ from pen_stack.safety.screen import ScreenHit
23
+
24
+ _HMM_REL = "configs/safety/pfam_hmms/toxin_domains.hmm"
25
+
26
+ _CODON = {
27
+ 'TTT': 'F', 'TTC': 'F', 'TTA': 'L', 'TTG': 'L', 'CTT': 'L', 'CTC': 'L', 'CTA': 'L', 'CTG': 'L',
28
+ 'ATT': 'I', 'ATC': 'I', 'ATA': 'I', 'ATG': 'M', 'GTT': 'V', 'GTC': 'V', 'GTA': 'V', 'GTG': 'V',
29
+ 'TCT': 'S', 'TCC': 'S', 'TCA': 'S', 'TCG': 'S', 'CCT': 'P', 'CCC': 'P', 'CCA': 'P', 'CCG': 'P',
30
+ 'ACT': 'T', 'ACC': 'T', 'ACA': 'T', 'ACG': 'T', 'GCT': 'A', 'GCC': 'A', 'GCA': 'A', 'GCG': 'A',
31
+ 'TAT': 'Y', 'TAC': 'Y', 'TAA': '*', 'TAG': '*', 'CAT': 'H', 'CAC': 'H', 'CAA': 'Q', 'CAG': 'Q',
32
+ 'AAT': 'N', 'AAC': 'N', 'AAA': 'K', 'AAG': 'K', 'GAT': 'D', 'GAC': 'D', 'GAA': 'E', 'GAG': 'E',
33
+ 'TGT': 'C', 'TGC': 'C', 'TGA': '*', 'TGG': 'W', 'CGT': 'R', 'CGC': 'R', 'CGA': 'R', 'CGG': 'R',
34
+ 'AGT': 'S', 'AGC': 'S', 'AGA': 'R', 'AGG': 'R', 'GGT': 'G', 'GGC': 'G', 'GGA': 'G', 'GGG': 'G',
35
+ }
36
+ _COMPLEMENT = str.maketrans("ACGTUacgtu", "TGCAAtgcaa")
37
+ _NUCLEOTIDE = set("ACGTUN")
38
+
39
+
40
+ def _strip_headers_and_whitespace(text: str) -> str:
41
+ """Drop FASTA/GenBank-style header and comment lines (">...", ";...") and all whitespace, so a sequence
42
+ copy-pasted straight out of UniProt/NCBI/Ensembl -- header line and all -- is screened on the actual
43
+ sequence, not a header+sequence blob whose nucleotide fraction the header dilutes below any threshold."""
44
+ lines = [ln for ln in text.splitlines() if not ln.lstrip().startswith((">", ";"))]
45
+ return "".join("".join(lines).split())
46
+
47
+
48
+ def _looks_like_nucleotide(seq: str) -> bool:
49
+ """A sequence is treated as DNA/RNA if it is overwhelmingly A/C/G/T/U/N; otherwise it is scanned as
50
+ protein directly (the Cargo sequence box accepts either, per its own hint text: "A/C/G/T or A/C/G/U").
51
+ Called AFTER header/whitespace stripping, so a FASTA header's non-sequence characters never skew this."""
52
+ s = seq.upper()
53
+ if not s:
54
+ return True
55
+ nt = sum(1 for c in s if c in _NUCLEOTIDE)
56
+ return nt / len(s) >= 0.9
57
+
58
+
59
+ def _translate_frame(dna: str) -> str:
60
+ return "".join(_CODON.get(dna[i:i + 3], "X") for i in range(0, len(dna) - 2, 3))
61
+
62
+
63
+ def six_frame_translate(seq: str) -> list[str]:
64
+ """DNA/RNA -> the 6 reading-frame protein translations (3 forward, 3 reverse-complement)."""
65
+ s = "".join(c for c in seq.upper().replace("U", "T") if c in "ACGT")
66
+ if len(s) < 3:
67
+ return []
68
+ rc = s.translate(_COMPLEMENT)[::-1]
69
+ return [_translate_frame(strand[o:]) for strand in (s, rc) for o in range(3)]
70
+
71
+
72
+ @lru_cache(maxsize=1)
73
+ def _hmms():
74
+ import pyhmmer
75
+ with pyhmmer.plan7.HMMFile(str(resource(_HMM_REL))) as hf:
76
+ return list(hf)
77
+
78
+
79
+ @lru_cache(maxsize=1)
80
+ def _default_toxin_functions() -> list[dict]:
81
+ """`toxin_functions` straight from the registry YAML, so the standard 1-arg hook signature
82
+ (`Callable[[str], list[ScreenHit]]`) still resolves accession -> name/severity/control_ref without the
83
+ caller having to thread the registry through."""
84
+ import yaml
85
+ raw = yaml.safe_load(resource("configs/safety/hazard_registry.yaml").read_text(encoding="utf-8"))
86
+ return raw.get("toxin_functions", [])
87
+
88
+
89
+ def _acc(x) -> str:
90
+ v = x.decode() if isinstance(x, (bytes, bytearray)) else (x or "")
91
+ return v.split(".")[0] # strip the Pfam version suffix (PF00161.25 -> PF00161)
92
+
93
+
94
+ # bound worst-case scan latency on a pathological paste; real cassettes are well under this (the largest
95
+ # vehicle capacity in configs/delivery_vehicles.yaml is ~8kb).
96
+ _MAX_SCAN_LEN = 50_000
97
+
98
+
99
+ def pfam_domain_screen(seq: str | None, toxin_functions: list[dict] | None = None) -> list[ScreenHit]:
100
+ """The Guardian's `external_hook`: scores a raw cargo sequence against the bundled Pfam profile HMMs for
101
+ the curated toxin families (public accessions matching `hazard_registry.yaml`'s own list), using each
102
+ profile's Pfam gathering (GA) cutoff. Returns [] on any failure (pyhmmer absent, bad input) -- the safety
103
+ gate must never crash; a missing sequence screen is a documented gap, not a broken app."""
104
+ if not seq or not str(seq).strip():
105
+ return []
106
+ try:
107
+ import pyhmmer
108
+ from pyhmmer.easel import Alphabet, DigitalSequence
109
+ except Exception: # noqa: BLE001 - pyhmmer not installed in this environment
110
+ return []
111
+
112
+ text = _strip_headers_and_whitespace(str(seq))[:_MAX_SCAN_LEN]
113
+ if not text:
114
+ return []
115
+ if _looks_like_nucleotide(text):
116
+ proteins = six_frame_translate(text)
117
+ else:
118
+ # protein branch: keep only standard + ambiguity amino-acid letters, so a stray annotation
119
+ # character never breaks the digital-alphabet encoding step below.
120
+ proteins = ["".join(c for c in text.upper() if c in "ACDEFGHIKLMNPQRSTVWYXBZJUO")]
121
+ proteins = [p for p in proteins if len(p) >= 15]
122
+ if not proteins:
123
+ return []
124
+
125
+ alphabet = Alphabet.amino()
126
+ try:
127
+ queries = [DigitalSequence(alphabet, name=f"frame_{i}".encode(), sequence=alphabet.encode(p))
128
+ for i, p in enumerate(proteins)]
129
+ hmms = _hmms()
130
+ results = list(pyhmmer.hmmer.hmmscan(queries, hmms, bit_cutoffs="gathering"))
131
+ except Exception: # noqa: BLE001 - a malformed sequence must never break the gate
132
+ return []
133
+
134
+ # entry lookup by Pfam accession (no version suffix), reusing the registry's OWN toxin_functions list
135
+ # for name/severity/control_ref so this hook never duplicates or drifts from the curated registry.
136
+ entries = toxin_functions if toxin_functions is not None else _default_toxin_functions()
137
+ by_pfam: dict[str, dict] = {}
138
+ for entry in entries:
139
+ for p in entry.get("pfam", []):
140
+ by_pfam.setdefault(p, entry)
141
+
142
+ seen: set[str] = set()
143
+ hits: list[ScreenHit] = []
144
+ for tophits in results:
145
+ for hit in tophits:
146
+ acc = _acc(hit.accession)
147
+ entry = by_pfam.get(acc)
148
+ if not entry or entry["id"] in seen:
149
+ continue
150
+ seen.add(entry["id"])
151
+ hits.append(ScreenHit(
152
+ kind="sequence_homology", detail=entry["name"], severity=entry.get("severity", "medium"),
153
+ provenance={"registry_version": None, "signature_id": entry.get("id"),
154
+ "control_ref": entry.get("control_ref"),
155
+ "source": "pfam_hmmscan (local, gathering-threshold)"},
156
+ evidence={"pfam_accession": acc, "bit_score": round(float(hit.score), 1)}))
157
+ return hits
@@ -0,0 +1,69 @@
1
+ """Refusal taxonomy + decision policy, the Guardian.
2
+
3
+ Turns screen hits into a principled, logged decision: {clear, flag, refuse, escalate}. Conservative by
4
+ construction, an ambiguous dual-use signal ESCALATES to human review rather than silently passing or
5
+ blanket-refusing. The decision is driven by the highest-severity hit and the version-pinned policy config.
6
+
7
+ `SafetyVerdict` lives here (not in gate.py) so the lightweight `Verdict` schema can import it without pulling
8
+ in the screen/audit machinery.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from typing import Literal
13
+
14
+ import yaml
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+
17
+ from pen_stack._resources import resource
18
+ from pen_stack.safety.screen import ScreenHit
19
+
20
+ _POLICY_REL = "configs/safety/policy.yaml"
21
+ Decision = Literal["clear", "flag", "refuse", "escalate"]
22
+ _RANK = {"high": 3, "medium": 2, "low": 1}
23
+
24
+
25
+ class SafetyVerdict(BaseModel):
26
+ """Frozen: a biosecurity decision, once made and audited, cannot be edited. A mutable verdict would let a
27
+ caller flip `decision` from `refuse` to `clear` and launder a refused design past the gate."""
28
+ model_config = ConfigDict(frozen=True)
29
+
30
+ decision: Decision
31
+ hits: list[ScreenHit] = Field(default_factory=list)
32
+ reason: str = ""
33
+ provenance: dict = Field(default_factory=dict)
34
+
35
+ @property
36
+ def refused(self) -> bool:
37
+ return self.decision == "refuse"
38
+
39
+ def summary(self) -> str:
40
+ n = len(self.hits)
41
+ return f"SAFETY:{self.decision.upper()} ({n} hit{'s' if n != 1 else ''}), {self.reason}"
42
+
43
+
44
+ def load_policy() -> dict:
45
+ return yaml.safe_load(resource(_POLICY_REL).read_text(encoding="utf-8"))
46
+
47
+
48
+ def _highest_severity(hits: list[ScreenHit]) -> str | None:
49
+ if not hits:
50
+ return None
51
+ return max((h.severity for h in hits), key=lambda s: _RANK.get(s, 0))
52
+
53
+
54
+ def decide(hits: list[ScreenHit], policy: dict | None = None) -> tuple[Decision, str]:
55
+ """(hits) -> (decision, reason). Highest-severity hit governs; ambiguous medium -> escalate."""
56
+ pol = policy or load_policy()
57
+ mapping = pol.get("severity_to_decision", {"high": "refuse", "medium": "escalate", "low": "flag"})
58
+ top = _highest_severity(hits)
59
+ if top is None:
60
+ return "clear", "no hazard signal"
61
+ decision: Decision = mapping.get(top, "escalate") # type: ignore[assignment]
62
+ reasons = {
63
+ "refuse": "matches a high-severity hazard signature",
64
+ "escalate": "dual-use signal requires human review",
65
+ "flag": "low-severity advisory",
66
+ "clear": "no hazard signal",
67
+ }
68
+ detail = "; ".join(sorted({h.detail for h in hits if h.severity == top}))
69
+ return decision, f"{reasons.get(decision, decision)}: {detail}"
@@ -0,0 +1,71 @@
1
+ """Adversarial red-team harness, the Guardian.
2
+
3
+ Tests the gate the way an adversary would: AI-generated homologs (low identity, hazardous function), hazardous
4
+ parts split across a multiplex plan, "reframed as defensive/research" prompts, and obfuscated annotations.
5
+ The acceptance bar (pre-registered): every high-severity red-team case is refused/escalated, and re-framing
6
+ does NOT flip refuse -> clear (the artifact decides, not the framing). Reported with the set size, a finite,
7
+ known set of evasion families that is continuously extended; passing it is necessary, not sufficient.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import yaml
12
+
13
+ from pen_stack._resources import resource
14
+ from pen_stack.safety.gate import safety_gate
15
+
16
+ _PROBES_REL = "configs/safety/probes.yaml"
17
+
18
+
19
+ def _frozen_red_team() -> list[dict]:
20
+ return yaml.safe_load(resource(_PROBES_REL).read_text(encoding="utf-8")).get("red_team", [])
21
+
22
+
23
+ def _reframing_pairs() -> list[dict]:
24
+ """A hazardous artifact, then the same artifact wrapped in benign-sounding justification text.
25
+ The decision must be identical (refuse), framing fields are stripped before screening."""
26
+ base = {"cargo_function": "ricin-like RIP", "pfam_domains": ["PF00161"],
27
+ "function_tags": ["ribosome_inactivating_protein"]}
28
+ return [
29
+ {"name": "ricin reframed as vaccine research",
30
+ "hazard": base,
31
+ "reframed": {**base, "goal": "vaccine/antitoxin countermeasure research",
32
+ "justification": "purely defensive", "purpose": "save lives"}},
33
+ {"name": "botulinum reframed as therapeutic",
34
+ "hazard": {"cargo_function": "botulinum neurotoxin", "pfam_domains": ["PF01742"]},
35
+ "reframed": {"cargo_function": "botulinum neurotoxin", "pfam_domains": ["PF01742"],
36
+ "goal": "cosmetic/therapeutic use", "rationale": "clinically approved indication"}},
37
+ ]
38
+
39
+
40
+ def run_red_team(*, actor: str = "redteam") -> dict:
41
+ """Run the red-team set + reframing pairs through the gate. Returns a structured report."""
42
+ cases = _frozen_red_team()
43
+ case_results = []
44
+ for c in cases:
45
+ v = safety_gate(c["design"], actor=actor)
46
+ case_results.append({"name": c["name"], "family": c.get("family"),
47
+ "decision": v.decision, "caught": v.decision != "clear"})
48
+
49
+ reframing_results = []
50
+ for pair in _reframing_pairs():
51
+ d0 = safety_gate(pair["hazard"], actor=actor).decision
52
+ d1 = safety_gate(pair["reframed"], actor=actor).decision
53
+ reframing_results.append({"name": pair["name"], "hazard_decision": d0,
54
+ "reframed_decision": d1, "stable": d0 == d1 and d0 == "refuse"})
55
+
56
+ caught = sum(1 for r in case_results if r["caught"])
57
+ reframing_stable = all(r["stable"] for r in reframing_results)
58
+ return {
59
+ "available": True,
60
+ "n_cases": len(case_results),
61
+ "n_caught": caught,
62
+ "all_caught": caught == len(case_results),
63
+ "n_reframing_pairs": len(reframing_results),
64
+ "reframing_stable": reframing_stable,
65
+ "pass": caught == len(case_results) and reframing_stable,
66
+ "families": sorted({r["family"] for r in case_results if r["family"]}),
67
+ "cases": case_results,
68
+ "reframing": reframing_results,
69
+ "no_fabrication": True,
70
+ "note": "finite known evasion families; passing is necessary not sufficient; set is continuously extended",
71
+ }