Skip to content

convert: read and write MuData (.h5mu) (#31) - #72

Merged
Claptar merged 11 commits into
mainfrom
feat/convert-mudata
Oct 23, 2026
Merged

Claptar merged 11 commits into
mainfrom
feat/convert-mudata

Conversation

@Claptar

@Claptar Claptar commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Part of #31. MuData — muon, mudata, SCENIC+ — in and out of adata convert.

Reading (adata convert x.h5mu -o x.h5ad): one AnnData per modality, to x.<mod>.h5ad (or the one --mod names), from an .h5mu or a MuData Zarr store.

  • The modality is copied as stored; then the global obs/var columns, obsm/varm, obsp/varp and uns are taken onto its rows through obsmap/varmap, in the modality's order, a block at a time. Graphs are remapped as they stream and rebuilt through csr_build's on-disk buckets, CSR or CSC kept.
  • A global name the modality already has keeps the modality's own, with a warning (on the shared axis; on the stacked axis such columns are copies muon/mudata pulled, so silently skipped). Columns pulled from a modality (<mod>:col) go back only to it.
  • No maps (older writers): rows match by name. A map that does not place each row once, or a global graph of the wrong shape, is refused before writing. --compression applies to the global parts too.
  • .h5mu and MuData Zarr are sniffed apart from AnnData (both used to be taken for one).

Writing (adata convert x.h5ad -o x.h5mu): var split by feature type as muon does (Gene Expression → rna, Peaks → atac, Antibody Capture → prot), or the whole AnnData under --mod. Each modality's X, layers, var, varm, varp and raw in one _write_targets fan-out pass (new skip/verb arguments); obs, obsm, obsp and uns once, globally; obsmap/varmap, masks, root attrs and the 512-byte MuData userblock. HDF5 only; a MuData Zarr output is not added.

Fixed on the way: anndata 0.7's dataframes (encoding-version 0.1.0) broke every copy — their categoricals point into __categories by an HDF5 object reference, which cannot leave the file: subset wrote an h5ad anndata could not read, and subset/convert to Zarr failed ("Object of type Reference is not JSON serializable"). copy_tree and subset_axis_group now write them as current categorical groups in a 0.2.0 frame (tests/test_legacy_copies.py). concat's _aligned_rows moved to core/subset.aligned_rows to be shared.

Verified:

  • Unit suite 1,770 passed (95.3%); integration 238 passed, including mudata reading our export and our import of a file mudata wrote; perf guards for both directions.
  • muon's minipbcite.h5mu (mudatasets): both modalities, global parts included, equal mudata 0.2.1's reading (current mudata cannot read that file's encoding-type: AnnData modalities; we can).
  • 10x pbmc3k Multiome filtered_feature_bc_matrix.h5 → h5ad → .h5mu: current mudata reads it without warnings, rna (36,601) and atac (98,319) equal the AnnData's subsets.

🤖 Generated with Claude Code

Reading writes one AnnData per modality (or the one --mod names): the
modality copied as stored, then the global obs/var columns, obsm/varm,
obsp/varp and uns taken onto its rows through obsmap/varmap, in the
modality's order, a block at a time; graphs remapped through on-disk
buckets, CSR or CSC kept. A global name the modality already has keeps
the modality's own, with a warning; columns pulled from a modality
(<mod>:col) go back only to it. Without maps, rows match by name; a map
that does not place each row once is refused before writing. .h5mu
files and MuData Zarr stores are sniffed apart from AnnData.

Writing splits var by feature type as muon does (Gene Expression ->
rna, Peaks -> atac, Antibody Capture -> prot), or writes the whole
AnnData under --mod: each modality's X, layers, var, varm, varp and raw
in one fan-out pass; obs, obsm, obsp and uns once, globally; maps,
masks, root attributes and the 512-byte MuData userblock.

Also: anndata 0.7's dataframes (encoding-version 0.1.0) broke every
copy, since their categoricals point into __categories by an object
reference that cannot leave the file: subset wrote an h5ad anndata
could not read, and Zarr outputs were refused. Copies now write them as
current categorical groups in a 0.2.0 frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 23, 2026 11:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-23T13:21:45.158607Z 7152d9e Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 02f1b999b8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/adata/conversion/mudata.py Outdated
Comment on lines +639 to +642
stems = unique_names(names)
for (label, _), name in zip(counted, names):
mods.append((stems[name] if names.count(name) > 1 else name,
np.flatnonzero(types == label)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Generate a distinct name for every normalized feature type

When two different feature-type labels normalize to the same modality name, such as Foo Bar and foo_bar, unique_names(names) returns a dictionary keyed by that repeated name, so both iterations select the same final value (for example, foo_bar_1). The write then attempts to create the same modality group twice and fails after staging has begun; assign unique names by position and detect any remaining collision while planning.

AGENTS.md reference: AGENTS.md:L127-L128

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5935e2b: modality names are claimed by position (claim over the names taken so far), so Foo Bar and foo_bar become foo_bar and foo_bar_1. Test: test_feature_types_that_meet_as_names_get_a_modality_each.

Comment thread src/adata/conversion/mudata.py Outdated
Comment on lines +581 to +582
appender = CsrAppender(parent, name, shape=(out_major, out_minor), data_dtype=data.dtype,
compression=compression)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the source layout for remapped sparse elements

When importing a compressed or int64-indexed global sparse obsm, varm, obsp, or varp element without --compression, this constructs CsrAppender without source templates or index_dtype. Its defaults therefore rewrite indices as int32 and create fresh uncompressed/default-chunked datasets, rather than retaining the source's index dtype, chunks, and compression as other subset paths do.

AGENTS.md reference: AGENTS.md:L134-L135

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5935e2b: CsrAppender takes optional templates (the source's data, indices, indptr), used for chunks and codecs, and the source's indices dtype (and a wider indptr dtype) is kept. Test: test_a_remapped_sparse_element_keeps_its_layout (int64, gzip in → int64, gzip out).

Comment thread src/adata/storage/__init__.py Outdated
Comment on lines +811 to +815
kw = fit_chunks(
dataset_create_kwargs(codes_src, target_backend=target_backend, dst_parent=group),
codes.shape, backend=target_backend,
)
create_dataset(group, "codes", data=np.asarray(codes), **kw)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply requested compression to selected legacy codes

When a legacy categorical is row-selected—such as a global anndata 0.7 obs column imported from MuData—the codes is not None branch forwards only the source creation settings and never applies the supplied compression. Consequently --compression gzip can compress the categories while leaving the codes uncompressed or in their original codec, despite the explicit conversion option.

AGENTS.md reference: AGENTS.md:L134-L135

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5935e2b: taken legacy codes go through with_compression when --compression is given. (The categories are HDF5 strings, which with_compression leaves uncompressed by design.) Test: test_compression_applies_to_legacy_codes_taken_onto_a_modality.

@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (py3.13)

1 791 tests   1 791 ✅  4m 45s ⏱️
    1 suites      0 💤
    1 files        0 ❌

Results for commit 7152d9e.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (py3.12)

1 791 tests   1 791 ✅  3m 59s ⏱️
    1 suites      0 💤
    1 files        0 ❌

Results for commit 7152d9e.

♻️ This comment has been updated with latest results.

…acy codes compressed

- Feature types that normalise to one name (`Foo Bar`, `foo_bar`) get
  distinct modality names by position; they shared one, and the second
  group failed with the output half written.
- Global sparse elements taken onto a modality keep the source's index
  dtypes, chunks and codecs (CsrAppender takes the source arrays as
  templates).
- --compression applies to legacy categorical codes taken by row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Claptar

Claptar commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5935e2bed0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +214 to +222
own = read_names(root["mod"][mod], axis)
missing = [n for n in own if n not in lookup]
if missing:
raise ValueError(
f"There is no {axis}map/{mod}, and {len(missing):,} of its {axis} "
f"names are not global ones (first: {missing[0]!r})."
)
warnings.append(f"No {axis}map/{mod}: its {axis} are matched to the global ones by name.")
return np.fromiter((lookup[n] for n in own), dtype=np.int64, count=len(own)), n_global

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject duplicate modality indexes before sparse remapping

When an older MuData has no map and a modality contains duplicate obs/var names, both rows are assigned the same global position here. Dense global elements happen to duplicate those rows, but _positions() can retain only the last local position, so importing a sparse global obsm/varm/obsp/varp silently leaves earlier duplicate rows empty or drops their edges. Reject repeated modality names during planning, as is already done for repeated global names, rather than producing a corrupted sparse element.

AGENTS.md reference: AGENTS.md:L127-L128

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 80ad2e9: without a map, a modality whose names repeat is refused in the plan, as repeated global names already were. Test: test_without_maps_repeated_modality_names_are_refused.

Comment thread src/adata/conversion/mudata.py Outdated
Comment on lines +624 to +625
if options.mod is not None and (not options.mod or "/" in options.mod):
raise ValueError(f"--mod {options.mod!r}: a modality name cannot be empty or hold '/'.")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject reserved HDF5 modality names during planning

With --mod . or --mod .., this validation passes even though those names denote the current or parent group rather than a storable child name (as the imported storable() helper documents). The write later calls mod_group.create_group(name), so it cannot create the intended modality and fails only after staging has begun or targets the wrong group. Reject these reserved names alongside empty names and names containing /.

AGENTS.md reference: AGENTS.md:L127-L128

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 80ad2e9: --mod must equal storable(--mod), so "", ".", ".." and names holding "/" are refused before writing. Tests: the parametrised test_export_refusals_leave_nothing.

…d names

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Claptar

Claptar commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 80ad2e9463

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/adata/conversion/mudata.py Outdated
Comment on lines +691 to +692
keep = set(read_names(src, "var", rows)) if "raw" in src else None
targets.append(Target(group, None, None if len(rows) == detail.n_var else rows, keep))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve raw-only variables in unsplit exports

When --mod is used, or when no feature-types column exists, the single modality is supposed to contain the whole AnnData, but this still builds var_keep from the current var names. _fan_out_raw interprets that set as a selection, so a common filtered AnnData whose raw snapshot contains additional genes silently loses those genes from raw/var, raw/X, and raw/varm. Pass None for var_keep when rows covers the full current axis, just as var_idx is already set to None in that case.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f5aa0b0: the whole AnnData as one modality (--mod, or no feature-types column) now passes var_keep=None, so raw is kept whole. Going further for the split case: each modality takes raw's features of its own type by raw/var's feature-types column when it has one, so raw-only genes and peaks land in rna and atac; without such a column, raw's features var lacks are left out with a warning naming how many. Tests: test_the_whole_anndata_as_one_modality_keeps_raw_whole, test_split_modalities_take_raws_features_of_their_type, test_raw_features_no_modality_can_claim_are_left_out_with_a_warning.

…feature types

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Claptar

Claptar commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f5aa0b0bfb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/adata/conversion/mudata.py Outdated
Comment on lines +740 to +743
return [set(np.asarray(raw_names, dtype=object)[raw_types == types[rows[0]]].tolist()) if len(rows) else set()
for _, rows in mods]
var_names = read_names(root, "var")
keeps = [set(var_names[i] for i in rows) for _, rows in mods]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve duplicate raw feature names by position

When a split export has non-unique raw.var_names, converting each modality's selection to a set loses occurrence identity. _fan_out_raw subsequently resolves that set with indices_from_name_set, which stops after the first occurrence of each name, so duplicate raw columns—and the corresponding raw/X and raw/varm columns—are silently dropped; if the same name occurs under multiple feature types, it can also be assigned to the wrong modality. Keep positional raw indices or reject duplicate names during planning.

AGENTS.md reference: AGENTS.md:L127-L128

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f13eea9: a split export whose raw/var names repeat is refused in the plan (names cannot place a repeated feature), pointing to --mod, which writes the whole AnnData with raw intact (no name selection then). Test: test_repeated_raw_names_refuse_a_split.

Comment thread src/adata/conversion/mudata.py Outdated
Comment on lines +740 to +741
return [set(np.asarray(raw_names, dtype=object)[raw_types == types[rows[0]]].tolist()) if len(rows) else set()
for _, rows in mods]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Report raw feature types with no modality

When raw/var contains a feature type that has been completely filtered out of the current var, no output modality is created for that type, and this early return silently omits all of those raw features. The fallback branch warns about unclaimable raw features, but the typed branch does not; detect these unmatched raw types during planning and warn or refuse before writing.

AGENTS.md reference: AGENTS.md:L127-L128

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f13eea9: raw feature types with no modality (filtered out of var entirely) are now reported in the plan with how many features they hold. Test: test_raw_features_of_a_type_var_no_longer_has_are_reported.

…ypes var lacks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Claptar

Claptar commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f13eea9607

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/adata/conversion/__init__.py Outdated
Comment on lines +51 to +52
if mudata.is_mudata_root(handle) or isinstance(handle.get("mod"), h5py.Group):
return False

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not reject legacy AnnData merely for a mod group

For an AnnData 0.7-era HDF5 file with obs and var but no root encoding attributes, an unrelated top-level mod group now makes this sniffer return false even when mudata.sniff() has already rejected the file as MuData. If such a store uses .h5 or another non-.h5ad extension, detect_format() no longer recognizes it by content and conversion fails unless the user explicitly supplies --from h5ad; before this change, the obs/var fallback accepted it. Only reject roots that actually satisfy the MuData signature.

AGENTS.md reference: AGENTS.md:L96-L101

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 416f2da: _sniff_h5ad turns a file away only when mudata.sniff takes it (root encoding-type: mudata, or muon's MuData userblock with a mod group); a bare mod group no longer does. Test: test_an_old_anndata_with_a_mod_group_is_still_an_anndata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Claptar

Claptar commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 416f2da296

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/adata/conversion/mudata.py Outdated
Comment on lines +731 to +732
if len(mods) == 1 and len(mods[0][1]) == n_var:
return [None]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep single-type exports on the typed raw path

When feature-type splitting is active but current var contains only one surviving type—for example, RNA-only var with a raw snapshot that still contains Peaks—this returns None before inspecting raw's types. _fan_out_raw consequently copies every raw feature into the RNA modality instead of reporting and excluding features whose type has no modality, contaminating that modality's raw matrix. The fresh evidence after the prior fix is that this early return still checks only modality count and coverage, not whether types_col is active; whole-raw behavior should be reserved for --mod or an unsplit export.

AGENTS.md reference: AGENTS.md:L127-L128

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fba0c5c: raw is kept whole only for an unsplit export (--mod, or no feature-types column); with a types column active, even a single modality takes only raw's features of its type, and the others are reported. Test: test_one_type_left_in_var_still_splits_raw_by_type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Claptar

Claptar commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fba0c5cf7f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/adata/conversion/mudata.py Outdated
if options.mod is not None and options.mod not in mods:
raise ValueError(f"No modality {options.mod!r}; this file has: {', '.join(mods)}.")
wanted = [options.mod] if options.mod is not None else mods
stems = unique_names(wanted)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allocate truly unique modality output stems

When modality names include a b, a_b, and a_b_1, unique_names() produces a_b, a_b_1, and a_b_1, so two modalities share an output and staging path. The second write truncates the first temporary store, and the subsequent duplicate rename fails instead of producing all modalities. Claim each generated stem against every previously generated stem and reject any remaining output collision during planning.

AGENTS.md reference: AGENTS.md:L127-L128

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7ed65ec: each modality's stem is claim(sanitize_label(mod), taken) against every stem so far, so a b, a_b, a_b_1 give a_b, a_b_1, a_b_1_1. Test: test_modality_names_that_meet_as_file_names_each_get_a_file.

Comment thread src/adata/conversion/mudata.py Outdated
templates=(data, indices, obj["indptr"]),
)
write_runs(
blocks(), n_rows=out_major, n_targets=1, destinations=[[appender]], scratch_parent=parent,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep scratch groups out of user mappings

For a Zarr destination whose global obsm/varm/obsp/varp contains an entry named __csr_scratch__, any sparse remap that needs sorting—for example, a modality whose map reverses rows—passes that user mapping as the scratch parent. write_runs() then tries to create its fixed __csr_scratch__ group where the legitimate output entry already exists and the conversion fails. Use a collision-free temporary name or a scratch location outside the user mapping.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7ed65ec: the buckets' scratch group is created under the output's root, not inside the user's obsm/varm/obsp/varp mapping. Test: test_a_global_entry_named_like_the_scratch_group_is_taken_too (a Zarr output, a reversed modality, an obsp entry named __csr_scratch__).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Claptar

Claptar commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7ed65ecfcf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +763 to +764
var_names = read_names(root, "var")
keeps = [set(var_names[i] for i in rows) for _, rows in mods]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject raw names claimed by multiple modalities

When raw/var lacks the feature-types column and the current var has the same name under two feature types, these sets both claim the single matching raw feature. This can occur when a unique raw snapshot is retained and current var names are later renamed to duplicates; the export then silently copies the same raw column into both modalities even though it can belong to only one. Detect overlapping keeps during planning and refuse the ambiguous split.

AGENTS.md reference: AGENTS.md:L127-L128

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 579bcfa: when raw is shared out by var's names (raw/var has no types column), names in more than one modality are refused in the plan, pointing to a raw/var types column or --mod. Test: test_var_names_in_two_modalities_refuse_a_split_by_name.

Comment on lines +655 to +660
counted = type_counts(types)
# By position: two types can name one modality (`Foo Bar`,
# `foo_bar`), and each needs a group of its own.
taken: set = set()
for label, _ in counted:
mods.append((claim(modality_name(label), taken), np.flatnonzero(types == label)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refuse an empty feature-type split

When a valid AnnData has zero variables but still declares a feature_types column, type_counts() is empty and this loop creates no modalities. The writer consequently emits an .h5mu with an empty mod group, which this converter's own importer immediately refuses as having no modalities. Create a default empty modality or reject this case during planning rather than successfully writing an unusable output.

AGENTS.md reference: AGENTS.md:L127-L128

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 579bcfa: with no features (so no types), the AnnData becomes one empty modality, rna or --mod's name; the result converts back. Test: test_an_anndata_without_features_is_one_empty_modality.

…ared by name; one modality for no features

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Claptar

Claptar commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

types_col = "feature_types"
mods: List[Tuple[str, np.ndarray]] = []
if types_col is not None:
types = np.asarray(read_str_all(var[types_col], parent_group=var), dtype=object)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stream feature types instead of materializing the column

For AnnData stores with a large var axis, read_str_all() retains the entire feature-types column as Python strings and then copies it into an object array, so export memory grows with the input rather than the configured chunk size. Build the modality selections incrementally from chunks or spill them to disk instead of materializing the column.

AGENTS.md reference: AGENTS.md:L6-L11

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this as is, deliberately. The feature-types column is one var-length array — the same budget as the var index, which this export reads whole anyway to write the global var (and which every converter here holds whole: names are per-axis state, not per-entry data). It does not grow with the matrix, which is what --chunk bounds. Streaming it would not lower the export's peak, since the stacked var names are the larger of the two.

Comment on lines +596 to +599
write_runs(
blocks(), n_rows=out_major, n_targets=1, destinations=[[appender]], scratch_parent=scratch,
grouped=grouped, canonical=False, counts=counts, value_dtype=data.dtype, chunk=1 << 20,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve duplicate sparse entries during remapping

When a global CSR/CSC entry contains duplicate coordinates, this call routes it through write_runs(), whose DuplicateSummer unconditionally coalesces those entries even though the modality map is injective. Such duplicates are valid in stored sparse matrices, so importing a modality can change their representation and numeric values through rounding or integer overflow; the remapping path should retain source duplicates rather than applying coordinate-builder semantics.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 31aa048: write_runs takes sum_duplicates=False, which the MuData remap passes, so duplicates go through untouched on both the grouped and the bucket path. Test: test_duplicate_entries_of_a_global_graph_are_kept (in-order and reversed modalities; nnz and values kept).

The remap is one to one, so a duplicate is the stored matrix's own; the
bucket builder summed it as if building from coordinates. write_runs
takes sum_duplicates=False for that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Claptar

Claptar commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 31aa048273

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

templates=(data, indices, obj["indptr"]),
)
write_runs(
blocks(), n_rows=out_major, n_targets=1, destinations=[[appender]], scratch_parent=scratch,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allocate scratch outside copied modality roots

For a Zarr output where the modality already contains a top-level __csr_scratch__ element and a global sparse element needs reordering, the modality element is copied into the destination root before this call makes write_runs() create its fixed scratch group in that same root, so Zarr raises because the name already exists. Fresh evidence beyond the prior scratch fix is that modality-root passthrough keys—not just global obsm/varm/obsp/varp entries—can occupy this name; use a collision-free name or a separate temporary store.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7152d9e, at the source: csr_build.scratch_group picks __csr_scratch__, or the first __csr_scratch___<n> the store does not hold, so no copied key can collide, for this converter or any other user of the buckets. (On HDF5 the scratch was already a separate temporary file.) Test: test_a_modality_key_named_like_the_scratch_group_is_left_alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Claptar

Claptar commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Hooray!

Reviewed commit: 7152d9e69e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@Claptar
Claptar merged commit 8026a46 into main Oct 23, 2026
6 checks passed
@Claptar
Claptar deleted the feat/convert-mudata branch October 23, 2026 13:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants