Skip to content

Commit a8f04a8

Browse files
authored
Merge pull request #9 from platforma-open/dedup-unified-pframe
Fix CID conflicts; unify outputs and exports
2 parents 5878736 + 7cb0850 commit a8f04a8

9 files changed

Lines changed: 242 additions & 335 deletions

File tree

.changeset/dedup-unified-pframe.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@platforma-open/milaboratories.sequence-properties.workflow': patch
3+
'@platforma-open/milaboratories.sequence-properties.model': patch
4+
'@platforma-open/milaboratories.sequence-properties.ui': patch
5+
'@platforma-open/milaboratories.sequence-properties': patch
6+
---
7+
8+
Refactor to fix CID conflicts between block instances with identical inputs and to align outputs with exports.
9+
10+
- Workflow: split per-block work out of the deferred render template so it dedups across block instances. The process template is renamed `info.tpl.tengo` and now only assembles the info blob from Python stats; xsv.importFile and pFrame building moved into `main.tpl.tengo` where `blockId`/trace are stamped at the spec layer.
11+
- Unified pFrame: the same pFrame is now published as both the block's UI output and the result-pool export — one canonical resource, two consumers. All calculated properties (charge, GRAVY, MW, pI, extinction coefficients, instability, aliphatic, aromaticity, ΔCharge) are exported, not just those flagged `isScore`.
12+
- Columns library: drop bespoke `pl7.app/isOutput` annotation in favor of `pl7.app/trace` for own-block identification in the UI. Public surface simplified to `buildColumns`, `aaFractionColumn`, `cloneSpec`.
13+
- Table: downgrade to PlAgDataTableV2 with the block's own columns only — fixed, predictable column list — until V3 default-visibility rules stabilize for the multi-source layout this block needs.
14+
- Clone-id / variant-key axis now visible by default in the table — it's the join key the user reads against the property values.
15+
- Plot pages: `dataColumnPredicate` filters by trace instead of `isOutput`.
16+
- Description: broadened to reflect the block's general utility beyond Lead Selection ranking.

docs/description.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Overview
22

3-
Computes physico-chemical properties for peptide and antibody/TCR sequences and emits them as standardized PColumns for Lead Selection ranking. The block detects modality from the input axis automatically — peptide or antibody/TCR — and degrades gracefully with sequencing coverage.
3+
Computes physico-chemical properties for peptide and antibody/TCR sequences and emits them as standardized PColumns — usable for ranking and lead selection, filtering and triage, plotting, downstream modeling, and any block that consumes per-clone or per-peptide numeric features. The block detects modality from the input axis automatically — peptide or antibody/TCR — and degrades gracefully with sequencing coverage.
44

55
Properties: net charge (pH 7), hydrophobicity (GRAVY), molecular weight, isoelectric point, extinction coefficients (oxidized and reduced), instability index, aliphatic index, aromaticity, and amino acid composition. Peptide mode computes them on the full sequence. Antibody/TCR mode computes them per CDR3 (CDR-H3/L3, or α3/β3 and γ3/δ3 for TCR), per full chain (VH/VL), and at the Fv level for paired antibody chains. Full-chain and Fv columns require all seven VDJ regions (FR1, CDR1, FR2, CDR2, FR3, CDR3, FR4); CDR3-only inputs receive CDR3 properties only.

model/src/index.ts

