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
32 changes: 32 additions & 0 deletions .changeset/sequence-embeddings-kind.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
'@platforma-open/milaboratories.sequence-embeddings': patch
'@platforma-open/milaboratories.sequence-embeddings.model': patch
---

Add the block's `kind` component

Every block must now declare a kind — a fourth component next to model, workflow
and ui. It carries the block's identity and its init-params contract, and
`block-tools structure check` fails without it.

This block's contract is `{ inputAnchor?: PlRef; embedding?: EmbeddingSelection }`.
A project template can therefore seed a new instance with both the dataset to
embed and the (scope, model) combination to embed it with. Everything else in
the block's data still defaults: `embeddingInitializedForAnchor` is the UI's
re-seed guard, `mem` and `cpu` are opt-in overrides the workflow otherwise sizes
itself, and `defaultBlockLabel` is written by the UI from the chosen input.

The two fields travel as a pair. A scope's `columns` are anchored ids that
resolve against `inputAnchor`, so an `embedding` seeded without the matching
`inputAnchor` points at nothing. Both stay optional, and the args projection —
which already refuses an incomplete selection — is what catches the mismatch
when the block runs.

The model's `init` now reads those params, and a new `templateParams` projection
hands the same two fields back out, so seeding and exporting are inverses. A
block created without params starts exactly as before.

The types the contract is built from (`EmbeddingSelection`, `SelectedScope`,
`EmbeddingModelId`, `Fidelity`, `ScopeFeature`, `ScopeReceptor`,
`WorkflowReceptor`) moved into the kind package, which owns the contract. The
model re-exports them, so every existing import path keeps working.
35 changes: 35 additions & 0 deletions .changeset/vdj-modality-model-selection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
'@platforma-open/milaboratories.sequence-embeddings.model': patch
'@platforma-open/milaboratories.sequence-embeddings': patch
---

Offer antibody and TCR models for VDJ amplicon-profiling input (MILAB-6668).

`synthetic-repertoire-profiler` tags its whole-variant amino-acid sequence with the
`amplicon-sequence` feature in both of its modalities, and this block mapped that
feature onto the `peptide` scope unconditionally. Since model compatibility is gated
on the scope feature, a DMS **VDJ** run was offered only ESM-2 and PeptideCLM-2, with
the peptide specialist as the default — no antibody or TCR model was reachable.

The block now reads the producer's `pl7.app/modality` declaration off the entity
axis. On a `vdj` run the whole-variant column becomes a `VDJRegion` scope and the
profiler's CDR3 region column becomes a `CDR3` scope (a new selector discovers the
region columns, which are keyed by region name in `pl7.app/feature` rather than on
`pl7.app/vdj/sequence`). That puts CurrAb, AbLang2, VHHBERT, H3BERTa and TCR-BERT in
reach. Amplicon runs, projects predating the declaration, and peptide-extraction
input are unaffected — all keep the peptide scope and the PeptideCLM-2 default.

Receptor handling: the profiler declares VDJ data but emits neither
`pl7.app/vdj/receptor` nor `pl7.app/vdj/chain`, because germline auto-detection
builds a custom reference from the user's own parent sequences and has no library
locus to read. `ScopeReceptor` gains an explicit `"unknown"` value for that case, and
model filtering relaxes its receptor and heavy-chain gates rather than defaulting to
`IG` — which would have silently hidden the TCR specialists from TCR data. The trade
is deliberate: the dropdown can now offer a TCR model for antibody input, so the user
picks the model that matches their library. With the receptor unknown the default
model is the universal one (ESM-2) rather than the highest-priority specialist, so no
receptor-specific model is chosen on the user's behalf from data that does not state
a receptor.

Inputs that do carry a receptor or chain key are unchanged, as are inputs with no
modality declaration — both keep the historical `IG` fallback and full gating.
7 changes: 6 additions & 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: Sequence Embeddings'
app-name-slug: 'block-sequence-embeddings'
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 All @@ -39,6 +39,11 @@ jobs:
package-path: 'block'
create-tag: 'true'

# Require the published `block` package to be bumped by a changeset on
# PRs (empty changeset or the `skip-changelog` label waives it). Needs
# the input to exist on the pinned `@v4` reusable workflow.
require-package-path-bump: true

npmrc-config: |
{
"registries": {
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: Sequence Embeddings - Mark Stable'
node-version: '20.x'
node-version: '22.x'
npmrc-config: |
{
"registries": {
Expand Down
1 change: 1 addition & 0 deletions block/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"devDependencies": {
"@milaboratories/ts-builder": "catalog:",
"@milaboratories/ts-configs": "catalog:",
"@platforma-open/milaboratories.sequence-embeddings.kind": "workspace:*",
"@platforma-open/milaboratories.sequence-embeddings.model": "workspace:*",
"@platforma-open/milaboratories.sequence-embeddings.ui": "workspace:*",
"@platforma-open/milaboratories.sequence-embeddings.workflow": "workspace:*",
Expand Down
4 changes: 4 additions & 0 deletions kind/.oxfmtrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": ["node_modules/@milaboratories/ts-builder/configs/oxfmt.json"],
"ignorePatterns": ["dist", "coverage", "CHANGELOG.md"]
}
3 changes: 3 additions & 0 deletions kind/.oxlintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": ["node_modules/@milaboratories/ts-builder/dist/configs/oxlint-node.json"]
}
37 changes: 37 additions & 0 deletions kind/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"name": "@platforma-open/milaboratories.sequence-embeddings.kind",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"sources": "./src/index.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
}
},
"scripts": {
"fmt": "ts-builder format",
"watch": "ts-builder build --target block-kind --watch",
"build": "ts-builder build --target block-kind && block-tools build-kind-manifest",
"check": "ts-builder check --target block-kind"
},
"dependencies": {
"@platforma-sdk/block-kind": "catalog:",
"@platforma-sdk/model": "catalog:"
},
"devDependencies": {
"@milaboratories/ts-builder": "catalog:",
"@milaboratories/ts-configs": "catalog:",
"@platforma-sdk/block-tools": "catalog:"
},
"peerDependencies": {
"@types/node": "*",
"typescript": "*"
}
}
177 changes: 177 additions & 0 deletions kind/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { assertParamsObject, defineBlockKind } from "@platforma-sdk/block-kind";
import type { PlRef, SUniversalPColumnId } from "@platforma-sdk/model";
import { isPlRef } from "@platforma-sdk/model";
import { name, version } from "../package.json" with { type: "json" };
import type {
EmbeddingModelId,
EmbeddingSelection,
Fidelity,
ScopeFeature,
ScopeReceptor,
SelectedScope,
} from "./types";

export * from "./types";

/**
* This block's init-params contract — the upstream dataset a new instance embeds,
* and the (scope, model) combination it embeds it with.
*
* Those two are what a creator actually chooses; everything else in the model's
* `BlockData` always defaults. `embeddingInitializedForAnchor` is the UI's
* re-seed guard, `mem`/`cpu` are opt-in overrides the workflow otherwise sizes
* itself, and `defaultBlockLabel` is written by the UI from the chosen input's
* option label.
*
* The two fields travel as a pair. A scope's `columns` are anchored ids that
* resolve against `inputAnchor`, so a template carrying an `embedding` without
* the matching `inputAnchor` seeds a selection that points at nothing. The
* contract cannot express that pairing as a type, so both stay optional and the
* args projection — which already refuses an incomplete selection — is what
* catches the mismatch at run time.
*
* Both fields are optional because the projection hands live state back
* untouched, and a freshly created block holds `undefined` and `{}` there.
* Requiring either would make the block export a file its own kind refuses to
* apply, so export and apply would stop being inverses.
*/
export type BlockParams = {
inputAnchor?: PlRef;
embedding?: EmbeddingSelection;
};

// Each closed set is declared once, as a const tuple, and its type is derived
// from it. That is what keeps the run-time check and the compile-time union from
// drifting: adding a case to the tuple widens the type, and a case added to the
// type alone does not compile.
const SCOPE_FEATURES = ["peptide", "CDR3", "VDJRegion", "Fv", "scFv"] as const;
const SCOPE_CHAINS = ["A", "B", ""] as const;
const SCOPE_RECEPTORS = ["IG", "TCRAB", "TCRGD", "unknown"] as const;
const FIDELITIES = ["high", "standard"] as const;
const EMBEDDING_MODEL_IDS = [
"esm2",
"ablang2",
"currab",
"vhhbert",
"h3berta",
"tcr-bert",
"peptideclm2",
"sceptr",
] as const;

