Tengo Gzirishvili commited on
Commit
a326839
·
1 Parent(s): 5194826

Surface per-assay failures in admin endpoints instead of silently dropping them

Browse files

Both /api/admin/run-benchmarks and /api/admin/seed-commons caught (or in
seed-commons' case, didn't even catch) per-assay errors with no way to see
why a dataset went missing from the result short of server log access. A
real run against the 3 bundled fixtures came back with only 1 dataset and
no indication why the other 2 were dropped. Now both endpoints return a
"failed": [{name, error}] list alongside any partial success, and
seed-commons' CSV-parsing loop is wrapped so one bad fixture degrades
instead of 500ing the whole request.

dee/server.py CHANGED
@@ -2782,22 +2782,26 @@ def create_app() -> Flask:
2782
 
2783
  scorer = _scoring.get_scorer(model)
2784
  results = []
 
2785
  for a in assays:
 
2786
  try:
2787
  csv_path = fixtures_dir / a["csv"]
2788
  recs = _parse_dms(csv_path.read_text(encoding="utf-8"))
2789
  if not recs:
 
2790
  continue
2791
  labels = [lab for (lab, _v) in recs]
2792
  measured = [v for (_lab, v) in recs]
2793
  scores_df = _scoring.score_guarded(scorer, a["sequence"])
2794
  predicted = _bm.predict_additive(scores_df, labels)
2795
  results.append(_bm.evaluate_dataset(
2796
- a.get("name", a.get("protein", "?")), a.get("protein", ""),
2797
  predicted, measured, source=a.get("source", ""),
2798
  ))
2799
- except Exception: # noqa: BLE001 — one bad assay must not kill the run
2800
- logger.exception("benchmark assay failed: %s", a.get("name"))
 
2801
 
2802
  out = {
2803
  "generated_at": _dt3.datetime.now(_dt3.timezone.utc).isoformat(),
@@ -2807,6 +2811,8 @@ def create_app() -> Flask:
2807
  "summary": _bm.summarize(results),
2808
  "datasets": [r.as_dict() for r in results],
2809
  }
 
 
2810
  out_path = Path(__file__).resolve().parent / "data" / "benchmarks.json"
2811
  try:
2812
  out_path.write_text(json.dumps(out, indent=2), encoding="utf-8")
@@ -2840,11 +2846,19 @@ def create_app() -> Flask:
2840
  return jsonify({"ok": False, "error": "manifest unreadable"}), 500
2841
 
2842
  assays = []
 
2843
  for a in manifest:
2844
- csv_path = fixtures_dir / a["csv"]
2845
- recs = _parse_dms(csv_path.read_text(encoding="utf-8"))
2846
- if recs:
2847
- assays.append((a["name"], recs))
 
 
 
 
 
 
 
2848
 
2849
  try:
2850
  rows = _seed_rows(assays) # enforces the effective-date gate
@@ -2854,8 +2868,11 @@ def create_app() -> Flask:
2854
 
2855
  result = _auth.replace_mutation_priors(rows)
2856
  _GLOBAL_PRIOR_CACHE["data"] = None # bust so the next design sees it immediately
2857
- return jsonify({"ok": bool(result.get("ok")), "substitutions": len(rows),
2858
- "contributing_assays": len(assays)})
 
 
 
2859
 
2860
  @app.post("/api/admin/rebuild-priors")
2861
  def admin_rebuild_priors() -> Response:
 
2782
 
2783
  scorer = _scoring.get_scorer(model)
2784
  results = []
2785
+ failed = []
2786
  for a in assays:
2787
+ name = a.get("name", a.get("protein", "?"))
2788
  try:
2789
  csv_path = fixtures_dir / a["csv"]
2790
  recs = _parse_dms(csv_path.read_text(encoding="utf-8"))
2791
  if not recs:
2792
+ failed.append({"name": name, "error": "no parseable records in csv"})
2793
  continue
2794
  labels = [lab for (lab, _v) in recs]
2795
  measured = [v for (_lab, v) in recs]
2796
  scores_df = _scoring.score_guarded(scorer, a["sequence"])
2797
  predicted = _bm.predict_additive(scores_df, labels)
2798
  results.append(_bm.evaluate_dataset(
2799
+ name, a.get("protein", ""),
2800
  predicted, measured, source=a.get("source", ""),
2801
  ))
2802
+ except Exception as exc: # noqa: BLE001 — one bad assay must not kill the run
2803
+ logger.exception("benchmark assay failed: %s", name)
2804
+ failed.append({"name": name, "error": f"{type(exc).__name__}: {exc}"})
2805
 
2806
  out = {
2807
  "generated_at": _dt3.datetime.now(_dt3.timezone.utc).isoformat(),
 
2811
  "summary": _bm.summarize(results),
2812
  "datasets": [r.as_dict() for r in results],
2813
  }
2814
+ if failed:
2815
+ out["failed"] = failed
2816
  out_path = Path(__file__).resolve().parent / "data" / "benchmarks.json"
2817
  try:
2818
  out_path.write_text(json.dumps(out, indent=2), encoding="utf-8")
 
2846
  return jsonify({"ok": False, "error": "manifest unreadable"}), 500
2847
 
2848
  assays = []
2849
+ failed = []
2850
  for a in manifest:
2851
+ name = a.get("name", a.get("protein", "?"))
2852
+ try:
2853
+ csv_path = fixtures_dir / a["csv"]
2854
+ recs = _parse_dms(csv_path.read_text(encoding="utf-8"))
2855
+ if recs:
2856
+ assays.append((name, recs))
2857
+ else:
2858
+ failed.append({"name": name, "error": "no parseable records in csv"})
2859
+ except Exception as exc: # noqa: BLE001 — one bad assay must not kill the seed
2860
+ logger.exception("seed-commons assay failed: %s", name)
2861
+ failed.append({"name": name, "error": f"{type(exc).__name__}: {exc}"})
2862
 