Lines changed: 10 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,9 @@
1-
import type {
2-
ColumnSource,
3-
InferOutputsType,
4-
PColumnIdAndSpec,
5-
PFrameHandle,
6-
} from "@platforma-sdk/model";
1+
import type { InferOutputsType, PColumnIdAndSpec, PFrameHandle } from "@platforma-sdk/model";
72
import {
83
Annotation,
9-
ArrayColumnProvider,
104
BlockModelV3,
115
createPFrameForGraphs,
12-
createPlDataTableV3,
6+
createPlDataTableV2,
137
} from "@platforma-sdk/model";
148
import { blockDataModel } from "./dataModel";
159
import type { BlockArgs, WorkflowInfo } from "./types";
@@ -60,90 +54,14 @@ export const platforma = BlockModelV3.create(blockDataModel)
6054
if (ctx.data.inputAnchor === undefined) return undefined;
6155
const ownCols = ctx.outputs?.resolve("propertiesPf")?.getPColumns();
6256
if (ownCols === undefined) return undefined;
63-
// `coverageTier` is set in workflow/main.tpl.tengo and surfaced via the
64-
// `info` JSON resource. Allowed values are defined in types.ts::WorkflowInfo.
65-
// Gate on `info` so the table renders consistently with the chosen aa
66-
// column rather than briefly without it while `info` is still resolving.
67-
const info = ctx.outputs?.resolve("info")?.getDataAsJson<WorkflowInfo>();
68-
if (info === undefined) return undefined;
69-
const tier = info.coverageTier;
70-
71-
// Build sources explicitly: upstream cols from the result pool minus
72-
// anything traced back to this block, plus this block's own cols from
73-
// `propertiesPf`. The workflow also publishes `exports.properties` —
74-
// a blockId-stamped score-only variant for downstream consumers like
75-
// Lead Selection — into the result pool. Filtering by trace excludes
76-
// it here so score cols don't duplicate the propertiesPf variant.
77-
const upstreamCols = ctx.resultPool.selectColumns(
78-
(spec) =>
79-
!spec.annotations?.[Annotation.Trace]?.includes("milaboratories.sequence-properties"),
80-
);
81-
const sources: ColumnSource[] = [
82-
new ArrayColumnProvider(upstreamCols),
83-
new ArrayColumnProvider(ownCols),
84-
];
85-
86-
return createPlDataTableV3(ctx, {
87-
tableState: ctx.data.tableState,
88-
columns: {
89-
sources,
90-
anchors: { main: ctx.data.inputAnchor },
91-
selector: { mode: "enrichment" },
92-
},
93-
// Default-visible: this block's columns + a single source amino-acid
94-
// sequence column matching the analysed coverage tier. Reviewer asked
95-
// for one sequence next to the properties — full-chain VDJRegion when
96-
// available (it contains the CDR3); CDR3 alone when that is all the
97-
// input has; peptide for peptide mode. Chain A (heavy / alpha / gamma)
98-
// only — chain B stays available via the column picker. Other upstream
99-
// cols → optional. This block's cols fall through unmatched and keep
100-
// their workflow-time `pl7.app/table/visibility` annotation.
101-
displayOptions: {
102-
visibility: [
103-
{
104-
match: (spec) => {
105-
if (spec.domain?.["pl7.app/vdj/scClonotypeChain/index"] === "secondary") {
106-
return false;
107-
}
108-
if (spec.domain?.["pl7.app/alphabet"] !== "aminoacid") return false;
109-
110-
const isVdj = spec.name === "pl7.app/vdj/sequence";
111-
const isUniversal = spec.name === "pl7.app/sequence";
112-
if (!isVdj && !isUniversal) return false;
113-
114-
const feature = isVdj
115-
? spec.domain?.["pl7.app/vdj/feature"]
116-
: spec.domain?.["pl7.app/feature"];
117-
118-
if (tier === "peptide") {
119-
return isUniversal && feature === "peptide";
120-
}
121-
122-
const chain = spec.domain?.["pl7.app/vdj/scClonotypeChain"];
123-
if (chain !== undefined && chain !== "A") return false;
124-
125-
if (tier === "full_chain") {
126-
return feature === "VDJRegion" || feature === "VDJRegionInFrame";
127-
}
128-
if (tier === "cdr3_only" || tier === "partial") {
129-
return feature === "CDR3";
130-
}
131-
return false;
132-
},
133-
visibility: "default",
134-
},
135-
{
136-
match: (spec) =>
137-
!spec.annotations?.[Annotation.Trace]?.includes(
138-
"milaboratories.sequence-properties",
139-
) &&
140-
spec.annotations?.["pl7.app/isLinkerColumn"] !== "true" &&
141-
spec.annotations?.["pl7.app/isOutput"] !== "true",
142-
visibility: "optional",
143-
},
144-
],
145-
},
146-
});
57+
// Temporary downgrade to V2 with a fixed, propertiesPf-only column list.
58+
// V3 default-visibility rules and the column picker are not yet stable for
59+
// the multi-source layout this block needs (upstream sequence column +
60+
// own scalar properties). Once V3 supports it natively, rewire across
61+
// blocks. aaFraction is 2-axis (variantKey × aminoAcid) — already filtered
62+
// from the graph pFrame; filtered here too so it doesn't widen the table.
63+
const tableCols = ownCols.filter((c) => c.spec.axesSpec.length === 1);
64+
return createPlDataTableV2(ctx, tableCols, ctx.data.tableState);
14765
})
14866
.outputWithStatus("propertiesPfHandle", (ctx): PFrameHandle | undefined => {
14967
const allPCols = ctx.outputs?.resolve("propertiesPf")?.getPColumns();

ui/src/pages/HistogramPage.vue

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,15 @@ const defaultOptions = computed((): PredefinedGraphOption<"histogram">[] | null
2424
return [{ inputName: "value", selectedSource: metric }];
2525
});
2626
27+
// Data = this block's own scalar properties. The propertiesPfHandle contains
28+
// pCols (ours, trace-injected) ∪ upstreamMeta (filtered out our trace at the
29+
// model layer). Our trace identifies our data candidates.
2730
const dataColumnPredicate = (spec: PColumnSpec) =>
2831
isNumericScalar(spec) &&
29-
spec.annotations?.["pl7.app/isOutput"] === "true" &&
30-
!spec.annotations?.["pl7.app/trace"]?.includes("sequence-properties");
32+
spec.annotations?.["pl7.app/trace"]?.includes("sequence-properties") === true;
3133
34+
// Meta = upstream columns only (sample groups, patient IDs, etc.) — anything
35+
// without our trace.
3236
const metaColumnPredicate = (spec: PColumnSpec) =>
3337
!spec.annotations?.["pl7.app/trace"]?.includes("sequence-properties");
3438
</script>

ui/src/pages/ScatterPage.vue

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,15 @@ const defaultOptions = computed((): PredefinedGraphOption<"scatterplot-umap">[]
3333
];
3434
});
3535
36+
// Data = this block's own scalar properties. The propertiesPfHandle contains
37+
// pCols (ours, trace-injected) ∪ upstreamMeta (filtered out our trace at the
38+
// model layer). Our trace identifies our data candidates.
3639
const dataColumnPredicate = (spec: PColumnSpec) =>
3740
isNumericScalar(spec) &&
38-
spec.annotations?.["pl7.app/isOutput"] === "true" &&
39-
!spec.annotations?.["pl7.app/trace"]?.includes("sequence-properties");
41+
spec.annotations?.["pl7.app/trace"]?.includes("sequence-properties") === true;
4042
43+
// Meta = upstream columns only (sample groups, patient IDs, etc.) — anything
44+
// without our trace.
4145
const metaColumnPredicate = (spec: PColumnSpec) =>
4246
!spec.annotations?.["pl7.app/trace"]?.includes("sequence-properties");
4347
</script>

