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,123 @@
1
+ """Nuclease off-target FINDER: enumerate -> CRISOT -> risk -> chromatin.
2
+
3
+ Chains the genome-wide enumeration into the existing, validated nuclease scorer, so a GUIDE returns a
4
+ genome-wide ranked off-target set rather than a hand-supplied one. The scoring is
5
+ UNCHANGED from the prior release, the real CRISOT-Score, the mismatch-calibrated risk band, and the chromatin annotation
6
+ (validated, not a re-ranker). v2 only adds the enumeration front end. Status: **validated** (CRISOT beats
7
+ homology on four unbiased assays; enumeration reproduces the documented off-target set).
8
+
9
+ Enumeration runs on the VM; this surface replays the committed cache or abstains (so the finder works for the
10
+ cached guides and abstains for a novel one), exactly like the heavy oracles.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from pen_stack.wgenome.offtarget_data import BENCH_SUMMARY, CHROMATIN_VALIDATION, calibrated_active_fraction
15
+ from pen_stack.wgenome.offtarget_enumerate import _ENZYME, enumerate_offtargets, resolve_enzyme
16
+ from pen_stack.wgenome.offtarget_predict import (
17
+ _chromatin_modifier,
18
+ _crisot_cache,
19
+ _ham20,
20
+ _risk_band,
21
+ locus_accessibility,
22
+ )
23
+
24
+ _BIN = 1000 # accessibility track bins are 1 kb (matches the off-target chromatin validation)
25
+ _CAS12A = {"AsCas12a", "LbCas12a"} # enumeration-supported, but the CRISOT scorer + risk calibration are SpCas9-only
26
+
27
+
28
+ def unsupported_nuclease_abstention(enzyme: str | None, mode: str = "finder") -> dict | None:
29
+ """Return an abstention when no validated off-target model covers ``enzyme``, else None.
30
+
31
+ The off-target scorer (CRISOT-Score) and the mismatch-calibrated empirical risk band are both SpCas9-specific,
32
+ so every nuclease path declines rather than apply a SpCas9 model to a different enzyme. Two cases decline. A
33
+ Cas12a variant can be ENUMERATED but not scored, so it carries the Cas12a note. Any other named enzyme the
34
+ registry does not carry would otherwise be substituted by SpCas9 and returned under the caller's name, with a
35
+ PAM the caller never asked for, so it declines with a note naming what is carried. Only an unnamed enzyme falls
36
+ through to the SpCas9 default.
37
+
38
+ The gate is shared by the genome-wide finder and the score-my-candidates path so the two cannot drift: a caller
39
+ who supplies candidate sites gets the same abstention as a caller who does not.
40
+ """
41
+ requested = (enzyme or "").strip()
42
+ enz = resolve_enzyme(requested)
43
+ if enz in _CAS12A:
44
+ status, note = "unvalidated", (
45
+ "Cas12a genome-wide enumeration is supported (Cas-OFFinder, TTTV 5' PAM), but the validated off-target "
46
+ "scorer (CRISOT-Score) and the mismatch-calibrated empirical risk band are SpCas9-specific. No "
47
+ "validated Cas12a off-target model is mounted, so this path does not rank Cas12a sites with a SpCas9 "
48
+ "scorer, which would mis-rank them. AsCas12a is listed as a Tier-1 nuclease in the Writer Atlas; a "
49
+ "validated Cas12a off-target scorer is future work.")
50
+ elif requested and enz is None:
51
+ status, note = "unsupported_enzyme", (
52
+ f"No validated off-target model is mounted for {requested!r}. The enumerator carries "
53
+ f"{', '.join(sorted(_ENZYME))}, each with its own PAM, and the scorer (CRISOT-Score) with its "
54
+ f"mismatch-calibrated risk band is SpCas9-specific. Scoring this request would report SpCas9 sites "
55
+ f"under a different enzyme's name and PAM.")
56
+ else:
57
+ return None
58
+ return {"family": "nuclease", "enzyme": enz or requested or None, "available": False, "abstain": True,
59
+ "mode": mode, "status": status, "cached_guides": [], "nomination_is_not_clearance": True, "note": note}
60
+
61
+
62
+ def find_nuclease_offtargets(guide: str, enzyme: str = "SpCas9", max_mismatch: int = 5, assay: str = "guideseq",
63
+ cell_type: str = "k562", top: int = 50) -> dict:
64
+ """Genome-wide nuclease off-target FINDER. Enumerates every genomic site within ``max_mismatch`` for the guide
65
+ (Cas-OFFinder over GRCh38, replayed from cache), scores each with the real CRISOT-Score + the mismatch-
66
+ calibrated empirical risk band, and annotates chromatin accessibility (from the accessibility track when present). Returns
67
+ the genome-wide ranked candidates with coordinates, or an abstention when the guide is not cached and no
68
+ VM scan is available. Never fabricates sites."""
69
+ declined = unsupported_nuclease_abstention(enzyme, mode="finder")
70
+ if declined is not None:
71
+ return declined
72
+ enz = resolve_enzyme(enzyme) or "SpCas9"
73
+ enum = enumerate_offtargets(guide, enz, max_mismatch)
74
+ if enum.get("abstain"):
75
+ return {"family": "nuclease", "enzyme": enz, "available": False, "abstain": True, "mode": "finder",
76
+ "status": "validated", "note": enum["note"], "cached_guides": enum.get("cached_guides", []),
77
+ "nomination_is_not_clearance": True}
78
+ g20 = enum["guide"][:20]
79
+ cache = _crisot_cache()
80
+ noms = []
81
+ for s in enum["sites"]:
82
+ seq = s["sequence"].upper()
83
+ nmm = int(s.get("n_mismatch", _ham20(g20, seq)))
84
+ af = calibrated_active_fraction(nmm, assay)
85
+ crisot = cache.get((g20, seq[:20]))
86
+ locus_acc = locus_accessibility(s["chrom"], s["position"] // _BIN, cell_type) # REAL accessibility track (or None)
87
+ noms.append({"chrom": s["chrom"], "position": s["position"], "strand": s["strand"], "site": seq,
88
+ "n_mismatch": nmm, "empirical_active_fraction": af, "risk_band": _risk_band(af),
89
+ "crisot_score": crisot,
90
+ "crisot_source": "cached_bench" if crisot is not None else "on-VM tool (not cached)",
91
+ "chromatin": _chromatin_modifier(None, locus_acc), "output_kind": "candidate"})
92
+ # rank by the real CRISOT score where cached, else by empirical risk (descending), then fewer mismatches
93
+ noms.sort(key=lambda n: (n["crisot_score"] if n["crisot_score"] is not None else -1.0,
94
+ n["empirical_active_fraction"] or 0.0, -n["n_mismatch"]), reverse=True)
95
+ n_on = sum(1 for n in noms if n["n_mismatch"] == 0)
96
+ # chromatin is a REAL accessibility annotation, populated per-site only when the accessibility track is mounted.
97
+ # Be explicit instead of silently returning null everywhere (which reads as if the layer were delivered).
98
+ chromatin_available = any(n.get("chromatin") is not None for n in noms)
99
+ _cv = CHROMATIN_VALIDATION["auroc_accessibility_for_activity"]["guideseq"]
100
+ chromatin_note = (
101
+ "Chromatin accessibility is a VALIDATED standalone annotation (open chromatin predicts cell-based "
102
+ "off-target activity: GUIDE-seq AUROC 0.671, cell-type-matched HEK293T DNase) but is ANNOTATION-ONLY, it "
103
+ "does NOT change the CRISOT-driven ranking. It is populated per-site only when the accessibility "
104
+ "track (chromatin_{ct}.parquet) is mounted; that track is not present on this deployment, so the per-site "
105
+ "column is omitted (ranking unaffected)." if not chromatin_available else
106
+ "Per-site chromatin accessibility from the mounted track, annotation-only, does not change the "
107
+ "CRISOT-driven ranking.")
108
+ return {"family": "nuclease", "enzyme": enz, "available": True, "abstain": False, "mode": "finder",
109
+ "guide": g20, "pam": enum["pam"], "source": enum["source"], "max_mismatch": int(max_mismatch),
110
+ "n_sites_genome_wide": enum["n_sites"], "n_on_target": n_on, "n_offtargets": len(noms) - n_on,
111
+ "assay_calibration": assay, "nominations": noms[:top], "bench": BENCH_SUMMARY.get(assay),
112
+ "status": "validated",
113
+ "chromatin_available": chromatin_available, "chromatin_note": chromatin_note,
114
+ "chromatin_validation": {"verdict": CHROMATIN_VALIDATION["verdict"], "guideseq_auroc": _cv["auroc"],
115
+ "ci95": _cv["ci95"], "matched_track": CHROMATIN_VALIDATION["matched_track"],
116
+ "is_reranker": False},
117
+ "method": ("genome-wide Cas-OFFinder enumeration over GRCh38 -> real CRISOT-Score -> mismatch-"
118
+ "calibrated risk band" + (" -> chromatin accessibility annotation (validated, annotation-"
119
+ "only, NOT a re-ranker)" if chromatin_available else
120
+ "; chromatin accessibility is a validated annotation surfaced when the accessibility track is "
121
+ "mounted (annotation-only, not a re-ranker)")),
122
+ "honesty": "genome-wide CANDIDATES, NOT a clearance; confirm with the recommended empirical assay",
123
+ "nomination_is_not_clearance": True}
@@ -0,0 +1,41 @@
1
+ """PASTE / (ee)PASSIGE off-target composition.
2
+
3
+ PASTE installs a landing-site attB with a prime editor (a Cas9 **nickase** + reverse transcriptase directed by a
4
+ pegRNA), then a serine **integrase** (Bxb1) inserts the cargo at the installed attB. Its off-target profile is
5
+ therefore the COMPOSITION of two mechanisms, each with its own status:
6
+ * the Cas9-nickase off-targets of the pegRNA spacer -> the validated nuclease finder, and
7
+ * the integrase pseudo-attP sites of the installed att -> the semi-validated integrase scan.
8
+
9
+ Returns BOTH candidate sets, labelled by component, and recommends BOTH a nuclease assay and an integrase assay.
10
+ Never fabricates; each component carries its own truthful status.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from pen_stack.wgenome.offtarget_integrase import nominate_integrase
15
+ from pen_stack.wgenome.offtarget_nuclease import find_nuclease_offtargets
16
+
17
+
18
+ def nominate_paste(guide: str | None = None, integrase: str = "Bxb1", max_mismatch: int = 5,
19
+ assay: str = "guideseq", cell_type: str = "k562", top: int = 20) -> dict:
20
+ """Compose the nuclease (pegRNA-directed Cas9 nickase) and integrase (installed attB) off-target components.
21
+ Returns both candidate sets with their own statuses + the dual confirm-assay recommendation. Abstains per
22
+ component when its input/cache is unavailable (never fabricates)."""
23
+ nuclease = (find_nuclease_offtargets(guide, "SpCas9", max_mismatch=max_mismatch, assay=assay,
24
+ cell_type=cell_type, top=top)
25
+ if guide else {"available": False, "abstain": True, "status": "validated",
26
+ "note": "provide the pegRNA spacer (guide) for the Cas9-nickase off-target scan"})
27
+ integ = nominate_integrase(integrase, top=top)
28
+ statuses = {"nuclease_component": nuclease.get("status", "validated"),
29
+ "integrase_component": integ.get("status", "mechanism_based_unvalidated")}
30
+ return {"family": "paste", "available": True, "abstain": False, "status": "composite",
31
+ "component_statuses": statuses,
32
+ "nuclease_component": nuclease, # Cas9-nickase off-targets (pegRNA-directed), validated finder
33
+ "integrase_component": integ, # installed-attB pseudo-attP, semi-validated scan
34
+ "confirm_assay": {"nuclease": ["GUIDE-seq", "CHANGE-seq"], "integrase": ["Cryptic-seq", "HIDE-seq"],
35
+ "note": "PASTE requires BOTH a nuclease off-target assay (for the nickase) AND an "
36
+ "integrase off-target assay (for the installed att)."},
37
+ "method": ("compose the validated nuclease finder (nickase, pegRNA spacer) with the semi-validated "
38
+ "integrase pseudo-attP scan (installed att); return both candidate sets by component"),
39
+ "honesty": "two component candidate sets, each with its own status; NOT a clearance. Confirm with "
40
+ "both a nuclease assay and an integrase assay.",
41
+ "nomination_is_not_clearance": True}
@@ -0,0 +1,282 @@
1
+ """Cross-writer-family, chromatin-aware off-target NOMINATION engine.
2
+
3
+ One interface over nucleases, serine integrases, and bridge recombinases. By construction:
4
+
5
+ * **nuclease** (Cas9): rank candidate off-target sites by the GROUNDED empirical risk (the real-data fraction
6
+ of candidates at k mismatches that were validated-active, `offtarget_data.MISMATCH_ACTIVE_FRACTION`), surface
7
+ the REAL CRISOT-Score when the (guide, site) is in the cached bench, and annotate with a documented
8
+ **chromatin-accessibility signal** (a validated annotation, NOT a re-ranker: open chromatin raises realized
9
+ off-target activity, Lazzarotto 2020, but it added no held-out ranking gain over CRISOT in an
10
+ incremental analysis, so it is not folded into the risk score). The annotation
11
+ reads the **chromatin accessibility track** (`phase_1/features/chromatin_{ct}.parquet`) when a candidate's
12
+ genomic locus + cell type are supplied AND the feature store is present, or accepts a caller-supplied
13
+ accessibility value; it **abstains** otherwise (the bare wheel / current deployed atlas do not ship the raw
14
+ accessibility track). The Off-Target-Bench shows the real CRISOT predictor BEATS the homology baseline on
15
+ FOUR unbiased assays (GUIDE/CIRCLE/CHANGE/SITE-seq; per-guide bootstrap CI excludes 0 on each).
16
+ * **serine integrase** (Bxb1): nominate cryptic **pseudo-attB** sites using the REAL documented Bxb1 attB core
17
+ (GCGGTCTC / central GT), candidate-flagged; the validating assay is Cryptic-seq/HIDE-seq.
18
+ * **bridge / seek**: delegate to the existing Perry-DMS pseudosite engine (`pen_stack.bridge.offtarget`).
19
+
20
+ A nominated off-target is a CANDIDATE, never a claim, and nomination is **not** a safety clearance, every result
21
+ ships with the empirical assay that would confirm it (`offtarget_assay`). Abstains without inputs; never invents
22
+ sites. The CRISOT predictor (CC-BY-NC) runs only on the VM; only derived scores are cached.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ from functools import lru_cache
27
+
28
+ from pen_stack.wgenome.offtarget_data import (
29
+ BENCH_SUMMARY,
30
+ bench_records,
31
+ calibrated_active_fraction,
32
+ )
33
+
34
+ _NUCLEASE = {"cas9", "spcas9", "nuclease", "sacas9", "ascas12a", "cas12a", "nickase"}
35
+ _INTEGRASE = {"serine_integrase", "bxb1", "phic31", "pe_integrase"}
36
+ _BRIDGE = {"bridge_is110", "seek_is1111", "bridge", "is110", "is621", "iscro4"}
37
+ _PASTE = {"paste", "passige", "ee_passige", "eepassige", "prime_editing_integrase"}
38
+ _CAST = {"cast", "shcast", "vchcast", "evocast", "cast_vk", "cast_v-k", "cast_if", "cas12k", "type_v-k", "type_i-f"}
39
+
40
+
41
+ def _ham20(a: str, b: str) -> int:
42
+ """Mismatches over the 20-nt protospacer (Hamming; ignores the PAM)."""
43
+ a, b = a[:20].upper(), b[:20].upper()
44
+ return sum(1 for x, y in zip(a, b) if x != y)
45
+
46
+
47
+ def _risk_band(active_fraction: float | None) -> str:
48
+ if active_fraction is None:
49
+ return "uncalibrated"
50
+ if active_fraction >= 0.5:
51
+ return "high"
52
+ if active_fraction >= 0.1:
53
+ return "medium"
54
+ if active_fraction >= 0.01:
55
+ return "low"
56
+ return "minimal"
57
+
58
+
59
+ @lru_cache(maxsize=1)
60
+ def _crisot_cache() -> dict:
61
+ """(On20, Off20) -> cached real CRISOT score, from the committed bench fixture (VM-computed)."""
62
+ out: dict = {}
63
+ for r in bench_records():
64
+ out[(r["On"][:20].upper(), r["Off"][:20].upper())] = r["crisot_score"]
65
+ return out
66
+
67
+
68
+ @lru_cache(maxsize=8)
69
+ def _chromatin_store(ct: str):
70
+ """The per-cell-type chromatin feature store (chrom,bin -> unified accessibility) + its median, or
71
+ None when the raw track is not present (bare wheel / CI / current deployed atlas). Real, data-gated."""
72
+ try:
73
+ import pandas as pd
74
+
75
+ from pen_stack._resources import project_root
76
+ from pen_stack.wgenome.features import add_accessibility
77
+ p = project_root() / "phase_1" / "features" / f"chromatin_{ct.lower()}.parquet"
78
+ if not p.exists():
79
+ return None
80
+ df = add_accessibility(pd.read_parquet(p))
81
+ if "accessibility" not in df.columns:
82
+ return None
83
+ med = float(df["accessibility"].median())
84
+ return df.set_index(["chrom", "bin"]), med
85
+ except Exception: # noqa: BLE001
86
+ return None
87
+
88
+
89
+ def locus_accessibility(chrom: str, bin_idx: int, ct: str = "k562") -> dict | None:
90
+ """REAL chromatin accessibility for a genomic bin (ATAC/DNase), or None (abstain) when the chromatin feature
91
+ store is absent or the bin is off-grid. `accessible` = above the cell-type median (relative, grounded)."""
92
+ store = _chromatin_store(ct)
93
+ if store is None:
94
+ return None
95
+ df, med = store
96
+ try:
97
+ val = float(df.loc[(chrom, int(bin_idx)), "accessibility"])
98
+ except Exception: # noqa: BLE001
99
+ return None
100
+ return {"accessibility_signal": round(val, 4), "cell_type": ct, "median": round(med, 4),
101
+ "accessible": bool(val >= med), "source": "chromatin_{ct}.parquet (ATAC/DNase)"}
102
+
103
+
104
+ def _chromatin_modifier(accessibility: float | None = None, locus_acc: dict | None = None) -> dict | None:
105
+ """Documented chromatin-accessibility ANNOTATION (NOT a validated quantitative axis): open chromatin raises
106
+ realized off-target activity (Lazzarotto et al. 2020, CHANGE-seq, 10.1038/s41587-020-0555-7). Uses the REAL
107
+ chromatin accessibility (`locus_acc`) when available, else a caller-supplied 0..1 scalar; abstains (None) when
108
+ neither is given. It is QUALITATIVE and does NOT change the numeric risk score, a controlled test on our data
109
+ found the signal weak/inconsistent (see `offtarget_data.CHROMATIN_VALIDATION`)."""
110
+ val = {"validated": True, "effect_size": "moderate", "changes_risk_score": False,
111
+ "validation": "validated standalone (moderate, cell-type-matched: GUIDE-seq off-target AUROC 0.58 "
112
+ "cross-cell -> 0.671 matched HEK293T DNase). ANNOTATION ONLY, tested the incremental value over "
113
+ "CRISOT: small real conditional signal but NO held-out ranking improvement, so it does NOT change the "
114
+ "numeric risk score (CRISOT sequence score captures the ranking).",
115
+ "doi": "10.1038/s41587-020-0555-7"}
116
+ if locus_acc is not None:
117
+ raises = bool(locus_acc.get("accessible"))
118
+ return {**val, "raises_realized_risk": raises, "source": "stage_b_track",
119
+ "accessibility_signal": locus_acc.get("accessibility_signal"), "cell_type": locus_acc.get("cell_type"),
120
+ "effect": ("accessible (open) chromatin in this cell type -> realized off-target activity tends "
121
+ "HIGHER for the same sequence match (qualitative; Lazzarotto 2020)" if raises
122
+ else "inaccessible (closed) chromatin -> realized off-target activity tends lower")}
123
+ if accessibility is None:
124
+ return None
125
+ acc = max(0.0, min(1.0, float(accessibility)))
126
+ raises = acc >= 0.5
127
+ return {**val, "accessibility": round(acc, 3), "raises_realized_risk": raises, "source": "caller_supplied",
128
+ "effect": ("open/accessible chromatin -> realized off-target activity tends HIGHER (qualitative; "
129
+ "Lazzarotto 2020)" if raises else "closed/inaccessible chromatin -> realized risk tends lower")}
130
+
131
+
132
+ def nominate_nuclease(guide: str, candidate_sites: list[str] | None, accessibility: list[float] | None = None,
133
+ assay: str = "guideseq", loci: list | None = None, cell_type: str = "k562",
134
+ top: int = 20) -> dict:
135
+ """Nominate + rank candidate nuclease off-target sites for a guide. ``candidate_sites`` are 20-23-nt genomic
136
+ protospacers (e.g. from a Cas-OFFinder scan). ``loci`` (optional) is a per-candidate (chrom, bin) for the REAL
137
+ chromatin-accessibility modifier; ``accessibility`` (optional) is a per-candidate 0..1 fallback scalar.
138
+ Abstains (no fabrication) when no candidates are supplied, genome-wide candidate ENUMERATION needs the on-VM
139
+ scan; this engine SCORES + RANKS + risk-bands them."""
140
+ g = (guide or "").upper()
141
+ if len(g) < 20:
142
+ return {"family": "nuclease", "available": False, "abstain": True,
143
+ "note": "guide must be >=20 nt (protospacer)"}
144
+ if not candidate_sites:
145
+ return {"family": "nuclease", "available": False, "abstain": True,
146
+ "note": "no candidate sites supplied, provide Cas-OFFinder/on-VM genome-scan candidates "
147
+ "(20-23 nt protospacers); this engine scores+ranks+risk-bands them, it does not "
148
+ "fabricate sites", "method": "homology + real-data mismatch calibration + cached CRISOT"}
149
+ cache = _crisot_cache()
150
+ g20 = g[:20]
151
+ noms = []
152
+ for i, site in enumerate(candidate_sites):
153
+ s = (site or "").upper()
154
+ nmm = _ham20(g20, s)
155
+ af = calibrated_active_fraction(nmm, assay)
156
+ crisot = cache.get((g20, s[:20]))
157
+ locus_acc = None
158
+ if loci and i < len(loci) and loci[i]:
159
+ locus_acc = locus_accessibility(loci[i][0], loci[i][1], cell_type) # REAL accessibility track (or None)
160
+ acc = accessibility[i] if (accessibility and i < len(accessibility)) else None
161
+ noms.append({"site": s, "n_mismatch": nmm, "empirical_active_fraction": af,
162
+ "risk_band": _risk_band(af), "crisot_score": crisot,
163
+ "crisot_source": ("cached_bench" if crisot is not None else "on-VM tool (not cached)"),
164
+ "chromatin": _chromatin_modifier(acc, locus_acc), "output_kind": "candidate"})
165
+ # rank by the real CRISOT score when available, else by empirical risk (descending), then fewer mismatches
166
+ noms.sort(key=lambda n: (n["crisot_score"] if n["crisot_score"] is not None else -1.0,
167
+ n["empirical_active_fraction"] or 0.0, -n["n_mismatch"]), reverse=True)
168
+ return {"family": "nuclease", "available": True, "abstain": False, "guide": g20,
169
+ "n_candidates": len(noms), "assay_calibration": assay,
170
+ "nominations": noms[:top],
171
+ "method": ("risk = empirical active fraction at k mismatches (real GUIDE-seq/CIRCLE-seq data); "
172
+ "ranked by the real CRISOT-Score when the (guide,site) is cached, else by empirical risk"),
173
+ "bench": BENCH_SUMMARY.get(assay), "output_kind": "candidate",
174
+ "honesty": "candidates, NOT a clearance, confirm with the recommended empirical assay"}
175
+
176
+
177
+ def pseudo_attb_sites(sequence: str, integrase: str = "Bxb1", max_arm_mismatch: int = 4, top: int = 20) -> dict:
178
+ """Nominate cryptic **pseudo-attB** sites in a provided sequence for a serine integrase, seeding on the REAL
179
+ documented attB core (e.g. Bxb1 GCGGTCTC / central GT) and tolerating arm mismatches. Candidate-flagged; the
180
+ validating assay is Cryptic-seq/HIDE-seq. Abstains when no documented att core is bundled for the integrase."""
181
+ from pen_stack.atlas.guide_design import _INTEGRASE_ATT
182
+ att = _INTEGRASE_ATT.get(integrase)
183
+ if not att:
184
+ return {"family": "serine_integrase", "available": False, "abstain": True,
185
+ "note": f"no documented att core bundled for {integrase!r}, cannot scan without fabricating"}
186
+ core, attb = att["core"].upper(), att["attB"].upper()
187
+ seq = (sequence or "").upper()
188
+ L = len(attb)
189
+ # locate the core within the full attB so we can align scanned windows to the documented site
190
+ c0 = attb.find(core)
191
+ hits = []
192
+ idx = seq.find(core)
193
+ while idx != -1:
194
+ start = idx - c0
195
+ if 0 <= start and start + L <= len(seq):
196
+ window = seq[start:start + L]
197
+ if "N" not in window:
198
+ arm_mm = sum(1 for a, b in zip(window, attb) if a != b)
199
+ if arm_mm <= max_arm_mismatch:
200
+ hits.append({"pos": start, "site": window, "arm_mismatch": arm_mm,
201
+ "core_matched": True, "output_kind": "candidate"})
202
+ idx = seq.find(core, idx + 1)
203
+ hits.sort(key=lambda h: h["arm_mismatch"])
204
+ return {"family": "serine_integrase", "integrase": integrase, "available": True, "abstain": False,
205
+ "att_core": core, "n_candidates": len(hits), "nominations": hits[:top],
206
+ "method": (f"cryptic pseudo-attB scan: seed on the documented {integrase} core {core} (central "
207
+ f"{att['central_dinucleotide']}), verify the {L}-nt attB with <= {max_arm_mismatch} arm "
208
+ "mismatches"),
209
+ "validating_assay": "Cryptic-seq / HIDE-seq (Hazelbaker et al., bioRxiv 2024.08.23.609471)",
210
+ "honesty": "CANDIDATE cryptic sites, recombination at a pseudo-attB is not claimed; confirm by "
211
+ "Cryptic-seq/HIDE-seq. Quantitative LSI off-target prediction (IntQuery) is paper-only "
212
+ "(no public weights), not run here."}
213
+
214
+
215
+ def nominate_offtargets(writer_family: str, guide: str | None = None, candidate_sites: list[str] | None = None,
216
+ sequence: str | None = None, accessibility: list[float] | None = None,
217
+ target_core: str | None = None, fasta=None, chroms: list[str] | None = None,
218
+ assay: str = "guideseq", loci: list | None = None, cell_type: str = "k562",
219
+ enzyme: str | None = None, max_mismatch: int = 5) -> dict:
220
+ """Cross-family dispatcher. Returns a nomination dict (candidates, never claims) + the recommended validation
221
+ assay, or an abstention. Never fabricates off-target sites. ``loci`` (per-candidate (chrom, bin)) +
222
+ ``cell_type`` enable the REAL chromatin-accessibility modifier for nucleases.
223
+
224
+ For a nuclease guide WITHOUT supplied ``candidate_sites``, this runs the genome-wide FINDER
225
+ (enumerate GRCh38 -> CRISOT -> risk -> chromatin) by default. Supplying ``candidate_sites`` keeps the
226
+ score-my-candidates path (backward compatible)."""
227
+ from pen_stack.wgenome.offtarget_assay import recommend_assay
228
+ fam = (writer_family or "").lower()
229
+ assay_rec = recommend_assay(writer_family)
230
+ # PASTE first (its name embeds an integrase) → compose nuclease + integrase
231
+ if fam in _PASTE or "paste" in fam or "passige" in fam:
232
+ from pen_stack.wgenome.offtarget_paste import nominate_paste
233
+ res = nominate_paste(guide=guide, integrase="Bxb1", max_mismatch=max_mismatch, assay=assay,
234
+ cell_type=cell_type)
235
+ elif fam in _CAST or "cast" in fam or "cas12k" in fam:
236
+ from pen_stack.wgenome.offtarget_cast import nominate_cast
237
+ res = nominate_cast(system=enzyme or writer_family or "ShCAST", spacer=guide, max_mismatch=max_mismatch)
238
+ elif fam in _NUCLEASE or "cas9" in fam or "nuclease" in fam or "cas12" in fam:
239
+ from pen_stack.wgenome.offtarget_nuclease import find_nuclease_offtargets, unsupported_nuclease_abstention
240
+ # The CRISOT scorer and the mismatch-calibrated risk band are SpCas9-specific, so BOTH nuclease paths are
241
+ # gated on the same abstention: supplying candidate_sites must not route around the finder's gate.
242
+ declined = unsupported_nuclease_abstention(enzyme or writer_family,
243
+ mode="candidate_scoring" if candidate_sites else "finder")
244
+ if declined is not None:
245
+ res = declined
246
+ elif candidate_sites: # score/rank the caller-supplied candidates
247
+ res = nominate_nuclease(guide or "", candidate_sites, accessibility, assay, loci=loci,
248
+ cell_type=cell_type)
249
+ else: # finder: enumerate genome-wide, then score (replays the VM cache or abstains)
250
+ res = find_nuclease_offtargets(guide or "", enzyme=enzyme or writer_family or "SpCas9",
251
+ max_mismatch=max_mismatch, assay=assay, cell_type=cell_type)
252
+ elif fam in _INTEGRASE or "integrase" in fam or "bxb1" in fam or "phic31" in fam:
253
+ from pen_stack.wgenome.offtarget_integrase import nominate_integrase, resolve_integrase
254
+ # honour the explicit integrase/system selector (``enzyme``); fall back to the family string. This is why
255
+ # system="phiC31" must reach the phiC31 branch instead of defaulting to Bxb1.
256
+ sel = enzyme or ("PhiC31" if "phic31" in fam else "Bxb1")
257
+ integrase = resolve_integrase(sel)
258
+ if integrase is None:
259
+ # A named integrase with no curated attachment-site record. The pseudo-att scan is driven by the att
260
+ # core of the SPECIFIC enzyme, so substituting another integrase's att would report a Bxb1 scan under a
261
+ # different enzyme's name. Abstain and name what is carried instead.
262
+ from pen_stack.wgenome.offtarget_integrase import integrase_att
263
+ known = sorted((integrase_att().get("integrases") or {}).keys())
264
+ res = {"family": "serine_integrase", "requested_integrase": sel, "available": False, "abstain": True,
265
+ "status": "unsupported_integrase", "nomination_is_not_clearance": True,
266
+ "note": (f"No curated attachment-site record for integrase {sel!r}, so the pseudo-att scan does "
267
+ f"not run: it is driven by the att core of the specific enzyme, and substituting a "
268
+ f"different integrase's att would misattribute the sites. Carried integrases: "
269
+ f"{', '.join(known)}.")}
270
+ elif sequence: # scan a supplied locus for cryptic pseudo-attB
271
+ res = pseudo_attb_sites(sequence, integrase)
272
+ else: # genome-wide pseudo-attP scan
273
+ res = nominate_integrase(integrase)
274
+ elif fam in _BRIDGE or "bridge" in fam or "is110" in fam or "is621" in fam or "seek" in fam:
275
+ from pen_stack.wgenome.offtarget_bridge import nominate_bridge
276
+ res = nominate_bridge(target_core=target_core, writer_family=writer_family, fasta=fasta, chroms=chroms)
277
+ else:
278
+ res = {"family": writer_family, "available": False, "abstain": True,
279
+ "note": f"no off-target nomination model for family {writer_family!r}"}
280
+ res["recommended_assay"] = assay_rec
281
+ res["nomination_is_not_clearance"] = True
282
+ return res
@@ -0,0 +1,135 @@
1
+ """Out-of-distribution / extrapolation detection.
2
+
3
+ A model's prediction is only as trustworthy as the resemblance of the query to what the model was trained
4
+ on. This detector scores how far a query sits from the training feature distribution and, when far,
5
+ **widens the conformal interval / lowers the confidence** (the hook into :mod:`wgenome.uncertainty`). It is
6
+ the difference between "grounded-confident" and "grounded-extrapolating" in the epistemic taxonomy.
7
+
8
+ Methods (pick per data size):
9
+ * ``mahalanobis`` (default) - distance to the training mean under the (shrunk) training covariance;
10
+ natural for the low-dimensional chromatin/distance feature spaces here.
11
+ * ``knn`` - mean distance to the k nearest training points (non-parametric; robust to non-Gaussianity).
12
+ * ``isolation_forest`` - tree-based density (sklearn), for larger feature sets.
13
+
14
+ Scope: OOD is defined **relative to the training data, not ground truth** - it flags "far from what I
15
+ have seen," never "wrong." The threshold is calibrated on a held-out in-distribution-vs-deliberately-OOD
16
+ construction (e.g. a held-out cell type or a sequence-divergent locus class) and the separation AUROC is
17
+ reported; it is a heuristic confidence signal, not a guarantee.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass, field
22
+
23
+ import numpy as np
24
+
25
+ from pen_stack.adapt.report import _auroc
26
+
27
+
28
+ @dataclass
29
+ class OODDetector:
30
+ method: str = "mahalanobis"
31
+ k: int = 10
32
+ shrinkage: float = 1e-3 # ridge on the covariance (numerical + small-N stability)
33
+ # fitted state
34
+ mean_: np.ndarray | None = None
35
+ inv_cov_: np.ndarray | None = None
36
+ train_: np.ndarray | None = None
37
+ iforest_: object | None = None
38
+ threshold_: float = float("nan")
39
+ train_quantiles_: dict = field(default_factory=dict)
40
+
41
+ # ---- fit -------------------------------------------------------------------------------------
42
+ def fit(self, X_train) -> "OODDetector":
43
+ X = np.asarray(X_train, float)
44
+ X = np.nan_to_num(X, nan=0.0)
45
+ self.mean_ = X.mean(axis=0)
46
+ if self.method == "mahalanobis":
47
+ cov = np.cov(X, rowvar=False)
48
+ cov = np.atleast_2d(cov) + self.shrinkage * np.eye(X.shape[1])
49
+ self.inv_cov_ = np.linalg.pinv(cov)
50
+ elif self.method == "knn":
51
+ self.train_ = X
52
+ try:
53
+ from scipy.spatial import cKDTree
54
+ self._kdtree = cKDTree(X) # O(log n) queries instead of an O(n) row-loop
55
+ except Exception: # noqa: BLE001 - scipy absent -> fall back to the brute-force loop
56
+ self._kdtree = None
57
+ elif self.method == "isolation_forest":
58
+ from sklearn.ensemble import IsolationForest
59
+ self.iforest_ = IsolationForest(n_estimators=200, random_state=42).fit(X)
60
+ else:
61
+ raise ValueError(f"unknown OOD method: {self.method}")
62
+ # store the in-distribution score distribution (for a default threshold + monotone widening)
63
+ s = self.score(X)
64
+ self.train_quantiles_ = {q: float(np.quantile(s, q)) for q in (0.5, 0.9, 0.95, 0.99)}
65
+ self.threshold_ = self.train_quantiles_[0.95] # default: 95th in-dist percentile
66
+ return self
67
+
68
+ # ---- score -----------------------------------------------------------------------------------
69
+ def score(self, X) -> np.ndarray:
70
+ """Higher = more out-of-distribution."""
71
+ X = np.nan_to_num(np.asarray(X, float), nan=0.0)
72
+ if X.ndim == 1:
73
+ X = X.reshape(1, -1)
74
+ if self.method == "mahalanobis":
75
+ d = X - self.mean_
76
+ m2 = np.einsum("ij,jk,ik->i", d, self.inv_cov_, d)
77
+ return np.sqrt(np.clip(m2, 0, None))
78
+ if self.method == "knn":
79
+ kk = min(self.k, len(self.train_))
80
+ if getattr(self, "_kdtree", None) is not None:
81
+ d, _ = self._kdtree.query(X, k=kk) # (n, kk) nearest-neighbour distances
82
+ d = np.atleast_2d(d)
83
+ return d.mean(axis=1)
84
+ out = []
85
+ for row in X:
86
+ dist = np.sqrt(((self.train_ - row) ** 2).sum(axis=1))
87
+ out.append(float(np.sort(dist)[:kk].mean()))
88
+ return np.asarray(out)
89
+ if self.method == "isolation_forest":
90
+ return -self.iforest_.score_samples(X) # higher = more anomalous
91
+ raise ValueError(self.method)
92
+
93
+ # ---- threshold calibration on a held-out in-dist vs OOD set ----------------------------------
94
+ def calibrate_threshold(self, X_id, X_ood) -> dict:
95
+ """Pick a threshold separating held-out in-distribution from deliberately-OOD queries and report
96
+ the separation AUROC (acceptance: >= 0.75). Threshold = Youden-J-optimal point on the ROC."""
97
+ s_id = self.score(X_id)
98
+ s_ood = self.score(X_ood)
99
+ scores = np.concatenate([s_id, s_ood])
100
+ labels = np.concatenate([np.zeros(len(s_id)), np.ones(len(s_ood))]) # 1 = OOD
101
+ auroc = _auroc(list(scores), list(labels))
102
+ # Youden J over candidate thresholds (the unique scores), VECTORIZED via searchsorted so this is
103
+ # O(n log n), not O(n^2) - the naive per-threshold mean loop is prohibitive at n in the thousands.
104
+ thr = np.unique(scores)
105
+ id_sorted, ood_sorted = np.sort(s_id), np.sort(s_ood)
106
+ tpr = 1.0 - np.searchsorted(ood_sorted, thr, side="left") / len(ood_sorted) # P(s_ood >= t)
107
+ fpr = 1.0 - np.searchsorted(id_sorted, thr, side="left") / len(id_sorted) # P(s_id >= t)
108
+ j = tpr - fpr
109
+ best = int(np.argmax(j))
110
+ best_t, best_j = float(thr[best]), float(j[best])
111
+ self.threshold_ = best_t
112
+ return {"auroc": auroc, "threshold": best_t, "youden_j": best_j,
113
+ "n_id": int(len(s_id)), "n_ood": int(len(s_ood)),
114
+ "separates": bool(auroc >= 0.75)}
115
+
116
+ def is_ood(self, X) -> np.ndarray:
117
+ return self.score(X) >= self.threshold_
118
+
119
+ # ---- widening hook into the conformal interval -----------------------------------------------
120
+ def widen_factor(self, X, cap: float = 3.0) -> np.ndarray:
121
+ """Monotone interval-widening / confidence-deflation multiplier in [1, cap].
122
+
123
+ 1.0 at/below the in-distribution median; rises smoothly with the OOD score, saturating at ``cap``.
124
+ Monotonicity is the UQ2 acceptance property: more OOD never narrows the interval.
125
+ """
126
+ s = self.score(X)
127
+ lo = self.train_quantiles_.get(0.5, float(np.median(s)))
128
+ hi = self.train_quantiles_.get(0.99, lo + 1.0)
129
+ span = max(hi - lo, 1e-9)
130
+ frac = np.clip((s - lo) / span, 0.0, 1.0)
131
+ return 1.0 + frac * (cap - 1.0)
132
+
133
+ def confidence(self, X) -> np.ndarray:
134
+ """A 0-1 confidence that DECREASES monotonically with the OOD score (1 / widen_factor)."""
135
+ return 1.0 / self.widen_factor(X)