Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 38 additions & 20 deletions src/seqforge/compose/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,10 @@ class ComposePlan:
units: list[dict[str, str]]
module: ModuleSelection
spec: Spec
#: relative path -> the registry name `rule onlist` will build it from. NOT the barcodes:
#: see `_resolve_token` and workflows/map/starsolo.smk for why compose does not write 111 MB.
#: The whitelists this recipe chose: backend param key -> the registry names its value renders,
#: in the value's own order (CB-position order for `soloCBwhitelist`). NOT the barcodes and NOT
#: paths — see `_resolve_token` for why compose writes neither 111 MB nor a location, and
#: workflows/map/starsolo.smk for where the consuming rule puts it.
onlist_files: dict[str, list[str]]
#: What the loaded spec's read floor admitted, or `None` when it declares none. The verdict lives
#: here and never in the manifest: it is recomputed under whatever KB is loaded at compile time,
Expand Down Expand Up @@ -612,41 +614,57 @@ def _resolve_params(
registry: OnlistRegistry,
onlist_files: dict[str, list[str]],
) -> dict[str, object]:
"""Render the KB backend params for a CLI, resolving every ``{onlist:alias}`` to a real path."""
"""Render the KB backend params for a CLI, resolving every ``{onlist:alias}`` to a registry name."""
out: dict[str, object] = {}
for key, value in spec.require_backend().params.items():
if isinstance(value, list):
rendered = [_resolve_token(v, spec, registry, onlist_files) for v in value]
out[key] = " ".join(str(r) for r in rendered)
else:
out[key] = render_param(_resolve_token(value, spec, registry, onlist_files))
values = value if isinstance(value, list) else [value]
rendered = [_resolve_token(v, spec, registry) for v in values]
out[key] = (
" ".join(str(r) for r in rendered)
if isinstance(value, list)
else render_param(rendered[0])
)
# Recorded per KEY and in the value's own order, which for ``soloCBwhitelist`` is CB-position
# order: the whitelists this recipe CHOSE, for the gate, the tests and the instrument that
# materializes one for a run of its own. Deliberately not a path — the consuming rule builds
# a path scoped to the sample reading it, and a second spelling here is a second spelling
# free to disagree with the one the pipeline actually uses.
chosen = [
str(r) for v, r in zip(values, rendered, strict=True) if _onlist_alias(v) is not None
]
if chosen:
onlist_files[key] = chosen
return out


def _resolve_token(
value: object, spec: Spec, registry: OnlistRegistry, onlist_files: dict[str, list[str]]
) -> object:
if not (isinstance(value, str) and value.startswith("{onlist:") and value.endswith("}")):
def _onlist_alias(value: object) -> str | None:
"""The alias inside an ``{onlist:<alias>}`` token; ``None`` for any other backend param value."""
if isinstance(value, str) and value.startswith("{onlist:") and value.endswith("}"):
return value[len("{onlist:") : -1]
return None


def _resolve_token(value: object, spec: Spec, registry: OnlistRegistry) -> object:
alias = _onlist_alias(value)
if alias is None:
return value
alias = value[len("{onlist:") : -1]
ref = spec.onlists.get(alias)
if ref is None:
raise ComposeError(f"backend references undeclared onlist alias {alias!r}")
name = ref.registry
rel = f"onlists/{name}.txt"
try:
registry.packed(name)
except OnlistNotAvailable as exc:
raise ComposeError(
f"onlist {name!r} is not materialized, so --soloCBwhitelist cannot be emitted: {exc}. "
"Register it (URL + sha256) or run with a registry that can fetch it."
) from exc
# Verified, not materialized. `registry.packed(name)` above is what proves the whitelist EXISTS
# and is the declared barcode set -- refusing here, at compile time, is the rung-3 promise
# `compose` makes. What it must not do is write the 111 MB: that is `rule onlist`'s job, and
# `temp()`'s. The name is recorded so the gate and the tests can still see which list was chosen.
onlist_files[rel] = [name]
return rel
# Verified, not materialized, and what is emitted is the NAME. `registry.packed(name)` above is
# what proves the whitelist EXISTS and is the declared barcode set -- refusing here, at compile
# time, is the rung-3 promise `compose` makes. What it must not do is write the 111 MB, nor say
# where the file goes: building it is `rule onlist`'s job and the location is the consuming
# module's, which is what lets that location be one no two jobs share.
return name