workflow/src/columns.lib.tengo

Lines changed: 68 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
11
// Output PColumn specs emitted by sequence-properties.
22
//
3-
// Two getters wrap a canonical column list and apply per-consumer annotations:
3+
// Public surface:
44
//
5-
// - `forPropertiesPf(args)` — every column, `pl7.app/isOutput: "true"`
6-
// added on top of the canonical annotations.
7-
// - `forExport(args, blockId)` — filtered to `pl7.app/isScore: "true"`
8-
// columns; domain cloned + `pl7.app/blockId`
9-
// stamped on so downstream blocks can
10-
// distinguish runs.
5+
// - `buildColumns(args)` — canonical scalar-property column list.
6+
// Returns `[{column, id, naRegex,
7+
// allowNA, spec}, ...]` with feature-
8+
// specific domains and annotations.
9+
// No blockId, no isOutput, no trace
10+
// — those are stamped on by the caller
11+
// at pframe-build time.
12+
// - `aaFractionColumn()` — 2-axis AA-fraction column descriptor
13+
// (peptide mode only).
14+
// - `cloneSpec(spec, dExtras, aExtras)` — spec-cloning helper used by the
15+
// caller to layer blockId / isOutput /
16+
// any other per-consumer overrides.
1117
//
1218
// `args` shape:
1319
// { mode, receptor, chains, fullChains, hasFv }
14-
// — identical to the relevant subset of `process.tpl.tengo`'s `params`.
20+
// — identical to the relevant subset of `main.tpl.tengo`'s plan.
1521

