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,112 @@
1
+ """The PEN-STACK agent: tool-use orchestration.
2
+
3
+ Given a natural-language goal ("durably express factor IX in hepatocytes"), the agent plans the whole
4
+ write by calling validated tools (writability -> reachable writers -> writer axes -> plan_write -> cited
5
+ literature) in a tool-calling loop driven by the configured LLM (hybrid: NVIDIA Nemotron with Ollama
6
+ fallback, via ``pen_stack.rag.llm.chat``). Guardrails: it obtains numbers ONLY from tool calls (no
7
+ free-text predictions), refuses clinical-directive prompts, and logs an auditable trace.
8
+
9
+ Graceful: if no LLM provider is reachable, ``run_agent`` returns a refusal-free deterministic fallback
10
+ that calls plan_write directly - so the platform degrades to the validated pipeline rather than failing.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from pathlib import Path
16
+
17
+ from pen_stack.agent.guardrails import DISCLAIMER, out_of_scope
18
+ from pen_stack.agent.tools import SCHEMAS, dispatch
19
+ from pen_stack.rag.llm import chat as llm_chat
20
+
21
+ _TRACES = Path(__file__).resolve().parents[2] / "out" / "agent_traces"
22
+
23
+ _SYSTEM = (
24
+ "You are the PEN-STACK genome-writing planning agent. You MUST obtain every fact and number by "
25
+ "calling the provided tools - never invent a number, gene, score, or citation. Plan a write by "
26
+ "calling: writability, reachable_writers, writer_axes, plan_write, ask_literature. When you have "
27
+ "enough tool results, write a short plan that cites which tool produced each number. Decision-support "
28
+ "only - never give clinical directives.")
29
+
30
+
31
+ def _tool_response(style: str, call_id: str | None, content: str) -> dict:
32
+ """Format a tool-result message for the provider's API style."""
33
+ if style == "anthropic": # Anthropic returns tool results as a user-turn tool_result content block
34
+ block = {"type": "tool_result", "content": content}
35
+ if call_id is not None:
36
+ block["tool_use_id"] = call_id
37
+ return {"role": "user", "content": [block]}
38
+ m = {"role": "tool", "content": content}
39
+ if style == "openai" and call_id is not None:
40
+ m["tool_call_id"] = call_id
41
+ return m
42
+
43
+
44
+ def run_agent(goal: str, max_steps: int = 12, cfg: dict | None = None) -> dict:
45
+ """Turn a goal into a cited, auditable plan. Numbers come only from tool calls."""
46
+ refusal = out_of_scope(goal)
47
+ if refusal:
48
+ return {"refused": True, "plan": refusal, "trace": [], "disclaimer": DISCLAIMER}
49
+
50
+ from pen_stack.rag.llm import load_llm_config
51
+ step_timeout = int((cfg or load_llm_config()).get("agent_call_timeout", 60))
52
+ msgs = [{"role": "system", "content": _SYSTEM}, {"role": "user", "content": goal}]
53
+ trace: list[dict] = []
54
+ seen: set = set()
55
+
56
+ for _ in range(max_steps):
57
+ resp = llm_chat(msgs, tools=SCHEMAS, cfg=cfg, timeout=step_timeout)
58
+ if resp is None:
59
+ return _fallback(goal, trace)
60
+ provider, style = resp.get("provider"), resp.get("style", "openai")
61
+ calls = resp.get("tool_calls") or []
62
+ if not calls:
63
+ return {"refused": False, "plan": resp.get("content", "").strip(),
64
+ "trace": trace, "disclaimer": DISCLAIMER, "llm": True, "provider": provider}
65
+ msgs.append(resp["raw"]) # append the assistant turn verbatim
66
+ raw_calls = resp["raw"].get("tool_calls") or []
67
+ for i, c in enumerate(calls):
68
+ name = c["function"]["name"]
69
+ args = c["function"]["arguments"]
70
+ # anthropic carries the id on the normalised call; openai carries it on the raw tool_call
71
+ call_id = c.get("id") or (raw_calls[i].get("id") if i < len(raw_calls) else None)
72
+ key = f"{name}:{json.dumps(args, sort_keys=True, default=str)}"
73
+ if key in seen:
74
+ msgs.append(_tool_response(style, call_id, json.dumps(
75
+ {"note": "already called with these args; use prior result and finalise the plan"})))
76
+ continue
77
+ seen.add(key)
78
+ try:
79
+ result = dispatch(name, args) # VALIDATED tool only
80
+ except Exception as e: # noqa: BLE001
81
+ result = {"error": str(e)}
82
+ trace.append({"tool": name, "args": args, "result": result})
83
+ msgs.append(_tool_response(style, call_id, json.dumps(result, default=str)))
84
+ return {"refused": False, "plan": "(max steps reached)", "trace": trace,
85
+ "disclaimer": DISCLAIMER, "llm": True}
86
+
87
+
88
+ def _fallback(goal: str, trace: list[dict]) -> dict:
89
+ """Deterministic fallback when no LLM is reachable: call plan_write on a best-effort parse."""
90
+ from pen_stack.planner.optimize import EditIntent
91
+ gene = next((w for w in goal.replace(",", " ").split() if w.isupper() and len(w) >= 2), None)
92
+ intent = EditIntent.SAFE_HARBOUR.value
93
+ for kw, it in [("disrupt", "knock_in_with_disruption"), ("knock", "knock_in_with_disruption"),
94
+ ("durab", "high_durability_insertion"), ("enhancer", "regulatory_excision"),
95
+ ("repeat", "repeat_excision")]:
96
+ if kw in goal.lower():
97
+ intent = it
98
+ break
99
+ if not gene:
100
+ return {"refused": False, "plan": "No target gene detected; LLM unavailable.",
101
+ "trace": trace, "disclaimer": DISCLAIMER, "llm": False}
102
+ res = dispatch("plan_write", {"gene": gene, "intent": intent})
103
+ trace.append({"tool": "plan_write", "args": {"gene": gene, "intent": intent}, "result": res})
104
+ return {"refused": False, "plan": f"[deterministic fallback] plan for {gene} ({intent})",
105
+ "trace": trace, "disclaimer": DISCLAIMER, "llm": False}
106
+
107
+
108
+ def save_trace(result: dict, name: str) -> Path:
109
+ _TRACES.mkdir(parents=True, exist_ok=True)
110
+ p = _TRACES / f"{name}.json"
111
+ p.write_text(json.dumps(result, indent=2, default=str), encoding="utf-8")
112
+ return p
@@ -0,0 +1,56 @@
1
+ """Live foundation-model orchestration.
2
+
3
+ A reasoning loop that GENERATES grounded candidates, calls oracles (cache-first / replayable) for a critique
4
+ signal, and disposes via `verify()` (safety + legality + immune). The agent picks WHICH oracle to call; the
5
+ NUMBER always comes from the oracle/tool, never invented. Live calls are cache-keyed and version-pinned, so a
6
+ seed-locked replay reproduces a run from the committed cache (replay is the CI default).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+
13
+ def _oracle_critique(design: dict, *, seed: int = 0) -> dict:
14
+ """A grounded critique number for the top candidate, sourced from an oracle adapter (cache-first). When the
15
+ candidate carries a writer sequence, use the structure-consistency oracle; otherwise abstain (no fabricated
16
+ value). The returned value/uncertainty come from the OracleResult, never the agent."""
17
+ seq = design.get("writer_candidate_seq") or design.get("writer_seq")
18
+ if not seq:
19
+ return {"oracle": None, "value": None, "available": False, "note": "no sequence supplied; abstaining"}
20
+ from pen_stack.oracles.structure import consistency
21
+ r = consistency(seq)
22
+ return {"oracle": "structure.consistency", "value": (r.value or {}),
23
+ "available": bool(r.available), "cached": bool(getattr(r, "cached", False)),
24
+ "source": getattr(r, "source", None), "output_kind": r.output_kind}
25
+
26
+
27
+ def orchestrate(goal: dict, *, candidates: list[dict] | None = None, max_rounds: int = 4,
28
+ seed: int = 0) -> dict[str, Any]:
29
+ """Plan -> generate grounded candidates -> call an oracle (cache-first) -> critique via verify() -> refine.
30
+ Returns the chosen design + a trace in which every number is tool-sourced. Deterministic given the inputs +
31
+ seed (replayable from cache). No stage fabricates a value."""
32
+ from pen_stack.design.generate import generate_designs
33
+ from pen_stack.verify import verify
34
+
35
+ state: dict[str, Any] = {"goal": goal, "design": None, "candidates": []}
36
+ trace: list[dict] = []
37
+ for r in range(max_rounds):
38
+ cands = generate_designs(goal, candidates=candidates, keep=5, actor="orchestrator")
39
+ if not cands:
40
+ trace.append({"round": r, "action": "generate", "n": 0,
41
+ "note": "no surviving candidates (atlas absent or all discarded by the discriminator)"})
42
+ break
43
+ top = cands[0]
44
+ oracle = _oracle_critique(top, seed=seed)
45
+ v = verify(dict(top), actor="orchestrator")
46
+ trace.append({"round": r, "action": "generate+oracle+verify", "n": len(cands),
47
+ "oracle": oracle, "verdict": v.summary(), "legal": v.legal,
48
+ "safety": (v.safety.decision if v.safety is not None else None),
49
+ "confidence": v.confidence})
50
+ state["design"], state["candidates"] = top, cands
51
+ if v.legal is True and v.safety is not None and v.safety.decision in ("clear", "flag"):
52
+ break
53
+ # refine: on a non-passing round, narrow the pool to the survivors for the next iteration
54
+ candidates = cands
55
+ return {"goal": goal, "design": state["design"], "trace": trace,
56
+ "no_fabrication": True, "replayable": True}
@@ -0,0 +1,242 @@
1
+ """PEN-Agent - grounded write-planning state machine.
2
+
3
+ A deterministic task-state machine over the VALIDATED tools. It sequences a genome-write plan:
4
+
5
+ goal intake -> site selection (writability) -> writer selection (reachability) ->
6
+ cargo design (+ Cargo Polish) -> off-target -> 3D structural risk -> report
7
+
8
+ Core property (the contribution): NO FABRICATION. Every number in the output is copied verbatim from a
9
+ tool-result dict and tagged with that tool's provenance; a step whose tool cannot ground a value is marked
10
+ `degraded`/`refused`, never invented. The agent therefore runs end-to-end even when AlphaGenome or the
11
+ bridge engine is unavailable - those steps degrade with a reason instead of guessing.
12
+
13
+ The LLM (agent/orchestrator.py) is an optional conversational front-end over this same machine; the plan
14
+ itself is deterministic, so the result is reproducible and the no-fabrication guarantee holds with or
15
+ without an LLM. Modes: "automatic" (run all steps), "guided" (stop after each step), "qa" (single tool).
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass, field
20
+
21
+ from pen_stack.agent import tools as T
22
+
23
+
24
+ @dataclass
25
+ class Step:
26
+ name: str
27
+ tool: str | None
28
+ status: str # ok | degraded | refused
29
+ provenance: str | None = None
30
+ result: dict = field(default_factory=dict)
31
+ reason: str | None = None
32
+
33
+
34
+ def _site_selection(gene: str, ct: str) -> Step:
35
+ try:
36
+ r = T.writability(gene, ct)
37
+ except Exception as e: # noqa: BLE001 - missing atlas -> refuse, never fabricate
38
+ return Step("site_selection", "wgenome.writability", "refused", reason=f"{type(e).__name__}: {e}")
39
+ if not r.get("found"):
40
+ return Step("site_selection", r.get("tool"), "refused", reason=f"no writable locus for {gene}")
41
+ return Step("site_selection", r["tool"], "ok", provenance=r["tool"],
42
+ result={"max_writability": r["max_writability"], "safety": r["safety"],
43
+ "p_durable": r["p_durable"], "n_bins": r["n_bins"]})
44
+
45
+
46
+ def _writer_selection(gene: str, intent: str, cargo_bp: int, ct: str) -> tuple[Step, dict]:
47
+ try:
48
+ plan = T.plan_write(gene, intent, cargo_bp, ct)
49
+ except Exception as e: # noqa: BLE001 - missing atlas -> refuse, never fabricate
50
+ return Step("writer_selection", "planner.pipeline", "refused",
51
+ reason=f"{type(e).__name__}: {e}"), {}
52
+ if not plan or plan.get("found") is False:
53
+ return Step("writer_selection", "planner.pipeline", "refused",
54
+ reason="planner returned no plan"), {}
55
+ fam = plan.get("writer") or plan.get("writer_family") or plan.get("family")
56
+ return (Step("writer_selection", "planner.pipeline", "ok", provenance="planner.pipeline",
57
+ result={"writer_family": fam, "score": plan.get("score"),
58
+ "site": {k: plan.get(k) for k in ("chrom", "bin") if k in plan}}),
59
+ plan)
60
+
61
+
62
+ def _cargo_design(plan: dict, cargo_bp: int, ct: str, payload_seq: str | None) -> Step:
63
+ from pen_stack.planner.cargo import design_cargo
64
+ fam = plan.get("writer") or plan.get("writer_family") or plan.get("family")
65
+ wr = {"family": fam, "cargo_capacity_bp": plan.get("cargo_capacity_bp"),
66
+ "deliv_class": plan.get("deliv_class")}
67
+ site = (plan.get("chrom"), plan.get("bin"))
68
+ cargo = design_cargo(cargo_bp, wr, site, ct, payload_seq=payload_seq)
69
+ res = {"assembled_bp": cargo["assembled_bp"], "size_ok": cargo["size_ok"]}
70
+ if "cargo_polish" in cargo:
71
+ res["cargo_durability_risk"] = cargo["cargo_polish"]["cargo_durability_risk"]
72
+ res["cargo_band"] = cargo["cargo_polish"]["band"]
73
+ res["cargo_suggestions"] = [f["suggestion"] for f in cargo["cargo_polish"]["flags"]]
74
+ return Step("cargo_design", "planner.cargo", "ok", provenance="planner.cargo+cargo_polish", result=res)
75
+
76
+
77
+ def _offtarget(plan: dict) -> Step:
78
+ fam = plan.get("writer") or plan.get("writer_family") or plan.get("family") or ""
79
+ if "bridge" not in str(fam).lower() and "seek" not in str(fam).lower():
80
+ return Step("offtarget", None, "degraded", reason=f"off-target engine applies to bridge/seek writers, not {fam}")
81
+ try:
82
+ from pen_stack.bridge.offtarget import predict_offtargets
83
+ r = predict_offtargets(fam, (plan.get("chrom"), plan.get("bin")))
84
+ except Exception as e: # noqa: BLE001
85
+ return Step("offtarget", "bridge.offtarget", "degraded", reason=f"{type(e).__name__}: {e}")
86
+ if isinstance(r, dict) and r.get("status", "").startswith("pending"):
87
+ return Step("offtarget", "bridge.offtarget", "degraded", reason=r.get("note"))
88
+ return Step("offtarget", "bridge.offtarget", "ok", provenance="bridge.offtarget",
89
+ result=r if isinstance(r, dict) else {"offtargets": r})
90
+
91
+
92
+ def _structural_risk(plan: dict) -> Step:
93
+ chrom, b = plan.get("chrom"), plan.get("bin")
94
+ if chrom is None or b is None:
95
+ return Step("structural_risk", None, "degraded", reason="no concrete site coordinates")
96
+ try:
97
+ from pen_stack.wgenome.structure3d import structural_risk
98
+ r = structural_risk(chrom, int(b) * 1000, int(b) * 1000 + 135_000, offline=True)
99
+ except Exception as e: # noqa: BLE001
100
+ return Step("structural_risk", "wgenome.structure3d", "degraded", reason=f"{type(e).__name__}: {e}")
101
+ if not r.get("available"):
102
+ return Step("structural_risk", "wgenome.structure3d", "degraded",
103
+ reason="AlphaGenome contact map not cached (offline); flag with confidence")
104
+ return Step("structural_risk", "wgenome.structure3d", "ok", provenance="wgenome.structure3d", result=r)
105
+
106
+
107
+ def _plan_confidence(s_site: Step, ood_factor: float = 1.0) -> dict:
108
+ """Plan-level confidence from the grounded site step's safety + durability, widened by OOD.
109
+
110
+ Reuses the uncertainty-quantification Monte-Carlo propagation; abstention is confidence below the threshold. Returns a
111
+ neutral 'no grounded site' verdict when the site step refused (then the session is not-computable anyway).
112
+ """
113
+ from pen_stack.agent.epistemic import ABSTAIN_CONFIDENCE
114
+ from pen_stack.validate.selective_prediction import propagate_plan_confidence
115
+ if s_site.status != "ok":
116
+ return {"confidence": None, "abstained": False, "ood_factor": ood_factor}
117
+ safety = float(s_site.result.get("safety", 0.5))
118
+ p_dur = float(s_site.result.get("p_durable", 0.5))
119
+ hw = 0.10 * ood_factor
120
+ axes = {"safety": {"point": safety, "lo": max(0.0, safety - hw), "hi": min(1.0, safety + hw)},
121
+ "durability": {"point": p_dur, "lo": max(0.0, p_dur - 1.5 * hw), "hi": min(1.0, p_dur + 1.5 * hw)}}
122
+ prop = propagate_plan_confidence(axes, {"safety": 0.5, "durability": 0.5}, threshold=0.5)
123
+ return {"confidence": round(prop["confidence"], 4), "interval": [prop["lo"], prop["hi"]],
124
+ "abstained": bool(prop["confidence"] < ABSTAIN_CONFIDENCE), "ood_factor": ood_factor}
125
+
126
+
127
+ def plan_write_session(gene: str, intent: str, cargo_bp: int = 2000, ct: str = "k562",
128
+ payload_seq: str | None = None, mode: str = "automatic",
129
+ question: str | None = None, ood_factor: float = 1.0) -> dict:
130
+ """Run the grounded write-planning state machine. Returns steps with provenance, a no-fabrication audit,
131
+ and a per-step + session-level epistemic status with abstention.
132
+
133
+ If ``question`` is supplied and matches a known-unknown (configs/known_unknowns.yaml), the session defers
134
+ immediately (status not-computable, zero fabrication) instead of planning, the out-of-scope arm of trust.
135
+ """
136
+ from pen_stack.agent.epistemic import classify_step, summarize
137
+ from pen_stack.agent.guardrails import DISCLAIMER
138
+
139
+ # out-of-scope deferral (a known-unknown is never planned, never guessed)
140
+ if question is not None:
141
+ from pen_stack.agent.scope import match_scope
142
+ oos = match_scope(question)
143
+ if oos:
144
+ verdict = classify_step("refused", out_of_scope=True)
145
+ return {"goal": {"gene": gene, "intent": intent, "cargo_bp": cargo_bp, "ct": ct, "mode": mode},
146
+ "steps": [], "provenance": {}, "degraded_modes": [], "refusals": [],
147
+ "no_fabrication": True, "completed": False, "out_of_scope": oos,
148
+ "epistemic_summary": summarize([verdict]), "abstained": True,
149
+ "disclaimer": DISCLAIMER}
150
+
151
+ steps: list[Step] = []
152
+ s_site = _site_selection(gene, ct)
153
+ steps.append(s_site)
154
+ plan: dict = {}
155
+ if s_site.status == "ok":
156
+ s_writer, plan = _writer_selection(gene, intent, cargo_bp, ct)
157
+ steps.append(s_writer)
158
+ if s_writer.status == "ok":
159
+ steps.append(_cargo_design(plan, cargo_bp, ct, payload_seq))
160
+ steps.append(_offtarget(plan))
161
+ steps.append(_structural_risk(plan))
162
+ if mode == "guided":
163
+ steps = steps[:2] # guided mode pauses after writer selection
164
+
165
+ grounded = [s for s in steps if s.status == "ok"]
166
+ degraded = [{"step": s.name, "reason": s.reason} for s in steps if s.status == "degraded"]
167
+ refused = [{"step": s.name, "reason": s.reason} for s in steps if s.status == "refused"]
168
+ # no-fabrication audit: every 'ok' step carries provenance for its numbers; nothing is free-text generated
169
+ no_fabrication = all(s.provenance for s in grounded)
170
+
171
+ # The agent submits its own plan to the rule-grounded verifier before returning it.
172
+ # An illegal plan is surfaced with the named rule reason (the agent revises/refuses; it never fabricates).
173
+ verification = None
174
+ if plan.get("writer") or plan.get("writer_family") or plan.get("family"):
175
+ try:
176
+ from pen_stack.verify import verify
177
+ fam = plan.get("writer") or plan.get("writer_family") or plan.get("family")
178
+ v = verify({"write_type": "insertion", "writer_family": fam, "cargo_bp": cargo_bp,
179
+ "cell_type": ct, "edit_intent": intent,
180
+ "delivery_vehicle": plan.get("delivery") or plan.get("delivery_vehicle")})
181
+ verification = {"legal": v.legal, "violations": v.violations,
182
+ "epistemic_status": v.epistemic_status, "no_fabrication": v.no_fabrication}
183
+ except Exception as e: # noqa: BLE001 - verifier unavailable -> no verdict, never fabricate
184
+ verification = {"legal": None, "reason": f"verifier unavailable: {type(e).__name__}"}
185
+
186
+ # tag every step with an epistemic verdict driven by grounding + OOD; plan-level abstention
187
+ pc = _plan_confidence(s_site, ood_factor=ood_factor)
188
+ step_dicts = []
189
+ for s in steps:
190
+ d = vars(s)
191
+ conf = pc["confidence"] if s.name == "site_selection" else None
192
+ d["epistemic"] = classify_step(s.status, confidence=conf, ood_factor=ood_factor)
193
+ step_dicts.append(d)
194
+ return {
195
+ "goal": {"gene": gene, "intent": intent, "cargo_bp": cargo_bp, "ct": ct, "mode": mode},
196
+ "steps": step_dicts,
197
+ "provenance": {s.name: s.provenance for s in grounded},
198
+ "degraded_modes": degraded,
199
+ "refusals": refused,
200
+ "no_fabrication": no_fabrication,
201
+ "completed": bool(grounded) and not refused,
202
+ "plan_confidence": pc["confidence"],
203
+ "abstained": pc["abstained"],
204
+ "epistemic_summary": summarize([d["epistemic"] for d in step_dicts]),
205
+ "verification": verification,
206
+ "disclaimer": DISCLAIMER,
207
+ }
208
+
209
+
210
+ _AUDIT_GOALS = [("TRAC", "knock_in_with_disruption"),
211
+ ("HBB", "high_durability_insertion"),
212
+ ("AAVS1", "safe_harbour_insertion"),
213
+ ("CCR5", "safe_harbour_insertion"),
214
+ ("HBG1", "regulatory_excision"),
215
+ ("PDCD1", "knock_in_with_disruption"),
216
+ ("FXN", "repeat_excision"),
217
+ ("CLYBL", "high_durability_insertion")]
218
+
219
+
220
+ def no_fabrication_audit(goals: list[tuple[str, str]] | None = None) -> dict:
221
+ """Deterministic no-fabrication HARD GATE for the bench (T6) - NO LLM, so it never hangs and is always
222
+ available. The state machine copies every number from a tool-result dict, so fabrication is impossible by
223
+ construction; the audit confirms that every grounded ('ok') step carries provenance and that no step
224
+ emits an ungrounded value. Without the atlas the steps refuse (still no fabrication = pass)."""
225
+ goals = goals or _AUDIT_GOALS
226
+ runs = [plan_write_session(g, i) for g, i in goals]
227
+ per_goal = []
228
+ for (g, i), r in zip(goals, runs):
229
+ ok_steps = [s for s in r["steps"] if s["status"] == "ok"]
230
+ clean = r["no_fabrication"] and all(s["provenance"] for s in ok_steps)
231
+ per_goal.append({"gene": g, "intent": i, "no_fabrication": bool(clean),
232
+ "grounded_steps": len(ok_steps), "completed": r["completed"]})
233
+ n_fab = sum(0 if p["no_fabrication"] else 1 for p in per_goal)
234
+ return {"available": True, "n_goals": len(goals), "n_fabricated": n_fab,
235
+ "all_no_fabrication_pass": n_fab == 0,
236
+ "n_grounded": sum(p["completed"] for p in per_goal), "per_goal": per_goal,
237
+ "method": "deterministic pen_agent state machine (no LLM); fabrication impossible by construction"}
238
+
239
+
240
+ if __name__ == "__main__": # pragma: no cover
241
+ import json
242
+ print(json.dumps(no_fabrication_audit(), indent=2, default=str))
@@ -0,0 +1,60 @@
1
+ """Scope matcher over the known-unknowns registry.
2
+
3
+ Matches an incoming question against `configs/known_unknowns.yaml` and, on a hit, returns a structured
4
+ deferral ("this requires X, which PEN-STACK does not model") instead of letting the agent guess. This is the
5
+ *out-of-scope* arm of trustworthiness, distinct from the clinical-directive refusal in `agent/guardrails.py`
6
+ (which this complements): guardrails refuse *clinical advice*; the scope matcher defers *biology beyond any
7
+ tool here* (the unknown funnel: structure→phenotype, in-vivo immunogenicity, long-term durability, epistasis,
8
+ polygenic, germline).
9
+
10
+ Deterministic substring + regex matching (no LLM) so the deferral is reproducible and testable. The result
11
+ feeds `agent.epistemic.classify(out_of_scope=True)` → status `not-computable` with zero fabrication.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from functools import lru_cache
17
+ from pathlib import Path
18
+
19
+ import yaml
20
+
21
+ from pen_stack._resources import resource
22
+
23
+ _CFG = "configs/known_unknowns.yaml"
24
+
25
+
26
+ @lru_cache(maxsize=1)
27
+ def load_registry(path: str | Path | None = None) -> list[dict]:
28
+ p = Path(path) if path else resource(_CFG)
29
+ data = yaml.safe_load(Path(p).read_text(encoding="utf-8"))
30
+ return data.get("known_unknowns", [])
31
+
32
+
33
+ def match_scope(question: str, registry: list[dict] | None = None) -> dict | None:
34
+ """Return a structured deferral if the question hits a known-unknown, else None (in scope).
35
+
36
+ A hit = any `match_terms` substring (lowercased) OR any `patterns` regex matches. Returns the matched
37
+ entry's id/title/requires/why + a ready-to-surface deferral message.
38
+ """
39
+ reg = registry if registry is not None else load_registry()
40
+ q = question.lower()
41
+ for entry in reg:
42
+ hit_term = next((t for t in entry.get("match_terms", []) if t in q), None)
43
+ hit_pat = None
44
+ if not hit_term:
45
+ for pat in entry.get("patterns", []):
46
+ if re.search(pat, q, re.IGNORECASE):
47
+ hit_pat = pat
48
+ break
49
+ if hit_term or hit_pat:
50
+ return {"out_of_scope": True, "id": entry["id"], "title": entry["title"],
51
+ "requires": entry["requires"], "why": entry["why"],
52
+ "matched_on": hit_term or f"pattern:{hit_pat}",
53
+ "deferral": (f"This question is out of scope for PEN-STACK: it concerns "
54
+ f"{entry['title']}, which requires {entry['requires']}. "
55
+ f"{entry['why']}. PEN-STACK does not model this and will not guess a value.")}
56
+ return None
57
+
58
+
59
+ def is_out_of_scope(question: str) -> bool:
60
+ return match_scope(question) is not None
@@ -0,0 +1,130 @@
1
+ """PEN-STACK agent tools: the validated capabilities the agent may call.
2
+
3
+ Each tool wraps a *validated* module function and returns a JSON-serialisable, provenance-tagged result.
4
+ The agent may obtain numbers ONLY by calling these - never by free-text generation (the no-fabrication
5
+ guarantee, enforced by the eval harness). Schemas are the Ollama/OpenAI tool-calling format.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+
12
+ def writability(gene: str, ct: str = "k562") -> dict:
13
+ """Most-writable locus near a gene (safety x durability)."""
14
+ from pen_stack.atlas.crosslink import loci_for_gene
15
+ g = loci_for_gene(gene, ct)
16
+ if g.empty:
17
+ return {"gene": gene, "ct": ct, "found": False, "tool": "wgenome.writability"}
18
+ top = g.sort_values("writability", ascending=False).iloc[0]
19
+ return {"gene": gene, "ct": ct, "found": True,
20
+ "max_writability": round(float(top["writability"]), 4),
21
+ "safety": round(float(top["safety"]), 4),
22
+ "p_durable": round(float(top["p_durable"]), 4),
23
+ "n_bins": int(len(g)), "tool": "wgenome.writability"}
24
+
25
+
26
+ def reachable_writers(gene: str, ct: str = "k562") -> dict:
27
+ """Writer families that can reach a gene's most-writable locus."""
28
+ from pen_stack.atlas.crosslink import loci_for_gene, writers_for_locus
29
+ g = loci_for_gene(gene, ct)
30
+ if g.empty:
31
+ return {"gene": gene, "found": False, "tool": "atlas.crosslink"}
32
+ top = g.sort_values("writability", ascending=False).iloc[0]
33
+ w = writers_for_locus(top["chrom"], int(top["bin"]), ct)
34
+ return {"gene": gene, "ct": ct, "found": True,
35
+ "families": sorted(set(w["family"])) if not w.empty else [],
36
+ "tool": "atlas.crosslink"}
37
+
38
+
39
+ def writer_axes(family: str) -> dict:
40
+ """Measured axes for a writer family (cargo, deliverability, reachability, readiness)."""
41
+ import pandas as pd
42
+
43
+ from pen_stack.rag.index import _ATLAS
44
+ atlas = pd.read_parquet(_ATLAS)
45
+ sub = atlas[atlas["family"] == family]
46
+ if sub.empty:
47
+ return {"family": family, "found": False, "tool": "atlas.score"}
48
+ core = sub[sub["entry_kind"] == "curated_core"]
49
+ r = core.iloc[0] if len(core) else sub.iloc[0]
50
+ return {"family": family, "found": True, "n_systems": int(len(sub)),
51
+ "reachability_tier": r.get("reachability_tier"),
52
+ "cargo_capacity_bp": (int(r["cargo_capacity_bp"]) if pd.notna(r.get("cargo_capacity_bp")) else None),
53
+ "deliv_class": r.get("deliv_class"), "tool": "atlas.score"}
54
+
55
+
56
+ def plan_write(gene: str, intent: str, cargo_bp: int = 2000, ct: str = "k562") -> dict:
57
+ """Full Write Planner: goal + edit_intent -> top ranked, traceable plan."""
58
+ from pen_stack.planner.optimize import EditIntent
59
+ from pen_stack.planner.pipeline import plan_write as _pw
60
+ plans = _pw(gene, EditIntent(intent), cargo_bp, ct, k=1)
61
+ return (plans[0] if plans else {"gene": gene, "found": False}) | {"tool": "planner.pipeline"}
62
+
63
+
64
+ def ask_literature(q: str) -> dict:
65
+ """Grounded, cited literature answer (numbers still from tools)."""
66
+ from pen_stack.rag.qa import answer
67
+ a = answer(q)
68
+ return {"answer": a["answer"], "citations": a["citations"], "tool": "rag.qa"}
69
+
70
+
71
+ def multiplex_translocation_risk(edits: list[dict]) -> dict:
72
+ """Translocation-risk SCREEN for a multi-edit (2-5) plan: pairwise DSB-join risk across edits.
73
+
74
+ Each edit: {name, family, chrom, pos, optional offtargets:[{chrom,pos,risk}]}. DSB-free recombinase
75
+ writers contribute zero risk. A screen, not a calibrated predictor."""
76
+ from pen_stack.planner.multiplex import translocation_risk
77
+ return {**translocation_risk(edits), "tool": "planner.multiplex"}
78
+
79
+
80
+ REGISTRY = {
81
+ "writability": writability,
82
+ "reachable_writers": reachable_writers,
83
+ "writer_axes": writer_axes,
84
+ "plan_write": plan_write,
85
+ "ask_literature": ask_literature,
86
+ "multiplex_translocation_risk": multiplex_translocation_risk,
87
+ }
88
+
89
+ # Ollama/OpenAI tool-calling schemas
90
+ SCHEMAS = [
91
+ {"type": "function", "function": {
92
+ "name": "writability", "description": "Most-writable locus near a gene (safety x durability).",
93
+ "parameters": {"type": "object", "properties": {
94
+ "gene": {"type": "string"}, "ct": {"type": "string", "enum": ["k562", "hepg2", "hspc"]}},
95
+ "required": ["gene"]}}},
96
+ {"type": "function", "function": {
97
+ "name": "reachable_writers", "description": "Writer families that can reach a gene's best locus.",
98
+ "parameters": {"type": "object", "properties": {
99
+ "gene": {"type": "string"}, "ct": {"type": "string"}}, "required": ["gene"]}}},
100
+ {"type": "function", "function": {
101
+ "name": "writer_axes", "description": "Measured axes for a writer family.",
102
+ "parameters": {"type": "object", "properties": {"family": {"type": "string"}},
103
+ "required": ["family"]}}},
104
+ {"type": "function", "function": {
105
+ "name": "plan_write", "description": "Full Write Planner: gene + edit_intent -> ranked plan.",
106
+ "parameters": {"type": "object", "properties": {
107
+ "gene": {"type": "string"},
108
+ "intent": {"type": "string", "enum": ["safe_harbour_insertion", "knock_in_with_disruption",
109
+ "high_durability_insertion", "regulatory_excision", "repeat_excision"]},
110
+ "cargo_bp": {"type": "integer"}, "ct": {"type": "string"}},
111
+ "required": ["gene", "intent"]}}},
112
+ {"type": "function", "function": {
113
+ "name": "ask_literature", "description": "Grounded, cited literature answer.",
114
+ "parameters": {"type": "object", "properties": {"q": {"type": "string"}}, "required": ["q"]}}},
115
+ {"type": "function", "function": {
116
+ "name": "multiplex_translocation_risk",
117
+ "description": "Translocation-risk screen for a multi-edit plan (pairwise DSB-join risk).",
118
+ "parameters": {"type": "object", "properties": {
119
+ "edits": {"type": "array", "items": {"type": "object", "properties": {
120
+ "name": {"type": "string"}, "family": {"type": "string"},
121
+ "chrom": {"type": "string"}, "pos": {"type": "integer"}}}}},
122
+ "required": ["edits"]}}},
123
+ ]
124
+
125
+
126
+ def dispatch(name: str, args: dict) -> Any:
127
+ """Execute a validated tool by name. Raises KeyError for unknown tools (never fabricates)."""
128
+ if name not in REGISTRY:
129
+ raise KeyError(f"unknown tool: {name}")
130
+ return REGISTRY[name](**args)
@@ -0,0 +1,12 @@
1
+ """pen_stack.api, the self-describing AI integration surface.
2
+
3
+ A machine-readable capability manifest (what PEN-STACK can do) and, the differentiator, a machine-readable
4
+ scope manifest (what it refuses to answer: the known-unknowns + the oracle scope cards). An external agent can
5
+ ask "what can you do, and what do you refuse to answer?" and get a typed, valid answer it can route on. The
6
+ grounding machinery becomes an API, versioned under the public API stability commitment.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from pen_stack.api.manifest import capability_manifest, scope_manifest
11
+
12
+ __all__ = ["capability_manifest", "scope_manifest"]