Tengo Gzirishvili Claude commited on
Commit
3e09cc5
Β·
1 Parent(s): 9c2665c

Deploy: commons provenance (migration 0018)

Browse files

Seeded 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 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 the aggregate table with a fresh set of de-identified rows.
2118
- Each row: {substitution, n_users, n_obs, mean_effect}. Service-role.
 
 
 
 
 
 
 
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 (the aggregate is fully recomputed each run)
2124
  try:
2125
- del_url = f"{SUPABASE_URL}/rest/v1/mutation_priors?substitution=neq.__none__"
 
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
- return prior.to_rows()
 
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
- total_obs += int(r.get("n_obs", 0) or 0)
2817
  except (TypeError, ValueError):
2818
- pass
 
 
 
 
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
- result = _auth.replace_mutation_priors(rows)
 
 
 
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
- assert rows and all(set(r) == {"substitution", "n_users", "n_obs", "mean_effect"} for r in rows)
 
 
 
 
 
 
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)