function assertMember<T extends string>(
key: string,
allowed: readonly T[],
value: unknown,
): asserts value is T {
if (typeof value !== "string" || !(allowed as readonly string[]).includes(value)) {
throw new Error(`'${key}' must be one of ${allowed.join(", ")}. Got: ${JSON.stringify(value)}`);
}
}

function assertString(key: string, value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new Error(`'${key}' must be a string. Got: ${JSON.stringify(value)}`);
}
}

/**
* `SUniversalPColumnId` is a branded string and the SDK ships no guard for it, so
* the check stops at "non-empty string". Whether an id resolves is a question
* about the anchor it is read against, not about the shape of the params.
*/
function assertColumnIds(value: unknown): asserts value is SUniversalPColumnId[] {
if (!Array.isArray(value) || value.length === 0) {
throw new Error("'scope.columns' must be a non-empty array of column ids.");
}
for (const [i, id] of value.entries()) {
if (typeof id !== "string" || id === "") {
throw new Error(`'scope.columns[${i}]' must be a non-empty column id string.`);
}
}
}

/**
* A scope is a snapshot the UI takes from the picker, so every one of its fields
* is present once the scope exists at all. There is no half-written scope to be
* lenient about — the leniency lives one level up, where `scope` itself may be
* missing.
*/
function assertSelectedScope(value: unknown): asserts value is SelectedScope {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("'embedding.scope' must be an object.");
}
const scope = value as Record<string, unknown>;

assertString("scope.id", scope.id);
assertString("scope.label", scope.label);
assertMember<ScopeFeature>("scope.feature", SCOPE_FEATURES, scope.feature);
assertMember<"A" | "B" | "">("scope.chain", SCOPE_CHAINS, scope.chain);
assertMember<ScopeReceptor>("scope.receptor", SCOPE_RECEPTORS, scope.receptor);
assertColumnIds(scope.columns);

if (typeof scope.isHeavy !== "boolean") {
throw new Error("'scope.isHeavy' must be a boolean.");
}
}

/**
* The selection the user assembles. All three fields are optional in the model,
* because the UI fills one dropdown at a time — so each is checked only when
* present. Whether the resulting pair is a *compatible* (scope, model) is
* meaning, not shape; the args projection is what decides that, and it does so
* only when the block runs.
*/
function parseEmbedding(value: unknown): EmbeddingSelection {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("'embedding' must be an object.");
}
const { scope, model, fidelity } = value as Record<string, unknown>;

if (scope !== undefined) assertSelectedScope(scope);
if (model !== undefined) {
assertMember<EmbeddingModelId>("embedding.model", EMBEDDING_MODEL_IDS, model);
}
if (fidelity !== undefined) {
assertMember<Fidelity>("embedding.fidelity", FIDELITIES, fidelity);
}

return { scope, model, fidelity };
}

/**
* The same contract at runtime, for params arriving from a template file rather
* than from typed code — the only point that can catch a hand-written entry
* being wrong.
*
* Keys the contract does not name are dropped by not being read; refusing them
* would mean holding a list of field names as strings that nothing keeps in step
* with the type.
*/
function parseInitializationParams(value: unknown): BlockParams {
assertParamsObject(value);

const { inputAnchor, embedding } = value;

// A readable `{ block, name }` reference is expanded to a full `PlRef` before
// this runs, so `isPlRef` is the only shape to accept here.
if (inputAnchor !== undefined && !isPlRef(inputAnchor)) {
throw new Error(
"'inputAnchor' must be a reference to an upstream column, written as { block, name }.",
);
}

return {
inputAnchor,
embedding: embedding === undefined ? undefined : parseEmbedding(embedding),
};
}

// Identity (`name`/`version`) comes from this package's own `package.json`, so
// the on-wire `{name}@{version}` reference can never drift from what npm
// publishes; the bundler inlines the JSON import.
export const kind = defineBlockKind<BlockParams>({
name,
version,
parseInitializationParams,
});
Loading
Loading