Annie Voigt commited on
Commit
f35546b
·
1 Parent(s): 0946ace

feat(datasets): dataset_load — execute the loading plan by dataset_id in one call

Browse files

Closes the High step-budget item: both measured 12-step runs burned 3-5
steps picking a loading entry point and 2 more collapsing probes because
they used the uncollapsed URL. The plan-side fix (7944fee) told the
agent HOW to load; this makes loading ONE tool call.

dataset_load(dataset_id) resolves the manifest, builds the loading plan
(_build_loading_plan stays the single source of truth), and executes its
get-the-data-open prefix — correct loader per source type, precomputed
collapsed_url, clinical join, curated-sample filter — threading the h5ad
path between steps. Returns the analysis-ready adata_path plus
analysis_path/data_level/default_contrast and per-step provenance; a
mid-plan failure surfaces the executed steps and last good intermediate.
Loader executors import lazily (scanpy only for Path P manifests).

Prompt efficiency rule 7 now mandates dataset_load as the first call for
any adata_path tool (dataset_plan_analysis/describe reserved for 'how
would you analyze' questions), and the GEO loader's next_step no longer
unconditionally tells the agent to run decoupler_inspect_data — the
last holdout of the rule-4 contradiction (Med item, same lane).

Live-verified: dataset_load('gse71989_chen') → one step, authenticated
collapsed-h5ad download, 22x21655, Path B + limma contrast.
7 tests in tests/test_dataset_load_tool.py; suite 1426 passed / 60
skipped.

prompts.yaml CHANGED
@@ -203,7 +203,7 @@ The ONLY datasets registered in this system are listed below with everything\
203
  \ ## Available Datasets. Skip straight to DE or the relevant analysis tool.\n\n5. **Always pass `subset_query` when the default contrast specifies one.** The ## Available\
204
  \ Datasets section lists each dataset's default contrasts, including any `subset_query`. When running DE,\
205
  \ read that value and pass it directly — never pre-filter the AnnData manually. Example: if the contrast\
206
- \ entry shows `subset_query=\"tumor_subtype != ''\"`, pass that exact string to `decoupler_differential_expression`.\n\n6. **Never import from `tools`, `src.tools`, `server`, or any `_mcp` module, and never inspect `FunctionTool`/`.fn`/`.run()` internals.** All functions listed below are already pre-loaded as plain callables in your namespace and are re-verified before every step. If a call to one of them raises `NameError` or `'FunctionTool' object is not callable`, do NOT start importing or introspecting — simply retry the exact same call with the parameters documented below. If it still fails after one retry, move on to a different approach rather than reverse-engineering the tool wrapper.\n\n7. **Never guess how to load a registered dataset — ask for its loading plan.** FIRST check whether the tool you need already takes a `dataset_id` (e.g. `dataset_score_bulk_samples`, `dataset_compare_activity_by_group`, `dataset_score_signature`, `dataset_get_integration_plan`). **Those tools load the dataset themselves — call them directly. Do NOT call `dataset_plan_analysis` and do NOT load the data first; that wastes two steps.** Only when you need a tool that takes an `adata_path` (e.g. `decoupler_differential_expression`) do you need the data on disk — and then your first call is `dataset_plan_analysis(dataset_id=..., user_question=...)` (or `dataset_describe`). It returns the exact loader, the exact arguments, and the correct URL to use. Do NOT open a dataset by pattern-matching its URL out of ## Available Datasets and picking a loader that looks right: `decoupler_load_geo_series_matrix` does not read h5ad, a bare `urllib`/`requests` download cannot authenticate to the private data repo, and `ad.read_h5ad` on a URL is not a local path. Each wrong guess costs a step, and the plan you skipped names the right call. In particular, when a dataset has a **collapsed** variant of its file the plan points at that URL — using the uncollapsed one forces two extra steps (`decoupler_annotate_probes_with_gpl` + `decoupler_collapse_probes_to_genes`) that the plan avoids entirely.\n\n8. **Load each dataset exactly once per turn.** The loaded AnnData is written to `output_path` and cached. If you already loaded a dataset this turn, reuse that path — never re-download or re-load it to 'check' something.\n\n## Available Functions\n\nYou have access to the following functions. These functions are already available\
207
  \ in your Python environment and can be called directly:\n\n{% for func_name, schema in functions.items() %}\n**{{ schema.function.name\
208
  \ }}({% for param_name in schema.function.parameters.properties.keys() %}{{ param_name }}{{ \", \" if not loop.last }}{%\
209
  \ endfor %})**\n- Description: {{ schema.function.description }}\n- Parameters:\n {% for param_name, param_info in schema.function.parameters.properties.items()\
 
203
  \ ## Available Datasets. Skip straight to DE or the relevant analysis tool.\n\n5. **Always pass `subset_query` when the default contrast specifies one.** The ## Available\
204
  \ Datasets section lists each dataset's default contrasts, including any `subset_query`. When running DE,\
205
  \ read that value and pass it directly — never pre-filter the AnnData manually. Example: if the contrast\
206
+ \ entry shows `subset_query=\"tumor_subtype != ''\"`, pass that exact string to `decoupler_differential_expression`.\n\n6. **Never import from `tools`, `src.tools`, `server`, or any `_mcp` module, and never inspect `FunctionTool`/`.fn`/`.run()` internals.** All functions listed below are already pre-loaded as plain callables in your namespace and are re-verified before every step. If a call to one of them raises `NameError` or `'FunctionTool' object is not callable`, do NOT start importing or introspecting — simply retry the exact same call with the parameters documented below. If it still fails after one retry, move on to a different approach rather than reverse-engineering the tool wrapper.\n\n7. **Never guess how to load a registered dataset — ask for its loading plan.** FIRST check whether the tool you need already takes a `dataset_id` (e.g. `dataset_score_bulk_samples`, `dataset_compare_activity_by_group`, `dataset_score_signature`, `dataset_get_integration_plan`). **Those tools load the dataset themselves — call them directly. Do NOT call `dataset_plan_analysis` and do NOT load the data first; that wastes two steps.** Only when you need a tool that takes an `adata_path` (e.g. `decoupler_differential_expression`, `decoupler_pseudobulk_aggregate`, the metadata tools) do you need the data on disk — and then your first call is `dataset_load(dataset_id=...)`. It EXECUTES the whole loading plan in one step (the correct loader for the source type, the precomputed collapsed URL, the clinical join, the curated-sample filter) and returns the analysis-ready `adata_path` plus the analysis path, data_level and default contrast — pass that path straight to the analysis tool. Do NOT hand-pick loaders, do NOT call `dataset_plan_analysis`/`dataset_describe` first just to load (use those only when the user asks how a dataset WOULD be analyzed), and do NOT re-load a dataset already loaded this turn. Do NOT open a dataset by pattern-matching its URL out of ## Available Datasets and picking a loader that looks right: `decoupler_load_geo_series_matrix` does not read h5ad, a bare `urllib`/`requests` download cannot authenticate to the private data repo, and `ad.read_h5ad` on a URL is not a local path. Each wrong guess costs a step, and `dataset_load` already makes the right calls including using a dataset's **collapsed** file variant when one exists (loading the uncollapsed one instead forces two extra steps, `decoupler_annotate_probes_with_gpl` + `decoupler_collapse_probes_to_genes`).\n\n8. **Load each dataset exactly once per turn.** The loaded AnnData is written to `output_path` and cached. If you already loaded a dataset this turn, reuse that path — never re-download or re-load it to 'check' something.\n\n## Available Functions\n\nYou have access to the following functions. These functions are already available\
207
  \ in your Python environment and can be called directly:\n\n{% for func_name, schema in functions.items() %}\n**{{ schema.function.name\
208
  \ }}({% for param_name in schema.function.parameters.properties.keys() %}{{ param_name }}{{ \", \" if not loop.last }}{%\
209
  \ endfor %})**\n- Description: {{ schema.function.description }}\n- Parameters:\n {% for param_name, param_info in schema.function.parameters.properties.items()\
src/tools/dataset_tools/catalog.py CHANGED
@@ -3,6 +3,7 @@
3
 
4
  from ._base import * # noqa: F401
5
  from ._base import ( # noqa: F401
 
6
  _build_loading_plan,
7
  _classify_metadata_values,
8
  _col_semantics,
@@ -202,6 +203,169 @@ def dataset_get_integration_plan(
202
  }
203
 
204
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  @dataset_mcp.tool
206
  def dataset_filter_to_curated_samples(
207
  adata_path: Annotated[
 
3
 
4
  from ._base import * # noqa: F401
5
  from ._base import ( # noqa: F401
6
+ _LOADING_PLAN_TOOLS,
7
  _build_loading_plan,
8
  _classify_metadata_values,
9
  _col_semantics,
 
203
  }
204
 
205
 
206
+ def _loading_step_executors() -> dict:
207
+ """Map loading-plan tool names to their callables, imported lazily.
208
+
209
+ Lazy so this module does not pull in the heavy loader stacks (scanpy for
210
+ the sc loader) at import time; the tool decorators leave these as plain
211
+ callable functions.
212
+ """
213
+ from src.tools.bulk_rnaseq.tools import (
214
+ decoupler_join_clinical_metadata,
215
+ decoupler_load_gdc_star_counts,
216
+ decoupler_load_url_counts,
217
+ )
218
+ from src.tools.rna.loaders import (
219
+ decoupler_annotate_probes_with_gpl,
220
+ decoupler_collapse_probes_to_genes,
221
+ decoupler_load_geo_series_matrix,
222
+ )
223
+
224
+ executors = {
225
+ "decoupler_load_url_counts": decoupler_load_url_counts,
226
+ "decoupler_load_gdc_star_counts": decoupler_load_gdc_star_counts,
227
+ "decoupler_join_clinical_metadata": decoupler_join_clinical_metadata,
228
+ "decoupler_load_geo_series_matrix": decoupler_load_geo_series_matrix,
229
+ "decoupler_annotate_probes_with_gpl": decoupler_annotate_probes_with_gpl,
230
+ "decoupler_collapse_probes_to_genes": decoupler_collapse_probes_to_genes,
231
+ "dataset_filter_to_curated_samples": dataset_filter_to_curated_samples,
232
+ }
233
+ try: # scanpy stack — only needed for Path P manifests
234
+ from src.tools.rna_sc import decoupler_load_and_visualize_data
235
+
236
+ executors["decoupler_load_and_visualize_data"] = decoupler_load_and_visualize_data
237
+ except Exception:
238
+ pass
239
+ return executors
240
+
241
+
242
+ @dataset_mcp.tool
243
+ def dataset_load(
244
+ dataset_id: Annotated[
245
+ str,
246
+ "The registered dataset to load, e.g. 'gse71729_moffitt' or 'tcga_paad'. "
247
+ "Call dataset_list_available() first only if you are unsure of the ID.",
248
+ ],
249
+ ) -> dict:
250
+ """
251
+ Load a registered dataset end-to-end and return the analysis-ready h5ad path.
252
+
253
+ This EXECUTES the manifest's loading plan in one call — the correct loader
254
+ for the source type (url / GEO series matrix / GDC / hosted h5ad), the
255
+ precomputed collapsed_url when one exists, the clinical-metadata join, and
256
+ the curated-sample filter — so you never pick an entry point yourself.
257
+
258
+ Call this as the FIRST step whenever a registered dataset must be on disk
259
+ for a tool that takes an adata_path (decoupler_differential_expression,
260
+ decoupler_pseudobulk_aggregate, metadata tools). Do NOT use it for tools
261
+ that already take a dataset_id (dataset_score_bulk_samples,
262
+ dataset_compare_activity_by_group) — those load the data themselves.
263
+
264
+ Returns adata_path (pass it straight to the next tool), the analysis path
265
+ (A = DESeq2 on raw counts, B = limma/ttest on normalized data, P =
266
+ single-cell: pseudobulk before any DE), the manifest's default contrast,
267
+ and the executed steps for provenance.
268
+ """
269
+ try:
270
+ manifest = load_manifest(dataset_id)
271
+ except KeyError as exc:
272
+ return {
273
+ "error": str(exc),
274
+ "available_datasets": [d["dataset_id"] for d in list_available_datasets()],
275
+ }
276
+
277
+ try:
278
+ plan = _build_loading_plan(manifest)
279
+ except Exception as exc:
280
+ return {"error": f"Could not build the loading plan for '{dataset_id}': {exc}"}
281
+
282
+ executors = _loading_step_executors()
283
+ executed: list[dict] = []
284
+ adata_path: str | None = None
285
+
286
+ for raw in plan:
287
+ tool = raw.get("tool")
288
+ if tool not in _LOADING_PLAN_TOOLS:
289
+ continue # analysis tail — owned by the caller, not the loader
290
+ fn = executors.get(tool)
291
+ if fn is None:
292
+ return {
293
+ "error": (
294
+ f"No executor for loading step '{tool}' — follow the "
295
+ f"loading_plan manually from dataset_describe('{dataset_id}')."
296
+ ),
297
+ "steps_executed": executed,
298
+ }
299
+ args = dict(raw.get("key_args") or {})
300
+ # Thread the running h5ad path through placeholder args
301
+ # ("<output_path from step N>") emitted by _build_loading_plan.
302
+ for k, v in args.items():
303
+ if isinstance(v, str) and v.startswith("<"):
304
+ args[k] = adata_path
305
+ try:
306
+ res = fn(**args)
307
+ except Exception as exc:
308
+ return {
309
+ "error": f"Loading step '{tool}' failed: {exc}",
310
+ "steps_executed": executed,
311
+ "adata_path": adata_path,
312
+ "note": (
313
+ "Steps executed so far succeeded; adata_path (if set) is the "
314
+ "last good intermediate. Consult dataset_describe for the "
315
+ "remaining plan rather than guessing a loader."
316
+ ),
317
+ }
318
+ if isinstance(res, dict) and res.get("error"):
319
+ return {
320
+ "error": f"Loading step '{tool}' failed: {res['error']}",
321
+ "steps_executed": executed,
322
+ "adata_path": adata_path,
323
+ }
324
+ out = res.get("output_path") if isinstance(res, dict) else None
325
+ if out:
326
+ adata_path = out
327
+ executed.append({"tool": tool, "output_path": out})
328
+
329
+ if adata_path is None:
330
+ return {
331
+ "error": (
332
+ f"The loading plan for '{dataset_id}' produced no h5ad — "
333
+ f"source type '{manifest.expression_source.get('type')}' may be "
334
+ "unsupported. Use dataset_describe and load manually."
335
+ ),
336
+ "steps_executed": executed,
337
+ }
338
+
339
+ contrast = get_contrast_groups(manifest)
340
+ path = manifest.analysis_path
341
+ de_method = {
342
+ "A": "deseq2 (raw integer counts)",
343
+ "B": "limma or ttest (pre-normalized data — never deseq2)",
344
+ "P": "NONE directly — pseudobulk first (decoupler_pseudobulk_aggregate), then Path A/B",
345
+ }.get(path, "see data_level")
346
+
347
+ return {
348
+ "dataset_id": dataset_id,
349
+ "adata_path": adata_path,
350
+ "analysis_path": path,
351
+ "data_level": manifest.data_level,
352
+ "recommended_de_method": de_method,
353
+ "default_contrast": contrast,
354
+ "steps_executed": executed,
355
+ "next_step": (
356
+ "The dataset is loaded and analysis-ready — pass adata_path directly "
357
+ "to the analysis tool. Do NOT call decoupler_inspect_data (the "
358
+ "manifest already fixes data_level) and do NOT reload the data."
359
+ + (
360
+ " Path P: aggregate to pseudobulk before any DE — never "
361
+ "DESeq2/limma on per-cell counts."
362
+ if path == "P"
363
+ else ""
364
+ )
365
+ ),
366
+ }
367
+
368
+
369
  @dataset_mcp.tool
370
  def dataset_filter_to_curated_samples(
371
  adata_path: Annotated[
src/tools/rna/loaders.py CHANGED
@@ -265,8 +265,10 @@ def decoupler_load_geo_series_matrix(
265
  "var_index_sample": list(adata.var.index[:5]),
266
  "output_path": str(output_path.resolve()),
267
  "next_step": (
268
- "Run decoupler_inspect_data(adata_path=output_path) to determine data type "
269
- "and recommended analysis path."
 
 
270
  ),
271
  "artifacts": [
272
  {
@@ -348,8 +350,10 @@ def decoupler_load_geo_series_matrix(
348
  "var_index_sample": list(adata.var.index[:5]),
349
  "output_path": str(output_path.resolve()),
350
  "next_step": (
351
- "Run decoupler_inspect_data(adata_path=output_path) to determine data type "
352
- "and recommended analysis path."
 
 
353
  ),
354
  "artifacts": [
355
  {
 
265
  "var_index_sample": list(adata.var.index[:5]),
266
  "output_path": str(output_path.resolve()),
267
  "next_step": (
268
+ "If this file is NOT a registered dataset, run "
269
+ "decoupler_inspect_data(adata_path=output_path) to determine data type and "
270
+ "analysis path. For a REGISTERED dataset, skip it — data_level and "
271
+ "analysis_path are manifest facts; go straight to the analysis tool."
272
  ),
273
  "artifacts": [
274
  {
 
350
  "var_index_sample": list(adata.var.index[:5]),
351
  "output_path": str(output_path.resolve()),
352
  "next_step": (
353
+ "If this file is NOT a registered dataset, run "
354
+ "decoupler_inspect_data(adata_path=output_path) to determine data type and "
355
+ "analysis path. For a REGISTERED dataset, skip it — data_level and "
356
+ "analysis_path are manifest facts; go straight to the analysis tool."
357
  ),
358
  "artifacts": [
359
  {
tests/test_dataset_load_tool.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for dataset_load — the registry-aware load-by-dataset_id executor.
3
+
4
+ The 2026-08-04 step-budget measurement showed runs burning 3-5 steps picking a
5
+ loading entry point (and 2 more collapsing probes when the uncollapsed URL was
6
+ used despite a precomputed collapsed_url). dataset_load executes the manifest's
7
+ loading plan in ONE tool call: correct loader for the source type, collapsed
8
+ URL when present, clinical join, curated-sample filter — and returns the
9
+ analysis-ready h5ad path plus routing facts.
10
+
11
+ Loader executors are monkeypatched (no network); the plans are built from the
12
+ real registry manifests so the wiring under test is the deployed wiring.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ sys.path.insert(0, str(Path(__file__).parent.parent))
21
+
22
+ import src.tools.dataset_tools.catalog as catalog # noqa: E402
23
+ from src.datasets.registry import load_manifest # noqa: E402
24
+ from src.tools.dataset_tools._base import _LOADING_PLAN_TOOLS, _build_loading_plan # noqa: E402
25
+
26
+
27
+ def _fake_executors(calls, fail_on=None):
28
+ """Executor map whose loaders record calls and return sequential paths."""
29
+
30
+ def make(tool):
31
+ def run(**kwargs):
32
+ if tool == fail_on:
33
+ raise RuntimeError("boom")
34
+ calls.append((tool, kwargs))
35
+ return {"output_path": f"/tmp/fake/{tool}_{len(calls)}.h5ad"}
36
+
37
+ return run
38
+
39
+ return {t: make(t) for t in _LOADING_PLAN_TOOLS}
40
+
41
+
42
+ def test_unknown_dataset_returns_available_list():
43
+ res = catalog.dataset_load("no_such_dataset")
44
+ assert "error" in res
45
+ assert "gse71729_moffitt" in res["available_datasets"]
46
+
47
+
48
+ def test_executes_plan_in_order_and_threads_paths(monkeypatch):
49
+ calls = []
50
+ monkeypatch.setattr(catalog, "_loading_step_executors", lambda: _fake_executors(calls))
51
+ res = catalog.dataset_load("gse71729_moffitt")
52
+
53
+ assert "error" not in res
54
+ plan_tools = [
55
+ s["tool"]
56
+ for s in _build_loading_plan(load_manifest("gse71729_moffitt"))
57
+ if s["tool"] in _LOADING_PLAN_TOOLS
58
+ ]
59
+ assert [t for t, _ in calls] == plan_tools
60
+ assert [s["tool"] for s in res["steps_executed"]] == plan_tools
61
+ # The final adata_path is the last step's output.
62
+ assert res["adata_path"] == f"/tmp/fake/{plan_tools[-1]}_{len(calls)}.h5ad"
63
+ # No placeholder strings may reach a loader.
64
+ for _, kwargs in calls:
65
+ for v in kwargs.values():
66
+ assert not (isinstance(v, str) and v.startswith("<")), (
67
+ f"placeholder leaked into loader args: {v}"
68
+ )
69
+ # Routing facts come from the manifest.
70
+ assert res["analysis_path"] in ("A", "B", "P")
71
+ assert res["data_level"]
72
+ assert "design_factor" in res["default_contrast"]
73
+ assert "decoupler_inspect_data" in res["next_step"] # explicitly discouraged
74
+
75
+
76
+ def test_curated_dataset_includes_curation_step(monkeypatch):
77
+ """tcga_paad has a curated_sample_list — the executor must run the filter."""
78
+ calls = []
79
+ monkeypatch.setattr(catalog, "_loading_step_executors", lambda: _fake_executors(calls))
80
+ res = catalog.dataset_load("tcga_paad")
81
+ tools = [t for t, _ in calls]
82
+ assert "dataset_filter_to_curated_samples" in tools
83
+ assert res["analysis_path"] == "A"
84
+ assert "deseq2" in res["recommended_de_method"]
85
+
86
+
87
+ def test_path_b_dataset_recommends_non_deseq2(monkeypatch):
88
+ calls = []
89
+ monkeypatch.setattr(catalog, "_loading_step_executors", lambda: _fake_executors(calls))
90
+ res = catalog.dataset_load("gse71729_moffitt")
91
+ assert res["analysis_path"] == "B"
92
+ assert "deseq2" not in res["recommended_de_method"].split(" ")[0]
93
+ assert "limma" in res["recommended_de_method"]
94
+
95
+
96
+ def test_step_failure_returns_partial_progress(monkeypatch):
97
+ calls = []
98
+ manifest = load_manifest("tcga_paad")
99
+ plan_tools = [
100
+ s["tool"] for s in _build_loading_plan(manifest) if s["tool"] in _LOADING_PLAN_TOOLS
101
+ ]
102
+ fail_tool = plan_tools[-1] # fail the last loading step
103
+ monkeypatch.setattr(
104
+ catalog, "_loading_step_executors", lambda: _fake_executors(calls, fail_on=fail_tool)
105
+ )
106
+ res = catalog.dataset_load("tcga_paad")
107
+ assert "error" in res and fail_tool in res["error"]
108
+ # earlier steps are reported, and the last good intermediate is surfaced
109
+ assert [s["tool"] for s in res["steps_executed"]] == plan_tools[:-1]
110
+ assert res["adata_path"] == f"/tmp/fake/{plan_tools[-2]}_{len(calls)}.h5ad"
111
+
112
+
113
+ def test_error_dict_from_loader_is_surfaced(monkeypatch):
114
+ def bad_executors():
115
+ ex = _fake_executors([])
116
+ first = [
117
+ s["tool"]
118
+ for s in _build_loading_plan(load_manifest("gse71729_moffitt"))
119
+ if s["tool"] in _LOADING_PLAN_TOOLS
120
+ ][0]
121
+ ex[first] = lambda **kw: {"error": "404 not found"}
122
+ return ex
123
+
124
+ monkeypatch.setattr(catalog, "_loading_step_executors", bad_executors)
125
+ res = catalog.dataset_load("gse71729_moffitt")
126
+ assert "error" in res and "404 not found" in res["error"]
127
+
128
+
129
+ def test_single_cell_dataset_flags_pseudobulk(monkeypatch):
130
+ calls = []
131
+ monkeypatch.setattr(catalog, "_loading_step_executors", lambda: _fake_executors(calls))
132
+ res = catalog.dataset_load("gse155698_steele")
133
+ assert res["analysis_path"] == "P"
134
+ assert "pseudobulk" in res["recommended_de_method"].lower()
135
+ assert "pseudobulk" in res["next_step"].lower()