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,118 @@
1
+ """Target-site / PAM / att-site availability filter.
2
+
3
+ A **sequence-computable hard filter** on reachability: for a candidate site sequence, does the writer family
4
+ carry the *targeting element* its mechanism physically requires? No usable element → the writer is unreachable
5
+ there and is REJECTED. This extends the WT-KB reachability tier with a sequence-level mechanistic check,
6
+ "the LLM may propose a writer, but if the physics says it cannot engage this site, the funnel rejects it."
7
+
8
+ Requirement per family (configs/target_sites.yaml):
9
+ * ``pam``, a PAM motif must occur in the window (Cas9 NGG / Cas12a TTTV / CAST GTN).
10
+ * ``core_dinuc``, a programmable bipartite recombinase needs its central core dinucleotide (bridge/seek CT).
11
+ * ``att_site``, a serine integrase needs an attB/attP (pseudo-)att; a naive genomic window has none, so
12
+ it REJECTS unless a landing pad was pre-installed (the key mechanistic reject).
13
+ * ``pe_installable``, a PE-integrase installs its own att, so the site is broadly reachable (available + note).
14
+
15
+ These are SCREENS, not activity guarantees (relaxed-PAM engineered variants especially); reported with a
16
+ confidence. The point is the hard *negative*: a physically impossible writer, site pairing is rejected.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from functools import lru_cache
22
+
23
+ import yaml
24
+
25
+ from pen_stack._resources import resource
26
+
27
+ # IUPAC nucleotide → regex character class (for PAM / att-core expansion).
28
+ _IUPAC = {"A": "A", "C": "C", "G": "G", "T": "T", "R": "[AG]", "Y": "[CT]", "S": "[GC]", "W": "[AT]",
29
+ "K": "[GT]", "M": "[AC]", "B": "[CGT]", "D": "[AGT]", "H": "[ACT]", "V": "[ACG]", "N": "[ACGT]"}
30
+
31
+
32
+ @lru_cache(maxsize=1)
33
+ def _cfg() -> dict:
34
+ return yaml.safe_load(resource("configs/target_sites.yaml").read_text(encoding="utf-8"))
35
+
36
+
37
+ def _clean(seq: str) -> str:
38
+ return re.sub(r"[^ACGT]", "", (seq or "").upper())
39
+
40
+
41
+ def iupac_to_regex(motif: str) -> str:
42
+ return "".join(_IUPAC.get(b, b) for b in motif.upper())
43
+
44
+
45
+ def find_motif(seq: str, motif: str) -> list[int]:
46
+ """0-based start positions of an IUPAC motif (overlapping) in a cleaned sequence."""
47
+ s = _clean(seq)
48
+ pat = iupac_to_regex(motif)
49
+ return [m.start() for m in re.finditer(f"(?=({pat}))", s)]
50
+
51
+
52
+ def target_site_available(family: str, seq: str, installed_att: bool = False) -> dict:
53
+ """Is the writer family's required targeting element present in ``seq``? Returns a structured verdict.
54
+
55
+ ``installed_att=True`` declares a pre-installed landing pad (so a serine/PE integrase becomes reachable
56
+ deterministically). The result is a HARD filter input: ``available=False`` → reject the writer at this site.
57
+ """
58
+ fams = _cfg()["families"]
59
+ fam = fams.get(family)
60
+ s = _clean(seq)
61
+ if fam is None:
62
+ # unknown family: do not silently reject; report not-checkable (reachability falls back to WT-KB tier)
63
+ return {"family": family, "available": True, "checked": False,
64
+ "reason": "no target-site rule for this family; reachability deferred to WT-KB tier",
65
+ "confidence": "none"}
66
+
67
+ req = fam["requirement"]
68
+ conf = fam.get("confidence", "inferred")
69
+
70
+ if req == "pam":
71
+ hits = find_motif(s, fam["pam"])
72
+ ok = len(hits) > 0
73
+ out = {"family": family, "requirement": "pam", "pam": fam["pam"], "n_pam_sites": len(hits),
74
+ "available": ok, "checked": True, "confidence": conf,
75
+ "reason": (f"{len(hits)} {fam['pam']} PAM site(s) present" if ok
76
+ else f"no {fam['pam']} PAM in the window, writer cannot target here (reject)")}
77
+ if "insertion_offset_bp" in fam:
78
+ out["insertion_offset_bp"] = fam["insertion_offset_bp"]
79
+ return out
80
+
81
+ if req == "core_dinuc":
82
+ hits = find_motif(s, fam["core_dinuc"])
83
+ ok = len(hits) > 0
84
+ return {"family": family, "requirement": "core_dinuc", "core": fam["core_dinuc"],
85
+ "n_core_sites": len(hits), "available": ok, "checked": True, "confidence": conf,
86
+ "reason": (f"central {fam['core_dinuc']} core present ({len(hits)} site(s)); loops reprogrammable"
87
+ if ok else f"no {fam['core_dinuc']} core in the window, bipartite recombinase "
88
+ "has no recombination point (reject)")}
89
+
90
+ if req == "att_site":
91
+ if installed_att:
92
+ return {"family": family, "requirement": "att_site", "available": True, "checked": True,
93
+ "confidence": conf, "installed_att": True,
94
+ "reason": "pre-installed att landing pad declared, integrase reachable deterministically"}
95
+ present = [m for m in fam.get("att_motifs", []) if iupac_to_regex(m) and re.search(iupac_to_regex(m), s)]
96
+ ok = bool(present)
97
+ return {"family": family, "requirement": "att_site", "available": ok, "checked": True,
98
+ "confidence": conf, "att_motifs_found": present,
99
+ "reason": ("a native (pseudo-)att core is present" if ok else
100
+ "no attB/attP (pseudo-)att in the window, serine integrase needs a pre-installed "
101
+ "landing pad; unreachable at a naive site (reject)")}
102
+
103
+ if req == "pe_installable":
104
+ return {"family": family, "requirement": "pe_installable", "available": True, "checked": True,
105
+ "confidence": conf,
106
+ "reason": "PE installs the att beacon at the chosen site, broadly reachable (no native motif "
107
+ "required; the install step is PE-bounded, per the WT-KB reachability tier)"}
108
+
109
+ return {"family": family, "available": True, "checked": False,
110
+ "reason": f"unhandled requirement {req!r}; reachability deferred to WT-KB tier", "confidence": "none"}
111
+
112
+
113
+ def filter_reachable(families: list[str], seq: str, installed_att: bool = False) -> dict:
114
+ """Split a candidate writer list into mechanistically reachable vs rejected at this site sequence."""
115
+ verdicts = {f: target_site_available(f, seq, installed_att=installed_att) for f in families}
116
+ reachable = [f for f, v in verdicts.items() if v["available"]]
117
+ rejected = [{"family": f, "reason": v["reason"]} for f, v in verdicts.items() if not v["available"]]
118
+ return {"reachable": reachable, "rejected": rejected, "verdicts": verdicts}
@@ -0,0 +1 @@
1
+ """pen_stack.rag - see the PEN-STACK program doc."""
@@ -0,0 +1,133 @@
1
+ """RAG corpus builder.
2
+
3
+ Assembles the provenance-tagged corpus that grounds the chat's General lane. EVERY chunk is real, already-curated
4
+ repository content - DOI-backed verbatim quotes, the metric guide, the writer-atlas family cards, the data/model
5
+ cards, and the scope boundary - never fabricated paper text. Each chunk carries:
6
+ chunk_id, text, source_id, doi, access_grade, type, scope_status
7
+ The built table is `data/rag_corpus.parquet` (SHA-locked at build); its embeddings are `data/rag_corpus_emb.npy`.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ import pandas as pd
14
+
15
+ from pen_stack._resources import project_root
16
+
17
+ _FIELDS = ["chunk_id", "text", "source_id", "doi", "access_grade", "type", "scope_status"]
18
+
19
+
20
+ def _writer_efficiency_chunks() -> list[dict]:
21
+ from pen_stack.atlas import writer_efficiency as we
22
+ out = []
23
+ for r in we.records().itertuples(index=False):
24
+ q = str(getattr(r, "quote", "") or "").strip()
25
+ if not q:
26
+ continue
27
+ eff = getattr(r, "efficiency_pct", None)
28
+ eff_s = f" Measured integration efficiency ~{eff}%." if eff is not None and not pd.isna(eff) else ""
29
+ out.append({
30
+ "text": (f"{r.system} ({r.family}) at {r.locus} in {r.cell_type}.{eff_s} "
31
+ f"Reported: \"{q}\""),
32
+ "source_id": f"writer_eff:{r.system}:{r.locus}", "doi": str(r.doi),
33
+ "access_grade": str(getattr(r, "source_access", "unknown")),
34
+ "type": "efficiency_measurement", "scope_status": "measured"})
35
+ return out
36
+
37
+
38
+ def _metric_guide_chunks() -> list[dict]:
39
+ from pen_stack.web.guide import metric_guide
40
+ mg = metric_guide() or {}
41
+ out = []
42
+ for key, m in (mg.get("metrics") or {}).items():
43
+ text = (f"Metric '{m.get('label', key)}' ({key}): scale {m.get('scale')}, {m.get('direction')}. "
44
+ f"What it means: {m.get('means')} How it is computed: {m.get('computed')} "
45
+ f"Validation: {m.get('validation')} Reference: {m.get('reference')}")
46
+ cites = m.get("reference") or ""
47
+ out.append({"text": text, "source_id": f"metric_guide:{key}", "doi": str(cites),
48
+ "access_grade": "curated", "type": "metric_card", "scope_status": "documented"})
49
+ sd = mg.get("safety_decision")
50
+ if sd:
51
+ out.append({"text": "Guardian biosecurity decisions: " + "; ".join(f"{k} = {v}" for k, v in sd.items()),
52
+ "source_id": "metric_guide:safety_decision", "doi": "", "access_grade": "curated",
53
+ "type": "metric_card", "scope_status": "documented"})
54
+ return out
55
+
56
+
57
+ def _atlas_card_chunks() -> list[dict]:
58
+ from pen_stack.rag.index import build_cards
59
+ out = []
60
+ for c in build_cards():
61
+ out.append({"text": c.text, "source_id": f"atlas:{c.key}", "doi": ";".join(c.citations),
62
+ "access_grade": "curated", "type": "writer_atlas_card", "scope_status": "measured/candidate"})
63
+ return out
64
+
65
+
66
+ def _doc_card_chunks() -> list[dict]:
67
+ """Chunk the committed data/model cards (docs/cards/*.md) by paragraph."""
68
+ out = []
69
+ cards_dir = project_root() / "docs" / "cards"
70
+ if not cards_dir.exists():
71
+ return out
72
+ for p in sorted(cards_dir.glob("*.md")):
73
+ text = p.read_text(encoding="utf-8", errors="ignore")
74
+ for para in [s.strip() for s in text.split("\n\n")]:
75
+ clean = " ".join(para.split())
76
+ if len(clean) < 80 or clean.startswith("#") and len(clean) < 120:
77
+ continue
78
+ out.append({"text": clean[:900], "source_id": f"card:{p.stem}", "doi": "",
79
+ "access_grade": "curated", "type": "data_card", "scope_status": "documented"})
80
+ return out
81
+
82
+
83
+ def _scope_chunks() -> list[dict]:
84
+ """The scope boundary: the known-unknowns the engine never predicts."""
85
+ from pen_stack.web.guide import pen_stack_facts
86
+ out = []
87
+ try:
88
+ facts = pen_stack_facts() or {}
89
+ except Exception: # noqa: BLE001
90
+ facts = {}
91
+ ku = facts.get("known_unknowns") or facts.get("scope", {}).get("known_unknowns")
92
+ if ku:
93
+ out.append({"text": ("PEN-STACK never predicts these known-unknowns (it abstains): "
94
+ + "; ".join(str(x) for x in ku) + ". These are measured clinical endpoints "
95
+ "outside the engine's validated envelope."),
96
+ "source_id": "scope:known_unknowns", "doi": "", "access_grade": "curated",
97
+ "type": "scope_boundary", "scope_status": "known_unknown"})
98
+ return out
99
+
100
+
101
+ def build_corpus() -> pd.DataFrame:
102
+ """Assemble the full provenance-tagged corpus from real repository content."""
103
+ rows: list[dict] = []
104
+ for fn in (_writer_efficiency_chunks, _metric_guide_chunks, _atlas_card_chunks,
105
+ _doc_card_chunks, _scope_chunks):
106
+ try:
107
+ rows.extend(fn())
108
+ except Exception as e: # noqa: BLE001 - a missing source must not silently corrupt the corpus; surface it
109
+ rows.append({"text": f"[source builder {fn.__name__} failed: {e}]", "source_id": "build_error",
110
+ "doi": "", "access_grade": "n/a", "type": "build_error", "scope_status": "n/a"})
111
+ # drop build-error rows from the shippable corpus but keep the count accurate
112
+ df = pd.DataFrame([r for r in rows if r["type"] != "build_error"])
113
+ df = df.drop_duplicates(subset=["text"]).reset_index(drop=True)
114
+ df["chunk_id"] = [f"c{i:04d}" for i in range(len(df))]
115
+ errors = [r for r in rows if r["type"] == "build_error"]
116
+ if errors:
117
+ raise RuntimeError("corpus build had source errors: " + " | ".join(r["text"] for r in errors))
118
+ return df[_FIELDS]
119
+
120
+
121
+ def corpus_path() -> Path:
122
+ return project_root() / "data" / "rag_corpus.parquet"
123
+
124
+
125
+ def emb_path() -> Path:
126
+ return project_root() / "data" / "rag_corpus_emb.npy"
127
+
128
+
129
+ def load_corpus() -> pd.DataFrame:
130
+ p = corpus_path()
131
+ if not p.exists():
132
+ raise FileNotFoundError(f"{p} not built; run scripts/build_rag_corpus.py")
133
+ return pd.read_parquet(p)
pen_stack/rag/embed.py ADDED
@@ -0,0 +1,98 @@
1
+ """RAG embedding client.
2
+
3
+ A pinned local embedder (`nomic-embed-text` via the already-running Ollama) for semantic retrieval, with a
4
+ deterministic, model-free **lexical fallback** so the General lane still retrieves (degraded) when no
5
+ embedder is reachable - e.g. in CI or if Ollama is down. The CORPUS embeddings are computed once at build time and
6
+ committed (`data/rag_corpus_emb.npy`); only the live QUERY is embedded at runtime, so retrieval stays reproducible.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import urllib.request
13
+ from functools import lru_cache
14
+
15
+ import numpy as np
16
+
17
+ EMBED_MODEL = os.getenv("PEN_RAG_EMBED_MODEL", "nomic-embed-text")
18
+ OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://localhost:11434")
19
+ _TIMEOUT = int(os.getenv("PEN_RAG_EMBED_TIMEOUT", "30"))
20
+ # nomic-embed-text is trained with task prefixes; using them is REQUIRED for retrieval to separate relevant from
21
+ # irrelevant (without them every pair sits in a narrow high-cosine band and abstention cannot discriminate).
22
+ _PREFIX = {"query": "search_query: ", "document": "search_document: "}
23
+
24
+
25
+ def embed_text(text: str, task: str = "query") -> np.ndarray | None:
26
+ """L2-normalised embedding of one string via Ollama (with the nomic task prefix), or None if unreachable."""
27
+ if os.getenv("PEN_RAG_NO_EMBED") == "1":
28
+ return None
29
+ try:
30
+ req = urllib.request.Request(
31
+ f"{OLLAMA_HOST}/api/embeddings",
32
+ data=json.dumps({"model": EMBED_MODEL, "prompt": _PREFIX.get(task, "") + (text or "")}).encode(),
33
+ headers={"Content-Type": "application/json"})
34
+ with urllib.request.urlopen(req, timeout=_TIMEOUT) as r:
35
+ vec = json.loads(r.read().decode())["embedding"]
36
+ v = np.asarray(vec, dtype="float32")
37
+ n = float(np.linalg.norm(v))
38
+ return v / n if n else v
39
+ except Exception: # noqa: BLE001 - any failure -> caller uses the lexical fallback
40
+ return None
41
+
42
+
43
+ # latency fix: cache live query embeddings in-process. The chat embeds the user's message on every General-lane
44
+ # request; repeat phrasings ("hi", "what is DNA", "what is gene editing") would otherwise hit Ollama every time. The
45
+ # document side is unaffected - corpus embeddings are computed once at build time and committed.
46
+ @lru_cache(maxsize=256)
47
+ def _embed_query_cached(text: str) -> tuple | None:
48
+ v = embed_text(text, task="query")
49
+ return tuple(float(x) for x in v) if v is not None else None
50
+
51
+
52
+ def embed_query(text: str) -> np.ndarray | None:
53
+ """Cached query-side embedding. Identical to `embed_text(text, task='query')` but memoised by text."""
54
+ t = (text or "").strip()
55
+ if not t:
56
+ return embed_text(t, task="query")
57
+ cached = _embed_query_cached(t)
58
+ return np.asarray(cached, dtype="float32") if cached is not None else None
59
+
60
+
61
+ def embed_corpus(texts: list[str]) -> np.ndarray:
62
+ """Embed the corpus DOCUMENTS at BUILD time. Raises if the embedder is unreachable (the build must be
63
+ deterministic and must never silently fall back to a different representation)."""
64
+ out = []
65
+ for t in texts:
66
+ v = embed_text(t, task="document")
67
+ if v is None:
68
+ raise RuntimeError(f"embedder '{EMBED_MODEL}' unreachable at {OLLAMA_HOST}; cannot build corpus embeddings")
69
+ out.append(v)
70
+ return np.vstack(out).astype("float32")
71
+
72
+
73
+ # --- deterministic, model-free lexical fallback (content-token Jaccard) --------------------------------------
74
+ # Stopwords are dropped so the fallback discriminates on CONTENT words: an out-of-corpus query (e.g. "what is the
75
+ # capital of France") must not match a genome-writing chunk via shared function words and slip past the abstention.
76
+ _STOP = frozenset((
77
+ "the and for are was were that this with from have has had not but you your his her its our their they them "
78
+ "what which who whom whose when where why how does did doing done can could should would will shall may might "
79
+ "into onto over under above below than then them they about after before between because while during each "
80
+ "any all some more most much many few any both either neither nor yet via per such only also same other "
81
+ "one two three get got make made use used using like just very too here there out off own").split())
82
+
83
+
84
+ def tokenize(s: str) -> set[str]:
85
+ return {w for w in "".join(c.lower() if c.isalnum() else " " for c in (s or "")).split()
86
+ if len(w) > 2 and w not in _STOP}
87
+
88
+
89
+ def lexical_scores(query: str, texts: list[str]) -> np.ndarray:
90
+ """Jaccard overlap of the query tokens with each document's tokens. Deterministic; no model."""
91
+ q = tokenize(query)
92
+ if not q:
93
+ return np.zeros(len(texts), dtype="float32")
94
+ out = np.zeros(len(texts), dtype="float32")
95
+ for i, t in enumerate(texts):
96
+ d = tokenize(t)
97
+ out[i] = len(q & d) / len(q | d) if d else 0.0
98
+ return out
@@ -0,0 +1,131 @@
1
+ """RAG grounding for the chat's General lane.
2
+
3
+ Retrieval is ADDITIVE, not a gate. The General lane ANSWERS general and social questions by default (restoring the
4
+ hybrid answering behaviour), clearly LABELLED as general knowledge - a labelled general answer is not a
5
+ fabrication, because it is never presented as a PEN-STACK result. Corpus retrieval, when it finds a relevant chunk,
6
+ UPGRADES the answer to 'literature-cited' (with its sources). Abstention is the rare exception: it fires only for a
7
+ SPECIFIC, unsourceable empirical / quantitative genome-writing claim the corpus does not cover and the engine is
8
+ not computing - the genuine citation-or-silence case - so the model cannot dress a fabricated statistic as fact.
9
+
10
+ Branches:
11
+ social -> a friendly answer + a pointer to what the engine can compute (no retrieval, no abstention).
12
+ cited -> a relevant corpus chunk was retrieved -> cite-or-silence answer, provenance 'literature-cited'.
13
+ general -> background / textbook knowledge -> answer from the LLM, labelled (provenance 'general').
14
+ abstained -> a specific unsourceable empirical claim -> decline + redirect to the engine (provenance 'abstained').
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import re
20
+
21
+ from pen_stack.rag.embed import tokenize
22
+ from pen_stack.rag.retrieve import retrieve
23
+
24
+ # nomic-embed-text compresses cosines into a narrow high band, so an absolute cosine alone cannot separate a valid
25
+ # in-corpus query from an off-topic one. The hybrid gate (cosine floor + lexical content-overlap, with a high-cosine
26
+ # escape) decides only whether the corpus is a relevant SOURCE to cite - NOT whether to answer at all.
27
+ THRESHOLD = float(os.getenv("PEN_RAG_THRESHOLD", "0.50"))
28
+ _SEM_HIGH = float(os.getenv("PEN_RAG_THRESHOLD_HIGH", "0.72"))
29
+ _LEX_THRESHOLD = float(os.getenv("PEN_RAG_LEX_THRESHOLD", "0.05"))
30
+
31
+ # A social / conversational opener: answered naturally, never gated on the corpus.
32
+ _SOCIAL = re.compile(r"^\s*(hi|hii+|hey+|hello+|yo|hola|sup|greetings|good (morning|afternoon|evening|day)|"
33
+ r"thanks?|thank you|cheers|ok(ay)?|cool|nice|great|awesome|who are you|what are you|"
34
+ r"how are you|what'?s up|introduce yourself|help)\b", re.I)
35
+
36
+ # A SPECIFIC genome-writing QUANTITATIVE / empirical claim (not definitional / mechanistic background). When the
37
+ # corpus has no source for it and it is in the general lane (so the engine is not computing it), we abstain +
38
+ # redirect rather than let the model invent a statistic dressed up as fact.
39
+ _SPECIFIC_EMPIRICAL = re.compile(
40
+ r"\b(efficienc\w*|integration rate|specificit\w*|on-?target rate|off-?target rate|knock-?in rate|titer|titre|"
41
+ r"affinit\w*|\bkd\b|ic50|fold[- ]improvement|expression level|copy number|"
42
+ r"percent\w* (of )?(integration|editing|knock|insertion))\b", re.I)
43
+
44
+ _SOCIAL_REPLY = ("Hi - I'm PEN-STACK's genome-writing co-scientist. Ask a genome-writing question (where to insert, "
45
+ "which writer, delivery, immune risk, off-targets, safety) and the engine computes a grounded, "
46
+ "traceable answer. I can also answer general background questions, clearly labelled as such.")
47
+ _GENERAL_NO_LLM = ("I can answer general background questions when the language model is enabled. Meanwhile, "
48
+ "PEN-STACK's engine can compute grounded, specific answers for your case.")
49
+ _LABEL = "Literature-cited (corpus) - not a PEN-STACK-computed result:"
50
+ _ABSTAIN_SPECIFIC = ("I don't have a grounded source for that specific value and I won't invent one. PEN-STACK's "
51
+ "engine can compute it from a concrete design - give me the gene, cell type, and cargo (or "
52
+ "the writer/guide) and I'll run the site, writer-efficiency, or off-target tools.")
53
+
54
+
55
+ def _is_grounded(query: str, hits: list[dict], method: str) -> bool:
56
+ """Is the corpus a relevant SOURCE to cite for this query?"""
57
+ if not hits:
58
+ return False
59
+ top = hits[0]
60
+ if not method.startswith("semantic"):
61
+ return top["score"] >= _LEX_THRESHOLD
62
+ lexical_overlap = bool(tokenize(query) & tokenize(top["text"]))
63
+ return top["score"] >= THRESHOLD and (lexical_overlap or top["score"] >= _SEM_HIGH)
64
+
65
+
66
+ def _dedup_sources(hits: list[dict]) -> list[dict]:
67
+ seen, out = set(), []
68
+ for h in hits:
69
+ sid = h["source_id"]
70
+ if sid in seen:
71
+ continue
72
+ seen.add(sid)
73
+ out.append({"source_id": sid, "doi": h.get("doi") or None, "type": h["type"], "score": round(h["score"], 3)})
74
+ return out
75
+
76
+
77
+ def _cited_answer(message: str, r: dict, allow_llm: bool) -> dict:
78
+ floor = THRESHOLD if r["method"].startswith("semantic") else _LEX_THRESHOLD
79
+ grounded_hits = [h for h in r["hits"] if h["score"] >= floor] or r["hits"][:2]
80
+ sources = _dedup_sources(grounded_hits)
81
+ deterministic = _LABEL + "\n\n" + "\n\n".join(
82
+ f"- {h['text']} [{h['source_id']}{(' · ' + h['doi']) if h.get('doi') else ''}]" for h in grounded_hits)
83
+ base = {"status": "grounded", "sources": sources, "provenance": "literature-cited", "grounded": True,
84
+ "retrieval": r["method"], "top_score": r["top_score"]}
85
+ if allow_llm:
86
+ from pen_stack.web.llm import _enforce_grounding, _run_llm
87
+ from pen_stack.web.tools import extract_grounded_numbers
88
+ ctx = "\n\n".join(f"[{i + 1}] ({h['source_id']}{(' DOI ' + h['doi']) if h.get('doi') else ''}) {h['text']}"
89
+ for i, h in enumerate(grounded_hits))
90
+ system = ("You answer ONLY from the SOURCES provided. After each claim, cite the bracketed [n] it came from. "
91
+ "If the sources do not cover the question, say so. Never add a number, name, or vehicle that is "
92
+ "not in the sources. Never present anything as a PEN-STACK-computed result.")
93
+ text, backend = _run_llm(f"SOURCES:\n{ctx}\n\nQUESTION: {message}\n\nAnswer concisely, citing [n].", system,
94
+ kind="general") # fast-path: short timeout + token cap for cited answers
95
+ if text:
96
+ allow = extract_grounded_numbers({"hits": [h["text"] for h in grounded_hits]})
97
+ return {**base, "reply": _LABEL + "\n\n" + _enforce_grounding(text.strip(), allow), "backend": backend}
98
+ return {**base, "reply": deterministic, "backend": "deterministic"}
99
+
100
+
101
+ def ground_general(message: str, *, allow_llm: bool = True) -> dict:
102
+ """Return the chat envelope for the General lane: answer (social / general / cited) by default; abstain only on a
103
+ specific unsourceable empirical claim."""
104
+ msg = (message or "").strip()
105
+ if _SOCIAL.match(msg):
106
+ return {"status": "social", "reply": _SOCIAL_REPLY, "sources": [], "provenance": "general",
107
+ "grounded": False, "backend": "deterministic", "retrieval": "none", "top_score": 0.0}
108
+
109
+ r = retrieve(msg, k=4)
110
+ if _is_grounded(msg, r["hits"], r["method"]):
111
+ return _cited_answer(msg, r, allow_llm)
112
+
113
+ # no corpus source. A specific empirical genome-writing claim -> abstain + redirect to the engine.
114
+ if _SPECIFIC_EMPIRICAL.search(msg):
115
+ return {"status": "abstained", "reply": _ABSTAIN_SPECIFIC, "sources": [], "provenance": "abstained",
116
+ "grounded": False, "backend": "deterministic", "retrieval": r["method"], "top_score": r["top_score"]}
117
+
118
+ # general / background knowledge -> ANSWER, clearly labelled as general (never a PEN-STACK result).
119
+ if allow_llm:
120
+ from pen_stack.web.llm import SYSTEM_GENERAL, _GENERAL_LABEL, _run_llm
121
+ text, backend = _run_llm(
122
+ f"USER: {msg}\n\nAnswer from general knowledge, clearly and at a graduate level. Do not present anything "
123
+ f"as a PEN-STACK result.", SYSTEM_GENERAL, kind="general") # fast-path timeout
124
+ if text:
125
+ return {"status": "general", "reply": _GENERAL_LABEL + "\n\n" + text.strip(), "sources": [],
126
+ "provenance": "general", "grounded": False, "backend": backend, "retrieval": r["method"],
127
+ "top_score": r["top_score"]}
128
+ from pen_stack.web.llm import _GENERAL_LABEL
129
+ return {"status": "general", "reply": _GENERAL_LABEL + "\n\n" + _GENERAL_NO_LLM, "sources": [],
130
+ "provenance": "general", "grounded": False, "backend": "deterministic", "retrieval": r["method"],
131
+ "top_score": r["top_score"]}
pen_stack/rag/index.py ADDED
@@ -0,0 +1,53 @@
1
+ """Grounded document index for the PEN-STACK RAG.
2
+
3
+ Builds a cited corpus of fact cards from the curated atlas + WT-KB (each card carries its source DOIs),
4
+ so retrieval-grounded answers always have a citation. If PaperQA + an LLM are available they can index a
5
+ literature corpus on top; the keyword retriever here is the dependency-light default that guarantees the
6
+ "every factual claim is cited" contract without any model.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+
13
+ import pandas as pd
14
+
15
+ _ATLAS = Path(__file__).resolve().parents[1] / "atlas" / "atlas.parquet"
16
+
17
+
18
+ @dataclass
19
+ class Card:
20
+ key: str
21
+ text: str
22
+ citations: list[str] = field(default_factory=list)
23
+
24
+
25
+ def build_cards(atlas_parquet: str | Path = _ATLAS) -> list[Card]:
26
+ """One fact card per writer family, summarising its measured targeting + readiness, with DOIs."""
27
+ df = pd.read_parquet(atlas_parquet)
28
+ cards: list[Card] = []
29
+ for fam, sub in df.groupby("family"):
30
+ core = sub[sub["entry_kind"].isin(["curated_core", "curated_rep"])]
31
+ rep = core.iloc[0] if len(core) else sub.iloc[0]
32
+ dois: list[str] = []
33
+ for d in core["key_dois"] if len(core) else sub["key_dois"]:
34
+ dois.extend(str(x) for x in list(d) if str(x).strip())
35
+ text = (f"Writer family {fam}: representative {rep['representative_system']}; "
36
+ f"mechanism {rep.get('mechanism_bucket')}; targeting {rep.get('targeting_modality')}; "
37
+ f"reachability {rep.get('reachability_tier')}; deliverability {rep.get('deliv_class')}; "
38
+ f"cargo {rep.get('cargo_capacity_bp')} bp; human-cell activity: "
39
+ f"{rep.get('human_cell_activity')}. {len(sub):,} systems catalogued.")
40
+ cards.append(Card(key=fam, text=text, citations=sorted(set(dois))))
41
+ return cards
42
+
43
+
44
+ def retrieve(question: str, cards: list[Card], k: int = 3) -> list[Card]:
45
+ """Keyword overlap retriever (lower-cased token Jaccard). Deterministic, no model needed."""
46
+ q = set(_tok(question))
47
+ scored = [(len(q & set(_tok(c.text + " " + c.key))), c) for c in cards]
48
+ scored.sort(key=lambda x: x[0], reverse=True)
49
+ return [c for n, c in scored if n > 0][:k]
50
+
51
+
52
+ def _tok(s: str) -> list[str]:
53
+ return [w for w in "".join(ch.lower() if ch.isalnum() else " " for ch in s).split() if len(w) > 2]