feat(datasets): dataset_load — execute the loading plan by dataset_id in one call
Browse filesCloses 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 +1 -1
- src/tools/dataset_tools/catalog.py +164 -0
- src/tools/rna/loaders.py +8 -4
- tests/test_dataset_load_tool.py +135 -0
|
@@ -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 `
|
| 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()\
|
|
@@ -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[
|
|
@@ -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 |
-
"
|
| 269 |
-
"
|
|
|
|
|
|
|
| 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 |
-
"
|
| 352 |
-
"
|
|
|
|
|
|
|
| 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 |
{
|
|
@@ -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()
|