Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
7 changes: 4 additions & 3 deletions modules/nf-core/custom/orfcollapse/meta.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,13 @@ description: |
Nat Biotechnol, doi:10.1038/s41587-022-01369-0; gencode-riboseqORFs
collapse_cutoff 0.9), implemented here with MMseqs2 sequence-identity
clustering rather than that tool's longest-shared-string / P-site-overlap
metric. Small ORFs (orf_class "smORF", i.e. aa_length <= 100) are clustered by
metric. Small ORFs (`aa_length` <= `--smorf-max-aa`, default 100) are clustered by
amino-acid identity upstream and this module folds each multi-member cluster
down to one representative.

Only smORF rows are collapsed; larger ORFs and transcript-anchored classes are
passed through untouched. Among the smORF members of a cluster the
Only small ORFs are collapsed; larger ORFs are passed through untouched.
Eligibility is a length test independent of `orf_class`, so a short uORF and a
short novel ORF are both candidates. Among the members of a cluster the
representative is chosen by longest aa_length (ties broken by orf_id), so the
result does not depend on which sequence MMseqs2 labelled the cluster
representative. Catalogue row order is preserved; dropped members fold their

@pinin4fjords pinin4fjords Aug 4, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[Low] Update the docs to match the code

The docs say that the longest ORF wins, but the code uses class specificity first. They should also name is_smorf as the eligibility source.

Comment added by Codex

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.

Done — orfcollapse/meta.yml now reads "the representative is chosen by class specificity, then longest aa_length, then orf_id", and names the catalogue's is_smorf flag as the eligibility source.

Expand Down
69 changes: 56 additions & 13 deletions modules/nf-core/custom/orfcollapse/templates/orfcollapse.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@
two deliberate departures from that reference:

- GENCODE collapses overlapping ORFs of any size within a shared locus;
this restricts collapsing to small ORFs (orf_class == "smORF", i.e.
aa_length <= 100) and clusters them locus-agnostically across the whole
this restricts collapsing to small ORFs (aa_length <= --smorf-max-aa,
default 100) and clusters them locus-agnostically across the whole
catalogue, since the target case is one micropeptide recurring at several
non-overlapping loci. The smORF-only restriction is this pipeline's choice,
not a GENCODE property.
non-overlapping loci. The small-ORF-only restriction is this pipeline's
choice, not a GENCODE property.
- similarity is MMseqs2 global sequence identity (--min-seq-id 0.9,
mmseqs/easycluster upstream) rather than GENCODE's longest-shared-substring
/ P-site-overlap metric, so the 0.9 here approximates rather than
Expand All @@ -32,10 +32,11 @@
high-confidence subset of the de-redundified catalogue and a folded
micropeptide is judged on its combined cross-caller / cross-sample evidence.

Only smORF rows are collapsed; larger ORFs and transcript-anchored classes pass
through untouched, preserving the deterministic coordinate/transcript merge from
upstream. Among the smORF members of a cluster the representative is chosen here
(longest aa_length, ties broken by orf_id) so the result is independent of which
Only small ORFs are collapsed; larger ORFs pass through untouched, preserving
the deterministic coordinate/transcript merge from upstream. Eligibility is a
length test, independent of `orf_class`, so a short uORF and a short novel ORF
are both candidates. Among the members of a cluster the representative is chosen
here (longest aa_length, ties broken by orf_id) so the result is independent of which
sequence MMseqs2 labelled the cluster representative. Catalogue row order is
preserved; dropped members fold their cross-caller / cross-sample evidence and
gene mappings into the survivor.
Expand Down Expand Up @@ -63,8 +64,12 @@
"rpbp": "max",
"price": "min",
}
CLASS_ORDER = ("canonical_cds", "uORF", "dORF", "novel_u", "smORF", "other")
SMORF_CLASS = "smORF"
CLASS_ORDER = ("canonical_cds", "uORF", "uoORF", "dORF", "doORF", "intORF", "novel_u", "other")

