17 Multimodal Expansion
Most sclet chapters assume one assay on one object. Real CITE-seq and multiome datasets carry a second modality — antibody counts (ADT) or chromatin accessibility (ATAC) — that has the same cells as the RNA assay but a different feature space. This chapter covers what sclet actually does with that second modality today.
The answer is short and specific. sclet implements the object contract for multimodal data: it detects a modality, registers it as a typed state record, and exposes both the object layer and the AI layer to code that can reason about a modality existing. It does not implement modality analysis: there is no ADT clustering, no ATAC peak calling, no cross-modality integration, and no multiome preprocessing. The next section shows the contract, and What Is Not Here Yet lists exactly what is missing and why it is not there.
17.1 The Two States: Attached and Registered
The chapter only makes sense if one distinction is kept sharp, because the AI layer and the diagnostic layer both depend on it:
- Attached: an altExp named
"ADT"or"ATAC"exists on the object. The data is present.has_multimodal_adt()/has_multimodal_atac()answer this. - Registered: a
multimodalanalysis-state record exists for that modality, so the mainline can talk about it without reaching into object internals.register_multimodal_adt()/register_multimodal_atac()write that record.
Registration records dimensions and nothing else. It performs no integration, no batch correction, no normalization and no modality-specific analysis. Every function in this chapter is written to respect that: the records below store n_features and n_cells of the stored altExp, which describes how large the modality is, not how informative it is.
17.2 Detecting and Registering
The two helpers are thin, and that is the point: they are the only place where sclet decides whether a modality “exists”.
library(sclet)
# The PBMC demo object carries a single RNA assay, so both helpers are FALSE.
c(adt = has_multimodal_adt(pbmc), atac = has_multimodal_atac(pbmc))## adt atac
## FALSE FALSE
Attaching a modality is an ordinary SingleCellExperiment operation. sclet deliberately does not wrap it, because the data is not sclet’s to read:
# `pbmc` has no colnames, so index by position rather than by name: a character
# subset against a NULL vector silently selects zero cells.
cell_index <- seq_len(40)
demo_multimodal <- pbmc[, cell_index]
# Give the subset real cell names and build the altExp from THOSE, so the two
# layers agree on which cell is which. An altExp whose colnames differ from its
# parent's is not a modality of that object: `altExp<-` warns today
# ("'colnames(value)' are not the same as 'colnames(x)'") and the message says
# it will become an error in the next Bioconductor release (measured
# 2026-10-21).
cell_ids <- paste0("cell_", cell_index)
colnames(demo_multimodal) <- cell_ids
altExp(demo_multimodal, "ADT") <- multimodal_alt(cell_ids, prefix = "ADT_feature_")
has_multimodal_adt(demo_multimodal)## [1] TRUE
## [1] "ADT"
Registration is the only step that touches the state layer. It fails loudly on an object that does not carry the modality, which is the behaviour you want: silent registration of an absent modality would make the rest of the chapter lie.
# Registering an absent modality is an error, not a no-op.
tryCatch(register_multimodal_adt(pbmc), error = function(e) conditionMessage(e))## [1] "altExp(sce, \"ADT\") not found. Load ADT data into the object first."
The registered record is readable through the analysis ledger, so it participates in provenance exactly like an Augur or Milo run. The ledger is the bounded view the AI layer reads, which is why it is also the right place to look as a user:
# The analysis ledger groups records by state type; "multimodal" is now a key.
ledger <- GetAnalysisLedger(demo_multimodal, detail = "summary")
names(ledger$state_records)## [1] "preprocess" "reduction" "graph"
## [4] "clustering" "geneset_scoring" "priority"
## [7] "rare_cells" "multimodal"
# The state record says which modality was registered and by which method.
record <- ledger$state_records$multimodal[[1]]
record[c("id", "method")]## $id
## [1] "demo_ADT"
##
## $method
## [1] "altExp_ADT"
The stored dimensions live on the analysis layer written by the same call, not on the state record:
# `unlist` because the ledger view returns the analysis as a nested list.
unlist(ledger$analyses$multimodal_adt[c("id", "n_features", "n_cells")])## id
## "demo_ADT"
## [1] 1
17.3 One-Step Registration
RunMultimodalWorkflow() is a workflow shell over the two registration calls. It detects every modality it knows about, registers each one, and then writes a single multimodal_workflow record that points at the component records. It is useful mainly as the one place that answers “what modalities does this object have, as far as sclet is concerned”.
multi <- pbmc[, cell_index]
colnames(multi) <- cell_ids
altExp(multi, "ADT") <- multimodal_alt(cell_ids, prefix = "ADT_feature_")
altExp(multi, "ATAC") <- multimodal_alt(cell_ids, n_features = 8L, prefix = "peak_")
multi <- RunMultimodalWorkflow(multi, name = "demo_multi")
# Two modality records plus the workflow record that points at them.
names(GetAnalysisLedger(multi, detail = "summary")$state_records$multimodal)## [1] "demo_multi_ADT" "demo_multi_ATAC"
The important negative case is the demo object: with no altExps, RunMultimodalWorkflow() returns the object unchanged and says so. It does not invent a record, and it does not fail — a workflow shell that hard-errors on an RNA-only object would be hostile to the common case.
## [1] TRUE
17.4 The AI Layer
The AI layer exposes three read-only actions for multimodal data. All three are in the multimodal action group (AIDefaultExecutionRegistry(sce, include = "multimodal")), all three are read-only (mutates_object = FALSE), and none of them writes an evidence node: they report state, they do not measure anything.
| Action | Reads | Returns |
|---|---|---|
check_multimodal_readiness |
altExps + multimodal state records | status, per-modality adt_available / atac_available / *_registered, workflow_record_available, questions |
summarize_multimodal_evidence |
the registered records | n_modalities, modality_kinds, per-kind n_features / n_cells, fixed caveats |
compare_multimodal_evidence |
two or more registered records | anonymised group_N codes plus per-code counts, or status = "not_available" |
All three have a prerequisite of the same shape: no multimodal state record means the action refuses. Attaching an altExp without registering it is not enough for the AI layer, which is why the previous section exists.
## [1] "check_multimodal_readiness"
## [2] "summarize_multimodal_evidence"
## [3] "compare_multimodal_evidence"
# One line of refusal, naming the missing state rather than failing.
registry$check_multimodal_readiness$prerequisites(pbmc, list())## [1] "no_multimodal_record: no multimodal state record is available on this object; no multimodal analysis has been recorded yet."
Readiness distinguishes the three interesting situations, and reports them in the questions field instead of acting on them:
# 1. Attached, not registered.
attached_only <- pbmc[, cell_index]
colnames(attached_only) <- cell_ids
altExp(attached_only, "ADT") <- multimodal_alt(cell_ids, prefix = "ADT_feature_")
ready <- check_multimodal_readiness(attached_only)
ready$status## [1] "ready_for_diagnostic"
## $adt_available
## [1] TRUE
##
## $adt_registered
## [1] FALSE
##
## $workflow_record_available
## [1] FALSE
## [1] "not_ready"
The bounded summaries are length-limited on purpose. They return counts, booleans and the platform labels "ADT" / "ATAC" — never a feature name, an antibody name, a barcode or a per-cell value — so they can be put into a prompt without leaking the dataset.
summary_of_demo <- summarize_multimodal_evidence(demo_multimodal)
summary_of_demo[c("status", "n_modalities", "modality_kinds", "raw_values_included")]## $status
## [1] "available"
##
## $n_modalities
## [1] 1
##
## $modality_kinds
## [1] "ADT"
##
## $raw_values_included
## [1] FALSE
## $n_features
## [1] 5
##
## $n_cells
## [1] 40
##
## $raw_values_included
## [1] FALSE
# The caveats travel with the summary so no reader quotes the counts as quality.
length(summary_of_demo$caveats)## [1] 4
compare_multimodal_evidence() requires two registered modalities. This is the case that most often surprises people, and it is not a bug:
# One registered modality: refusal with a reason, not a comparison of one thing.
compare_multimodal_evidence(demo_multimodal)[c("status", "reason", "n_modalities")]## $status
## [1] "not_available"
##
## $reason
## [1] "at_least_two_modalities_required"
##
## $n_modalities
## [1] 1
# Two registered modalities: comparison by anonymised code only.
comparison <- compare_multimodal_evidence(multi)
comparison[c("status", "n_modalities", "modality_codes")]## $status
## [1] "available"
##
## $n_modalities
## [1] 2
##
## $modality_codes
## [1] "group_1" "group_2"
## $group_1
## $group_1$n_features
## [1] 5
##
## $group_1$n_cells
## [1] 40
##
## $group_1$raw_values_included
## [1] FALSE
##
##
## $group_2
## $group_2$n_features
## [1] 8
##
## $group_2$n_cells
## [1] 40
##
## $group_2$raw_values_included
## [1] FALSE
Two properties of these results are deliberate and worth stating plainly. First, the comparison is over stored dimensions of different assay platforms: a modality with more features is not more informative, and nothing here ranks or prefers one. Second, the modality labels come back as group_1 / group_2 so that a summary can describe a two-modality object without naming the platform in a result that might be re-sent elsewhere.
Registration is also what carries the modality into the planning context. Without a record the AI layer has no multimodal state to read, and the action catalog it sees for this object is a plain RNA catalog:
multimodal_actions <- c("check_multimodal_readiness",
"summarize_multimodal_evidence", "compare_multimodal_evidence")
# Registered: the three multimodal actions are visible to a planner.
# available_actions is a list of descriptors, so the names are pulled out.
context <- sclet:::sclet_ai_context(demo_multimodal, target = "planning",
detail = "summary", registry = registry)
offered <- vapply(context$available_actions, function(a) a$name, character(1))
# `intersect` is ambiguous once `sclet` is attached (dplyr, GenomicRanges and
# base all export one), so the base implementation is named explicitly --
# `library(conflicted)` turns the silent ambiguity into a hard error rather
# than a warning.
base::intersect(offered, multimodal_actions)## [1] "check_multimodal_readiness"
## [2] "summarize_multimodal_evidence"
## [3] "compare_multimodal_evidence"
# Never invented: a registry that excludes the group cannot offer them.
AIDefaultExecutionRegistry(demo_multimodal, include = "read") |>
names() |>
base::intersect(multimodal_actions)## character(0)
17.5 What Is Not Here Yet
Everything below the object contract is absent, and it is absent because no backend has been wired in. This is the full list of what a reader would reasonably expect from a chapter titled “multimodal expansion” and will not find in sclet today:
- No cross-modality integration. There is no WNN, MOFA, scMoMaT, totalVI or MultiVI.
register_multimodal_*()records that an ADT or ATAC altExp exists; it does not produce a joint embedding, a corrected assay, or a combined neighbour graph. AnyRunIntegration()call operates on the RNA assay alone. - No ADT analysis. There is no CLR normalization, no ADT-specific marker test, no ADT-based clustering, and no ADT QC beyond whatever QC you computed yourself. The ADT altExp is carried, not analysed.
- No ATAC analysis. There is no peak calling (
Signac-styleCallPeaks), noArchR/SnapATAC-style tile matrix construction, no TF footprinting, and no accessibility-based annotation. The ATAC altExp is carried, not analysed. - No multiome preprocessing. There is no cell-level linking step for 10x Multiome output; that happens upstream in
Cell Ranger ARC, andscletassumes you arrive with an object whose RNA and ATAC agree on cells. - No modality-specific workflow.
RunMultimodalWorkflow()registers modalities and stops. It does not dispatch to any analysis, which is why the record’smethodis"multimodal_mainline"and itssummarycontains onlyhas_ADT/has_ATAC. - No evidence from the AI layer. The three multimodal actions are all read-only and all write nothing. There is no
multimodalevidence node kind, so no AI answer can cite a multimodal result — because there is no multimodal result to cite.
The reason for the gap is not effort, it is the contract. Every other sclet mainline is built on the same three-layer pattern: a Run*() that computes, a typed state record that stores what was computed, and a plot_* / get_* pair that reads it back. A modality backend has to be honest about which cells are comparable across modalities before it can write a record at all, and that question — matching, filtering, and what to do about cells present in one modality but not the other — is unsolved in the object contract. Registering the modality now, without computing on it, is what keeps the door open: when a backend arrives, altExp("ADT") and its multimodal_adt record are already the agreed entry point.
17.6 What to Do Today
Until a backend exists, the honest route through multimodal data is:
- Attach the modality upstream with
altExp(sce, "ADT") <- adt_sceand make sure the cells line up.scletwill not do this for you, and it will not silently subset. - Register it with
register_multimodal_adt()(orRunMultimodalWorkflow()for both modalities) so the object’s state layer records that the modality exists and how large it is. - Analyse the modality in your own code — the barcode-level ADT or ATAC work is a script, not a
Run*()call. - Keep the RNA analysis on the mainlines. Clustering, annotation, trajectory and differential analysis all read the RNA assay; a multimodal object is still a valid input to every chapter before this one.
- Use
check_multimodal_readiness()as the answer to “what doesscletthink this object has?” — it distinguishes attached from registered and never implies an analysis has been run.
# The end state of the recommended route, on the demo object: attached and
# registered, with nothing computed.
registered <- summarize_multimodal_evidence(demo_multimodal)
c(modalities = registered$n_modalities,
kinds = paste(registered$modality_kinds, collapse = ","),
raw_values = registered$raw_values_included)## modalities kinds raw_values
## "1" "ADT" "FALSE"
# A real CITE-seq object: the ADT counts come from your own Cell Ranger run.
# Why not executed here: the book has no CITE-seq dataset, and attaching a
# fabricated ADT matrix would document a result that cannot be reproduced.
adt_sce <- Read10X("path/to/adt/filtered_feature_bc_matrix/")
common <- intersect(colnames(sce), colnames(adt_sce))
sce <- sce[, common]
altExp(sce, "ADT") <- adt_sce[, common]
sce <- register_multimodal_adt(sce, name = "cite_ADT")
check_multimodal_readiness(sce)
summarize_multimodal_evidence(sce)