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.
- pen_stack/__init__.py +2 -0
- pen_stack/_resources.py +34 -0
- pen_stack/active/__init__.py +20 -0
- pen_stack/active/acquire.py +165 -0
- pen_stack/active/brains.py +74 -0
- pen_stack/active/campaign.py +109 -0
- pen_stack/active/design.py +66 -0
- pen_stack/active/validate.py +104 -0
- pen_stack/adapt/__init__.py +14 -0
- pen_stack/adapt/finetune.py +33 -0
- pen_stack/adapt/ingest.py +86 -0
- pen_stack/adapt/pipeline.py +101 -0
- pen_stack/adapt/recalibrate.py +58 -0
- pen_stack/adapt/report.py +130 -0
- pen_stack/agent/__init__.py +1 -0
- pen_stack/agent/cite.py +175 -0
- pen_stack/agent/co_scientist.py +262 -0
- pen_stack/agent/epistemic.py +102 -0
- pen_stack/agent/guardrails.py +67 -0
- pen_stack/agent/mcp_server.py +215 -0
- pen_stack/agent/orchestrator.py +112 -0
- pen_stack/agent/orchestrator_live.py +56 -0
- pen_stack/agent/pen_agent.py +242 -0
- pen_stack/agent/scope.py +60 -0
- pen_stack/agent/tools.py +130 -0
- pen_stack/api/__init__.py +12 -0
- pen_stack/api/manifest.py +160 -0
- pen_stack/atlas/__init__.py +1 -0
- pen_stack/atlas/atlas.parquet +0 -0
- pen_stack/atlas/build_wtkb.py +80 -0
- pen_stack/atlas/crosslink.py +179 -0
- pen_stack/atlas/expand.py +190 -0
- pen_stack/atlas/guide_design.py +178 -0
- pen_stack/atlas/schema.py +59 -0
- pen_stack/atlas/scorecard.py +134 -0
- pen_stack/atlas/scorecard_v3.parquet +0 -0
- pen_stack/atlas/universe.py +75 -0
- pen_stack/atlas/universe_v3.parquet +0 -0
- pen_stack/atlas/variant_propose.py +155 -0
- pen_stack/atlas/writer_efficiency.py +184 -0
- pen_stack/atlas/writer_predict.py +229 -0
- pen_stack/atlas/writer_recommend.py +170 -0
- pen_stack/atlas/writer_verify.py +167 -0
- pen_stack/atlas/wtkb.parquet +0 -0
- pen_stack/bridge/__init__.py +1 -0
- pen_stack/bridge/activity.py +52 -0
- pen_stack/bridge/cli.py +65 -0
- pen_stack/bridge/fold_qc.py +53 -0
- pen_stack/bridge/guide_qc.py +87 -0
- pen_stack/bridge/ingest.py +139 -0
- pen_stack/bridge/offtarget.py +191 -0
- pen_stack/bridge/offtarget_energetics.py +105 -0
- pen_stack/bridge/ortholog_screen.py +73 -0
- pen_stack/bridge/pipeline.py +83 -0
- pen_stack/build/__init__.py +16 -0
- pen_stack/build/cloudlab.py +74 -0
- pen_stack/build/ingest.py +47 -0
- pen_stack/build/protocol.py +82 -0
- pen_stack/build/simlab.py +30 -0
- pen_stack/cli.py +126 -0
- pen_stack/data/__init__.py +1 -0
- pen_stack/data/encode.py +84 -0
- pen_stack/data/genome.py +71 -0
- pen_stack/data/ingest_chromatin.py +119 -0
- pen_stack/data/ingest_integration.py +112 -0
- pen_stack/data/ingest_safety_annot.py +201 -0
- pen_stack/data/ingest_trip.py +76 -0
- pen_stack/design/__init__.py +14 -0
- pen_stack/design/capsid_generate.py +62 -0
- pen_stack/design/generate.py +70 -0
- pen_stack/design/pareto.py +70 -0
- pen_stack/design/space.py +137 -0
- pen_stack/design/writer_variants.py +121 -0
- pen_stack/env/__init__.py +1 -0
- pen_stack/env/genome_writing_env.py +248 -0
- pen_stack/env/policies.py +94 -0
- pen_stack/graph/__init__.py +21 -0
- pen_stack/graph/build.py +133 -0
- pen_stack/graph/cell_types.py +58 -0
- pen_stack/graph/ingest.py +132 -0
- pen_stack/graph/query.py +148 -0
- pen_stack/graph/schema.py +100 -0
- pen_stack/loop/__init__.py +15 -0
- pen_stack/loop/continual.py +61 -0
- pen_stack/loop/cycle.py +84 -0
- pen_stack/loop/drift.py +41 -0
- pen_stack/mech/__init__.py +1 -0
- pen_stack/mech/classify_atlas.py +71 -0
- pen_stack/mech/pfam_whitelist.yaml +247 -0
- pen_stack/mech/whitelist.py +66 -0
- pen_stack/monitor/__init__.py +1 -0
- pen_stack/monitor/europepmc.py +32 -0
- pen_stack/monitor/run.py +57 -0
- pen_stack/monitor/triage.py +63 -0
- pen_stack/oracles/__init__.py +65 -0
- pen_stack/oracles/affinity.py +116 -0
- pen_stack/oracles/cache.py +53 -0
- pen_stack/oracles/energetics.py +33 -0
- pen_stack/oracles/genome.py +167 -0
- pen_stack/oracles/protein_design.py +136 -0
- pen_stack/oracles/reliability.py +64 -0
- pen_stack/oracles/rna.py +28 -0
- pen_stack/oracles/schema.py +77 -0
- pen_stack/oracles/status.py +123 -0
- pen_stack/oracles/structure.py +42 -0
- pen_stack/oracles/structure_run.py +76 -0
- pen_stack/oracles/vcell.py +74 -0
- pen_stack/planner/__init__.py +1 -0
- pen_stack/planner/ada_risk.py +64 -0
- pen_stack/planner/antipeg_oracle.py +75 -0
- pen_stack/planner/capsid_epitope_oracle.py +135 -0
- pen_stack/planner/cargo.py +56 -0
- pen_stack/planner/cargo_polish.py +146 -0
- pen_stack/planner/chromosome.py +106 -0
- pen_stack/planner/delivery.py +55 -0
- pen_stack/planner/delivery_constraints.py +110 -0
- pen_stack/planner/delivery_immune.py +61 -0
- pen_stack/planner/delivery_immunology.py +222 -0
- pen_stack/planner/delivery_predict.py +196 -0
- pen_stack/planner/delivery_vehicles.py +37 -0
- pen_stack/planner/genotoxicity_oracle.py +112 -0
- pen_stack/planner/immune_mhc2.py +154 -0
- pen_stack/planner/immune_profile.py +292 -0
- pen_stack/planner/innate_sensing.py +135 -0
- pen_stack/planner/multiplex.py +110 -0
- pen_stack/planner/optimize.py +278 -0
- pen_stack/planner/pipeline.py +87 -0
- pen_stack/planner/report.py +26 -0
- pen_stack/planner/router.py +57 -0
- pen_stack/planner/seroprevalence_oracle.py +92 -0
- pen_stack/planner/target_site.py +118 -0
- pen_stack/rag/__init__.py +1 -0
- pen_stack/rag/corpus.py +133 -0
- pen_stack/rag/embed.py +98 -0
- pen_stack/rag/ground.py +131 -0
- pen_stack/rag/index.py +53 -0
- pen_stack/rag/llm.py +215 -0
- pen_stack/rag/qa.py +105 -0
- pen_stack/rag/retrieve.py +48 -0
- pen_stack/rules/__init__.py +9 -0
- pen_stack/rules/evaluators.py +318 -0
- pen_stack/rules/loader.py +31 -0
- pen_stack/rules/schema.py +99 -0
- pen_stack/rules/solver.py +43 -0
- pen_stack/rules/spec.py +78 -0
- pen_stack/safety/__init__.py +21 -0
- pen_stack/safety/audit.py +90 -0
- pen_stack/safety/gate.py +58 -0
- pen_stack/safety/pfam_scan.py +157 -0
- pen_stack/safety/policy.py +69 -0
- pen_stack/safety/redteam.py +71 -0
- pen_stack/safety/registry.py +255 -0
- pen_stack/safety/screen.py +59 -0
- pen_stack/safety/standards.py +141 -0
- pen_stack/score/__init__.py +1 -0
- pen_stack/score/recalibrate.py +77 -0
- pen_stack/score/therapeutic.py +85 -0
- pen_stack/server/__init__.py +1 -0
- pen_stack/server/api.py +647 -0
- pen_stack/spec/__init__.py +18 -0
- pen_stack/spec/clarify.py +42 -0
- pen_stack/spec/extract.py +406 -0
- pen_stack/spec/resolvers/__init__.py +17 -0
- pen_stack/spec/resolvers/cell.py +51 -0
- pen_stack/spec/resolvers/chem.py +32 -0
- pen_stack/spec/resolvers/feature.py +36 -0
- pen_stack/spec/resolvers/gene.py +43 -0
- pen_stack/spec/resolvers/locus.py +29 -0
- pen_stack/spec/resolvers/phenotype.py +37 -0
- pen_stack/spec/satisfy.py +114 -0
- pen_stack/spec/service.py +33 -0
- pen_stack/spec/writespec.py +252 -0
- pen_stack/twin/__init__.py +14 -0
- pen_stack/twin/calibrate.py +61 -0
- pen_stack/twin/data/__init__.py +12 -0
- pen_stack/twin/data/position_effect.py +245 -0
- pen_stack/twin/mechanistic.py +147 -0
- pen_stack/twin/outcome.py +132 -0
- pen_stack/twin/position_effect.py +454 -0
- pen_stack/ui/__init__.py +1 -0
- pen_stack/ui/app.py +713 -0
- pen_stack/validate/__init__.py +1 -0
- pen_stack/validate/adapt_demo.py +69 -0
- pen_stack/validate/agent_eval.py +117 -0
- pen_stack/validate/bench_adversarial_tasks.py +118 -0
- pen_stack/validate/bench_coscientist_tasks.py +60 -0
- pen_stack/validate/bench_graph_tasks.py +64 -0
- pen_stack/validate/bench_rule_tasks.py +84 -0
- pen_stack/validate/bench_trust_tasks.py +92 -0
- pen_stack/validate/bench_writetype_tasks.py +101 -0
- pen_stack/validate/blind_gsh_discovery.py +261 -0
- pen_stack/validate/cargo_directionality.py +57 -0
- pen_stack/validate/closed_loop.py +63 -0
- pen_stack/validate/durability_baselines.py +185 -0
- pen_stack/validate/experiment_design.py +65 -0
- pen_stack/validate/expr_controls.py +39 -0
- pen_stack/validate/forward_hypotheses.py +104 -0
- pen_stack/validate/generative_design.py +62 -0
- pen_stack/validate/guide_qc_demo.py +69 -0
- pen_stack/validate/heldout_celltype_expr.py +32 -0
- pen_stack/validate/immune_calibration.py +133 -0
- pen_stack/validate/intent_specification.py +82 -0
- pen_stack/validate/known_biology_expr.py +38 -0
- pen_stack/validate/offtarget_energetics_eval.py +144 -0
- pen_stack/validate/out_of_scope_refusal.py +82 -0
- pen_stack/validate/outcome_calibration.py +194 -0
- pen_stack/validate/outcome_prediction.py +76 -0
- pen_stack/validate/paper3_benchmark.py +165 -0
- pen_stack/validate/paper4_real_validation.py +144 -0
- pen_stack/validate/paper4_validation.py +82 -0
- pen_stack/validate/protocol_safety.py +62 -0
- pen_stack/validate/safety_screening.py +72 -0
- pen_stack/validate/selective_prediction.py +104 -0
- pen_stack/validate/seq_vs_measured.py +134 -0
- pen_stack/validate/target_site_controls.py +65 -0
- pen_stack/validate/uncertainty_eval.py +244 -0
- pen_stack/validate/ungrounded_baseline.py +234 -0
- pen_stack/validate/within_locus_ranking.py +84 -0
- pen_stack/validate/writer_recovery.py +91 -0
- pen_stack/verify/__init__.py +5 -0
- pen_stack/verify/proof.py +206 -0
- pen_stack/verify/schema.py +53 -0
- pen_stack/verify/service.py +191 -0
- pen_stack/web/__init__.py +18 -0
- pen_stack/web/guide.py +110 -0
- pen_stack/web/llm.py +393 -0
- pen_stack/web/llm_provider.py +119 -0
- pen_stack/web/router.py +119 -0
- pen_stack/web/server.py +96 -0
- pen_stack/web/tools.py +197 -0
- pen_stack/wgenome/__init__.py +1 -0
- pen_stack/wgenome/chromatin_seq.py +83 -0
- pen_stack/wgenome/durability.py +108 -0
- pen_stack/wgenome/export_tracks.py +52 -0
- pen_stack/wgenome/features.py +82 -0
- pen_stack/wgenome/genotoxic_blocklist.py +88 -0
- pen_stack/wgenome/gsh_baseline.py +154 -0
- pen_stack/wgenome/mesh_features.py +61 -0
- pen_stack/wgenome/offtarget_assay.py +80 -0
- pen_stack/wgenome/offtarget_bridge.py +47 -0
- pen_stack/wgenome/offtarget_cast.py +97 -0
- pen_stack/wgenome/offtarget_data.py +148 -0
- pen_stack/wgenome/offtarget_enumerate.py +274 -0
- pen_stack/wgenome/offtarget_integrase.py +155 -0
- pen_stack/wgenome/offtarget_nuclease.py +123 -0
- pen_stack/wgenome/offtarget_paste.py +41 -0
- pen_stack/wgenome/offtarget_predict.py +282 -0
- pen_stack/wgenome/ood.py +135 -0
- pen_stack/wgenome/providers.py +278 -0
- pen_stack/wgenome/safety.py +69 -0
- pen_stack/wgenome/structure3d.py +212 -0
- pen_stack/wgenome/uncertainty.py +250 -0
- pen_stack/wgenome/writability.py +72 -0
- pen_stack-0.1.0.dist-info/METADATA +401 -0
- pen_stack-0.1.0.dist-info/RECORD +259 -0
- pen_stack-0.1.0.dist-info/WHEEL +5 -0
- pen_stack-0.1.0.dist-info/entry_points.txt +3 -0
- pen_stack-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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."""
|
pen_stack/agent/cite.py
ADDED
|
@@ -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
|