File size: 35,938 Bytes
0892c76
 
 
6ae6b6a
0892c76
6ae6b6a
 
 
 
 
 
 
 
 
 
 
0892c76
526f1d2
 
 
 
 
 
 
 
 
 
 
 
0892c76
 
 
 
 
 
 
 
 
 
 
c3b49d6
0892c76
 
 
 
c3b49d6
0892c76
 
 
 
 
 
 
 
 
c3b49d6
526f1d2
 
 
b04653d
c3b49d6
b04653d
c3b49d6
 
 
0892c76
 
 
 
 
 
 
c3b49d6
 
 
 
 
 
 
0892c76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
 
 
 
 
 
0892c76
 
 
 
c3b49d6
0892c76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
0892c76
 
 
 
c3b49d6
0892c76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
0892c76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6ae6b6a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
6ae6b6a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
6ae6b6a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
6ae6b6a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b04653d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
b04653d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
b04653d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
b04653d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
526f1d2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
526f1d2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
526f1d2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
526f1d2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
526f1d2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
"""
Cross-dataset integration tools (ADR-0001, Layer 1).

This MCP server exposes the cross-dataset result-combination paths:

1. decoupler_meta_analyze: *late integration* β€” combine >=2 per-dataset result
   files (same result_type + contrast) into one meta-analyzed table with
   per-feature cross-dataset heterogeneity (Cochran's Q / I^2). For INDEPENDENT
   cohorts (plan mode == "late").

2. decoupler_normalization_concordance: *same-cohort sensitivity check* β€” compare
   >=2 results that are the SAME samples quantified/normalized different ways
   (sibling variants; plan mode == "concordance"). It does NOT combine β€” it
   reports descriptive agreement (Pearson/Spearman, sign-concordance, significant-
   call overlap), because meta-analyzing identical samples double-counts the
   cohort. Routed here by get_integration_plan's mode == "concordance".

3. decoupler_integrate_datasets: *early integration β€” DE* β€” POOL >=2 datasets into
   one matrix (feature intersection + a `batch` obs key) and run a single batch-aware
   DE (`dataset` as a covariate). Only for datasets the plan rules "early" (shared
   feature space + poolable data_level); it re-checks the plan and refuses/reroutes
   otherwise. (ADR-0001 T9, Mode A.)

4. decoupler_pool_cohorts: *early integration β€” SCORING* β€” POOL >=2 datasets and
   ComBat-correct (scanpy.pp.combat on the log-normalised matrix) into ONE
   pre-normalised matrix for PER-SAMPLE activity scoring (dataset_score_bulk_samples).
   The no-design-matrix counterpart to decoupler_integrate_datasets: scoring has no
   design matrix to carry a batch covariate, so the pooled matrix is batch-corrected
   before scoring. Same "early"-only plan gate. (ADR-0001 item 10, Mode A.)

Design (ADR-0001 Mode B):
- The *math* is generic over the result envelope and lives in
  src/workflows/meta_analysis.py (``combine_envelopes``); *strategy selection* is
  field-based in src/core/combine.py. This tool is the Layer-1 boundary: it reads
  the producer tools' native CSVs into the envelope, runs the engine, and writes
  the combined table. It never picks the combine math by tool identity.
- Which dataset can be combined with which is decided upstream by
  ``dataset_get_integration_plan`` (mode early/late/refuse); on a "late" verdict
  the specialist runs each dataset's pipeline, then calls this tool.
"""

from __future__ import annotations

from datetime import datetime
from pathlib import Path
from typing import Annotated, Literal

import pandas as pd
from fastmcp import FastMCP

from src.core.combine import (
    CANONICAL_RESULT_TYPES,
    ENVELOPE_FEATURE_FIELD,
    ENVELOPE_VALUE_FIELDS,
)

# Mode-A early integration (T9 + item 10): the plan gate, the combined-AnnData
# builder, the ComBat pre-correction (scoring path), and the batch-aware DE tool.
# Module-level so tests can monkeypatch them.
from src.datasets.registry import get_integration_plan
from src.tools.rna._base import OUTPUT_DIR
from src.tools.rna.analysis import decoupler_differential_expression
from src.workflows.concordance import concordance_metrics
from src.workflows.integration import batch_correct_for_scoring, build_combined_anndata
from src.workflows.meta_analysis import combine_envelopes

integration_mcp = FastMCP(name="integration")