1622
ll := import("@platforma-sdk/workflow-tengo:ll")
1723

@@ -379,68 +385,86 @@ buildColumns := func(args) {
379385

380386
// ---------------------------------------------------------------------------
381387
// Spec-cloning helper. Builds a fresh spec dict with optional domain and
382-
// annotation extras. Used by both getters so the two outputs never share
383-
// dict references.
388+
// annotation extras. Callers use this to layer per-consumer overrides
389+
// (e.g. `pl7.app/blockId` in domain, `pl7.app/isOutput` in annotations).
384390
// ---------------------------------------------------------------------------
385391

386392
cloneSpec := func(spec, domainExtras, annotationExtras) {
393+
// Shallow-copy every top-level key (kind, name, valueType, axesSpec, ...)
394+
// so specs already filled in by xsv.importFile keep their kind + axesSpec.
395+
out := {}
396+
for k, v in spec { out[k] = v }
397+
398+
// Overlay domain — always build a fresh dict so the result never aliases
399+
// the caller's input domain.
387400
newDomain := {}
388401
if spec.domain {
389402
for k, v in spec.domain { newDomain[k] = v }
390403
}
391404
if domainExtras {
392405
for k, v in domainExtras { newDomain[k] = v }
393406
}
407+
out.domain = newDomain
408+
409+
// Overlay annotations — same fresh-dict rule.
394410
newAnnotations := {}
395411
if spec.annotations {
396412
for k, v in spec.annotations { newAnnotations[k] = v }
397413
}
398414
if annotationExtras {
399415
for k, v in annotationExtras { newAnnotations[k] = v }
400416
}
401-
return {
402-
name: spec.name,
403-
valueType: spec.valueType,
404-
domain: newDomain,
405-
annotations: newAnnotations
406-
}
407-
}
417+
out.annotations = newAnnotations
408418

409-
wrap := func(col, newSpec) {
410-
return {
411-
column: col.column,
412-
id: col.id,
413-
naRegex: col.naRegex,
414-
allowNA: col.allowNA,
415-
spec: newSpec
416-
}
419+
return out
417420
}
418421

419422
// ---------------------------------------------------------------------------
420-
// Public getters.
423+
// AA fraction (peptide mode) — single 2-axis column descriptor.
424+
// Lives here so the entire column inventory of the block is in one place.
421425
// ---------------------------------------------------------------------------
422426

423-
forPropertiesPf := func(args) {
424-
cols := buildColumns(args)
425-
out := []
426-
for col in cols {
427-
out += [wrap(col, cloneSpec(col.spec, undefined, { "pl7.app/isOutput": "true" }))]
427+
aaFractionColumn := func(keyAxisSpec) {
428+
return {
429+
axes: [
430+
{ column: "entity_key", spec: keyAxisSpec },
431+
{
432+
column: "aminoAcid",
433+
spec: {
434+
name: "pl7.app/aminoAcid",
435+
type: "String",
436+
annotations: { "pl7.app/label": "Amino Acid" }
437+
}
438+
}
439+
],
440+
column: {
441+
column: "value",
442+
id: "aaFraction",
443+
naRegex: "",
444+
allowNA: true,
445+
spec: {
446+
name: "pl7.app/aaFraction",
447+
valueType: "Double",
448+
domain: { "pl7.app/feature": "peptide" },
449+
annotations: {
450+
"pl7.app/label": "AA Fraction",
451+
"pl7.app/format": ".3f",
452+
"pl7.app/min": "0",
453+
"pl7.app/max": "1",
454+
"pl7.app/table/visibility": "optional",
455+
"pl7.app/table/orderPriority": "69000"
456+
}
457+
}
458+
}
428459
}
429-
return out
430460
}
431461

432-
forExport := func(args, blockId) {
433-
cols := buildColumns(args)
434-
out := []
435-
for col in cols {
436-
if !col.spec.annotations { continue }
437-
if col.spec.annotations["pl7.app/isScore"] != "true" { continue }
438-
out += [wrap(col, cloneSpec(col.spec, { "pl7.app/blockId": blockId }, undefined))]
439-
}
440-
return out
441-
}
462+
// ---------------------------------------------------------------------------
463+
// Public exports.
464+
// ---------------------------------------------------------------------------
442465

443466
export ll.toStrict({
444-
forPropertiesPf: forPropertiesPf,
445-
forExport: forExport
467+
buildColumns: buildColumns,
468+
aaFractionColumn: aaFractionColumn,
469+
cloneSpec: cloneSpec
446470
})

workflow/src/info.tpl.tengo

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Info-blob builder. Runs after the Python step completes (depends on its
2+
// `stats.json` output) and emits the JSON resource consumed by the model's
3+
// `info` output. Kept as a separate template — not inlined into main —
4+
// because it must wait for the Python step's stats resource to resolve,
5+
// and as a deferred render it dedups across block instances with identical
6+
// inputs.
7+
8+
self := import("@platforma-sdk/workflow-tengo:tpl")
9+
smart := import("@platforma-sdk/workflow-tengo:smart")
10+
canonical := import("@platforma-sdk/workflow-tengo:canonical")
11+
constants := import("@platforma-sdk/workflow-tengo:constants")
12+
messages := import(":messages")
13+
14+
self.defineOutputs("info")
15+
16+
self.body(func(args) {
17+
params := args.params
18+
mode := params.mode
19+
receptor := params.receptor
20+
chainsWithCdr3 := params.chainsWithCdr3
21+
coverageTier := params.coverageTier
22+
infoMessages := params.infoMessages
23+
24+
stats := args.stats.getDataAsJson()
25+
medians := stats.medianCdr3Length
26+
27+
// R11c — single-domain antibodies (nanobodies / VHH) miss the IgG-calibrated
28+
// CDR-H3 length risk thresholds. Surface an info message when the dataset
29+
// looks like VHH (heavy chain only, long median CDR-H3 ≥ 16 aa).
30+
if receptor == "IG" && len(chainsWithCdr3) == 1 && chainsWithCdr3[0] == "A" {
31+
medA := medians["A"]
32+
if medA != undefined && medA >= 16 {
33+
infoMessages += [messages.vhh()]
34+
}
35+
}
36+
37+
// R9 — Instability Index is NA for peptides shorter than 10 aa. Surface a
38+
// banner in peptide mode whenever any row falls below the floor so the
39+
// user understands why the Instability Index column is blank.
40+
if mode == "peptide" && stats.hasPeptideBelowInstabilityFloor == true {
41+
infoMessages += [messages.peptidesShortInstability()]
42+
}
43+
44+
return {
45+
info: smart.createValueResource(constants.RTYPE_JSON, canonical.encode({
46+
mode: mode,
47+
receptor: receptor,
48+
coverageTier: coverageTier,
49+
messages: infoMessages
50+
}))
51+
}
52+
})

0 commit comments

Comments
 (0)