Skip to content
Open
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
6 changes: 4 additions & 2 deletions modules/nf-core/custom/orfnormalise/meta.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@ description: |
`orf_type_native` carries the caller's own ORF-type label verbatim, so every
harmonisation decision below stays auditable without re-running the caller.
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.
vocabulary, on the part before any colon so that a qualified label such as
Ribo-TISH's `Novel:CDSFrameOverlap` matches on `Novel`. Tokens whose location
matches no entry cause the process to fail; successful outputs report
`unmapped_orf_type=0` on the `# parser_columns:` provenance line.

`orf_class` is purely positional: it records where the ORF sits relative to
the annotated CDS and never encodes its length. Select small ORFs with the
Expand Down
14 changes: 10 additions & 4 deletions modules/nf-core/custom/orfnormalise/templates/orfnormalise.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,8 @@ def write_outputs(bed_path, tsv_path, bed_lines, tsv_rows, parser_columns, unmap
# Every caller's ORF-type vocabulary is a closed enum, so tokens are matched
# exactly (casefolded) rather than by substring. Substring matching mis-fired
# on the overlap forms, because "uorf" is a substring of "overlap_uorf".
#
# No key may contain a colon: classify() matches only the part before one.
# ----------------------------------------------------------------------------

CLASS_TOKENS = {
Expand Down Expand Up @@ -498,13 +500,17 @@ def write_outputs(bed_path, tsv_path, bed_lines, tsv_rows, parser_columns, unmap
def classify(caller, orf_type):
"""Map a caller's native ORF-type token to the harmonised class.

Returns (orf_class, matched); `matched` is False when a non-empty token
matched no entry, so unmapped labels can be counted rather than silently
absorbed into `other`.
Only the part before the first colon is matched; the full token survives in
`orf_type_native`.

Returns (orf_class, matched); `matched` is False when a non-empty token's
location matched no entry, so unmapped labels can be counted rather than
silently absorbed into `other`.
"""
if not orf_type:
return "other", True
cls = CLASS_TOKENS[caller].get(orf_type.strip().casefold())
location = orf_type.split(":", 1)[0]
cls = CLASS_TOKENS[caller].get(location.strip().casefold())
if cls is None:
return "other", False
return cls, True
Expand Down
46 changes: 46 additions & 0 deletions modules/nf-core/custom/orfnormalise/tests/main.nf.test
Original file line number Diff line number Diff line change
Expand Up @@ -431,4 +431,50 @@ nextflow_process {
}
}

test("ribotish composite ORF types map on the location, keeping the qualifier native") {

when {
process {
"""
input[0] = channel
.of(
'Tid\tGid\tGenomePos\tTisType\tAALen\tFisherPvalue',

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.

Do you have a real input we could use as a proper fixture in test-datasets? The existing one (genomics/homo_sapiens/riboseq_expression/orf_predictions/sample1.ribotish.pred.txt) obviously isn't representative enough, so we should consider adding another one rather than embedding dummy data here.

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.

Yes — real output with these tokens exists, so this needn't stay synthetic. The composite labels are what made a pipeline-level snapshot-recording run fail on genuine chr20 Ribo-TISH output, with this distribution:

Novel:CDSFrameOverlap     29
Novel:Known                4
3'UTR:CDSFrameOverlap      1
Internal:CDSFrameOverlap   1

That is also why the module tests missed the gap — the existing fixture only contains bare tokens, exactly as you say.

Two choices I would rather you made before I open the test-datasets PR, since both affect other modules:

  1. A second file, or make the existing one representative? My instinct is to add a new file (sample1.ribotish.composite.pred.txt or similar) rather than modify sample1.ribotish.pred.txt in place. Editing a shared fixture is what briefly broke custom/orfcollapse when Refresh the custom/orfcollapse fixture for the positional orf_class vocabulary test-datasets#2193 merged — there is no version gate, so every consumer picks the change up immediately and only notices when something next touches it. But if you would rather the existing fixture simply became representative, that is fine too and I will sequence the module PRs behind it.
  2. Region. The existing sample1.* family is chr20, and real composite tokens are plentiful there, so the family can stay on one region unless you want otherwise.

Once you have picked, the order is test-datasets first, then this PR swaps the inline rows for the fixture. Happy to do both.

@pinin4fjords pinin4fjords Aug 9, 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.

We try not to overwrite files in test-datasets, they should really be write-once (though it's patchily followed). Add a new one.

't1\tg1\tchr1:1000-1100:+\tNovel:CDSFrameOverlap\t30\t0.001',
"t1\tg1\tchr1:1100-1200:+\t3'UTR:CDSFrameOverlap\t30\t0.001",
't1\tg1\tchr1:1200-1300:+\tInternal:CDSFrameOverlap\t30\t0.001',
't1\tg1\tchr1:1300-1400:+\tNovel:Known\t30\t0.001'
)
.collectFile(name: 'composite.ribotish.pred.txt', newLine: true, sort: false)
.map { orfs -> [[id: 'sample1'], orfs, 'ribotish'] }
input[1] = channel
.of('chr1\ttest\texon\t1001\t2000\t.\t+\t.\tgene_id "g1"; transcript_id "t1";')
.collectFile(name: 'composite.gtf', newLine: true)
.map { gtf -> [[id: 'reference'], gtf] }
"""
}
}

then {
def lines = path(process.out.tsv[0][1]).text.readLines()
def header = lines.find { it.startsWith('orf_id') }.split('\t') as List
def data = lines.findAll { !it.startsWith('#') && !it.startsWith('orf_id') }
def i_class = header.indexOf('orf_class')
def i_native = header.indexOf('orf_type_native')
def classes = data.collect { it.split('\t')[i_class] } as Set
def natives = data.collect { it.split('\t')[i_native] }
def qualified = natives.findAll { it.contains(':') }
def provenance = lines.find { it.startsWith('# parser_columns:') }

assertAll(
// The part before the colon selects the class; the qualifier survives
// in orf_type_native.
{ assert process.success },
{ assert classes == ['novel_u', 'dORF', 'intORF'] as Set },
{ assert qualified.size() == natives.size() },
{ assert natives.contains('Novel:CDSFrameOverlap') },
{ assert provenance.contains('unmapped_orf_type=0') }
)
}
}

}