Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5be7d5d
MILAB-6720: score imported receptor sets keyed on variantKey
erohinaelena Aug 27, 2026
9d3255f
MILAB-6720: move CI to node 22
erohinaelena Aug 27, 2026
d37c4f3
MILAB-6720: bump block-tools to 2.14.3
erohinaelena Aug 27, 2026
6021a5f
MILAB-6720: add the constant chain column with pt, not in the scorer
erohinaelena Aug 27, 2026
c59b0b8
fix: swapped chain labels on single-cell TCR datasets
erohinaelena Aug 27, 2026
5a79022
MILAB-6720: keep CHAIN_LOCUS to real loci; unmapped chains travel by …
erohinaelena Aug 27, 2026
3f0fbfe
MILAB-6720: collect the chain literals in the loop that builds the table
erohinaelena Aug 27, 2026
e411089
MILAB-6720: hoist the input-table resource budget to one constant
erohinaelena Aug 27, 2026
d9f22a2
MILAB-6720: assemble the scorer's input table in one pt pass
erohinaelena Aug 27, 2026
f963a97
Revert "MILAB-6720: assemble the scorer's input table in one pt pass"
erohinaelena Aug 27, 2026
f994049
MILAB-6720: assemble the scorer's input table in one pt pass
erohinaelena Aug 27, 2026
a392c5c
MILAB-6720: drop the now-dead TABLE_CPU / TABLE_MEM constants
erohinaelena Aug 27, 2026
4018b1f
MILAB-6720: read the CDR3 alphabet from the columns, not from an assu…
erohinaelena Aug 27, 2026
28fcff4
MILAB-6720: resolve the CDR3 alphabet in one place, dropping the "" s…
erohinaelena Aug 27, 2026
d7970a4
MILAB-6720: find the key axis by name in the model, not by position
erohinaelena Aug 27, 2026
b821558
MILAB-6720: accept the lint autofix on ENTITY_KEY_NAMES
erohinaelena Aug 27, 2026
0220b26
remove extra check
erohinaelena Aug 27, 2026
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
49 changes: 49 additions & 0 deletions .changeset/variantkey-imported-sets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
'@platforma-open/milaboratories.generation-probability.workflow': minor
'@platforma-open/milaboratories.generation-probability.model': minor
'@platforma-open/milaboratories.generation-probability.block': minor
Comment thread
kevindetry-milaboratories marked this conversation as resolved.
---

Score imported receptor sets, and fix swapped chain labels on single-cell TCR data

**Imported receptor sets.** A set from Import VDJ Data was offered nowhere and scored nothing.
Four separate things stopped it, each failing differently.

The dataset dropdown filtered on the presence of a per-record `pl7.app/vdj/chain` column, which
an imported set does not have — so it never appeared as an option at all. Its locus is a property
of the whole set, recorded on the key axis, and that is now accepted as an alternative.

The CDR3 alphabet was read from `pl7.app/alphabet` on the key axis or from a `<key>/structure`
domain key. An imported set states it in neither — the structure key belongs to the
`scClonotypeKey` vocabulary its axis does not use — so the run died on "Cannot determine CDR3
alphabet". Its sequence columns do carry the alphabet, so that is where it is now read from when
the axis says nothing. The axis stays authoritative when it does say something: a MiXCR set emits
both nucleotide and amino-acid CDR3 columns, and the axis is what says which of them defines the
clonotype.

Scoring needed a per-row locus column and quietly produced an empty result without one. The
scorer's input table is now assembled in a single pass that appends a constant locus column for a
unit whose locus is the same for every row, so the scoring script is unchanged and still simply
reads a locus per row.

Whether the two chains are scored separately followed the key axis being
`pl7.app/vdj/scClonotypeKey`. A paired imported set carries both chains in one frame under the
`pl7.app/vdj/scClonotypeChain` column domain on a `variantKey` axis, so it took the bulk path,
where the first CDR3 column found is scored and the other chain is silently dropped. That now
follows the column domain.

**A light chain from an imported set is reported as skipped, not scored.** OLGA needs IGK and IGL
as separate models and an imported set records only "IG Light", so the locus is genuinely unknown.
Rather than guess, the light unit keeps its chain name, gets no Pgen, and is named in the block's
existing skipped-chains banner. Heavy chains (IGH) and TCR alpha/beta (TRA, TRB) score normally.

