Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .changeset/migrate-to-structurer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@platforma-open/milaboratories.generation-probability.model': minor
'@platforma-open/milaboratories.generation-probability.block': minor
'@platforma-open/milaboratories.generation-probability.workflow': patch
'@platforma-open/milaboratories.generation-probability.software': patch
---

Migrate onto the structurer and take the full SDK upgrade (block-tools 2.14.3, tengo-builder 4.0.23, model 1.83.0, ui-vue 1.83.3).

Adds the mandatory block kind. Its init-params contract is the input dataset plus the species, so a project template can seed a configured Generation Probability block.
5 changes: 5 additions & 0 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion 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.generation-probability.kind": "workspace:*",
"@platforma-open/milaboratories.generation-probability.model": "workspace:*",
"@platforma-open/milaboratories.generation-probability.ui": "workspace:*",
"@platforma-open/milaboratories.generation-probability.workflow": "workspace:*",
Expand Down Expand Up @@ -61,4 +62,4 @@
}
}
}
}
}
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.generation-probability.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": "*"
}
}
59 changes: 59 additions & 0 deletions kind/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { assertParamsObject, defineBlockKind } from "@platforma-sdk/block-kind";
import { isColumnUniversalId } from "@platforma-sdk/model";
import { name, version } from "../package.json" with { type: "json" };

// The two species the Pgen models are trained for. Lives here rather than in the
// model so the init-params contract and the UI dropdown cannot name different
// sets; the model re-exports it for the UI.
export const SPECIES_OPTIONS = [
{ label: "Human", value: "human" },
{ label: "Mouse", value: "mouse" },
] as const;

export type Species = (typeof SPECIES_OPTIONS)[number]["value"];

const SPECIES_VALUES: readonly string[] = SPECIES_OPTIONS.map((option) => option.value);

/**
* This block's init-params contract — the dataset to score and the species whose
* generation model scores it. Both are what `.args()` requires, so they are the
* whole of what a template configures.
*
* Left out: `datasetLabel`, which the UI derives from the picked option's label,
* and the table / distribution-chart view state.
*
* Both fields are optional because the projection hands live state back
* untouched, and a block whose dataset or species is not picked yet holds
* `undefined` 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 = {
/**
* A column id, as produced by `deriveColumnOptions`. Declared `string` to
* match the model's `BlockData`; the parser still checks it is a real column
* id, which every value the picker can produce is.
*/
inputAnchor?: string;
species?: Species;
};

/** The same contract at runtime, for params arriving from a template file rather than typed code. */
function parseInitializationParams(value: unknown): BlockParams {
assertParamsObject(value);

const { inputAnchor, species } = value;

if (inputAnchor !== undefined && !isColumnUniversalId(inputAnchor)) {
throw new Error("'inputAnchor' must be a column id.");
}
if (species !== undefined && !SPECIES_VALUES.includes(species as string)) {
throw new Error(`'species' must be one of: ${SPECIES_VALUES.join(", ")}.`);
}

return { inputAnchor, species: species as Species | undefined };
}