# Survivor preference when a peptide cluster spans more than one class, most
# specific first. Mirrors orfmerge's CLASS_SPECIFICITY so an annotated CDS is
# never deleted by a longer novel ORF that happens to share its peptide.
CLASS_SPECIFICITY = ("canonical_cds", "uoORF", "uORF", "doORF", "dORF", "intORF", "novel_u", "other")


def read_fasta(path):
Expand Down Expand Up @@ -113,15 +118,26 @@ def best_score(values, direction):


def merge_members(members):
"""Fold smORF rows sharing an AA cluster into one representative row dict."""
rep = sorted(members, key=lambda r: (-int(r.get("aa_length") or 0), r["orf_id"]))[0]
"""Fold small-ORF rows sharing an AA cluster into one representative row dict."""
rank = {c: i for i, c in enumerate(CLASS_SPECIFICITY)}
rep = sorted(
members,
key=lambda r: (
rank.get(r.get("orf_class", "other"), len(rank)),
-int(r.get("aa_length") or 0),
r["orf_id"],
),
)[0]
out = dict(rep)
for c in CALLERS:
out[f"called_by_{c}"] = "1" if any(r.get(f"called_by_{c}") == "1" for r in members) else "0"
out[f"score_{c}"] = best_score([r.get(f"score_{c}", "") for r in members], SCORE_DIRECTIONS[c])
samples = sorted({s for r in members for s in (r.get("samples") or "").split(",") if s})
out["n_samples"] = str(len(samples))
out["samples"] = ",".join(samples)
if "orf_type_native" in out:
natives = sorted({t for r in members for t in (r.get("orf_type_native") or "").split(",") if t})
out["orf_type_native"] = ",".join(natives)
return rep["orf_id"], out


Expand All @@ -139,14 +155,32 @@ def main():
default=1,
help="Minimum distinct samples for an ORF to enter the consensus view (default: 1)",
)
parser.add_argument(
"--smorf-max-aa",
type=int,
default=100,
help="Maximum aa_length eligible for peptide-level collapse (default: 100)",
)
args = parser.parse_args(shlex.split("${args}"))
if args.smorf_max_aa < 1:
sys.exit(f"orfcollapse: --smorf-max-aa must be >= 1, got {args.smorf_max_aa}")
Comment on lines +160 to +168

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[Medium] Remove the duplicate smORF threshold

is_smorf already reaches this process. A second threshold can disagree with it: when the normaliser uses 150 aa, collapse still excludes rows above 100 aa.

Suggested change
parser.add_argument(
"--smorf-max-aa",
type=int,
default=100,
help="Maximum aa_length eligible for peptide-level collapse (default: 100)",
)
args = parser.parse_args(shlex.split("${args}"))
if args.smorf_max_aa < 1:
sys.exit(f"orfcollapse: --smorf-max-aa must be >= 1, got {args.smorf_max_aa}")
args = parser.parse_args(shlex.split("${args}"))

Comment added by Codex

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.

Kept the flag, but made the disagreement impossible rather than removing the cross-check.

is_smorf is now the only thing that decides eligibility (orfcollapse.py:219), and orfcollapse.py:209-215 aborts if the flag and --smorf-max-aa ever disagree — naming the offending orf_ids and telling the caller to pass the same value to both processes. So the 150-vs-100 case you describe fails loudly instead of silently excluding rows.

The reason for keeping it: deleting the argument removes the disagreement but also removes the check, and the module stays usable standalone outside the pipeline. Happy to drop it entirely if you'd rather orfcollapse take the catalogue's word unconditionally.


prefix = "${prefix}"

catalogue = pd.read_csv("${catalogue_tsv}", sep="\\t", comment="#", dtype=str, keep_default_na=False)
header = list(catalogue.columns)
rows = catalogue.to_dict("records")

# The collapse scope is derived from these two columns, so a silent rename
# upstream must abort rather than quietly collapse nothing.
missing = [c for c in ("orf_class", "aa_length") if c not in header]
if missing:
sys.exit(f"orfcollapse: catalogue is missing required column(s) {missing}")

