Spaces:
Running
Running
Tengo Gzirishvili Claude commited on
Commit Β·
3e09cc5
1
Parent(s): 9c2665c
Deploy: commons provenance (migration 0018)
Browse filesSeeded published-study rows can no longer be counted as contributing labs.
/api/atlas reports the two separately. Scoped the prior wipe by source so a
user rebuild can't delete the DMS seed. Run migration 0018 before seeding.
Co-Authored-By: Claude <noreply@anthropic.com>
- dee/auth.py +16 -5
- dee/core/aggregate.py +11 -2
- dee/core/dms_seed.py +2 -1
- dee/server.py +38 -8
- supabase/migrations/0018_prior_provenance.sql +60 -0
- tests/test_admin_benchmarks_seed.py +2 -2
- tests/test_agent_tools.py +62 -0
- tests/test_aggregate.py +7 -1
dee/auth.py
CHANGED
|
@@ -43,6 +43,7 @@ from __future__ import annotations
|
|
| 43 |
import hashlib
|
| 44 |
import hmac
|
| 45 |
import json
|
|
|
|
| 46 |
import logging
|
| 47 |
import os
|
| 48 |
import threading
|
|
@@ -2113,16 +2114,26 @@ def get_mutation_priors() -> list:
|
|
| 2113 |
return []
|
| 2114 |
|
| 2115 |
|
| 2116 |
-
def replace_mutation_priors(rows: list) -> Dict[str, Any]:
|
| 2117 |
-
"""Replace
|
| 2118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2119 |
"""
|
|
|
|
|
|
|
| 2120 |
if not (SUPABASE_URL and SUPABASE_SERVICE_KEY):
|
| 2121 |
return {"ok": False, "error": "supabase-not-configured"}
|
| 2122 |
import urllib.request
|
| 2123 |
-
# wipe then upsert
|
| 2124 |
try:
|
| 2125 |
-
del_url = f"{SUPABASE_URL}/rest/v1/mutation_priors
|
|
|
|
| 2126 |
req = urllib.request.Request(del_url, method="DELETE",
|
| 2127 |
headers=_supabase_headers({"Prefer": "return=minimal"}))
|
| 2128 |
with urllib.request.urlopen(req, timeout=10.0):
|
|
|
|
| 43 |
import hashlib
|
| 44 |
import hmac
|
| 45 |
import json
|
| 46 |
+
import urllib.parse
|
| 47 |
import logging
|
| 48 |
import os
|
| 49 |
import threading
|
|
|
|
| 2114 |
return []
|
| 2115 |
|
| 2116 |
|
| 2117 |
+
def replace_mutation_priors(rows: list, source: str = "user") -> Dict[str, Any]:
|
| 2118 |
+
"""Replace ONE SOURCE's aggregate rows with a freshly computed set.
|
| 2119 |
+
|
| 2120 |
+
Each row: {substitution, n_users, n_obs, mean_effect, source}. Service-role.
|
| 2121 |
+
|
| 2122 |
+
The delete is scoped to `source` β this used to wipe the whole table, which
|
| 2123 |
+
was fine while every row came from user outcomes but destroys the seeded
|
| 2124 |
+
DMS commons the moment both coexist (migration 0018). A nightly user-prior
|
| 2125 |
+
rebuild silently deleting the published-study seed is exactly the sort of
|
| 2126 |
+
failure nobody notices until the atlas is empty again.
|
| 2127 |
"""
|
| 2128 |
+
if source not in ("user", "dms"):
|
| 2129 |
+
return {"ok": False, "error": f"unknown-source-{source}"}
|
| 2130 |
if not (SUPABASE_URL and SUPABASE_SERVICE_KEY):
|
| 2131 |
return {"ok": False, "error": "supabase-not-configured"}
|
| 2132 |
import urllib.request
|
| 2133 |
+
# wipe then upsert β this source's rows are fully recomputed each run
|
| 2134 |
try:
|
| 2135 |
+
del_url = (f"{SUPABASE_URL}/rest/v1/mutation_priors"
|
| 2136 |
+
f"?source=eq.{urllib.parse.quote(source)}")
|
| 2137 |
req = urllib.request.Request(del_url, method="DELETE",
|
| 2138 |
headers=_supabase_headers({"Prefer": "return=minimal"}))
|
| 2139 |
with urllib.request.urlopen(req, timeout=10.0):
|
dee/core/aggregate.py
CHANGED
|
@@ -124,9 +124,17 @@ class GlobalPrior:
|
|
| 124 |
def __bool__(self) -> bool:
|
| 125 |
return bool(self.effects)
|
| 126 |
|
| 127 |
-
def to_rows(self) -> List[dict]:
|
| 128 |
"""Serialize for storage in public.mutation_priors (de-identified).
|
| 129 |
-
A 3-tuple key (wt, mut, bin) serializes as 'W>L@hi'.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
out = []
|
| 131 |
for sub, eff in sorted(self.effects.items(), key=lambda kv: tuple(map(str, kv[0]))):
|
| 132 |
label = f"{sub[0]}>{sub[1]}" + (f"@{sub[2]}" if len(sub) > 2 and sub[2] else "")
|
|
@@ -135,6 +143,7 @@ class GlobalPrior:
|
|
| 135 |
"n_users": self.n_users[sub],
|
| 136 |
"n_obs": self.n_obs[sub],
|
| 137 |
"mean_effect": round(eff, 6),
|
|
|
|
| 138 |
})
|
| 139 |
return out
|
| 140 |
|
|
|
|
| 124 |
def __bool__(self) -> bool:
|
| 125 |
return bool(self.effects)
|
| 126 |
|
| 127 |
+
def to_rows(self, source: str = "user") -> List[dict]:
|
| 128 |
"""Serialize for storage in public.mutation_priors (de-identified).
|
| 129 |
+
A 3-tuple key (wt, mut, bin) serializes as 'W>L@hi'.
|
| 130 |
+
|
| 131 |
+
`source` records WHERE the pooling came from and is not cosmetic.
|
| 132 |
+
For 'dms' rows n_users counts independent published STUDIES, not
|
| 133 |
+
people β reporting the two together would assert lab adoption this
|
| 134 |
+
platform has not earned (migration 0018).
|
| 135 |
+
"""
|
| 136 |
+
if source not in ("user", "dms"):
|
| 137 |
+
raise ValueError(f"unknown prior source {source!r}")
|
| 138 |
out = []
|
| 139 |
for sub, eff in sorted(self.effects.items(), key=lambda kv: tuple(map(str, kv[0]))):
|
| 140 |
label = f"{sub[0]}>{sub[1]}" + (f"@{sub[2]}" if len(sub) > 2 and sub[2] else "")
|
|
|
|
| 143 |
"n_users": self.n_users[sub],
|
| 144 |
"n_obs": self.n_obs[sub],
|
| 145 |
"mean_effect": round(eff, 6),
|
| 146 |
+
"source": source,
|
| 147 |
})
|
| 148 |
return out
|
| 149 |
|
dee/core/dms_seed.py
CHANGED
|
@@ -58,7 +58,8 @@ def seed_rows(
|
|
| 58 |
if meas:
|
| 59 |
grouped.append((str(assay_id), meas))
|
| 60 |
prior = _agg.build_priors(grouped, now=now, enforce_gate=enforce_gate)
|
| 61 |
-
|
|
|
|
| 62 |
|
| 63 |
|
| 64 |
def parse_proteingym_csv(text: str) -> List[Tuple[str, float]]:
|
|
|
|
| 58 |
if meas:
|
| 59 |
grouped.append((str(assay_id), meas))
|
| 60 |
prior = _agg.build_priors(grouped, now=now, enforce_gate=enforce_gate)
|
| 61 |
+
# Published studies, not platform labs β see migration 0018.
|
| 62 |
+
return prior.to_rows("dms")
|
| 63 |
|
| 64 |
|
| 65 |
def parse_proteingym_csv(text: str) -> List[Tuple[str, float]]:
|
dee/server.py
CHANGED
|
@@ -2809,13 +2809,25 @@ def create_app() -> Flask:
|
|
| 2809 |
rebuild accrues enough data (that's the honest cold-start, shown as
|
| 2810 |
such in the UI)."""
|
| 2811 |
from dee.core import aggregate as _agg
|
| 2812 |
-
rows = _auth.get_mutation_priors() # [{substitution, n_users, n_obs, mean_effect}]
|
| 2813 |
total_obs = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2814 |
for r in rows:
|
|
|
|
| 2815 |
try:
|
| 2816 |
-
|
| 2817 |
except (TypeError, ValueError):
|
| 2818 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2819 |
return jsonify({
|
| 2820 |
"ok": True,
|
| 2821 |
"substitutions": rows,
|
|
@@ -2823,6 +2835,21 @@ def create_app() -> Flask:
|
|
| 2823 |
"total_observations": total_obs,
|
| 2824 |
"min_users": _agg.MIN_USERS,
|
| 2825 |
"effective_date": _agg.EFFECTIVE_DATE.isoformat(),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2826 |
})
|
| 2827 |
|
| 2828 |
@app.get("/api/benchmarks")
|
|
@@ -3108,7 +3135,10 @@ def create_app() -> Flask:
|
|
| 3108 |
return jsonify({"ok": False, "error": "gated", "detail": str(exc),
|
| 3109 |
"effective_date": _agg.EFFECTIVE_DATE.isoformat()}), 423
|
| 3110 |
|
| 3111 |
-
|
|
|
|
|
|
|
|
|
|
| 3112 |
_GLOBAL_PRIOR_CACHE["data"] = None # bust so the next design sees it immediately
|
| 3113 |
out = {"ok": bool(result.get("ok")), "substitutions": len(rows),
|
| 3114 |
"contributing_assays": len(assays)}
|
|
@@ -3146,8 +3176,8 @@ def create_app() -> Flask:
|
|
| 3146 |
except _agg.AggregationGateError as exc:
|
| 3147 |
return jsonify({"ok": False, "error": "gated", "detail": str(exc),
|
| 3148 |
"effective_date": _agg.EFFECTIVE_DATE.isoformat()}), 423
|
| 3149 |
-
rows = prior.to_rows()
|
| 3150 |
-
result = _auth.replace_mutation_priors(rows)
|
| 3151 |
_GLOBAL_PRIOR_CACHE["data"] = None # bust the scorer cache
|
| 3152 |
return jsonify({"ok": bool(result.get("ok")),
|
| 3153 |
"substitutions": len(rows),
|
|
@@ -3805,8 +3835,8 @@ def _rebuild_all_priors_once() -> None:
|
|
| 3805 |
|
| 3806 |
try:
|
| 3807 |
grouped = _auth.list_all_de_outcomes_grouped()
|
| 3808 |
-
rows = _agg.build_priors(grouped).to_rows()
|
| 3809 |
-
_auth.replace_mutation_priors(rows)
|
| 3810 |
_GLOBAL_PRIOR_CACHE["data"] = None # this route's own cache
|
| 3811 |
_agg.bust_cached_global_prior() # the shared one design_variant_library (chat) reads
|
| 3812 |
logger.info("prior rebuild: de -> %d substitutions from %d libraries", len(rows), len(grouped))
|
|
|
|
| 2809 |
rebuild accrues enough data (that's the honest cold-start, shown as
|
| 2810 |
such in the UI)."""
|
| 2811 |
from dee.core import aggregate as _agg
|
| 2812 |
+
rows = _auth.get_mutation_priors() # [{substitution, n_users, n_obs, mean_effect, source}]
|
| 2813 |
total_obs = 0
|
| 2814 |
+
# Count the two provenances SEPARATELY and never sum them into one
|
| 2815 |
+
# "labs contributing" figure. A row seeded from published DMS studies
|
| 2816 |
+
# carries n_users = number of independent STUDIES; reporting that
|
| 2817 |
+
# beside user-contributed rows would assert lab adoption this platform
|
| 2818 |
+
# has not earned. Migration 0018 added the column for exactly this.
|
| 2819 |
+
by_source = {"user": 0, "dms": 0}
|
| 2820 |
+
obs_by_source = {"user": 0, "dms": 0}
|
| 2821 |
for r in rows:
|
| 2822 |
+
src = r.get("source") or "user"
|
| 2823 |
try:
|
| 2824 |
+
n = int(r.get("n_obs", 0) or 0)
|
| 2825 |
except (TypeError, ValueError):
|
| 2826 |
+
n = 0
|
| 2827 |
+
total_obs += n
|
| 2828 |
+
if src in by_source:
|
| 2829 |
+
by_source[src] += 1
|
| 2830 |
+
obs_by_source[src] += n
|
| 2831 |
return jsonify({
|
| 2832 |
"ok": True,
|
| 2833 |
"substitutions": rows,
|
|
|
|
| 2835 |
"total_observations": total_obs,
|
| 2836 |
"min_users": _agg.MIN_USERS,
|
| 2837 |
"effective_date": _agg.EFFECTIVE_DATE.isoformat(),
|
| 2838 |
+
# The honest breakdown. `from_published_studies` is prior
|
| 2839 |
+
# knowledge the field already had; `from_platform_labs` is the
|
| 2840 |
+
# only number that measures THIS commons growing.
|
| 2841 |
+
"provenance": {
|
| 2842 |
+
"from_published_studies": {
|
| 2843 |
+
"substitutions": by_source["dms"],
|
| 2844 |
+
"observations": obs_by_source["dms"],
|
| 2845 |
+
"unit": "independent published DMS assays",
|
| 2846 |
+
},
|
| 2847 |
+
"from_platform_labs": {
|
| 2848 |
+
"substitutions": by_source["user"],
|
| 2849 |
+
"observations": obs_by_source["user"],
|
| 2850 |
+
"unit": "distinct labs logging wet-lab outcomes",
|
| 2851 |
+
},
|
| 2852 |
+
},
|
| 2853 |
})
|
| 2854 |
|
| 2855 |
@app.get("/api/benchmarks")
|
|
|
|
| 3135 |
return jsonify({"ok": False, "error": "gated", "detail": str(exc),
|
| 3136 |
"effective_date": _agg.EFFECTIVE_DATE.isoformat()}), 423
|
| 3137 |
|
| 3138 |
+
# source="dms": replaces only the published-study rows, leaving any
|
| 3139 |
+
# user-contributed commons untouched (migration 0018). _seed_rows
|
| 3140 |
+
# already stamps each row source="dms".
|
| 3141 |
+
result = _auth.replace_mutation_priors(rows, source="dms")
|
| 3142 |
_GLOBAL_PRIOR_CACHE["data"] = None # bust so the next design sees it immediately
|
| 3143 |
out = {"ok": bool(result.get("ok")), "substitutions": len(rows),
|
| 3144 |
"contributing_assays": len(assays)}
|
|
|
|
| 3176 |
except _agg.AggregationGateError as exc:
|
| 3177 |
return jsonify({"ok": False, "error": "gated", "detail": str(exc),
|
| 3178 |
"effective_date": _agg.EFFECTIVE_DATE.isoformat()}), 423
|
| 3179 |
+
rows = prior.to_rows("user")
|
| 3180 |
+
result = _auth.replace_mutation_priors(rows, source="user")
|
| 3181 |
_GLOBAL_PRIOR_CACHE["data"] = None # bust the scorer cache
|
| 3182 |
return jsonify({"ok": bool(result.get("ok")),
|
| 3183 |
"substitutions": len(rows),
|
|
|
|
| 3835 |
|
| 3836 |
try:
|
| 3837 |
grouped = _auth.list_all_de_outcomes_grouped()
|
| 3838 |
+
rows = _agg.build_priors(grouped).to_rows("user")
|
| 3839 |
+
_auth.replace_mutation_priors(rows, source="user")
|
| 3840 |
_GLOBAL_PRIOR_CACHE["data"] = None # this route's own cache
|
| 3841 |
_agg.bust_cached_global_prior() # the shared one design_variant_library (chat) reads
|
| 3842 |
logger.info("prior rebuild: de -> %d substitutions from %d libraries", len(rows), len(grouped))
|
supabase/migrations/0018_prior_provenance.sql
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- 0018_prior_provenance.sql β say where each pooled row CAME FROM.
|
| 2 |
+
--
|
| 3 |
+
-- public.mutation_priors stored `n_users` with no source column, so a row
|
| 4 |
+
-- pooled from three PUBLISHED DMS STUDIES was indistinguishable from one
|
| 5 |
+
-- pooled from three PLATFORM LABS. /api/atlas surfaced that number raw, which
|
| 6 |
+
-- would have read as "3 labs contributed this" the moment the commons was
|
| 7 |
+
-- seeded from public data (dee/core/dms_seed.py) β a claim about traction
|
| 8 |
+
-- that simply is not true.
|
| 9 |
+
--
|
| 10 |
+
-- That distinction is load-bearing for this product specifically. The whole
|
| 11 |
+
-- pitch is that the numbers are inspectable; overstating where they came from
|
| 12 |
+
-- is the one error that cannot be walked back. So provenance is a column, and
|
| 13 |
+
-- the atlas reports the two counts separately.
|
| 14 |
+
--
|
| 15 |
+
-- `source` is deliberately free-form-but-checked rather than an enum: new
|
| 16 |
+
-- provenance kinds (a curated literature import, a partner contribution) are
|
| 17 |
+
-- expected, and a CHECK is cheaper to widen than an enum type is to alter.
|
| 18 |
+
|
| 19 |
+
alter table public.mutation_priors
|
| 20 |
+
add column if not exists source text not null default 'user';
|
| 21 |
+
|
| 22 |
+
alter table public.mutation_priors
|
| 23 |
+
drop constraint if exists mutation_priors_source_ck;
|
| 24 |
+
alter table public.mutation_priors
|
| 25 |
+
add constraint mutation_priors_source_ck
|
| 26 |
+
check (source in ('user', 'dms'));
|
| 27 |
+
|
| 28 |
+
comment on column public.mutation_priors.source is
|
| 29 |
+
'Where this pooled row came from. ''user'' = wet-lab outcomes logged by '
|
| 30 |
+
'platform labs. ''dms'' = published deep-mutational-scanning studies, '
|
| 31 |
+
'seeded via dee/core/dms_seed.py. For ''dms'' rows n_users counts '
|
| 32 |
+
'INDEPENDENT STUDIES, not people β /api/atlas must label them separately '
|
| 33 |
+
'and never sum them into a single "labs contributing" figure.';
|
| 34 |
+
|
| 35 |
+
-- Same treatment for the per-tool prior, so CRISPR/primer aggregates cannot
|
| 36 |
+
-- drift into the same ambiguity as they start being populated.
|
| 37 |
+
alter table public.outcome_priors
|
| 38 |
+
add column if not exists source text not null default 'user';
|
| 39 |
+
|
| 40 |
+
alter table public.outcome_priors
|
| 41 |
+
drop constraint if exists outcome_priors_source_ck;
|
| 42 |
+
alter table public.outcome_priors
|
| 43 |
+
add constraint outcome_priors_source_ck
|
| 44 |
+
check (source in ('user', 'dms'));
|
| 45 |
+
|
| 46 |
+
comment on column public.outcome_priors.source is
|
| 47 |
+
'Provenance of this pooled row β see public.mutation_priors.source.';
|
| 48 |
+
|
| 49 |
+
-- Seeded and user-contributed rows for the same substitution must coexist:
|
| 50 |
+
-- they are different evidence and get counted separately. The old primary key
|
| 51 |
+
-- (substitution alone) would have made a seed overwrite real user data.
|
| 52 |
+
alter table public.mutation_priors
|
| 53 |
+
drop constraint if exists mutation_priors_pkey;
|
| 54 |
+
alter table public.mutation_priors
|
| 55 |
+
add primary key (substitution, source);
|
| 56 |
+
|
| 57 |
+
alter table public.outcome_priors
|
| 58 |
+
drop constraint if exists outcome_priors_pkey;
|
| 59 |
+
alter table public.outcome_priors
|
| 60 |
+
add primary key (tool, feature_key, source);
|
tests/test_admin_benchmarks_seed.py
CHANGED
|
@@ -162,7 +162,7 @@ def test_seed_commons_against_real_fixture(client, monkeypatch):
|
|
| 162 |
_with_admin(monkeypatch)
|
| 163 |
captured = {}
|
| 164 |
|
| 165 |
-
def fake_replace(rows):
|
| 166 |
captured["rows"] = rows
|
| 167 |
return {"ok": True}
|
| 168 |
|
|
@@ -191,7 +191,7 @@ def test_seed_commons_survives_one_bad_csv(client, monkeypatch, tmp_path):
|
|
| 191 |
degrade to the other assays and report the failure by name."""
|
| 192 |
_with_admin(monkeypatch)
|
| 193 |
real_fixtures = _fixtures_dir()
|
| 194 |
-
monkeypatch.setattr(server._auth, "replace_mutation_priors", lambda rows: {"ok": True})
|
| 195 |
monkeypatch.setattr(server, "__file__", str(tmp_path / "server.py"))
|
| 196 |
(tmp_path / "data").mkdir()
|
| 197 |
import shutil
|
|
|
|
| 162 |
_with_admin(monkeypatch)
|
| 163 |
captured = {}
|
| 164 |
|
| 165 |
+
def fake_replace(rows, source="user"):
|
| 166 |
captured["rows"] = rows
|
| 167 |
return {"ok": True}
|
| 168 |
|
|
|
|
| 191 |
degrade to the other assays and report the failure by name."""
|
| 192 |
_with_admin(monkeypatch)
|
| 193 |
real_fixtures = _fixtures_dir()
|
| 194 |
+
monkeypatch.setattr(server._auth, "replace_mutation_priors", lambda rows, source="user": {"ok": True})
|
| 195 |
monkeypatch.setattr(server, "__file__", str(tmp_path / "server.py"))
|
| 196 |
(tmp_path / "data").mkdir()
|
| 197 |
import shutil
|
tests/test_agent_tools.py
CHANGED
|
@@ -905,3 +905,65 @@ def test_imported_hosts_are_reachable_through_their_common_aliases(host, expect)
|
|
| 905 |
assert any(p["name"] == expect for p in result["promoters"]), (
|
| 906 |
f"{expect} missing from {host}: "
|
| 907 |
f"{[p['name'] for p in result['promoters']]}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 905 |
assert any(p["name"] == expect for p in result["promoters"]), (
|
| 906 |
f"{expect} missing from {host}: "
|
| 907 |
f"{[p['name'] for p in result['promoters']]}")
|
| 908 |
+
|
| 909 |
+
|
| 910 |
+
# βββββββββ commons provenance (migration 0018) βββββββββ
|
| 911 |
+
|
| 912 |
+
def test_seeded_rows_are_labelled_as_published_studies_not_users():
|
| 913 |
+
"""A DMS-seeded row must never be countable as a contributing lab.
|
| 914 |
+
|
| 915 |
+
mutation_priors had no source column, so seeding the commons from public
|
| 916 |
+
deep-mutational-scanning data would have made /api/atlas report
|
| 917 |
+
"276 substitutions, 3 users each" β which reads as lab adoption that does
|
| 918 |
+
not exist. For a product whose pitch is that its numbers are inspectable,
|
| 919 |
+
that is the one error there is no walking back from.
|
| 920 |
+
"""
|
| 921 |
+
from dee.core import aggregate as _agg
|
| 922 |
+
|
| 923 |
+
prior = _agg.GlobalPrior(
|
| 924 |
+
effects={("W", "L"): 0.5},
|
| 925 |
+
n_users={("W", "L"): 3},
|
| 926 |
+
n_obs={("W", "L"): 42},
|
| 927 |
+
)
|
| 928 |
+
seeded = prior.to_rows("dms")
|
| 929 |
+
assert seeded and seeded[0]["source"] == "dms"
|
| 930 |
+
|
| 931 |
+
contributed = prior.to_rows() # default
|
| 932 |
+
assert contributed[0]["source"] == "user"
|
| 933 |
+
|
| 934 |
+
with pytest.raises(ValueError):
|
| 935 |
+
prior.to_rows("marketing")
|
| 936 |
+
|
| 937 |
+
|
| 938 |
+
def test_a_user_rebuild_cannot_wipe_the_seeded_commons(monkeypatch):
|
| 939 |
+
"""The delete must be scoped by source.
|
| 940 |
+
|
| 941 |
+
replace_mutation_priors used to clear the WHOLE table before inserting.
|
| 942 |
+
Once both provenances coexist, the nightly user-prior rebuild would have
|
| 943 |
+
silently deleted the published-study seed β the kind of failure nobody
|
| 944 |
+
notices until the atlas is empty again.
|
| 945 |
+
"""
|
| 946 |
+
from dee import auth as _auth
|
| 947 |
+
|
| 948 |
+
monkeypatch.setattr(_auth, "SUPABASE_URL", "https://example.invalid")
|
| 949 |
+
monkeypatch.setattr(_auth, "SUPABASE_SERVICE_KEY", "test-key")
|
| 950 |
+
|
| 951 |
+
seen = {}
|
| 952 |
+
|
| 953 |
+
class _Resp:
|
| 954 |
+
def __enter__(self): return self
|
| 955 |
+
def __exit__(self, *a): return False
|
| 956 |
+
|
| 957 |
+
def fake_urlopen(req, timeout=None):
|
| 958 |
+
if req.get_method() == "DELETE":
|
| 959 |
+
seen["delete_url"] = req.full_url
|
| 960 |
+
return _Resp()
|
| 961 |
+
|
| 962 |
+
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
| 963 |
+
_auth.replace_mutation_priors([], source="user")
|
| 964 |
+
|
| 965 |
+
assert "source=eq.user" in seen["delete_url"], (
|
| 966 |
+
f"delete was not scoped by source: {seen['delete_url']}")
|
| 967 |
+
assert "neq.__none__" not in seen["delete_url"], "still wiping every row"
|
| 968 |
+
|
| 969 |
+
assert _auth.replace_mutation_priors([], source="nonsense")["ok"] is False
|
tests/test_aggregate.py
CHANGED
|
@@ -131,7 +131,13 @@ def test_to_rows_is_deidentified_and_roundtrips():
|
|
| 131 |
obls = [("u%d" % i, _lib_for_sub((2.0, 0.0))) for i in range(3)]
|
| 132 |
out = build_priors(obls, min_users=3, now=AFTER)
|
| 133 |
rows = out.to_rows()
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
# no user ids, no positions, no raw values anywhere in the serialized form
|
| 136 |
blob = str(rows)
|
| 137 |
assert "u0" not in blob and "u1" not in blob and "10" not in "".join(r["substitution"] for r in rows)
|
|
|
|
| 131 |
obls = [("u%d" % i, _lib_for_sub((2.0, 0.0))) for i in range(3)]
|
| 132 |
out = build_priors(obls, min_users=3, now=AFTER)
|
| 133 |
rows = out.to_rows()
|
| 134 |
+
# `source` (migration 0018) records provenance and carries no user data β
|
| 135 |
+
# the exact-key assertion stays exact so a future field that DOES carry
|
| 136 |
+
# user data still fails here rather than sliding in unnoticed.
|
| 137 |
+
assert rows and all(
|
| 138 |
+
set(r) == {"substitution", "n_users", "n_obs", "mean_effect", "source"}
|
| 139 |
+
for r in rows)
|
| 140 |
+
assert all(r["source"] == "user" for r in rows)
|
| 141 |
# no user ids, no positions, no raw values anywhere in the serialized form
|
| 142 |
blob = str(rows)
|
| 143 |
assert "u0" not in blob and "u1" not in blob and "10" not in "".join(r["substitution"] for r in rows)
|