Skip to content

Read AnnData's nullable-encoded columns and v3 categoricals - #101

Merged
xinaesthete merged 3 commits into
mainfrom
claude/anndata-table-reads
Jul 31, 2026
Merged

Read AnnData's nullable-encoded columns and v3 categoricals#101
xinaesthete merged 3 commits into
mainfrom
claude/anndata-table-reads

Conversation

@xinaesthete

Copy link
Copy Markdown
Contributor

Stacked on #100 — targets claude/spatialdata-python-package-17c6d7 because the test fixture writes a real store with python/spatialdata-js-util. Review the base PR first; this diff is 6 files under packages/core.

Teaches the reader two shapes it did not handle, both of which fail silently — plausible output rather than an error.

Nullable-encoded columns

AnnData stores a nullable column as a group of values + mask, not an array, so opening its path as an array fails outright. The symptom is usually a missing index rather than a missing value, because obs/_index and var/_index are ordinary columns — a table loads with varN in place of gene names, or with no row ids at all.

This is not a legacy shape to tolerate. anndata 0.13 defaults to zarr_write_format = 3 and writes string columns this way by default, _index included, so it is simply what a freshly written spatialdata 0.8 store looks like. I confirmed the pandas dtype makes no difference — a plain object index and a pd.array(..., dtype="string") one both come out nullable.

All three encodings are read (nullable-string-array, nullable-integer, nullable-boolean) with the mask honoured, so a masked entry decodes as null. The missing-value handling from #95 already treats null as absent, so it stays distinguishable from a real 0 or '' rather than being rendered as one.

Declared kinds for the same columns

getObsColumnKinds did not recognise them either. It settles the kind from encoding-type where one is decisive and otherwise falls back to dtype in the node's array metadata — which a group does not have — so every nullable column came back undefined, sending 'auto' mode back to sniffing decoded values. That is the case the lookup exists to avoid, and on a current store it would have applied to most of the text columns.

The three encoding names live in nullableArrays.ts next to the reader, mapped to the kind of their values. Listing them at the classifier instead would mean two places that must agree about what AnnData writes, in different modules.

zarr v3 categoricals

Separately: a v3 categorical decoded to its raw integer codes. The categories array is written as string on v3, and the text check tested for one v2 spelling of that dtype (v2:object), so the column resolved to codes — plausible-looking numbers, no error anywhere. It now asks zarrita whether the dtype is text, which covers v3 string and v2 U/S alike. Pandas' -1 missing code maps to null instead of indexing off the end of the categories array.

Confirmed on a real store: feature_types reads "Gene Expression" and genome reads "Unknown" where both previously came back as 0.

Testing

  • Unit cases in tableElement.spec.ts for all three encodings against mock tree nodes.
  • Two end-to-end cases that open the fixture store through readZarr and assert kinds off the real tree — a mock would only assert the shape the mock chose to write.
  • The fixture asserts its own encodings before the tests run, so it fails loudly rather than misleadingly if anndata changes what it writes.

I checked the kind test fails without the fix (qc_count: undefined where 'numeric' is expected) rather than passing for unrelated reasons.

Known gap

The 0.5.0 / 0.6.1 / 0.7.2 integration fixtures all predate nullable encodings — I checked, and every one writes obs/_index as a string-array array. So the integration net has never seen this shape and stays green through it. A v0.8.0 fixture would close that; left for its own PR since it touches fixture generation and CI time.

Related: #97 (the guards would not have fixed any of this on their own — the group branch is incomplete independently of the casts).

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@xinaesthete, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 2 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9fea6c72-8bd1-44b6-86a6-f9b38c1a7670

📥 Commits

Reviewing files that changed from the base of the PR and between 0e0f2b5 and 22285df.

📒 Files selected for processing (6)
  • .changeset/nullable-anndata-columns.md
  • packages/core/src/models/VAnnDataSource.ts
  • packages/core/src/models/index.ts
  • packages/core/src/models/nullableArrays.ts
  • packages/core/tests/nullableStringArray.spec.ts
  • packages/core/tests/tableElement.spec.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@xinaesthete
