diff --git a/.changeset/tcr-chain-label-mapping.md b/.changeset/tcr-chain-label-mapping.md new file mode 100644 index 0000000..c21c894 --- /dev/null +++ b/.changeset/tcr-chain-label-mapping.md @@ -0,0 +1,16 @@ +--- +'@platforma-open/milaboratories.sequence-properties.workflow': minor +'@platforma-open/milaboratories.sequence-properties.ui': minor +'@platforma-open/milaboratories.sequence-properties.software': minor +'@platforma-open/milaboratories.sequence-properties': minor +--- + +Fix inverted TCR chain labels. Chain letter A is the D-recombining chain, so for TCRAB it is beta and for TCRGD it is delta — CDR3 and full-chain column labels, and the partial-coverage info messages, named the other chain. PColumn names and chain domains are unchanged; only labels move. + +Per-chain CDR3 descriptions no longer name antibody loops (CDR-H3 / CDR-L3) on TCR input. + +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. + +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. + +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. diff --git a/docs/spec-deviations.md b/docs/spec-deviations.md index 516e476..a5fe634 100644 --- a/docs/spec-deviations.md +++ b/docs/spec-deviations.md @@ -683,3 +683,220 @@ is `pl-tengo test`, matching `blocks/repertoire-labeling`. - Cross-block survey and spec atom A-0060: `synthetic-repertoire-profiler/dms-modality.md`. - Reference migration: `antibody-sequence-liabilities/model/src/index.ts` (`declaredModality`), `workflow/src/main.tpl.tengo`. + +--- + +## SD-011: Chain Letter "A" Is The D-Recombining Chain, Not Alpha/Gamma + +**Status:** applied +**Date:** 2026-08-31 +**Affected file:** `workflow/src/columns.lib.tengo` (`labelFragments`), +`workflow/src/main.tpl.tengo` (`chainLabel`) + +### Symptom + +On paired αβ TCR input the CDR3 and full-chain labels named the wrong chain: +the column labelled `CDR-α3 Net Charge (pH 7)` carried the beta chain's +values, and `CDR-β3` carried alpha's. Same inversion on γδ (`CDR-γ3` showed +delta), and in the R11b partial-coverage info messages ("…found for alpha +chain" on beta data). + +### Root cause + +The spec states the opposite mapping. `README.md` R13 (L210-212) and +`pcolumn-spec.md` (L204-218) both assert that for `TCRAB` chain `"A"` is TRA +(alpha) and for `TCRGD` chain `"A"` is TRG (gamma), with an explicit note: +*"for αβ TCR, chain 'A' is alpha, not beta."* The implementation followed the +spec. + +The producer does the reverse. MiXCR orders a receptor's chain slots by +diversity — the D-recombining chain first — and the slot index is what becomes +the `pl7.app/vdj/scClonotypeChain` letter: + +```tengo +// mixcr-clonotyping/workflow/src/process.tpl.tengo:39-44 +// Chain with higher diversity go first +"IG": { chains: ["IGHeavy", "IGLight"] }, +"TCRAB": { chains: ["TCRBeta", "TCRAlpha"] }, +"TCRGD": { chains: ["TCRDelta", "TCRGamma"] } +``` + +So `"A"` is heavy / beta / delta. The spec's cited source +(`antibody-tcr-lead-selection/workflow/src/utils.lib.tengo`) is itself +inverted for TCR and is the origin of the error. R13 carried a pre-M1 gate +requiring this mapping be verified against real MiXCR output; this is that +verification. + +### Trigger + +Any paired TCR dataset — αβ or γδ. Confirmed on single-cell TCRAB. + +### Impact (before fix) + +Labels only. PColumn `name` values and the `pl7.app/vdj/scClonotypeChain` +domain (`"A"`/`"B"`) were correct throughout, so downstream blocks selecting +by spec were unaffected. A user reading the table, or picking a default +scatter axis by label, saw one chain's values attributed to the other. + +### Options considered + +**A. Follow the producer; correct the labels. [chosen]** +Two label maps change. Names and domains untouched, so no schema impact and +no downstream re-keying. + +**B. Follow the spec and leave the labels as they were.** +Requires MiXCR to renumber its chain slots — out of scope, and would break +every block already reading the A/B convention. + +**C. Emit the concrete chain name instead of a slot-derived label.** +Removes the ambiguity at the source, but changes every TCR column label and +needs the concrete chain on paired input, which single-cell does not carry +per column. + +### Decision + +**A.** Three shipped components independently encode A = D-recombining chain: +the producer (`mixcr-clonotyping` above), `import-vdj-data` +(`bare-set-specs.lib.tengo:64-71`, `PAIRED_CHAIN_DOMAIN`), and +`antibody-sequence-liabilities` (`main.tpl.tengo:25-29`, with the same +rationale in a comment). The SDK naming-conventions guide agrees. The spec and +`antibody-tcr-lead-selection` are the outliers. + +### Implementation + +`labelFragments(receptor, chain)` in `columns.lib.tengo` and `chainLabel(ch)` +in `main.tpl.tengo`. Covered by `workflow/src/columns.test.tengo`, which pins +the slot→label mapping for all three receptors and asserts the chain domain +stays independent of the label. + +### References + +- Spec sections requiring correction: `README.md` R13 (L210-212), the R13a + label table (L218), L451, the γδ edge-case row (L587); `pcolumn-spec.md` + L204-218. +- Producer: `blocks/mixcr-clonotyping/workflow/src/process.tpl.tengo:39-44`. +- Public docs: `docs/docs.platforma.bio/docs/30-sdk/100-vdj-guides/60-naming-conventions.md`. +- Still inverted, tracked separately: + `antibody-tcr-lead-selection/workflow/src/utils.lib.tengo:702-705`. + +--- + +## SD-012: Table Order Follows Spoken Chain Naming, Not Slot Order + +**Status:** applied +**Date:** 2026-08-31 +**Affected file:** `workflow/src/columns.lib.tengo` (`displaysFirst`, +`buildCdr3Columns`, `buildFullChainColumns`) + +### Root cause + +`pl7.app/table/orderPriority` was keyed to the chain *slot* — `"A"` always took +the higher band — and the spec fixes those numbers slot-wise from the IG +perspective (`pcolumn-spec.md:295`: `"67000" // 66000 for light chain`). For IG +that is invisible, since heavy is both the first-named and the slot-`A` chain. +For TCR the two rules diverge, because slot `A` is the D-recombining chain +(SD-011) — beta, not alpha. The table therefore led with `CDR-β3`, leaking +MiXCR's diversity-first slot assignment into the column order exactly as the +labels did before SD-011. + +### Options considered + +**A. Order by the receptor's spoken naming. [chosen]** Assign the existing +bands by a (receptor, chain) display rank instead of the slot letter. IG keeps +heavy-then-light; no name, domain or value changes. +**B. Leave ordering on the slot.** Matches the spec's literal numbers, but +keeps a producer artifact in front of the scientist. +**C. Order by slot and rename labels to match.** Rejected — re-introduces the +SD-011 bug. + +### Decision + +**A.** Slot identity belongs to the producer; the label and the reading order +are the user-facing surface. SD-011 established that for labels, and column +position is the same surface reached a different way. Nothing cross-block is +broken: `import-vdj-data` keys `orderPriority` per region, identical for both +chains (`bare-set-specs.lib.tengo:331`), so its chain order is incidental. + +### Deliberately not changed: the default plot axis + +`ui/src/utils/scalarColumns.ts` still selects `scClonotypeChain: "A"` for the +default scatter and histogram source, per R19/R20. Table order is a reading +convention; the default plotted chain is an analytical one, and beta/delta carry +the greater CDR3 diversity. The chart labels its own axes, so the divergence is +unambiguous. The R19a/R20a fallback reads *emission* order — unchanged here — so +it also still lands on chain `A`. + +### Implementation + +`displaysFirst(receptor, chain)` in `columns.lib.tengo`, pinned per receptor +across both bands by `Test_buildColumns_tableOrderFollowsSpokenNaming` in +`workflow/src/columns.test.tengo`. + +### References + +- Spec requiring correction: `pcolumn-spec.md:295` and the slot-keyed + orderPriority values through L240-L420. +- Predecessor: SD-011. Default-axis spec: `README.md` R19, R20, R19a, R20a. + +--- + +## SD-013: Derive The Chain Slot From The Locus On Bulk Input + +**Status:** applied +**Date:** 2026-09-01 +**Affected file:** `workflow/src/columns.lib.tengo` (`chainToSlot`), +`workflow/src/main.tpl.tengo` (per-column chain resolution) + +### Root cause + +Spec R13 reads chain identity from `pl7.app/vdj/scClonotypeChain`. Bulk MiXCR +does not emit that key at all: it names the locus on the key axis via +`pl7.app/vdj/chain` (`"IGHeavy"`, `"TCRAlpha"`, ...), one locus per dataset. +The code filled the gap by assuming slot `"A"` for every bulk column. + +While slot `A` was believed to be alpha (pre-SD-011), that assumption happened +to label bulk alpha datasets correctly and bulk beta ones wrongly. SD-011 +corrected the slot semantics, which flipped the victims rather than removing +them: `TCRAlpha`, `TCRGamma`, `IGLight`, `IGKappa` and `IGLambda` inputs were +labelled as their paired partner, and the R11b coverage messages named the +wrong chain with them. + +SD-008 already derives the *receptor* from the same `pl7.app/vdj/chain` key. +The locus determines the slot just as unambiguously, so the information needed +was present and discarded. + +### Options considered + +**A. Derive the slot from the locus. [chosen]** `chainToSlot` maps the eight +MiXCR loci onto the slot MiXCR itself seats them in, per its diversity-first +`receptorInfos` ordering. Unknown or absent loci keep the previous `"A"` +default, so non-MiXCR producers are unaffected. +**B. Emit no chain domain on bulk input.** Truer to the data, since a bulk set +has no pairing, but changes the column shape and breaks consumers keying on it. +**C. Keep assuming `"A"`.** Leaves half of all bulk receptors mislabelled. + +### Decision + +**A.** The derivation already existed for the receptor (SD-008); extending it +to the slot uses the same key and the same MiXCR rule. + +### Emitted domain changes on affected datasets + +This is not label-only. On bulk `TCRAlpha`, `TCRGamma`, `IGLight`, `IGKappa` +and `IGLambda` input the emitted `pl7.app/vdj/scClonotypeChain` domain moves +from `"A"` to `"B"`, which changes PColumn identity for those datasets. The new +value is the correct one, and a consumer that matched the old `"A"` was +matching a chain that was never there. Bulk `IGHeavy`, `TCRBeta` and `TCRDelta` +are unchanged, as is all paired single-cell input. + +### Implementation + +`chainToSlot(chain)` in `columns.lib.tengo`, applied in `main.tpl.tengo` where +`scClonotypeChain` is absent, preferring a per-column `pl7.app/vdj/chain` and +falling back to the key axis. Covered by +`Test_chainToSlot_locusSeatsTheDiverseChainInA`. + +### References + +- Predecessors: SD-008 (receptor from the same key), SD-011 (slot semantics). +- Slot ordering: `blocks/mixcr-clonotyping/workflow/src/process.tpl.tengo:39-44`. \ No newline at end of file diff --git a/docs/visualizations-plan.md b/docs/visualizations-plan.md index 88ad3ea..d45acc5 100644 --- a/docs/visualizations-plan.md +++ b/docs/visualizations-plan.md @@ -86,7 +86,7 @@ Read modality from `app.model.outputs.info` (existing `WorkflowInfo` carries `mo - `mode === 'peptide'` → look up by `name === 'pl7.app/charge'/'hydrophobicity'` with `domain['pl7.app/feature'] === 'peptide'`. - antibody/TCR (any other mode) → look up by `name === 'pl7.app/charge'/'hydrophobicity'` with `domain['pl7.app/feature'] === 'CDR3'` and `domain['pl7.app/vdj/scClonotypeChain'] === 'A'`. -The label naming (`CDR-H3 / CDR-α3 / CDR-γ3`) is already encoded in column annotations by the workflow (R13a) — graph-maker reads label from spec annotations, no UI-side label work required. +The label naming (`CDR-H3 / CDR-β3 / CDR-δ3`) is already encoded in column annotations by the workflow (R13a) — graph-maker reads label from spec annotations, no UI-side label work required. #### Fallback (R19a, R20a) When the modality default is not present in `propertiesPfCols`, take the first one (histogram) or two (scatter) `PColumnIdAndSpec` entries that pass `dataColumnPredicate`, preserving the workflow's emission order. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c89487e..549ca5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,14 +28,14 @@ catalogs: specifier: 1.1.0 version: 1.1.0 '@platforma-sdk/block-tools': - specifier: 2.14.3 - version: 2.14.3 + specifier: 2.15.1 + version: 2.15.1 '@platforma-sdk/model': specifier: 1.83.0 version: 1.83.0 '@platforma-sdk/tengo-builder': - specifier: 4.0.23 - version: 4.0.23 + specifier: 4.0.26 + version: 4.0.26 '@platforma-sdk/test': specifier: 1.83.2 version: 1.83.2 @@ -80,7 +80,7 @@ importers: version: 1.7.2(@microsoft/api-extractor@7.57.6(@types/node@25.3.2))(@types/node@25.3.2)(@volar/typescript@2.4.28)(@vue/language-core@3.3.10)(rollup@4.53.3)(vue@3.5.25(typescript@7.0.2))(yaml@2.8.1) '@platforma-sdk/block-tools': specifier: 'catalog:' - version: 2.14.3(@types/node@25.3.2) + version: 2.15.1(@types/node@25.3.2) shx: specifier: 'catalog:' version: 0.4.0 @@ -110,7 +110,7 @@ importers: version: link:../workflow '@platforma-sdk/block-tools': specifier: 'catalog:' - version: 2.14.3(@types/node@25.3.2) + version: 2.15.1(@types/node@25.3.2) '@platforma-sdk/model': specifier: 'catalog:' version: 1.83.0 @@ -144,7 +144,7 @@ importers: version: 1.4.0 '@platforma-sdk/block-tools': specifier: 'catalog:' - version: 2.14.3(@types/node@25.3.2) + version: 2.15.1(@types/node@25.3.2) model: dependencies: @@ -175,7 +175,7 @@ importers: version: 1.4.0 '@platforma-sdk/block-tools': specifier: 'catalog:' - version: 2.14.3(@types/node@25.3.2) + version: 2.15.1(@types/node@25.3.2) '@types/node': specifier: '*' version: 25.3.2 @@ -190,7 +190,7 @@ importers: version: 1.8.2 '@platforma-sdk/block-tools': specifier: 'catalog:' - version: 2.14.3(@types/node@25.3.2) + version: 2.15.1(@types/node@25.3.2) test: dependencies: @@ -271,7 +271,7 @@ importers: devDependencies: '@platforma-sdk/tengo-builder': specifier: 'catalog:' - version: 4.0.23 + version: 4.0.26 '@platforma-sdk/test': specifier: 'catalog:' version: 1.83.2(@bytecodealliance/preview2-shim@0.17.8)(@types/node@25.3.2)(vite@8.0.8(@types/node@25.3.2)(yaml@2.8.1)) @@ -982,6 +982,10 @@ packages: resolution: {integrity: sha512-HIjPfGAYRRD0hbq5YSTkWt5XgBiv+yYtw73EjWLxui8eUEeB2ffdgsvH7IhVx2oXjUOL4p7M4i8PBnmdJ3830w==} engines: {node: '>=22.19.0'} + '@milaboratories/pl-client@3.16.1': + resolution: {integrity: sha512-0L00ttBIRyr1vT8HGSwFeW/iBLyUprDTCQd2Da/Bpa9RWcMmU0sXwXF3m754Ortlwy+ZyY5Pg6404/EV7Ph76A==} + engines: {node: '>=22.19.0'} + '@milaboratories/pl-config@1.8.5': resolution: {integrity: sha512-XnfYXSSkRxeImQ21k6I8y5apisvagcSgMGfEeyRxNdSGwUVJbbI8TwJk+XBEDQ4lErW8oqtLxKjFQuHJMzRUoQ==} @@ -1013,6 +1017,9 @@ packages: '@milaboratories/pl-model-backend@1.4.21': resolution: {integrity: sha512-Z2z5J8bglslgXXoi1aySi1ho+/d+ylJiuEaRw9XTNuE5iBM3EQPe0rw095pXGOinTlMpmvUcCI2wkVCraUQ1dA==} + '@milaboratories/pl-model-backend@1.4.24': + resolution: {integrity: sha512-nexeamWHa5CdIcPWxBQihUWJi6VYFAiiFeoR+UVrprmDTMmF1xsnFixgrSjH/VwFttpfRl0sJt66XrYfgviROw==} + '@milaboratories/pl-model-common@1.46.2': resolution: {integrity: sha512-VEeauisApYScvCS8lnK3zpFJ520xuTAodKJmjR8ulHcMrWMyWMfHEdGb7j5OMD0mM/OwTgmQrrJ5eB7Xd+xoOQ==} @@ -1539,6 +1546,10 @@ packages: resolution: {integrity: sha512-CD0NPfUoXiJhl6JArC057oCXbqKkJf5HJsmrlZShnh6lKRaIQlibF8VUqbpBi9+PopsGCQ4/s5HnLR7wQoWDzg==} hasBin: true + '@platforma-sdk/block-tools@2.15.1': + resolution: {integrity: sha512-RNL9uZuWoc6aU9F9O7wbANDn2cpghyfL4UIfcOQ6FJ38IhUo2eJhcmSY1ns++2SqKp6T5vZg8OZUC9rtW+m1ww==} + hasBin: true + '@platforma-sdk/blocks-deps-updater@2.2.0': resolution: {integrity: sha512-p9lBxhFXM9WoRsrJO7dfkiXSK+1m63yIn1sKhBO71eMbhrLMyVYHEOeNf3w5OCdbRF5QsNhXzWuiTmFK3zHFsA==} hasBin: true @@ -1549,8 +1560,11 @@ packages: '@platforma-sdk/package-builder-lib@1.3.0': resolution: {integrity: sha512-CdBjmNo6E1fBxKYWaXa49L/L2WLURxs2f1TAqxLIZlHRE4DZ6E1TEj3jNNKESWp+/9rwtLkTAzmTzNPrDgz+2Q==} - '@platforma-sdk/tengo-builder@4.0.23': - resolution: {integrity: sha512-Qkxpg3wuZQOc6KbEL3pifOCdqOH91TaL3jDluVIm5mz3qpx96KO+To8AT/0nLqmvaF7yv8AP22QiyyqduAhGiw==} + '@platforma-sdk/package-builder-lib@1.4.0': + resolution: {integrity: sha512-ORcuPJ5SM1RiQAOUVM7alWDbOWQ/gUTgLDToP/yAs2uHXhKNvXphgDQTamCX6cseMZ0uKdWzTudJAQAJM1Kvwg==} + + '@platforma-sdk/tengo-builder@4.0.26': + resolution: {integrity: sha512-ARFS42fhlxtM5QdUy7izpPR63vbKaCog26qBE1zqKp7DRe3Xwbdn9CEHTTNoDDZkX9gzRKeHMwDgXJhR2oxMLg==} engines: {node: '>=22'} hasBin: true @@ -7838,6 +7852,24 @@ snapshots: utility-types: 3.11.0 yaml: 2.8.1 + '@milaboratories/pl-client@3.16.1': + dependencies: + '@grpc/grpc-js': 1.14.4 + '@milaboratories/pl-http': 1.2.4 + '@milaboratories/pl-model-common': 1.48.0 + '@milaboratories/ts-helpers': 1.8.6 + '@protobuf-ts/grpc-transport': 2.11.1(@grpc/grpc-js@1.14.4) + '@protobuf-ts/runtime': 2.11.1 + '@protobuf-ts/runtime-rpc': 2.11.1 + canonicalize: 2.1.0 + denque: 2.1.0 + long: 5.3.2 + lru-cache: 11.2.4 + openapi-fetch: 0.15.0 + undici: 7.16.0 + utility-types: 3.11.0 + yaml: 2.8.1 + '@milaboratories/pl-config@1.8.5': dependencies: '@milaboratories/ts-helpers': 1.8.6 @@ -7950,6 +7982,12 @@ snapshots: canonicalize: 2.1.0 zod: 3.25.76 + '@milaboratories/pl-model-backend@1.4.24': + dependencies: + '@milaboratories/pl-client': 3.16.1 + canonicalize: 2.1.0 + zod: 3.25.76 + '@milaboratories/pl-model-common@1.46.2': dependencies: '@milaboratories/helpers': 1.14.2 @@ -8012,7 +8050,7 @@ snapshots: oxlint: 1.63.0 oxlint-plugin-eslint: 1.63.0 rolldown: 1.1.3 - rolldown-plugin-dts: 0.28.2(@volar/typescript@2.4.28)(rolldown@1.1.3)(typescript@7.0.2)(vue-tsc@3.3.10(typescript@5.6.3)) + rolldown-plugin-dts: 0.28.2(@volar/typescript@2.4.28)(rolldown@1.1.3)(typescript@7.0.2)(vue-tsc@3.3.10(typescript@7.0.2)) rollup-plugin-copy: 3.5.0 rollup-plugin-sourcemaps2: 0.5.6(@types/node@25.3.2)(rollup@4.53.3) typescript: 7.0.2 @@ -8058,7 +8096,7 @@ snapshots: oxlint: 1.63.0 oxlint-plugin-eslint: 1.63.0 rolldown: 1.1.3 - rolldown-plugin-dts: 0.28.2(@volar/typescript@2.4.28)(rolldown@1.1.3)(typescript@7.0.2)(vue-tsc@3.3.10(typescript@5.6.3)) + rolldown-plugin-dts: 0.28.2(@volar/typescript@2.4.28)(rolldown@1.1.3)(typescript@7.0.2)(vue-tsc@3.3.10(typescript@7.0.2)) rollup-plugin-copy: 3.5.0 rollup-plugin-sourcemaps2: 0.5.6(@types/node@25.3.2)(rollup@4.53.3) typescript: 7.0.2 @@ -8104,7 +8142,7 @@ snapshots: oxlint: 1.63.0 oxlint-plugin-eslint: 1.63.0 rolldown: 1.1.3 - rolldown-plugin-dts: 0.28.2(@volar/typescript@2.4.28)(rolldown@1.1.3)(typescript@7.0.2)(vue-tsc@3.3.10(typescript@5.6.3)) + rolldown-plugin-dts: 0.28.2(@volar/typescript@2.4.28)(rolldown@1.1.3)(typescript@7.0.2)(vue-tsc@3.3.10(typescript@7.0.2)) rollup-plugin-copy: 3.5.0 rollup-plugin-sourcemaps2: 0.5.6(@types/node@25.3.2)(rollup@4.53.3) typescript: 7.0.2 @@ -8150,7 +8188,7 @@ snapshots: oxlint: 1.63.0 oxlint-plugin-eslint: 1.63.0 rolldown: 1.1.3 - rolldown-plugin-dts: 0.28.2(@volar/typescript@2.4.28)(rolldown@1.1.3)(typescript@7.0.2)(vue-tsc@3.3.10(typescript@5.6.3)) + rolldown-plugin-dts: 0.28.2(@volar/typescript@2.4.28)(rolldown@1.1.3)(typescript@7.0.2)(vue-tsc@3.3.10(typescript@7.0.2)) rollup-plugin-copy: 3.5.0 rollup-plugin-sourcemaps2: 0.5.6(@types/node@25.3.2)(rollup@4.53.3) typescript: 7.0.2 @@ -8613,6 +8651,31 @@ snapshots: - '@types/node' - aws-crt + '@platforma-sdk/block-tools@2.15.1(@types/node@25.3.2)': + dependencies: + '@aws-sdk/client-ecr-public': 3.859.0 + '@aws-sdk/client-s3': 3.859.0 + '@inquirer/prompts': 7.10.1(@types/node@25.3.2) + '@milaboratories/pl-http': 1.2.4 + '@milaboratories/pl-model-backend': 1.4.24 + '@milaboratories/pl-model-common': 1.48.0 + '@milaboratories/pl-model-middle-layer': 1.32.0 + '@milaboratories/resolve-helper': 1.1.3 + '@milaboratories/ts-helpers': 1.8.6 + '@platforma-sdk/blocks-deps-updater': 2.2.0 + '@platforma-sdk/package-builder-lib': 1.4.0 + canonicalize: 2.1.0 + commander: 15.0.0 + lru-cache: 11.2.4 + mime-types: 2.1.35 + tar: 7.4.3 + undici: 7.16.0 + yaml: 2.8.1 + zod: 3.25.76 + transitivePeerDependencies: + - '@types/node' + - aws-crt + '@platforma-sdk/blocks-deps-updater@2.2.0': dependencies: yaml: 2.8.1 @@ -8644,9 +8707,22 @@ snapshots: transitivePeerDependencies: - aws-crt - '@platforma-sdk/tengo-builder@4.0.23': + '@platforma-sdk/package-builder-lib@1.4.0': dependencies: - '@milaboratories/pl-model-backend': 1.4.21 + '@aws-sdk/client-s3': 3.859.0 + '@aws-sdk/lib-storage': 3.859.0(@aws-sdk/client-s3@3.859.0) + '@milaboratories/resolve-helper': 1.1.3 + archiver: 7.0.1 + undici: 7.16.0 + winston: 3.17.0 + yaml: 2.8.1 + zod: 3.25.76 + transitivePeerDependencies: + - aws-crt + + '@platforma-sdk/tengo-builder@4.0.26': + dependencies: + '@milaboratories/pl-model-backend': 1.4.24 '@milaboratories/resolve-helper': 1.1.3 '@milaboratories/tengo-tester': 1.6.4 '@milaboratories/ts-helpers': 1.8.6 @@ -13638,7 +13714,7 @@ snapshots: dependencies: glob: 10.4.5 - rolldown-plugin-dts@0.28.2(@volar/typescript@2.4.28)(rolldown@1.1.3)(typescript@7.0.2)(vue-tsc@3.3.10(typescript@5.6.3)): + rolldown-plugin-dts@0.28.2(@volar/typescript@2.4.28)(rolldown@1.1.3)(typescript@7.0.2)(vue-tsc@3.3.10(typescript@7.0.2)): dependencies: dts-resolver: 3.0.0 get-tsconfig: 5.0.0-beta.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 80838a9..d58380f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,13 +12,13 @@ catalog: "@milaboratories/ts-builder": 1.7.2 "@milaboratories/ts-configs": 1.4.0 "@platforma-sdk/workflow-tengo": 6.8.3 - "@platforma-sdk/block-tools": 2.14.3 + "@platforma-sdk/block-tools": 2.15.1 "@platforma-sdk/block-kind": 1.1.0 "@platforma-sdk/model": 1.83.0 "@platforma-sdk/ui-vue": 1.83.3 "@platforma-sdk/test": 1.83.2 "@milaboratories/helpers": 1.14.5 - "@platforma-sdk/tengo-builder": 4.0.23 + "@platforma-sdk/tengo-builder": 4.0.26 "@platforma-sdk/package-builder": 3.15.0 "@platforma-open/milaboratories.runenv-python-3": 1.8.2 diff --git a/software/src/pipeline.py b/software/src/pipeline.py index 4d7c12f..74933d6 100644 --- a/software/src/pipeline.py +++ b/software/src/pipeline.py @@ -126,7 +126,7 @@ def run(reads: pl.DataFrame, plan: dict[str, Any]) -> dict[str, Any]: - `aa_fraction` (DataFrame): long-format (entity_key, aminoAcid, value). Empty body when mode is not peptide. - `stats` (dict): dataset-level stats consumed by the workflow info layer - (e.g. R11c VHH detection — median CDR-H3 length per chain; + (e.g. R11c VHH detection — median CDR3 length per chain; R9 — peptide count below the Instability Index length floor). """ mode = plan["mode"] diff --git a/software/src/pka_tables.py b/software/src/pka_tables.py index 28e1034..fd4d0b9 100644 --- a/software/src/pka_tables.py +++ b/software/src/pka_tables.py @@ -5,7 +5,7 @@ - **Peptide set**: short, fully solvent-exposed residues. Used for peptide-mode inputs and for CDR3 sequences regardless of full-chain availability. - **Protein set**: residues in a folded globular-domain context. Used for - reconstructed full VH / VL chains. + reconstructed full variable-domain chains. Source: Kozlowski LP. *IPC 2.0: prediction of isoelectric point and pKa dissociation constants.* Nucleic Acids Research 49(W1):W285-W292 (2021). diff --git a/ui/src/utils/scalarColumns.ts b/ui/src/utils/scalarColumns.ts index 9d1c4e2..0c60997 100644 --- a/ui/src/utils/scalarColumns.ts +++ b/ui/src/utils/scalarColumns.ts @@ -57,8 +57,9 @@ export function defaultScatterAxes( }; } // Antibody/TCR — IG/TCRAB/TCRGD, bulk or single-cell. Domain is identical - // across receptors; the user-facing label (CDR-H3 / CDR-α3 / CDR-γ3) is + // across receptors; the user-facing label (CDR-H3 / CDR-β3 / CDR-δ3) is // already encoded in spec annotations by the workflow (R13a). + // Deliberately slot A (the more diverse chain) though the table leads with α/γ. const domain = { "pl7.app/feature": "CDR3", "pl7.app/vdj/scClonotypeChain": "A", diff --git a/workflow/src/columns.lib.tengo b/workflow/src/columns.lib.tengo index e91a74f..69217db 100644 --- a/workflow/src/columns.lib.tengo +++ b/workflow/src/columns.lib.tengo @@ -11,6 +11,9 @@ // at pframe-build time. // - `aaFractionColumn(spec, feat)` — 2-axis AA-fraction column descriptor // (whole-sequence modes: peptide / amplicon). +// - `chainToSlot(chain)` — MiXCR locus ("TCRAlpha", "IGLight", ...) +// → the A/B slot it occupies, for bulk +// input that carries no scClonotypeChain. // - `cloneSpec(spec, dExtras, aExtras)` — spec-cloning helper used by the // caller to layer blockId / isOutput / // any other per-consumer overrides. @@ -33,21 +36,46 @@ CHARGE_SHIFT_PH_TO := "6.0" CHARGE_SHIFT_DESC := "Net charge change from pH 7.4 (blood) to pH 6.0 (endosome). Negative values mean the molecule gains positive charge on acidification — the productive direction for histidine-driven pH switching. Histidine dominates (~−0.46 per His)." // Receptor + chain → human label fragments (CDR3 / full-chain). -// Spec R13a: PColumn name and chain domain are unchanged; only the label varies. +// Chain A is the D-recombining chain: heavy, beta, delta — never alpha or gamma. labelFragments := func(receptor, chain) { if receptor == "TCRAB" { - if chain == "A" { return { cdr3: "CDR-α3", fullChain: "Vα" } } - if chain == "B" { return { cdr3: "CDR-β3", fullChain: "Vβ" } } + if chain == "A" { return { cdr3: "CDR-β3", fullChain: "Vβ" } } + if chain == "B" { return { cdr3: "CDR-α3", fullChain: "Vα" } } } if receptor == "TCRGD" { - if chain == "A" { return { cdr3: "CDR-γ3", fullChain: "Vγ" } } - if chain == "B" { return { cdr3: "CDR-δ3", fullChain: "Vδ" } } + if chain == "A" { return { cdr3: "CDR-δ3", fullChain: "Vδ" } } + if chain == "B" { return { cdr3: "CDR-γ3", fullChain: "Vγ" } } } // IG / unknown — antibody convention. if chain == "A" { return { cdr3: "CDR-H3", fullChain: "VH" } } return { cdr3: "CDR-L3", fullChain: "VL" } } +// Table order follows the receptor's spoken naming — heavy/light, α/β, γ/δ — not slot order. +displaysFirst := func(receptor, chain) { + if receptor == "TCRAB" || receptor == "TCRGD" { return chain == "B" } + return chain == "A" +} + +// Bulk input names its locus but has no slot; MiXCR seats the D-recombining chain in A. +CHAIN_TO_SLOT := { + IGHeavy: "A", + IGLight: "B", + IGKappa: "B", + IGLambda: "B", + TCRBeta: "A", + TCRAlpha: "B", + TCRDelta: "A", + TCRGamma: "B" +} + +chainToSlot := func(chain) { + if chain == undefined { return "A" } + s := CHAIN_TO_SLOT[chain] + if s == undefined { return "A" } + return s +} + // Build a single output column descriptor consumed by xsv.importFile. // `tsvCol` is the TSV column header emitted by Python (e.g. "charge_peptide", "charge_A_CDR3"). // Clones the caller's `annotations` dict so the label stamp does not aliasing- @@ -180,25 +208,24 @@ buildWholeSeqColumns := func(feature, deltaLabel) { return cols } -// CDR-H3 (chain A) and CDR-L3 (chain B) carry different descriptions per -// pcolumn-spec.md — different developability signals. +// Chain A and chain B CDR3s carry different developability signals (pcolumn-spec.md). CDR3_CHARGE_DESC := { A: "Strongly positive CDR3 charge correlates with polyreactivity via electrostatic interactions. No universal preferred direction in Lead Selection. IPC 2.0 peptide pKa set.", - B: "Strongly positive CDR-L3 charge contributes to paratope polyreactivity. Strongly negative charge is primarily a PK concern. No universal preferred direction. IPC 2.0 peptide pKa set." + B: "Strongly positive CDR3 charge contributes to paratope polyreactivity. Strongly negative charge is primarily a PK concern. No universal preferred direction. IPC 2.0 peptide pKa set." } CDR3_GRAVY_DESC := { A: "Lower hydrophobicity preferred for developability. CDR3 GRAVY > 0 is an informal aggregation/polyreactivity heuristic.", - B: "Same aggregation and polyreactivity signal as CDR-H3 hydrophobicity; lower independent predictive weight. The TAP score uses combined 6-CDR GRAVY — CDR-L3 alone has limited independent validation." + B: "Same aggregation and polyreactivity signal as the paired chain's CDR3 hydrophobicity; lower independent predictive weight. For antibodies, the TAP score uses combined 6-CDR GRAVY — this CDR3 alone has limited independent validation." } buildCdr3Columns := func(receptor, chains) { cols := [] - cdr3OrderA := 68000 - cdr3OrderB := 67700 + cdr3OrderFirst := 68000 + cdr3OrderSecond := 67700 for chain in chains { frag := labelFragments(receptor, chain) cdr3Dom := { "pl7.app/feature": "CDR3", "pl7.app/vdj/scClonotypeChain": chain } - chargeOrder := (chain == "A" ? cdr3OrderA : cdr3OrderB) + chargeOrder := (displaysFirst(receptor, chain) ? cdr3OrderFirst : cdr3OrderSecond) gravyOrder := chargeOrder - 100 cols += [makeCol("charge_" + chain + "_CDR3", "pl7.app/charge", "Double", @@ -236,12 +263,12 @@ buildCdr3Columns := func(receptor, chains) { buildFullChainColumns := func(receptor, fullChains) { cols := [] - fcOrderBaseA := 67000 - fcOrderBaseB := 66000 + fcOrderBaseFirst := 67000 + fcOrderBaseSecond := 66000 for chain in fullChains { frag := labelFragments(receptor, chain) fcDom := { "pl7.app/feature": "VDJRegion", "pl7.app/vdj/scClonotypeChain": chain } - base := (chain == "A" ? fcOrderBaseA : fcOrderBaseB) + base := (displaysFirst(receptor, chain) ? fcOrderBaseFirst : fcOrderBaseSecond) fcLabel := frag.fullChain cols += [makeCol("charge_" + chain + "_VDJRegion", "pl7.app/charge", "Double", @@ -293,7 +320,7 @@ buildFullChainColumns := func(receptor, fullChains) { cols += [makeCol("instability_" + chain + "_VDJRegion", "pl7.app/instabilityIndex", "Double", fcLabel + " Instability Index", fcDom, { "pl7.app/format": ".2f", - "pl7.app/description": "Guruprasad index, calibrated for in-vitro stability of soluble globular proteins via dipeptide composition. Weak predictor of antibody Tm — use as supplementary ranking aid.", + "pl7.app/description": "Guruprasad index, calibrated for in-vitro stability of soluble globular proteins via dipeptide composition. For antibodies, a weak predictor of Tm — use as supplementary ranking aid.", "pl7.app/table/visibility": "optional", "pl7.app/table/orderPriority": string(base - 600) })] @@ -301,7 +328,7 @@ buildFullChainColumns := func(receptor, fullChains) { fcLabel + " Aliphatic Index", fcDom, { "pl7.app/format": ".1f", "pl7.app/min": "0", - "pl7.app/description": "Ikai aliphatic index, derived from globular mesophilic enzymes. Weak correlation with antibody Tm. No rankingOrder — high values can correlate with aggregation propensity.", + "pl7.app/description": "Ikai aliphatic index, derived from globular mesophilic enzymes. For antibodies, weak correlation with Tm. No rankingOrder — high values can correlate with aggregation propensity.", "pl7.app/table/visibility": "optional", "pl7.app/table/orderPriority": string(base - 700) })] @@ -475,5 +502,6 @@ aaFractionColumn := func(keyAxisSpec, feature) { export ll.toStrict({ buildColumns: buildColumns, aaFractionColumn: aaFractionColumn, + chainToSlot: chainToSlot, cloneSpec: cloneSpec }) diff --git a/workflow/src/columns.test.tengo b/workflow/src/columns.test.tengo new file mode 100644 index 0000000..aa49213 --- /dev/null +++ b/workflow/src/columns.test.tengo @@ -0,0 +1,99 @@ +test := import("@platforma-sdk/workflow-tengo:test") +columns := import(":columns") + +VDJ_MODE := "antibody_tcr_universal" + +labelOf := func(cols, tsvCol) { + for col in cols { + if col.column == tsvCol { + return col.spec.annotations["pl7.app/label"] + } + } + return undefined +} + +pairedCols := func(receptor) { + return columns.buildColumns({ + mode: VDJ_MODE, + receptor: receptor, + chains: ["A", "B"], + fullChains: ["A", "B"], + hasFv: false + }) +} + +Test_buildColumns_chainAIsTheDiverseChain := func() { + pairs := [ + { receptor: "IG", a: "CDR-H3", b: "CDR-L3", fcA: "VH", fcB: "VL" }, + { receptor: "TCRAB", a: "CDR-β3", b: "CDR-α3", fcA: "Vβ", fcB: "Vα" }, + { receptor: "TCRGD", a: "CDR-δ3", b: "CDR-γ3", fcA: "Vδ", fcB: "Vγ" } + ] + for pair in pairs { + cols := pairedCols(pair.receptor) + test.isEqual(pair.a + " Net Charge (pH 7)", labelOf(cols, "charge_A_CDR3"), + pair.receptor + " chain A CDR3 label") + test.isEqual(pair.b + " Net Charge (pH 7)", labelOf(cols, "charge_B_CDR3"), + pair.receptor + " chain B CDR3 label") + test.isEqual(pair.fcA + " Net Charge (pH 7)", labelOf(cols, "charge_A_VDJRegion"), + pair.receptor + " chain A full-chain label") + test.isEqual(pair.fcB + " Net Charge (pH 7)", labelOf(cols, "charge_B_VDJRegion"), + pair.receptor + " chain B full-chain label") + } +} + +orderOf := func(cols, tsvCol) { + for col in cols { + if col.column == tsvCol { + return col.spec.annotations["pl7.app/table/orderPriority"] + } + } + return undefined +} + +Test_buildColumns_tableOrderFollowsSpokenNaming := func() { + pairs := [ + { receptor: "IG", first: "A", second: "B" }, + { receptor: "TCRAB", first: "B", second: "A" }, + { receptor: "TCRGD", first: "B", second: "A" } + ] + for pair in pairs { + cols := pairedCols(pair.receptor) + test.isEqual("68000", orderOf(cols, "charge_" + pair.first + "_CDR3"), + pair.receptor + " CDR3 leads with chain " + pair.first) + test.isEqual("67700", orderOf(cols, "charge_" + pair.second + "_CDR3"), + pair.receptor + " CDR3 trails with chain " + pair.second) + test.isEqual("67000", orderOf(cols, "charge_" + pair.first + "_VDJRegion"), + pair.receptor + " full-chain leads with chain " + pair.first) + test.isEqual("66000", orderOf(cols, "charge_" + pair.second + "_VDJRegion"), + pair.receptor + " full-chain trails with chain " + pair.second) + } +} + +Test_chainToSlot_locusSeatsTheDiverseChainInA := func() { + seats := { + IGHeavy: "A", IGLight: "B", IGKappa: "B", IGLambda: "B", + TCRBeta: "A", TCRAlpha: "B", + TCRDelta: "A", TCRGamma: "B" + } + for locus, slot in seats { + test.isEqual(slot, columns.chainToSlot(locus), locus + " slot") + } + test.isEqual("A", columns.chainToSlot(undefined), "missing locus defaults to A") + test.isEqual("A", columns.chainToSlot("Nonsense"), "unknown locus defaults to A") +} + +Test_buildColumns_chainDomainIsIndependentOfTheLabel := func() { + for receptor in ["IG", "TCRAB", "TCRGD"] { + cols := pairedCols(receptor) + for col in cols { + if col.column == "charge_A_CDR3" { + test.isEqual("A", col.spec.domain["pl7.app/vdj/scClonotypeChain"], + receptor + " chain A domain") + } + if col.column == "charge_B_CDR3" { + test.isEqual("B", col.spec.domain["pl7.app/vdj/scClonotypeChain"], + receptor + " chain B domain") + } + } + } +} diff --git a/workflow/src/main.tpl.tengo b/workflow/src/main.tpl.tengo index 79e2ff4..3d0916e 100644 --- a/workflow/src/main.tpl.tengo +++ b/workflow/src/main.tpl.tengo @@ -175,10 +175,12 @@ wf.body(func(args) { // Spec deviation SD-008 — bulk MiXCR axes lack `pl7.app/vdj/receptor` but // carry `pl7.app/vdj/chain`; derive receptor from chain when receptor is // absent. See docs/spec-deviations.md. + axisChain := undefined if keyAxisSpec.domain != undefined { + axisChain = keyAxisSpec.domain["pl7.app/vdj/chain"] r := resolveReceptor( keyAxisSpec.domain["pl7.app/vdj/receptor"], - keyAxisSpec.domain["pl7.app/vdj/chain"] + axisChain ) if r.seen { receptor = r.value @@ -241,8 +243,9 @@ wf.body(func(args) { chain := d["pl7.app/vdj/scClonotypeChain"] if chain == undefined || chain == "" { - // Bulk MiXCR data without chain annotation — assume primary chain "A". - chain = "A" + // Bulk has no slot; derive it from the locus so the label names the real chain. + colChain := d["pl7.app/vdj/chain"] + chain = columnSpecs.chainToSlot(colChain != undefined ? colChain : axisChain) } // Same receptor value is expected on every input column; last seen wins. @@ -264,12 +267,12 @@ wf.body(func(args) { // Receptor-aware chain label for user-facing messages (R11b). chainLabel := func(ch) { if receptor == "TCRAB" { - if ch == "A" { return "alpha" } - if ch == "B" { return "beta" } + if ch == "A" { return "beta" } + if ch == "B" { return "alpha" } } if receptor == "TCRGD" { - if ch == "A" { return "gamma" } - if ch == "B" { return "delta" } + if ch == "A" { return "delta" } + if ch == "B" { return "gamma" } } // IG / unknown — antibody convention. if ch == "A" { return "heavy" } @@ -360,7 +363,7 @@ wf.body(func(args) { // Python step contract: reads input.tsv + plan.json; writes properties.tsv, // plus aa_fraction.tsv in peptide mode (empty body in antibody/TCR mode), plus // stats.json (dataset-level scalars consumed by the info layer — e.g. R11c - // median CDR-H3 length per chain). + // median CDR3 length per chain). soft := assets.importSoftware("@platforma-open/milaboratories.sequence-properties.software:compute-properties") // 12GiB (up from the 4GiB default): the vectorized step materializes full-N // count matrices, so peak memory scales with clone count. cpu(1) is