unknown = sorted(set(catalogue["orf_class"]) - set(CLASS_ORDER))
if unknown:
sys.exit(f"orfcollapse: unknown orf_class value(s) {unknown}; update CLASS_ORDER")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[Medium] Require and validate is_smorf

Collapse now depends on the flag from the merged catalogue, so it should fail clearly if that column is missing or contains anything except 0 or 1.

Suggested change
# The collapse scope is derived from these two columns, so a silent rename
# upstream must abort rather than quietly collapse nothing.
missing = [c for c in ("orf_class", "aa_length") if c not in header]
if missing:
sys.exit(f"orfcollapse: catalogue is missing required column(s) {missing}")
unknown = sorted(set(catalogue["orf_class"]) - set(CLASS_ORDER))
if unknown:
sys.exit(f"orfcollapse: unknown orf_class value(s) {unknown}; update CLASS_ORDER")
missing = [c for c in ("orf_class", "aa_length", "is_smorf") if c not in header]
if missing:
sys.exit(f"orfcollapse: catalogue is missing required column(s) {missing}")
unknown = sorted(set(catalogue["orf_class"]) - set(CLASS_ORDER))
if unknown:
sys.exit(f"orfcollapse: unknown orf_class value(s) {unknown}; update CLASS_ORDER")
invalid_smorf = sorted(set(catalogue["is_smorf"]) - {"0", "1"})
if invalid_smorf:
sys.exit(f"orfcollapse: invalid is_smorf value(s) {invalid_smorf}; expected 0 or 1")

Comment added by Codex

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.

Done — orfcollapse.py:178-188 now has three checks before anything is written: missing required columns (orf_class, aa_length, is_smorf), unknown orf_class values, and is_smorf values outside {0, 1}.


bed_index = {}
with open("${bed12}") as fh:
for line in fh:
Expand All @@ -157,9 +191,18 @@ def main():
aa = read_fasta("${aa_fasta}")
cluster_of = read_clusters("${cluster_tsv}")

# Eligibility is derived here from aa_length rather than read from a
# propagated flag, so the scope cannot silently drift from --smorf-max-aa.
def is_small(row):
try:
aa = int(row.get("aa_length") or 0)
except ValueError:
return False
return 0 < aa <= args.smorf_max_aa

clusters = defaultdict(list)
for r in rows:
if r.get("orf_class") == SMORF_CLASS:
if is_small(r):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[Medium] Use the flag already in the catalogue

Use the decision made by orfnormalise. I tested a 120-aa row marked 1 and a 50-aa row marked 0. Only the flagged pair collapsed.

Suggested change
# Eligibility is derived here from aa_length rather than read from a
# propagated flag, so the scope cannot silently drift from --smorf-max-aa.
def is_small(row):
try:
aa = int(row.get("aa_length") or 0)
except ValueError:
return False
return 0 < aa <= args.smorf_max_aa
clusters = defaultdict(list)
for r in rows:
if r.get("orf_class") == SMORF_CLASS:
if is_small(r):
clusters = defaultdict(list)
for r in rows:
if r["is_smorf"] == "1":

Comment added by Codex

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.

Done — orfcollapse.py:219 clusters on r.get("is_smorf") == "1", so the decision comes from orfnormalise rather than being re-derived here.

clusters[cluster_of.get(r["orf_id"], r["orf_id"])].append(r)