def _read_files_in(manifest: DatasetManifest, module: WorkflowModule) -> dict[str, str]:
Expand Down
18 changes: 9 additions & 9 deletions src/seqforge/compose/params.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,14 +439,14 @@ def render_param(value: object) -> str:
return str(value)


def _resolves_to_onlist_path(value: object) -> bool:
def _resolves_to_onlist_name(value: object) -> bool:
"""A KB param whose value is an ``{onlist:<alias>}`` token, or a list of them.

Such a value is resolved to a materialized whitelist PATH at compose time (see
``compose.core._resolve_token``), so its config rendering is a path, not the verbatim token — the
per-key faithfulness check must skip it or it would compare a path against a token and always fail.
Both STARsolo's ``soloCBwhitelist`` and chromap's ``barcode_whitelist`` are such params; keying on
the VALUE rather than the key name covers a third one without spelling it out.
Such a value is resolved to the whitelist's REGISTRY NAME at compose time (see
``compose.core._resolve_token``), so its config rendering is that name, not the verbatim token —
the per-key faithfulness check must skip it or it would compare a name against an alias and
always fail. Both STARsolo's ``soloCBwhitelist`` and chromap's ``barcode_whitelist`` are such
params; keying on the VALUE rather than the key name covers a third one without spelling it out.
"""
values = value if isinstance(value, list) else [value]
return any(isinstance(v, str) and v.startswith("{onlist:") for v in values)
Expand Down Expand Up @@ -536,9 +536,9 @@ def params_gate(

# ---- 3. faithfulness, per key, per owner ----
for key, expected in params.items():
if _resolves_to_onlist_path(expected):
continue # an {onlist:...} token is resolved to a path at compose, so it is not
# compared verbatim (the registry proves the whitelist exists in `_resolve_token`).
if _resolves_to_onlist_name(expected):
continue # an {onlist:...} token is resolved to a registry name at compose, so it is
# not compared verbatim (the registry proves the whitelist exists in `_resolve_token`).
# Value-based, not a key name: covers STARsolo's `soloCBwhitelist` AND chromap's
# `barcode_whitelist` without either being spelled out here.
want = render_param(expected)
Expand Down
20 changes: 18 additions & 2 deletions src/seqforge/workflows/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,22 @@
from ..kb.schema import Spec

#: CalVer YYYY.M.PATCH; bump when any shipped module's rules/params change.
#: 2026.8.25 — `rule onlist` materializes a whitelist under the SAMPLE that reads it, in `map/starsolo`
#: and `map/chromap` (#488, the last case of #478's class). It owned `onlists/<name>.txt` — one ~115 MB
#: file every sample in the deposit maps against — and snakemake removes an output before running the
#: job that makes it, so a second instance over one results directory raced the first's REMOVAL: the
#: hazard measured at 0 of 6 on the index rule, where an atomic idempotent body still gave 0 of 6.
#: The rule stays local, stays without a `container:`, stays the `seqforge io onlist write` verb, and
#: stays `temp()`; only its output gains a sample scope. **What moves is a location, never a byte**:
#: the aligner reads the same barcodes and every count, matrix and record is identical. The emitted
#: config now records the whitelist's registry NAME rather than a path, and the consuming module
#: builds the path from it — one owner for the location, so config and rule cannot disagree — which
#: is what makes the sample scope expressible at all, since a path fixed at compile time is one no
#: job can vary. A chemistry declaring three whitelists still renders three paths in CB-position
#: order into one argv token. `temp()` now reclaims each copy after the job that read it, so the peak
#: is one copy per aligner job in flight (three for those chemistries) rather than one per compiled
#: run kept forever — which is not the permanent duplication ADR-0015 rejected. `required_config` is
#: unchanged for both.
#: 2026.8.24 — `rule umi_count` says how much of each cell's yield was ribosomal (#471). Two more
#: `obs` columns on the plate object — `n_umis_rrna` and `rrna_fraction`, both counted over
#: `umi_combined`, the same population `n_umis` and the saturation are read off — and the page carries
Expand Down Expand Up @@ -623,7 +639,7 @@
#: dereferenced and never declared. The contract was wrong, not the module.
#: 2026.7.1 — star.smk hardcodes --outSAMtype (it is a module detail, and starsolo.smk always
#: hardcoded it); required_config gains primary_feature and drops bulk.outSAMtype.
WORKFLOW_VERSION = "2026.8.24"
WORKFLOW_VERSION = "2026.8.25"

_MODULE_DIR = Path(__file__).parent

Expand Down Expand Up @@ -792,7 +808,7 @@ def argv_keys_read_by(source: Path) -> frozenset[str]:

#: chromap's parse namespace — the byte-decided knobs a ``map/chromap`` backend may declare. Just the
#: barcode whitelist: chromap corrects the cell barcode against it (like STARsolo's ``soloCBwhitelist``),
#: and it resolves through the same ``{onlist:<alias>}`` mechanism to a materialized path. Everything
#: and it resolves through the same ``{onlist:<alias>}`` mechanism to a registry name. Everything
#: else chromap needs is either a fixed module detail (the ``--preset atac`` mode, hardcoded in
#: chromap.smk the way star.smk hardcodes ``--outSAMtype``) or read geometry the manifest already states
#: (which file is the barcode read arrives via ``read_files_in``, not a parse param). The namespace is
Expand Down
32 changes: 19 additions & 13 deletions src/seqforge/workflows/map/chromap.smk
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,17 @@ def commajoin(sample, role):
return ",".join(fastqs(sample, role))


def whitelist():
"""The materialized barcode whitelist path — the config value is the argv rendering.

`config["chromap"]["barcode_whitelist"]` is `onlists/<name>.txt`, built on demand by `rule onlist`
and `temp()`-deleted after — the same packed-onlist discipline starsolo.smk uses for
`soloCBwhitelist`, so a 111 MB whitelist is never written into the run directory at compile time.
def whitelist(sample):
"""This SAMPLE's barcode whitelist — the config names the list, this module owns the path.

`config["chromap"]["barcode_whitelist"]` is the registry NAME the KB chose; the file is built on
demand by `rule onlist` under a directory named for the sample that reads it and `temp()`-deleted
after — the same packed-onlist discipline starsolo.smk uses for `soloCBwhitelist`, so a 111 MB
whitelist is never written into the run directory at compile time and no job here owns a path a
second snakemake instance over one results directory would delete out from under the first.
"""
return CHROMAP["barcode_whitelist"]
name = CHROMAP["barcode_whitelist"]
return f"onlists/{sample}/{name}.txt"


@cache
Expand Down Expand Up @@ -117,15 +120,18 @@ rule all:


rule onlist:
"""Materialize one barcode whitelist for chromap to read once and snakemake to then delete.
"""Materialize one sample's barcode whitelist for chromap to read once and snakemake to delete.

Byte-for-byte the same rule as starsolo.smk's — a barcode whitelist is a barcode whitelist. `temp()`
is the point: the ARC list is materialized on demand and deleted when the last job needing it is
done, never written into the run directory at compile time. No `container:`: this runs `seqforge`,
not an aligner, so the ambient compose environment already has it.
is the point: the ARC list is materialized on demand and deleted when the job needing it is done,
never written into the run directory at compile time. The SAMPLE in the path is the second half:
one file the whole deposit shares is a path every concurrent snakemake instance over one results
directory owns, and snakemake deletes an output before running the job that produces it — so the
second instance races the first's removal, which is 0 of 6 surviving and not a slow write. No
`container:`: this runs `seqforge`, not an aligner, so the ambient compose environment has it.
"""
output:
temp("onlists/{name}.txt"),
temp("onlists/{sample}/{name}.txt"),
localrule: True
shell:
"seqforge io onlist write {wildcards.name} --out {output}"
Expand All @@ -143,7 +149,7 @@ rule chromap_align:
gdna1=lambda wc: fastqs(wc.sample, config["read_files_in"]["gdna1"]),
gdna2=lambda wc: fastqs(wc.sample, config["read_files_in"]["gdna2"]),
barcode=lambda wc: fastqs(wc.sample, config["read_files_in"]["barcode"]),
whitelist=whitelist(),
whitelist=lambda wc: whitelist(wc.sample),
output:
fragments=temp(f"{OUTDIR}/{{sample}}/{RAW_FRAGMENTS}"),
# The pinned aligner: liulab-runtime's `align-dna`, resolved by compose to a ghcr tag or a prebuilt
Expand Down
31 changes: 24 additions & 7 deletions src/seqforge/workflows/map/starsolo.smk
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,21 @@ def readfilesin(sample, *roles):
return " ".join(",".join(fastqs(sample, role)) for role in roles)


def whitelists():
"""One path for 10x; three for a split-pool chemistry. The config value is the argv rendering."""
return SOLO["soloCBwhitelist"].split()
def whitelists(sample):
"""This SAMPLE's barcode whitelists -- one for 10x, three for a split-pool chemistry, in order.

The config names the whitelists the KB chose (registry names, CB-position order, one argv token);
the PATH is this module's, and it is scoped to the sample that reads it. That split is what keeps
the two from disagreeing -- there is no location in the config to drift from the location the rule
declares -- and it is why no job here owns a path a second snakemake instance over one results
directory would delete out from under the first. A whitelist is ~115 MB and every sample in a
deposit maps against the same one, so it was the last such path: `temp()` now reclaims each copy
when the job that read it is done, which costs one copy per aligner job in flight (three for the
chemistries declaring three) against a STAR job of tens of minutes, rather than a copy per
compiled run kept forever. The order is the config's, unchanged: STARsolo pairs the Nth whitelist
with the Nth CB position.
"""
return [f"onlists/{sample}/{name}.txt" for name in SOLO["soloCBwhitelist"].split()]


@cache
Expand Down Expand Up @@ -172,18 +184,23 @@ rule all:


rule onlist:
"""Materialize one barcode whitelist, for STAR to read once and snakemake to then delete.
"""Materialize one sample's barcode whitelist, for STAR to read once and snakemake to then delete.

`temp()` is the entire point, and why -- the 111 MB a compiled run used to carry three times over,
and why an input with no producing rule was `temp()`-able in name only -- is argued once in
ADR-0015.
ADR-0015. The SAMPLE in the path is the other half and is not decoration: this output used to be
one file the whole deposit shared, and snakemake deletes an output before running the job that
produces it, so a second instance over one results directory raced the first's REMOVAL rather
than its write -- measured on the index rule as 0 of 6 instances surviving, and an atomic,
idempotent body still 0 of 6, because the window is not the rule's to close. A copy nobody else
can claim leaves nothing to race. See `whitelists` for what it costs.

No `container:` directive, deliberately. This runs `seqforge`, which is not an aligner -- the
ambient environment is the one that just ran `seqforge compose`, so it is by construction the one
that has it. Naming `align-rna` here would put our own tool inside STAR's image.
"""
output:
temp("onlists/{name}.txt"),
temp("onlists/{sample}/{name}.txt"),
localrule: True
shell:
"seqforge io onlist write {wildcards.name} --out {output}"
Expand Down Expand Up @@ -298,7 +315,7 @@ rule starsolo_count:
cdna=lambda wc: fastqs(wc.sample, config["read_files_in"]["cdna"]),
barcode=lambda wc: fastqs(wc.sample, config["read_files_in"]["barcode"]),
loaded=rules.load_genome.output,
whitelist=whitelists(),
whitelist=lambda wc: whitelists(wc.sample),
output:
# `temp()` on everything: the raw matrices are consumed by `solo_to_h5ad`, the stats +
# filtered tree + logs by `qc_bundle`, and the BAM by `solo_to_cram`. Snakemake deletes each
Expand Down
Loading
Loading