# Enrichment tools serialise a WIDE activity matrix (1 contrast row x N features);
# the DE tool serialises a LONG table (feature rows x stat/pvalue/padj columns).
# These are the only two native shapes produced by the four `@combinable` rna
# tools β€” the result_type tells the reader which shape to expect.
_ENRICHMENT_RESULT_TYPES = frozenset(
    {
        "tf_enrichment",
        "pathway_enrichment",
        "hallmark_enrichment",
    }
)

# Default feature-label column per result_type (matches the rna tools' combine
# descriptors). Only used for messaging β€” the envelope feature column is always
# normalised to ENVELOPE_FEATURE_FIELD.
_DEFAULT_FEATURE_FIELD = {
    "de": "gene",
    "tf_enrichment": "TF",
    "pathway_enrichment": "Pathway",
    "hallmark_enrichment": "Gene_Set",
}

_REQUIRED_SPEC_KEYS = ("path", "result_type", "contrast")


def _spec_to_envelope(spec: dict) -> pd.DataFrame:
    """Read one per-dataset result file into a Mode-B result envelope.

    ``spec`` keys: ``path`` (the result CSV), ``result_type`` (one of
    CANONICAL_RESULT_TYPES), ``contrast`` (the comparison label), optional
    ``dataset_id`` (defaults to the file stem) and optional ``padj_path``
    (enrichment only β€” a companion p-adjusted CSV).

    Two native shapes are handled, chosen by ``result_type``:

    - enrichment (tf/pathway/hallmark): the activities CSV is WIDE β€” one contrast
      row x N feature columns. The single row is transposed to one row per
      feature with the activity under the envelope ``score`` column. A
      ``padj_path`` companion, if given, contributes the envelope ``padj`` column.
    - ``de``: the results CSV is already LONG β€” feature rows with envelope-named
      value columns (``stat``/``pvalue``/``padj``); those present are carried
      through.

    The shared tags (``result_type``/``contrast``/``dataset_id``) are stamped on
    every row so the caller's result-level compatibility check and the combined
    output can identify the source.
    """
    path = Path(spec["path"])
    if not path.exists():
        raise FileNotFoundError(f"result file not found: {path}")
    result_type = spec["result_type"]
    contrast = spec["contrast"]
    dataset_id = spec.get("dataset_id") or path.stem

    raw = pd.read_csv(path, index_col=0)
    if raw.shape[0] == 0 or raw.shape[1] == 0:
        raise ValueError(f"result file is empty: {path}")

    if result_type in _ENRICHMENT_RESULT_TYPES:
        # Wide activities: take the (single) contrast row -> score per feature.
        scores = raw.iloc[0]
        env = pd.DataFrame(
            {
                ENVELOPE_FEATURE_FIELD: scores.index.astype(str),
                "score": pd.to_numeric(scores.to_numpy(), errors="coerce"),
            }
        )
        padj_path = spec.get("padj_path")
        if padj_path:
            padj_raw = pd.read_csv(padj_path, index_col=0)
            padj_row = padj_raw.iloc[0]
            padj_map = {str(k): pd.to_numeric(v, errors="coerce") for k, v in padj_row.items()}
            env["padj"] = env[ENVELOPE_FEATURE_FIELD].map(padj_map)
    else:
        # Long table: feature in the index, envelope value columns by name.
        env = raw.copy()
        env.index = env.index.astype(str)
        # reset_index() makes the former index the first column; name it `feature`.
        env = env.reset_index()
        env = env.rename(columns={env.columns[0]: ENVELOPE_FEATURE_FIELD})
        present = [f for f in ENVELOPE_VALUE_FIELDS if f in env.columns]
        if not present:
            raise ValueError(
                f"result file {path} (result_type='{result_type}') has none of the "
                f"envelope value columns {ENVELOPE_VALUE_FIELDS}; columns present: "
                f"{list(raw.columns)}"
            )
        env = env[[ENVELOPE_FEATURE_FIELD, *present]]

    env["result_type"] = result_type
    env["contrast"] = contrast
    env["dataset_id"] = dataset_id
    return env


def _refusal(reason: str, **extra) -> dict:
    """Uniform refuse payload (mirrors the dataset tools' error-dict convention)."""
    return {"error": reason, "refused": True, **extra}