remap, merged_rows, dropped = {}, {}, set()
Expand Down
3 changes: 3 additions & 0 deletions modules/nf-core/custom/orfcollapse/tests/main.nf.test
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ nextflow_process {
{ assert process.success },
{ assert snapshot(sanitizeOutput(process.out)).match() },
// The two identical smORFs on different strands/loci collapse to one row.
// Four input rows fold to three, and the assertions below pin
// which row went and that its evidence moved to the survivor,
// so a silently empty eligible set cannot pass unnoticed.
{ assert rows.size() == 3 },
{ assert byId.containsKey('orf_00000001') },
{ assert !byId.containsKey('orf_00000002') },
Expand Down
22 changes: 11 additions & 11 deletions modules/nf-core/custom/orfcollapse/tests/main.nf.test.snap
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
{
"id": "cohort"
},
"cohort.catalogue.tsv:md5,2ae2eb7006ab613d0d23ea5eaa959615"
"cohort.catalogue.tsv:md5,a9007ad196e22f8817576368c9036bbf"
]
],
"consensus_bed12": [
Expand All @@ -47,15 +47,15 @@
{
"id": "cohort"
},
"cohort.catalogue.consensus.tsv:md5,2ae2eb7006ab613d0d23ea5eaa959615"
"cohort.catalogue.consensus.tsv:md5,a9007ad196e22f8817576368c9036bbf"
]
],
"multiqc": [
[
{
"id": "cohort"
},
"cohort.catalogue.mqc.tsv:md5,0541bc7510de3bc238145a4f913a9be9"
"cohort.catalogue.mqc.tsv:md5,29287249a0db954902f07adaa72b6bb4"
]
],
"orf_to_gene_tsv": [
Expand All @@ -71,11 +71,11 @@
]
}
],
"timestamp": "2026-08-03T10:46:50.930367",
"meta": {
"nf-test": "0.9.3",
"nextflow": "25.04.8"
},
"timestamp": "2026-06-25T20:22:39.073880596"
"nf-test": "0.9.5",
"nextflow": "25.10.4"
}
},
"collapse identical small ORFs across loci - stub": {
"content": [
Expand Down Expand Up @@ -149,10 +149,10 @@
]
}
],
"timestamp": "2026-08-03T10:46:57.809342",
"meta": {
"nf-test": "0.9.3",
"nextflow": "25.04.8"
},
"timestamp": "2026-06-25T20:22:43.127779465"
"nf-test": "0.9.5",
"nextflow": "25.10.4"
}
}
}
23 changes: 15 additions & 8 deletions modules/nf-core/custom/orfmerge/meta.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,23 @@ description: |
(typically) `bedtools/getfasta` + `seqkit/translate` downstream to obtain
the AA FASTA.

Strategy is class-aware (operating on the harmonised `orf_class` written
by `custom/orfnormalise`):
Rows are partitioned by clustering strategy. The harmonised `orf_class`
written by `custom/orfnormalise` selects the strategy but is never part of a
grouping key, because callers disagree on class for the same ORF (Ribo-TISH
reports `5'UTR` for both uORFs and CDS-overlapping uORFs) and keying on it
would emit one catalogue row per disagreeing caller:

- canonical_cds: collapse by (transcript_id, strand). One
canonical CDS per transcript by definition.
- uORF, dORF, other: collapse by (transcript_id, strand, start,
end). A single transcript can host multiple
- canonical_cds: grouped by (transcript_id, strand), then
reciprocal-overlap clustered within the
transcript so a short truncated variant is
not folded into the full-length CDS.
- uORF, uoORF, dORF, collapse by (transcript_id, strand, start,
doORF, intORF, other: end). A single transcript can host multiple
distinct uORFs / dORFs / internal ORFs, so
keying on the outer span keeps them in
separate clusters while still merging
cross-caller calls that agree on coordinates.
- novel_u, smORF: greedy reciprocal-overlap clustering on the
- novel_u: greedy reciprocal-overlap clustering on the
outer genomic span at `--reciprocal-overlap`
(default 0.8). Catches fuzzy cross-caller
matches and exact-coordinate collapses in
Expand Down Expand Up @@ -112,7 +117,9 @@ output:
Per-ORF table with `called_by_<caller>` and `score_<caller>`
columns for each supported caller, `n_samples` / `samples`
cross-sample recurrence columns, plus orf_class, aa_length,
and host gene_id / transcript_id.
host gene_id / transcript_id, `orf_type_native` listing every
caller-native ORF-type label in the cluster, and the
`is_smorf` length flag.
pattern: "*.tsv"
ontologies:
- edam: http://edamontology.org/format_3475 # TSV
Expand Down
Loading