Claude Skill

preprocess-imaging

Design or audit the data-preparation stage of a medical-imaging model — DICOM/NIfTI intake, resampling and intensity normalisation, and the augmentation plan — so the pipeline is leakage-safe before model-scaffold builds the training repo. Emits a declarative preprocessing manife

LLM Mart · 0 points · 3 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download aperivue-medsci-skills-skills_preprocess-imaging-815765c.zip · 27 KB
Part of aperivue/medsci-skills — 47 skills

Install

skills CLI npx skills add https://github.com/Aperivue/medsci-skills/tree/main/skills/preprocess-imaging
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install aperivue-medsci-skills@llmmart
Git git clone https://github.com/Aperivue/medsci-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole aperivue/medsci-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Preprocess-Imaging Skill

Purpose

This skill designs and audits the data-preparation stage of a medical-imaging model — the stage before a training repo is built — and proves it is leakage-safe by construction. Data leakage enters one step earlier than the split table can see: a normaliser fit on the whole dataset, a data-fitted transform run before the split exists, or a patient whose slices land in more than one partition. Each silently inflates every downstream metric (Kapoor & Narayanan, Patterns 2023; Varoquaux & Cheplygina, npj Digit Med 2022; CLAIM 2024 data items).

It is the missing first link in the lane: preprocess-imaging (prepare + audit) → /model-scaffold (build) → /model-validation (validate the split) → /model-evaluation + /analyze-stats (metrics) → /write-paper + /check-reporting (publish). It integrates MONAI / TorchIO transforms (referenced in the emitted plan); it does not reimplement them, and it never executes preprocessing on real patient data.

When to use

  • You have a data manifest (one row per image/slice with a patient/subject ID) and want a leakage-safe preprocessing plan + a machine-checkable manifest before scaffolding a model.
  • You want to audit an existing preprocessing pipeline for data-stage leakage.

When NOT to use

  • Auditing the train/val/test split table itself → /model-validation (split-leakage gate).
  • Building the training repo / model code → /model-scaffold (it consumes this manifest).
  • Choosing the architecture → /architecture-zoo.
  • Held-out metrics / calibration → /model-evaluation then /analyze-stats.
  • Reimplementing MONAI / TorchIO transforms → out of scope (this skill wires and audits them).

Workflow

Phase 1 — Inventory the data and the intended steps

Collect: modality (CT / MR / X-ray / US / path), the data manifest (one row per image/slice with a patient_id), the intended resample spacing, the intensity transform (fixed HU window vs a fitted z-score / min-max / histogram match), and the augmentation plan. See references/preprocessing_guide.md for modality-aware guidance (what normalisation is standard per modality, which augmentations preserve vs break physiology).

Phase 2 — Decide fit scope and order (the leakage-safe rules)

  • Fit dataset-level normalisation on the training split only — never on all/full/test.
  • Run any data-fitted transform AFTER the split — before the split there is no train/test distinction, so the fit spans partitions.
  • Prefer per-image (per-sample) normalisation where clinically appropriate: it uses only that image's own statistics and is leakage-free even before the split.
  • Keep augmentation train-only — augmenting val/test folds undisclosed test-time augmentation into the reported metric.
  • Split at the patient level, then map slices to their patient's split (never split slices).

Phase 3 — Emit the preprocessing manifest

Write a declarative JSON manifest that model-scaffold consumes and the gate checks:

{
  "split_seed": 42,
  "transforms": [
    {"name": "hu_window", "type": "clip", "fit_scope": "none", "stage": "before_split"},
    {"name": "train_zscore", "type": "standardize", "fit_scope": "train", "stage": "after_split"},
    {"name": "flip_rotate", "type": "augmentation", "stage": "after_split", "applies_to": ["train"]}
  ],
  "split_assignment": [
    {"patient_id": "P001", "unit_id": "P001_s1", "split": "train"}
  ]
}

fit_scope: train (OK) · all/full/dataset/test (leak) · sample/per_image/none/fixed (not data-fitted, leakage-free). stage: before_split / after_split.

Declare the fit scope of resampling too. A target spacing you chose in advance is fixed and never leaks (fit_scope: fixed). A target derived from the cohort does: nnU-Net sets its target spacing from a percentile of the dataset fingerprint, so a resample fitted over every case carries held-out geometry into the training grid exactly as an intensity statistic would. Which one you have is decided by the fingerprint's scope, not by the word "resample".

Phase 4 — Gate the manifest (deterministic)

python3 scripts/check_preprocessing_leakage.py --manifest preprocessing_manifest.json --strict

That gate asks whether a transform was fit on the right scope. Before an inference run on a cohort the model was not trained on, ask the other question — is that cohort in the intensity domain the trained normaliser assumes?

python3 scripts/check_normalizer_domain.py \
    --profile eda/<cohort>_profile.json \
    --contract work/nnUNet_results/.../plans.json \
    --splits external_mri --out qc/normalizer_domain.json --strict

Verdicts: PREPROCESS_BEFORE_SPLIT, NORMALIZATION_LEAKAGE, PATIENT_CROSS_SPLIT (Major); AUGMENTATION_ON_EVAL, UNSPECIFIED_FIT_SCOPE, MISSING_SEED (Minor). The verdict is reproduced by set arithmetic + rule on the manifest, never asserted from prose. A green gate is a precondition for handing the manifest to /model-scaffold.

Integration

  • Feeds /model-scaffold — the audited manifest is the scaffold's preprocessing input; its split_assignment is the same patient-level split /model-validation later re-verifies.
  • /self-review model_development probe audits data-stage leakage in a finished manuscript; this skill produces the leakage-safe pipeline it looks for.
  • /check-reporting — the manifest documents the CLAIM 2024 / TRIPOD+AI data-preprocessing items.

Anti-Hallucination

  • Never fabricate image statistics, patient IDs, or split assignments. Every value in the manifest comes from the real data manifest and the researcher's declared pipeline — never invented. This skill designs and audits the plan; it does not run preprocessing on real patient data or synthesise the images it describes.
  • Never report a preprocessing-audit "pass" without running check_preprocessing_leakage.py. The leakage verdict is reproduced deterministically (rule + set arithmetic on the manifest), never asserted from prose.
  • Never label a dataset-fitted transform as per-sample to clear the gate. The manifest's type / fit_scope / stage must describe what the code actually does; a mislabelled transform hides a real leak the gate would otherwise catch.
  • Integrate, don't reimplement. Reference MONAI / TorchIO transforms; do not write a new normalisation/resampling implementation or claim results for one.

Reproducible challenge

scripts/check_normalizer_domain_challenge/ ships a synthetic profile/contract triple: a cohort in the contract's own domain that must come back clean (the false-positive guard), an arbitrary-unit cohort that must raise a Major, and an unreadable contract that must refuse rather than pass.

scripts/check_preprocessing_leakage_challenge/ ships a synthetic leak/clean manifest pair with a network-free verify.sh wired into the skill's validation commands.