@integration_mcp.tool
def decoupler_meta_analyze(
    results: Annotated[
        list[dict],
        "Two or more per-dataset result specs to meta-analyze (late integration). "
        "Each item is a dict: {'path': <result CSV>, 'result_type': one of "
        "'de'|'tf_enrichment'|'pathway_enrichment'|'hallmark_enrichment', "
        "'contrast': <label, e.g. 'Tumor.vs.Normal'>, 'dataset_id': <optional "
        "label>, 'padj_path': <optional enrichment p-adjusted CSV>}. For DE use "
        "the de_results_path; for enrichment use the activities CSV (output_path). "
        "ALL items MUST share the same result_type AND contrast.",
    ],
    strategy: Annotated[
        str | None,
        "Optional combine strategy override: 'inverse_variance' | 'stouffer' | "
        "'fisher' | 'rank_aggregation'. Default None auto-selects the best strategy "
        "the available envelope fields support (effect-size > Stouffer > Fisher).",
    ] = None,
    out_prefix: Annotated[str | None, "Output file prefix for the combined results CSV."] = None,
) -> dict:
    """
    Meta-analyze (late integration) >=2 per-dataset result tables into one.

    Use this for the "late" branch of a cross-dataset request: after
    dataset_get_integration_plan returns mode=="late", run each dataset's pipeline
    SEPARATELY (same analysis tool, same contrast/method on each), then call this
    tool with the per-dataset result files.

    It enforces the result-level compatibility check (all inputs share
    result_type AND contrast β€” a mismatch is refused), reads each result into the
    common envelope, combines them with a field-dispatched strategy (no pooling of
    raw expression), and reports per-feature cross-dataset heterogeneity (Cochran's
    Q and I^2). High I^2 (>50%) flags a feature whose effect is inconsistent across
    cohorts.

    Returns a summary dict with the chosen strategy, the combined results CSV path,
    the number of features and significant features, and the top combined rows.
    Returns an {"error": ..., "refused": True} dict (does not raise) when inputs
    are incompatible, so the agent can surface the reason to the user.
    """
    # --- Validate arity + per-item schema ---------------------------------
    if not isinstance(results, list) or len(results) < 2:
        return _refusal(
            f"Meta-analysis needs at least 2 per-dataset results; got "
            f"{len(results) if isinstance(results, list) else 'a non-list'}."
        )
    for i, spec in enumerate(results):
        if not isinstance(spec, dict):
            return _refusal(f"results[{i}] is not a dict: {spec!r}")
        missing = [k for k in _REQUIRED_SPEC_KEYS if not spec.get(k)]
        if missing:
            return _refusal(f"results[{i}] is missing required key(s) {missing}.")
        rt = spec["result_type"]
        if rt not in CANONICAL_RESULT_TYPES:
            return _refusal(
                f"results[{i}] has unknown result_type '{rt}'; expected one of "
                f"{sorted(CANONICAL_RESULT_TYPES)}."
            )

    # --- Result-level compatibility: shared result_type AND contrast ------
    result_types = {s["result_type"] for s in results}
    contrasts = {s["contrast"] for s in results}
    if len(result_types) > 1:
        return _refusal(
            "Cannot meta-analyze across different result types "
            f"{sorted(result_types)} β€” combine only like-with-like (e.g. all "
            "tf_enrichment). Run the SAME analysis tool on each dataset first.",
            result_types=sorted(result_types),
        )
    if len(contrasts) > 1:
        return _refusal(
            f"Cannot meta-analyze across different contrasts {sorted(contrasts)} β€” "
            "every dataset must be analyzed with the same contrast before combining.",
            contrasts=sorted(contrasts),
        )

    result_type = next(iter(result_types))
    contrast = next(iter(contrasts))

    # --- Read each result into the envelope -------------------------------
    try:
        envelopes = [_spec_to_envelope(spec) for spec in results]
    except (FileNotFoundError, ValueError) as exc:
        return _refusal(f"Could not read a result into the envelope: {exc}")

    dataset_ids = [spec.get("dataset_id") or Path(spec["path"]).stem for spec in results]

    # --- Combine (field-based strategy dispatch) --------------------------
    try:
        combined = combine_envelopes(envelopes, strategy=strategy)
    except Exception as exc:  # NoCombineStrategyError, ValueError, ...
        return _refusal(f"Meta-analysis could not combine these results: {exc}")

    strategy_used = combined.attrs.get("strategy", strategy or "auto")

    out_prefix = out_prefix or f"meta_{result_type}_{datetime.now():%Y%m%d_%H%M%S}"
    out_path = OUTPUT_DIR / f"{out_prefix}_meta_analysis.csv"
    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
    combined.to_csv(out_path, index=False)

    n_features = int(len(combined))
    n_sig = int((combined["padj"] < 0.05).sum()) if "padj" in combined else 0

    return {
        "message": (
            f"Meta-analyzed {len(results)} datasets "
            f"({', '.join(dataset_ids)}) for {result_type} / contrast '{contrast}' "
            f"using the '{strategy_used}' strategy. {n_features} features combined, "
            f"{n_sig} significant (padj < 0.05)."
        ),
        "mode": "late",
        "result_type": result_type,
        "contrast": contrast,
        "datasets_combined": dataset_ids,
        "n_datasets": len(results),
        "strategy": strategy_used,
        "available_fields": combined.attrs.get("available_fields", []),
        "n_features": n_features,
        "n_significant": n_sig,
        "combined_results_path": str(out_path.resolve()),
        "output_path": str(out_path.resolve()),
        "top_results": combined.head(10).round(4).to_dict(orient="records"),
        "heterogeneity_note": (
            "Per-feature Cochran's Q and I^2 are in the combined CSV (columns Q, "
            "I2). I^2 > 50% flags a feature whose effect is inconsistent across "
            "cohorts; report it alongside the combined estimate."
        ),
        "artifacts": [
            {
                "description": "Meta-analysis combined results (combined stat/score/p, padj, Q, I2)",
                "path": str(out_path.resolve()),
            }
        ],
    }