// 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 });
10 changes: 10 additions & 0 deletions kind/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "@milaboratories/ts-configs/block/facade",
"compilerOptions": {
"outDir": "./dist",
"rootDir": ".",
"resolveJsonModule": true
},
"include": ["src/**/*", "package.json"],
"exclude": ["dist", "node_modules"]
}
1 change: 1 addition & 0 deletions model/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"dependencies": {
"@milaboratories/graph-maker": "catalog:",
"@milaboratories/helpers": "catalog:",
"@platforma-open/milaboratories.generation-probability.kind": "workspace:*",
"@platforma-sdk/model": "catalog:"
},
"devDependencies": {
Expand Down
116 changes: 68 additions & 48 deletions model/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { GraphMakerState } from "@milaboratories/graph-maker";
import {
AccessorColumnsProvider,
BlockModelV3,
ColumnsCollection,
createPFrameForGraphs,
Expand All @@ -9,25 +8,29 @@ import {
DataModelBuilder,
deriveColumnOptions,
InferOutputsType,
isDataColumn,
ListOptionBase,
PColumn,
PColumnDataUniversal,
PColumnIdAndSpec,
PlDataTableStateV2,
} from "@platforma-sdk/model";

export const SPECIES_OPTIONS = [
{ label: "Human", value: "human" },
{ label: "Mouse", value: "mouse" },
] as const;
import type { Species } from "@platforma-open/milaboratories.generation-probability.kind";
import { kind, SPECIES_OPTIONS } from "@platforma-open/milaboratories.generation-probability.kind";

export const PGEN_NAME = "pl7.app/vdj/generationProbability";
const CHAIN_NAME = "pl7.app/vdj/chain";

// A bare string in a selector normalises to a REGEX matcher -- unanchored, and
// with `.` as a wildcard. Every name here is one literal, so match it exactly:
// the pframe also carries `pl7.app/vdj/minlog10GenerationProbability`, and an
// unanchored pattern is one rename away from catching it.
const exactly = (value: string) => ({ type: "exact" as const, value });

export type BlockData = {
inputAnchor?: string;
datasetLabel: string;
species?: (typeof SPECIES_OPTIONS)[number]["value"];
species?: Species;
tableState: PlDataTableStateV2;
distributionGraphState: GraphMakerState;
};
Expand All @@ -50,7 +53,9 @@ const inputSelectors = ENTITY_KEY_NAMES.map((name) => ({
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(() => ({
const dataModel = new DataModelBuilder({ kind }).from<BlockData>("v1").init(({ params }) => ({
inputAnchor: params?.inputAnchor,
species: params?.species,
datasetLabel: "",
tableState: createPlDataTableStateV2(),
distributionGraphState: {
Expand All @@ -62,7 +67,7 @@ const dataModel = new DataModelBuilder().from<BlockData>("v1").init(() => ({
},
}));

export const platforma = BlockModelV3.create(dataModel)
export const platforma = BlockModelV3.create({ dataModel, kind })

.args((data) => {
if (data.inputAnchor == null) throw new Error("Input dataset is required");
Expand All @@ -73,6 +78,12 @@ export const platforma = BlockModelV3.create(dataModel)
};
})

// Inverse of the kind's init-params contract: the same two fields `init`
// consumes. `datasetLabel` is derived by the UI from the picked option, and
// the table / chart states are view state -- neither is configuration a
// template carries.
.templateParams((data) => ({ inputAnchor: data.inputAnchor, species: data.species }))

.output("inputOptions", () => {
const collection = ColumnsCollection(["result_pool"]).filter({
include: inputSelectors,
Expand All @@ -82,34 +93,34 @@ export const platforma = BlockModelV3.create(dataModel)
// 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) => {
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();
const scorable = collection.getColumns().filter((anchor) => {
const spec = anchor.getSpec();
const keyAxis = keyAxisOf(spec);
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: spec },
include: [{ name: [exactly(CHAIN_NAME)] }],
mode: "enrichment",
})
.map((anchor) => anchor.id),
);
return deriveColumnOptions(collection)
.filter(({ id }) => scorableIds.has(id))
.map<ListOptionBase<string>>(({ id, label }) => ({ value: id, label }));
.isEmpty();
});
if (scorable.length === 0) return [];
// Label the survivors only: `deriveColumnOptions` reads the spec of every
// entry it is handed, so passing the whole collection here would re-read
// the ones just discarded.
return deriveColumnOptions([{ columns: scorable, isFinal: collection.isFinal() }]).map<
ListOptionBase<string>
>(({ id, label }) => ({ value: id, label }));
})

.outputWithStatus("pgenTable", (ctx) => {
Expand All @@ -118,8 +129,10 @@ export const platforma = BlockModelV3.create(dataModel)
const collection = ColumnsCollection([pgenOutput]);
if (!collection.isFinal()) return undefined;
return createPlDataTableV3(ctx, {
primaryColumns: collection.filter({ include: [{ name: PGEN_NAME }] }).getColumns(),
columns: collection.filter({ exclude: [{ name: PGEN_NAME }] }).getColumns(),
// Every column here comes straight off the block's own pframe accessor, so
// they are all bare leaves and pass `hasSingleDataColumn` by construction.
primaryColumns: collection.filter({ include: [{ name: exactly(PGEN_NAME) }] }).getColumns(),
columns: collection.filter({ exclude: [{ name: exactly(PGEN_NAME) }] }).getColumns(),
tableState: ctx.data.tableState,
});
})
Expand All @@ -129,29 +142,36 @@ export const platforma = BlockModelV3.create(dataModel)
.outputWithStatus("pgenGraphPf", (ctx) => {
const pgenOutput = ctx.outputs?.resolve("pgenPf");
if (pgenOutput === undefined) return undefined;
const provider = AccessorColumnsProvider(pgenOutput);
if (!provider.isFinal()) return undefined;
const pgenCols = provider
const collection = ColumnsCollection([pgenOutput]);
if (!collection.isFinal()) return undefined;
// Narrow host-side: only the survivors pay a spec and data round-trip.
// `isDataColumn` is the right guard for the PColumn bridge -- `PColumn.id`
// is typed `PObjectId`, which only a bare leaf carries.
const pgenCols = collection
.filter({ include: [{ name: exactly(PGEN_NAME) }] })
.getColumns()
.filter(isDataColumn)
.map<PColumn<undefined | PColumnDataUniversal>>((column) => ({
id: column.id,
spec: column.getSpec(),
data: column.getData(),
}))
.filter((column) => column.spec.name === PGEN_NAME);
}));
if (pgenCols.length === 0) return undefined;
return createPFrameForGraphs(ctx, pgenCols);
})

.output("pgenGraphPfCols", (ctx) => {
const pgenOutput = ctx.outputs?.resolve("pgenPf");
if (pgenOutput === undefined) return undefined;
const provider = AccessorColumnsProvider(pgenOutput);
if (!provider.isFinal()) return undefined;
return provider
const collection = ColumnsCollection([pgenOutput]);
if (!collection.isFinal()) return undefined;
// `PColumnIdAndSpec.columnId` is a `PObjectId` slot, same constraint as
// `PColumn.id` above.
return collection
.filter({ include: [{ name: exactly(PGEN_NAME) }] })
.getColumns()
.map<PColumnIdAndSpec>((column) => ({ columnId: column.id, spec: column.getSpec() }))
.filter((column) => column.spec.name === PGEN_NAME);
.filter(isDataColumn)
.map<PColumnIdAndSpec>((column) => ({ columnId: column.id, spec: column.getSpec() }));
})

.output("progress", (ctx) =>
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,4 @@
"oxlint": "*"
},
"packageManager": "pnpm@9.12.0"
}
}
Loading
Loading