2863
  try:
2864
  rows = _seed_rows(assays) # enforces the effective-date gate
 
2868
 
2869
  result = _auth.replace_mutation_priors(rows)
2870
  _GLOBAL_PRIOR_CACHE["data"] = None # bust so the next design sees it immediately
2871
+ out = {"ok": bool(result.get("ok")), "substitutions": len(rows),
2872
+ "contributing_assays": len(assays)}
2873
+ if failed:
2874
+ out["failed"] = failed
2875
+ return jsonify(out)
2876
 
2877
  @app.post("/api/admin/rebuild-priors")
2878
  def admin_rebuild_priors() -> Response:
tests/test_admin_benchmarks_seed.py CHANGED
@@ -117,6 +117,44 @@ def test_run_benchmarks_missing_fixtures_404(client, monkeypatch, tmp_path):
117
  assert r.status_code == 404
118
 
119
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  # --------------------------------------------------------------------------- #
121
  # /api/admin/seed-commons — real bundled fixture, mocked Supabase write
122
  # --------------------------------------------------------------------------- #
@@ -147,6 +185,30 @@ def test_seed_commons_against_real_fixture(client, monkeypatch):
147
  assert ">" in row["substitution"]
148
 
149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  def test_seed_commons_gated_before_effective_date(client, monkeypatch):
151
  _with_admin(monkeypatch)
152
  import datetime as _dt
 
117
  assert r.status_code == 404
118
 
119
 
120
+ def test_run_benchmarks_surfaces_per_assay_failure(client, monkeypatch, tmp_path):
121
+ """One assay's scoring blows up (e.g. a real ESM error on the Space) — the
122
+ other two must still complete, and the failure must be visible in the
123
+ response itself (name + error), not just swallowed into a shorter
124
+ 'datasets' list with no explanation. This is what a real diagnosis of
125
+ 'why did only 1 of 3 datasets come back' should read directly off the
126
+ curl output instead of requiring server-log access."""
127
+ _with_admin(monkeypatch)
128
+ real_fixtures = _fixtures_dir()
129
+ manifest = json.loads((real_fixtures / "manifest.json").read_text(encoding="utf-8"))
130
+ assert len(manifest) >= 3
131
+
132
+ def flaky_score_guarded(scorer, seq):
133
+ if len(seq) > 500: # the two longer real fixtures (PABP 577aa, DLG4 724aa)
134
+ raise RuntimeError("boom: simulated real scoring failure")
135
+ return pd.DataFrame([{"position": 0, "wt_aa": seq[0], "mut_aa": "Z", "delta_ll": 0.1}])
136
+
137
+ monkeypatch.setattr(server._scoring, "get_scorer", lambda *a, **kw: "the-scorer")
138
+ monkeypatch.setattr(server._scoring, "score_guarded", flaky_score_guarded)
139
+ monkeypatch.setattr(server, "__file__", str(tmp_path / "server.py"))
140
+ (tmp_path / "data").mkdir()
141
+ import shutil
142
+ shutil.copytree(real_fixtures, tmp_path / "data" / "dms_fixtures")
143
+
144
+ r = client.post("/api/admin/run-benchmarks",
145
+ headers={"X-Admin-Token": ADMIN_TOKEN}, json={"model": "small"})
146
+ assert r.status_code == 200
147
+ body = r.get_json()
148
+ long_assays = [a["name"] for a in manifest if len(a["sequence"]) > 500]
149
+ assert len(long_assays) == 2, "expected exactly PABP + DLG4 to be >500aa"
150
+ assert body["summary"]["n_datasets"] == len(manifest) - len(long_assays)
151
+ assert "failed" in body and len(body["failed"]) == len(long_assays)
152
+ failed_names = {f["name"] for f in body["failed"]}
153
+ assert failed_names == set(long_assays)
154
+ for f in body["failed"]:
155
+ assert "boom: simulated real scoring failure" in f["error"]
156
+
157
+
158
  # --------------------------------------------------------------------------- #
159
  # /api/admin/seed-commons — real bundled fixture, mocked Supabase write
160
  # --------------------------------------------------------------------------- #
 
185
  assert ">" in row["substitution"]
186
 
187
 
188
+ def test_seed_commons_survives_one_bad_csv(client, monkeypatch, tmp_path):
189
+ """A single unreadable/corrupt fixture CSV must not 500 the whole request
190
+ (there was previously no try/except around this loop at all) — it should
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
198
+ dest = tmp_path / "data" / "dms_fixtures"
199
+ shutil.copytree(real_fixtures, dest)
200
+ manifest = json.loads((dest / "manifest.json").read_text(encoding="utf-8"))
201
+ broken_name = manifest[0]["name"]
202
+ (dest / manifest[0]["csv"]).unlink() # simulate a missing/corrupt fixture file
203
+
204
+ r = client.post("/api/admin/seed-commons", headers={"X-Admin-Token": ADMIN_TOKEN})
205
+ assert r.status_code == 200
206
+ body = r.get_json()
207
+ assert body["contributing_assays"] == len(manifest) - 1
208
+ assert "failed" in body and len(body["failed"]) == 1
209
+ assert body["failed"][0]["name"] == broken_name
210
+
211
+
212
  def test_seed_commons_gated_before_effective_date(client, monkeypatch):
213
  _with_admin(monkeypatch)
214
  import datetime as _dt