@integration_mcp.tool
def decoupler_normalization_concordance(
    results: Annotated[
        list[dict],
        "Two or more SAME-COHORT variant result specs to compare (e.g. the TPM "
        "and TMM quantifications of one cohort). Each item is a dict: {'path': "
        "<result CSV>, 'result_type': one of 'de'|'tf_enrichment'|"
        "'pathway_enrichment'|'hallmark_enrichment', 'contrast': <label>, "
        "'dataset_id': <variant label, e.g. 'gse205154_sears_tpm'>, 'padj_path': "
        "<optional enrichment p-adjusted CSV>}. For DE use the de_results_path; for "
        "enrichment use the activities CSV (output_path). ALL items MUST share the "
        "same result_type AND contrast.",
    ],
    sig_threshold: Annotated[
        float,
        "Adjusted-p cutoff defining a 'significant' call per variant, for the "
        "significant-call overlap (default 0.05).",
    ] = 0.05,
    out_prefix: Annotated[
        str | None, "Output file prefix for the per-feature concordance CSV."
    ] = None,
) -> dict:
    """
    Compare same-cohort variants descriptively (normalization sensitivity check).

    Use this for the "concordance" branch of a cross-dataset request: when
    dataset_get_integration_plan returns mode=="concordance" (the requested
    datasets are sibling quantifications of ONE cohort β€” same samples, different
    units, e.g. GSE205154 TPM vs TMM). Run each variant's pipeline SEPARATELY
    (same analysis tool, same contrast on each), then call this tool with the
    per-variant result files.

    Do NOT call decoupler_meta_analyze for sibling variants: meta-analysis assumes
    independent cohorts, so combining identical samples double-counts them
    (Stouffer inflates the score by ~sqrt(N); Cochran's Q / I^2 are 0 by
    construction). This tool combines nothing β€” it reports how much the
    normalization choice changed the answer:

    - pairwise Pearson and Spearman correlation of the per-feature effect,
    - sign-concordance (fraction of shared features pointing the same way),
    - the spread of the effect across variants, and
    - overlap of the significant-call sets (Jaccard + per-variant-only counts),
      when an adjusted p-value is available.

    Returns a summary dict (mode == "concordance") plus a per-feature CSV. Returns
    an {"error": ..., "refused": True} dict (does not raise) when inputs are
    incompatible, so the agent can surface the reason.
    """
    # --- Validate arity + per-item schema (mirrors decoupler_meta_analyze) ---
    if not isinstance(results, list) or len(results) < 2:
        return _refusal(
            f"Concordance needs at least 2 same-cohort variant results; got "
            f"{len(results) if isinstance(results, list) else 'a non-list'}."
        )
    for i, spec in enumerate(results):
        if not isinstance(spec, dict):
            return _refusal(f"results[{i}] is not a dict: {spec!r}")
        missing = [k for k in _REQUIRED_SPEC_KEYS if not spec.get(k)]
        if missing:
            return _refusal(f"results[{i}] is missing required key(s) {missing}.")
        rt = spec["result_type"]
        if rt not in CANONICAL_RESULT_TYPES:
            return _refusal(
                f"results[{i}] has unknown result_type '{rt}'; expected one of "
                f"{sorted(CANONICAL_RESULT_TYPES)}."
            )

    # --- Result-level compatibility: shared result_type AND contrast ---------
    result_types = {s["result_type"] for s in results}
    contrasts = {s["contrast"] for s in results}
    if len(result_types) > 1:
        return _refusal(
            "Cannot compare across different result types "
            f"{sorted(result_types)} β€” compare like-with-like (e.g. all "
            "tf_enrichment). Run the SAME analysis tool on each variant first.",
            result_types=sorted(result_types),
        )
    if len(contrasts) > 1:
        return _refusal(
            f"Cannot compare across different contrasts {sorted(contrasts)} β€” every "
            "variant must be analyzed with the same contrast before comparing.",
            contrasts=sorted(contrasts),
        )

    result_type = next(iter(result_types))
    contrast = next(iter(contrasts))
    dataset_ids = [spec.get("dataset_id") or Path(spec["path"]).stem for spec in results]

    # --- Read each result into the envelope ----------------------------------
    try:
        envelopes = [_spec_to_envelope(spec) for spec in results]
    except (FileNotFoundError, ValueError) as exc:
        return _refusal(f"Could not read a result into the envelope: {exc}")

    # --- Descriptive agreement (no combine) ----------------------------------
    try:
        summary, per_feature = concordance_metrics(
            envelopes, dataset_ids=dataset_ids, sig_threshold=sig_threshold
        )
    except ValueError as exc:
        return _refusal(f"Concordance could not compare these results: {exc}")

    out_prefix = out_prefix or f"concordance_{result_type}_{datetime.now():%Y%m%d_%H%M%S}"
    out_path = OUTPUT_DIR / f"{out_prefix}_concordance.csv"
    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
    per_feature.to_csv(out_path, index=False)

    overlap = summary["significant_overlap"]
    if summary["n_variants"] == 2:
        corr_msg = f"Pearson r={summary['pearson_r']:.4f}, Spearman r={summary['spearman_r']:.4f}"
    else:
        corr_msg = (
            f"min pairwise Pearson r={summary['pearson_min']:.4f}, "
            f"min Spearman r={summary['spearman_min']:.4f}"
        )
    overlap_msg = (
        f"; {overlap['n_significant_in_all']}/{overlap['n_significant_in_any']} "
        f"significant calls agree (Jaccard {overlap['jaccard_significant']:.3f})"
        if overlap.get("available")
        else ""
    )

    return {
        "message": (
            f"Compared {summary['n_variants']} same-cohort variants "
            f"({', '.join(dataset_ids)}) for {result_type} / contrast '{contrast}' "
            f"on the '{summary['effect_field']}' effect over "
            f"{summary['n_features_shared']} shared features: {corr_msg}; "
            f"sign-concordance {summary['sign_concordance']:.3f}{overlap_msg}. "
            f"This is a normalization sensitivity check, NOT a meta-analysis β€” the "
            f"variants are the same samples, so results are not combined."
        ),
        "mode": "concordance",
        "result_type": result_type,
        "contrast": contrast,
        "variants_compared": dataset_ids,
        "n_variants": summary["n_variants"],
        "n_features_shared": summary["n_features_shared"],
        "n_features_union": summary["n_features_union"],
        "effect_field": summary["effect_field"],
        "pearson_pairwise": summary["pearson_pairwise"],
        "spearman_pairwise": summary["spearman_pairwise"],
        "pearson_min": summary["pearson_min"],
        "spearman_min": summary["spearman_min"],
        "sign_concordance": summary["sign_concordance"],
        "n_sign_flips": summary["n_sign_flips"],
        "sign_flip_features": summary["sign_flip_features"],
        "max_effect_spread": summary["max_effect_spread"],
        "mean_effect_spread": summary["mean_effect_spread"],
        "significant_overlap": overlap,
        "top_divergent_features": summary["top_divergent_features"],
        "concordance_table_path": str(out_path.resolve()),
        "output_path": str(out_path.resolve()),
        "interpretation_note": (
            "High correlation + sign-concordance ~1.0 + high Jaccard means the "
            "normalization choice barely changes the result (a robustness signal). "
            "Report agreement metrics, not a combined score β€” there is no added "
            "statistical power here because the variants share all samples. "
            "Variant-only significant calls are normalization-sensitive (marginal) "
            "and should not be treated as robust findings."
        ),
        "artifacts": [
            {
                "description": (
                    "Per-feature concordance table (effect per variant, spread, "
                    "sign agreement, per-variant significance)"
                ),
                "path": str(out_path.resolve()),
            }
        ],
    }


