Skip to content

Commit 7b93ff5

Browse files
committed
MILAB-6848: ensure sc TCR chains are displayed in the same order as in literature: TCRAB, TCRGD
1 parent 6696896 commit 7b93ff5

5 files changed

Lines changed: 102 additions & 6 deletions

File tree

.changeset/tcr-chain-label-mapping.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,5 @@ Fix inverted TCR chain labels. Chain letter A is the D-recombining chain, so for
1010
Per-chain CDR3 descriptions no longer name antibody loops (CDR-H3 / CDR-L3) on TCR input.
1111

1212
Adds a Tengo unit test covering the chain-letter to label mapping for all three receptors, and wires `pl-tengo test` into the workflow package so it runs.
13+
14+
Orders paired-chain columns by the receptor's spoken naming — alpha before beta, gamma before delta — instead of by chain slot. IG keeps heavy before light. The default scatter and histogram source stays on chain A.

docs/spec-deviations.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,3 +634,62 @@ stays independent of the label.
634634
- Public docs: `docs/docs.platforma.bio/docs/30-sdk/100-vdj-guides/60-naming-conventions.md`.
635635
- Still inverted, tracked separately:
636636
`antibody-tcr-lead-selection/workflow/src/utils.lib.tengo:702-705`.
637+
638+
---
639+
640+
## SD-011: Table Order Follows Spoken Chain Naming, Not Slot Order
641+
642+
**Status:** applied
643+
**Date:** 2026-08-31
644+
**Affected file:** `workflow/src/columns.lib.tengo` (`displaysFirst`,
645+
`buildCdr3Columns`, `buildFullChainColumns`)
646+
647+
### Root cause
648+
649+
`pl7.app/table/orderPriority` was keyed to the chain *slot*`"A"` always took
650+
the higher band — and the spec fixes those numbers slot-wise from the IG
651+
perspective (`pcolumn-spec.md:295`: `"67000" // 66000 for light chain`). For IG
652+
that is invisible, since heavy is both the first-named and the slot-`A` chain.
653+
For TCR the two rules diverge, because slot `A` is the D-recombining chain
654+
(SD-010) — beta, not alpha. The table therefore led with `CDR-β3`, leaking
655+
MiXCR's diversity-first slot assignment into the column order exactly as the
656+
labels did before SD-010.
657+
658+
### Options considered
659+
660+
**A. Order by the receptor's spoken naming. [chosen]** Assign the existing
661+
bands by a (receptor, chain) display rank instead of the slot letter. IG keeps
662+
heavy-then-light; no name, domain or value changes.
663+
**B. Leave ordering on the slot.** Matches the spec's literal numbers, but
664+
keeps a producer artifact in front of the scientist.
665+
**C. Order by slot and rename labels to match.** Rejected — re-introduces the
666+
SD-010 bug.
667+
668+
### Decision
669+
670+
**A.** Slot identity belongs to the producer; the label and the reading order
671+
are the user-facing surface. SD-010 established that for labels, and column
672+
position is the same surface reached a different way. Nothing cross-block is
673+
broken: `import-vdj-data` keys `orderPriority` per region, identical for both
674+
chains (`bare-set-specs.lib.tengo:331`), so its chain order is incidental.
675+
676+
### Deliberately not changed: the default plot axis
677+
678+
`ui/src/utils/scalarColumns.ts` still selects `scClonotypeChain: "A"` for the
679+
default scatter and histogram source, per R19/R20. Table order is a reading
680+
convention; the default plotted chain is an analytical one, and beta/delta carry
681+
the greater CDR3 diversity. The chart labels its own axes, so the divergence is
682+
unambiguous. The R19a/R20a fallback reads *emission* order — unchanged here — so
683+
it also still lands on chain `A`.
684+
685+
### Implementation
686+
687+
`displaysFirst(receptor, chain)` in `columns.lib.tengo`, pinned per receptor
688+
across both bands by `Test_buildColumns_tableOrderFollowsSpokenNaming` in
689+
`workflow/src/columns.test.tengo`.
690+
691+
### References
692+
693+
- Spec requiring correction: `pcolumn-spec.md:295` and the slot-keyed
694+
orderPriority values through L240-L420.
695+
- Predecessor: SD-010. Default-axis spec: `README.md` R19, R20, R19a, R20a.

ui/src/utils/scalarColumns.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export function defaultScatterAxes(
5959
// Antibody/TCR — IG/TCRAB/TCRGD, bulk or single-cell. Domain is identical
6060
// across receptors; the user-facing label (CDR-H3 / CDR-β3 / CDR-δ3) is
6161
// already encoded in spec annotations by the workflow (R13a).
62+
// Deliberately slot A (the more diverse chain) though the table leads with α/γ.
6263
const domain = {
6364
"pl7.app/feature": "CDR3",
6465
"pl7.app/vdj/scClonotypeChain": "A",

workflow/src/columns.lib.tengo

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ labelFragments := func(receptor, chain) {
4848
return { cdr3: "CDR-L3", fullChain: "VL" }
4949
}
5050

51+
// Table order follows the receptor's spoken naming — heavy/light, α/β, γ/δ — not slot order.
52+
displaysFirst := func(receptor, chain) {
53+
if receptor == "TCRAB" || receptor == "TCRGD" { return chain == "B" }
54+
return chain == "A"
55+
}
56+
5157
// Build a single output column descriptor consumed by xsv.importFile.
5258
// `tsvCol` is the TSV column header emitted by Python (e.g. "charge_peptide", "charge_A_CDR3").
5359
// Clones the caller's `annotations` dict so the label stamp does not aliasing-
@@ -192,12 +198,12 @@ CDR3_GRAVY_DESC := {
192198

193199
buildCdr3Columns := func(receptor, chains) {
194200
cols := []
195-
cdr3OrderA := 68000
196-
cdr3OrderB := 67700
201+
cdr3OrderFirst := 68000
202+
cdr3OrderSecond := 67700
197203
for chain in chains {
198204
frag := labelFragments(receptor, chain)
199205
cdr3Dom := { "pl7.app/feature": "CDR3", "pl7.app/vdj/scClonotypeChain": chain }
200-
chargeOrder := (chain == "A" ? cdr3OrderA : cdr3OrderB)
206+
chargeOrder := (displaysFirst(receptor, chain) ? cdr3OrderFirst : cdr3OrderSecond)
201207
gravyOrder := chargeOrder - 100
202208

203209
cols += [makeCol("charge_" + chain + "_CDR3", "pl7.app/charge", "Double",
@@ -235,12 +241,12 @@ buildCdr3Columns := func(receptor, chains) {
235241

236242
buildFullChainColumns := func(receptor, fullChains) {
237243
cols := []
238-
fcOrderBaseA := 67000
239-
fcOrderBaseB := 66000
244+
fcOrderBaseFirst := 67000
245+
fcOrderBaseSecond := 66000
240246
for chain in fullChains {
241247
frag := labelFragments(receptor, chain)
242248
fcDom := { "pl7.app/feature": "VDJRegion", "pl7.app/vdj/scClonotypeChain": chain }
243-
base := (chain == "A" ? fcOrderBaseA : fcOrderBaseB)
249+
base := (displaysFirst(receptor, chain) ? fcOrderBaseFirst : fcOrderBaseSecond)
244250
fcLabel := frag.fullChain
245251

246252
cols += [makeCol("charge_" + chain + "_VDJRegion", "pl7.app/charge", "Double",

workflow/src/columns.test.tengo

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,34 @@ Test_buildColumns_chainAIsTheDiverseChain := func() {
4141
}
4242
}
4343

44+
orderOf := func(cols, tsvCol) {
45+
for col in cols {
46+
if col.column == tsvCol {
47+
return col.spec.annotations["pl7.app/table/orderPriority"]
48+
}
49+
}
50+
return undefined
51+
}
52+
53+
Test_buildColumns_tableOrderFollowsSpokenNaming := func() {
54+
pairs := [
55+
{ receptor: "IG", first: "A", second: "B" },
56+
{ receptor: "TCRAB", first: "B", second: "A" },
57+
{ receptor: "TCRGD", first: "B", second: "A" }
58+
]
59+
for pair in pairs {
60+
cols := pairedCols(pair.receptor)
61+
test.isEqual("68000", orderOf(cols, "charge_" + pair.first + "_CDR3"),
62+
pair.receptor + " CDR3 leads with chain " + pair.first)
63+
test.isEqual("67700", orderOf(cols, "charge_" + pair.second + "_CDR3"),
64+
pair.receptor + " CDR3 trails with chain " + pair.second)
65+
test.isEqual("67000", orderOf(cols, "charge_" + pair.first + "_VDJRegion"),
66+
pair.receptor + " full-chain leads with chain " + pair.first)
67+
test.isEqual("66000", orderOf(cols, "charge_" + pair.second + "_VDJRegion"),
68+
pair.receptor + " full-chain trails with chain " + pair.second)
69+
}
70+
}
71+
4472
Test_buildColumns_chainDomainIsIndependentOfTheLabel := func() {
4573
for receptor in ["IG", "TCRAB", "TCRGD"] {
4674
cols := pairedCols(receptor)

0 commit comments

Comments
 (0)