**Swapped chain labels.** MiXCR fixes "A" as the more diverse chain — the one that recombines a D
segment — so a receptor's chain order is TCRBeta/TCRAlpha and TCRDelta/TCRGamma, not alphabetical.
The label table read A as Alpha and B as Beta, so on a single-cell TCR alpha/beta dataset the alpha
column was labelled "Generation probability (Beta)" and the beta column "(Alpha)". Gamma/delta was
swapped the same way. The Pgen values were always right — the locus came from the per-record chain
column, never from the A/B letter — but a mislabelled column reads as the wrong chain's result.
Labels are now derived from the chain that the A/B slot resolves to, through the single table that
also decides the locus, so a label cannot disagree with the model that produced the value beside it.

Bulk, single-cell IG, peptide and amplicon inputs are unaffected.
2 changes: 1 addition & 1 deletion .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
with:
app-name: 'Block: Generation Probability'
app-name-slug: 'block-generation-probability'
node-version: '20.x'
node-version: '22.x'
gha-runner-label: hz-ubuntu-dind
build-script-name: 'build:dev-local'
build-before-publish-script-name: 'build:release'
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/mark-stable.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
uses: milaboratory/github-ci/.github/workflows/block-mark-stable.yaml@v4
with:
app-name: 'Block: Generation Probability - Mark Stable'
node-version: '20.x'
node-version: '22.x'
npmrc-config: |
{
"registries": {
Expand Down
64 changes: 41 additions & 23 deletions model/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,24 @@ export type BlockData = {
distributionGraphState: GraphMakerState;
};

const inputSelectors = [
{
axes: [{ name: "pl7.app/vdj/clonotypeKey" }],
annotations: { "pl7.app/isAnchor": "true" },
},
{
axes: [{ name: "pl7.app/vdj/scClonotypeKey" }],
annotations: { "pl7.app/isAnchor": "true" },
},
{
axes: [{ name: "pl7.app/variantKey" }],
annotations: { "pl7.app/isAnchor": "true" },
},
// The axes a dataset can be keyed on, mirroring ENTITY_KEY_NAMES in main.tpl.tengo. Both the
// selectors and the scorability check below are derived from this one list so they cannot drift.
const ENTITY_KEY_NAMES = [
"pl7.app/vdj/clonotypeKey",
"pl7.app/vdj/scClonotypeKey",
"pl7.app/variantKey",
];

const inputSelectors = ENTITY_KEY_NAMES.map((name) => ({
axes: [{ name }],
annotations: { "pl7.app/isAnchor": "true" },
}));

// The key axis is found by name, never by position: which index it sits at is a property of the
// producer, and main.tpl.tengo searches for it the same way rather than assuming one.
const keyAxisOf = (spec: { axesSpec: { name: string; domain?: Record<string, string> }[] }) =>
spec.axesSpec.find((axis) => ENTITY_KEY_NAMES.includes(axis.name));

const dataModel = new DataModelBuilder().from<BlockData>("v1").init(() => ({
datasetLabel: "",
tableState: createPlDataTableStateV2(),
Expand Down Expand Up @@ -74,19 +77,34 @@ export const platforma = BlockModelV3.create(dataModel)
const collection = ColumnsCollection(["result_pool"]).filter({
include: inputSelectors,
});
// A dataset is scorable when the locus can be established. Normally that means a per-record
// pl7.app/vdj/chain column. An imported receptor set has none: the locus is a property of
// the whole set and is read from the key axis instead -- pl7.app/vdj/chain for a single
// mapped chain, or pl7.app/vdj/receptor plus the chain column domain for a paired one --
// so requiring the column would keep every imported set out of this dropdown.
const scorableIds = new Set(
collection
.getColumns()
.filter(
(anchor) =>
!ColumnsCollection(["result_pool"])
.discover({
anchors: { main: anchor.getSpec() },
include: [{ name: [{ type: "exact", value: CHAIN_NAME }] }],
mode: "enrichment",
})
.isEmpty(),
)
.filter((anchor) => {
const keyAxis = keyAxisOf(anchor.getSpec());
const keyDomain = keyAxis?.domain ?? {};
if (
keyAxis?.name === "pl7.app/variantKey" &&
keyDomain["pl7.app/vdj/clonotypingRunId"] !== undefined
) {
return (
keyDomain["pl7.app/vdj/chain"] !== undefined ||
keyDomain["pl7.app/vdj/receptor"] !== undefined
);
}
return !ColumnsCollection(["result_pool"])
.discover({
anchors: { main: anchor.getSpec() },
include: [{ name: [{ type: "exact", value: CHAIN_NAME }] }],
mode: "enrichment",
})
.isEmpty();
})
.map((anchor) => anchor.id),
);
return deriveColumnOptions(collection)
Expand Down
Loading
Loading