@integration_mcp.tool
def decoupler_integrate_datasets(
    dataset_ids: Annotated[
        list[str],
        "Two or more registered dataset IDs to POOL into one matrix and analyze "
        "together (early integration), e.g. ['tcga_paad', 'paca_au_rnaseq'].",
    ],
    design_factor: Annotated[
        str,
        "obs column holding the contrast groups; must exist in EVERY dataset "
        "(e.g. 'tumor_subtype').",
    ],
    test_group: Annotated[str, "Test arm of the contrast (a value of design_factor)."],
    control_group: Annotated[str, "Control / reference arm of the contrast."],
    method: Annotated[
        Literal["auto", "deseq2", "limma"],
        "DE method on the pooled matrix. 'auto' (default) picks deseq2 for pooled "
        "raw counts and limma otherwise, from the integration plan's poolable "
        "data_level. `dataset` is always modelled as a batch covariate.",
    ] = "auto",
    min_shared_features: Annotated[
        int,
        "Minimum shared gene symbols required to pool; below this the datasets "
        "likely do not share a feature space and the request is refused.",
    ] = 200,
    out_prefix: Annotated[str | None, "Output file prefix."] = None,
) -> dict:
    """
    Early integration (Mode A): pool >=2 datasets into one matrix + one batch-aware DE.

    Use this ONLY when dataset_get_integration_plan returns mode=="early": the
    datasets share a poolable feature space AND data_level, so they can be
    concatenated and analyzed jointly with `dataset` as a batch covariate (more
    power than meta-analysis when valid). This tool RE-CHECKS the plan itself and
    refuses (does not pool) when the verdict is not 'early':

    - 'late'        -> run each dataset separately, then decoupler_meta_analyze.
    - 'concordance' -> same-cohort variants -> decoupler_normalization_concordance.
    - 'refuse'      -> incompatible (e.g. confounded design / cross-modality).

    On 'early' it builds the combined AnnData (feature intersection + a `batch`
    obs key = dataset_id), runs decoupler_differential_expression with
    batch_column='batch' (DESeq2 ~batch+factor for raw counts, limma ~batch+group
    otherwise), and returns the pooled DE result plus per-dataset composition.
    Returns an {"error": ..., "refused": True} dict (does not raise) so the agent
    can surface the reason and reroute.

    Pass design_factor + test_group + control_group: they drive the plan's
    confound check AND the pooled contrast.
    """
    if not isinstance(dataset_ids, list) or len(dataset_ids) < 2:
        return _refusal(
            "Early integration needs >=2 dataset_ids; got "
            f"{len(dataset_ids) if isinstance(dataset_ids, list) else 'a non-list'}."
        )

    try:
        plan = get_integration_plan(
            dataset_ids,
            design_factor=design_factor,
            test_group=test_group,
            control_group=control_group,
        )
    except Exception as exc:  # unknown dataset id, etc.
        return _refusal(f"Could not compute the integration plan: {exc}")

    mode = plan.get("mode")
    if mode != "early":
        reason = plan.get("reason", "")
        reroute = {
            "late": "Run each dataset separately, then call decoupler_meta_analyze.",
            "concordance": (
                "These are same-cohort variants β€” call decoupler_normalization_concordance instead."
            ),
        }.get(mode, "")
        return _refusal(
            (
                f"Datasets are not eligible for early pooling (plan mode='{mode}'). "
                f"{reason} {reroute}"
            ).strip(),
            mode=mode,
            plan_reason=reason,
            refusal_rules_triggered=plan.get("refusal_rules_triggered", []),
        )

    # Derive the DE method from the poolable data_level unless explicitly set.
    if method == "auto":
        method = "deseq2" if plan.get("poolable_data_level") == "raw_counts" else "limma"

    try:
        built = build_combined_anndata(
            dataset_ids,
            design_factor=design_factor,
            batch_key="batch",
            min_shared_features=min_shared_features,
            out_prefix=(f"{out_prefix}_combined" if out_prefix else None),
        )
    except Exception as exc:
        return _refusal(f"Could not build the pooled dataset: {exc}", mode="early")

    try:
        de = decoupler_differential_expression(
            adata_path=built["output_path"],
            design_factor=design_factor,
            contrast=[design_factor, test_group, control_group],
            method=method,
            batch_column="batch",
            out_prefix=out_prefix,
        )
    except Exception as exc:
        return _refusal(f"Pooled DE failed: {exc}", mode="early", combined=built, method=method)

    return {
        "message": (
            f"Early integration of {len(dataset_ids)} datasets "
            f"({', '.join(dataset_ids)}): pooled {built['n_obs']} samples over "
            f"{built['n_vars']} shared genes and ran {de.get('method_used')} with "
            f"dataset modelled as a batch covariate. "
            f"{de.get('n_significant')} significant genes."
        ),
        "mode": "early",
        "plan_reason": plan.get("reason", ""),
        "datasets": list(dataset_ids),
        "batch_key": "batch",
        "per_batch_n": built["per_batch_n"],
        "n_shared_features": built["n_vars"],
        "n_combined_samples": built["n_obs"],
        "method_used": de.get("method_used"),
        "n_significant": de.get("n_significant"),
        "batch_modeled": True,
        "combined_path": built["output_path"],
        "de_results_path": de.get("output_path"),
        "sanity_warnings": de.get("sanity_warnings"),
        "interpretation_note": (
            "Early-integration (pooled) result: the datasets were concatenated and "
            "analyzed jointly with `dataset` as a batch covariate, so the group "
            "effect is estimated adjusting for dataset. Report the datasets combined, "
            "the per-dataset sample counts, and the batch handling. This is more "
            "powerful than meta-analysis, but valid ONLY because the plan verdict was "
            "'early' (shared feature space + poolable data_level)."
        ),
        "artifacts": [
            {"description": "Pooled DE results (CSV)", "path": de.get("output_path")},
            {"description": "Combined AnnData (h5ad)", "path": built["output_path"]},
        ],
    }


