Support cluster level properties#4
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces support for cluster-level inputs by resolving cluster-keyed properties down to individual variants using the variant-to-cluster linker. This is achieved by updating column discovery in the model to traverse the linker (setting maxHops: 1) and explicitly joining the cluster-keyed values with the linker in the workflow. Feedback on the workflow template suggests caching the resolved linker frames (linkDf) to avoid redundant heavy helper jobs when processing multiple rounds from the same clustering run, as well as guarding against undefined when retrieving the linkers.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| // Resolve a property column to a [variantKey -> <valueHeader>] frame. A | ||
| // variant-keyed column is read directly; a cluster-keyed column (pl7.app/clusterId) | ||
| // is broadcast to its member variants through the variant→cluster linker | ||
| // (pl7.app/link), joined explicitly here — the bundle does not re-project a value | ||
| // across a linker onto the finer variant axis (A-0016). Returns undefined when the | ||
| // column is neither variant- nor cluster-keyed, or its clustering run has no linker. | ||
| resolveVariantValueDf := func(colKey, valueHeader) { | ||
| spec := columns.getSpec(colKey) | ||
| if is_undefined(spec) { | ||
| return undefined | ||
| } | ||
| isVariantKeyed := false | ||
| for ax in spec.axesSpec { | ||
| if ax.name == VARIANT_KEY_AXIS { | ||
| isVariantKeyed = true | ||
| break | ||
| } | ||
| } | ||
| if isVariantKeyed { | ||
| vt := pframes.tsvFileBuilder() | ||
| vt.mem("8GiB") | ||
| vt.cpu(2) | ||
| vt.setAxisHeader(VARIANT_KEY_AXIS, "variantKey") | ||
| vt.add(columns.getColumn(colKey), { header: "val" }) | ||
| return ptWf.frame(vt.build(), { xsvType: "tsv" }). | ||
| select(pt.col("variantKey"), pt.col("val").alias(valueHeader)) | ||
| } | ||
| // Cluster-keyed: locate the property's cluster axis. | ||
| clusterAxis := undefined | ||
| for ax in spec.axesSpec { | ||
| if isClusterIdAxis(ax.name) { | ||
| clusterAxis = ax | ||
| break | ||
| } | ||
| } | ||
| if is_undefined(clusterAxis) { | ||
| return undefined | ||
| } | ||
| // Pick the variant→cluster linker from the same clustering run (domain match). | ||
| linker := undefined | ||
| linkerClusterAxisName := undefined | ||
| for lc in columns.getColumns("linkers") { | ||
| hasVariant := false | ||
| lcCluster := undefined | ||
| for ax in lc.spec.axesSpec { | ||
| if ax.name == VARIANT_KEY_AXIS { | ||
| hasVariant = true | ||
| } | ||
| if isClusterIdAxis(ax.name) { | ||
| lcCluster = ax | ||
| } | ||
| } | ||
| if hasVariant && !is_undefined(lcCluster) && clusterAxisDomainsMatch(clusterAxis, lcCluster) { | ||
| linker = lc | ||
| linkerClusterAxisName = lcCluster.name | ||
| break | ||
| } | ||
| } | ||
| if is_undefined(linker) { | ||
| return undefined | ||
| } | ||
| // Cluster property frame [clusterId -> value]. | ||
| ct := pframes.tsvFileBuilder() | ||
| ct.mem("8GiB") | ||
| ct.cpu(2) | ||
| ct.setAxisHeader(clusterAxis.name, "clusterId") | ||
| ct.add(columns.getColumn(colKey), { header: "val" }) | ||
| clusterDf := ptWf.frame(ct.build(), { xsvType: "tsv" }). | ||
| select(pt.col("clusterId"), pt.col("val").alias(valueHeader)) | ||
| // Linker frame [clusterId, variantKey] (value column unused). | ||
| lt := pframes.tsvFileBuilder() | ||
| lt.mem("8GiB") | ||
| lt.cpu(2) | ||
| lt.setAxisHeader(linkerClusterAxisName, "clusterId") | ||
| lt.setAxisHeader(VARIANT_KEY_AXIS, "variantKey") | ||
| lt.add(linker, { header: "link" }) | ||
| linkDf := ptWf.frame(lt.build(), { xsvType: "tsv" }). | ||
| select(pt.col("clusterId"), pt.col("variantKey")) | ||
| // Broadcast each cluster's value onto its member variants. | ||
| return clusterDf.join(linkDf, { on: "clusterId", how: "inner" }). | ||
| select(pt.col("variantKey"), pt.col(valueHeader)) | ||
| } |
There was a problem hiding this comment.
Performance Optimization: Cache Resolved Linker Frames
Currently, resolveVariantValueDf builds a new linker frame (linkDf) from scratch every time it is called for a cluster-keyed column. In a typical composition-enrichment view with multiple rounds (e.g., 3 to 10 rounds), all rounds are usually from the same clustering run and thus share the exact same variant→cluster linker column.
Rebuilding the linker frame for each round creates redundant heavy helper jobs (each requesting 8GiB of memory and 2 CPUs) to export and load the same column repeatedly.
Solution:
We can cache the resolved linkDf frames by linker.id in a map defined in the outer scope of wf.body and reuse them across calls.
Additionally, we should guard the iteration over columns.getColumns("linkers") to prevent potential runtime crashes if getColumns returns undefined.
linkerFramesCache := {}
// Resolve a property column to a [variantKey -> <valueHeader>] frame. A
// variant-keyed column is read directly; a cluster-keyed column (pl7.app/clusterId)
// is broadcast to its member variants through the variant→cluster linker
// (pl7.app/link), joined explicitly here — the bundle does not re-project a value
// across a linker onto the finer variant axis (A-0016). Returns undefined when the
// column is neither variant- nor cluster-keyed, or its clustering run has no linker.
resolveVariantValueDf := func(colKey, valueHeader) {
spec := columns.getSpec(colKey)
if is_undefined(spec) {
return undefined
}
isVariantKeyed := false
for ax in spec.axesSpec {
if ax.name == VARIANT_KEY_AXIS {
isVariantKeyed = true
break
}
}
if isVariantKeyed {
vt := pframes.tsvFileBuilder()
vt.mem("8GiB")
vt.cpu(2)
vt.setAxisHeader(VARIANT_KEY_AXIS, "variantKey")
vt.add(columns.getColumn(colKey), { header: "val" })
return ptWf.frame(vt.build(), { xsvType: "tsv" }).
select(pt.col("variantKey"), pt.col("val").alias(valueHeader))
}
// Cluster-keyed: locate the property's cluster axis.
clusterAxis := undefined
for ax in spec.axesSpec {
if isClusterIdAxis(ax.name) {
clusterAxis = ax
break
}
}
if is_undefined(clusterAxis) {
return undefined
}
// Pick the variant→cluster linker from the same clustering run (domain match).
linker := undefined
linkerClusterAxisName := undefined
linkers := columns.getColumns("linkers")
if !is_undefined(linkers) {
for lc in linkers {
hasVariant := false
lcCluster := undefined
for ax in lc.spec.axesSpec {
if ax.name == VARIANT_KEY_AXIS {
hasVariant = true
}
if isClusterIdAxis(ax.name) {
lcCluster = ax
}
}
if hasVariant && !is_undefined(lcCluster) && clusterAxisDomainsMatch(clusterAxis, lcCluster) {
linker = lc
linkerClusterAxisName = lcCluster.name
break
}
}
}
if is_undefined(linker) {
return undefined
}
// Cluster property frame [clusterId -> value].
ct := pframes.tsvFileBuilder()
ct.mem("8GiB")
ct.cpu(2)
ct.setAxisHeader(clusterAxis.name, "clusterId")
ct.add(columns.getColumn(colKey), { header: "val" })
clusterDf := ptWf.frame(ct.build(), { xsvType: "tsv" }).
select(pt.col("clusterId"), pt.col("val").alias(valueHeader))
// Linker frame [clusterId, variantKey] (value column unused).
linkDf := linkerFramesCache[linker.id]
if is_undefined(linkDf) {
lt := pframes.tsvFileBuilder()
lt.mem("8GiB")
lt.cpu(2)
lt.setAxisHeader(linkerClusterAxisName, "clusterId")
lt.setAxisHeader(VARIANT_KEY_AXIS, "variantKey")
lt.add(linker, { header: "link" })
linkDf = ptWf.frame(lt.build(), { xsvType: "tsv" }).
select(pt.col("clusterId"), pt.col("variantKey"))
linkerFramesCache[linker.id] = linkDf
}
// Broadcast each cluster's value onto its member variants.
return clusterDf.join(linkDf, { on: "clusterId", how: "inner" }).
select(pt.col("variantKey"), pt.col(valueHeader))
}
| clusterAxisDomainsMatch := func(a, b) { | ||
| if is_undefined(a) || is_undefined(b) { | ||
| return false | ||
| } | ||
| if is_undefined(a.domain) != is_undefined(b.domain) { | ||
| return false | ||
| } | ||
| if is_undefined(a.domain) && is_undefined(b.domain) { | ||
| return true | ||
| } | ||
| if len(a.domain) != len(b.domain) { | ||
| return false | ||
| } | ||
| for k, v in a.domain { | ||
| if is_undefined(b.domain[k]) || b.domain[k] != v { | ||
| return false | ||
| } | ||
| } | ||
| return true | ||
| } |
There was a problem hiding this comment.
undefined vs empty-map domain treated differently
clusterAxisDomainsMatch returns false when one axis has domain: undefined and the other has domain: {} (empty map). Platforms sometimes omit the domain key entirely and sometimes emit an empty object, so two axis specs from the same clustering run could compare unequal, causing the linker lookup to fail silently and the cluster-keyed column to be treated as unresolvable. Consider normalising the check: if either domain is undefined treat it as an empty map before comparing.
Prompt To Fix With AI
This is a comment left during a code review.
Path: workflow/src/main.tpl.tengo
Line: 39-58
Comment:
**`undefined` vs empty-map domain treated differently**
`clusterAxisDomainsMatch` returns `false` when one axis has `domain: undefined` and the other has `domain: {}` (empty map). Platforms sometimes omit the domain key entirely and sometimes emit an empty object, so two axis specs from the same clustering run could compare unequal, causing the linker lookup to fail silently and the cluster-keyed column to be treated as unresolvable. Consider normalising the check: if either domain is `undefined` treat it as an empty map before comparing.
How can I resolve this? If you propose a fix, please make it concise.| keptByProperty := undefined | ||
| if hasPropFilter { | ||
| fpTable := pframes.tsvFileBuilder() | ||
| fpTable.mem("8GiB") | ||
| fpTable.cpu(2) | ||
| fpTable.setAxisHeader(VARIANT_KEY_AXIS, "variantKey") | ||
| fpTable.add(columns.getColumn("filterProperty"), { header: "fProp" }) | ||
| k := ptWf.frame(fpTable.build(), { xsvType: "tsv" }) | ||
| if !is_undefined(propMin) { | ||
| k = k.filter(pt.col("fProp").ge(pt.lit(propMin))) | ||
| } | ||
| if !is_undefined(propMax) { | ||
| k = k.filter(pt.col("fProp").le(pt.lit(propMax))) | ||
| filterDf := resolveVariantValueDf("filterProperty", "fProp") | ||
| if !is_undefined(filterDf) { | ||
| k := filterDf | ||
| if !is_undefined(propMin) { | ||
| k = k.filter(pt.col("fProp").ge(pt.lit(propMin))) | ||
| } | ||
| if !is_undefined(propMax) { | ||
| k = k.filter(pt.col("fProp").le(pt.lit(propMax))) | ||
| } | ||
| keptByProperty = k.select(pt.col("variantKey")) | ||
| } | ||
| keptByProperty = k.select(pt.col("variantKey")) | ||
| } |
There was a problem hiding this comment.
Silent filter drop when cluster linker is unresolvable
When hasPropFilter is true (the user set a property-range filter) but resolveVariantValueDf returns undefined (e.g., no matching linker), keptByProperty stays undefined. Because filterActive is now hasSampleFilter || !is_undefined(keptByProperty), the property filter is silently ignored: the "Filtered" facet never appears, and there is no indication to the user that the filter had no effect. This asymmetry exists already for the property value-mode path (which returns empty outputs), but for the filter path the block still renders—just without the filter the user configured.
Prompt To Fix With AI
This is a comment left during a code review.
Path: workflow/src/main.tpl.tengo
Line: 279-292
Comment:
**Silent filter drop when cluster linker is unresolvable**
When `hasPropFilter` is `true` (the user set a property-range filter) but `resolveVariantValueDf` returns `undefined` (e.g., no matching linker), `keptByProperty` stays `undefined`. Because `filterActive` is now `hasSampleFilter || !is_undefined(keptByProperty)`, the property filter is silently ignored: the "Filtered" facet never appears, and there is no indication to the user that the filter had no effect. This asymmetry exists already for the property value-mode path (which returns empty outputs), but for the filter path the block still renders—just without the filter the user configured.
How can I resolve this? If you propose a fix, please make it concise.
Greptile Summary
This PR extends the mutation heatmap block so that cluster-keyed columns (keyed by
pl7.app/clusterId) can be used wherever variant-keyed columns were previously accepted: as the property value source, as the property-range filter input, and as per-round enrichment frequencies for the composition view. Cluster values are broadcast to individual variants at workflow execution time via the variant→cluster linker (pl7.app/link) using an explicit inner join in the newresolveVariantValueDfhelper.model/src/index.ts):maxHopsinfindColumnsraised from 0 to 1 for bothpropertyOptionsandroundFrequencyOptions, with an explicitisLinkerColumnguard added topropertyOptionsto prevent the linker column itself from appearing as a selectable numeric property.workflow/src/main.tpl.tengo): NewisClusterIdAxis/clusterAxisDomainsMatchhelpers andresolveVariantValueDffunction handle both variant- and cluster-keyed columns uniformly; linkers are fetched into the bundle duringprepareonly when at least one cluster-capable input is active;filterActiveis correctly deferred until after resolution so the "Filtered" facet only appears when the filter actually restricts the variant set.Key terms introduced or changed in this PR:
pl7.app/clusterId/pl7.app/vdj/clusterIdpl7.app/isLinkerColumnpl7.app/link) column with axes[clusterId, variantKey]addMultiwhen cluster resolution may be neededresolveVariantValueDf[variantKey → value]frameclusterAxisDomainsMatchmaxHopsfindColumns; 1 now allows traversal through the variant→cluster linker to surface cluster-keyed columnspropertyOptionsandroundFrequencyOptionsfilterActivehasSampleFilter || !is_undefined(keptByProperty)— so a cluster-keyed filter that cannot be resolved does not produce a spurious or missing facetneedLinkersprepare-phase flag; true whenever any cluster-capable input is presentkeptByPropertyresolveVariantValueDf, supporting both variant- and cluster-keyed filter propertiesConfidence Score: 4/5
Safe to merge; all three cluster-capable input paths handle unresolvable linkers gracefully and the core variant-keyed path is unchanged.
The central resolveVariantValueDf logic is sound and the deduplication, linker selection, and join semantics are all correct. Two edge cases are worth watching: clusterAxisDomainsMatch treats an empty-map domain and an absent domain as different (which could silently fail to match linkers on some platforms), and a cluster-keyed filter property that cannot be resolved is silently dropped rather than surfaced as a user-visible state, leaving the block rendering as if no property filter were set.
workflow/src/main.tpl.tengo — specifically the clusterAxisDomainsMatch domain comparison and the silent filter-drop path when keptByProperty stays undefined.
Important Files Changed
Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Column selected as property / filter / round-freq] --> B{Is column spec available?} B -- No --> Z[return undefined] B -- Yes --> C{Has variantKey axis?} C -- Yes --> D[Build TSV: variantKey → value\nReturn per-variant frame directly] C -- No --> E{Has clusterId axis?} E -- No --> Z E -- Yes --> F[Identify clusterAxis from spec] F --> G[Search 'linkers' bundle for linker\nwith matching clusterId domain] G --> H{Matching linker found?} H -- No --> Z H -- Yes --> I[Build cluster TSV: clusterId → value] I --> J[Build linker TSV: clusterId, variantKey] J --> K[INNER JOIN on clusterId] K --> L[SELECT variantKey, value\nReturn per-variant frame] D --> M{Call site} L --> M Z --> N{Which call site?} N -- valueProperty --> O[return empty outputs\nno heatmap rendered] N -- filterProperty --> P[keptByProperty = undefined\nfilter silently dropped] N -- roundFrequency --> Q[roundFrames = undefined\nhasComposition = false]%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% flowchart TD A[Column selected as property / filter / round-freq] --> B{Is column spec available?} B -- No --> Z[return undefined] B -- Yes --> C{Has variantKey axis?} C -- Yes --> D[Build TSV: variantKey → value\nReturn per-variant frame directly] C -- No --> E{Has clusterId axis?} E -- No --> Z E -- Yes --> F[Identify clusterAxis from spec] F --> G[Search 'linkers' bundle for linker\nwith matching clusterId domain] G --> H{Matching linker found?} H -- No --> Z H -- Yes --> I[Build cluster TSV: clusterId → value] I --> J[Build linker TSV: clusterId, variantKey] J --> K[INNER JOIN on clusterId] K --> L[SELECT variantKey, value\nReturn per-variant frame] D --> M{Call site} L --> M Z --> N{Which call site?} N -- valueProperty --> O[return empty outputs\nno heatmap rendered] N -- filterProperty --> P[keptByProperty = undefined\nfilter silently dropped] N -- roundFrequency --> Q[roundFrames = undefined\nhasComposition = false]Prompt To Fix All With AI
Reviews (1): Last reviewed commit: "Support cluster level properties" | Re-trigger Greptile
Context used: