File size: 21,358 Bytes
1182b6d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
 
1182b6d
c3b49d6
 
 
 
 
 
 
 
 
1182b6d
 
 
c3b49d6
 
 
 
1182b6d
 
c3b49d6
 
 
 
 
1182b6d
 
 
 
 
 
 
c3b49d6
1182b6d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
1182b6d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
 
 
 
 
 
 
 
 
 
 
1182b6d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
 
 
 
1182b6d
 
 
c3b49d6
 
1182b6d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
 
 
1182b6d
 
c3b49d6
 
 
1182b6d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
1182b6d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
1182b6d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
1182b6d
 
 
c3b49d6
 
1182b6d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
1182b6d
 
 
 
 
 
 
 
c3b49d6
1182b6d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
1182b6d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
1182b6d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
 
 
 
1182b6d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Validates a loaded dataset against its manifest declaration.

DESIGN
------
All functions accept plain numpy arrays and pandas DataFrames β€” no scanpy or
AnnData imports.  The MCP tool (dataset_validate_manifest_against_data in
dataset_tools.py) handles h5ad loading and calls validate_manifest_against_data.

Five check categories
---------------------
data_level        Detected expression type vs. manifest declaration.
feature_id_type   Spot-check var.index format (gene symbols, Ensembl, etc.).
metadata_columns  Declared columns present in obs; values within allowed set.
group_columns     Each group column exists and has β‰₯2 usable groups.
default_contrasts Each contrast has β‰₯3 samples per group after subset_query.

Return structure
----------------
All public functions return a dict with at minimum:
    status  "pass" | "warning" | "error"
    message short human-readable summary

validate_manifest_against_data returns:
    overall_valid   bool
    n_errors        int
    n_warnings      int
    checks          dict of category β†’ result dict
    errors          list[str]   β€” blocking issues
    warnings        list[str]   β€” non-blocking issues
    recommendations list[str]   β€” suggested next steps