@integration_mcp.tool
def decoupler_pool_cohorts(
    dataset_ids: Annotated[
        list[str],
        "Two or more registered dataset IDs to POOL into one batch-corrected matrix "
        "for PER-SAMPLE activity scoring (early integration), e.g. ['tcga_paad', "
        "'paca_au_rnaseq'].",
    ],
    min_shared_features: Annotated[
        int,
        "Minimum shared gene symbols required to pool; below this the datasets "
        "likely do not share a feature space and the request is refused.",
    ] = 200,
    out_prefix: Annotated[str | None, "Output file prefix."] = None,
) -> dict:
    """
    Early integration for SCORING: pool >=2 datasets + ComBat-correct into one matrix.

    Use this when the user wants PER-SAMPLE activity scores (PROGENy / CollecTRI /
    Hallmark via dataset_score_bulk_samples) across two or more cohorts pooled
    together β€” NOT a differential-expression contrast. It returns ONE pre-normalised,
    batch-corrected samples x genes matrix; feed its output_path straight to
    dataset_score_bulk_samples, so the scores are no longer cohort-confounded.

    This is the no-design-matrix counterpart to decoupler_integrate_datasets:
    - decoupler_integrate_datasets  -> early DE CONTRAST: models `dataset` as a batch
      COVARIATE in the test (the statistically correct route for DE).
    - decoupler_pool_cohorts (this) -> early per-sample SCORING: there is no design
      matrix to carry a covariate, so the pooled matrix is ComBat-corrected
      (scanpy.pp.combat on the log-normalised values) BEFORE scoring.

    Like decoupler_integrate_datasets it RE-CHECKS dataset_get_integration_plan and
    pools ONLY on mode=='early'; otherwise it refuses (does not pool):
    - 'late'        -> score each cohort separately, or decoupler_meta_analyze a DE.
    - 'concordance' -> same-cohort variants -> decoupler_normalization_concordance.
    - 'refuse'      -> incompatible (e.g. cross-modality / no shared feature space).

    ComBat is run WITHOUT a biological covariate (pure dataset-shift removal); if the
    biological groups are confounded with cohort it can also remove real between-cohort
    biology β€” stated in the returned interpretation_note. Do NOT use this matrix for DE
    testing (ComBat + naive DE inflates false positives β€” use decoupler_integrate_datasets).

    Returns an {"error": ..., "refused": True} dict (does not raise) so the agent can
    surface the reason and reroute.
    """
    if not isinstance(dataset_ids, list) or len(dataset_ids) < 2:
        return _refusal(
            "Early pooling needs >=2 dataset_ids; got "
            f"{len(dataset_ids) if isinstance(dataset_ids, list) else 'a non-list'}."
        )

    try:
        plan = get_integration_plan(dataset_ids)
    except Exception as exc:  # unknown dataset id, etc.
        return _refusal(f"Could not compute the integration plan: {exc}")

    mode = plan.get("mode")
    if mode != "early":
        reason = plan.get("reason", "")
        reroute = {
            "late": (
                "Score each cohort separately, or run a contrast per cohort and "
                "decoupler_meta_analyze the results."
            ),
            "concordance": (
                "These are same-cohort variants β€” call decoupler_normalization_concordance instead."
            ),
        }.get(mode, "")
        return _refusal(
            (
                f"Datasets are not eligible for early pooling (plan mode='{mode}'). "
                f"{reason} {reroute}"
            ).strip(),
            mode=mode,
            plan_reason=reason,
            refusal_rules_triggered=plan.get("refusal_rules_triggered", []),
        )

    # Pool into one raw combined matrix (no contrast needed for scoring).
    try:
        built = build_combined_anndata(
            dataset_ids,
            design_factor=None,
            batch_key="batch",
            min_shared_features=min_shared_features,
            out_prefix=(f"{out_prefix}_pooled" if out_prefix else None),
        )
    except Exception as exc:
        return _refusal(f"Could not build the pooled dataset: {exc}", mode="early")

    # ComBat-correct the pooled matrix for per-sample scoring.
    import scanpy as sc

    raw_pooled_path = built["output_path"]
    try:
        pooled = sc.read_h5ad(raw_pooled_path)
        corrected, info = batch_correct_for_scoring(
            pooled, batch_key="batch", data_level=plan.get("poolable_data_level")
        )
    except Exception as exc:
        return _refusal(f"ComBat batch correction failed: {exc}", mode="early", combined=built)

    prefix = out_prefix or ("pooled_" + "_".join(str(d) for d in dataset_ids))[:80]
    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
    out_path = OUTPUT_DIR / f"{prefix}_combat.h5ad"
    corrected.write_h5ad(out_path)
    # Drop the intermediate UN-corrected pooled matrix so it cannot be scored by mistake.
    Path(raw_pooled_path).unlink(missing_ok=True)

    return {
        "message": (
            f"Early integration (scoring) of {len(dataset_ids)} datasets "
            f"({', '.join(dataset_ids)}): pooled {built['n_obs']} samples over "
            f"{info['n_genes_corrected']} shared genes and ComBat-corrected the "
            f"matrix ({info['normalization']}) with `dataset` as the batch key. "
            f"Score it with dataset_score_bulk_samples."
        ),
        "mode": "early",
        "plan_reason": plan.get("reason", ""),
        "datasets": list(dataset_ids),
        "batch_key": "batch",
        "per_batch_n": built["per_batch_n"],
        "n_combined_samples": built["n_obs"],
        "n_shared_features": built["n_vars"],
        "n_genes_corrected": info["n_genes_corrected"],
        "n_genes_dropped_constant_within_batch": info["n_genes_dropped_constant_within_batch"],
        "batch_correction": info["method"],
        "normalization": info["normalization"],
        "batch_corrected": True,
        "output_path": str(out_path.resolve()),
        "expression_path": str(out_path.resolve()),
        "next_step": (
            "Pass output_path as expression_path to dataset_score_bulk_samples "
            "(resource=progeny|collectri|hallmark) for per-sample activity scores; the "
            "scores are batch-corrected, so cohort differences no longer confound them."
        ),
        "interpretation_note": (
            "Pooled + ComBat-corrected for per-sample activity scoring: the cohorts were "
            "concatenated on shared genes and `dataset` batch effects were removed with "
            "ComBat BEFORE scoring (no design-matrix covariate exists for per-sample "
            "scoring). Report the datasets pooled, the per-dataset sample counts, and that "
            "ComBat was applied. CAVEAT: ComBat ran without a biological covariate, so if a "
            "group of interest is confounded with cohort, real between-cohort biology may "
            "also be removed. Do NOT use this matrix for differential-expression testing β€” "
            "for an early DE contrast use decoupler_integrate_datasets (batch covariate)."
        ),
        "artifacts": [
            {
                "description": "Pooled, ComBat batch-corrected AnnData (h5ad) β€” score this",
                "path": str(out_path.resolve()),
            }
        ],
    }