xinaesthete force-pushed the claude/anndata-table-reads branch 2 times, most recently from fd818ea to 6bcbf01 Compare July 31, 2026 14:41
@xinaesthete
xinaesthete force-pushed the claude/anndata-table-reads branch 2 times, most recently from 382e412 to 6abb630 Compare July 31, 2026 15:15
Base automatically changed from claude/spatialdata-python-package-17c6d7 to main July 31, 2026 15:20
@xinaesthete
xinaesthete force-pushed the claude/anndata-table-reads branch from 6abb630 to b625f8f Compare July 31, 2026 15:23
@xinaesthete

Copy link
Copy Markdown
Contributor Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

xinaesthete and others added 3 commits July 31, 2026 16:53
A nullable column is stored as a *group* of `values` + `mask`, not an
array, so opening it as an array fails outright. Because `obs/_index` and
`var/_index` are themselves columns, the visible symptom was missing
variable names rather than a missing value — the browser fell back to
`varN`.

This is not confined to stores our writer has touched: AnnData writes
nullable encodings by default from 0.13, and `spatialdata` inherits that,
so freshly written stores carry them too.

Handles all three encodings sharing the layout — `nullable-string-array`,
`nullable-integer`, `nullable-boolean` — since they differ only in the
dtype of `values`. The mask is applied rather than discarded, so a missing
entry reads as `null` and stays distinguishable from an empty string or a
real zero.

Both read paths are covered: `_loadColumn`, which dispatches on
`encoding-type`, and `getFlatArrDecompressed`, which index reads reach
directly and which now resolves the node before assuming it is an array.
A group with any other encoding still fails, with a message naming what
was found.

Not routed through `anndata.js`: it dispatches none of the nullable
encodings (in the published 0.0.2 and on main), and it pins zarrita 0.5.1,
which is the subject of the still-open upstream #48. Our index and column
reads already deliberately bypass it for exactly this reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A categorical column's values were only decoded when its categories array
had dtype `v2:object`. A zarr v3 store writes them as `string`, so the
check failed and the column resolved to its raw integer codes — plausible
looking numbers rather than an error, and wrong wherever a label was
expected. v2 fixed-width unicode (`v2:U*`) had the same problem.

Test against the type rather than one spelling of it: zarrita's `is()`
already knows that `string`, `v2:U*` and `v2:S*` are all text, so the
check becomes `is('string') || is('object')`.

Also map pandas' -1 "missing" code to null instead of indexing off the end
of the categories array and yielding undefined.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`classifyObsColumnNode` settles a column's kind from its `encoding-type`
where one is decisive, and otherwise falls back to the dtype in the node's
array metadata. A nullable column is a group of `values` + `mask`, so it has
no array metadata of its own, and its encoding was not in the lookup — every
one of them came back `undefined`, which sends `'auto'` mode back to sniffing
decoded values.

That is not a rare shape. AnnData 0.13 defaults to zarr v3 and writes string
columns as `nullable-string-array` there, so on a freshly written store the
columns arriving without a declared kind are most of the text ones — the case
the lookup exists to avoid.

The three encoding names now live in `nullableArrays` alongside the reader,
mapped to the kind of their `values`, because reading them and classifying
them have to stay in step and are in different modules.

Also switches `getObsColumnKinds` to the `getObsGroup()` helper. It was still
reaching for `this.parsed.obs` behind a `typeof === 'object'` test, which
admits a `LazyZarrArray` — the narrowing a08fd37 introduced for the two
accessors either side of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@xinaesthete
xinaesthete force-pushed the claude/anndata-table-reads branch from b625f8f to 22285df Compare July 31, 2026 15:54
@xinaesthete
xinaesthete merged commit 1925695 into main Jul 31, 2026
6 checks passed
@xinaesthete
xinaesthete deleted the claude/anndata-table-reads branch July 31, 2026 16:09
@github-actions github-actions Bot mentioned this pull request Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant