Skip to content

Commit 0cb7124

Browse files
committed
MILAB-6848: derive bulk chain slot from the locus instead of assuming A
1 parent 7b93ff5 commit 0cb7124

5 files changed

Lines changed: 107 additions & 3 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,5 @@ Per-chain CDR3 descriptions no longer name antibody loops (CDR-H3 / CDR-L3) on T
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.
1313

1414
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.
15+
16+
Derives the chain slot from the locus on bulk input instead of assuming chain A. Bulk TCRAlpha, TCRGamma, IGLight, IGKappa and IGLambda datasets were labelled as their paired partner; their emitted scClonotypeChain domain now reads B rather than A.

docs/spec-deviations.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,3 +693,66 @@ across both bands by `Test_buildColumns_tableOrderFollowsSpokenNaming` in
693693
- Spec requiring correction: `pcolumn-spec.md:295` and the slot-keyed
694694
orderPriority values through L240-L420.
695695
- Predecessor: SD-010. Default-axis spec: `README.md` R19, R20, R19a, R20a.
696+
697+
---
698+
699+
## SD-012: Derive The Chain Slot From The Locus On Bulk Input
700+
701+
**Status:** applied
702+
**Date:** 2026-09-01
703+
**Affected file:** `workflow/src/columns.lib.tengo` (`chainToSlot`),
704+
`workflow/src/main.tpl.tengo` (per-column chain resolution)
705+
706+
### Root cause
707+
708+
Spec R13 reads chain identity from `pl7.app/vdj/scClonotypeChain`. Bulk MiXCR
709+
does not emit that key at all: it names the locus on the key axis via
710+
`pl7.app/vdj/chain` (`"IGHeavy"`, `"TCRAlpha"`, ...), one locus per dataset.
711+
The code filled the gap by assuming slot `"A"` for every bulk column.
712+
713+
While slot `A` was believed to be alpha (pre-SD-010), that assumption happened
714+
to label bulk alpha datasets correctly and bulk beta ones wrongly. SD-010
715+
corrected the slot semantics, which flipped the victims rather than removing
716+
them: `TCRAlpha`, `TCRGamma`, `IGLight`, `IGKappa` and `IGLambda` inputs were
717+
labelled as their paired partner, and the R11b coverage messages named the
718+
wrong chain with them.
719+
720+
SD-008 already derives the *receptor* from the same `pl7.app/vdj/chain` key.
721+
The locus determines the slot just as unambiguously, so the information needed
722+
was present and discarded.
723+
724+
### Options considered
725+
726+
**A. Derive the slot from the locus. [chosen]** `chainToSlot` maps the eight
727+
MiXCR loci onto the slot MiXCR itself seats them in, per its diversity-first
728+
`receptorInfos` ordering. Unknown or absent loci keep the previous `"A"`
729+
default, so non-MiXCR producers are unaffected.
730+
**B. Emit no chain domain on bulk input.** Truer to the data, since a bulk set
731+
has no pairing, but changes the column shape and breaks consumers keying on it.
732+
**C. Keep assuming `"A"`.** Leaves half of all bulk receptors mislabelled.
733+
734+
### Decision
735+
736+
**A.** The derivation already existed for the receptor (SD-008); extending it
737+
to the slot uses the same key and the same MiXCR rule.
738+
739+
### Emitted domain changes on affected datasets
740+
741+
This is not label-only. On bulk `TCRAlpha`, `TCRGamma`, `IGLight`, `IGKappa`
742+
and `IGLambda` input the emitted `pl7.app/vdj/scClonotypeChain` domain moves
743+
from `"A"` to `"B"`, which changes PColumn identity for those datasets. The new
744+
value is the correct one, and a consumer that matched the old `"A"` was
745+
matching a chain that was never there. Bulk `IGHeavy`, `TCRBeta` and `TCRDelta`
746+
are unchanged, as is all paired single-cell input.
747+
748+
### Implementation
749+
750+
`chainToSlot(chain)` in `columns.lib.tengo`, applied in `main.tpl.tengo` where
751+
`scClonotypeChain` is absent, preferring a per-column `pl7.app/vdj/chain` and
752+
falling back to the key axis. Covered by
753+
`Test_chainToSlot_locusSeatsTheDiverseChainInA`.
754+
755+
### References
756+
757+
- Predecessors: SD-008 (receptor from the same key), SD-010 (slot semantics).
758+
- Slot ordering: `blocks/mixcr-clonotyping/workflow/src/process.tpl.tengo:39-44`.

workflow/src/columns.lib.tengo

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
// at pframe-build time.
1212
// - `aaFractionColumn(spec, feat)` — 2-axis AA-fraction column descriptor
1313
// (whole-sequence modes: peptide / amplicon).
14+
// - `chainToSlot(chain)` — MiXCR locus ("TCRAlpha", "IGLight", ...)
15+
// → the A/B slot it occupies, for bulk
16+
// input that carries no scClonotypeChain.
1417
// - `cloneSpec(spec, dExtras, aExtras)` — spec-cloning helper used by the
1518
// caller to layer blockId / isOutput /
1619
// any other per-consumer overrides.
@@ -54,6 +57,25 @@ displaysFirst := func(receptor, chain) {
5457
return chain == "A"
5558
}
5659

60+
// Bulk input names its locus but has no slot; MiXCR seats the D-recombining chain in A.
61+
CHAIN_TO_SLOT := {
62+
IGHeavy: "A",
63+
IGLight: "B",
64+
IGKappa: "B",
65+
IGLambda: "B",
66+
TCRBeta: "A",
67+
TCRAlpha: "B",
68+
TCRDelta: "A",
69+
TCRGamma: "B"
70+
}
71+
72+
chainToSlot := func(chain) {
73+
if chain == undefined { return "A" }
74+
s := CHAIN_TO_SLOT[chain]
75+
if s == undefined { return "A" }
76+
return s
77+
}
78+
5779
// Build a single output column descriptor consumed by xsv.importFile.
5880
// `tsvCol` is the TSV column header emitted by Python (e.g. "charge_peptide", "charge_A_CDR3").
5981
// Clones the caller's `annotations` dict so the label stamp does not aliasing-
@@ -480,5 +502,6 @@ aaFractionColumn := func(keyAxisSpec, feature) {
480502
export ll.toStrict({
481503
buildColumns: buildColumns,
482504
aaFractionColumn: aaFractionColumn,
505+
chainToSlot: chainToSlot,
483506
cloneSpec: cloneSpec
484507
})

workflow/src/columns.test.tengo

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,19 @@ Test_buildColumns_tableOrderFollowsSpokenNaming := func() {
6969
}
7070
}
7171

72+
Test_chainToSlot_locusSeatsTheDiverseChainInA := func() {
73+
seats := {
74+
IGHeavy: "A", IGLight: "B", IGKappa: "B", IGLambda: "B",
75+
TCRBeta: "A", TCRAlpha: "B",
76+
TCRDelta: "A", TCRGamma: "B"
77+
}
78+
for locus, slot in seats {
79+
test.isEqual(slot, columns.chainToSlot(locus), locus + " slot")
80+
}
81+
test.isEqual("A", columns.chainToSlot(undefined), "missing locus defaults to A")
82+
test.isEqual("A", columns.chainToSlot("Nonsense"), "unknown locus defaults to A")
83+
}
84+
7285
Test_buildColumns_chainDomainIsIndependentOfTheLabel := func() {
7386
for receptor in ["IG", "TCRAB", "TCRGD"] {
7487
cols := pairedCols(receptor)

workflow/src/main.tpl.tengo

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,10 +202,12 @@ wf.body(func(args) {
202202
// Spec deviation SD-008 — bulk MiXCR axes lack `pl7.app/vdj/receptor` but
203203
// carry `pl7.app/vdj/chain`; derive receptor from chain when receptor is
204204
// absent. See docs/spec-deviations.md.
205+
axisChain := undefined
205206
if keyAxisSpec.domain != undefined {
207+
axisChain = keyAxisSpec.domain["pl7.app/vdj/chain"]
206208
r := resolveReceptor(
207209
keyAxisSpec.domain["pl7.app/vdj/receptor"],
208-
keyAxisSpec.domain["pl7.app/vdj/chain"]
210+
axisChain
209211
)
210212
if r.seen {
211213
receptor = r.value
@@ -268,8 +270,9 @@ wf.body(func(args) {
268270

269271
chain := d["pl7.app/vdj/scClonotypeChain"]
270272
if chain == undefined || chain == "" {
271-
// Bulk MiXCR data without chain annotation — assume primary chain "A".
272-
chain = "A"
273+
// Bulk has no slot; derive it from the locus so the label names the real chain.
274+
colChain := d["pl7.app/vdj/chain"]
275+
chain = columnSpecs.chainToSlot(colChain != undefined ? colChain : axisChain)
273276
}
274277

275278
// Same receptor value is expected on every input column; last seen wins.

0 commit comments

Comments
 (0)