This is a precise, worked description of the L★ data model. It builds on the normative proposal in
../misc/Lstar_proposal.md (Part 2 — the core model, and Appendix A —
the Zarr schema); read that for the full specification and the formal profile catalog. Here the goal
is to explain what each construct is and why, with enough examples that you can read and write an
L★ dataset by hand. Where lstar's current implementation covers only part of the spec, a
Implemented today note says so.
A dataset is a set of axes — the entities you index by — and fields — typed data defined over one or more of those axes. Everything else (embeddings, graphs, trees, factor models, spatial geometry, fitted models, multi-sample collections, experimental designs) is a convention expressed with axes and fields, not a separate kind of object.
That economy is the whole point. A fixed schema gives you a few named slots (obs, obsm, obsp,
layers, …); when a method produces something that doesn't fit a slot, it lands in uns/misc as an
opaque blob. In L★ a new method adds a role and maybe an axis — never a schema slot — so the
long tail of analyses (RNA-velocity graphs, gene-regulatory networks, cell–cell communication
tensors, fate probabilities, fitted models) all have a first-class, queryable home.
An axis is a named, ordered set of element labels, with provenance. The two ordinary axes are
cells (barcodes) and genes (symbols); a study also has samples; analysis introduces more
(pca, umap, a leiden cluster axis, …).
Two properties carry weight later:
- Observed vs. derived. An axis is observed if the experiment gives it (cells, genes, peaks, proteins, transcripts, spots, donors) or derived if analysis produces it (clusters, metacells, neighborhoods, terminal states, factors, tree nodes). A coordinate axis is a special derived case whose elements are the dimensions of a space — the 50 columns of a PCA, the 2 axes of a UMAP.
- Label-keyed. An axis is identified by its labels, not by integer position. A field refers to
elements by label — which is what lets a field cover only some of an axis (partial coverage), or
carry labels not currently present (a superset). Access is a join on labels; an uncovered element
is absent, not silently filled with
NA. - Namespaced. Each axis name is local to the dataset or sample that defines it. Sample A's
cellsand sample B'scellsare distinct axes. This is exactly what lets a multi-sample study stay a collection of heterogeneous parts instead of being concatenated onto one shared axis (see Collections).
"Observation" and "feature" are not kinds of axis — they're a labeling a profile applies to the two
axes of a measure: in cells × genes, cells are the observations and genes the features. Which axis
plays which role is a profile choice.
Implemented today. lstar models
origin(observed/derived) androle(observation/feature/coordinate/…). Per-sample namespaced axes are used by the collection profiles (e.g.cells.GSM5746259). Partial coverage is implemented: a field may cover a subset of a span axis via anindexof positions into it (see Fields, below) — a modality measured on only some cells (10x multiome barcode whitelists, CITE-seq dropout) lands over the sharedcellsaxis with an index, not a separate axis or an NA-padded matrix.
A field is typed data over an ordered tuple of axes — its span. The arity of the span is the shape of the thing:
- arity 1 — a vector: a metadata column, pseudotime, a cluster label.
- arity 2 — a matrix or relation: counts, PCA loadings, a kNN graph, proportions.
- arity 3 — a tensor: a
group × group × lr_paircommunication tensor; ancelltype × gene × varianteQTL table.
A field carries these attributes — but only values is required; the rest is the resolved view,
inferred from the data and overridden only to unlock a role-specific behavior:
| attribute | what it is |
|---|---|
| span | the axes it ranges over, e.g. ["cells","genes"], ["cells","cells"] (a relation) |
| role | a semantic type tag — what kind of object this is and what invariants apply (below) |
| coverage / index | full (default), or partial — the field covers only a subset of one span axis (index_axis), keyed by an index of integer positions into it (one per value row along that axis) |
| values + encoding | the data, stored (dense/csr/csc/coo/ragged/raster) or virtual (recipe) |
| state | for measures: raw/lognorm/scaled/… so a method can refuse an invalid operation |
| uncertainty | an optional companion field of the same span (a p-value, a variance) |
| provenance | method, parameters, seed, version, and the input fields/axes it was built from |
Two consequences of "only values is required" are worth internalizing:
- Inference does the work. Hand the binding a character vector and it becomes a
label; a numeric vector ameasure; a sparse matrix the size ofcells × genesameasureover those axes. You specifystate=lognormonly so that differential-expression guards apply, or force an integer vector to be alabelrather than ameasure— not to make storage work. A field with a missing or wrong role still stores and reads back faithfully; the role enables behavior, it does not gate storage. - Partial factors are normal. A
celltypedefined on 8,000 of 12,438 cells is partial — the rest are unannotated, notNA. A factor referencing 2,000 barcodes absent from the object keeps them as out-of-axis labels that resolve on a later join. Nothing is forced into a dense, NA-padded table.
Implemented today. Encodings
dense,csc,csr,coo, andutf8(strings, lstar's encoding forlabel/string fields), each optionally with a nullable validitymask.state,subtype, relation flags, andprovenanceare stored. Partial coverage (coverage="partial"+ anindexintoindex_axis) is implemented and round-trips across Python, C++, and R.recipe(virtual fields),ragged, andrasterare specified but not yet implemented.
A role declares what kind of object a field is and what invariants apply. A reader that doesn't recognize a role still sees a typed field over known axes — so unknown roles degrade gracefully rather than breaking. The core vocabulary:
| role | span shape | examples |
|---|---|---|
| measure | (obs, feature) or arity-1 |
counts, normalized/scaled expression, QC metrics, marker stats |
| embedding | (axis, coordinate-axis) |
PCA/UMAP/tSNE scores; spatial x/y/z; CellRank fate probabilities |
| loading | (feature, coordinate-axis) |
PCA loadings; MOFA / cNMF / SCENIC weights (shares the embedding's axis) |
| relation | (axisA, axisB) |
kNN/SNN graph, scVelo transitions, a SCENIC gene network, an orthology map, a deconvolution. Flags directed/weighted, subtype ∈ {similarity, transition, spatial, interaction, regulatory, correspondence, membership} |
| label | (axis) → categories |
a leiden clustering, a cell-type annotation. Induces a group axis equal to its categories |
| sequence | (axis, position) ragged |
TCR/BCR CDR3 strings, CRISPR-edited barcodes |
| design | (axis) |
a cacoa linear-model design over samples (formula + contrast). Induces a coefficient axis |
| transform | a fitted model | a trained scVI/CellTypist/UMAP projector — applied to data, re-appliable to new cells |
The vocabulary is open: a new method introduces a role (or a namespaced x-myorg:foo term), and
the model doesn't change. The long-tail example from the proposal — RNA velocity — is just four
ordinary fields, no schema slot:
velocity role=measure over (cells, genes) a displacement on the gene basis
velocity_graph role=relation over (cells, cells) directed, subtype=transition
fate role=embedding over (cells, terminal_state) a derived axis of end states
grn role=relation over (genes, genes) directed, subtype=regulatory
Implemented today. Profiles emit
measure,embedding,loading,relation,label, anddesign(the per-cellsamplepartition).sequenceandtransformare specified;models/(fitted transforms) are not yet written.
Most of L★'s economy comes from three rules that induce a derived axis from a field, so that downstream results are ordinary fields rather than special cases:
- Coordinate axis. An
embeddingplaces an axis into a coordinate space; the columns it produces are an axis. The 50 columns of a PCA are thepcaaxis — shared by the cell scores (embeddingovercells × pca) and the gene loadings (loadingovergenes × pca). One rule unifies PCA, UMAP, MOFA, cNMF, SCENIC, CellRank. Physical x/y/z is the same construction with an observed coordinate axis and no loading. - Group axis. The categories of a
labelare a derived axis — the 9 categories ofleidenbecome theleidenaxis — so per-group results (markers, PAGA connectivity, composition, communication) are ordinary fields over it. This is why "results that aren't per-cell" need no special slot. - Union axis. The union of several axes, with
relation/membershipfields back to each: the jointcellsof a collection over its samples; metacells over cells; cells over transcript points.
Implemented today (group axis). Rule 2 is live: a
labelstored in thecategoricalencoding (integer codes + an ordered category set) induces a derivedfactoraxis whose labels are its categories.Dataset.induce(field)(Python) does this — andadd_fieldof a categorical fires it automatically (induction is data-driven: it keys on the value's type, not on a profile listing it). Identity is canonical — a label's bare name + its ordered label set — so independent results over the same clustering land on one axis and align; a name clash with different labels is an error, never a silent merge. Auto-induction is guarded: a categorical whose levels are near-unique — an identifier stored as a categorical, e.g.var['ensembl_id']with one level per gene — does not mint a degeneratefactoraxis the size of the data; it stays a plain categorical (an explicitinduce()is still honored if a caller truly wants one). The axis carriesinduced_byback to its field, andvalidate()checks the axis labels still equal the field's categories, so induction is checkable, not merely conventional. Coordinate (rule 1) and union (rule 3) axes are still created by the profiles directly. Seeformat.mdfor the on-diskcategoricalencoding.
A few named patterns are bundles of the above, not new constructs:
- A table (
obs,var,cellMeta,sampleMeta) is the set of arity-1 fields over one axis, presented as a data frame. It's a view: each column remains an individual field with its own role, state, coverage, and provenance. - A tree/hierarchy (a cluster dendrogram, a lineage) is a derived node axis + a
relationparent field + amembershipleaf-map. - Geometry: cell/spot positions are an
embeddingover an observed coordinate axis (position is an attribute of the entities), whereas an image is arasterfield over its own(y, x, channel)base axes (position is the index).transformfields relate coordinate frames.
Every field and axis records the inputs it was built from, so a dataset is a provenance graph:
umap ← knn ← pca ← data ← counts. That makes results reproducible, lets a guard refuse an
operation on a measure of the wrong state (clustering raw counts), and gives an automated agent an
auditable lineage. Provenance round-trips as the native mapping type of each language — a dict in
Python, a named list in R, a JSON object in C++ — so method parameters (and a joint product's
input_axes) survive a conversion in either direction. Two conventions ride on it: a normalization
recipe ({model, depthScale, log_base, winsor_caps}) stored on the raw measure lets a consumer
recompute a normalized view on demand instead of persisting a redundant dense matrix — keep the small
params here and any large precomputed per-axis vectors (a CLR divisor, an IDF) as their own arity-1
recipe_scalar fields; and a joint product (WNN/MOFA) records its contributing feature axes in
input_axes.
A complete single-cell object is a short, language-independent listing of its axes and fields — this is the canonical example from the proposal (§2.5):
Sample "pbmc_donorA"
axes
cells 12,438 barcodes [observed]
genes 18,200 symbols [observed]
pca 50 dims [derived: induced by the 'pca' embedding]
umap 2 dims [derived: induced by the 'umap' embedding]
leiden 9 clusters [derived: induced by the 'leiden' label]
fields
counts measure cells × genes state=raw sparse
data measure cells × genes state=lognorm sparse
n_umi measure cells (QC)
pct_mt measure cells (QC)
pca embedding cells × pca induces the 'pca' latent axis
pca_loadings loading genes × pca shares the 'pca' axis
umap embedding cells × umap induces the 'umap' axis
knn relation cells × cells similarity, weighted
leiden label cells → leiden induces the 'leiden' factor axis (categorical)
markers.lfc measure leiden × genes on the 'leiden' axis (canonical factor-first)
markers.padj measure leiden × genes + uncertainty
dendrogram hierarchy over leiden
Notice there are no special slots — markers (a per-cluster result) and the dendrogram (a tree) are
just fields over the induced leiden axis. See
examples.md §1 for this built in code.
A kind="collection" dataset represents heterogeneous samples — possibly different cells and
different gene sets, even different species — without concatenating them into one matrix. Because
axes are namespaced, each member keeps its own cells/genes; the joint analysis lives over a
derived union cells axis. The canonical shape (the conos profile, proposal Appendix B.5):
Collection "BM_integration" (2 human, 2 mouse)
members (each a sample with its OWN axes)
human_1 cells 9,102 × genes 19,400
mouse_1 cells 7,330 × genes 21,800 (a DIFFERENT gene axis)
axes
samples 4
cells union = 31,934 ids = (sample, cell) [derived]
joint 12 clusters [derived]
fields
sampleMeta table over samples { species, donor, tissue }
inclusion relation cells → sample.cells which union cell came from which sample
orthology relation genes(human) × genes(mouse) cross-species feature map
knn relation cells × cells the joint integration graph
umap embedding cells × umap joint layout
joint label cells → joint clusters spanning all samples
No concatenated matrix exists: featureVector("CD8A") gathers per sample — human cells return CD8A,
mouse cells the orthologous Cd8a, absent elsewhere. Alignment within a sample is legitimate (its facets
share cells); across samples you keep a collection joined by a graph. A single Pagoda2 object is the
sample unit; a Conos object is the collection unit.
Reducing over a collection without materializing it. collection_pseudobulk(ds, factor) aggregates
the per-sample counts.<s> measures into a (joint-group × genes) pseudobulk — grouped by a factor
over the union cells axis (a joint clustering) — streamed one sample at a time (the joint
matrix never exists), with float64 accumulation and genes label-aligned across samples (absent ⇒ 0). It's
the collection generalization of pseudobulk(): a per-group result is just a measure over the induced
factor axis, so nothing special is needed to express "joint pseudobulk over an atlas of samples."
Implemented today. Build a collection from any list of per-sample objects with
collection_from(samples, joint=...)(Python & R). The R profiles also ingest a Conos object and a split Seurat v5 assay as collections: asamplesaxis, per-samplecells.{s}/genes.{s}axes andcounts.{s}measures, a unioncellsaxis, asampledesign label, and the joint embedding / clusters / graph. A collection converts back out, too — to a Seurat v5 split assay (write_seurat, preserves the collection) or one flattened AnnData (write_anndata), and a Conos round-trips viaread_conos. The orthology and inclusion relations and the four packaging modes (3.2) are specified but not yet emitted. Seeexamples.md§9.
A conforming store satisfies (what lstar.validate checks today): every field span references
existing axes; a field's shape matches its axes' lengths — or, for partial coverage, len(index)
along index_axis (with index in range, and index_axis one of the span axes); a relation (csc/csr
2-D) spans exactly two axes; a 1-arity label is utf8 and as long as its axis; an induced factor
axis's labels match its inducing field's categories. Roles/states outside the core registry are
warnings, not errors — the vocabulary is open.
Next: the on-disk format spec (Appendix A realized), and the worked
examples. The full normative text — including the profile rule catalog for AnnData,
Seurat, pagoda2, Conos, and cacoa — is in ../misc/Lstar_proposal.md.