Decouple orf_class from ORF length across the custom/orf* modules - #12498
Conversation
custom/orfnormalise overwrote orf_class with "smORF" for every ORF at or under 100 aa, so a short uORF, a short downstream ORF, a short truncated CDS variant and a short novel intergenic ORF were indistinguishable in exactly the size range microprotein work cares about. Because custom/orfmerge keys its clustering strategy on orf_class and custom/orfcollapse scoped its peptide dedup to that value, the length override also decided how an ORF merged across callers. orf_class is now purely positional, and length moves to a separate is_smorf flag driven by a new --smorf-max-aa (default 100, reproducing the previous membership exactly). The vocabulary gains uoORF, doORF and intORF, which the callers were already reporting: RiboCode and ribotricer emit Overlap_uORF/Overlap_dORF, RiboCode and Ribo-TISH emit internal, PRICE emits uoORF and iORF. ORF-type tokens are now matched exactly rather than by substring, which is what previously folded overlap_uORF into uORF. orfmerge partitions by clustering strategy with orf_class absent from every 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 class emits one row per disagreeing caller. Reciprocal overlap is measured on summed exon-block intersection rather than the outer span, which for a spliced ORF is mostly intron, and linkage is complete so a chain of partial overlaps cannot fold distinct ORFs together. Unknown classes and unassigned rows now abort. orfcollapse derives its eligible set from aa_length plus --smorf-max-aa rather than a propagated flag, and prefers the more specific class when a peptide cluster spans several, so an annotated CDS is not deleted by a longer novel ORF sharing its peptide. Adds orf_type_native, carrying each caller's own label through to the catalogue so every harmonisation decision stays auditable. Requires the refreshed orfcollapse fixture from nf-core/test-datasets#2193.
5da87e1 to
baab83c
Compare
There was a problem hiding this comment.
AI-assisted review by Codex, on behalf of @pinin4fjords.
I found three behavioural bugs. I reproduced each one at the current commit and tested the complete fix on arm64 and x86_64.
These parts span multiple locations:
- Add
modules/nf-core/custom/orfnormalise/tests/nextflow.configand load it once frommain.nf.test:
params.module_args = ""
process {
withName: "CUSTOM_ORFNORMALISE" {
ext.args = params.module_args
}
}Set module_args in both override tests, then delete both one-off config files.
- Add three regression tests: flag-based collapse eligibility, exact structures across strategy buckets, and failure on an unknown ORF type. No snapshot changes are needed.
- Update the
orfcollapse.pydocstring andmeta.yml.is_smorfcontrols eligibility. Representative order is class specificity, length, thenorf_id. - For cross-strategy merging, group exact structures first, choose one representative proxy, run the existing strategy clustering, then restore the proxy members.
Comment added by Codex
| 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}") |
There was a problem hiding this comment.
[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.
| 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
There was a problem hiding this comment.
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.
| # 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") |
There was a problem hiding this comment.
[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.
| # 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
There was a problem hiding this comment.
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}.
| # 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): |
There was a problem hiding this comment.
[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.
| # 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
There was a problem hiding this comment.
Done — orfcollapse.py:219 clusters on r.get("is_smorf") == "1", so the decision comes from orfnormalise rather than being re-derived here.
| ) | ||
|
|
||
| write_outputs(OUT_BED, OUT_TSV, bed_lines, tsv_rows, resolved_columns) | ||
| write_outputs(OUT_BED, OUT_TSV, bed_lines, tsv_rows, resolved_columns, unmapped, args.smorf_max_aa) |
There was a problem hiding this comment.
[Medium] Fail if a caller adds a new ORF type
Unknown tokens become other, which can select the wrong merge strategy. Fail before writing outputs. The tested error includes the token and count.
| write_outputs(OUT_BED, OUT_TSV, bed_lines, tsv_rows, resolved_columns, unmapped, args.smorf_max_aa) | |
| if unmapped: | |
| details = ", ".join(f"{token!r} ({count})" for token, count in sorted(unmapped.items())) | |
| sys.exit(f"orfnormalise: unmapped ORF type value(s): {details}") | |
| write_outputs(OUT_BED, OUT_TSV, bed_lines, tsv_rows, resolved_columns, unmapped, args.smorf_max_aa) |
Comment added by Codex
There was a problem hiding this comment.
Done — orfnormalise.py:1017 exits before write_outputs, with the token and its count in the message.
| config "./smorf_max_aa.config" | ||
|
|
||
| when { |
There was a problem hiding this comment.
[Low] Set the override from the test
The shared config in the review body exposes module_args, so this test needs no separate config file. I tested the same pattern for both overrides.
| config "./smorf_max_aa.config" | |
| when { | |
| when { | |
| params { | |
| module_args = "--smorf-max-aa 150" | |
| } |
Comment added by Codex
There was a problem hiding this comment.
Done — main.nf.test:364 sets module_args = "--smorf-max-aa 150" from the test, and the --score-field case at :289 uses the same pattern. The separate config file is gone.
| 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 |
There was a problem hiding this comment.
[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
There was a problem hiding this comment.
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.
| by_class = defaultdict(list) | ||
| for r in rows: | ||
| by_class[r.get("orf_class", "other")].append(r) | ||
|
|
||
| unknown = sorted(set(by_class) - set(CLASS_ORDER)) | ||
| if unknown: | ||
| sys.exit(f"orfmerge: unknown orf_class value(s) {unknown}; update CLASS_ORDER") | ||
|
|
||
| clusters = [] | ||
| # canonical CDS: one per transcript by definition - collapse by (tid, strand). | ||
| clusters.extend(group_by(by_class.get("canonical_cds", []), lambda r: (r.get("transcript_id") or "", r["strand"]))) | ||
| # uORF/dORF/other: a transcript can host multiple distinct ones, so | ||
| # additionally key on the outer span to keep them separate. | ||
| for cls in ("uORF", "dORF", "other"): | ||
| clusters.extend( | ||
| group_by( | ||
| by_class.get(cls, []), | ||
| lambda r: (r.get("transcript_id") or "", r["strand"], int(r["start"]), int(r["end"])), | ||
| ) | ||
| # Not transcript-anchored: one reciprocal-overlap pass over the union of | ||
| # these classes, so calls disagreeing on class still reach one cluster. | ||
| non_anchored = [r for cls in OVERLAP_CLASSES for r in by_class.get(cls, [])] | ||
| clusters.extend(cluster_by_reciprocal_overlap(non_anchored, frac=args.reciprocal_overlap)) | ||
| # One annotated CDS per transcript, so the span stays out of the key -- | ||
| # but grouping on (transcript_id, strand) alone would fold a short | ||
| # truncated variant into the full-length CDS and emit only the longest, so | ||
| # cluster on exonic overlap within the transcript. | ||
| loose = [r for cls in LOOSE_CLASSES for r in by_class.get(cls, [])] | ||
| for grp in group_by(loose, lambda r: (r.get("transcript_id") or "", r["strand"])): | ||
| clusters.extend(cluster_by_reciprocal_overlap(grp, frac=args.reciprocal_overlap)) | ||
| # Transcript-anchored and non-unique per transcript, so the outer span | ||
| # joins the key. Overlap clustering cannot be used here: it merges nested | ||
| # ORFs, folding a uORF that covers most of the CDS into the CDS itself. | ||
| keyed = set(OVERLAP_CLASSES) | set(LOOSE_CLASSES) | ||
| anchored = [r for cls, rows_c in by_class.items() if cls not in keyed for r in rows_c] | ||
| clusters.extend( | ||
| group_by( | ||
| anchored, | ||
| lambda r: (r.get("transcript_id") or "", r["strand"], int(r["start"]), int(r["end"])), | ||
| ) | ||
| # novel_u / smORF: not transcript-anchored - reciprocal-overlap clustering. | ||
| for cls in ("novel_u", "smORF"): | ||
| clusters.extend(cluster_by_reciprocal_overlap(by_class.get(cls, []), frac=args.reciprocal_overlap)) | ||
| ) |
There was a problem hiding this comment.
[Medium] Merge exact structures before applying class rules
An exact ORF can receive different classes from different callers. Group those rows first, use the most specific representative to select the strategy, then restore every member after clustering.
| by_class = defaultdict(list) | |
| for r in rows: | |
| by_class[r.get("orf_class", "other")].append(r) | |
| unknown = sorted(set(by_class) - set(CLASS_ORDER)) | |
| if unknown: | |
| sys.exit(f"orfmerge: unknown orf_class value(s) {unknown}; update CLASS_ORDER") | |
| clusters = [] | |
| # canonical CDS: one per transcript by definition - collapse by (tid, strand). | |
| clusters.extend(group_by(by_class.get("canonical_cds", []), lambda r: (r.get("transcript_id") or "", r["strand"]))) | |
| # uORF/dORF/other: a transcript can host multiple distinct ones, so | |
| # additionally key on the outer span to keep them separate. | |
| for cls in ("uORF", "dORF", "other"): | |
| clusters.extend( | |
| group_by( | |
| by_class.get(cls, []), | |
| lambda r: (r.get("transcript_id") or "", r["strand"], int(r["start"]), int(r["end"])), | |
| ) | |
| # Not transcript-anchored: one reciprocal-overlap pass over the union of | |
| # these classes, so calls disagreeing on class still reach one cluster. | |
| non_anchored = [r for cls in OVERLAP_CLASSES for r in by_class.get(cls, [])] | |
| clusters.extend(cluster_by_reciprocal_overlap(non_anchored, frac=args.reciprocal_overlap)) | |
| # One annotated CDS per transcript, so the span stays out of the key -- | |
| # but grouping on (transcript_id, strand) alone would fold a short | |
| # truncated variant into the full-length CDS and emit only the longest, so | |
| # cluster on exonic overlap within the transcript. | |
| loose = [r for cls in LOOSE_CLASSES for r in by_class.get(cls, [])] | |
| for grp in group_by(loose, lambda r: (r.get("transcript_id") or "", r["strand"])): | |
| clusters.extend(cluster_by_reciprocal_overlap(grp, frac=args.reciprocal_overlap)) | |
| # Transcript-anchored and non-unique per transcript, so the outer span | |
| # joins the key. Overlap clustering cannot be used here: it merges nested | |
| # ORFs, folding a uORF that covers most of the CDS into the CDS itself. | |
| keyed = set(OVERLAP_CLASSES) | set(LOOSE_CLASSES) | |
| anchored = [r for cls, rows_c in by_class.items() if cls not in keyed for r in rows_c] | |
| clusters.extend( | |
| group_by( | |
| anchored, | |
| lambda r: (r.get("transcript_id") or "", r["strand"], int(r["start"]), int(r["end"])), | |
| ) | |
| # novel_u / smORF: not transcript-anchored - reciprocal-overlap clustering. | |
| for cls in ("novel_u", "smORF"): | |
| clusters.extend(cluster_by_reciprocal_overlap(by_class.get(cls, []), frac=args.reciprocal_overlap)) | |
| ) | |
| exact_groups = group_by(rows, lambda r: (r["chrom"], r["strand"], tuple(r["_blocks"]))) | |
| merge_rows = [] | |
| for members in exact_groups: | |
| proxy = dict(representative(members)) | |
| proxy["_members"] = members | |
| merge_rows.append(proxy) | |
| by_class = defaultdict(list) | |
| for r in merge_rows: | |
| by_class[r.get("orf_class", "other")].append(r) | |
| unknown = sorted(set(by_class) - set(CLASS_ORDER)) | |
| if unknown: | |
| sys.exit(f"orfmerge: unknown orf_class value(s) {unknown}; update CLASS_ORDER") | |
| proxy_clusters = [] | |
| non_anchored = [r for cls in OVERLAP_CLASSES for r in by_class.get(cls, [])] | |
| proxy_clusters.extend(cluster_by_reciprocal_overlap(non_anchored, frac=args.reciprocal_overlap)) | |
| loose = [r for cls in LOOSE_CLASSES for r in by_class.get(cls, [])] | |
| for grp in group_by(loose, lambda r: (r.get("transcript_id") or "", r["strand"])): | |
| proxy_clusters.extend(cluster_by_reciprocal_overlap(grp, frac=args.reciprocal_overlap)) | |
| keyed = set(OVERLAP_CLASSES) | set(LOOSE_CLASSES) | |
| anchored = [r for cls, rows_c in by_class.items() if cls not in keyed for r in rows_c] | |
| proxy_clusters.extend( | |
| group_by( | |
| anchored, | |
| lambda r: (r.get("transcript_id") or "", r["strand"], int(r["start"]), int(r["end"])), | |
| ) | |
| ) | |
| clusters = [ | |
| [member for proxy in cluster for member in proxy["_members"]] | |
| for cluster in proxy_clusters | |
| ] |
Comment added by Codex
There was a problem hiding this comment.
Done — orfmerge.py:445-449 groups rows by exact structure (chrom, strand, blocks) into a proxy carrying _members, selects the strategy from the most specific representative, and :480 restores every member after clustering. So callers that disagree on class for the same structure merge into one row.
…ven collapse Class selects the clustering strategy in orfmerge, so two callers agreeing on an ORF's exact structure but disagreeing on its class were partitioned into different strategies and could never merge -- the de-merge this PR set out to remove, surviving one level down. Rows sharing an identical exon structure are now grouped first and clustered as a single proxy chosen by class specificity, with the members restored afterwards. Output is byte-identical on every existing fixture, so a synthetic test pins the new behaviour. orfcollapse takes eligibility from the catalogue's is_smorf rather than re-deriving it, and requires the column to be present and 0/1. --smorf-max-aa is kept solely to re-derive the flag and abort when the two disagree, so a threshold passed to one process and not the other fails loudly instead of silently narrowing the collapse scope. This keeps the flag and the length on a row in agreement, which deriving is_smorf from the row's own aa_length needs. orfnormalise aborts on an unmapped ORF-type token instead of counting it: the token falls through to `other` and selects the wrong merge strategy. Explicit `other` mappings are unaffected, since they report as matched. Also corrects two shipped docs against the code: orfcollapse's representative is chosen by class specificity before length, and orfmerge's reciprocal overlap is measured on summed exon-block intersection, not the outer genomic span. The two one-off orfnormalise test configs are replaced by a shared tests/nextflow.config exposing params.module_args.
|
Thanks — all three reproduce, and they're fixed in 23abd3f. One I've solved differently from the suggestion, so flagging that up front. Collapse threshold. Rather than dropping Cross-strategy merging turns out to be byte-identical on every existing fixture: the chr20 data contains no pair of callers that agree on exact structure but disagree on class across a strategy boundary. So there are no snapshot changes, and a synthetic test pins the new behaviour. Detail (AI-assisted)Unmapped ORF types — taken as suggested. Explicit
Exact-structure pre-grouping — rows sharing Docs — Test config — shared Regression tests — identical structures merging across strategy buckets ( Verification — 21/21 across the three modules, run locally container-free. Content md5s matched the committed snapshots on the first run; only Known gap — the new missing-column and invalid- |
pinin4fjords
left a comment
There was a problem hiding this comment.
AI-assisted review by Codex on behalf of @pinin4fjords.
Most fixes work. The x86_64 module and subworkflow runs produced consistent TSV, BED, FASTA, mapping, consensus and MultiQC outputs, including the 150-aa threshold path. One class-validation edge case remains, and the metadata should match the fail-fast behaviour. Details inline.
Comment added by Codex
| ORF-type tokens are matched exactly (casefolded) against each caller's closed | ||
| vocabulary; the count of tokens matching no entry is reported as | ||
| `unmapped_orf_type` on the `# parser_columns:` provenance line. |
There was a problem hiding this comment.
[Low] Describe the fail-fast behaviour
Unmatched tokens abort before outputs are written, so a non-zero count cannot appear in the provenance line.
| ORF-type tokens are matched exactly (casefolded) against each caller's closed | |
| vocabulary; the count of tokens matching no entry is reported as | |
| `unmapped_orf_type` on the `# parser_columns:` provenance line. | |
| ORF-type tokens are matched exactly (casefolded) against each caller's closed | |
| vocabulary. Tokens matching no entry cause the process to fail; successful | |
| outputs report `unmapped_orf_type=0` on the `# parser_columns:` provenance line. |
Comment added by Codex
There was a problem hiding this comment.
Applied verbatim — thanks.
| missing = sorted({c for r in rows for c in required if c not in r}) | ||
| if missing: | ||
| sys.exit(f"orfmerge: normalised TSV is missing required column(s) {missing}") | ||
|
|
There was a problem hiding this comment.
[Medium] Validate classes before selecting representatives
An unknown class can be hidden when its structure matches a known class. Validate the original rows before exact grouping.
| unknown = sorted({row.get("orf_class", "other") for row in rows} - set(CLASS_ORDER)) | |
| if unknown: | |
| sys.exit(f"orfmerge: unknown orf_class value(s) {unknown}; update CLASS_ORDER") | |
Comment added by Codex
There was a problem hiding this comment.
Done — orfmerge.py:440 validates orf_class across the original rows, before exact grouping, so an unknown class can't hide behind a known-class structure.
The exact-structure pre-grouping moved the unknown-class guard onto proxies. representative() ranks an unknown class last, so at a shared structure a known class always won the proxy and the unknown one escaped the guard entirely, then write_catalogue re-emitted the row under the known class -- the silent swallow the guard exists to prevent. Validate the input rows before grouping. Also states orfnormalise's fail-fast behaviour in meta.yml: an unmatched token aborts before outputs are written, so a non-zero unmapped_orf_type can never appear in the provenance line.
|
Both taken, in d121556. The class validation is a real regression from my exact-structure grouping — good catch.
|
…12613) * Map Ribo-TISH's colon-qualified ORF types on their location Ribo-TISH qualifies a positional label after a colon rather than replacing it: Novel:CDSFrameOverlap, 3'UTR:CDSFrameOverlap, Internal:CDSFrameOverlap, Novel:Known. CLASS_TOKENS holds only the bare locations and classify() matched the whole token, so every one of those fell through unmatched. Since nf-core#12498 made an unmatched token fatal, real Ribo-TISH output now aborts the process — four nf-core/riboseq pipeline tests failed this way on chr20 data, with 3 to 33 offending rows each. Match on the part before the first colon. orf_class is positional, and CDSFrameOverlap describes a frame relationship rather than a position, so the qualifier does not belong in the class; orf_type_native already carries the full token verbatim, so nothing is lost and the harmonisation stays auditable. No CLASS_TOKENS key contains a colon, so this cannot shorten a token meant to match whole, and no new vocabulary entries are needed: the four tokens resolve to novel_u, dORF and intORF through the existing table. The module tests missed this because their fixtures use bare tokens; only real Ribo-TISH output produces the composite form. The added test uses the four observed tokens and fails without this change with the same message the pipeline produced. * chore: retrigger CI The original checks for this head wedged during the 2026-08-06 Actions incident: queued with zero jobs created, and refusing both cancel ("already completed") and rerun ("already running"). No API path revives them, so a new head is the only way to get CI. The tree is unchanged, so the local 11/11 verification still describes exactly this content. * docs: state colon matching without the change narrative Review asked for comments that stand alone rather than describe what the previous code did. The classify() docstring, the CLASS_TOKENS header, meta.yml and the new test now state current behaviour only. The colon-free-key invariant is kept, moved onto the table it constrains so it is read when that table is edited. Comment-only: orfnormalise.py's token stream is unchanged apart from the docstring literal (5877 tokens before and after, one differing). * test: read the composite ORF types from a real fixture Replaces the inline synthetic rows with genomics/homo_sapiens/riboseq_expression/orf_predictions/sample1.ribotish.extended.pred.txt, added in nf-core/test-datasets#2208, plus the chr20 GTF the other ribotish tests use. Real extended-mode output carries seven colon-qualified TisType values, not the four this test previously covered: 5'UTR:Known, 5'UTR:CDSFrameOverlap and Truncated:Known also occur. All seven resolve through the existing CLASS_TOKENS table, and the test now names each one, so the vocabulary is pinned by the fixture rather than by hand-written rows.
custom/orfnormaliseoverwroteorf_classwithsmORFfor every ORF at or under 100 aa, so a short uORF, a short downstream ORF, a short truncated CDS variant and a short novel intergenic ORF were indistinguishable in exactly the size range microprotein work cares about — and becausecustom/orfmergekeys its clustering strategy onorf_classandcustom/orfcollapsescoped its peptide dedup to that value, the length override also decided how an ORF merged across callers.orf_classis now purely positional and length moves to a separateis_smorfflag driven by a new--smorf-max-aa(default 100, which reproduces the previous membership exactly), and the vocabulary gainsuoORF,doORFandintORF— classes the callers were already reporting and the substring matching was folding away. This is a breaking change toorf_classvalues; it needs the refreshed fixture from nf-core/test-datasets#2193, which is merged.AI-assisted detail — keep or bin as you see fit
Why the classes were being lost
Tokens were matched by substring, so
overlap_uORFmatched theuorfrule. Verified against each caller's source:Overlap_uORF/Overlap_dORFuORF/dORFuoORF/doORFoverlap_uORF/overlap_dORFuORF/dORFuoORF/doORFinternal/InternalotherintORFuoORF,iORF,orphanuORF,other,otheruoORF,intORF,novel_uMatching is now exact per caller — every vocabulary is a closed enum — and the count of tokens matching no entry is reported as
unmapped_orf_typeon the# parser_columns:provenance line, so an upstream label change fails loudly instead of landing in the wrong class. It is 0 for all five callers on the chr20 fixtures.ribotricer's
internaldeliberately staysother: it ischeck_orf_type()'s terminal fall-through rather than a frame-tested call, sointORFwould assert a relationship the tool never tested.Why
orf_classhad to leave the merge keyCallers disagree on class for the same ORF, and Ribo-TISH cannot agree even in principle —
tisType()tests only the start position, so5'UTRcovers both uORF and uoORF. Keying on class emits one row per disagreeing caller.orfmergenow partitions by clustering strategy with class absent from every grouping key, andrepresentative()resolves the disagreement viaCLASS_SPECIFICITY(most specific first). There is a new test where four callers report the same ORF asOverlap_uORF,overlap_uORF,uoORFand5'UTRand collapse to oneuoORFrow.Two further clustering fixes fell out of that:
canonical_cdsis grouped by transcript and then overlap-clustered within it, so a short truncated variant is no longer folded into the full-length CDS and emitted as the longest member only. Transcript-anchored classes keep the exact-span key: overlap clustering merges nested ORFs, and a 100 aa uORF covering 84% of its CDS folded into the CDS when I tried it. Separating "same ORF, fuzzy bounds" from "different ORF, mostly nested" needs a shared-stop-codon test, which I have left for a follow-up. The consequence — two callers whose short-ORF bounds differ by a few nt give one row each — is pinned by a test.Unknown
orf_classvalues and unassigned rows now abort rather than being dropped from the catalogue silently, which the previous closed enumeration did.orfcollapse
Eligibility is derived from
aa_lengthplus--smorf-max-aarather than a propagated flag, so the collapse scope cannot drift from the publishedis_smorf, and the module aborts if the columns it depends on are missing. The survivor of a cross-class peptide fold now prefers the more specific class: previously it was chosen by length alone, so a 40 aa annotated CDS was deleted by a 60 aa novel ORF sharing its peptide and the MultiQC table reported zerocanonical_cds.New column
orf_type_nativecarries each caller's own ORF-type label through to the catalogue (unioned across a cluster), so every mapping decision above is auditable without re-running callers.Testing
Nine tests on orfnormalise, seven on orfmerge, two on orfcollapse. New: the
is_smorfboundary at--smorf-max-aaand at +1 withorf_classunchanged; a non-default--smorf-max-aaoverride; the four-caller class-disagreement merge;canonical_cdsvariant separation; and the pinned short-ORF de-merge. The two synthetic tests from #12495 gain the two new columns — without them the new presence guard aborts.Snapshots are re-recorded. Content md5s come from a local run, which is byte-identical to CI for deterministic outputs;
versions.ymlmd5s are kept from the committed snapshots, since those carry the container's interpreter rather than my host's. I could not run the module containers locally (no DNS in my Docker VM), so every test was executed container-free and CI is the authority on that last piece.