diff --git a/.changeset/prerun-feedback-and-run-gate.md b/.changeset/prerun-feedback-and-run-gate.md new file mode 100644 index 0000000..e847fcf --- /dev/null +++ b/.changeset/prerun-feedback-and-run-gate.md @@ -0,0 +1,57 @@ +--- +'@platforma-open/milaboratories.import-vdj.workflow': patch +'@platforma-open/milaboratories.import-vdj.model': patch +'@platforma-open/milaboratories.import-vdj.ui': patch +'@platforma-open/milaboratories.import-vdj': patch +--- + +Say what the block is checking, and refuse to run until it has + +Loading a file left the panel silent while every column of it was profiled — a whole-file pass, +minutes on remote storage — and the profile outputs are retentive, so the dropdowns went on +answering with the *previous* file's headers as though nothing had happened. A mapping that had +passed every check against that previous file also still counted as valid, so Run stayed live over +a file nobody had read yet, against headers it might not even contain. + +- **The wait is announced, on both doors.** Prerun now states which file a profile was taken from, + and which dataset and format an inference was run for; the model pairs each with its result so the + two cannot get out of step. The panel announces the wait and withholds the mapping until the + columns on offer really belong to what is selected. Keyed to the input rather than to "prerun is + busy", because prerun also re-runs on every mapping edit to re-check the id column. The import + itself now shows the block's loader, which it never did. The verdict about the selected columns + now sits below the controls it is about, rather than between them. +- **The column list waits until it has something to list.** "The following columns will be + imported:" appeared with nothing under it as soon as a dataset was picked, before any format was, + which read as an import that would emit nothing. It also outlived a dataset switch, listing the + previous dataset's columns. +- **Loading a second file disables Run.** Picking a different file drops the parts of the mapping + that name columns, keeping the receptor declaration and the numbering scheme, which describe the + data rather than one file. Re-picking the same file is not a swap and keeps the mapping. +- **A repeated id column now stops the run.** The record key is the identity's hash, so a value + repeated on rows that are not identical merges two records into one. Prerun always found these + and the panel always warned, but the warning was only a warning, and a run driven through the API + imported the merged set without complaint. Run is now refused both while the verdict is + outstanding and when it reports a repeat, and the platform enforces it as well as the interface. +- **The warning names the mapping it is about.** It used to compare against the freshly picked + column while the verdict was still the previous mapping's, so changing an offending column flashed + the old accusation under the new selection. What a verdict covers is the id column *and* the + sequence columns, since a collision is a repeated id whose other mapped cells differ — keyed on + the id alone, a clean verdict outlived a remapped chain and the run gate accepted it. It also listed up to ten repeated values; it now lists three and a count, + printed whole, since the id column can hold sequences and trimming those hides what tells them + apart. +- **Alert headings appear.** Four alerts passed their heading to a slot `PlAlert` does not have, so + the headings had never rendered — a warning about a non-unique id column read as an unlabelled + wall of values. +- **The id column can be cleared.** Clearing it left the field reading "Value not available" in + red: "nothing chosen" is stored as an empty string, and a dropdown counts any value that is not + `undefined` as chosen. Relatedly, two places named the IG chain pair where they meant every + mapped chain, so a TCR mapping could never clear itself and was offered its own sequence columns + as record properties. + +Refusing the run on a prerun verdict needs that verdict inside the args projection, which sees only +the block's own data, so the UI mirrors it in. That is a hairpin, and deliberate: unlike a column +mapping there is no gesture at which the fact could be captured, because the scientist picks a +column and only then does the check discover whether it is sound. The two rules that keep it safe — +a verdict carries what it is about, and is dropped when the source changes — are stated on +`BlockData.prerunChecks`, and the checks still to come should follow them. It can all go once +`argsValid` can read prerun directly. diff --git a/block/src/block-extra.ts b/block/src/block-extra.ts index fc00cdd..603d743 100644 --- a/block/src/block-extra.ts +++ b/block/src/block-extra.ts @@ -7,3 +7,9 @@ // BlockPointer, platforma, or the block-named Block* // aliases — those names come from ./index and `export *` from this // file would shadow them. + +// The rule that decides whether a collision verdict is about the mapping now selected. Exposed +// because the run gate depends on it and the block's own tests have to state the verdict the UI +// would have mirrored in; a test that rebuilt the rule by hand would drift from the gate it means +// to exercise. +export { collisionCheckKey } from "@platforma-open/milaboratories.import-vdj.model"; diff --git a/model/src/data-model.ts b/model/src/data-model.ts index ab96a0e..61446a3 100644 --- a/model/src/data-model.ts +++ b/model/src/data-model.ts @@ -22,19 +22,6 @@ const viewStateDefaults = () => ({ airrColumnsPresent: false, }); -/** - * V1 kept the scientist's edits in two buckets — `args` and `uiState`. V3 keeps them in one, - * and derives what the workflow sees. This upgrade runs once per project, the first time a - * project saved under V1 is opened. - * - * The mapping is field-for-field: nothing is reshaped, because nothing in V1's shape was bent - * to dodge the stale gate. What changes is where the fields *go afterwards* — the labels and - * the secondary count type stop being projected into args, which is the whole point of the - * migration for this block. See `index.ts`. - * - * V1's `loadFromFile` is dropped rather than carried: the panel now derives which door it is - * showing from whether a file is loaded, so a stored flag could only disagree with the data. - */ export function upgradeLegacyData({ args, uiState, diff --git a/model/src/index.ts b/model/src/index.ts index 1d1479a..84c6565 100644 --- a/model/src/index.ts +++ b/model/src/index.ts @@ -1,4 +1,4 @@ -import type { InferOutputsType, PColumnKey, PColumnValue } from "@platforma-sdk/model"; +import type { InferOutputsType, PColumnKey, PColumnValue, PlRef } from "@platforma-sdk/model"; import { BlockModelV3, DataColumn, @@ -6,8 +6,15 @@ import { TreeNodeAccessor, } from "@platforma-sdk/model"; import { blockDataModel } from "./data-model"; -import type { BlockArgs, BlockData, ColumnDescription, ColumnProfile } from "./types"; -import { bareSetValid } from "./types"; +import type { + BareSetMapping, + BlockArgs, + BlockData, + ColumnDescription, + ColumnProfile, + ImportFormat, +} from "./types"; +import { bareSetValid, collisionCheckKey } from "./types"; export * from "./types"; export { upgradeLegacyData } from "./data-model"; @@ -39,14 +46,20 @@ function withoutDatasetDoorMapping(args: BlockArgs): BlockArgs { } /** - * The workflow's view of the block, and the only place validation lives. - * - * Three jobs, in order: refuse what cannot run (by throwing), drop the door that is not in - * use, and drop the fields the chosen path never reads. What it deliberately does NOT do is - * reorder `chains`: canonicalising a set the user picked in their own order would change the - * args of every project already on disk, and buys only the rare case of someone re-picking the - * same chains in a different order. + * Refuse a bare set whose mapped columns prerun has not cleared — including when there is no + * verdict yet. The record key is the identity's hash, so running before the answer is in is how two + * different records silently become one. */ +function requireCheckedColumns(data: BlockData): void { + const columns = collisionCheckKey(data.bareSet); + if (columns === undefined) return; // bareSetValid has already refused + const checks = data.prerunChecks; + if (checks?.columns !== columns) throw new Error("Validating the selected columns"); + if (checks.identityCollides) { + throw new Error(`"${data.bareSet?.identity}" repeats on rows that are not identical`); + } +} + function projectArgs(data: BlockData): BlockArgs { const { datasetRef, format, chains, customMapping, primaryCountType, fileSource } = data; @@ -60,6 +73,8 @@ function projectArgs(data: BlockData): BlockArgs { datasetRef, fileSource, format, + // Not sorted: canonicalising an order the user chose would change the args of every project + // already on disk, to buy the rare re-pick of the same chains in a different order. chains, customMapping, primaryCountType, @@ -72,6 +87,7 @@ function projectArgs(data: BlockData): BlockArgs { // mapping unfurled under a format nobody had chosen. if (fileSource !== undefined) { if (!bareSetValid(data.bareSet)) throw new Error("Finish mapping the file's columns"); + requireCheckedColumns(data); return withoutDatasetDoorMapping(args); } @@ -85,6 +101,7 @@ function projectArgs(data: BlockData): BlockArgs { // identity column, because the key is the identity's hash and the label is its value. if (data.bareSet !== undefined) { if (!bareSetValid(data.bareSet)) throw new Error("Finish mapping the record's columns"); + requireCheckedColumns(data); return withoutDatasetDoorMapping(args); } @@ -143,15 +160,6 @@ export const platforma = BlockModelV3.create(blockDataModel) bareSet: data.bareSet, })) - /** - * Identity values that appear on rows which are not identical to each other. - * - * Not `retentive`: this gates the run, so a stale value is worse than a briefly absent one. - * - * Read straight from prerun here, and *not* mirrored into `uiState` from a UI watcher. That - * mirror is what the format-validity flags do, and it is a hairpin — an output written back - * into state that a derivation then reads. It survives on one client and races on two. - */ /** * Drives the upload for a directly-loaded file. * @@ -177,7 +185,19 @@ export const platforma = BlockModelV3.create(blockDataModel) { isActive: true }, ) + /** + * Identity values repeated on rows that are not identical, with the mapping they were found + * under. One value, so the two can never be read from different runs. + * + * Not `retentive`: this reports a defect, so a briefly absent verdict beats a stale one. + */ .output("identityCollisions", (ctx) => { + const mapping = ctx.prerun + ?.resolve({ field: "collisionsFor", allowPermanentAbsence: true }) + ?.getDataAsJsonOrUndefined>(); + const key = collisionCheckKey(mapping); + if (key === undefined) return undefined; + const raw = ctx.prerun ?.resolve({ field: "identityCollisions", allowPermanentAbsence: true }) ?.getDataAsString(); @@ -188,7 +208,7 @@ export const platforma = BlockModelV3.create(blockDataModel) .split("\n") .map((l) => l.trim()) .filter((l) => l.length > 0); - return lines.slice(1); + return { key, values: lines.slice(1) }; }) .retentiveOutput("datasetOptions", (ctx) => { @@ -264,6 +284,44 @@ export const platforma = BlockModelV3.create(blockDataModel) } }) + /** + * Which file the profile the UI currently sees was taken from, so a mismatch with + * `data.fileSource.sampleId` means "the panel is showing the last file's columns". + * + * Keyed to the file, not to whether prerun is busy: `prerunArgs` carries `bareSet`, so prerun + * re-runs on every mapping edit to re-check the identity column for collisions. + */ + .retentiveOutput("profiledSampleId", (ctx) => { + const profile = ctx.prerun?.resolve({ field: "columnProfile", allowPermanentAbsence: true }); + if (profile === undefined) return undefined; + // Marks the read unstable (pl-tree/src/accessors.ts:347), so `retentive` keeps reporting the + // previous file's id until the new profile lands — the id and the profile can never disagree. + if (!profile.getIsReadyOrError()) return undefined; + return ctx.prerun + ?.resolve({ field: "profiledSampleId", allowPermanentAbsence: true }) + ?.getDataAsJsonOrUndefined(); + }) + + /** + * Drives the block's loader (`ui/src/app.ts`). Excludes prerun: the loader covers the whole + * block, and prerun re-runs while the settings panel is being edited. + */ + .output("isRunning", (ctx) => ctx.outputs?.getIsReadyOrError() === false) + + /** + * Which dataset and format the inference on screen was run for — the dataset door's twin of + * {@link profiledSampleId}, paired with the headers the same way. Absent on the bare-set path, + * where prerun answers with collisions instead of columns. + */ + .retentiveOutput("inferredFor", (ctx) => { + const headers = ctx.prerun?.resolve({ field: "headerColumns", allowPermanentAbsence: true }); + if (headers === undefined) return undefined; + if (!headers.getIsReadyOrError()) return undefined; + return ctx.prerun + ?.resolve({ field: "inferredFor", allowPermanentAbsence: true }) + ?.getDataAsJsonOrUndefined<{ datasetRef?: PlRef; format?: ImportFormat }>(); + }) + /** Headers of the dataset selected from the pool. Absent on the file door. */ .retentiveOutput("datasetColumns", (ctx) => { const headers = ctx.prerun diff --git a/model/src/types.ts b/model/src/types.ts index 6ef71ee..86b8b99 100644 --- a/model/src/types.ts +++ b/model/src/types.ts @@ -110,6 +110,13 @@ export const CHAIN_SLOT_LABELS: Record = { TCRGamma: "TCR-ɣ", }; +/** What to call each numbering scheme in front of the scientist. */ +export const SCHEME_LABELS: Record = { + imgt: "IMGT", + kabat: "Kabat", + chothia: "Chothia", +}; + /** What a column can hold, decided by profiling every row of the file. */ export type ColumnValueType = "Long" | "Double" | "String"; @@ -222,6 +229,20 @@ export type BlockData = { // --- bare set. Its presence is what selects the bare path in the workflow. bareSet?: BareSetMapping; + /** + * What prerun found, kept here so the args projection — which sees only `data` — can gate the run + * on it. Written by the UI (`ui/src/app.ts`). + * + * Every entry carries what it is *about* and is cleared when the source changes, so a verdict for + * something no longer selected is ignored rather than applied. Add further checks the same way. + */ + prerunChecks?: { + /** The mapping the verdicts below were reached for — see {@link collisionCheckKey}. */ + columns: string; + /** The id column repeats on rows whose other mapped cells differ, so two records would merge. */ + identityCollides: boolean; + }; + // --- view state. None of this is projected anywhere. tableState: PlDataTableStateV2; settingsOpen: boolean; @@ -278,6 +299,36 @@ export function propertyCollisions(properties: ImportedProperty[]): Record hs.length > 1)); } +/** + * What a collision verdict is about. A collision is an identity repeated on rows whose *other + * mapped cells* differ, so the sequence columns are part of the question and remapping a chain + * invalidates the answer. Sorted, so the key does not depend on the order columns were picked in. + */ +export function collisionCheckKey( + mapping: Pick | undefined, +): string | undefined { + if (mapping === undefined || !mapping.identity) return undefined; + const mapped = Object.entries(mapping.sequences ?? {}) + .filter(([, column]) => Boolean(column)) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([slot, column]) => `${slot}=${column}`); + return [mapping.identity, ...mapped].join("\u0000"); +} + +/** + * The mapping with everything that names a column dropped. The receptor declaration and the + * numbering scheme describe the biology and outlive any one file; the column names do not. + */ +export function forgetMappedColumns(bare: BareSetMapping | undefined): BareSetMapping | undefined { + if (bare === undefined) return undefined; + return { + identity: "", + chainSelection: bare.chainSelection, + sequences: {}, + scheme: bare.scheme, + }; +} + /** * Whether a bare-set mapping is complete enough to run. * diff --git a/test/src/wf.test.ts b/test/src/wf.test.ts index 61fa2b2..67dfcc8 100644 --- a/test/src/wf.test.ts +++ b/test/src/wf.test.ts @@ -19,7 +19,7 @@ import { blockSpec as sequencePropertiesSpec } from "@platforma-open/milaborator import type { PTableHandle } from "@platforma-sdk/model"; import { createPlDataTableStateV2, uniquePlId } from "@platforma-sdk/model"; import { awaitStableState, blockTest } from "@platforma-sdk/test"; -import { ImportVdjBlockPointer } from "this-block"; +import { collisionCheckKey, ImportVdjBlockPointer } from "this-block"; /** * A complete `BlockData` from the fields a test actually cares about. @@ -29,6 +29,9 @@ import { ImportVdjBlockPointer } from "this-block"; * visibly `tableState`, which the stats table is built from. */ function blockData(fields: Record): Record { + const bareSet = fields.bareSet as + | { identity: string; sequences: Record } + | undefined; return { defaultBlockLabel: "", customBlockLabel: "", @@ -40,6 +43,13 @@ function blockData(fields: Record): Record { mixcrColumnsPresent: false, crColumnsPresent: false, airrColumnsPresent: false, + // `args` refuses a bare set whose columns prerun has not cleared, and the verdict reaches data + // through a UI watcher these tests never run. So stand in for it — clean unless the test passes + // its own `prerunChecks`, which `...fields` below lets it do. Keyed with the block's own rule, + // so a change to what a verdict covers fails here rather than silently passing. + ...(collisionCheckKey(bareSet) !== undefined + ? { prerunChecks: { columns: collisionCheckKey(bareSet), identityCollides: false } } + : {}), ...fields, }; } @@ -307,33 +317,56 @@ blockTest( sequences: { IGHeavy: "VH", IGLight: "VL" }, scheme: SCHEME, }, + // What the UI mirrors in once prerun answers. Stated here because the mirror is a UI + // watcher and these tests drive the block directly. + prerunChecks: { + columns: collisionCheckKey({ + identity: "mAb ID", + sequences: { IGHeavy: "VH", IGLight: "VL" }, + }), + identityCollides: true, + }, }), }); const state = (await awaitStableState(project.getBlockState(blockId), 300000)) as { outputs?: Record; - inputsValid?: boolean; - canRun?: boolean; }; - const wrapped = state.outputs?.identityCollisions as - | { value?: string[] } - | string[] - | undefined; - const collisions = (Array.isArray(wrapped) ? wrapped : (wrapped?.value ?? [])) as string[]; + // The refusal the block's name promises: a colliding id column makes args invalid, so the + // interface offers no Run. Read from the overview, not the block state — the block state + // carries outputs, and runnability lives on the overview. + const overview = (await project.overview.getValue())!; + const blockOverview = overview.blocks.find((b) => b.id === blockId)!; + expect(blockOverview.inputsValid).toBe(false); + expect(blockOverview.canRun).toBe(false); + + type Verdict = { key: string; values: string[] }; + const wrapped = state.outputs?.identityCollisions as { value?: Verdict } | Verdict | undefined; + const found = (wrapped && "key" in wrapped ? wrapped : wrapped?.value) as Verdict | undefined; + + // The verdict names the mapping it is about — the id column AND the sequence columns, since a + // collision is a repeated id whose other mapped cells differ. Keyed on the id alone, a clean + // verdict outlived a remapped chain and the run gate accepted it, merging records. + expect(found?.key).toBe( + collisionCheckKey({ identity: "mAb ID", sequences: { IGHeavy: "VH", IGLight: "VL" } }), + ); + // Remapping a chain is a different question, so the old verdict must not answer it. + expect(found?.key).not.toBe( + collisionCheckKey({ identity: "mAb ID", sequences: { IGHeavy: "VH", IGLight: "VL2" } }), + ); + const collisions = found?.values ?? []; // The differing pair is reported, so the scientist is told which value to fix. expect(collisions).toContain("AB-001"); // The identical pair is not: repeating a record verbatim discards nothing. expect(collisions).not.toContain("AB-002"); - // GAP, verified here rather than assumed: `argsValid` disables Run in the interface, but - // the platform does not enforce it — `project.runBlock` resolves happily on an invalid - // block. So "the run does not start" holds for a scientist clicking Run and not for an API - // caller, and a colliding set driven through the API would still import and merge records. - // Closing that needs a workflow-side refusal, which is data-dependent and therefore a - // separate awaiting template. - await expect(project.runBlock(blockId)).resolves.toBeUndefined(); + // Enforced by the platform, not only by the interface: invalid args means no args to render a + // production from, so an API caller cannot drive a colliding set through either. This was a + // documented gap while the collision verdict sat outside the gate — args stayed valid, and + // `runBlock` imported a set that merged records without complaint. + await expect(project.runBlock(blockId)).rejects.toThrow(/currentArgs not set/); }, ); @@ -511,6 +544,12 @@ blockTest( "Affinity (nM)": "Double", }); + // The profile names the file it came from. The panel reads this to tell this file's columns + // from the previous file's, still retained while the new one is scanned. + expect((state.outputs?.profiledSampleId as { value?: string } | undefined)?.value).toBe( + "SDIRECT000000000000000001", + ); + // Indistinguishable from the pool door: same axes, same key, same columns — abundance // alone on [sampleId, variantKey], every property of the record on the record axis. for (const c of columns) { diff --git a/ui/src/app.ts b/ui/src/app.ts index 064ee1d..683c7dc 100644 --- a/ui/src/app.ts +++ b/ui/src/app.ts @@ -1,10 +1,15 @@ import { platforma } from "@platforma-open/milaboratories.import-vdj.model"; import { defineAppV3 } from "@platforma-sdk/ui-vue"; import MainPage from "./pages/MainPage.vue"; -import { watch } from "vue"; +import { watch, watchEffect } from "vue"; + +export const sdkPlugin = defineAppV3(platforma, (app) => { + syncPrerunChecks(app.model); -export const sdkPlugin = defineAppV3(platforma, () => { return { + // Main run only: the loader covers the whole block, and prerun re-runs while the settings + // panel is being edited. Prerun waits are announced inside the panel instead. + progress: () => app.model.outputs.isRunning, routes: { "/": () => MainPage, }, @@ -13,6 +18,41 @@ export const sdkPlugin = defineAppV3(platforma, () => { export const useApp = sdkPlugin.useApp; +type AppModel = ReturnType["model"]; + +/** + * Carry prerun's verdicts into `data` so the args projection can refuse the run on them. An output + * written back into state a derivation reads — the hairpin — kept safe by the contract on + * `BlockData.prerunChecks`, which the two halves below are. + */ +function syncPrerunChecks(model: AppModel) { + // Idempotent: every client derives the same verdict from the same output, and the guard means + // agreeing clients do not write at all. + watchEffect(() => { + const found = model.outputs.identityCollisions; + if (found === undefined) return; + const next = { columns: found.key, identityCollides: found.values.length > 0 }; + const current = model.data.prerunChecks; + if (current?.columns === next.columns && current.identityCollides === next.identityCollides) { + return; + } + model.data.prerunChecks = next; + }); + + // A verdict cannot outlive the file or dataset it was reached for. Watching primitives, not the + // refs, so a server patch swapping the data object does not clear it spuriously. + watch( + () => [ + model.data.fileSource?.sampleId, + model.data.datasetRef?.blockId, + model.data.datasetRef?.name, + ], + () => { + model.data.prerunChecks = undefined; + }, + ); +} + // Make sure labels are initialized const unwatch = watch(sdkPlugin, ({ loaded }) => { if (!loaded) return; diff --git a/ui/src/pages/MainPage.vue b/ui/src/pages/MainPage.vue index 401fd9c..ccaef08 100644 --- a/ui/src/pages/MainPage.vue +++ b/ui/src/pages/MainPage.vue @@ -1,17 +1,9 @@ + + + + diff --git a/workflow/src/bare-set-specs.lib.tengo b/workflow/src/bare-set-specs.lib.tengo index a71d468..2a2e9ec 100644 --- a/workflow/src/bare-set-specs.lib.tengo +++ b/workflow/src/bare-set-specs.lib.tengo @@ -744,8 +744,20 @@ columnsBySource := func(chains, scheme, properties) { } } +// The mapped sequence columns, keyed by slot — the other half of what the collision check reads. +// Emitted alongside a verdict so the panel and the run gate can tell a verdict about the current +// mapping from one reached before a chain was remapped. +mappedSequences := func(bare, mappedChains) { + mapped := {} + for chain in mappedChains { + mapped[chain] = bare.sequences[chain] + } + return mapped +} + export { CHAINS: CHAINS, + mappedSequences: mappedSequences, CHAIN_LABELS: CHAIN_LABELS, REGIONS: REGIONS, SCHEMES: SCHEMES, diff --git a/workflow/src/prerun.tpl.tengo b/workflow/src/prerun.tpl.tengo index 11b9cbf..24435eb 100644 --- a/workflow/src/prerun.tpl.tengo +++ b/workflow/src/prerun.tpl.tengo @@ -46,7 +46,10 @@ wf.body(func(args) { outputs := { // getImportProgress on this is what starts the upload. fileImports: imported.handle, - columnProfile: profile.output("profile") + columnProfile: profile.output("profile"), + // Which file the profile above describes. Stated here because nothing exposes the args + // a staging context was built with, so the model cannot work it out. + profiledSampleId: smart.createJsonResource(args.fileSource.sampleId) } if args.bareSet != undefined { @@ -60,6 +63,13 @@ wf.body(func(args) { } if bare.identity != "" && len(mappedChains) > 0 { + // What the collisions below are about: the identity AND the sequence columns, because a + // collision is a repeated identity whose other mapped cells differ. Naming only the + // identity let a verdict outlive the mapping it was reached for. + outputs.collisionsFor = smart.createJsonResource({ + identity: bare.identity, + sequences: specs.mappedSequences(bare, mappedChains) + }) outputs.identityCollisions = render.create(collisionsTpl, { dataset: { spec: { @@ -114,7 +124,11 @@ wf.body(func(args) { return { outputs: { - identityCollisions: collisions + identityCollisions: collisions, + collisionsFor: smart.createJsonResource({ + identity: bare.identity, + sequences: specs.mappedSequences(bare, mappedChains) + }) }, exports: {} } @@ -136,7 +150,14 @@ wf.body(func(args) { outputs: { columnsInfo: columnsInfo, columnDescriptions: columnDescriptions, - headerColumns: headerColumns + headerColumns: headerColumns, + // What the inference above was about. Same reason as the file door's profiledSampleId: + // the model cannot ask which args a staging context was built with, so the panel cannot + // otherwise tell this dataset's columns from the last one's, still on screen. + inferredFor: smart.createJsonResource({ + datasetRef: args.datasetRef, + format: args.format + }) }, exports: {} }