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
pen_stack/rag/llm.py ADDED
@@ -0,0 +1,215 @@
1
+ """Provider-agnostic LLM layer for PEN-STACK services (RAG, agent, monitor).
2
+
3
+ Hybrid backend: a strong hosted model for reasoning/agent/Q&A (default NVIDIA Nemotron, OpenAI-compatible)
4
+ with automatic fallback to a local, free, private model (Ollama). The single switch is `configs/llm.yaml`.
5
+
6
+ This is strictly an orchestration/phrasing layer. Every quantitative claim and every citation still comes
7
+ from the deterministic validated-tool path; the LLM never introduces a number, gene, or citation. The
8
+ choice of model therefore does not affect scientific reproducibility - only the quality of orchestration
9
+ and prose. If no provider is reachable, the callers fall back to the deterministic answer (LLM optional).
10
+
11
+ Secrets: the API key is read from the env var named in `api_key_env`, then from the gitignored
12
+ `api_key_file`. Keys are NEVER committed.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import urllib.request
19
+ from pathlib import Path
20
+
21
+ import yaml
22
+
23
+ _CFG = Path(__file__).resolve().parents[2] / "configs" / "llm.yaml"
24
+ _ROOT = Path(__file__).resolve().parents[2]
25
+
26
+ _SYSTEM = ("You rephrase already-verified genome-writing facts into one clear paragraph for a wet-lab "
27
+ "scientist. Use ONLY the facts provided. Do NOT invent or alter any number, gene, or citation. "
28
+ "Do not give clinical advice. Keep it under 90 words.")
29
+
30
+
31
+ def load_llm_config(path: str | Path = _CFG) -> dict:
32
+ return yaml.safe_load(Path(path).read_text(encoding="utf-8"))
33
+
34
+
35
+ def _provider_cfg(cfg: dict, name: str) -> dict | None:
36
+ return (cfg.get("providers") or {}).get(name)
37
+
38
+
39
+ def _resolve_key(pcfg: dict) -> str | None:
40
+ env = pcfg.get("api_key_env")
41
+ if env and os.environ.get(env):
42
+ return os.environ[env].strip()
43
+ f = pcfg.get("api_key_file")
44
+ if f:
45
+ p = Path(f)
46
+ if not p.is_absolute():
47
+ p = _ROOT / f
48
+ if p.exists():
49
+ return p.read_text(encoding="utf-8").strip()
50
+ return pcfg.get("api_key")
51
+
52
+
53
+ def _norm_tool_calls(raw: list | None) -> list:
54
+ out = []
55
+ for c in raw or []:
56
+ fn = c.get("function", {})
57
+ args = fn.get("arguments", {})
58
+ if isinstance(args, str):
59
+ try:
60
+ args = json.loads(args or "{}")
61
+ except json.JSONDecodeError:
62
+ args = {}
63
+ out.append({"function": {"name": fn.get("name"), "arguments": args}})
64
+ return out
65
+
66
+
67
+ def _chat_openai(pcfg: dict, messages: list, tools: list | None, temperature: float,
68
+ timeout: int) -> dict | None:
69
+ """OpenAI-compatible /v1/chat/completions (NVIDIA NIM, OpenAI, vLLM, Ollama /v1)."""
70
+ base = pcfg["api_base"].rstrip("/")
71
+ key = _resolve_key(pcfg)
72
+ payload = {"model": pcfg["model"], "messages": messages, "temperature": temperature,
73
+ "max_tokens": int(pcfg.get("max_tokens", 1024))}
74
+ if tools:
75
+ payload["tools"] = tools
76
+ payload["tool_choice"] = "auto"
77
+ headers = {"Content-Type": "application/json"}
78
+ if key:
79
+ headers["Authorization"] = f"Bearer {key}"
80
+ req = urllib.request.Request(f"{base}/chat/completions", data=json.dumps(payload).encode(), headers=headers)
81
+ with urllib.request.urlopen(req, timeout=timeout) as r:
82
+ d = json.load(r)
83
+ msg = d["choices"][0]["message"]
84
+ return {"content": (msg.get("content") or "").strip(), "tool_calls": _norm_tool_calls(msg.get("tool_calls")),
85
+ "raw": msg, "style": "openai"}
86
+
87
+
88
+ def _chat_ollama(pcfg: dict, messages: list, tools: list | None, temperature: float,
89
+ timeout: int) -> dict | None:
90
+ """Ollama native /api/chat."""
91
+ base = pcfg["api_base"].rstrip("/")
92
+ payload = {"model": str(pcfg["model"]).split("/")[-1], "messages": messages, "stream": False,
93
+ "options": {"temperature": temperature}}
94
+ if tools:
95
+ payload["tools"] = tools
96
+ req = urllib.request.Request(f"{base}/api/chat", data=json.dumps(payload).encode(),
97
+ headers={"Content-Type": "application/json"})
98
+ with urllib.request.urlopen(req, timeout=timeout) as r:
99
+ d = json.load(r)
100
+ msg = d.get("message", {})
101
+ return {"content": (msg.get("content") or "").strip(), "tool_calls": _norm_tool_calls(msg.get("tool_calls")),
102
+ "raw": msg, "style": "ollama"}
103
+
104
+
105
+ def _chat_anthropic(pcfg: dict, messages: list, tools: list | None, temperature: float,
106
+ timeout: int) -> dict | None:
107
+ """Anthropic Messages API (/v1/messages). System prompts are a top-level field (not a message role),
108
+ and tool calls come back as `tool_use` content blocks; both are normalised to the common shape."""
109
+ base = pcfg.get("api_base", "https://api.anthropic.com/v1").rstrip("/")
110
+ key = _resolve_key(pcfg)
111
+ sys_txt = "\n".join(m["content"] for m in messages if m.get("role") == "system")
112
+ msgs = [{"role": m["role"], "content": m["content"]} for m in messages if m.get("role") != "system"]
113
+ payload = {"model": pcfg["model"], "max_tokens": int(pcfg.get("max_tokens", 1024)),
114
+ "temperature": temperature, "messages": msgs}
115
+ if sys_txt:
116
+ payload["system"] = sys_txt
117
+ if tools: # convert OpenAI-style function tools to Anthropic tool schema
118
+ payload["tools"] = [{"name": t["function"]["name"], "description": t["function"].get("description", ""),
119
+ "input_schema": t["function"].get("parameters", {"type": "object", "properties": {}})}
120
+ for t in tools]
121
+ headers = {"content-type": "application/json", "anthropic-version": "2023-06-01"}
122
+ if key:
123
+ headers["x-api-key"] = key
124
+ req = urllib.request.Request(f"{base}/messages", data=json.dumps(payload).encode(), headers=headers)
125
+ with urllib.request.urlopen(req, timeout=timeout) as r:
126
+ d = json.load(r)
127
+ content, tool_calls = "", []
128
+ for block in d.get("content", []):
129
+ if block.get("type") == "text":
130
+ content += block.get("text", "")
131
+ elif block.get("type") == "tool_use": # keep the tool_use id so the caller can build a tool_result
132
+ tool_calls.append({"id": block.get("id"),
133
+ "function": {"name": block.get("name"), "arguments": block.get("input", {})}})
134
+ # `raw` must be a valid Anthropic assistant message (role + content blocks) so a multi-turn tool loop can
135
+ # append it verbatim; the full response object would be rejected on the next call.
136
+ raw = {"role": "assistant", "content": d.get("content", [])}
137
+ return {"content": content.strip(), "tool_calls": tool_calls, "raw": raw, "style": "anthropic"}
138
+
139
+
140
+ def _call_provider(name: str, cfg: dict, messages: list, tools: list | None, timeout: int) -> dict | None:
141
+ pcfg = _provider_cfg(cfg, name)
142
+ if not pcfg:
143
+ return None
144
+ temp = float(cfg.get("temperature", 0.1))
145
+ style = pcfg.get("style", "openai")
146
+ try:
147
+ if style == "ollama":
148
+ return _chat_ollama(pcfg, messages, tools, temp, timeout)
149
+ if style == "anthropic":
150
+ return _chat_anthropic(pcfg, messages, tools, temp, timeout)
151
+ return _chat_openai(pcfg, messages, tools, temp, timeout)
152
+ except Exception: # noqa: BLE001 - any provider failure -> let the caller try the fallback
153
+ return None
154
+
155
+
156
+ # Cooldown cache: once a provider fails (e.g. Ollama not installed on the laptop tier), skip it for
157
+ # `health_ttl` seconds instead of re-attempting it on every call. This is what prevents the multi-minute
158
+ # stalls when a configured provider is absent/slow - we pay one failed attempt, then bypass it.
159
+ _COOLDOWN: dict[str, float] = {}
160
+
161
+
162
+ def chat(messages: list, tools: list | None = None, cfg: dict | None = None,
163
+ timeout: int | None = None) -> dict | None:
164
+ """Provider-agnostic chat. Tries the active provider, then the configured fallback, skipping any
165
+ provider in cooldown (recently unreachable). Returns {content, tool_calls, provider} or None if every
166
+ provider fails (callers then degrade deterministically - the LLM is non-load-bearing)."""
167
+ import time
168
+ cfg = cfg or load_llm_config()
169
+ timeout = timeout if timeout is not None else int(cfg.get("call_timeout", 60))
170
+ ttl = float(cfg.get("health_ttl", 120))
171
+ order = [cfg.get("provider", "nvidia")]
172
+ fb = cfg.get("fallback")
173
+ if fb and fb not in order:
174
+ order.append(fb)
175
+ now = time.time()
176
+ tried_any = False
177
+ for name in order:
178
+ if _COOLDOWN.get(name, 0) > now: # provider recently failed -> skip without waiting
179
+ continue
180
+ tried_any = True
181
+ res = _call_provider(name, cfg, messages, tools, timeout)
182
+ if res is not None:
183
+ res["provider"] = name
184
+ _COOLDOWN.pop(name, None)
185
+ return res
186
+ _COOLDOWN[name] = now + ttl # mark unreachable; don't retry for ttl seconds
187
+ if not tried_any: # every provider in cooldown -> one cheap retry of the first
188
+ name = order[0]
189
+ res = _call_provider(name, cfg, messages, tools, min(timeout, int(cfg.get("health_timeout", 8))))
190
+ if res is not None:
191
+ res["provider"] = name
192
+ _COOLDOWN.pop(name, None)
193
+ return res
194
+ return None
195
+
196
+
197
+ def active_provider(cfg: dict | None = None, timeout: int | None = None) -> str | None:
198
+ """Name of the first reachable provider (active, then fallback), or None. Uses the config `health_timeout`
199
+ by default so an absent provider is detected quickly (and then cooled down by chat())."""
200
+ cfg = cfg or load_llm_config()
201
+ timeout = timeout if timeout is not None else int(cfg.get("health_timeout", 8))
202
+ r = chat([{"role": "user", "content": "ok"}], cfg=cfg, timeout=timeout)
203
+ return r.get("provider") if r else None
204
+
205
+
206
+ def available(cfg: dict | None = None, timeout: int = 30) -> bool:
207
+ return active_provider(cfg, timeout) is not None
208
+
209
+
210
+ def phrase(facts: str, cfg: dict | None = None, timeout: int = 120) -> str | None:
211
+ """Rephrase grounded facts. Returns None on any failure (caller keeps the deterministic answer)."""
212
+ msgs = [{"role": "system", "content": _SYSTEM},
213
+ {"role": "user", "content": f"Facts:\n{facts}\n\nRephrase as one paragraph."}]
214
+ r = chat(msgs, cfg=cfg, timeout=timeout)
215
+ return (r.get("content") or None) if r else None
pen_stack/rag/qa.py ADDED
@@ -0,0 +1,105 @@
1
+ """Grounded, cited Q&A over the PEN-STACK platform.
2
+
3
+ The front door for non-expert users. Contract (enforced by pen_stack.agent.guardrails):
4
+ * clinical-directive questions are refused;
5
+ * every *quantitative* claim is produced by a validated tool call (writability / atlas / cross-link),
6
+ never guessed by the LLM - the answer's ``provenance`` block names the tool;
7
+ * every factual claim carries a citation (DOIs from the curated atlas/WT-KB).
8
+
9
+ An optional LLM (Ollama/Qwen via litellm) only *phrases* the grounded facts; it is never the source of a
10
+ number or a citation. With no LLM available the deterministic tool+retrieval path still satisfies the
11
+ contract - that is the whole point.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import re
16
+
17
+ from pen_stack.agent.guardrails import DISCLAIMER, enforce_grounded, out_of_scope
18
+ from pen_stack.rag.index import build_cards, retrieve
19
+
20
+ _GENE_RE = re.compile(r"\b([A-Z][A-Z0-9]{2,9})\b") # crude gene-symbol cue (TRAC, CCR5, ...)
21
+ _FAMILY_HINTS = {
22
+ "bridge": "bridge_IS110", "is110": "bridge_IS110", "iscro4": "bridge_IS110",
23
+ "seek": "seek_IS1111", "is1111": "seek_IS1111", "cast": "CAST_VK",
24
+ "integrase": "serine_integrase", "bxb1": "serine_integrase", "paste": "PE_integrase",
25
+ "prime": "PE_integrase", "cas9": "Cas9", "cas12a": "Cas12a", "tnpb": "TnpB_Fanzor",
26
+ "fanzor": "TnpB_Fanzor",
27
+ }
28
+ _WRITABLE_CUES = ("where", "writable", "safe harbour", "safe harbor", "insert", "insertion site", "locus")
29
+ # Standing citation for tool-derived writability claims: the Writable Genome atlas
30
+ # (TRIP durability supervision + clinical-CIS safety supervision).
31
+ _WRITABILITY_CITATIONS = ["10.1016/j.cell.2013.07.018"] # Akhtar 2013 (TRIP) - durability supervision
32
+
33
+
34
+ def _family_in(question: str) -> str | None:
35
+ q = question.lower()
36
+ for cue, fam in _FAMILY_HINTS.items():
37
+ if cue in q:
38
+ return fam
39
+ return None
40
+
41
+
42
+ def answer(question: str, ct: str = "k562", use_llm: bool = False) -> dict:
43
+ refusal = out_of_scope(question)
44
+ if refusal:
45
+ return {"refused": True, "answer": refusal, "citations": [], "provenance": [],
46
+ "disclaimer": DISCLAIMER}
47
+
48
+ cards = build_cards()
49
+ retrieved = retrieve(question, cards, k=3)
50
+ citations = sorted({d for c in retrieved for d in c.citations})
51
+ provenance: list[dict] = []
52
+ parts: list[str] = []
53
+
54
+ # --- numeric route 1: "where can I write / writable loci for GENE" -> writability tool ---
55
+ if any(cue in question.lower() for cue in _WRITABLE_CUES):
56
+ genes = [g for g in _GENE_RE.findall(question) if g not in {"PEN", "STACK", "DNA", "RNA"}]
57
+ if genes:
58
+ try:
59
+ from pen_stack.atlas.crosslink import loci_for_gene
60
+ g = loci_for_gene(genes[0], ct)
61
+ if not g.empty:
62
+ w = float(g["writability"].max())
63
+ provenance.append({"tool": "crosslink.loci_for_gene",
64
+ "args": {"gene": genes[0], "ct": ct},
65
+ "result": {"max_writability": round(w, 3), "n_bins": int(len(g))}})
66
+ parts.append(f"For {genes[0]} in {ct}, the most writable bin scores "
67
+ f"{w:.3f} (writability = safety x durability), across {len(g)} bins.")
68
+ citations = sorted(set(citations) | set(_WRITABILITY_CITATIONS))
69
+ except FileNotFoundError:
70
+ parts.append("(Writable-genome atlas not loaded; numeric writability unavailable.)")
71
+
72
+ # --- numeric route 2: "which writer / tell me about FAMILY" -> atlas tool ---
73
+ fam = _family_in(question)
74
+ if fam:
75
+ import pandas as pd
76
+
77
+ from pen_stack.rag.index import _ATLAS
78
+ adf = pd.read_parquet(_ATLAS)
79
+ sub = adf[adf["family"] == fam]
80
+ rep = sub[sub["entry_kind"] == "curated_core"]
81
+ rep = rep.iloc[0] if len(rep) else sub.iloc[0]
82
+ provenance.append({"tool": "atlas.query", "args": {"family": fam},
83
+ "result": {"n_systems": int(len(sub)),
84
+ "reachability_tier": rep.get("reachability_tier"),
85
+ "deliv_class": rep.get("deliv_class")}})
86
+ parts.append(f"{fam}: {len(sub):,} catalogued systems; reachability {rep.get('reachability_tier')}; "
87
+ f"deliverability {rep.get('deliv_class')}; representative {rep['representative_system']}.")
88
+
89
+ # --- factual route: retrieval-grounded summary (always cited) ---
90
+ if retrieved:
91
+ parts.append("Relevant atlas facts: " + " | ".join(c.text for c in retrieved[:2]))
92
+
93
+ if not parts:
94
+ parts.append("No grounded match in the atlas. Try naming a writer family or a target gene.")
95
+
96
+ out = {"refused": False, "answer": " ".join(parts), "citations": citations,
97
+ "provenance": provenance, "disclaimer": DISCLAIMER}
98
+ out = enforce_grounded(out)
99
+ # optional LLM phrasing - presentation only; numbers/citations stay tool-derived (additive field)
100
+ if use_llm:
101
+ from pen_stack.rag.llm import phrase
102
+ phrased = phrase(out["answer"])
103
+ if phrased:
104
+ out["answer_phrased"] = phrased
105
+ return out
@@ -0,0 +1,48 @@
1
+ """RAG retriever.
2
+
3
+ Loads the committed corpus + its committed embedding matrix and returns the top-k chunks for a query by EXACT
4
+ cosine over the embeddings (semantic, via the live query embedder), with a deterministic token-Jaccard fallback
5
+ when no embedder is reachable. Exact cosine over a committed matrix is the reproducible equivalent of a vector
6
+ index at this corpus scale (hundreds of chunks) - FAISS is unnecessary.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from functools import lru_cache
11
+
12
+ import numpy as np
13
+
14
+ from pen_stack.rag.corpus import emb_path, load_corpus
15
+ from pen_stack.rag.embed import embed_query, lexical_scores
16
+
17
+
18
+ @lru_cache(maxsize=1)
19
+ def _load():
20
+ df = load_corpus()
21
+ p = emb_path()
22
+ emb = None
23
+ if p.exists():
24
+ m = np.load(p).astype("float32")
25
+ if len(m) == len(df): # guard against a stale embedding matrix
26
+ emb = m
27
+ return df, emb
28
+
29
+
30
+ def retrieve(query: str, k: int = 4) -> dict:
31
+ df, emb = _load()
32
+ texts = df["text"].tolist()
33
+ qv = embed_query(query) if emb is not None else None # LRU-cached query embedding
34
+ if qv is not None and emb is not None:
35
+ scores = emb @ qv # both L2-normalised -> dot product is cosine
36
+ method = "semantic (nomic-embed-text)"
37
+ else:
38
+ scores = lexical_scores(query, texts)
39
+ method = "lexical (jaccard fallback)"
40
+ order = [int(i) for i in np.argsort(-scores)[:k]]
41
+ hits = []
42
+ for i in order:
43
+ r = df.iloc[i]
44
+ hits.append({"chunk_id": str(r.chunk_id), "text": str(r.text), "source_id": str(r.source_id),
45
+ "doi": str(r.doi) if r.doi else "", "type": str(r.type),
46
+ "scope_status": str(r.scope_status), "score": float(scores[i])})
47
+ return {"query": query, "method": method, "hits": hits,
48
+ "top_score": float(scores[order[0]]) if order else 0.0, "n_corpus": len(df)}
@@ -0,0 +1,9 @@
1
+ """Machine-readable rules engine for genome writing. Rules are versioned data in
2
+ configs/rules/*.yaml; evaluators (rules/evaluators.py) delegate to the existing validated functions; the
3
+ solver (rules/solver.py) returns legality + named reasons. Import evaluators so they register on load."""
4
+ from pen_stack.rules import evaluators as _evaluators # noqa: F401 (registers evaluators)
5
+ from pen_stack.rules.loader import load_ruleset
6
+ from pen_stack.rules.schema import Design, Rule, Ruleset
7
+ from pen_stack.rules.solver import evaluate, is_legal, legality_report
8
+
9
+ __all__ = ["load_ruleset", "Design", "Rule", "Ruleset", "evaluate", "is_legal", "legality_report"]