"""

from __future__ import annotations

import re
from typing import Any

import numpy as np
import pandas as pd

# ---------------------------------------------------------------------------
# Data-level compatibility map
# (detected_type β†’ set of manifest data_level values that are compatible)
# ---------------------------------------------------------------------------

_COMPAT: dict[str, set[str]] = {
    "raw_counts": {"raw_counts"},
    "log_expression": {"log_expression", "normalized", "tpm", "fpkm", "protein_abundance"},
    "log_ratio_microarray": {"log_ratio", "log_expression", "normalized"},
    "unknown": {
        "raw_counts",
        "log_expression",
        "log_ratio",
        "normalized",
        "tpm",
        "fpkm",
        "protein_abundance",
    },
}

# Feature-ID patterns (applied to a sample of var.index values)
_GENE_SYMBOL_RE = re.compile(r"^[A-Z][A-Z0-9\-\.]{1,19}$")
_ENSEMBL_HUMAN = re.compile(r"^ENSG\d{11}$")
_ENSEMBL_MOUSE = re.compile(r"^ENSMUSG\d{11}$")
_ENTREZ_RE = re.compile(r"^\d+$")

_MANIFEST_TO_DETECTED: dict[str, str] = {
    "gene_symbol": "gene_symbol",
    "ensembl_gene_id": "ensembl",
    "entrez_id": "entrez",
    "probe_id": "probe_id",
    "protein_id": "protein_id",
}


# ---------------------------------------------------------------------------
# Individual checks
# ---------------------------------------------------------------------------


def check_data_level(
    X_flat: np.ndarray,
    declared_data_level: str,
) -> dict[str, Any]:
    """
    Compare manifest-declared data_level against detected expression type.

    Parameters
    ----------
    X_flat:
        1-D numpy array sampled from adata.X (up to ~50 000 values is enough).
    declared_data_level:
        Value of manifest.data_level (e.g. 'log_expression', 'raw_counts').

    Returns
    -------
    dict with: status, declared, detected, compatible, message, details.
    """
    from src.workflows.microarray import classify_expression_data_type

    info = classify_expression_data_type(X_flat)
    detected = info["data_type"]
    compatible = declared_data_level in _COMPAT.get(detected, set())

    if detected == "unknown":
        status = "warning"
        msg = (
            f"Could not confidently classify expression data "
            f"(min={info['value_min']}, max={info['value_max']}, "
            f"is_integer={info['is_integer']}, has_negatives={info['has_negatives']}). "
            f"Manifest declares '{declared_data_level}' β€” verify manually."
        )
    elif compatible:
        status = "pass"
        msg = (
            f"Detected '{detected}' is compatible with declared data_level='{declared_data_level}'."
        )
    else:
        status = "error"
        msg = (
            f"MISMATCH: manifest declares data_level='{declared_data_level}' "
            f"but data appears to be '{detected}'. "
            f"Check whether the correct data level is declared in the manifest, "
            f"or whether a normalisation step was already applied."
        )

    return {
        "status": status,
        "declared": declared_data_level,
        "detected": detected,
        "compatible": compatible,
        "message": msg,
        "details": {
            k: info[k]
            for k in (
                "is_integer",
                "has_negatives",
                "value_min",
                "value_max",
                "value_mean",
                "value_median",
            )
        },
    }


def check_feature_id_type(
    var_index_sample: list[str],
    declared_type: str,
) -> dict[str, Any]:
    """
    Spot-check var.index values to verify they match the declared feature_id_type.

    Samples up to 200 values and checks the fraction matching each pattern.
    Returns a warning (not error) when confidence is low, since probe ID
    formats vary too much across platforms for a definitive check.

    Parameters
    ----------
    var_index_sample:
        List of feature names from adata.var.index (sample or full list).
    declared_type:
        manifest.feature_id_type value.
    """
    sample = var_index_sample[:200]
    n = len(sample)
    if n == 0:
        return {
            "status": "error",
            "declared": declared_type,
            "detected_pattern": "unknown",
            "message": "var.index is empty β€” cannot check feature ID type.",
        }

    n_gene = sum(1 for v in sample if _GENE_SYMBOL_RE.match(str(v)))
    n_ensh = sum(1 for v in sample if _ENSEMBL_HUMAN.match(str(v)))
    n_ensm = sum(1 for v in sample if _ENSEMBL_MOUSE.match(str(v)))
    n_entrez = sum(1 for v in sample if _ENTREZ_RE.match(str(v)))

    fracs = {
        "gene_symbol": n_gene / n,
        "ensembl": (n_ensh + n_ensm) / n,
        "entrez": n_entrez / n,
    }

    # Ensembl IDs satisfy the gene-symbol regex too, so check Ensembl first.
    ensembl_frac = (n_ensh + n_ensm) / n
    if ensembl_frac >= 0.5:
        detected_pattern = "ensembl"
        best_frac = ensembl_frac
        best_pattern = "ensembl"
    else:
        best_pattern, best_frac = max(fracs.items(), key=lambda x: x[1])
        detected_pattern = best_pattern if best_frac >= 0.5 else "probe_id_or_unknown"

    expected_pattern = _MANIFEST_TO_DETECTED.get(declared_type, declared_type)
    # probe_id and protein_id can't be detected reliably β€” treat as pass if
    # the data doesn't strongly look like something else
    if declared_type in ("probe_id", "protein_id"):
        if best_frac > 0.7 and best_pattern in ("gene_symbol", "ensembl", "entrez"):
            status = "warning"
            msg = (
                f"Manifest declares feature_id_type='{declared_type}' but "
                f"{best_frac:.0%} of var.index values look like '{best_pattern}'. "
                f"If probes were already collapsed to gene symbols, "
                f"update feature_id_type to 'gene_symbol'."
            )
        else:
            status = "pass"
            msg = (
                f"feature_id_type='{declared_type}' β€” pattern not automatically "
                f"verifiable (platform-specific). Sample: {sample[:5]}"
            )
    elif detected_pattern == expected_pattern:
        status = "pass"
        msg = (
            f"var.index looks like '{detected_pattern}' ({best_frac:.0%} match), "
            f"consistent with declared feature_id_type='{declared_type}'."
        )
    elif detected_pattern == "probe_id_or_unknown":
        status = "warning"
        msg = (
            f"Could not confidently classify var.index format "
            f"(best match '{best_pattern}' at only {best_frac:.0%}). "
            f"Manifest declares '{declared_type}'. Sample: {sample[:5]}"
        )
    else:
        status = "warning"
        msg = (
            f"var.index looks like '{detected_pattern}' ({best_frac:.0%} match) "
            f"but manifest declares feature_id_type='{declared_type}'. "
            f"Sample: {sample[:5]}"
        )

    return {
        "status": status,
        "declared": declared_type,
        "detected_pattern": detected_pattern,
        "pattern_fractions": {k: round(v, 3) for k, v in fracs.items()},
        "n_sampled": n,
        "sample_features": sample[:10],
        "message": msg,
    }


def check_metadata_columns(
    obs_df: pd.DataFrame,
    manifest: Any,
) -> dict[str, Any]:
    """
    Verify declared metadata_columns exist in obs and values match allowed set.

    Parameters
    ----------
    obs_df:
        adata.obs as a pandas DataFrame.
    manifest:
        DatasetManifest instance.
    """
    if not manifest.metadata_columns:
        return {
            "status": "pass",
            "message": "No metadata_columns declared in manifest β€” skipped.",
            "n_checked": 0,
            "columns": {},
        }

    columns: dict[str, Any] = {}
    errors: list[str] = []
    warnings: list[str] = []

    for col_name, col_def in manifest.metadata_columns.items():
        if hasattr(col_def, "role"):
            decoded_col = col_def.decoded_column or col_name
            source_col = col_def.source_column
            allowed = set(col_def.allowed_values)
            missing_vals = set(col_def.missing_values) | {"", "nan", "None"}
        else:
            decoded_col = col_def.get("decoded_column") or col_name
            source_col = col_def.get("source_column")
            allowed = set(col_def.get("allowed_values") or [])
            missing_vals = set(col_def.get("missing_values") or []) | {"", "nan", "None"}

        check_col = decoded_col
        result: dict[str, Any] = {"check_column": check_col, "source_column": source_col}

        if check_col not in obs_df.columns:
            if source_col and source_col in obs_df.columns:
                result["status"] = "warning"
                result["message"] = (
                    f"Source column '{source_col}' present but decoded column "
                    f"'{check_col}' missing β€” numeric decoder may not have run."
                )
                warnings.append(result["message"])
            else:
                result["status"] = "error"
                result["message"] = f"Column '{check_col}' not found in obs."
                errors.append(result["message"])
            columns[col_name] = result
            continue

        actual_vals = obs_df[check_col].astype(str)
        non_missing = actual_vals[~actual_vals.isin(missing_vals)]
        value_counts = actual_vals.value_counts().to_dict()
        result["value_counts"] = {str(k): int(v) for k, v in value_counts.items()}
        result["n_missing"] = int(actual_vals.isin(missing_vals).sum())
        result["n_annotated"] = int(len(non_missing))

        if allowed:
            unexpected = sorted(set(non_missing.unique()) - allowed)
            result["unexpected_values"] = unexpected
            if unexpected:
                result["status"] = "warning"
                result["message"] = (
                    f"Unexpected values in '{check_col}': {unexpected}. "
                    f"Declared allowed: {sorted(allowed)}"
                )
                warnings.append(result["message"])
            else:
                result["status"] = "pass"
                result["message"] = (
                    f"All non-missing values in '{check_col}' match declared "
                    f"allowed_values ({len(non_missing)} annotated, "
                    f"{result['n_missing']} missing)."
                )
        else:
            result["unexpected_values"] = []
            result["status"] = "pass"
            result["message"] = (
                f"Column '{check_col}' present ({len(actual_vals)} values; "
                f"no allowed_values constraint declared)."
            )

        columns[col_name] = result

    overall = "error" if errors else ("warning" if warnings else "pass")
    return {
        "status": overall,
        "n_checked": len(columns),
        "columns": columns,
        "message": (
            f"{len(errors)} error(s), {len(warnings)} warning(s) "
            f"across {len(columns)} declared metadata columns."
        ),
    }


def check_group_columns(
    obs_df: pd.DataFrame,
    manifest: Any,
    min_group_size: int = 3,
) -> dict[str, Any]:
    """
    Verify each group_column exists in obs and has β‰₯2 groups with enough samples.

    Parameters
    ----------
    obs_df:
        adata.obs as a pandas DataFrame.
    manifest:
        DatasetManifest instance.
    min_group_size:
        Minimum samples per group (default 3).
    """
    columns: dict[str, Any] = {}
    errors: list[str] = []
    warnings: list[str] = []

    for col in manifest.group_columns:
        if col not in obs_df.columns:
            columns[col] = {
                "status": "error",
                "present": False,
                "message": f"group_column '{col}' not found in obs.",
            }
            errors.append(f"group_column '{col}' missing from obs")
            continue

        vc = obs_df[col].astype(str).value_counts()
        qualifying = {str(k): int(v) for k, v in vc.items() if v >= min_group_size}
        small = {str(k): int(v) for k, v in vc.items() if v < min_group_size}
        n_usable = len(qualifying)

        if n_usable < 2:
            status = "warning"
            msg = (
                f"Column '{col}' has fewer than 2 groups with β‰₯{min_group_size} "
                f"samples (qualifying: {list(qualifying.keys())})."
            )
            warnings.append(msg)
        else:
            status = "pass"
            msg = f"Column '{col}' has {n_usable} usable groups (β‰₯{min_group_size} samples each)."

        columns[col] = {
            "status": status,
            "present": True,
            "n_unique": int(vc.nunique()),
            "qualifying_groups": qualifying,
            "small_groups": small,
            "message": msg,
        }

    overall = "error" if errors else ("warning" if warnings else "pass")
    return {
        "status": overall,
        "n_checked": len(manifest.group_columns),
        "columns": columns,
        "message": (
            f"{len(errors)} error(s), {len(warnings)} warning(s) "
            f"across {len(manifest.group_columns)} group_column(s)."
        ),
    }


def check_default_contrasts(
    obs_df: pd.DataFrame,
    manifest: Any,
    min_group_size: int = 3,
) -> dict[str, Any]:
    """
    Verify each default_contrast has enough samples in both groups.

    Applies subset_query if declared, then counts test and control samples.

    Parameters
    ----------
    obs_df:
        adata.obs as a pandas DataFrame.
    manifest:
        DatasetManifest instance.
    min_group_size:
        Minimum samples per group for a contrast to be feasible (default 3).
    """
    if not manifest.default_contrasts:
        return {
            "status": "pass",
            "message": "No default_contrasts declared β€” skipped.",
            "contrasts": [],
        }

    results: list[dict] = []
    errors: list[str] = []
    warnings: list[str] = []

    for i, contrast in enumerate(manifest.default_contrasts):
        factor = contrast.get("design_factor", "")
        test_grp = contrast.get("test_group", "")
        ctrl_grp = contrast.get("control_group", "")
        subset_q = contrast.get("subset_query")
        method = contrast.get("method", "ttest")
        label = f"{test_grp} vs {ctrl_grp} (contrast {i})"

        entry: dict[str, Any] = {
            "design_factor": factor,
            "test_group": test_grp,
            "control_group": ctrl_grp,
            "subset_query": subset_q,
            "method": method,
        }

        # Apply subset_query
        working_df = obs_df
        if subset_q:
            try:
                working_df = obs_df.query(subset_q)
                entry["n_after_subset"] = len(working_df)
            except Exception as exc:
                entry["status"] = "error"
                entry["message"] = f"subset_query failed: {exc}"
                errors.append(entry["message"])
                results.append(entry)
                continue

        if factor not in working_df.columns:
            entry["status"] = "error"
            entry["message"] = (
                f"design_factor '{factor}' not found in obs (after applying subset_query)."
            )
            errors.append(entry["message"])
            results.append(entry)
            continue

        col = working_df[factor].astype(str)
        n_test = int((col == test_grp).sum())
        n_ctrl = int((col == ctrl_grp).sum())
        entry["n_test"] = n_test
        entry["n_control"] = n_ctrl

        if n_test == 0 or n_ctrl == 0:
            entry["status"] = "error"
            entry["message"] = (
                f"{label}: one or both groups have 0 samples "
                f"(test='{test_grp}': {n_test}, control='{ctrl_grp}': {n_ctrl}). "
                f"Check group label spelling and subset_query."
            )
            errors.append(entry["message"])
        elif n_test < min_group_size or n_ctrl < min_group_size:
            entry["status"] = "warning"
            entry["message"] = (
                f"{label}: groups are small "
                f"(test={n_test}, control={n_ctrl}, min={min_group_size}). "
                f"Results may be underpowered."
            )
            warnings.append(entry["message"])
        else:
            entry["status"] = "pass"
            entry["message"] = f"{label}: feasible β€” test={n_test}, control={n_ctrl}."

        results.append(entry)

    overall = "error" if errors else ("warning" if warnings else "pass")
    return {
        "status": overall,
        "n_checked": len(manifest.default_contrasts),
        "contrasts": results,
        "message": (
            f"{len(errors)} error(s), {len(warnings)} warning(s) "
            f"across {len(manifest.default_contrasts)} contrast(s)."
        ),
    }


# ---------------------------------------------------------------------------
# Top-level validator
# ---------------------------------------------------------------------------


def validate_manifest_against_data(
    X_flat: np.ndarray,
    var_index: list[str],
    obs_df: pd.DataFrame,
    manifest: Any,
) -> dict[str, Any]:
    """
    Run all five checks and return a consolidated validation report.

    Parameters
    ----------
    X_flat:
        1-D numpy array of expression values (sample up to ~50 000 values).
    var_index:
        Feature names from adata.var.index.
    obs_df:
        adata.obs as a pandas DataFrame.
    manifest:
        DatasetManifest instance.

    Returns
    -------
    dict with: overall_valid, n_errors, n_warnings, checks, errors,
               warnings, recommendations.
    """
    checks = {
        "data_level": check_data_level(X_flat, manifest.data_level),
        "feature_id_type": check_feature_id_type(var_index, manifest.feature_id_type),
        "metadata_columns": check_metadata_columns(obs_df, manifest),
        "group_columns": check_group_columns(obs_df, manifest),
        "default_contrasts": check_default_contrasts(obs_df, manifest),
    }

    all_errors: list[str] = []
    all_warnings: list[str] = []

    for name, result in checks.items():
        status = result.get("status", "pass")
        if status == "error":
            all_errors.append(f"[{name}] {result.get('message', '')}")
        elif status == "warning":
            all_warnings.append(f"[{name}] {result.get('message', '')}")

    overall_valid = len(all_errors) == 0

    recommendations: list[str] = []
    if overall_valid and not all_warnings:
        recommendations.append(
            "Manifest is consistent with loaded data. "
            "Proceed with dataset_plan_analysis to start analysis."
        )
    if all_errors:
        recommendations.append(
            "Fix errors before running analysis β€” they indicate manifest "
            "declarations that contradict the actual data."
        )
    if any("[feature_id_type]" in w for w in all_warnings):
        recommendations.append(
            "Verify feature_id_type by inspecting adata.var.index directly "
            "and updating the manifest if the format has changed."
        )
    if any("[metadata_columns]" in w for w in all_warnings):
        recommendations.append(
            "Review unexpected metadata values β€” they may indicate new "
            "categories in the data or a stale manifest."
        )
    if any("decoder" in w for w in all_warnings):
        recommendations.append(
            "Re-run decoupler_load_geo_series_matrix with decode_numeric=True "
            "to apply numeric-to-label mapping."
        )

    return {
        "dataset_id": manifest.dataset_id,
        "n_samples": len(obs_df),
        "n_features": len(var_index),
        "overall_valid": overall_valid,
        "n_errors": len(all_errors),
        "n_warnings": len(all_warnings),
        "checks": checks,
        "errors": all_errors,
        "warnings": all_warnings,
        "recommendations": recommendations,
    }