Skip to content

Support cluster level properties#4

Open
mzueva wants to merge 2 commits into
mainfrom
mzueva/cluster-level-support
Open

Support cluster level properties#4
mzueva wants to merge 2 commits into
mainfrom
mzueva/cluster-level-support

Conversation

@mzueva

@mzueva mzueva commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

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 new resolveVariantValueDf helper.

  • Model (model/src/index.ts): maxHops in findColumns raised from 0 to 1 for both propertyOptions and roundFrequencyOptions, with an explicit isLinkerColumn guard added to propertyOptions to prevent the linker column itself from appearing as a selectable numeric property.
  • Workflow (workflow/src/main.tpl.tengo): New isClusterIdAxis / clusterAxisDomainsMatch helpers and resolveVariantValueDf function handle both variant- and cluster-keyed columns uniformly; linkers are fetched into the bundle during prepare only when at least one cluster-capable input is active; filterActive is 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:

Term Definition Change
pl7.app/clusterId / pl7.app/vdj/clusterId Cluster identity axis; a column keyed by this axis has one value per cluster, not per variant Now accepted as a property, filter-property, or round-frequency source
pl7.app/isLinkerColumn Annotation marking a variant→cluster linker (pl7.app/link) column with axes [clusterId, variantKey] Now explicitly excluded from the property picker; now fetched into the bundle via addMulti when cluster resolution may be needed
resolveVariantValueDf New workflow function that normalises any column (variant- or cluster-keyed) into a [variantKey → value] frame New; used for value property, filter property, and each round frequency
clusterAxisDomainsMatch New helper that compares two cluster axis spec objects to confirm they belong to the same clustering run (equal domains) New
maxHops Traversal depth parameter in findColumns; 1 now allows traversal through the variant→cluster linker to surface cluster-keyed columns Changed from 0 → 1 in both propertyOptions and roundFrequencyOptions
filterActive Boolean that enables the "Filtered" heatmap facet Changed: now computed after cluster resolution — hasSampleFilter || !is_undefined(keptByProperty) — so a cluster-keyed filter that cannot be resolved does not produce a spurious or missing facet
needLinkers New prepare-phase flag; true whenever any cluster-capable input is present New; controls whether variant→cluster linkers are included in the column bundle
keptByProperty Set of variant keys that pass the property-range filter Now populated via resolveVariantValueDf, supporting both variant- and cluster-keyed filter properties

Confidence 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

Filename Overview
.changeset/cluster-level-property.md Changeset entry for the cluster-level property feature (A-0016); accurately describes the scope and approach for all three cluster-capable inputs.
model/src/index.ts Raises maxHops to 1 in both propertyOptions and roundFrequencyOptions discovery; adds an explicit isLinkerColumn guard in propertyOptions to prevent the linker itself from appearing as a numeric property.
workflow/src/main.tpl.tengo Adds isClusterIdAxis, clusterAxisDomainsMatch helpers and the central resolveVariantValueDf function; linkers are fetched in prepare when any cluster-capable input is active; filterActive computation is correctly deferred until after resolution. Silent filter drop when a cluster-keyed filter property has no matching linker is the main behavior gap.

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]
Loading
%%{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]
Loading

Fix All in Claude Code

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
workflow/src/main.tpl.tengo:39-58
**`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.

### Issue 2 of 2
workflow/src/main.tpl.tengo:279-292
**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.

Reviews (1): Last reviewed commit: "Support cluster level properties" | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

Context used:

  • Context used - Terms is a types in codebase. Provide the list of ... (source)

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread workflow/src/main.tpl.tengo Outdated
Comment on lines +174 to +255
// 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))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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))
	}

Comment on lines +39 to +58
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
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Fix in Claude Code

Comment on lines 279 to 292
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"))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Fix in Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant