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,86 @@
1
+ """Ingest a user's private assay into per-site labels matching the model's feature schema.
2
+
3
+ The runnable in-code path is TABULAR: a CSV/TSV/Parquet of sites + an outcome label (and, optionally, the
4
+ released-model score column or the per-bin features to attach). The upstream FASTQ/BAM -> per-site label
5
+ derivation (integration-site sequencing, GUIDE-seq, expression-stability profiling) is documented in
6
+ docs/private_data_formats.md and runs in the Docker image with the usual aligners; it produces exactly the
7
+ tabular schema this module validates, so the two halves compose.
8
+
9
+ Schema (standardized output): chrom, bin, ct, label, [score], [features...]
10
+ * label: 0/1 (discrimination) or a real value in [0,1] (calibration target).
11
+ * score: the released model's output for that site (safety / p_durable / writability) - the thing we
12
+ recalibrate. If absent, attach_features() joins it from the writability atlas.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+
18
+ import numpy as np
19
+ import pandas as pd
20
+
21
+ BIN_BP = 1000
22
+ REQUIRED = ("chrom", "label") # plus one of {bin, pos}
23
+
24
+
25
+ def load_user_labels(path: str | Path) -> pd.DataFrame:
26
+ """Load + validate a user label table (.csv/.tsv/.parquet). Returns a frame with chrom, bin, ct, label."""
27
+ p = Path(path)
28
+ if p.suffix in (".parquet", ".pq"):
29
+ df = pd.read_parquet(p)
30
+ else:
31
+ df = pd.read_csv(p, sep="\t" if p.suffix in (".tsv", ".txt") else ",")
32
+ return normalize(df)
33
+
34
+
35
+ def normalize(df: pd.DataFrame) -> pd.DataFrame:
36
+ """Validate columns and standardize: derive `bin` from `pos` if needed; coerce label; default ct."""
37
+ df = df.copy()
38
+ missing = [c for c in REQUIRED if c not in df.columns]
39
+ if missing:
40
+ raise ValueError(f"user table missing required columns: {missing} (have {list(df.columns)})")
41
+ if "bin" not in df.columns:
42
+ if "pos" not in df.columns:
43
+ raise ValueError("user table needs a 'bin' or a 'pos' column to locate each site")
44
+ df["bin"] = (df["pos"].astype(int) // BIN_BP).astype(int)
45
+ if "ct" not in df.columns:
46
+ df["ct"] = "user"
47
+ lab = pd.to_numeric(df["label"], errors="coerce")
48
+ if lab.isna().any():
49
+ raise ValueError("label column has non-numeric / missing values")
50
+ if not (((lab == 0) | (lab == 1)).all() or ((lab >= 0) & (lab <= 1)).all()):
51
+ raise ValueError("label must be binary {0,1} or a probability in [0,1]")
52
+ df["label"] = lab.astype(float)
53
+ keep = ["chrom", "bin", "ct", "label"] + [c for c in df.columns
54
+ if c not in ("chrom", "bin", "ct", "label", "pos")]
55
+ return df[keep].reset_index(drop=True)
56
+
57
+
58
+ def attach_features(df: pd.DataFrame, target: str = "safety", ct: str = "k562") -> pd.DataFrame:
59
+ """Join the released model's score for `target` (safety|p_durable|writability) from the writability atlas.
60
+
61
+ No-op if the score column is already present (user supplied it). Raises if the atlas is unavailable and
62
+ no score column exists - the caller then supplies scores directly.
63
+ """
64
+ col = {"safety": "safety", "durability": "p_durable", "p_durable": "p_durable",
65
+ "writability": "writability"}.get(target, target)
66
+ if col in df.columns and df[col].notna().any():
67
+ return df.rename(columns={col: "score"}) if col != "score" else df
68
+ if "score" in df.columns:
69
+ return df
70
+ atlas = Path(__file__).resolve().parents[2].parent / "phase_1" / "out" / f"atlas_{ct}.parquet"
71
+ if not atlas.exists():
72
+ raise FileNotFoundError(
73
+ f"no '{col}'/'score' column and atlas absent ({atlas}); supply the released-model score "
74
+ "column in the user table, or run inside the image where the atlas is mounted.")
75
+ a = pd.read_parquet(atlas, columns=["chrom", "bin", col])
76
+ out = df.merge(a, on=["chrom", "bin"], how="left").rename(columns={col: "score"})
77
+ if out["score"].isna().any():
78
+ out = out.dropna(subset=["score"]).reset_index(drop=True)
79
+ return out
80
+
81
+
82
+ def schema_summary(df: pd.DataFrame) -> dict:
83
+ return {"n_sites": int(len(df)), "n_chroms": int(df["chrom"].nunique()),
84
+ "label_kind": "binary" if set(np.unique(df["label"])) <= {0.0, 1.0} else "continuous",
85
+ "positive_rate": round(float(df["label"].mean()), 4),
86
+ "has_score": "score" in df.columns}
@@ -0,0 +1,101 @@
1
+ """The adaptation pipeline (ingest -> split -> recalibrate/finetune -> held-out gate -> version).
2
+
3
+ `adapt()` is the one entry point. It splits the user's sites into train/held-out (chromosome-grouped when
4
+ possible), fits the adaptation on train, scores the released vs adapted model on the SAME held-out split,
5
+ and applies the validation gate. The adapted artifact is written under models/local_<id>/ ONLY - the
6
+ released model is never overwritten, and its fingerprint is checked before/after to prove it (acceptance).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+
15
+ from pen_stack.adapt import report as R
16
+ from pen_stack.adapt.recalibrate import recalibrate
17
+
18
+ _ROOT = Path(__file__).resolve().parents[2]
19
+ _MODELS = _ROOT / "models"
20
+ # released score-producing modules - the "released model" we must prove is unchanged by adaptation.
21
+ _RELEASED = [_ROOT / "pen_stack" / "wgenome" / m for m in ("safety.py", "durability.py", "writability.py")]
22
+
23
+
24
+ def _split(df: pd.DataFrame, seed: int, holdout_frac: float = 0.3):
25
+ """Chromosome-grouped holdout when >=2 chromosomes (no leakage); else a seeded random split."""
26
+ rng = np.random.default_rng(seed)
27
+ chroms = df["chrom"].unique()
28
+ if len(chroms) >= 2:
29
+ n_ho = max(1, int(round(len(chroms) * holdout_frac)))
30
+ ho_chroms = set(rng.choice(chroms, size=n_ho, replace=False))
31
+ mask = df["chrom"].isin(ho_chroms)
32
+ else:
33
+ mask = pd.Series(rng.random(len(df)) < holdout_frac, index=df.index)
34
+ return df[~mask].reset_index(drop=True), df[mask].reset_index(drop=True)
35
+
36
+
37
+ def adapt(df: pd.DataFrame, target: str = "safety", method: str = "isotonic", local_id: str = "local",
38
+ seed: int = 20260604, primary: str = "brier", margin: float = 0.0,
39
+ feature_cols: list[str] | None = None, models_dir: str | Path = _MODELS) -> dict:
40
+ """Recalibrate (or fine-tune) the released `target` score on the user frame (needs 'score' + 'label').
41
+
42
+ Returns the held-out before/after report + the gate decision + the artifact paths. The adapted model is
43
+ activated (written + flagged) only if it beats the released model on the held-out split.
44
+ """
45
+ if "score" not in df.columns or "label" not in df.columns:
46
+ raise ValueError("adapt() needs standardized columns 'score' and 'label' (see adapt.ingest)")
47
+ fp_before = R.released_fingerprint(*_RELEASED)
48
+
49
+ train, holdout = _split(df, seed)
50
+ if len(train) < 5 or len(holdout) < 3:
51
+ raise ValueError(f"not enough data after split (train={len(train)}, holdout={len(holdout)})")
52
+
53
+ base_holdout = np.clip(holdout["score"].to_numpy(float), 0, 1) # released score as a probability
54
+ if method == "isotonic":
55
+ cal = recalibrate(train["score"], train["label"])
56
+ adapted_holdout = cal.transform(holdout["score"])
57
+ artifact = "calibrator.json"
58
+ elif method == "finetune":
59
+ from pen_stack.adapt.finetune import finetune_head, predict_proba
60
+ cols = feature_cols or ["score"]
61
+ model = finetune_head(train[cols].to_numpy(float), train["label"], seed=seed)
62
+ adapted_holdout = predict_proba(model, holdout[cols].to_numpy(float))
63
+ cal, artifact = None, "head.txt"
64
+ else:
65
+ raise ValueError(f"unknown method: {method!r} (use 'isotonic' or 'finetune')")
66
+
67
+ # no-skill constant predictor: the TRAIN base rate applied to every held-out site (no leakage). The
68
+ # adapted model must beat this too, else its 'improvement' is just regression to climatology.
69
+ base_rate = float(np.clip(train["label"].mean(), 1e-6, 1 - 1e-6))
70
+ no_skill = R.evaluate(np.full(len(holdout), base_rate), holdout["label"])
71
+
72
+ base = R.evaluate(base_holdout, holdout["label"])
73
+ adapted = R.evaluate(adapted_holdout, holdout["label"])
74
+ gate = R.gate(base, adapted, primary=primary, margin=margin, no_skill=no_skill)
75
+
76
+ out_dir = Path(models_dir) / f"local_{local_id}"
77
+ fp_after = R.released_fingerprint(*_RELEASED)
78
+ released_unchanged = fp_before == fp_after
79
+ report = {"local_id": local_id, "target": target, "method": method,
80
+ "n_train": int(len(train)), "n_holdout": int(len(holdout)),
81
+ "held_out_before": base, "held_out_after": adapted, "held_out_no_skill": no_skill, "gate": gate,
82
+ "released_model_unchanged": released_unchanged,
83
+ "released_fingerprint": fp_after, "activated": gate["activate"]}
84
+ card = R.model_card(f"local_{local_id}", target, method, base, adapted, gate,
85
+ len(train), len(holdout), fp_after)
86
+ paths = R.write_report(out_dir, report, card)
87
+ # persist the adapted artifact ONLY when the gate passes; otherwise remove any stale artifact so a
88
+ # previously-activated adaptation that now fails the gate is not left active (released model stays in force).
89
+ artifact_path = out_dir / artifact
90
+ if gate["activate"]:
91
+ if method == "isotonic":
92
+ cal.save(artifact_path)
93
+ else:
94
+ model.booster_.save_model(str(artifact_path))
95
+ paths["artifact"] = str(artifact_path)
96
+ else:
97
+ for stale in (out_dir / "calibrator.json", out_dir / "head.txt"):
98
+ if stale.exists():
99
+ stale.unlink()
100
+ report["paths"] = paths
101
+ return report
@@ -0,0 +1,58 @@
1
+ """Isotonic recalibration of a released score on user labels (private, in-container).
2
+
3
+ Isotonic regression learns a monotonic map released_score -> calibrated probability. Being monotonic it
4
+ NEVER changes the ranking (AUROC is preserved); it only fixes calibration (Brier / ECE). Small and robust on
5
+ the small datasets users typically have. The calibrator is saved as plain JSON under models/local_<id>/ -
6
+ the released model is untouched.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+
15
+
16
+ class IsotonicCalibrator:
17
+ """Thin, serializable wrapper around sklearn's IsotonicRegression (saved as JSON, no pickle)."""
18
+
19
+ def __init__(self):
20
+ self._iso = None
21
+ self.fitted = False
22
+
23
+ def fit(self, scores, labels) -> "IsotonicCalibrator":
24
+ from sklearn.isotonic import IsotonicRegression
25
+ s, y = np.asarray(scores, float), np.asarray(labels, float)
26
+ self._iso = IsotonicRegression(out_of_bounds="clip", y_min=0.0, y_max=1.0)
27
+ self._iso.fit(s, y)
28
+ self.fitted = True
29
+ return self
30
+
31
+ def transform(self, scores):
32
+ if not self.fitted:
33
+ raise RuntimeError("calibrator not fitted")
34
+ return self._iso.predict(np.asarray(scores, float))
35
+
36
+ def to_dict(self) -> dict:
37
+ return {"kind": "isotonic", "x": list(map(float, self._iso.X_thresholds_)),
38
+ "y": list(map(float, self._iso.y_thresholds_))}
39
+
40
+ def save(self, path: str | Path) -> Path:
41
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
42
+ Path(path).write_text(json.dumps(self.to_dict(), indent=2), encoding="utf-8")
43
+ return Path(path)
44
+
45
+ @classmethod
46
+ def load(cls, path: str | Path) -> "IsotonicCalibrator":
47
+ d = json.loads(Path(path).read_text(encoding="utf-8"))
48
+ obj = cls()
49
+ from sklearn.isotonic import IsotonicRegression
50
+ iso = IsotonicRegression(out_of_bounds="clip", y_min=0.0, y_max=1.0)
51
+ iso.fit(np.asarray(d["x"], float), np.asarray(d["y"], float)) # re-fit on the stored knots
52
+ obj._iso, obj.fitted = iso, True
53
+ return obj
54
+
55
+
56
+ def recalibrate(scores, labels) -> IsotonicCalibrator:
57
+ """Fit an isotonic calibrator mapping a released score to a calibrated probability on user labels."""
58
+ return IsotonicCalibrator().fit(scores, labels)
@@ -0,0 +1,130 @@
1
+ """Held-out evaluation, the validation GATE, and the model card.
2
+
3
+ The adapted artifact ACTIVATES only if it beats the released model on the user's held-out split (the gate).
4
+ Calibration is judged by Brier score + expected calibration error (ECE, lower is better); discrimination by
5
+ AUROC (higher is better). The released model is provably unchanged (its artifact hash is recorded and
6
+ re-checked); a before/after report and a model card are always written.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+
14
+ import numpy as np
15
+
16
+
17
+ def _auroc(scores, labels) -> float:
18
+ pos = [s for s, y in zip(scores, labels) if y == 1]
19
+ neg = [s for s, y in zip(scores, labels) if y == 0]
20
+ if not pos or not neg:
21
+ return float("nan")
22
+ return sum((p > n) + 0.5 * (p == n) for p in pos for n in neg) / (len(pos) * len(neg))
23
+
24
+
25
+ def _ece(probs, labels, n_bins: int = 10) -> float:
26
+ probs, labels = np.asarray(probs, float), np.asarray(labels, float)
27
+ edges = np.linspace(0, 1, n_bins + 1)
28
+ ece, n = 0.0, len(probs)
29
+ for i in range(n_bins):
30
+ m = (probs >= edges[i]) & (probs < edges[i + 1] if i < n_bins - 1 else probs <= edges[i + 1])
31
+ if m.sum():
32
+ ece += (m.sum() / n) * abs(probs[m].mean() - labels[m].mean())
33
+ return float(ece)
34
+
35
+
36
+ def evaluate(probs, labels) -> dict:
37
+ """Calibration + discrimination metrics for a set of probabilities against binary labels."""
38
+ probs, labels = np.asarray(probs, float), np.asarray(labels, float)
39
+ brier = float(np.mean((probs - labels) ** 2))
40
+ biny = labels if set(np.unique(labels)) <= {0.0, 1.0} else (labels >= 0.5).astype(float)
41
+ return {"n": int(len(probs)), "brier": round(brier, 5), "ece": round(_ece(probs, biny), 5),
42
+ "auroc": round(_auroc(list(probs), list(biny)), 4)}
43
+
44
+
45
+ def gate(base: dict, adapted: dict, primary: str = "brier", margin: float = 0.0,
46
+ no_skill: dict | None = None) -> dict:
47
+ """Activate the adapted model only if it BEATS the released model AND the no-skill constant predictor on
48
+ the held-out primary metric.
49
+
50
+ primary='brier'|'ece' -> lower is better; primary='auroc' -> higher is better. `margin` is the minimum
51
+ improvement required (guards against noise on small holdouts). The `no_skill` guard is essential:
52
+ recalibration can trivially lower Brier by regressing to the base rate, so we require the adapted model
53
+ to beat the constant base-rate predictor too - otherwise the 'improvement' is no skill, just climatology.
54
+ """
55
+ lower_better = primary in ("brier", "ece")
56
+
57
+ def better(x, ref):
58
+ return (ref - x) if lower_better else (x - ref)
59
+
60
+ b, a = base[primary], adapted[primary]
61
+ imp_released = better(a, b)
62
+ beats_released = imp_released > margin
63
+ beats_no_skill = True
64
+ ns = None
65
+ if no_skill is not None:
66
+ ns = no_skill[primary]
67
+ beats_no_skill = better(a, ns) > margin
68
+ activate = bool(beats_released and beats_no_skill)
69
+ if activate:
70
+ decision = "ADAPTED ACTIVATED (beats released AND the no-skill constant on held-out)"
71
+ elif not beats_no_skill:
72
+ decision = "ADAPTED REJECTED (improvement is no skill - does not beat the constant base rate)"
73
+ else:
74
+ decision = "ADAPTED REJECTED (does not beat released; released model kept)"
75
+ return {"primary_metric": primary, "lower_is_better": lower_better,
76
+ "released": b, "adapted": a, "no_skill_constant": ns,
77
+ "improvement_vs_released": round(imp_released, 5), "margin": margin,
78
+ "beats_released": bool(beats_released), "beats_no_skill": bool(beats_no_skill),
79
+ "activate": activate, "decision": decision}
80
+
81
+
82
+ def released_fingerprint(*paths: str | Path) -> dict:
83
+ """Hash designated released-model artifacts so we can prove they are unchanged by adaptation."""
84
+ import hashlib
85
+ fp = {}
86
+ for p in paths:
87
+ p = Path(p)
88
+ if p.exists():
89
+ fp[str(p.name)] = hashlib.sha256(p.read_bytes()).hexdigest()[:16]
90
+ return fp
91
+
92
+
93
+ def model_card(local_id: str, target: str, method: str, base: dict, adapted: dict, gate_res: dict,
94
+ n_train: int, n_holdout: int, released_fp: dict) -> str:
95
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%d")
96
+ return "\n".join([
97
+ f"# PEN-STACK local adaptation - {local_id}",
98
+ "",
99
+ f"- **Date:** {ts}",
100
+ f"- **Target score:** {target} **Method:** {method}",
101
+ f"- **Data:** {n_train} train / {n_holdout} held-out sites (private, in-container)",
102
+ f"- **Released-model fingerprint (unchanged):** {released_fp}",
103
+ "",
104
+ "## Held-out before/after",
105
+ "| metric | released | adapted |",
106
+ "|---|---|---|",
107
+ f"| Brier (lower better) | {base['brier']} | {adapted['brier']} |",
108
+ f"| ECE (lower better) | {base['ece']} | {adapted['ece']} |",
109
+ f"| AUROC (higher better) | {base['auroc']} | {adapted['auroc']} |",
110
+ "",
111
+ f"## Gate: **{gate_res['decision']}**",
112
+ f"- primary metric `{gate_res['primary_metric']}`: released {gate_res['released']} -> adapted "
113
+ f"{gate_res['adapted']} (improvement vs released {gate_res['improvement_vs_released']}, "
114
+ f"no-skill constant {gate_res.get('no_skill_constant')}, margin {gate_res['margin']}; "
115
+ f"beats released={gate_res['beats_released']}, beats no-skill={gate_res['beats_no_skill']}).",
116
+ "",
117
+ "## Scope",
118
+ "Recalibration / light fine-tuning on a small private dataset; overfitting is mitigated (not "
119
+ "eliminated) by the held-out gate. Not unsupervised learning from raw reads. The released model is "
120
+ "never overwritten - this artifact lives under `models/local_<id>/` and activates only if the gate "
121
+ "passed.",
122
+ ])
123
+
124
+
125
+ def write_report(out_dir: str | Path, report: dict, card: str) -> dict:
126
+ out = Path(out_dir)
127
+ out.mkdir(parents=True, exist_ok=True)
128
+ (out / "report.json").write_text(json.dumps(report, indent=2, default=str), encoding="utf-8")
129
+ (out / "model_card.md").write_text(card, encoding="utf-8")
130
+ return {"report": str(out / "report.json"), "model_card": str(out / "model_card.md")}
@@ -0,0 +1 @@
1
+ """pen_stack.agent - see the PEN-STACK program doc."""
@@ -0,0 +1,175 @@
1
+ """Cited mechanistic rationale + scoped generalisation.
2
+
3
+ Every recommendation carries a short, literature-cited "why". Crucially the citations are **drawn from the
4
+ curated world-model** (the verifier rule provenance, the writer/delivery/locus DOIs), not generated by a
5
+ language model, so they **resolve by construction**. A hallucinated-citation guard rejects any DOI that is
6
+ not in the curated, already-verified set. Numbers in the rationale remain tool-sourced (the prose is
7
+ a presentation layer over verified facts; no quantity is invented).
8
+
9
+ Generalisation toward adjacent genetic-engineering tasks is **grounded-or-refused**, a task is
10
+ answered only if it maps to an existing grounded capability; anything else is refused with a scope statement,
11
+ never faked.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from functools import lru_cache
16
+
17
+ import yaml
18
+
19
+ from pen_stack._resources import resource
20
+
21
+
22
+ @lru_cache(maxsize=1)
23
+ def curated_dois() -> frozenset[str]:
24
+ """The set of DOIs that appear in the CURATED, already-verified world-model (delivery palette, GSH loci,
25
+ writer panel, rule provenance). A citation is 'grounded' iff its DOI is in this set, so a citation can
26
+ only ever point at a source the substrate has actually curated (no hallucinated references)."""
27
+ dois: set[str] = set()
28
+ veh = yaml.safe_load(resource("configs/delivery_vehicles.yaml").read_text(encoding="utf-8"))["vehicles"]
29
+ for v in veh.values():
30
+ dois.update(v.get("dois", []) or [])
31
+ dois.update((v.get("immune_safety") or {}).get("immune_dois", []) or []) # immune priors
32
+ # computed-oracle provenance (genotoxicity: VISDB/COSMIC; capsid epitope: MHCflurry/HLA)
33
+ for _art in ("configs/genotoxicity_oracle.yaml", "configs/capsid_epitope_oracle.yaml"):
34
+ try:
35
+ a = yaml.safe_load(resource(_art).read_text(encoding="utf-8"))
36
+ dois.update(a.get("provenance_dois", []) or [])
37
+ except FileNotFoundError:
38
+ pass
39
+ # computed innate-sensing provenance (CpG-TLR9 / AAV CpG-depletion / RNA modification)
40
+ from pen_stack.planner.innate_sensing import PROVENANCE_DOIS as _innate_dois
41
+ dois.update(_innate_dois)
42
+ # off-target nomination provenance (GUIDE/CIRCLE/CHANGE/SITE-seq assays + CRISOT predictor + LSI assays)
43
+ try:
44
+ from pen_stack.wgenome.offtarget_data import (
45
+ ASSAY_PROVENANCE,
46
+ INTEGRASE_ASSAY_PROVENANCE,
47
+ PREDICTOR_PROVENANCE,
48
+ )
49
+ for _src in (ASSAY_PROVENANCE, PREDICTOR_PROVENANCE, INTEGRASE_ASSAY_PROVENANCE):
50
+ for _rec in _src.values():
51
+ if _rec.get("doi"):
52
+ dois.add(_rec["doi"])
53
+ except Exception: # noqa: BLE001
54
+ pass
55
+ # delivery provenance (FLIP-AAV capsid-fitness + approved-therapy serotype->tissue priors)
56
+ try:
57
+ trop = yaml.safe_load(resource("configs/aav_serotype_tropism.yaml").read_text(encoding="utf-8")) or {}
58
+ dois.update(trop.get("provenance_dois", []) or [])
59
+ for _rec in (trop.get("serotypes") or {}).values():
60
+ if _rec.get("doi"):
61
+ dois.add(_rec["doi"])
62
+ dois.update(["10.1101/2021.11.09.467890", "10.1038/s41587-020-00793-4", "10.1126/science.aaw2900"])
63
+ except Exception: # noqa: BLE001
64
+ pass
65
+ # anti-vector seroprevalence provenance (serosurveys)
66
+ try:
67
+ sp = yaml.safe_load(resource("configs/seroprevalence.yaml").read_text(encoding="utf-8"))
68
+ for _rec in (sp.get("serotypes") or {}).values():
69
+ dois.update(_rec.get("dois", []) or [])
70
+ except FileNotFoundError:
71
+ pass
72
+ # anti-PEG serosurvey provenance
73
+ try:
74
+ ap = yaml.safe_load(resource("configs/antipeg.yaml").read_text(encoding="utf-8"))
75
+ dois.update((ap.get("prevalence") or {}).get("dois", []) or [])
76
+ except FileNotFoundError:
77
+ pass
78
+ # route/immune-privilege modifier provenance
79
+ from pen_stack.planner.immune_profile import ROUTE_MODIFIER_DOI
80
+ dois.add(ROUTE_MODIFIER_DOI)
81
+ gsh = yaml.safe_load(resource("configs/gsh_validated_heldout.yaml").read_text(encoding="utf-8"))["gsh"]
82
+ for g in gsh:
83
+ if g.get("doi"):
84
+ dois.add(g["doi"])
85
+ import csv
86
+ with open(resource("data/writer_panel.csv"), encoding="utf-8") as f:
87
+ for row in csv.DictReader(f):
88
+ if row.get("doi"):
89
+ dois.add(row["doi"])
90
+ # rule provenance DOIs
91
+ from pen_stack.rules import load_ruleset
92
+ for r in load_ruleset().rules:
93
+ dois.update(r.provenance.get("doi", []) or [])
94
+ # biosecurity-standards alignment provenance (IBBIS Common Mechanism + SecureDNA)
95
+ try:
96
+ from pen_stack.safety.standards import COMMON_MECHANISM, SECUREDNA
97
+ dois.add(COMMON_MECHANISM["citation_doi"])
98
+ dois.add("10.48550/arXiv." + SECUREDNA["citation_arxiv"])
99
+ except Exception: # noqa: BLE001
100
+ pass
101
+ return frozenset(dois)
102
+
103
+
104
+ def citations_grounded(dois: list[str]) -> dict:
105
+ """Hallucinated-citation guard: every cited DOI must be in the curated set. Returns the verdict + any
106
+ ungrounded DOIs (which would be a hallucination and are rejected)."""
107
+ curated = curated_dois()
108
+ ungrounded = [d for d in dois if d not in curated]
109
+ return {"all_grounded": not ungrounded, "ungrounded": ungrounded, "n_checked": len(dois)}
110
+
111
+
112
+ def cited_rationale(design: dict) -> dict:
113
+ """A short, literature-cited mechanistic 'why' for a design, with citations DRAWN FROM the curated
114
+ world-model (so they resolve by construction). Numbers are tool-sourced; the prose is presentation only."""
115
+ from pen_stack.graph import build_graph
116
+ from pen_stack.verify import verify
117
+ g = build_graph()
118
+ v = verify(design)
119
+ fam = design.get("writer_family")
120
+ veh = design.get("delivery_vehicle")
121
+ cites: list[dict] = []
122
+ wnode = g.nodes.get(f"writer:{fam}")
123
+ if wnode:
124
+ for d in wnode.props.get("dois", [])[:1]:
125
+ cites.append({"doi": d, "claim": f"{fam} mechanism / characterisation", "source": "world-model writer"})
126
+ vnode = g.nodes.get(f"vehicle:{veh}")
127
+ if vnode:
128
+ for d in vnode.props.get("dois", [])[:1]:
129
+ cites.append({"doi": d, "claim": f"{veh} delivery properties", "source": "world-model vehicle"})
130
+ form = wnode.props.get("output_form") if wnode else None
131
+ verdict = "legal" if v.legal else "illegal"
132
+ rationale = (f"{fam} ({form}-form) installs a {design.get('cargo_bp')} bp cargo via {veh}; the rule-grounded "
133
+ f"verifier judges this {verdict}"
134
+ + (f" (violated: {', '.join(x['rule_id'] for x in v.violations)})" if not v.legal else "")
135
+ + (f"; calibrated confidence {v.confidence}" if v.confidence is not None else
136
+ "; confidence abstained (unscored)") + ".")
137
+ guard = citations_grounded([c["doi"] for c in cites])
138
+ return {"design": {k: val for k, val in design.items() if not str(k).startswith("_")},
139
+ "rationale": rationale, "citations": cites, "n_citations": len(cites),
140
+ "citations_grounded": guard["all_grounded"], "ungrounded_citations": guard["ungrounded"],
141
+ "no_fabrication": v.no_fabrication and guard["all_grounded"],
142
+ "note": "citations are drawn from the curated world-model (resolve by construction); numbers are "
143
+ "verifier-sourced; the prose is a presentation layer (no fabricated quantity)."}
144
+
145
+
146
+ # --------------------------------------------------------------------------------------------------
147
+ # Scoped generalisation: grounded-or-refused.
148
+ # --------------------------------------------------------------------------------------------------
149
+ # adjacent genetic-engineering tasks that MAP to an existing grounded capability (answerable);
150
+ # anything else is refused with a scope statement.
151
+ _GROUNDED_TASKS = {
152
+ "delivery_selection": "maps to the delivery rules (cargo-form + capacity + integration)",
153
+ "write_type_legality": "maps to the write-type router + verifier",
154
+ "off_target_screen": "maps to the bridge off-target engine (a screen, not a per-site calculator)",
155
+ "writer_variant_critique": "maps to the writer-verification branch (score/critique, never invent)",
156
+ }
157
+
158
+
159
+ def generalise(task: str, payload: dict | None = None) -> dict:
160
+ """Answer an adjacent genetic-engineering task ONLY if it maps to a grounded capability; otherwise REFUSE
161
+ with a scope statement (Principle 4: grounded-or-refused, never faked)."""
162
+ payload = payload or {}
163
+ if task not in _GROUNDED_TASKS:
164
+ return {"task": task, "grounded": False, "refused": True,
165
+ "scope_statement": f"'{task}' has no grounding in PEN-STACK; refused rather than faked. "
166
+ f"Grounded tasks: {sorted(_GROUNDED_TASKS)}.",
167
+ "no_fabrication": True}
168
+ result: dict = {"task": task, "grounded": True, "refused": False,
169
+ "grounding": _GROUNDED_TASKS[task], "no_fabrication": True}
170
+ if task in ("delivery_selection", "write_type_legality") and payload:
171
+ from pen_stack.verify import verify
172
+ v = verify(payload)
173
+ result["verdict"] = {"legal": v.legal, "confidence": v.confidence,
174
+ "violations": [x["rule_id"] for x in v.violations]}
175
+ return result