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 +21 -0
- msp_sc-0.2.0/PKG-INFO +223 -0
- msp_sc-0.2.0/README.md +193 -0
- msp_sc-0.2.0/msp/__init__.py +35 -0
- msp_sc-0.2.0/msp/__main__.py +122 -0
- msp_sc-0.2.0/msp/agent_util.py +70 -0
- msp_sc-0.2.0/msp/annotate.py +632 -0
- msp_sc-0.2.0/msp/inspect.py +601 -0
- msp_sc-0.2.0/msp/integrate.py +890 -0
- msp_sc-0.2.0/msp/plots.py +138 -0
- msp_sc-0.2.0/msp/report.py +751 -0
- msp_sc-0.2.0/msp_sc.egg-info/PKG-INFO +223 -0
- msp_sc-0.2.0/msp_sc.egg-info/SOURCES.txt +16 -0
- msp_sc-0.2.0/msp_sc.egg-info/dependency_links.txt +1 -0
- msp_sc-0.2.0/msp_sc.egg-info/requires.txt +16 -0
- msp_sc-0.2.0/msp_sc.egg-info/top_level.txt +1 -0
- msp_sc-0.2.0/pyproject.toml +41 -0
- msp_sc-0.2.0/setup.cfg +4 -0
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
|