msp-sc 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
msp_sc-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 chansigit
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
msp_sc-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,223 @@
1
+ Metadata-Version: 2.4
2
+ Name: msp-sc
3
+ Version: 0.2.0
4
+ Summary: Multi-sample-pipeline: harmony integration of osp per-sample outputs, cluster QC inspection and cell-type annotation agents, self-contained HTML report
5
+ Author-email: chansigit <chansigit@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/chansigit/msp
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Intended Audience :: Science/Research
10
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: scanpy
15
+ Requires-Dist: anndata
16
+ Requires-Dist: igraph
17
+ Requires-Dist: harmonypy==0.2.0
18
+ Requires-Dist: torch
19
+ Requires-Dist: standissect-lite>=0.2.0
20
+ Requires-Dist: pandas
21
+ Requires-Dist: numpy
22
+ Requires-Dist: scipy
23
+ Requires-Dist: scikit-learn
24
+ Requires-Dist: matplotlib
25
+ Requires-Dist: seaborn
26
+ Requires-Dist: adjustText
27
+ Provides-Extra: agent
28
+ Requires-Dist: claude-agent-sdk; extra == "agent"
29
+ Dynamic: license-file
30
+
31
+ # msp — multi-sample-pipeline
32
+
33
+ Integrates the per-sample outputs of [osp](https://github.com/chansigit/osp)
34
+ (one `clustered.h5ad` per 10x run) into one harmony-corrected space, then
35
+ runs two optional Claude-agent steps on the result: a per-cluster QC
36
+ **inspection** (proposals only) and a cell-type **annotation** (coarse/fine
37
+ labels, explicit merges, real removal). Every step writes a self-contained
38
+ `report.html` (all figures base64-embedded) that the next step reads.
39
+
40
+ ```
41
+ osp per-sample ──▶ integrate ──▶ inspect ──▶ annotate
42
+ propose-only propose-only removes cells
43
+ integrated.h5ad annotated.h5ad
44
+ ```
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install msp-sc # PyPI name; `import msp`
50
+ # with the agent steps (needs claude-agent-sdk + Claude Code CLI credentials):
51
+ pip install "msp-sc[agent]" # + claude-agent-sdk for inspect / annotate
52
+ ```
53
+
54
+ Dependencies of note: `harmonypy>=0.2.0` (the torch-based fork — set
55
+ `MSP_DEVICE=cpu|cuda|mps` to override device auto-detection) and
56
+ `standissect-lite` (minor-sibling fragment detection inside clusters).
57
+
58
+ ## Quick usage
59
+
60
+ ```bash
61
+ # integration + report
62
+ python -m msp A/clustered.h5ad B/clustered.h5ad --batch-col project --outdir msp_out --species human
63
+
64
+ # the whole chain (integration → inspection agent → annotation agent)
65
+ python -m msp A/clustered.h5ad B/clustered.h5ad --batch-col project --outdir msp_out \
66
+ --species human --annotate --model claude-sonnet-5
67
+
68
+ # re-integrate one merged h5ad (e.g. a previous round's survivors) instead of per-sample inputs
69
+ python -m msp --from-h5ad prev_round/annotated_zmip.h5ad --batch-col project --outdir msp_out2 --annotate --model claude-sonnet-5
70
+
71
+ # individual steps
72
+ python -m msp.inspect msp_out --model claude-sonnet-5 # QC verdicts
73
+ python -m msp.annotate msp_out --model claude-sonnet-5 # identity + merges + removal (after inspect)
74
+ python -m msp.report msp_out # rebuild report.html only
75
+ ```
76
+
77
+ Re-running the same `python -m msp` command resumes: a step is skipped when
78
+ its contract files already exist (`integrated.h5ad`+`report.html`,
79
+ `inspection_proposal.json`, `annotation_proposal.json`+`annotated.h5ad`);
80
+ `--force` redoes everything.
81
+
82
+ ```python
83
+ from msp import run_multi_sample_pipeline, generate_report
84
+ run_multi_sample_pipeline(["A/clustered.h5ad", "B/clustered.h5ad"], batch_col="project", outdir="msp_out")
85
+ generate_report("msp_out")
86
+
87
+ from msp.inspect import inspect_clusters # optional agent steps
88
+ from msp.annotate import annotate_clusters
89
+ ```
90
+
91
+ ## The three steps
92
+
93
+ ### 1. integrate (`msp.integrate`, propose-only)
94
+
95
+ concat (hard checks: identical gene axis, one batch value per file,
96
+ globally unique barcodes, no cell lost) → normalize from raw
97
+ `layers["counts"]` → per-batch HVG → scale/PCA on the merged cells → harmony
98
+ → neighbors on `X_pca_harmony` → leiden at each resolution (`msp_leiden_r*`,
99
+ default 0.3/1.0/2.0) → UMAP → standissect-lite fragments on the coarsest
100
+ resolution → QC/DEG artifacts → `integrated.h5ad` + `report.html`.
101
+
102
+ Removal *candidates* are computed but never applied:
103
+
104
+ - `minor_sibling_qc.csv` — standissect fragments failing QC against their parent;
105
+ - `cell_outliers.csv` — per-cluster doublet/ambient outliers (cell flagged only
106
+ when it clears BOTH gates: cluster median + 3×MAD **and** an absolute floor
107
+ of 0.5; OR across metrics and across r1.0/r2.0);
108
+ - osp's own per-sample `_qc_action == "drop"` cells;
109
+ - their union is `preannotation_removal.csv` and the "Pre-annotation
110
+ filtering" UMAP. The precomputed DEG tables (`deg_global_*` one-vs-rest,
111
+ `deg_local_*` vs the 3 nearest PAGA neighbours, at r1.0 and r2.0) exclude
112
+ these cells; `stress_clusters.csv` flags clusters whose top genes are a
113
+ dissociation-stress/mitochondrial signature.
114
+
115
+ ### 2. inspect (`msp.inspect`, propose-only)
116
+
117
+ One agent session puts every r1.0 cluster through the five-test battery
118
+ (markers / QC axis / composition / geometry / stability) with live tools
119
+ (`check_genes`, `check_qc_scores`, `check_stability`, `check_deg`,
120
+ `subcluster`). Output: `inspection_proposal.json`, `inspection_notes.md`,
121
+ `obs["_msp_action"]` (keep/flag/drop) and `obs["_msp_verdict"]` written
122
+ into `integrated.h5ad`, the verdict UMAP. Live DEG excludes the
123
+ pre-annotation removal set, matching the precomputed tables.
124
+
125
+ ### 3. annotate (`msp.annotate`, removes cells)
126
+
127
+ One agent session annotates every **r2.0** cluster. Coverage is enforced
128
+ twice: the agent keeps one Claude Code Task per cluster
129
+ (TaskCreate/TaskUpdate/TaskList), and the host refuses `finalize_annotation`
130
+ until every cluster has a validated `submit_cluster`. Per cluster the agent
131
+ answers a fixed reasoning chain — (1) distinct entity or splinter of its
132
+ r1.0 parent/siblings, (2) coarse + fine label, or noise/low-quality →
133
+ remove, (3) merge target or keep separate — using `cluster_context`
134
+ (parent/siblings/PAGA neighbours/sample composition/QC/inspect verdict/prior
135
+ label compositions), `check_genes` and `check_deg`. Prior label columns
136
+ (osp's `_ann_coarse`/`_ann_fine`, the authors' own cell-type columns) are
137
+ detected, not assumed, and shown as reference evidence only.
138
+
139
+ Merge decisions are made in one session and validated deterministically on
140
+ the host (union-find over `merge_target`; a merged group shares one
141
+ coarse/fine label; one fine label belongs to one coarse label; equal fine
142
+ labels must be merged explicitly; nothing merges into a removed cluster) —
143
+ no separate harmonization pass.
144
+
145
+ Removal is real at this step: removed = `preannotation_removal.csv` ∪
146
+ inspect drop ∪ agent-removed clusters, archived per cell with sources in
147
+ `annotation_removed.csv`. `annotated.h5ad` keeps the survivors with
148
+ `msp_ann_cluster` (merged id, e.g. `1+2+4`), `msp_ann_coarse`,
149
+ `msp_ann_fine`, `msp_ann_action`; `integrated.h5ad` is left untouched.
150
+
151
+ ## Output directory
152
+
153
+ | file | written by | what |
154
+ |---|---|---|
155
+ | `integrated.h5ad` | integrate (+inspect adds `_msp_*`) | all cells, harmony space, `msp_leiden_r*`, `standissect_product` |
156
+ | `report.html` | every step | self-contained report: Sample Summary · UMAPs · Per-cluster QC (standissect) · Leiden Cluster QC · Cluster Annotations (DEG) · Cell Type Annotation |
157
+ | `integration_summary.csv`, `per_sample_qc.csv`, `sample_decisions.csv`* | integrate | sample-level tables (*optional, written by the caller) |
158
+ | `cluster_qc_*.csv`, `cell_outliers.csv`, `cell_outlier_summary.csv` | integrate | per-cluster / per-cell QC |
159
+ | `fragments_*.csv`, `overlap_*.csv`, `minor_sibling_qc.csv`, `fractal_markers.csv` | integrate | standissect-lite bundle |
160
+ | `deg_global_*.csv`, `deg_local_*.csv`, `paga_neighbors_*.csv`, `stress_clusters.csv` | integrate | DEG at r1.0/r2.0 |
161
+ | `preannotation_removal.csv` | integrate | union of removal candidates (cell, recommend_removal) |
162
+ | `inspection_proposal.json`, `inspection_notes.md` | inspect | five-test verdicts per cluster |
163
+ | `annotation_proposal.json`, `annotation_notes.md` | annotate | per-cluster labels, merges, evidence, merged groups |
164
+ | `annotation_removed.csv` | annotate | every removed cell with its sources |
165
+ | `annotated.h5ad` | annotate | survivors with `msp_ann_*` columns |
166
+ | `figures/*.png` | all | one signal per file, fixed UMAP geometry |
167
+
168
+ ## Conventions
169
+
170
+ - **Propose, never remove** until `annotate`: `integrate` and `inspect` add
171
+ columns and CSVs, never drop cells; computation-only exclusions (DEG) are
172
+ documented where they happen.
173
+ - Inherited per-sample obs columns (QC metrics, `_ann_*`, `_qc_action`,
174
+ doublet calls) ride along; sample-local leiden labels are prefixed with
175
+ the sample value (`H12inner:3`). Per-sample embeddings/uns/layers are
176
+ dropped — only raw counts travel; everything integrated is recomputed.
177
+ - Doublet detection is NOT rerun: it belongs to the per-sample stage.
178
+ - `checkpoint`-style writes: h5ad files are written to `*.tmp.h5ad` and
179
+ renamed, never in place.
180
+ - All heavy computation lives in `msp.integrate`; `msp.report` only renders
181
+ artifacts already on disk, so `python -m msp.report` is always safe.
182
+
183
+ Driven in production by `ecarsi.crosssample`, which decides which samples
184
+ enter integration (agent decision, archived) before calling this package.
185
+
186
+ ## Tuning integration
187
+
188
+ `python -m msp` exposes the integration knobs; the Python entry point takes
189
+ the same names as keyword arguments (`run_multi_sample_pipeline(...,
190
+ n_top_genes=, n_pcs=, n_neighbors=, resolutions=, harmony_kwargs={...})`).
191
+
192
+ | knob | default | CLI |
193
+ |---|---|---|
194
+ | HVGs per batch | 2000 | `--n-top-genes` |
195
+ | PCs | 50 | `--n-pcs` |
196
+ | kNN neighbours (on `X_pca_harmony`) | 15 | `--n-neighbors` |
197
+ | leiden resolutions | 0.3 1.0 2.0 | `--resolutions` (1.0 and 2.0 required by inspect/annotate) |
198
+ | harmony | harmonypy defaults | `--harmony KEY=VALUE` (repeatable) |
199
+
200
+ Harmony is called as `harmonypy.run_harmony(X_pca, obs[[batch_col]],
201
+ batch_col, random_state=0, device=<auto or $MSP_DEVICE>, **harmony_kwargs)`;
202
+ anything not overridden is harmonypy's default:
203
+
204
+ | harmony parameter | default | meaning |
205
+ |---|---|---|
206
+ | `theta` | 2 (per covariate) | diversity penalty — higher = stronger mixing across batches |
207
+ | `lamb` | 1 | ridge penalty on the correction; `-1` = auto-estimate (R behaviour, uses `alpha`=0.2) |
208
+ | `sigma` | 0.1 | soft k-means width — larger = softer cluster assignment |
209
+ | `nclust` | min(round(N/30), 100) | number of harmony clusters |
210
+ | `tau` | 0 | discounting for small batches (expected cells per cluster) |
211
+ | `block_size` | 0.05 | fraction of cells updated per block |
212
+ | `max_iter_harmony` | 10 | outer iterations |
213
+ | `max_iter_kmeans` | 20 | inner clustering iterations |
214
+ | `epsilon_cluster` / `epsilon_harmony` | 1e-5 / 1e-4 | convergence tolerances |
215
+
216
+ Example: gentler correction that keeps more within-batch structure and runs longer:
217
+
218
+ ```bash
219
+ python -m msp ... --harmony theta=1 --harmony max_iter_harmony=20
220
+ ```
221
+
222
+ The effective overrides are recorded in `uns["msp"]["harmony"]` of
223
+ `integrated.h5ad` (empty = all defaults).
msp_sc-0.2.0/README.md ADDED
@@ -0,0 +1,193 @@
1
+ # msp — multi-sample-pipeline
2
+
3
+ Integrates the per-sample outputs of [osp](https://github.com/chansigit/osp)
4
+ (one `clustered.h5ad` per 10x run) into one harmony-corrected space, then
5
+ runs two optional Claude-agent steps on the result: a per-cluster QC
6
+ **inspection** (proposals only) and a cell-type **annotation** (coarse/fine
7
+ labels, explicit merges, real removal). Every step writes a self-contained
8
+ `report.html` (all figures base64-embedded) that the next step reads.
9
+
10
+ ```
11
+ osp per-sample ──▶ integrate ──▶ inspect ──▶ annotate
12
+ propose-only propose-only removes cells
13
+ integrated.h5ad annotated.h5ad
14
+ ```
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install msp-sc # PyPI name; `import msp`
20
+ # with the agent steps (needs claude-agent-sdk + Claude Code CLI credentials):
21
+ pip install "msp-sc[agent]" # + claude-agent-sdk for inspect / annotate
22
+ ```
23
+
24
+ Dependencies of note: `harmonypy>=0.2.0` (the torch-based fork — set
25
+ `MSP_DEVICE=cpu|cuda|mps` to override device auto-detection) and
26
+ `standissect-lite` (minor-sibling fragment detection inside clusters).
27
+
28
+ ## Quick usage
29
+
30
+ ```bash
31
+ # integration + report
32
+ python -m msp A/clustered.h5ad B/clustered.h5ad --batch-col project --outdir msp_out --species human
33
+
34
+ # the whole chain (integration → inspection agent → annotation agent)
35
+ python -m msp A/clustered.h5ad B/clustered.h5ad --batch-col project --outdir msp_out \
36
+ --species human --annotate --model claude-sonnet-5
37
+
38
+ # re-integrate one merged h5ad (e.g. a previous round's survivors) instead of per-sample inputs
39
+ python -m msp --from-h5ad prev_round/annotated_zmip.h5ad --batch-col project --outdir msp_out2 --annotate --model claude-sonnet-5
40
+
41
+ # individual steps
42
+ python -m msp.inspect msp_out --model claude-sonnet-5 # QC verdicts
43
+ python -m msp.annotate msp_out --model claude-sonnet-5 # identity + merges + removal (after inspect)
44
+ python -m msp.report msp_out # rebuild report.html only
45
+ ```
46
+
47
+ Re-running the same `python -m msp` command resumes: a step is skipped when
48
+ its contract files already exist (`integrated.h5ad`+`report.html`,
49
+ `inspection_proposal.json`, `annotation_proposal.json`+`annotated.h5ad`);
50
+ `--force` redoes everything.
51
+
52
+ ```python
53
+ from msp import run_multi_sample_pipeline, generate_report
54
+ run_multi_sample_pipeline(["A/clustered.h5ad", "B/clustered.h5ad"], batch_col="project", outdir="msp_out")
55
+ generate_report("msp_out")
56
+
57
+ from msp.inspect import inspect_clusters # optional agent steps
58
+ from msp.annotate import annotate_clusters
59
+ ```
60
+
61
+ ## The three steps
62
+
63
+ ### 1. integrate (`msp.integrate`, propose-only)
64
+
65
+ concat (hard checks: identical gene axis, one batch value per file,
66
+ globally unique barcodes, no cell lost) → normalize from raw
67
+ `layers["counts"]` → per-batch HVG → scale/PCA on the merged cells → harmony
68
+ → neighbors on `X_pca_harmony` → leiden at each resolution (`msp_leiden_r*`,
69
+ default 0.3/1.0/2.0) → UMAP → standissect-lite fragments on the coarsest
70
+ resolution → QC/DEG artifacts → `integrated.h5ad` + `report.html`.
71
+
72
+ Removal *candidates* are computed but never applied:
73
+
74
+ - `minor_sibling_qc.csv` — standissect fragments failing QC against their parent;
75
+ - `cell_outliers.csv` — per-cluster doublet/ambient outliers (cell flagged only
76
+ when it clears BOTH gates: cluster median + 3×MAD **and** an absolute floor
77
+ of 0.5; OR across metrics and across r1.0/r2.0);
78
+ - osp's own per-sample `_qc_action == "drop"` cells;
79
+ - their union is `preannotation_removal.csv` and the "Pre-annotation
80
+ filtering" UMAP. The precomputed DEG tables (`deg_global_*` one-vs-rest,
81
+ `deg_local_*` vs the 3 nearest PAGA neighbours, at r1.0 and r2.0) exclude
82
+ these cells; `stress_clusters.csv` flags clusters whose top genes are a
83
+ dissociation-stress/mitochondrial signature.
84
+
85
+ ### 2. inspect (`msp.inspect`, propose-only)
86
+
87
+ One agent session puts every r1.0 cluster through the five-test battery
88
+ (markers / QC axis / composition / geometry / stability) with live tools
89
+ (`check_genes`, `check_qc_scores`, `check_stability`, `check_deg`,
90
+ `subcluster`). Output: `inspection_proposal.json`, `inspection_notes.md`,
91
+ `obs["_msp_action"]` (keep/flag/drop) and `obs["_msp_verdict"]` written
92
+ into `integrated.h5ad`, the verdict UMAP. Live DEG excludes the
93
+ pre-annotation removal set, matching the precomputed tables.
94
+
95
+ ### 3. annotate (`msp.annotate`, removes cells)
96
+
97
+ One agent session annotates every **r2.0** cluster. Coverage is enforced
98
+ twice: the agent keeps one Claude Code Task per cluster
99
+ (TaskCreate/TaskUpdate/TaskList), and the host refuses `finalize_annotation`
100
+ until every cluster has a validated `submit_cluster`. Per cluster the agent
101
+ answers a fixed reasoning chain — (1) distinct entity or splinter of its
102
+ r1.0 parent/siblings, (2) coarse + fine label, or noise/low-quality →
103
+ remove, (3) merge target or keep separate — using `cluster_context`
104
+ (parent/siblings/PAGA neighbours/sample composition/QC/inspect verdict/prior
105
+ label compositions), `check_genes` and `check_deg`. Prior label columns
106
+ (osp's `_ann_coarse`/`_ann_fine`, the authors' own cell-type columns) are
107
+ detected, not assumed, and shown as reference evidence only.
108
+
109
+ Merge decisions are made in one session and validated deterministically on
110
+ the host (union-find over `merge_target`; a merged group shares one
111
+ coarse/fine label; one fine label belongs to one coarse label; equal fine
112
+ labels must be merged explicitly; nothing merges into a removed cluster) —
113
+ no separate harmonization pass.
114
+
115
+ Removal is real at this step: removed = `preannotation_removal.csv` ∪
116
+ inspect drop ∪ agent-removed clusters, archived per cell with sources in
117
+ `annotation_removed.csv`. `annotated.h5ad` keeps the survivors with
118
+ `msp_ann_cluster` (merged id, e.g. `1+2+4`), `msp_ann_coarse`,
119
+ `msp_ann_fine`, `msp_ann_action`; `integrated.h5ad` is left untouched.
120
+
121
+ ## Output directory
122
+
123
+ | file | written by | what |
124
+ |---|---|---|
125
+ | `integrated.h5ad` | integrate (+inspect adds `_msp_*`) | all cells, harmony space, `msp_leiden_r*`, `standissect_product` |
126
+ | `report.html` | every step | self-contained report: Sample Summary · UMAPs · Per-cluster QC (standissect) · Leiden Cluster QC · Cluster Annotations (DEG) · Cell Type Annotation |
127
+ | `integration_summary.csv`, `per_sample_qc.csv`, `sample_decisions.csv`* | integrate | sample-level tables (*optional, written by the caller) |
128
+ | `cluster_qc_*.csv`, `cell_outliers.csv`, `cell_outlier_summary.csv` | integrate | per-cluster / per-cell QC |
129
+ | `fragments_*.csv`, `overlap_*.csv`, `minor_sibling_qc.csv`, `fractal_markers.csv` | integrate | standissect-lite bundle |
130
+ | `deg_global_*.csv`, `deg_local_*.csv`, `paga_neighbors_*.csv`, `stress_clusters.csv` | integrate | DEG at r1.0/r2.0 |
131
+ | `preannotation_removal.csv` | integrate | union of removal candidates (cell, recommend_removal) |
132
+ | `inspection_proposal.json`, `inspection_notes.md` | inspect | five-test verdicts per cluster |
133
+ | `annotation_proposal.json`, `annotation_notes.md` | annotate | per-cluster labels, merges, evidence, merged groups |
134
+ | `annotation_removed.csv` | annotate | every removed cell with its sources |
135
+ | `annotated.h5ad` | annotate | survivors with `msp_ann_*` columns |
136
+ | `figures/*.png` | all | one signal per file, fixed UMAP geometry |
137
+
138
+ ## Conventions
139
+
140
+ - **Propose, never remove** until `annotate`: `integrate` and `inspect` add
141
+ columns and CSVs, never drop cells; computation-only exclusions (DEG) are
142
+ documented where they happen.
143
+ - Inherited per-sample obs columns (QC metrics, `_ann_*`, `_qc_action`,
144
+ doublet calls) ride along; sample-local leiden labels are prefixed with
145
+ the sample value (`H12inner:3`). Per-sample embeddings/uns/layers are
146
+ dropped — only raw counts travel; everything integrated is recomputed.
147
+ - Doublet detection is NOT rerun: it belongs to the per-sample stage.
148
+ - `checkpoint`-style writes: h5ad files are written to `*.tmp.h5ad` and
149
+ renamed, never in place.
150
+ - All heavy computation lives in `msp.integrate`; `msp.report` only renders
151
+ artifacts already on disk, so `python -m msp.report` is always safe.
152
+
153
+ Driven in production by `ecarsi.crosssample`, which decides which samples
154
+ enter integration (agent decision, archived) before calling this package.
155
+
156
+ ## Tuning integration
157
+
158
+ `python -m msp` exposes the integration knobs; the Python entry point takes
159
+ the same names as keyword arguments (`run_multi_sample_pipeline(...,
160
+ n_top_genes=, n_pcs=, n_neighbors=, resolutions=, harmony_kwargs={...})`).
161
+
162
+ | knob | default | CLI |
163
+ |---|---|---|
164
+ | HVGs per batch | 2000 | `--n-top-genes` |
165
+ | PCs | 50 | `--n-pcs` |
166
+ | kNN neighbours (on `X_pca_harmony`) | 15 | `--n-neighbors` |
167
+ | leiden resolutions | 0.3 1.0 2.0 | `--resolutions` (1.0 and 2.0 required by inspect/annotate) |
168
+ | harmony | harmonypy defaults | `--harmony KEY=VALUE` (repeatable) |
169
+
170
+ Harmony is called as `harmonypy.run_harmony(X_pca, obs[[batch_col]],
171
+ batch_col, random_state=0, device=<auto or $MSP_DEVICE>, **harmony_kwargs)`;
172
+ anything not overridden is harmonypy's default:
173
+
174
+ | harmony parameter | default | meaning |
175
+ |---|---|---|
176
+ | `theta` | 2 (per covariate) | diversity penalty — higher = stronger mixing across batches |
177
+ | `lamb` | 1 | ridge penalty on the correction; `-1` = auto-estimate (R behaviour, uses `alpha`=0.2) |
178
+ | `sigma` | 0.1 | soft k-means width — larger = softer cluster assignment |
179
+ | `nclust` | min(round(N/30), 100) | number of harmony clusters |
180
+ | `tau` | 0 | discounting for small batches (expected cells per cluster) |
181
+ | `block_size` | 0.05 | fraction of cells updated per block |
182
+ | `max_iter_harmony` | 10 | outer iterations |
183
+ | `max_iter_kmeans` | 20 | inner clustering iterations |
184
+ | `epsilon_cluster` / `epsilon_harmony` | 1e-5 / 1e-4 | convergence tolerances |
185
+
186
+ Example: gentler correction that keeps more within-batch structure and runs longer:
187
+
188
+ ```bash
189
+ python -m msp ... --harmony theta=1 --harmony max_iter_harmony=20
190
+ ```
191
+
192
+ The effective overrides are recorded in `uns["msp"]["harmony"]` of
193
+ `integrated.h5ad` (empty = all defaults).
@@ -0,0 +1,35 @@
1
+ """msp (multi-sample-pipeline): integrate osp per-sample outputs (harmony)
2
+ → multi-resolution leiden + UMAP → cluster QC / DEG tables → self-contained
3
+ HTML report, with two optional Claude-agent steps that run afterwards:
4
+
5
+ integrate (msp.integrate) propose-only: nothing deleted, nothing named
6
+ inspect (msp.inspect) per-cluster five-test QC verdicts, proposals only
7
+ annotate (msp.annotate) coarse/fine cell identity on msp_leiden_r2.0,
8
+ explicit merges, REAL removal → annotated.h5ad
9
+
10
+ Entry points:
11
+ from msp import run_multi_sample_pipeline, generate_report
12
+
13
+ run_multi_sample_pipeline(["A/clustered.h5ad", "B/clustered.h5ad"],
14
+ batch_col="project", outdir="msp_out")
15
+ generate_report("msp_out")
16
+
17
+ Command line:
18
+ python -m msp A/clustered.h5ad B/clustered.h5ad --batch-col project --outdir msp_out
19
+ python -m msp ... --inspect --annotate --model claude-sonnet-5 # full chain
20
+ python -m msp.inspect msp_out # QC inspection agent only
21
+ python -m msp.annotate msp_out # annotation agent only (after inspect)
22
+ python -m msp.report msp_out # rebuild the report only
23
+
24
+ msp.inspect / msp.annotate are intentionally not imported here — they depend
25
+ on the optional claude-agent-sdk (`pip install "msp[agent]"`); use
26
+ `from msp.inspect import inspect_clusters` / `from msp.annotate import
27
+ annotate_clusters` when needed.
28
+ """
29
+
30
+ from .integrate import integrate_adata, load_and_merge, run_multi_sample_pipeline
31
+ from .plots import save_single_umap
32
+ from .report import generate_report
33
+
34
+ __all__ = ["integrate_adata", "load_and_merge", "run_multi_sample_pipeline", "generate_report",
35
+ "save_single_umap"]
@@ -0,0 +1,122 @@
1
+ """python -m msp: integrate osp per-sample outputs (concat → harmony → leiden →
2
+ UMAP → QC/DEG tables → HTML report), end to end.
3
+
4
+ With --inspect, the per-cluster QC inspection agent (msp.inspect) runs
5
+ afterwards; with --annotate, the cell-type annotation agent (msp.annotate)
6
+ runs after that (both need the optional claude-agent-sdk). Each step is
7
+ skipped when its contract file already exists, so re-running the same
8
+ command resumes where it stopped; --force redoes everything.
9
+ """
10
+
11
+ import argparse
12
+ import os
13
+ import sys
14
+
15
+ from .integrate import integrate_adata, run_multi_sample_pipeline
16
+ from .report import generate_report, write_report_context
17
+
18
+ parser = argparse.ArgumentParser(prog="msp", description=__doc__,
19
+ formatter_class=argparse.RawDescriptionHelpFormatter)
20
+ parser.add_argument("inputs", nargs="*", help="per-sample clustered.h5ad files (osp outputs)")
21
+ parser.add_argument("--from-h5ad", default=None, metavar="H5AD",
22
+ help="instead of per-sample inputs: one already-merged h5ad with layers['counts'] "
23
+ "(e.g. a previous round's annotated_zmip.h5ad) — re-integrated from scratch via "
24
+ "integrate_adata; prior obs columns ride along as annotation evidence")
25
+ parser.add_argument("--batch-col", required=True, help="obs column naming the sample/batch")
26
+ parser.add_argument("--outdir", required=True)
27
+ parser.add_argument("--species", default=None, help="stored in uns['msp']; context for the agents")
28
+ parser.add_argument("--resolutions", type=float, nargs="+", default=[0.3, 1.0, 2.0],
29
+ help="leiden resolutions; 1.0 and 2.0 must be present for inspect/annotate")
30
+ parser.add_argument("--n-top-genes", type=int, default=2000)
31
+ parser.add_argument("--n-pcs", type=int, default=50)
32
+ parser.add_argument("--n-neighbors", type=int, default=15)
33
+ parser.add_argument("--harmony", action="append", default=[], metavar="KEY=VALUE",
34
+ help="harmonypy.run_harmony override, repeatable: e.g. --harmony theta=1 "
35
+ "--harmony lamb=-1 --harmony max_iter_harmony=20 --harmony sigma=0.2 "
36
+ "(defaults: theta=2, lamb=1, sigma=0.1, nclust=min(N/30,100), "
37
+ "max_iter_harmony=10, max_iter_kmeans=20)")
38
+ parser.add_argument("--inspect", action="store_true",
39
+ help="after integration, run the per-cluster QC inspection agent (msp.inspect)")
40
+ parser.add_argument("--annotate", action="store_true",
41
+ help="after inspection, run the cell-type annotation agent (msp.annotate); "
42
+ "implies --inspect")
43
+ parser.add_argument("--language", default="English", help='agent prose language (default "English")')
44
+ parser.add_argument("--model", default=None, help='model for the agents, e.g. "claude-sonnet-5"')
45
+ parser.add_argument("--effort", default=None, choices=["low", "medium", "high", "xhigh", "max"],
46
+ help="reasoning effort for the agents (models that support it)")
47
+ parser.add_argument("--max-turns", type=int, default=None,
48
+ help="agent turn budget (defaults: inspect 100, annotate 200)")
49
+ parser.add_argument("--report-context", default=None, metavar="TEXT",
50
+ help='where this run sits, for report titles (e.g. "round 2 · fu2022-meniscus"); '
51
+ "persisted in <outdir>/report_context.txt so later report refreshes keep it")
52
+ parser.add_argument("--force", action="store_true", help="redo steps whose outputs already exist")
53
+ args = parser.parse_args()
54
+
55
+ if bool(args.inputs) == bool(args.from_h5ad):
56
+ sys.exit("give either per-sample inputs or --from-h5ad, not both / neither")
57
+ if args.annotate:
58
+ args.inspect = True
59
+ if args.inspect and not {1.0, 2.0} <= set(args.resolutions):
60
+ sys.exit("--inspect/--annotate need leiden resolutions 1.0 and 2.0 (see --resolutions)")
61
+
62
+ out = args.outdir
63
+ write_report_context(out, args.report_context)
64
+
65
+
66
+ def _parse_kv(items):
67
+ """KEY=VALUE → {key: number|list|str}; comma-separated values become lists."""
68
+ def conv(v):
69
+ for cast in (int, float):
70
+ try:
71
+ return cast(v)
72
+ except ValueError:
73
+ pass
74
+ return v
75
+ out = {}
76
+ for it in items:
77
+ if "=" not in it:
78
+ sys.exit(f"--harmony expects KEY=VALUE, got {it!r}")
79
+ k, v = it.split("=", 1)
80
+ out[k.strip()] = [conv(x) for x in v.split(",")] if "," in v else conv(v)
81
+ return out
82
+
83
+
84
+ harmony_kwargs = _parse_kv(args.harmony)
85
+
86
+
87
+ def _done(*names):
88
+ return all(os.path.exists(os.path.join(out, n)) for n in names)
89
+
90
+
91
+ if args.force or not _done("integrated.h5ad", "report.html"):
92
+ kw = dict(species=args.species, resolutions=tuple(args.resolutions), n_top_genes=args.n_top_genes,
93
+ n_pcs=args.n_pcs, n_neighbors=args.n_neighbors, harmony_kwargs=harmony_kwargs)
94
+ if args.from_h5ad:
95
+ import scanpy as sc
96
+
97
+ ad = sc.read_h5ad(args.from_h5ad)
98
+ _, summary = integrate_adata(ad, args.batch_col, out, inputs=[args.from_h5ad], **kw)
99
+ else:
100
+ _, summary = run_multi_sample_pipeline(args.inputs, batch_col=args.batch_col, outdir=out, **kw)
101
+ print(summary)
102
+ print(f"report: {generate_report(out)}")
103
+ else:
104
+ print(f"[resume] integration already done in {out} (integrated.h5ad + report.html) — skipping")
105
+
106
+ agent_kw = dict(species=args.species, language=args.language, model=args.model, effort=args.effort)
107
+
108
+ if args.inspect:
109
+ if args.force or not _done("inspection_proposal.json"):
110
+ from .inspect import inspect_clusters
111
+
112
+ inspect_clusters(out, max_turns=args.max_turns or 100, **agent_kw)
113
+ else:
114
+ print(f"[resume] inspection_proposal.json exists in {out} — skipping inspect")
115
+
116
+ if args.annotate:
117
+ if args.force or not _done("annotation_proposal.json", "annotated.h5ad"):
118
+ from .annotate import annotate_clusters
119
+
120
+ annotate_clusters(out, max_turns=args.max_turns or 200, **agent_kw)
121
+ else:
122
+ print(f"[resume] annotation_proposal.json + annotated.h5ad exist in {out} — skipping annotate")
@@ -0,0 +1,70 @@
1
+ """Shared agent-call helper for every claude-agent-sdk session in msp / zmip.
2
+
3
+ The CLI itself retries transient API errors (429 / overloaded / 5xx) with
4
+ backoff, but a subscription usage window that is used up ("Claude usage
5
+ limit reached …") ends the session with an error result and the CLI does
6
+ NOT wait for the reset. A self-driving loop must not stop for that: this
7
+ wrapper recognises limit-type failures and re-runs the whole query after a
8
+ wait (session state is in-memory on the host — submitted entries persist,
9
+ the agent simply starts its investigation again), bounded by a total wait
10
+ budget. Any other failure is raised immediately.
11
+
12
+ async for message in run_query(prompt, options, label="inspect"):
13
+ ...
14
+
15
+ Env: AGENT_LIMIT_WAIT_MIN (minutes between retries, default 10),
16
+ AGENT_LIMIT_WAIT_MAX_H (total hours to keep waiting, default 12).
17
+ """
18
+
19
+ import asyncio
20
+ import os
21
+ import re
22
+ import time
23
+
24
+ LIMIT_PATTERN = re.compile(
25
+ r"usage limit|rate[ _-]?limit|limit will reset|resets at|too many requests|overloaded|"
26
+ r"quota|429|capacity|out of extra usage|spend limit",
27
+ re.IGNORECASE,
28
+ )
29
+
30
+
31
+ def is_limit_error(text) -> bool:
32
+ return bool(text) and bool(LIMIT_PATTERN.search(str(text)))
33
+
34
+
35
+ class AgentLimitExhausted(RuntimeError):
36
+ pass
37
+
38
+
39
+ async def run_query(prompt, options, label="agent"):
40
+ """Yield the SDK's messages exactly like query(); if the run ends in a
41
+ limit-type error, wait and start over (bounded)."""
42
+ from claude_agent_sdk import ResultMessage, query
43
+
44
+ wait_min = float(os.environ.get("AGENT_LIMIT_WAIT_MIN", "10"))
45
+ max_h = float(os.environ.get("AGENT_LIMIT_WAIT_MAX_H", "12"))
46
+ waited = 0.0
47
+ attempt = 0
48
+ while True:
49
+ attempt += 1
50
+ limit_hit = None
51
+ try:
52
+ async for message in query(prompt=prompt, options=options):
53
+ if isinstance(message, ResultMessage) and getattr(message, "is_error", False) \
54
+ and is_limit_error(getattr(message, "result", "")):
55
+ limit_hit = message.result
56
+ continue # swallow: the retry below replaces this result
57
+ yield message
58
+ except Exception as e: # transport-level failure carrying a limit message
59
+ if not is_limit_error(str(e)):
60
+ raise
61
+ limit_hit = str(e)
62
+ if limit_hit is None:
63
+ return
64
+ if waited / 3600 >= max_h:
65
+ raise AgentLimitExhausted(f"[{label}] usage limit still in force after {waited / 3600:.1f} h: {limit_hit}")
66
+ print(f"== [{label}] usage/rate limit (attempt {attempt}): {str(limit_hit)[:160]!r} — "
67
+ f"waiting {wait_min:.0f} min, {max_h - waited / 3600:.1f} h of wait budget left", flush=True)
68
+ t0 = time.time()
69
+ await asyncio.sleep(wait_min * 60)
70
+ waited += time.time() - t0