Files (medsci-skills)
  • references
    • preprocessing_guide.md 4.9 KB
      # Medical-imaging preprocessing — modality-aware guidance
      
      Companion to `preprocess-imaging`. This is *produce* knowledge: what preprocessing is standard per
      modality, which augmentations preserve versus break physiology, and where leakage hides. It wires
      MONAI / TorchIO transforms by name; it does not reimplement them.
      
      ## 1. Intensity normalisation by modality
      
      | Modality | Standard intensity handling | Fitted on data? | Leakage risk |
      |---|---|---|---|
      | **CT** | Fixed HU window/level per task (e.g. lung −600/1500, soft-tissue 40/400), then scale to [0,1] or [−1,1] | **No** — fixed HU bounds are physical, not fitted | None (fixed transform) |
      | **MR** | Bias-field correction (N4) → intensity normalisation (z-score, or Nyúl/histogram matching to a reference) | **Yes** — z-score/Nyúl are fitted | Fit the reference/stats on **train only**; per-image z-score is leakage-free |
      | **X-ray** | Per-image min–max or z-score; optional CLAHE | Per-image = **no**; dataset z-score = yes | Prefer per-image; if dataset-level, train-only |
      | **Ultrasound** | Per-image normalisation; crop the vendor UI/annotation border | Per-image = **no** | Cropping is fixed; watch for burned-in PHI/annotations |
      | **Pathology (WSI)** | Stain normalisation (Macenko/Vahadane/Reinhard) to a reference tile | **Yes** — reference is fitted | Choose the reference from the **train** split only |
      
      **Rule of thumb:** a *fixed* transform (HU window, resample to a fixed spacing, fixed crop) never
      leaks. A *fitted* transform (z-score with dataset statistics, histogram/stain matching to a reference,
      PCA/whitening) leaks unless it is fit on the training split and applied after the split.
      
      ## 2. Resampling and geometry
      
      - Resample to a **fixed target spacing** (a task constant, not fitted) — e.g. 1×1×1 mm, or the
        dataset median spacing computed **once on train** and then frozen as a constant.
      - Register/reorient to a canonical orientation (e.g. RAS) before patch extraction.
      - Foreground cropping by a fixed intensity threshold is fixed (safe); cropping by a *fitted* body-mask
        model is a fitted transform — train-only.
      
      ## 3. Augmentation appropriateness (physiology-preserving vs breaking)
      
      Augmentation must keep the image clinically plausible and label-consistent.
      
      | Augmentation | Usually safe | Breaks physiology / label when… |
      |---|---|---|
      | Flip (L–R) | Most 2-D texture tasks | **Laterality matters** — cardiac silhouette, situs, side-labelled findings; flipping mislabels side |
      | Rotation (small) | Most tasks | Large rotations for orientation-dependent tasks (e.g. gravity-dependent effusion/air-fluid levels) |
      | Elastic / grid deformation | Soft-tissue segmentation | Rigid structures (bone fracture morphology); can invent/erase small lesions |
      | Intensity scale/shift, gamma | CT/MR within-modality | Beyond clinically plausible HU/contrast range; simulates a nonexistent scanner |
      | Gaussian noise / blur | Robustness to acquisition | Enough to hide the target finding (micro-nodule, microcalcification) |
      | Cutout / random erasing | General | Can erase the sole lesion in a positive case → label noise |
      | MixUp / CutMix | Some classification | Segmentation/detection where mixed pixels have no valid mask/box |
      
      **Apply augmentation to the training split only.** Augmenting val/test is test-time augmentation
      (TTA): legitimate only if pre-specified and disclosed, never folded silently into the headline metric.
      
      ## 4. Where leakage hides (the gate's targets)
      
      1. **Normalisation fit on non-train data** — z-score/histogram/stain statistics computed over
         all/test data. → `NORMALIZATION_LEAKAGE`. Fit on train; apply the frozen statistics to val/test.
      2. **Fitted transform before the split** — computing dataset statistics, then splitting. There is no
         train/test distinction yet, so the fit is cross-partition. → `PREPROCESS_BEFORE_SPLIT`.
      3. **Patient slices across splits** — splitting *images* instead of *patients*, so a patient's slices
         sit in train and test. → `PATIENT_CROSS_SPLIT`. Split at the patient level, then map slices.
      4. **Augmentation on eval** — see §3. → `AUGMENTATION_ON_EVAL`.
      5. **Undeclared fit scope** — a fitted transform with no stated scope cannot be cleared.
         → `UNSPECIFIED_FIT_SCOPE`. Declare `fit_scope=train`.
      
      ## 5. Library wiring (integrate, don't reimplement)
      
      - **MONAI transforms** — `LoadImaged`, `Spacingd`, `ScaleIntensityRanged` (fixed HU window),
        `NormalizeIntensityd` (z-score; set on the train subset), `RandFlipd`/`RandAffined`/`RandGaussianNoised`
        (train pipeline only).
      - **TorchIO** — `Resample`, `ZNormalization`, `HistogramStandardization` (fit `landmarks` on train),
        `RandomFlip`/`RandomElasticDeformation` (train only).
      - **Stain normalisation (WSI)** — `torchstain` / `staintools` Macenko/Vahadane; fit the reference on a
        train tile.
      
      Record every step in the preprocessing manifest with its `type`, `fit_scope`, and `stage` so the gate
      can decide leakage deterministically.
      
  • scripts
    • check_normalizer_domain_challenge
      • expected
        • arbitrary_mismatch.txt 831 B
          =========================================
           Normaliser-Domain Gate (preprocess-imaging)
          =========================================
            contract: CTNormalization (assumes hounsfield), clip [-38, 174]
          | Check | Severity | Detail |
          |---|---|---|
          | NORMALIZER_DOMAIN_MISMATCH | Major | the contract applies CTNormalization, which assumes Hounsfield units, but 0 of 10 case(s) in 'external_mri' contain a voxel at or below -500. Hounsfield units are defined by an air floor near -1000; a cohort that never goes negative is not in them. Per-case minima range 0 to 0. |
          | NORMALIZER_SPLIT_DIVERGENCE | Flag | 6 of 10 case(s) in 'unlabelled_pool' bottom out near air and the rest do not — mixed modality, or a rescale applied to part of the cohort. One normalisation contract cannot be right for both. |
          
          MAJOR candidate: 1 domain issue(s).
          
        • hu_clean.txt 364 B
          =========================================
           Normaliser-Domain Gate (preprocess-imaging)
          =========================================
            contract: CTNormalization (assumes hounsfield), clip [-38, 174]
          | Check | Severity | Detail |
          |---|---|---|
          | (none) | — | every split is in the domain the contract assumes |
          
          OK: every split is in the domain the contract assumes.
          
      • fixture
        • plan_ct.json 486 B
          {
            "_synthetic": "Not a real trained plan. Shape mimics an nnU-Net plans.json.",
            "configurations": {
              "3d_fullres": {
                "normalization_schemes": [
                  "CTNormalization"
                ],
                "spacing": [
                  2.5,
                  0.8,
                  0.8
                ]
              }
            },
            "foreground_intensity_properties_per_channel": {
              "0": {
                "mean": 90.0,
                "std": 40.0,
                "percentile_00_5": -38.0,
                "percentile_99_5": 174.0,
                "min": -450.0,
                "max": 1040.0
              }
            }
          }
          
        • profile_arbitrary.json 4.4 KB
          {
            "_synthetic": "Not real patients. Ten synthetic MR-like cases with no air floor, plus a deliberately mixed pool where 6 of 10 bottom out near air.",
            "dataset": "synthetic mixed",
            "splits": [
              {
                "name": "external_mri",
                "labelled": true
              },
              {
                "name": "unlabelled_pool",
                "labelled": false
              }
            ],
            "cases": [
              {
                "case": "mr_000",
                "split": "external_mri",
                "intensity": {
                  "min": 0.0,
                  "p01": 0.0,
                  "p50": 120.0,
                  "p99": 640.0,
                  "max": 1180.0
                }
              },
              {
                "case": "mr_001",
                "split": "external_mri",
                "intensity": {
                  "min": 0.0,
                  "p01": 0.0,
                  "p50": 120.0,
                  "p99": 640.0,
                  "max": 1180.0
                }
              },
              {
                "case": "mr_002",
                "split": "external_mri",
                "intensity": {
                  "min": 0.0,
                  "p01": 0.0,
                  "p50": 120.0,
                  "p99": 640.0,
                  "max": 1180.0
                }
              },
              {
                "case": "mr_003",
                "split": "external_mri",
                "intensity": {
                  "min": 0.0,
                  "p01": 0.0,
                  "p50": 120.0,
                  "p99": 640.0,
                  "max": 1180.0
                }
              },
              {
                "case": "mr_004",
                "split": "external_mri",
                "intensity": {
                  "min": 0.0,
                  "p01": 0.0,
                  "p50": 120.0,
                  "p99": 640.0,
                  "max": 1180.0
                }
              },
              {
                "case": "mr_005",
                "split": "external_mri",
                "intensity": {
                  "min": 0.0,
                  "p01": 0.0,
                  "p50": 120.0,
                  "p99": 640.0,
                  "max": 1180.0
                }
              },
              {
                "case": "mr_006",
                "split": "external_mri",
                "intensity": {
                  "min": 0.0,
                  "p01": 0.0,
                  "p50": 120.0,
                  "p99": 640.0,
                  "max": 1180.0
                }
              },
              {
                "case": "mr_007",
                "split": "external_mri",
                "intensity": {
                  "min": 0.0,
                  "p01": 0.0,
                  "p50": 120.0,
                  "p99": 640.0,
                  "max": 1180.0
                }
              },
              {
                "case": "mr_008",
                "split": "external_mri",
                "intensity": {
                  "min": 0.0,
                  "p01": 0.0,
                  "p50": 120.0,
                  "p99": 640.0,
                  "max": 1180.0
                }
              },
              {
                "case": "mr_009",
                "split": "external_mri",
                "intensity": {
                  "min": 0.0,
                  "p01": 0.0,
                  "p50": 120.0,
                  "p99": 640.0,
                  "max": 1180.0
                }
              },
              {
                "case": "mix_000",
                "split": "unlabelled_pool",
                "intensity": {
                  "min": -1024.0,
                  "p01": -1000.0,
                  "p50": -60.0,
                  "p99": 300.0,
                  "max": 1400.0
                }
              },
              {
                "case": "mix_001",
                "split": "unlabelled_pool",
                "intensity": {
                  "min": -1024.0,
                  "p01": -1000.0,
                  "p50": -60.0,
                  "p99": 300.0,
                  "max": 1400.0
                }
              },
              {
                "case": "mix_002",
                "split": "unlabelled_pool",
                "intensity": {
                  "min": -1024.0,
                  "p01": -1000.0,
                  "p50": -60.0,
                  "p99": 300.0,
                  "max": 1400.0
                }
              },
              {
                "case": "mix_003",
                "split": "unlabelled_pool",
                "intensity": {
                  "min": -1024.0,
                  "p01": -1000.0,
                  "p50": -60.0,
                  "p99": 300.0,
                  "max": 1400.0
                }
              },
              {
                "case": "mix_004",
                "split": "unlabelled_pool",
                "intensity": {
                  "min": -1024.0,
                  "p01": -1000.0,
                  "p50": -60.0,
                  "p99": 300.0,
                  "max": 1400.0
                }
              },
              {
                "case": "mix_005",
                "split": "unlabelled_pool",
                "intensity": {
                  "min": -1024.0,
                  "p01": -1000.0,
                  "p50": -60.0,
                  "p99": 300.0,
                  "max": 1400.0
                }
              },
              {
                "case": "mix_006",
                "split": "unlabelled_pool",
                "intensity": {
                  "min": 0.0,
                  "p01": 0.0,
                  "p50": 90.0,
                  "p99": 520.0,
                  "max": 900.0
                }
              },
              {
                "case": "mix_007",
                "split": "unlabelled_pool",
                "intensity": {
                  "min": 0.0,
                  "p01": 0.0,
                  "p50": 90.0,
                  "p99": 520.0,
                  "max": 900.0
                }
              },
              {
                "case": "mix_008",
                "split": "unlabelled_pool",
                "intensity": {
                  "min": 0.0,
                  "p01": 0.0,
                  "p50": 90.0,
                  "p99": 520.0,
                  "max": 900.0
                }
              },
              {
                "case": "mix_009",
                "split": "unlabelled_pool",
                "intensity": {
                  "min": 0.0,
                  "p01": 0.0,
                  "p50": 90.0,
                  "p99": 520.0,
                  "max": 900.0
                }
              }
            ]
          }
          
        • profile_hu.json 2.7 KB
          {
            "_synthetic": "Not real patients. Twelve synthetic CT cases, all with an air floor.",
            "dataset": "synthetic CT",
            "splits": [
              {
                "name": "external_ct",
                "labelled": true
              }
            ],
            "cases": [
              {
                "case": "ct_000",
                "split": "external_ct",
                "intensity": {
                  "min": -1024.0,
                  "p01": -1000.0,
                  "p50": -80.0,
                  "p99": 320.0,
                  "max": 1500.0
                }
              },
              {
                "case": "ct_001",
                "split": "external_ct",
                "intensity": {
                  "min": -1024.0,
                  "p01": -1000.0,
                  "p50": -80.0,
                  "p99": 320.0,
                  "max": 1500.0
                }
              },
              {
                "case": "ct_002",
                "split": "external_ct",
                "intensity": {
                  "min": -1024.0,
                  "p01": -1000.0,
                  "p50": -80.0,
                  "p99": 320.0,
                  "max": 1500.0
                }
              },
              {
                "case": "ct_003",
                "split": "external_ct",
                "intensity": {
                  "min": -1024.0,
                  "p01": -1000.0,
                  "p50": -80.0,
                  "p99": 320.0,
                  "max": 1500.0
                }
              },
              {
                "case": "ct_004",
                "split": "external_ct",
                "intensity": {
                  "min": -1024.0,
                  "p01": -1000.0,
                  "p50": -80.0,
                  "p99": 320.0,
                  "max": 1500.0
                }
              },
              {
                "case": "ct_005",
                "split": "external_ct",
                "intensity": {
                  "min": -1024.0,
                  "p01": -1000.0,
                  "p50": -80.0,
                  "p99": 320.0,
                  "max": 1500.0
                }
              },
              {
                "case": "ct_006",
                "split": "external_ct",
                "intensity": {
                  "min": -1024.0,
                  "p01": -1000.0,
                  "p50": -80.0,
                  "p99": 320.0,
                  "max": 1500.0
                }
              },
              {
                "case": "ct_007",
                "split": "external_ct",
                "intensity": {
                  "min": -1024.0,
                  "p01": -1000.0,
                  "p50": -80.0,
                  "p99": 320.0,
                  "max": 1500.0
                }
              },
              {
                "case": "ct_008",
                "split": "external_ct",
                "intensity": {
                  "min": -1024.0,
                  "p01": -1000.0,
                  "p50": -80.0,
                  "p99": 320.0,
                  "max": 1500.0
                }
              },
              {
                "case": "ct_009",
                "split": "external_ct",
                "intensity": {
                  "min": -1024.0,
                  "p01": -1000.0,
                  "p50": -80.0,
                  "p99": 320.0,
                  "max": 1500.0
                }
              },
              {
                "case": "ct_010",
                "split": "external_ct",
                "intensity": {
                  "min": -1024.0,
                  "p01": -1000.0,
                  "p50": -80.0,
                  "p99": 320.0,
                  "max": 1500.0
                }
              },
              {
                "case": "ct_011",
                "split": "external_ct",
                "intensity": {
                  "min": -1024.0,
                  "p01": -1000.0,
                  "p50": -80.0,
                  "p99": 320.0,
                  "max": 1500.0
                }
              }
            ]
          }
          
      • problem.md 2.3 KB
        # Challenge — the normaliser that was right for the data it was fit on, and wrong for the data it met
        
        A segmentation model is trained on CT. Its training plan records a normalisation contract: clip to
        the CT foreground window, then z-score by the CT mean and standard deviation. That contract travels
        with the checkpoint into inference, and the inference command has **no argument that declares the
        modality of the incoming images**.
        
        The model is then applied to an MR cohort. Every case returns a file. The job exits 0.
        
        ## The question
        
        Given a dataset profile with per-case intensity summaries and the trained contract, decide whether
        the cohort about to be predicted is in the intensity domain that contract assumes.
        
        ## What makes it hard
        
        The tempting check is the destructive one — "how much of each volume does the clip discard?" — and
        it is wrong. A CT volume's 99th percentile is bone, and clipping bone above a soft-tissue window is
        what the contract is *for*. That check fires on 100% of the cohort the plan was fit on. A detector
        that rejects its own training domain is worse than no detector, and a five-number summary cannot
        tell "the window is working" from "the window is destroying the image".
        
        The check that survives is narrower and decisive: **Hounsfield units are defined by an air floor
        near −1000.** A cohort in which no case contains a voxel below −500 is not in Hounsfield units,
        whatever any metadata field claims — and a CT contract applied to it is applying a window that means
        nothing there.
        
        ## What the fixtures encode
        
        - A cohort that IS in the contract's domain must come back clean. This is the false-positive guard,
          and it is the half most detectors of this kind fail.
        - A cohort with no air floor anywhere must raise a Major.
        - A split where some cases bottom out near air and some do not cannot be served by one contract, and
          is flagged.
        - A contract the loader cannot parse must **refuse**, not score as "assumes arbitrary" and pass.
        
        ## Provenance
        
        Derived from `demo/05_msd_amos_spleen`, where this exact mismatch cost a measured **~0.28 Dice** —
        established by two independent counterfactual arms, neither of which retrained the model. The
        toolkit's own profiler had already recorded the underlying property before training, as a **Minor**,
        in a directory no later step reads. The gap this card closes is routing and severity, not detection.
        
      • verify.sh 3 KB
        #!/usr/bin/env bash
        # Deterministic verifier for the normaliser-domain challenge card.
        # Runs check_normalizer_domain.py on two synthetic profiles against one synthetic CT contract and
        # diffs stdout against expected/. No network, no imaging library, no pixels — every verdict is
        # decided by comparing five summary numbers per case against the contract. Exit 0 = both match and
        # both exit codes are correct.
        #
        # Fixtures (synthetic only — no real patients, no PII):
        #   profile_hu.json         12 cases, every one with an air floor near -1024. Under a CT contract
        #                           this MUST be clean: it is exactly the cohort the contract was fit on,
        #                           and a check that rejects its own training domain is worse than none.
        #   profile_arbitrary.json  10 cases with no negative voxel at all (NORMALIZER_DOMAIN_MISMATCH,
        #                           Major) plus a pool where 6 of 10 bottom out near air and the rest do
        #                           not (NORMALIZER_SPLIT_DIVERGENCE, Flag).
        #   plan_ct.json            an nnU-Net-shaped plan declaring CTNormalization.
        #
        # The third case this card guards is the contract loader: an unreadable contract must FAIL rather
        # than score as "assumes arbitrary" and return OK. A gate that passes on input it could not read
        # looks exactly like a gate that passed.
        set -euo pipefail
        HERE="$(cd "$(dirname "$0")" && pwd)"
        DET="$HERE/../check_normalizer_domain.py"
        
        clean="$(python3 "$DET" --profile "$HERE/fixture/profile_hu.json"        --contract "$HERE/fixture/plan_ct.json")"
        bad="$(  python3 "$DET" --profile "$HERE/fixture/profile_arbitrary.json" --contract "$HERE/fixture/plan_ct.json")"
        
        ok=1
        diff -u "$HERE/expected/hu_clean.txt"            <(printf '%s\n' "$clean") || { echo "FAIL: HU fixture drifted" >&2; ok=0; }
        diff -u "$HERE/expected/arbitrary_mismatch.txt"  <(printf '%s\n' "$bad")   || { echo "FAIL: mismatch fixture drifted" >&2; ok=0; }
        
        python3 "$DET" --profile "$HERE/fixture/profile_hu.json"        --contract "$HERE/fixture/plan_ct.json" --strict >/dev/null 2>&1 && rc_clean=0 || rc_clean=$?
        python3 "$DET" --profile "$HERE/fixture/profile_arbitrary.json" --contract "$HERE/fixture/plan_ct.json" --strict >/dev/null 2>&1 && rc_bad=0   || rc_bad=$?
        [ "$rc_clean" -eq 0 ]     || { echo "FAIL: the contract's own CT domain must exit 0 (got $rc_clean)" >&2; ok=0; }
        [ "${rc_bad:-0}" -eq 1 ]  || { echo "FAIL: the domain mismatch must exit 1 under --strict (got ${rc_bad:-0})" >&2; ok=0; }
        
        # an unreadable contract must refuse, not quietly pass
        echo '{"not_a_contract": true}' > "$HERE/.tmp_bad_contract.json"
        python3 "$DET" --profile "$HERE/fixture/profile_hu.json" --contract "$HERE/.tmp_bad_contract.json" >/dev/null 2>&1 && rc_unread=0 || rc_unread=$?
        rm -f "$HERE/.tmp_bad_contract.json"
        [ "${rc_unread:-0}" -ne 0 ] || { echo "FAIL: an unreadable contract must not return success" >&2; ok=0; }
        
        if [ "$ok" -eq 1 ]; then
          echo "PASS: the gate clears its own CT domain, flags an arbitrary-unit cohort and a mixed split, and refuses an unreadable contract."
        else
          exit 1
        fi
        
    • check_preprocessing_leakage_challenge
      • expected
        • clean.txt 370 B
          =========================================
           Preprocessing-Leakage Gate (preprocess-imaging)
          =========================================
            transforms=4  units=5  patients=5  partitions={'test': 2, 'train': 2, 'val': 1}  seed=7
          | Check | Severity | Detail |
          |---|---|---|
          | (none) | — | preprocessing manifest is leakage-safe |
          
          OK: preprocessing manifest is leakage-safe.
          
        • leak.txt 1 KB
          =========================================
           Preprocessing-Leakage Gate (preprocess-imaging)
          =========================================
            transforms=4  units=6  patients=5  partitions={'test': 2, 'train': 3, 'val': 1}  seed=42
          | Check | Severity | Detail |
          |---|---|---|
          | NORMALIZATION_LEAKAGE | Major | data-fitted transform 'hist_match_to_atlas' (histogram_match) is fit on a non-train scope ('all'); test-set statistics leak into training |
          | PREPROCESS_BEFORE_SPLIT | Major | data-fitted transform 'dataset_zscore' (standardize) runs before the split (stage=before_split); the fit spans train and test |
          | AUGMENTATION_ON_EVAL | Minor | augmentation 'random_flip' is applied to test; train-time augmentation on an evaluation split folds undisclosed test-time augmentation into the reported metric |
          | PATIENT_CROSS_SPLIT | Major | 1 of 5 patients have units in >= 2 splits (e.g. 'P03' in test/train); the same patient in train and test inflates every metric. Offenders: P03 |
          
          MAJOR candidate: 3 preprocessing-leakage issue(s).
          
      • fixture
        • manifest_clean.json 800 B
          {
            "split_seed": 7,
            "transforms": [
              {"name": "train_zscore", "type": "standardize", "fit_scope": "train", "stage": "after_split"},
              {"name": "per_image_norm", "type": "normalization", "fit_scope": "sample", "stage": "before_split"},
              {"name": "random_flip", "type": "augmentation", "stage": "after_split", "applies_to": ["train"]},
              {"name": "hu_window", "type": "clip", "fit_scope": "none", "stage": "before_split"}
            ],
            "split_assignment": [
              {"patient_id": "P01", "unit_id": "P01_s1", "split": "train"},
              {"patient_id": "P02", "unit_id": "P02_s1", "split": "train"},
              {"patient_id": "P03", "unit_id": "P03_s1", "split": "validation"},
              {"patient_id": "P04", "unit_id": "P04_s1", "split": "test"},
              {"patient_id": "P05", "unit_id": "P05_s1", "split": "test"}
            ]
          }
          
        • manifest_leak.json 881 B
          {
            "split_seed": 42,
            "transforms": [
              {"name": "hist_match_to_atlas", "type": "histogram_match", "fit_scope": "all", "stage": "after_split"},
              {"name": "dataset_zscore", "type": "standardize", "fit_scope": "train", "stage": "before_split"},
              {"name": "random_flip", "type": "augmentation", "stage": "after_split", "applies_to": ["train", "test"]},
              {"name": "hu_window", "type": "clip", "fit_scope": "none", "stage": "before_split"}
            ],
            "split_assignment": [
              {"patient_id": "P01", "unit_id": "P01_s1", "split": "train"},
              {"patient_id": "P02", "unit_id": "P02_s1", "split": "train"},
              {"patient_id": "P03", "unit_id": "P03_s1", "split": "train"},
              {"patient_id": "P03", "unit_id": "P03_s2", "split": "test"},
              {"patient_id": "P04", "unit_id": "P04_s1", "split": "validation"},
              {"patient_id": "P05", "unit_id": "P05_s1", "split": "test"}
            ]
          }
          
      • problem.md 903 B
        # Challenge — preprocessing-leakage gate
        
        A medical-imaging preprocessing pipeline can leak the test distribution into training
        one stage before the split gate can see it. Given a declarative preprocessing manifest
        (JSON), `check_preprocessing_leakage.py` must decide — by rule and by set arithmetic on
        the patient IDs, not from prose — whether the pipeline is leakage-safe.
        
        ## Task
        Run the gate on the two synthetic manifests in `fixture/` and reproduce `expected/`:
        
        - `manifest_leak.json` → three Major verdicts (`NORMALIZATION_LEAKAGE`,
          `PREPROCESS_BEFORE_SPLIT`, `PATIENT_CROSS_SPLIT`) plus a Minor `AUGMENTATION_ON_EVAL`;
          exit 1 under `--strict`.
        - `manifest_clean.json` → no claims; exit 0. Note the per-image normalisation runs
          before the split yet does **not** fire: a per-sample transform is leakage-free.
        
        ## Verify
        ```bash
        bash verify.sh   # deterministic, network-free
        ```
        
      • verify.sh 2.1 KB
        #!/usr/bin/env bash
        # Deterministic verifier for the preprocessing-leakage challenge card.
        # Runs check_preprocessing_leakage.py on two synthetic preprocessing manifests and
        # diffs stdout against expected/. No network, no torch — every leak is decided by
        # rule + set arithmetic on the manifest. Exit 0 = both match and exit codes correct.
        #
        # Fixtures (synthetic only — no real patients, no PII):
        #   manifest_leak.json  — 4 leaks: a histogram_match fit on 'all' (NORMALIZATION_LEAKAGE),
        #                         a dataset standardize before the split (PREPROCESS_BEFORE_SPLIT),
        #                         augmentation applied to test (AUGMENTATION_ON_EVAL), and patient
        #                         P03 in train+test (PATIENT_CROSS_SPLIT).
        #   manifest_clean.json — train-only z-score after split, per-image norm (leakage-free even
        #                         before split), augmentation on train only, fixed HU window, and a
        #                         disjoint patient split.
        set -euo pipefail
        HERE="$(cd "$(dirname "$0")" && pwd)"
        DET="$HERE/../check_preprocessing_leakage.py"
        
        leak="$(python3 "$DET" --manifest "$HERE/fixture/manifest_leak.json")"
        clean="$(python3 "$DET" --manifest "$HERE/fixture/manifest_clean.json")"
        
        ok=1
        if ! diff -u "$HERE/expected/leak.txt" <(printf '%s\n' "$leak"); then
          echo "FAIL: leak-fixture output drifted from expected/leak.txt" >&2; ok=0
        fi
        if ! diff -u "$HERE/expected/clean.txt" <(printf '%s\n' "$clean"); then
          echo "FAIL: clean-fixture output drifted from expected/clean.txt" >&2; ok=0
        fi
        
        python3 "$DET" --manifest "$HERE/fixture/manifest_leak.json" --strict --quiet >/dev/null 2>&1 && rc_leak=0 || rc_leak=$?
        python3 "$DET" --manifest "$HERE/fixture/manifest_clean.json" --strict --quiet >/dev/null 2>&1 && rc_clean=0 || rc_clean=$?
        [ "${rc_leak:-0}" -eq 1 ] || { echo "FAIL: leak fixture should exit 1 under --strict (got ${rc_leak:-0})" >&2; ok=0; }
        [ "$rc_clean" -eq 0 ]      || { echo "FAIL: clean fixture should exit 0 under --strict (got $rc_clean)" >&2; ok=0; }
        
        if [ "$ok" -eq 1 ]; then
          echo "PASS: preprocessing-leakage gate flags the 3 Major leaks + 1 augmentation-on-eval flag and clears the safe manifest."
        else
          exit 1
        fi
        
    • check_normalizer_domain.py 10.1 KB
      #!/usr/bin/env python3
      """Normaliser-domain gate: is the cohort you are about to predict in the domain the trained
      normalisation contract assumes?
      
      `check_preprocessing_leakage.py` asks whether a data-fitted transform was fit on the right SCOPE
      (train only, after the split). This asks a different question, at a different moment: at inference,
      is the cohort being handed to a trained normaliser in the INTENSITY DOMAIN that normaliser was fit
      on? Nothing else in this toolkit asks it, and nothing carries a profiling-stage observation forward
      to the moment of use.
      
      **Why this exists.** In demo/05_msd_amos_spleen, a segmentation model trained on CT was applied to
      MRI. The training plan carried `CTNormalization` into inference and the inference command had no
      argument declaring the incoming modality, so a Hounsfield-unit clip was applied to arbitrary-unit
      images. Median Dice was 0.0152. Two independent counterfactual arms — rescale the input, or swap the
      normaliser — each recovered it to ~0.29 without retraining, so roughly **0.28 Dice** was the domain
      mismatch alone. The run exited 0 and wrote a plausible-looking segmentation for every case.
      
      The profiler had already seen it. `/profile-imaging` flagged `INTENSITY_SCALE_INCONSISTENT` on that
      cohort *before training* — as a **Minor**, in a directory no later step reads. The gap this gate
      closes is therefore **routing and severity**, not detection: it re-reads an existing profile against
      the contract that will actually be applied, at the point where ignoring it costs something.
      
      Reads two JSON files and nothing else — no imaging library, no pixels, no model. stdlib only, so it
      travels with the artifacts.
      
        --profile   a /profile-imaging profile (`profile_imaging_dataset.py` output) with per-case
                    `intensity` {min, p01, p50, p99, max} and `split`
        --contract  the normalisation contract that will be applied. Either an nnU-Net-style `plans.json`
                    (the scheme and the foreground statistics are read from it) or a small JSON of the
                    form {"scheme": "...", "assumes": "hounsfield"|"arbitrary"|"zero_mean",
                    "clip": [lo, hi], "mean": m, "std": s}.
      
      Verdicts:
      
        NORMALIZER_DOMAIN_MISMATCH  (Major)  the contract assumes Hounsfield units and the split contains
                                             no negative voxels — HU is defined by an air floor near
                                             -1000, so a cohort that never goes negative is not in it
        NORMALIZER_SPLIT_DIVERGENCE (Flag)   splits inside one cohort disagree about the intensity domain,
                                             so one contract cannot be right for all of them
      
      **A third check was written and deleted.** `NORMALIZER_CLIP_DESTRUCTIVE` compared each split's 99th
      percentile against the contract's clip ceiling. Run against the cohorts this repository already
      produces, it fired on **100% of the CT arm and 100% of the MSD training set** — the very data the
      plan was fit on. Of course it did: a CT volume's p99 is bone, and clipping bone above a soft-tissue
      window is what `CTNormalization` is *for*. A five-number summary cannot distinguish "the window is
      doing its job" from "the window is destroying the image", and a check that rejects its own training
      data is worse than no check. Deleted rather than tuned.
      """
      from __future__ import annotations
      
      import argparse
      import json
      import sys
      from pathlib import Path
      
      HU_AIR_CEILING = -500.0     # a genuine HU volume has voxels below this (air is near -1000)
      
      
      def load(p: str) -> dict:
          try:
              return json.loads(Path(p).read_text(encoding="utf-8"))
          except Exception as exc:                                  # noqa: BLE001
              print(f"FATAL: cannot read {p}: {exc}", file=sys.stderr)
              raise SystemExit(2) from exc
      
      
      def contract_from(obj: dict, channel: str = "0") -> dict:
          """Accept an nnU-Net plans.json or a small hand-written contract."""
          if "configurations" in obj:                                # nnU-Net plans.json
              cfg = obj["configurations"].get("3d_fullres") or next(iter(obj["configurations"].values()))
              schemes = cfg.get("normalization_schemes") or []
              scheme = schemes[0] if schemes else "unknown"
              props = (obj.get("foreground_intensity_properties_per_channel")
                       or obj.get("foreground_intensity_properties_by_modality") or {})
              s = props.get(channel, {})
              return {
                  "scheme": scheme,
                  "assumes": "hounsfield" if "CT" in scheme else "arbitrary",
                  "clip": [s.get("percentile_00_5"), s.get("percentile_99_5")] if s else None,
                  "mean": s.get("mean"), "std": s.get("std"),
              }
          c = dict(obj)
          if "scheme" not in c:
              # Fail loudly rather than scoring an unparseable contract as "assumes arbitrary" and
              # returning OK. A gate that passes on input it could not read looks exactly like a gate
              # that passed — this one refuses instead.
              raise SystemExit(
                  "FATAL: --contract is neither an nnU-Net plans.json (no 'configurations' key) nor a "
                  "contract object (no 'scheme' key). Refusing to evaluate a contract I cannot read; "
                  "pass the plans.json the model was trained under, or a JSON with at least "
                  '{"scheme": "...", "assumes": "hounsfield"|"arbitrary"}.')
          c.setdefault("assumes", "hounsfield" if "CT" in str(c.get("scheme", "")) else "arbitrary")
          return c
      
      
      def by_split(profile: dict) -> dict[str, list[dict]]:
          out: dict[str, list[dict]] = {}
          for case in profile.get("cases", []):
              if "intensity" not in case:
                  continue
              out.setdefault(case.get("split", "(unsplit)"), []).append(case)
          return out
      
      
      def main() -> int:
          ap = argparse.ArgumentParser(description="Does the cohort match the normaliser's assumed domain?")
          ap.add_argument("--profile", required=True)
          ap.add_argument("--contract", required=True)
          ap.add_argument("--channel", default="0")
          ap.add_argument("--splits", nargs="*", default=None,
                          help="restrict to these split names (default: every labelled split)")
          ap.add_argument("--out")
          ap.add_argument("--strict", action="store_true", help="exit 1 when a Major is raised")
          a = ap.parse_args()
      
          profile = load(a.profile)
          contract = contract_from(load(a.contract), a.channel)
          groups = by_split(profile)
          if a.splits:
              groups = {k: v for k, v in groups.items() if k in a.splits}
          if not groups:
              print("FATAL: no per-case intensity found in the profile", file=sys.stderr)
              return 2
      
          claims, domains = [], {}
          for split, cases in sorted(groups.items()):
              mins = [c["intensity"].get("min") for c in cases if c["intensity"].get("min") is not None]
              if not mins:
                  continue
              n_air = sum(1 for m in mins if m <= HU_AIR_CEILING)
              looks_hu = n_air == len(mins)
              domains[split] = "hounsfield" if looks_hu else ("mixed" if n_air else "non-hounsfield")
      
              if contract.get("assumes") == "hounsfield" and n_air == 0:
                  claims.append({
                      "verdict": "NORMALIZER_DOMAIN_MISMATCH", "severity": "Major", "split": split,
                      "detail": (f"the contract applies {contract.get('scheme')}, which assumes Hounsfield "
                                 f"units, but 0 of {len(mins)} case(s) in '{split}' contain a voxel at or "
                                 f"below {HU_AIR_CEILING:.0f}. Hounsfield units are defined by an air "
                                 f"floor near -1000; a cohort that never goes negative is not in them. "
                                 f"Per-case minima range {min(mins):.0f} to {max(mins):.0f}."),
                  })
              elif contract.get("assumes") == "hounsfield" and 0 < n_air < len(mins):
                  claims.append({
                      "verdict": "NORMALIZER_SPLIT_DIVERGENCE", "severity": "Flag", "split": split,
                      "detail": (f"{n_air} of {len(mins)} case(s) in '{split}' bottom out near air and the "
                                 f"rest do not — mixed modality, or a rescale applied to part of the "
                                 f"cohort. One normalisation contract cannot be right for both."),
                  })
      
          if len({d for d in domains.values() if d != "mixed"}) > 1:
              claims.append({
                  "verdict": "NORMALIZER_SPLIT_DIVERGENCE", "severity": "Flag", "split": "(cohort)",
                  "detail": (f"splits disagree about the intensity domain ({domains}); a single trained "
                             f"contract cannot be correct for all of them."),
              })
      
          n_major = sum(1 for c in claims if c["severity"] == "Major")
          report = {
              "detector": "check_normalizer_domain",
              "profile": a.profile, "contract": a.contract,
              "contract_read": contract,
              "splits_examined": {k: {"n": len(v), "domain": domains.get(k)} for k, v in groups.items()},
              "claims": claims,
              "summary": {"n_claims": len(claims), "n_major": n_major,
                          "n_flag": len(claims) - n_major,
                          "verdict": "OK" if not claims else ("MAJOR" if n_major else "FLAG")},
          }
      
          print("=" * 41)
          print(" Normaliser-Domain Gate (preprocess-imaging)")
          print("=" * 41)
          print(f"  contract: {contract.get('scheme')} (assumes {contract.get('assumes')})"
                + (f", clip [{contract['clip'][0]:.0f}, {contract['clip'][1]:.0f}]"
                   if contract.get("clip") and None not in contract["clip"] else ""))
          print("| Check | Severity | Detail |")
          print("|---|---|---|")
          if claims:
              for c in claims:
                  print(f"| {c['verdict']} | {c['severity']} | {c['detail']} |")
          else:
              print("| (none) | — | every split is in the domain the contract assumes |")
          print()
          print(f"MAJOR candidate: {n_major} domain issue(s)." if n_major
                else ("FLAG: review the divergence above." if claims
                      else "OK: every split is in the domain the contract assumes."))
      
          if a.out:
              Path(a.out).parent.mkdir(parents=True, exist_ok=True)
              Path(a.out).write_text(json.dumps(report, indent=2) + "\n")
              print(f"\nwrote {a.out}")
          return 1 if (a.strict and n_major) else 0
      
      
      if __name__ == "__main__":
          raise SystemExit(main())
      
    • check_preprocessing_leakage.py 13.8 KB
      #!/usr/bin/env python3
      """Data-stage preprocessing-leakage gate for a medical-imaging pipeline (preprocess-imaging).
      
      `model-validation`'s split-leakage gate proves the train/val/test *split* is
      patient-disjoint. But leakage also enters one stage earlier — in **preprocessing** —
      and the split table cannot see it. The three classic data-stage leaks
      (Kapoor & Narayanan, Patterns 2023; Varoquaux & Cheplygina, npj Digit Med 2022;
      CLAIM 2024 data-partition/preprocessing items) are:
      
        1. fitting a dataset-level normalisation / scaler on data that is NOT the training
           split (the test intensity distribution leaks into training);
        2. running any data-fitted transform BEFORE the split exists (there is no train/test
           distinction yet, so the fit is inherently cross-partition);
        3. the same patient's slices landing in more than one split (slice-level overlap that
           a per-image manifest hides).
      
      This gate reads a declarative **preprocessing manifest** (JSON — the artifact this
      skill emits, or one the researcher writes) and decides each of these by rule and by
      set arithmetic on the patient IDs, not from prose. A *per-image* / *per-sample*
      transform (each image normalised by its own statistics) is leakage-free and never
      fires; only a *dataset-fitted* transform can leak.
      
      CHECKS (verdicts):
        1. PREPROCESS_BEFORE_SPLIT  (Major)  a data-fitted transform runs before the split
                                             (stage=before_split) — the fit spans partitions.
        2. NORMALIZATION_LEAKAGE    (Major)  a data-fitted transform is fit on a non-train
                                             scope (all/full/dataset/test/both) after the split.
        3. PATIENT_CROSS_SPLIT      (Major)  a patient_id whose units appear in >= 2 splits.
        4. AUGMENTATION_ON_EVAL     (Minor)  an augmentation is applied to val/test (train-time
                                             augmentation folded into evaluation / undisclosed TTA).
        5. UNSPECIFIED_FIT_SCOPE    (Minor)  a data-fitted transform declares no fit_scope — the
                                             leak cannot be ruled out; declare train-only.
        6. MISSING_SEED             (Minor)  no split_seed — the split cannot be regenerated.
      
      A data-fitted transform is one whose `type` is a fitted operation (normalization,
      standardize, scaler, min-max, clip_percentile, histogram_match, pca, whitening,
      feature_selection, resample, …) AND whose `fit_scope` is not per-sample/none/fixed.
      A genuinely fixed transform (a fixed HU window, a resample to a spacing you chose
      in advance) is not data-fitted and never leaks — declare `fit_scope: fixed` and it
      stays silent.
      
      Resampling is on that list because the *target* is so often derived rather than
      chosen: nnU-Net sets its target spacing from a percentile of the dataset
      fingerprint, so a resample fitted over every case carries held-out geometry into
      the training grid just as an intensity statistic would. "Resample to a fixed
      spacing never leaks" is true only when the spacing really is fixed.
      
      MANIFEST (JSON)
        {
          "split_seed": 42,
          "transforms": [
            {"name": "...", "type": "standardize", "fit_scope": "train", "stage": "after_split"},
            {"name": "...", "type": "augmentation", "stage": "after_split", "applies_to": ["train"]}
          ],
          "split_assignment": [
            {"patient_id": "P001", "unit_id": "P001_s1", "split": "train"}, ...
          ]
        }
        fit_scope   : train / training / train_val / dev  -> OK; all / full / dataset / test /
                      both / combined -> leak; sample / per_image / instance / none -> not data-fitted.
        stage       : before_split / after_split (synonyms: pre-split / post-split).
        split       : train/val/test synonyms collapse (training/validation/holdout/…).
      
      INPUTS
        --manifest  preprocessing manifest JSON (required).
      
      OUTPUT
        A reconciliation table (stdout) and, with --out, a JSON artifact:
          {manifest, n_transforms, n_units, n_patients, partitions{name:count}, seed,
           claims[{verdict, severity, detail, where}], summary}
        PREPROCESS_BEFORE_SPLIT / NORMALIZATION_LEAKAGE / PATIENT_CROSS_SPLIT are Major.
      
      Stdlib-only (json / argparse / pathlib). Exit codes: 0 clean (or report-only),
      1 Major claim(s) found (with --strict), 2 input/usage error.
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import sys
      from pathlib import Path
      
      # Transform types whose parameters are FIT from data (so their fit scope matters).
      FIT_BASED_TYPES = {
          "normalization", "normalize", "normalisation", "standardize", "standardise",
          "standardization", "zscore", "z-score", "z_score", "scaler", "scaling",
          "minmax", "min-max", "min_max", "clip_percentile", "percentile_clip",
          "percentile_clipping", "histogram_match", "histogram_matching",
          "histogram_equalization", "histogram_equalisation", "pca", "whitening",
          "feature_selection", "intensity_normalization", "intensity_normalisation",
          "nyul", "zca",
          # Resampling belongs here whenever the *target* is derived from the data. nnU-Net's
          # target spacing is a percentile of the dataset fingerprint, so a resample fitted over
          # all cases carries held-out geometry into the training grid exactly as an intensity
          # statistic would. A target that is genuinely fixed declares fit_scope=fixed and drops
          # out in _is_fit_based -- that is the case the docstring used to describe as if it
          # were the only one.
          "resample", "resampling", "respacing", "resample_spacing", "target_spacing",
          "spacing_normalization", "spacing_normalisation",
      }
      # fit_scope values that make a transform NOT data-fitted (per-sample or fixed).
      SAMPLE_SCOPES = {
          "sample", "per_sample", "per-sample", "per_image", "per-image", "perimage",
          "instance", "per_instance", "none", "fixed", "self",
      }
      # fit_scope values that leak (fit touches non-training data).
      NON_TRAIN_SCOPES = {
          "all", "full", "dataset", "entire", "everything", "combined", "both",
          "test", "testing", "holdout", "hold-out", "trainvaltest", "train_val_test",
          "train+test", "all_data", "whole",
      }
      TRAIN_OK_SCOPES = {
          "train", "training", "train_val", "trainval", "train+val", "development",
          "dev", "fold_train", "train_only",
      }
      BEFORE_STAGES = {"before_split", "before-split", "pre_split", "pre-split", "before", "pre"}
      SPLIT_SYNONYM = {
          "train": "train", "training": "train",
          "val": "val", "validation": "val", "valid": "val", "dev": "val",
          "test": "test", "testing": "test", "holdout": "test", "hold-out": "test",
          "eval": "test", "evaluation": "test",
      }
      EVAL_SPLITS = {"val", "test"}
      
      
      def _norm(s) -> str:
          return str(s).strip().lower() if s is not None else ""
      
      
      def _is_fit_based(t: dict) -> bool:
          typ = _norm(t.get("type"))
          scope = _norm(t.get("fit_scope"))
          if typ not in FIT_BASED_TYPES:
              return False
          if scope in SAMPLE_SCOPES:
              return False
          return True
      
      
      def check(manifest: dict) -> list[dict]:
          claims: list[dict] = []
          transforms = manifest.get("transforms") or []
      
          for t in transforms:
              name = t.get("name") or t.get("type") or "(unnamed)"
              typ = _norm(t.get("type"))
              scope = _norm(t.get("fit_scope"))
              stage = _norm(t.get("stage"))
      
              if _is_fit_based(t):
                  if stage in BEFORE_STAGES:
                      claims.append({
                          "verdict": "PREPROCESS_BEFORE_SPLIT", "severity": "Major",
                          "detail": (f"data-fitted transform '{name}' ({typ}) runs before the split "
                                     f"(stage=before_split); the fit spans train and test"),
                          "where": name,
                      })
                  elif scope in NON_TRAIN_SCOPES:
                      claims.append({
                          "verdict": "NORMALIZATION_LEAKAGE", "severity": "Major",
                          "detail": (f"data-fitted transform '{name}' ({typ}) is fit on a non-train "
                                     f"scope ('{scope}'); test-set statistics leak into training"),
                          "where": name,
                      })
                  elif scope not in TRAIN_OK_SCOPES:
                      # data-fitted, after split, but fit_scope undeclared/unknown -> ambiguous
                      claims.append({
                          "verdict": "UNSPECIFIED_FIT_SCOPE", "severity": "Minor",
                          "detail": (f"data-fitted transform '{name}' ({typ}) declares no train-only "
                                     f"fit_scope ('{scope or 'missing'}'); declare fit_scope=train so "
                                     f"leakage can be ruled out"),
                          "where": name,
                      })
      
              if typ in ("augmentation", "augment", "aug"):
                  applies = [_norm(x) for x in (t.get("applies_to") or [])]
                  applies = {SPLIT_SYNONYM.get(a, a) for a in applies}
                  leaked = sorted(applies & EVAL_SPLITS)
                  if leaked:
                      claims.append({
                          "verdict": "AUGMENTATION_ON_EVAL", "severity": "Minor",
                          "detail": (f"augmentation '{name}' is applied to {'/'.join(leaked)}; "
                                     f"train-time augmentation on an evaluation split folds "
                                     f"undisclosed test-time augmentation into the reported metric"),
                          "where": name,
                      })
      
          # Patient-level cross-split (set arithmetic).
          rows = manifest.get("split_assignment") or []
          pat_to_splits: dict[str, set] = {}
          for r in rows:
              pid = r.get("patient_id") or r.get("subject_id") or r.get("patient") or r.get("id")
              sp = SPLIT_SYNONYM.get(_norm(r.get("split")), _norm(r.get("split")))
              if pid is None or not sp:
                  continue
              pat_to_splits.setdefault(str(pid), set()).add(sp)
          offenders = sorted(p for p, s in pat_to_splits.items() if len(s) >= 2)
          if offenders:
              ex = offenders[0]
              claims.append({
                  "verdict": "PATIENT_CROSS_SPLIT", "severity": "Major",
                  "detail": (f"{len(offenders)} of {len(pat_to_splits)} patients have units in "
                             f">= 2 splits (e.g. '{ex}' in {'/'.join(sorted(pat_to_splits[ex]))}); "
                             f"the same patient in train and test inflates every metric. "
                             f"Offenders: {', '.join(offenders)}"),
                  "where": ex,
              })
      
          # Reproducibility.
          if manifest.get("split_seed") is None and rows:
              claims.append({
                  "verdict": "MISSING_SEED", "severity": "Minor",
                  "detail": "no split_seed recorded; the split cannot be regenerated or re-verified",
                  "where": "split_seed",
              })
      
          return claims
      
      
      def analyze(manifest_path: str) -> dict:
          p = Path(manifest_path)
          if not p.is_file():
              sys.stderr.write(f"ERROR: manifest not found: {manifest_path}\n")
              sys.exit(2)
          try:
              manifest = json.loads(p.read_text(encoding="utf-8"))
          except (json.JSONDecodeError, ValueError) as e:
              sys.stderr.write(f"ERROR: manifest is not valid JSON: {e}\n")
              sys.exit(2)
          if not isinstance(manifest, dict):
              sys.stderr.write("ERROR: manifest JSON must be an object\n")
              sys.exit(2)
      
          claims = check(manifest)
          rows = manifest.get("split_assignment") or []
          partitions: dict[str, int] = {}
          patients: set = set()
          for r in rows:
              sp = SPLIT_SYNONYM.get(_norm(r.get("split")), _norm(r.get("split")))
              if sp:
                  partitions[sp] = partitions.get(sp, 0) + 1
              pid = r.get("patient_id") or r.get("subject_id") or r.get("patient") or r.get("id")
              if pid is not None:
                  patients.add(str(pid))
          n_major = sum(1 for c in claims if c["severity"] == "Major")
          return {
              "manifest": str(p),
              "n_transforms": len(manifest.get("transforms") or []),
              "n_units": len(rows),
              "n_patients": len(patients),
              "partitions": dict(sorted(partitions.items())),
              "seed": manifest.get("split_seed"),
              "claims": claims,
              "summary": {
                  "n_claims": len(claims),
                  "n_major": n_major,
                  "n_flag": len(claims) - n_major,
                  "verdict": "MAJOR_CANDIDATE" if n_major else "OK",
              },
          }
      
      
      def render(result: dict) -> str:
          lines = ["| Check | Severity | Detail |", "|---|---|---|"]
          for c in result["claims"]:
              lines.append(f"| {c['verdict']} | {c['severity']} | {c['detail']} |")
          if len(lines) == 2:
              lines.append("| (none) | — | preprocessing manifest is leakage-safe |")
          return "\n".join(lines)
      
      
      def main() -> int:
          ap = argparse.ArgumentParser(description="Data-stage preprocessing-leakage gate.")
          ap.add_argument("--manifest", required=True, help="preprocessing manifest JSON")
          ap.add_argument("--out", help="write JSON artifact to this path")
          ap.add_argument("--strict", action="store_true", help="exit 1 if any Major claim exists")
          ap.add_argument("--quiet", action="store_true", help="suppress stdout table")
          args = ap.parse_args()
      
          result = analyze(args.manifest)
      
          if not args.quiet:
              print("=" * 41)
              print(" Preprocessing-Leakage Gate (preprocess-imaging)")
              print("=" * 41)
              p = result
              print(f"  transforms={p['n_transforms']}  units={p['n_units']}  "
                    f"patients={p['n_patients']}  partitions={p['partitions']}  seed={p['seed']}")
              print(render(result))
              print()
              s = result["summary"]
              if s["n_major"]:
                  print(f"MAJOR candidate: {s['n_major']} preprocessing-leakage issue(s).")
              elif s["n_flag"]:
                  print(f"MINOR flag: {s['n_flag']} preprocessing hygiene issue(s) (see table).")
              else:
                  print("OK: preprocessing manifest is leakage-safe.")
      
          if args.out:
              Path(args.out).parent.mkdir(parents=True, exist_ok=True)
              Path(args.out).write_text(json.dumps({"detector": "check_preprocessing_leakage", **result}, indent=2), encoding="utf-8")
              if not args.quiet:
                  print(f"\nwrote {args.out}")
      
          return 1 if (args.strict and result["summary"]["n_major"]) else 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
  • tests
    • test_preprocessing_leakage.sh 5 KB
      #!/usr/bin/env bash
      # Regression test for the preprocessing-leakage gate (preprocess-imaging).
      # Synthetic, PII-free JSON manifests reproduce each verdict class. Stdlib-only (python3).
      set -u
      
      HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      SCRIPT="$HERE/../scripts/check_preprocessing_leakage.py"
      CH="$HERE/../scripts/check_preprocessing_leakage_challenge"
      TMP="$(mktemp -d -t preproc_XXXX)"
      OUT="$TMP/out.json"
      trap 'rm -rf "$TMP"' EXIT
      
      fail=0
      check() { local label="$1"; shift
          if "$@" >/dev/null 2>&1; then printf '  PASS  %s\n' "$label"
          else printf '  FAIL  %s\n' "$label"; fail=$((fail+1)); fi
      }
      has_verdict() { python3 -c "
      import json,sys
      d=json.load(open('$OUT'))
      assert any(c['verdict']=='$1' for c in d['claims']), '$1 not found'
      "; }
      no_verdict() { python3 -c "
      import json,sys
      d=json.load(open('$OUT'))
      assert not any(c['verdict']=='$1' for c in d['claims']), '$1 unexpectedly present'
      "; }
      
      [[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; }
      
      # (1) leak fixture -> 3 Major verdicts + exit 1
      python3 "$SCRIPT" --manifest "$CH/fixture/manifest_leak.json" --out "$OUT" --strict --quiet >/dev/null 2>&1
      check "exit 1 (leak manifest)" test "$?" -eq 1
      check "PREPROCESS_BEFORE_SPLIT detected" has_verdict PREPROCESS_BEFORE_SPLIT
      check "NORMALIZATION_LEAKAGE detected" has_verdict NORMALIZATION_LEAKAGE
      check "PATIENT_CROSS_SPLIT detected" has_verdict PATIENT_CROSS_SPLIT
      check "AUGMENTATION_ON_EVAL detected" has_verdict AUGMENTATION_ON_EVAL
      
      # (2) clean fixture -> exit 0, no Major
      python3 "$SCRIPT" --manifest "$CH/fixture/manifest_clean.json" --strict --quiet >/dev/null 2>&1
      check "exit 0 (clean manifest)" test "$?" -eq 0
      
      # (3) data-fitted transform after split with NO fit_scope -> Minor UNSPECIFIED_FIT_SCOPE, exit 0
      cat > "$TMP/unspec.json" <<'EOF'
      {"split_seed": 1,
       "transforms": [{"name": "scaler", "type": "minmax", "stage": "after_split"}],
       "split_assignment": [{"patient_id": "A", "split": "train"}, {"patient_id": "B", "split": "test"}]}
      EOF
      python3 "$SCRIPT" --manifest "$TMP/unspec.json" --out "$OUT" --quiet >/dev/null 2>&1
      check "UNSPECIFIED_FIT_SCOPE detected" has_verdict UNSPECIFIED_FIT_SCOPE
      python3 "$SCRIPT" --manifest "$TMP/unspec.json" --strict --quiet >/dev/null 2>&1
      check "unspecified-scope is Minor (exit 0 under --strict)" test "$?" -eq 0
      
      # (4) split present but no split_seed -> Minor MISSING_SEED
      cat > "$TMP/noseed.json" <<'EOF'
      {"transforms": [{"name": "z", "type": "standardize", "fit_scope": "train", "stage": "after_split"}],
       "split_assignment": [{"patient_id": "A", "split": "train"}, {"patient_id": "B", "split": "test"}]}
      EOF
      python3 "$SCRIPT" --manifest "$TMP/noseed.json" --out "$OUT" --quiet >/dev/null 2>&1
      check "MISSING_SEED detected" has_verdict MISSING_SEED
      
      # (5) per-image normalisation BEFORE split -> NOT a leak (sample scope is leakage-free)
      cat > "$TMP/persample.json" <<'EOF'
      {"split_seed": 3,
       "transforms": [{"name": "perimg", "type": "normalization", "fit_scope": "sample", "stage": "before_split"}],
       "split_assignment": [{"patient_id": "A", "split": "train"}, {"patient_id": "B", "split": "test"}]}
      EOF
      python3 "$SCRIPT" --manifest "$TMP/persample.json" --out "$OUT" --quiet >/dev/null 2>&1
      check "per-image transform before split does NOT fire PREPROCESS_BEFORE_SPLIT" no_verdict PREPROCESS_BEFORE_SPLIT
      python3 "$SCRIPT" --manifest "$TMP/persample.json" --strict --quiet >/dev/null 2>&1
      check "exit 0 on per-image-safe manifest" test "$?" -eq 0
      
      # (6) resampling. A target spacing chosen in advance is fixed and never leaks; a target
      # derived from the cohort (nnU-Net takes a percentile of the dataset fingerprint) is a
      # fitted parameter, and fitting it over every case carries held-out geometry into the
      # training grid exactly as an intensity statistic would.
      cat > "$TMP/resample_fitted.json" <<'EOF'
      {"split_seed": 42,
       "transforms": [{"name": "resample_to_fingerprint_median", "type": "resample",
                       "fit_scope": "all", "stage": "before_split"}],
       "split_assignment": [{"patient_id": "A", "split": "train"}, {"patient_id": "B", "split": "test"}]}
      EOF
      python3 "$SCRIPT" --manifest "$TMP/resample_fitted.json" --out "$OUT" --quiet >/dev/null 2>&1
      check "data-derived resample target before split fires PREPROCESS_BEFORE_SPLIT" \
            has_verdict PREPROCESS_BEFORE_SPLIT
      
      cat > "$TMP/resample_fixed.json" <<'EOF'
      {"split_seed": 42,
       "transforms": [{"name": "resample_to_1mm_iso", "type": "resample",
                       "fit_scope": "fixed", "stage": "before_split"}],
       "split_assignment": [{"patient_id": "A", "split": "train"}, {"patient_id": "B", "split": "test"}]}
      EOF
      python3 "$SCRIPT" --manifest "$TMP/resample_fixed.json" --out "$OUT" --quiet >/dev/null 2>&1
      check "resample to a declared fixed spacing does NOT fire PREPROCESS_BEFORE_SPLIT" \
            no_verdict PREPROCESS_BEFORE_SPLIT
      python3 "$SCRIPT" --manifest "$TMP/resample_fixed.json" --strict --quiet >/dev/null 2>&1
      check "exit 0 on fixed-spacing resample" test "$?" -eq 0
      
      # (7) the shipped challenge card passes
      check "challenge verify.sh passes" bash "$CH/verify.sh"
      
      echo "fail=$fail"; [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail"
      exit "$fail"
      
  • SKILL.md 8.1 KB
    ---
    name: preprocess-imaging
    description: >
      Design or audit the data-preparation stage of a medical-imaging model — DICOM/NIfTI intake,
      resampling and intensity normalisation, and the augmentation plan — so the pipeline is leakage-safe
      before model-scaffold builds the training repo. Emits a declarative preprocessing manifest and a
      deterministic data-stage leakage gate that catches the leaks a split table cannot see: a
      dataset-level normaliser fit on non-train data, any data-fitted transform run before the split, and
      the same patient's slices crossing splits. Integrates MONAI / TorchIO transforms; it does not
      reimplement them, and it never runs preprocessing on real patient data.
    triggers: preprocess imaging, preprocessing, data pipeline, DICOM, NIfTI, resample, spacing, intensity normalization, intensity normalisation, windowing, HU window, z-score, histogram matching, augmentation, augmentation plan, TorchIO, MONAI transforms, data leakage, normalization leakage, preprocessing manifest, fit on train, per-image normalization, patient-level split, slice-level leakage, imaging data prep
    tools: Read, Write, Edit, Bash, Grep, Glob
    model: inherit
    ---
    
    # Preprocess-Imaging Skill
    
    ## Purpose
    
    This skill designs and audits the **data-preparation stage** of a medical-imaging model — the stage
    *before* a training repo is built — and proves it is **leakage-safe by construction**. Data leakage
    enters one step earlier than the split table can see: a normaliser fit on the whole dataset, a
    data-fitted transform run before the split exists, or a patient whose slices land in more than one
    partition. Each silently inflates every downstream metric (Kapoor & Narayanan, *Patterns* 2023;
    Varoquaux & Cheplygina, *npj Digit Med* 2022; CLAIM 2024 data items).
    
    It is the **missing first link** in the lane: **preprocess-imaging (prepare + audit)** →
    `/model-scaffold` (build) → `/model-validation` (validate the split) → `/model-evaluation` +
    `/analyze-stats` (metrics) → `/write-paper` + `/check-reporting` (publish). It **integrates**
    MONAI / TorchIO transforms (referenced in the emitted plan); it does not reimplement them, and it
    never executes preprocessing on real patient data.
    
    ## When to use
    - You have a data manifest (one row per image/slice with a patient/subject ID) and want a
      leakage-safe preprocessing plan + a machine-checkable manifest before scaffolding a model.
    - You want to audit an existing preprocessing pipeline for data-stage leakage.
    
    ## When NOT to use
    - Auditing the train/val/test split table itself → `/model-validation` (split-leakage gate).
    - Building the training repo / model code → `/model-scaffold` (it consumes this manifest).
    - Choosing the architecture → `/architecture-zoo`.
    - Held-out metrics / calibration → `/model-evaluation` then `/analyze-stats`.
    - Reimplementing MONAI / TorchIO transforms → out of scope (this skill wires and audits them).
    
    ## Workflow
    
    ### Phase 1 — Inventory the data and the intended steps
    Collect: modality (CT / MR / X-ray / US / path), the data manifest (one row per image/slice with a
    `patient_id`), the intended resample spacing, the intensity transform (fixed HU window vs a fitted
    z-score / min-max / histogram match), and the augmentation plan. See
    [`references/preprocessing_guide.md`](references/preprocessing_guide.md) for modality-aware guidance
    (what normalisation is standard per modality, which augmentations preserve vs break physiology).
    
    ### Phase 2 — Decide fit scope and order (the leakage-safe rules)
    - **Fit dataset-level normalisation on the training split only** — never on all/full/test.
    - **Run any data-fitted transform AFTER the split** — before the split there is no train/test
      distinction, so the fit spans partitions.
    - **Prefer per-image (per-sample) normalisation** where clinically appropriate: it uses only that
      image's own statistics and is leakage-free even before the split.
    - **Keep augmentation train-only** — augmenting val/test folds undisclosed test-time augmentation
      into the reported metric.
    - **Split at the patient level**, then map slices to their patient's split (never split slices).
    
    ### Phase 3 — Emit the preprocessing manifest
    Write a declarative JSON manifest that `model-scaffold` consumes and the gate checks:
    
    ```json
    {
      "split_seed": 42,
      "transforms": [
        {"name": "hu_window", "type": "clip", "fit_scope": "none", "stage": "before_split"},
        {"name": "train_zscore", "type": "standardize", "fit_scope": "train", "stage": "after_split"},
        {"name": "flip_rotate", "type": "augmentation", "stage": "after_split", "applies_to": ["train"]}
      ],
      "split_assignment": [
        {"patient_id": "P001", "unit_id": "P001_s1", "split": "train"}
      ]
    }
    ```
    
    `fit_scope`: `train` (OK) · `all`/`full`/`dataset`/`test` (leak) · `sample`/`per_image`/`none`/`fixed`
    (not data-fitted, leakage-free). `stage`: `before_split` / `after_split`.
    
    **Declare the fit scope of resampling too.** A target spacing you chose in advance is fixed and
    never leaks (`fit_scope: fixed`). A target *derived* from the cohort does: nnU-Net sets its target
    spacing from a percentile of the dataset fingerprint, so a resample fitted over every case carries
    held-out geometry into the training grid exactly as an intensity statistic would. Which one you
    have is decided by the fingerprint's scope, not by the word "resample".
    
    ### Phase 4 — Gate the manifest (deterministic)
    ```bash
    python3 scripts/check_preprocessing_leakage.py --manifest preprocessing_manifest.json --strict
    ```
    
    That gate asks whether a transform was fit on the right **scope**. Before an *inference* run on a
    cohort the model was not trained on, ask the other question — is that cohort in the intensity
    **domain** the trained normaliser assumes?
    
    ```bash
    python3 scripts/check_normalizer_domain.py \
        --profile eda/<cohort>_profile.json \
        --contract work/nnUNet_results/.../plans.json \
        --splits external_mri --out qc/normalizer_domain.json --strict
    ```
    Verdicts: `PREPROCESS_BEFORE_SPLIT`, `NORMALIZATION_LEAKAGE`, `PATIENT_CROSS_SPLIT` (Major);
    `AUGMENTATION_ON_EVAL`, `UNSPECIFIED_FIT_SCOPE`, `MISSING_SEED` (Minor). The verdict is reproduced
    by set arithmetic + rule on the manifest, never asserted from prose. A green gate is a precondition
    for handing the manifest to `/model-scaffold`.
    
    ## Integration
    - **Feeds `/model-scaffold`** — the audited manifest is the scaffold's preprocessing input; its
      `split_assignment` is the same patient-level split `/model-validation` later re-verifies.
    - **`/self-review`** `model_development` probe audits data-stage leakage in a finished manuscript;
      this skill *produces* the leakage-safe pipeline it looks for.
    - **`/check-reporting`** — the manifest documents the CLAIM 2024 / TRIPOD+AI data-preprocessing items.
    
    ## Anti-Hallucination
    
    - **Never fabricate image statistics, patient IDs, or split assignments.** Every value in the
      manifest comes from the real data manifest and the researcher's declared pipeline — never invented.
      This skill designs and audits the plan; it does not run preprocessing on real patient data or
      synthesise the images it describes.
    - **Never report a preprocessing-audit "pass" without running `check_preprocessing_leakage.py`.** The
      leakage verdict is reproduced deterministically (rule + set arithmetic on the manifest), never
      asserted from prose.
    - **Never label a dataset-fitted transform as per-sample to clear the gate.** The manifest's
      `type` / `fit_scope` / `stage` must describe what the code actually does; a mislabelled transform
      hides a real leak the gate would otherwise catch.
    - **Integrate, don't reimplement.** Reference MONAI / TorchIO transforms; do not write a new
      normalisation/resampling implementation or claim results for one.
    
    ## Reproducible challenge
    `scripts/check_normalizer_domain_challenge/` ships a synthetic profile/contract triple: a cohort in
    the contract's own domain that must come back **clean** (the false-positive guard), an arbitrary-unit
    cohort that must raise a Major, and an unreadable contract that must **refuse** rather than pass.
    
    `scripts/check_preprocessing_leakage_challenge/` ships a synthetic leak/clean manifest pair with a
    network-free `verify.sh` wired into the skill's validation commands.
    
  • skill.yml 3.4 KB
    schema_version: 2
    name: preprocess-imaging
    layer: D
    owner_domain: model_validation
    maturity: official
    
    when_to_use: "Design or audit the data-preparation stage of a medical-imaging model — DICOM/NIfTI intake, resampling and intensity normalisation, and the augmentation plan — so the pipeline is leakage-safe BEFORE model-scaffold builds the training repo. Emits a declarative preprocessing manifest and a deterministic data-stage leakage gate that catches the leaks the split table cannot see: a dataset-level normaliser fit on non-train data, any data-fitted transform run before the split, and the same patient's slices crossing splits."
    when_NOT_to_use: "Auditing the train/val/test split table itself (use model-validation's split-leakage gate); scaffolding the training repo or model code (use model-scaffold — it consumes this manifest); choosing the architecture (use architecture-zoo); computing held-out metrics or calibration (use model-evaluation then analyze-stats); LLM/MLLM data handling (use mllm-eval); reimplementing MONAI/TorchIO transforms (out of scope — this skill wires and audits them, never rebuilds them)."
    
    inputs:
      - "data manifest (one row per image/slice with a patient/subject ID) and modality"
      - "intended preprocessing steps (resample spacing, intensity window/normalisation, augmentation plan)"
      - "split-assignment (patient/subject ID + train/val/test), when available, for the slice-level leakage check"
    outputs:
      - "a declarative preprocessing manifest (JSON: transforms with type/fit_scope/stage + split_assignment + split_seed) that model-scaffold consumes"
      - "preprocessing-leakage audit JSON (deterministic)"
      - "modality-aware augmentation-appropriateness notes (physiology-preserving vs breaking) and a normalisation fit-scope recommendation"
    deterministic_scripts:
      - scripts/check_preprocessing_leakage.py
    side_effects:
      - writes_decision_notes
    downstream_consumers:
      - model-scaffold
      - model-validation
      - check-reporting
      - self-review
    forbidden_actions:
      - fabricate_image_statistics_or_split_assignments
      - approve_a_pipeline_that_fits_normalisation_on_non_train_data
      - report_a_preprocessing_audit_pass_without_running_the_detector
      - reimplement_MONAI_or_TorchIO_transforms
    
    # v2.1 quality card
    purpose: "Catch data-stage leakage in a medical-imaging pipeline — a normaliser fit on the test distribution, a data-fitted transform run before the split, or a patient's slices spread across splits — before it silently inflates every downstream metric."
    safety_boundaries:
      - "Advisory plus deterministic-audit only: never alters images, statistics, or split assignments."
      - "The leakage verdict is reproduced by a stdlib script (rule + set arithmetic on the manifest), never asserted from prose."
      - "Integrates MONAI / TorchIO transforms by reference; it does not reimplement them and never runs preprocessing on real patient data."
    known_limitations:
      - "Audits the declared manifest, not the executed code; a transform mislabelled in the manifest (e.g. a dataset normaliser tagged fit_scope=sample) can hide a real leak."
      - "A clean data-stage audit is necessary, not sufficient — the split table (model-validation) and held-out evaluation (model-evaluation) still apply."
    validation_commands:
      - "python3 scripts/check_preprocessing_leakage.py --manifest <preprocessing_manifest.json> --strict"
      - "bash scripts/check_preprocessing_leakage_challenge/verify.sh  # deterministic, network-free"
    evidence_surface: ci_validator
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related