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,58 @@
1
+ """Cell-type coverage cards + cross-type OOD labelling + graceful degradation.
2
+
3
+ Each cell type is a graph node carrying a **coverage card** (which data tracks exist). A score is only as
4
+ trustworthy as its coverage: a partial-coverage cell type **degrades gracefully** (its confidence is capped),
5
+ and a score computed in one cell type but *queried* for another is **OOD-labelled**, the finding that
6
+ chromatin marks are conserved, so cross-cell-type context is intrinsically weak/heuristic, not a guarantee.
7
+ Tier-B cell types are a documented roadmap, never silently extrapolated.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from functools import lru_cache
12
+
13
+ import yaml
14
+
15
+ from pen_stack._resources import resource
16
+
17
+ # graceful-degradation policy: the maximum trustworthy confidence a cell type's coverage supports.
18
+ _MAX_CONF = {"full": 1.0, "partial": 0.6, "none": 0.0}
19
+
20
+
21
+ @lru_cache(maxsize=1)
22
+ def _cfg() -> dict:
23
+ return yaml.safe_load(resource("configs/cell_types.yaml").read_text(encoding="utf-8"))
24
+
25
+
26
+ def coverage_card(cell_type: str) -> dict | None:
27
+ return _cfg()["cell_types"].get(cell_type)
28
+
29
+
30
+ def cell_types() -> list[str]:
31
+ return list(_cfg()["cell_types"])
32
+
33
+
34
+ def tier_b_roadmap() -> list[dict]:
35
+ return list(_cfg().get("tier_b_roadmap", []))
36
+
37
+
38
+ def degrade(raw_confidence: float, cell_type: str) -> dict:
39
+ """Cap a confidence by the cell type's coverage (graceful degradation). Returns the degraded value +
40
+ whether degradation was applied + the coverage label, never silently inflates."""
41
+ card = coverage_card(cell_type) or {}
42
+ cov = card.get("coverage", "none")
43
+ cap = _MAX_CONF.get(cov, 0.0)
44
+ degraded = min(float(raw_confidence), cap)
45
+ return {"cell_type": cell_type, "coverage": cov, "raw_confidence": round(float(raw_confidence), 4),
46
+ "confidence": round(degraded, 4), "degraded": degraded < float(raw_confidence),
47
+ "cap": cap, "tracks": card.get("tracks", [])}
48
+
49
+
50
+ def cross_cell_type_ood(query_cell_type: str, scored_in_cell_type: str) -> dict:
51
+ """Label a cross-cell-type query as OOD/extrapolating (cross-type signal is weak, heuristic).
52
+ Same cell type = in-distribution; different = extrapolating."""
53
+ ood = query_cell_type != scored_in_cell_type
54
+ return {"query_cell_type": query_cell_type, "scored_in_cell_type": scored_in_cell_type,
55
+ "ood": ood,
56
+ "label": "extrapolating (cross-cell-type; chromatin conserved -> weak heuristic)"
57
+ if ood else "in-distribution",
58
+ "note": "cross-cell-type transfer is a heuristic signal, not a guarantee; reported, not hidden"}
@@ -0,0 +1,132 @@
1
+ """The gated living-ingestion loop, the agent proposes; a gate disposes.
2
+
3
+ The living-literature monitor (Europe PMC scan) and agents emit **candidate** nodes/edges from new evidence.
4
+ Principle 1 is inviolable and encoded here: **no process auto-edits the curated world-model.** Candidates are
5
+ *quarantined*; the ONLY way one enters the graph is `gate_admit(...)` with (a) automated checks passing AND
6
+ (b) explicit approval. Every admission is versioned with date + evidence + the gate that admitted it
7
+ (auditable history, Principle 3).
8
+
9
+ There is deliberately no bulk-merge / auto-accept function: `propose(...)` only appends to the quarantine and
10
+ never receives a `Graph`, so it *cannot* mutate the graph (asserted by test).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import datetime as _dt
15
+ from dataclasses import dataclass, field
16
+ from typing import Any, Literal
17
+
18
+ from pen_stack.graph.schema import Edge, Graph, Node
19
+
20
+ CandidateKind = Literal["node", "edge"]
21
+ Status = Literal["quarantined", "admitted", "rejected"]
22
+
23
+
24
+ @dataclass
25
+ class Candidate:
26
+ kind: CandidateKind
27
+ payload: dict[str, Any] # a Node-like or Edge-like dict
28
+ provenance: dict[str, Any] # MUST carry a source + (doi or europepmc id)
29
+ evidence: str = "predicted" # measured / curated / predicted
30
+ status: Status = "quarantined"
31
+ note: str | None = None
32
+
33
+
34
+ @dataclass
35
+ class Quarantine:
36
+ """Holds candidates; NEVER holds or touches a Graph. The only sink for `propose`."""
37
+ items: list[Candidate] = field(default_factory=list)
38
+
39
+ def propose(self, c: Candidate) -> None:
40
+ c.status = "quarantined" # forced; a proposal is never pre-approved
41
+ self.items.append(c)
42
+
43
+ def pending(self) -> list[Candidate]:
44
+ return [c for c in self.items if c.status == "quarantined"]
45
+
46
+
47
+ def automated_checks(c: Candidate) -> tuple[bool, list[str]]:
48
+ """Gate stage 1: a candidate must be provenance-bearing + well-typed before a human can even approve it."""
49
+ reasons = []
50
+ prov = c.provenance or {}
51
+ if not (prov.get("source") or prov.get("doi") or prov.get("europepmc")):
52
+ reasons.append("no provenance (need source / doi / europepmc id)")
53
+ if c.evidence not in ("measured", "curated", "predicted"):
54
+ reasons.append(f"invalid evidence kind {c.evidence!r}")
55
+ if c.kind == "node" and not (c.payload.get("id") and c.payload.get("type")):
56
+ reasons.append("node payload missing id/type")
57
+ if c.kind == "edge" and not all(c.payload.get(k) for k in ("src", "dst", "etype")):
58
+ reasons.append("edge payload missing src/dst/etype")
59
+ return (not reasons), reasons
60
+
61
+
62
+ def gate_admit(graph: Graph, candidate: Candidate, *, approved: bool, admitted_by: str,
63
+ log: list[dict] | None = None) -> dict:
64
+ """The ONLY path a candidate enters the curated graph. Requires automated checks AND explicit approval;
65
+ records a versioned admission. Returns the decision record; the graph is mutated ONLY on admit."""
66
+ log = log if log is not None else []
67
+ ok, reasons = automated_checks(candidate)
68
+ decision = {"date": _dt.date.today().isoformat(), "kind": candidate.kind,
69
+ "payload_id": candidate.payload.get("id") or candidate.payload.get("etype"),
70
+ "evidence": candidate.evidence, "provenance": candidate.provenance,
71
+ "automated_checks_passed": ok, "approved": bool(approved), "admitted_by": admitted_by}
72
+ if not (ok and approved):
73
+ candidate.status = "rejected"
74
+ decision["admitted"] = False
75
+ decision["reasons"] = reasons or ["not approved by gate"]
76
+ log.append(decision)
77
+ return decision
78
+ # admit
79
+ if candidate.kind == "node":
80
+ graph.add_node(Node(id=candidate.payload["id"], type=candidate.payload["type"],
81
+ props=candidate.payload.get("props", {})))
82
+ else:
83
+ graph.add_edge(Edge(src=candidate.payload["src"], dst=candidate.payload["dst"],
84
+ etype=candidate.payload["etype"], evidence=candidate.evidence,
85
+ confidence=candidate.payload.get("confidence"),
86
+ scope=candidate.payload.get("scope"), provenance=candidate.provenance))
87
+ candidate.status = "admitted"
88
+ decision["admitted"] = True
89
+ log.append(decision)
90
+ return decision
91
+
92
+
93
+ # --------------------------------------------------------------------------------------------------
94
+ # back-test: surface a KNOWN recent addition as a candidate (deterministic / CI-safe, no network)
95
+ # --------------------------------------------------------------------------------------------------
96
+ def back_test_candidates() -> list[Candidate]:
97
+ """The pre-registered back-test fixture: the recently-published bridge system **ISPpu10** (Europe PMC
98
+ PPR1218813; bioRxiv 2026-03-19), which the live monitor surfaces. Proposed as a candidate writer node +
99
+ a `performs insertion` edge, to be admitted only through the gate."""
100
+ prov = {"source": "Europe PMC monitor", "europepmc": "PPR1218813",
101
+ "doi": "10.64898/2026.03.19.712850", "date": "2026-03-19"}
102
+ return [
103
+ Candidate(kind="node", evidence="predicted", provenance=prov,
104
+ payload={"id": "writer:ISPpu10", "type": "writer",
105
+ "props": {"family": "bridge_IS110", "system": "ISPpu10",
106
+ "output_form": "DNA", "note": "structure-gated bridge recombinase"}}),
107
+ Candidate(kind="edge", evidence="predicted", provenance=prov,
108
+ payload={"src": "writer:ISPpu10", "dst": "write_type:insertion", "etype": "performs",
109
+ "scope": "coverage_only (newly reported; not yet human-cell measured)"}),
110
+ ]
111
+
112
+
113
+ def propose_from_monitor(since: str = "2026-01-01", back_test: bool = False) -> Quarantine:
114
+ """Run the monitor and quarantine its hits as candidates (live path; network-guarded). The back-test
115
+ path is deterministic. Candidates are ALWAYS quarantined, never merged here."""
116
+ q = Quarantine()
117
+ if back_test:
118
+ for c in back_test_candidates():
119
+ q.propose(c)
120
+ return q
121
+ try:
122
+ from pen_stack.monitor.run import run_monitor
123
+ rep = run_monitor(since=since)
124
+ for row in rep.get("queue", []) if isinstance(rep, dict) else []:
125
+ q.propose(Candidate(kind="node", evidence="predicted",
126
+ provenance={"source": "monitor", "doi": row.get("doi"),
127
+ "europepmc": row.get("id")},
128
+ payload={"id": f"writer:{row.get('name', 'candidate')}", "type": "writer",
129
+ "props": row}))
130
+ except Exception: # noqa: BLE001 - network/Europe PMC absent -> empty quarantine, never fabricates
131
+ pass
132
+ return q
@@ -0,0 +1,148 @@
1
+ """Multi-hop queries over the world-model graph.
2
+
3
+ An agent asks a design question as ONE grounded multi-hop traversal, "which writers reach locus L AND are
4
+ deliverable by a vehicle carrying their cargo form?", and every answer carries the **provenanced edges** it
5
+ traversed, so the result is grounded by construction (no-fabrication: the answer is the path, not free text).
6
+ The flat atlas/crosslink joins become graph *views* (`writers_for_locus`, `vehicles_for_writer`) for parity.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from pen_stack.graph.build import build_graph
11
+ from pen_stack.graph.schema import Edge, Graph
12
+
13
+
14
+ def graph() -> Graph:
15
+ return build_graph()
16
+
17
+
18
+ # ---- table-view parity queries (reproduce the joins) --------------------------------------
19
+ def vehicles_for_writer(family: str, g: Graph | None = None) -> list[dict]:
20
+ g = g or graph()
21
+ wid = f"writer:{family}"
22
+ return [{"vehicle": e.dst.split(":", 1)[1], "evidence": e.evidence, "scope": e.scope,
23
+ "provenance": e.provenance} for e in g.out_edges(wid, "deliverable_by")]
24
+
25
+
26
+ def writers_for_locus(locus: str, g: Graph | None = None) -> list[dict]:
27
+ g = g or graph()
28
+ lid = f"locus:{locus}"
29
+ out = []
30
+ for w in g.nodes_of("writer"):
31
+ for e in g.out_edges(w.id, "reaches"):
32
+ if e.dst == lid:
33
+ out.append({"writer": w.props["family"], "evidence": e.evidence, "scope": e.scope,
34
+ "provenance": e.provenance})
35
+ return out
36
+
37
+
38
+ # common safe-harbour aliases not captured by the GSH name or its anchor gene (case-insensitive).
39
+ _LOCUS_ALIASES = {"rosa26": "hrosa26", "rosa-26": "hrosa26", "hipp11": "h11", "hipp-11": "h11"}
40
+
41
+
42
+ def _resolve_gsh_locus(query: str, g: Graph) -> str | None:
43
+ """Resolve a user's locus query to a curated GSH node id, matching (case-insensitively) the GSH NAME, its
44
+ ANCHOR GENE, or a common alias, so AAVS1, its anchor PPP1R12C, and ROSA26 (the curated node is hRosa26)
45
+ all reach the same node. Returns the node id (``locus:<name>``) or None if it is not a curated safe harbour."""
46
+ q = str(query or "").strip().lower()
47
+ if not q:
48
+ return None
49
+ q = _LOCUS_ALIASES.get(q, q)
50
+ for n in g.nodes_of("locus"):
51
+ name = n.id.split(":", 1)[1]
52
+ anchor = str(n.props.get("anchor_gene") or "")
53
+ if q in {name.lower(), anchor.lower()}:
54
+ return n.id
55
+ return None
56
+
57
+
58
+ def _answer(w, reach_edges: list[Edge], deliv: list[Edge]) -> dict:
59
+ path: list[Edge] = reach_edges[:1] + deliv
60
+ return {
61
+ "writer": w.props["family"], "output_form": w.props["output_form"],
62
+ "vehicles": [e.dst.split(":", 1)[1] for e in deliv],
63
+ "provenance_path": [{"src": e.src, "dst": e.dst, "etype": e.etype, "evidence": e.evidence,
64
+ "scope": e.scope, "provenance": e.provenance} for e in path],
65
+ }
66
+
67
+
68
+ # ---- multi-hop design query (the headline graph capability) ------------------------------------
69
+ def writers_reaching_and_deliverable(locus: str, cargo_form: str | None = None,
70
+ g: Graph | None = None) -> dict:
71
+ """Multi-hop: writers that REACH `locus` AND are DELIVERABLE_BY a vehicle (optionally carrying
72
+ `cargo_form`). Returns each answer with the full provenanced edge path (grounded answer).
73
+
74
+ Locus resolution (the graph only materialised curated safe-harbour nodes, so anything but their
75
+ exact name returned nothing): a query is first resolved to a curated GSH node by name / anchor gene / alias.
76
+ Failing that, if it resolves to a REAL gene, the answer is the tier-1 reprogrammable writers, which reach
77
+ ANY locus at the locus level (the documented crosslink property), with a scope flag saying so and that it is
78
+ NOT a curated safe harbour. A query that is neither returns a clean, explained empty result, never an error."""
79
+ g = g or graph()
80
+ gsh_id = _resolve_gsh_locus(locus, g)
81
+
82
+ if gsh_id is not None: # curated safe harbour: the existing grounded traversal
83
+ resolved = gsh_id.split(":", 1)[1]
84
+ answers = []
85
+ for w in g.nodes_of("writer"):
86
+ if cargo_form is not None and w.props.get("output_form") != cargo_form:
87
+ continue
88
+ reach = [e for e in g.out_edges(w.id, "reaches") if e.dst == gsh_id]
89
+ deliv = g.out_edges(w.id, "deliverable_by")
90
+ if reach and deliv:
91
+ answers.append(_answer(w, reach, deliv))
92
+ note = ("every answer is a provenanced multi-hop path over the graph"
93
+ + (f"; '{locus}' resolved to curated safe harbour '{resolved}'" if resolved.lower() != str(locus).strip().lower() else ""))
94
+ return {"locus": locus, "resolved_locus": resolved, "is_curated_safe_harbour": True,
95
+ "cargo_form": cargo_form, "n_answers": len(answers), "answers": answers,
96
+ "grounded": all(a["provenance_path"] for a in answers), "no_fabrication": True, "note": note}
97
+
98
+ # not a curated GSH: does it resolve to a real gene? -> tier-1 reprogrammable writers reach ANY locus.
99
+ resolved_gene = None
100
+ try:
101
+ from pen_stack.planner.optimize import gene_region, resolve_gene
102
+ rg = resolve_gene(str(locus))
103
+ resolved_gene = rg if gene_region(rg) else None
104
+ except Exception: # noqa: BLE001 - gene-coords absent (CI/offline) -> no fallback, clean empty
105
+ resolved_gene = None
106
+
107
+ if resolved_gene is None:
108
+ return {"locus": locus, "resolved_locus": None, "is_curated_safe_harbour": False,
109
+ "cargo_form": cargo_form, "n_answers": 0, "answers": [], "grounded": True, "no_fabrication": True,
110
+ "note": (f"'{locus}' is neither a curated safe-harbour locus nor a resolvable gene symbol, no "
111
+ "grounded writer→locus→vehicle path (a clean empty result, not an error).")}
112
+
113
+ # tier-1 = the writers the graph gives locus-level `reaches` edges (near-universal reprogrammable reach).
114
+ tier1 = {e.src for e in g.edges if e.etype == "reaches"}
115
+ answers = []
116
+ for w in g.nodes_of("writer"):
117
+ if w.id not in tier1:
118
+ continue
119
+ if cargo_form is not None and w.props.get("output_form") != cargo_form:
120
+ continue
121
+ deliv = g.out_edges(w.id, "deliverable_by")
122
+ if not deliv:
123
+ continue
124
+ synth = Edge(w.id, f"locus:{resolved_gene}", "reaches", "predicted",
125
+ scope="locus-level reachability (per-site element check is Planner work); NOT a curated "
126
+ "safe-harbour node, tier-1 reprogrammable near-universal reach",
127
+ provenance={"source": "crosslink reachability_tier (tier-1 reprogrammable); locus resolved "
128
+ "to a real gene, not a curated GSH, treat as a candidate, verify per-site"})
129
+ answers.append(_answer(w, [synth], deliv))
130
+ return {"locus": locus, "resolved_locus": resolved_gene, "is_curated_safe_harbour": False,
131
+ "cargo_form": cargo_form, "n_answers": len(answers), "answers": answers,
132
+ "grounded": all(a["provenance_path"] for a in answers), "no_fabrication": True,
133
+ "note": (f"'{resolved_gene}' is not a curated safe harbour; these are the tier-1 reprogrammable writers, "
134
+ "which reach any locus at the locus level (a candidate, not a validated safe-harbour path, "
135
+ "per-site reachability + safety are the Planner / Guardian's job).")}
136
+
137
+
138
+ def outcomes_for_writer(family: str, g: Graph | None = None) -> list[dict]:
139
+ """Documented (measured) writes performed by a writer family, the outcome edges."""
140
+ g = g or graph()
141
+ wid = f"writer:{family}"
142
+ out = []
143
+ for o in g.nodes_of("outcome"):
144
+ for e in g.out_edges(o.id, "used_writer"):
145
+ if e.dst == wid:
146
+ out.append({"outcome": o.id.split(":", 1)[1], "cargo_bp": o.props["cargo_bp"],
147
+ "evidence": e.evidence, "doi": o.props["doi"]})
148
+ return out
@@ -0,0 +1,100 @@
1
+ """The living world-model knowledge graph, schema.
2
+
3
+ The substrate's ground truth (L3) was flat tables joined by code. Promotes it to a queryable
4
+ **knowledge graph**: typed nodes (writer / locus / cargo / vehicle / cell_type / write_type / outcome) joined
5
+ by typed edges (reaches / deliverable_by / performs / durable_in / carries / used_writer / observed_at), where
6
+ **every edge carries its provenance, its uncertainty, and the scope within which it holds** (Principle 2).
7
+
8
+ Edges are typed by evidence kind, `measured` (a documented experimental outcome) > `curated` (a
9
+ hand-verified fact, e.g. a delivery-vehicle property) > `predicted` (a model/homology inference), so an
10
+ agent traversing the graph always knows how much to trust each hop. Nodes/edges are plain dataclasses; the
11
+ store (build/query) is pure-Python + JSON/sqlite (Docker-friendly, no graph-DB dependency).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import asdict, dataclass, field
16
+ from typing import Any, Literal
17
+
18
+ NodeType = Literal["writer", "locus", "cargo", "vehicle", "cell_type", "write_type", "outcome"]
19
+ EdgeType = Literal["reaches", "deliverable_by", "performs", "durable_in", "carries",
20
+ "used_writer", "observed_at"]
21
+ EvidenceKind = Literal["measured", "curated", "predicted"]
22
+
23
+
24
+ @dataclass
25
+ class Node:
26
+ id: str
27
+ type: NodeType
28
+ props: dict[str, Any] = field(default_factory=dict)
29
+
30
+ def to_dict(self) -> dict:
31
+ return asdict(self)
32
+
33
+
34
+ @dataclass
35
+ class Edge:
36
+ src: str
37
+ dst: str
38
+ etype: EdgeType
39
+ evidence: EvidenceKind # measured / curated / predicted (trust ordering)
40
+ confidence: float | None = None # [0,1] where known; None = not quantified (abstain)
41
+ scope: str | None = None # the context within which the edge holds (limit)
42
+ provenance: dict[str, Any] = field(default_factory=dict) # {source, doi, date, ...}
43
+
44
+ def to_dict(self) -> dict:
45
+ return asdict(self)
46
+
47
+ @property
48
+ def key(self) -> tuple:
49
+ return (self.src, self.dst, self.etype)
50
+
51
+
52
+ class Graph:
53
+ """An in-memory typed multigraph with provenance-tagged edges. Pure Python; serialises to JSON."""
54
+
55
+ def __init__(self) -> None:
56
+ self.nodes: dict[str, Node] = {}
57
+ self.edges: list[Edge] = []
58
+ self._adj: dict[str, list[Edge]] = {} # src -> edges (built lazily)
59
+
60
+ # ---- construction --------------------------------------------------------------------------
61
+ def add_node(self, node: Node) -> None:
62
+ self.nodes[node.id] = node
63
+
64
+ def add_edge(self, edge: Edge) -> None:
65
+ if edge.src not in self.nodes or edge.dst not in self.nodes:
66
+ raise KeyError(f"edge {edge.key} references an unknown node")
67
+ self.edges.append(edge)
68
+ self._adj.setdefault(edge.src, []).append(edge)
69
+
70
+ # ---- traversal -----------------------------------------------------------------------------
71
+ def out_edges(self, node_id: str, etype: EdgeType | None = None) -> list[Edge]:
72
+ es = self._adj.get(node_id, [])
73
+ return [e for e in es if etype is None or e.etype == etype]
74
+
75
+ def neighbors(self, node_id: str, etype: EdgeType | None = None) -> list[str]:
76
+ return [e.dst for e in self.out_edges(node_id, etype)]
77
+
78
+ def nodes_of(self, ntype: NodeType) -> list[Node]:
79
+ return [n for n in self.nodes.values() if n.type == ntype]
80
+
81
+ # ---- serialisation -------------------------------------------------------------------------
82
+ def to_dict(self) -> dict:
83
+ return {"nodes": [n.to_dict() for n in self.nodes.values()],
84
+ "edges": [e.to_dict() for e in self.edges]}
85
+
86
+ @classmethod
87
+ def from_dict(cls, d: dict) -> "Graph":
88
+ g = cls()
89
+ for n in d["nodes"]:
90
+ g.add_node(Node(**n))
91
+ for e in d["edges"]:
92
+ g.add_edge(Edge(**e))
93
+ return g
94
+
95
+ def summary(self) -> dict:
96
+ from collections import Counter
97
+ return {"n_nodes": len(self.nodes), "n_edges": len(self.edges),
98
+ "nodes_by_type": dict(Counter(n.type for n in self.nodes.values())),
99
+ "edges_by_type": dict(Counter(e.etype for e in self.edges)),
100
+ "edges_by_evidence": dict(Counter(e.evidence for e in self.edges))}
@@ -0,0 +1,15 @@
1
+ """pen_stack.loop, the closed loop, autonomy Level 3.
2
+
3
+ PEN-STACK generates safe legal designs, predicts their outcomes, chooses the most informative experiments,
4
+ exports gated protocols, runs them in the simulated lab or a real one, ingests results through the world-model's
5
+ gate, and updates its calibration, twin, and immune labels round after round, detecting when observations drift
6
+ from predictions and widening uncertainty rather than over-trusting a stale model. It keeps a human in control at
7
+ every gate, never fabricates a number, and stops deliberately at autonomy Level 3.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from pen_stack.loop.continual import continual_update
12
+ from pen_stack.loop.cycle import AUTONOMY_LEVEL, loop_converges_faster_than_random, run_loop
13
+ from pen_stack.loop.drift import detect_drift
14
+
15
+ __all__ = ["run_loop", "continual_update", "detect_drift", "loop_converges_faster_than_random", "AUTONOMY_LEVEL"]
@@ -0,0 +1,61 @@
1
+ """Gated, versioned, reversible continual learning, the closed loop.
2
+
3
+ Recalibrates the trust layer + the twin + the immune proxies on ADMITTED outcomes only, never on
4
+ un-gated evidence. Every update is versioned and reversible (carries a `rollback_to` pointer), attributable to
5
+ the admitted evidence and the approver. High drift widens intervals rather than over-trusting a shifting model.
6
+ This is recalibration, NOT full retraining of the foundation models.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ from statistics import mean
13
+
14
+
15
+ def _evidence_digest(admitted_results: list) -> str:
16
+ payloads = [getattr(r, "payload", r) for r in admitted_results]
17
+ return hashlib.sha256(json.dumps(payloads, sort_keys=True, default=str).encode()).hexdigest()[:12]
18
+
19
+
20
+ def _readouts(admitted_results: list) -> list[float]:
21
+ out = []
22
+ for r in admitted_results:
23
+ payload = getattr(r, "payload", r)
24
+ ro = payload.get("readout") if isinstance(payload, dict) else None
25
+ if ro is not None:
26
+ out.append(float(ro))
27
+ return out
28
+
29
+
30
+ def _update_immune_labels(admitted_results: list) -> dict:
31
+ """Immune-measurement results can move an axis proxy -> outcome-validated (recorded). Requires an
32
+ admitted measurement that names an immune axis + a CI (the calibration rule); else no graduation."""
33
+ graduated = []
34
+ for r in admitted_results:
35
+ payload = getattr(r, "payload", r)
36
+ if isinstance(payload, dict) and payload.get("immune_axis_measured") and payload.get("ci"):
37
+ graduated.append(payload["immune_axis_measured"])
38
+ return {"graduated_to_validated": graduated,
39
+ "note": "graduation requires an admitted immune measurement with a CI (the immune-calibration gate)"}
40
+
41
+
42
+ def continual_update(admitted_results: list, *, drift: dict | None = None, approver: str,
43
+ prev_version: str | None = None) -> dict:
44
+ """Recalibrate trust + twin + immune proxies on ADMITTED outcomes only. Versioned + reversible. High drift
45
+ widens intervals rather than over-trusting a shifting model."""
46
+ if not admitted_results:
47
+ return {"updated": False, "reason": "no admitted evidence", "rollback_to": prev_version}
48
+ readouts = _readouts(admitted_results)
49
+ calibration = {"mean_readout": round(mean(readouts), 4) if readouts else None, "n": len(readouts)}
50
+ interval_inflation = 1.0
51
+ if drift and drift.get("severity") == "high":
52
+ interval_inflation = 1.0 + min(1.0, drift.get("ece", 0.0)) # widen, don't over-trust
53
+ imm_lbls = _update_immune_labels(admitted_results)
54
+ version = f"v{_evidence_digest(admitted_results)}"
55
+ return {
56
+ "updated": True, "version": version, "by": approver,
57
+ "n_evidence": len(admitted_results), "calibration": calibration,
58
+ "interval_inflation": round(interval_inflation, 4), "immune_labels": imm_lbls,
59
+ "rollback_to": prev_version, # reversible: revert to the prior belief version
60
+ "note": "recalibration only (not foundation-model retraining); admitted-evidence-only; versioned+reversible",
61
+ }
@@ -0,0 +1,84 @@
1
+ """The DBTL orchestrator, the closed loop.
2
+
3
+ One continual design→build→test→learn cycle, integrating every prior cycle:
4
+ generate → decide/batch → safety-gated export → run (sim-lab / real lab) →
5
+ ingest (gate) → drift → continual learn.
6
+ **Autonomy Level 3**: closed, but with humans/lab IN CONTROL at every gate (safety, build, belief-admission),
7
+ not autonomous. Numbers come only from tools; no stage fabricates a value.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ AUTONOMY_LEVEL = 3
14
+
15
+
16
+ def _converged(history: list[dict]) -> bool:
17
+ """Converge when the best observed readout stops improving meaningfully over the last two rounds."""
18
+ bests = [h.get("best_readout") for h in history if h.get("best_readout") is not None]
19
+ return len(bests) >= 2 and abs(bests[-1] - bests[-2]) < 0.01
20
+
21
+
22
+ def _best_readout(results: list) -> float | None:
23
+ vals = []
24
+ for r in results:
25
+ payload = getattr(r, "payload", r)
26
+ ro = payload.get("readout") if isinstance(payload, dict) else None
27
+ if ro is not None:
28
+ vals.append(float(ro))
29
+ return max(vals) if vals else None
30
+
31
+
32
+ def run_loop(goal: dict, cell_state: str, *, candidates: list[dict] | None = None, rounds: int = 5,
33
+ use_lab: bool = False, approver: str = "human", seed: int = 0) -> dict[str, Any]:
34
+ """One Level-3 DBTL campaign. Gated: safety + build + belief-admission each await `approver`. Optimises
35
+ efficacy AND immune-risk; numbers come only from tools; no stage fabricates. Pass an explicit `candidates`
36
+ pool to run atlas-independently (sim-lab is the default; a real lab attaches at the same interface)."""
37
+ from pen_stack.active.design import select_batch
38
+ from pen_stack.build.ingest import ingest_result
39
+ from pen_stack.build.protocol import ProtocolExportError, _to_protocol_ir, export_protocol
40
+ from pen_stack.build.simlab import run_simulated
41
+ from pen_stack.design.generate import generate_designs
42
+ from pen_stack.loop.continual import continual_update
43
+ from pen_stack.loop.drift import detect_drift
44
+
45
+ history: list[dict] = []
46
+ prev_version: str | None = None
47
+ for r in range(rounds):
48
+ cands = generate_designs(goal, candidates=candidates, keep=8, actor=approver) # (safe+legal+calibrated)
49
+ if not cands:
50
+ history.append({"round": r, "n": 0, "note": "no surviving candidates (atlas absent or all discarded)"})
51
+ break
52
+ batch = select_batch(cands, cell_state, {"round": r}, k=4) # (info + immune-VOI)
53
+ admitted, blocked = [], 0
54
+ for d in batch:
55
+ try:
56
+ export_protocol(d, {"round": r}, actor=approver) # safety-gate (may block)
57
+ except ProtocolExportError:
58
+ blocked += 1
59
+ continue
60
+ ir = _to_protocol_ir(d, {"round": r})
61
+ res = (_run_real(ir, d) if use_lab else run_simulated(ir, d, cell_state, seed=seed + r))
62
+ admitted.append(ingest_result(res, admitted_by=approver)) # gate (human approves)
63
+ drift = detect_drift(batch, admitted, cell_state=cell_state)
64
+ update = continual_update(admitted, drift=drift, approver=approver, prev_version=prev_version)
65
+ prev_version = update.get("version", prev_version)
66
+ history.append({"round": r, "n": len(admitted), "blocked": blocked, "best_readout": _best_readout(admitted),
67
+ "drift": drift, "update": update})
68
+ if _converged(history):
69
+ break
70
+ return {"goal": goal, "history": history, "final_model_version": prev_version,
71
+ "autonomy_level": AUTONOMY_LEVEL, "human_in_control": True, "no_fabrication": True}
72
+
73
+
74
+ def _run_real(protocol_ir: dict, design: dict): # pragma: no cover - a real lab attaches here
75
+ raise NotImplementedError("attach a real lab at this interface (same shape as the sim-lab)")
76
+
77
+
78
+ def loop_converges_faster_than_random(reps: int = 15, rounds: int = 6) -> dict:
79
+ """The loop's active Learn stage reaches a target model quality in FEWER rounds than random,
80
+ proven retrospectively with reps + a bootstrap CI (the convergence headline; reuses the prior validation)."""
81
+ from pen_stack.active.validate import retrospective_active_learning
82
+ r = retrospective_active_learning(reps=reps, rounds=rounds)
83
+ return {"reaches_optimum_faster_than_random": r["active_beats_random"],
84
+ "active_vs_random_ci": r["active_vs_random"]["ci"], "honest_note": r["honest_note"]}
@@ -0,0 +1,41 @@
1
+ """Drift detection, the closed loop.
2
+
3
+ Compares what the twin PREDICTED against what the experiments OBSERVED. Growing miscalibration => drift => widen
4
+ uncertainty and flag, rather than over-trusting a stale model. Covers calibration/residual shift, not every
5
+ failure mode.
6
+ """
7
+ from __future__ import annotations
8
+
9
+
10
+ def _predicted(design: dict, cell_state: str) -> float | None:
11
+ from pen_stack.twin.outcome import predict_outcome
12
+ v = predict_outcome(design, design.get("cell_state") or cell_state or "k562")
13
+ return v["predicted_outcome"].get("relative_expression")
14
+
15
+
16
+ def _empirical_calibration_error(designs: list, results: list, cell_state: str = "") -> tuple[float, int]:
17
+ errs = []
18
+ for d, r in zip(designs, results):
19
+ pred = _predicted(d, cell_state)
20
+ obs = (r.get("payload", {}) if hasattr(r, "get") else {}).get("readout") if not isinstance(r, dict) else r.get("readout")
21
+ # results may be raw dicts (sim/ingest) or Candidates; read the readout from either
22
+ if obs is None and hasattr(r, "payload"):
23
+ obs = r.payload.get("readout")
24
+ if pred is not None and obs is not None:
25
+ errs.append(abs(float(pred) - float(obs)))
26
+ if not errs:
27
+ return 0.0, 0
28
+ return sum(errs) / len(errs), len(errs)
29
+
30
+
31
+ def _drift_threshold() -> float:
32
+ return 0.20 # mean abs predicted-vs-observed error above which we flag drift
33
+
34
+
35
+ def detect_drift(designs: list, results: list, *, cell_state: str = "") -> dict:
36
+ """Predicted (twin) vs observed (results). Growing miscalibration => drift => widen + flag."""
37
+ ece_now, n = _empirical_calibration_error(designs, results, cell_state)
38
+ sev = "high" if ece_now > _drift_threshold() else "low"
39
+ return {"severity": sev, "ece": round(ece_now, 4), "n": n,
40
+ "action": "inflate_intervals" if sev == "high" else "monitor",
41
+ "note": "calibration/residual drift only, not every failure mode"}
@@ -0,0 +1 @@
1
+ """pen_stack.mech - see the PEN-STACK program doc."""