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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-16
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
## Context

`bound-label-atlas-to-device-limits` made "no atlas" a first-class state: `labelColorData` is
nullable, the staging helper skips texels when it is null, a 1x1 placeholder keeps the sampler
complete, and the shader's `u_labelAtlasCapacity` uniform makes the pie branch unreachable. That was
built for the device-limit failure path. This change reaches the same state deliberately.

## Decisions

### The gate is pull-based, and computed where the colours are

The alternative was push-based: have every mutation that could change multi-label-ness tell the
renderer. That is the shape with the failure mode this codebase already knows — a missed
notification among many call sites, producing a stale frame rather than a slow one.

Computing it inside `createStyleGetters`, over the same `data` binding `getColors` closes over, makes
staleness structurally impossible in the direction that matters. `getColors` returns
`[...new Set(values)]` for that annotation, so `getColors(p).length >= 2` implies the stored data is
multi-valued, implies the gate is true. The gate can therefore only ever over-report — allocate when
it need not — never under-report.

That covers the transitions a push model would have had to enumerate individually: annotation switch,
projection switch, dataset swap, the EAT overlay, isolation, legend hide/show. All of them go through
a getter rebuild, and `data` is in the cache key.

### The default is "allocate"

`isMultilabel` is required on `WebGLStyleGetters`, so TypeScript consumers cannot omit it. The
renderer still calls it optionally and defaults to `true`. The asymmetry is deliberate: a consumer
that omits the getter over-allocates, which costs memory, whereas one that under-reports silently
drops pie segments — a wrong picture presented as data. Only the first is an acceptable failure.

### The transition is tracked in the renderer, not signalled by the caller

A change in multi-label-ness need not move the style signature, which samples four points' colours —
but it changes every point's staged slice count and whether the atlas exists. `render()` compares the
current gate against the last observed value and marks styles dirty on a change. This is not free
(a getter-cache miss rebuilds the getters) but it is not measurable either: `computeStyleSignature`
already calls `getColors` on four points every render.

It is also load-bearing rather than belt-and-braces: `_refreshSelectedAnnotationValues` nulls the
style-getter cache without calling `invalidateStyleCache`, so a caller-driven invalidation would have
a real hole.

### Why memoize

`isMultilabelAnnotationData` is `data.some(values => values.length > 1)` for dense storage — O(N),
573K at Swiss-Prot scale — and the getters are rebuilt on a legend hide, a selection, a projection
switch. A `WeakMap` keyed on the storage object makes every call after the first O(1), and releases
with the dataset.

Soundness rests on storage never being mutated in place: conversion, the EAT overlay, numeric binning
and the isolation path all return fresh objects. `Int32Array` storage answers in O(1) anyway, and the
sparse form scans only its overrides, which is the cost bound its own spec already imposes.

## Risks / Trade-offs

**A mid-session allocation on the transition into multi-label.** Allocating and uploading 17.5 MiB at
573K is real work — but that transition already pays a full re-stage, because the annotation changed.
The allocation is a fraction of it.

**Fidelity is still pinned to high-water capacity**, unchanged from #457: the atlas is planned against
capacity, not the drawn count, so it survives the colour-only fast path without re-planning.

**A revert cannot regress to corruption.** The absent-atlas state ships in #457 on the error path;
this change only adds a second reason to enter it.

## Open Questions

None.
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
## Why

The pie-chart colour atlas is 32 of the 76 bytes per point resident on the GPU — 42% — and for a
single-label annotation every one of those bytes is dead weight. Nothing samples it: the fragment
shader's pie branch requires `v_labelCount > 1.5`, and `fillLabelColorTexels` returns immediately for
a point with one colour. At Swiss-Prot scale that is 17.51 MiB of CPU and 17.51 MiB of GPU held for a
feature the view is not using, plus a full-surface upload on every restage.

`bound-label-atlas-to-device-limits` (#457) made "no atlas" a real, tested state — it is what a
device that cannot hold one already falls back to, and the shader already refuses to sample when the
atlas capacity uniform is zero. This change adds a second reason to enter that state.

## What Changes

- **Gate allocation on the selected annotation's storage.** `createStyleGetters` exposes
`isMultilabel()`, computed over the same `data` binding the colour getters close over, so the
answer is exactly as fresh as the colours it gates. `syncLabelAtlas` allocates only while it is
true, and releases when it goes false.
- **Memoize the predicate.** `isMultilabelAnnotationData` is O(N) for dense storage, and the getters
are rebuilt on a legend hide, a selection, a projection switch. A `WeakMap` keyed on the storage
object makes it O(1) after the first call — sound because no producer mutates an `AnnotationData`
in place; every one builds fresh storage.
- **Re-stage on either transition.** The style signature samples four points' colours, which a change
in multi-label-ness need not move — but it changes every point's staged slice count. The renderer
tracks the transition itself rather than relying on a caller to invalidate; in particular
`_refreshSelectedAnnotationValues` nulls the style-getter cache without calling
`invalidateStyleCache`.

## Capabilities

### Modified Capabilities

- `renderer-capability-limits`: resources for a feature the view is not using are not allocated, and
the gate is evaluated over stored values rather than rendered colours.

<!-- point-visibility already carries the storage-shaped requirement, added by
bound-label-atlas-to-device-limits. This change implements it. -->

## Impact

- `packages/utils/src/visualization/annotation-data-access.ts` — a memoized
`isMultilabelAnnotationDataCached` beside the existing predicate.
- `packages/core/src/components/scatter-plot/styling/style-getters.ts` — computes the gate once per
getter rebuild and returns it.
- `packages/core/src/components/scatter-plot/webgl/types.ts` — `WebGLStyleGetters` gains
`isMultilabel`. The renderer calls it optionally and defaults to **true**: a consumer that omits it
over-allocates, which wastes memory, where under-reporting would silently drop pie segments. Only
the first failure direction is acceptable.
- `packages/core/src/components/scatter-plot/webgl/renderer/webgl-renderer.ts` — the gate in
`syncLabelAtlas`, and the transition check in `render()`.
- `packages/core/src/components/scatter-plot/scatter-plot.ts` — one line forwarding the getter.
- No user-visible change on a multi-label annotation. No bundle-format, CLI or Python change.

## Depends On

`bound-label-atlas-to-device-limits` (#457, PR #458), which built the absent-atlas state machine,
the shader gate, and the placeholder this change reuses.
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
## ADDED Requirements

### Requirement: Resources for an unused feature SHALL NOT be allocated

The renderer SHALL allocate the multi-label colour atlas only while the selected annotation actually
stores more than one value for some protein, and SHALL release it when that ceases to be true. It
SHALL re-stage on either transition, because the change alters every point's slice count without
necessarily altering any sampled style value.

#### Scenario: A single-value annotation costs nothing

- **WHEN** a dataset is rendered with an annotation whose every protein has one value
- **THEN** no capacity-sized colour atlas is allocated on the CPU or the GPU
- **AND** markers render exactly as they did when the atlas was allocated unconditionally

#### Scenario: Switching to a multi-value annotation mid-session

- **WHEN** the user selects a multi-value annotation after a single-value one
- **THEN** the atlas is allocated once and the points are re-staged with their slice counts
- **AND** switching back releases it

#### Scenario: The gate does not depend on what is currently visible

- **WHEN** hidden values reduce every point to a single rendered colour
- **THEN** the annotation is still treated as multi-value and the atlas is retained, so restoring a
hidden value needs no reallocation
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
## 1. The predicate

- [x] 1.1 Add `isMultilabelAnnotationDataCached` to `annotation-data-access.ts`, a `WeakMap` memo over
the existing predicate, with the no-in-place-mutation reasoning stated
- [x] 1.2 Tests: agrees with the uncached form on every storage shape; memoizes per object rather
than per content; answers from storage, so hiding cannot retract the state

## 2. The gate

- [x] 2.1 `createStyleGetters` computes it once, over the same `data` binding the colour getters close
over, and returns `isMultilabel`
- [x] 2.2 `WebGLStyleGetters` gains `isMultilabel`, required so TypeScript consumers cannot omit it
- [x] 2.3 `scatter-plot.ts` forwards it
- [x] 2.4 `syncLabelAtlas` allocates only while it is true and releases when it goes false
- [x] 2.5 `render()` compares the gate against the last observed value and marks styles dirty on a
transition — load-bearing, because `_refreshSelectedAnnotationValues` nulls the getter cache
without calling `invalidateStyleCache`
- [x] 2.6 The renderer calls the getter optionally, defaulting to **true**. Omitting it over-allocates
(wastes memory); under-reporting would silently drop pie segments. Only the first is acceptable.

## 3. Tests

- [x] 3.1 A single-label annotation allocates nothing beyond the 1x1 placeholder and never issues a
`texSubImage2D`
- [x] 3.2 Single -> multi -> single allocates exactly once and releases, ending on the placeholder
- [x] 3.3 The transition re-stages, even though the style signature cannot observe it
- [x] 3.4 Staying multi-label refreshes in place rather than reallocating
- [x] 3.5 Filter atlas allocations from framebuffer ones in the assertions — the gamma pipeline
allocates its own canvas-sized texture, which is not what these tests are about

## 4. Ship

- [x] 4.1 `pnpm test` (2,293 tests), `pnpm test:e2e` (124), `pnpm precommit`, `pnpm format:check`
- [x] 4.2 `openspec validate --strict`
- [x] 4.3 Reread proposal/design against the final diff, tick tasks, archive on the branch
- [x] 4.4 Open the PR stacked on `fix/render-cliff-456`; merge or rebase — never squash
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-17
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
## Why

`defer-label-atlas-allocation` gave "the live renderer has no label atlas" a second cause, and the
export path was not told.

Before it, the state had exactly one cause: the device could not hold an atlas, so the live view was
rendering dominant colours and an export that did the same matched the screen. The scenario in
`renderer-capability-limits` says so — _"WHEN the live renderer has no label atlas THEN the export
allocates none either and renders dominant colours"_ — and while that was the only cause, the
condition determined the outcome.

Releasing the atlas for a single-label annotation added a second cause with the opposite correct
answer. `this.atlas` records only what the last completed render staged, and nothing forces a render
before an export, so it is now null in situations where the export **should** allocate — before the
first populate, on an empty render, and in the window between an annotation switch and the next
frame. It is equally non-null in the mirror window, after a switch to a single-label annotation,
where the export should allocate nothing.

The code was corrected in the same PR (`exportLabelStride`). This change corrects the spec, which
still describes the pre-#457 world and would lead the next reader to reintroduce the defect.

## What Changes

- **The WANT question moves to the styling authority.** Whether an atlas is wanted at all is asked
of the same style getters the export stages its colours through, so the atlas decision and the
colour decision cannot disagree. This is a spec correction only; the code already does it.
- **The existing scenario is narrowed to its real condition.** "The live view has no atlas" becomes
"the device cannot hold one", which is the case it was written for and where it still holds.
- **The two new cases are stated.** A single-value annotation exports no atlas even if the renderer
still holds one from an earlier annotation; a multi-value annotation exports one even if no frame
has been staged yet.

## Capabilities

### Modified Capabilities

- `renderer-capability-limits`: the export's atlas decision is sourced from the live styling
authority rather than from the live renderer's current allocation, and the "no atlas" scenario is
narrowed to the device-limit case it was written for.

## Impact

- `openspec/specs/renderer-capability-limits/spec.md` — one requirement's scenarios.
- No code change. `packages/core/src/components/scatter-plot/webgl/renderer/webgl-renderer.ts`
(`exportLabelStride`) and its two tests in `webgl-renderer.export-transform.test.ts` already
implement and pin this; they shipped in the same PR that made the spec stale.
- No user-visible change beyond the one already shipped: a figure exported in the window after an
annotation switch shows pie markers rather than dominant colours.

## Depends On

`defer-label-atlas-allocation` (#457), which introduced the second cause this change describes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
## MODIFIED Requirements

### Requirement: Exported images SHALL use the same marker fidelity as the live view

The export renderer SHALL query its own context's texture limit and SHALL use a slice stride no
greater than the live renderer's, so an exported figure carries the same marker segmentation the
user saw on screen. Its declared maximum output dimension SHALL be the smaller of its own limit and
its configured maximum.

Whether an atlas is wanted at all SHALL be decided from the same styling authority the export stages
its colours through, and SHALL NOT be inferred from whichever atlas the last completed render left
behind — nothing forces a render before an export, so that allocation is stale in both directions. A
live plan, where one exists, still caps the stride.

#### Scenario: A figure matches the screen

- **WHEN** the live view is rendering at reduced stride and the user exports an image
- **THEN** the exported markers use the same stride

#### Scenario: The device cannot hold an atlas

- **WHEN** the live renderer has permanently disabled its atlas because no layout fits the device
- **THEN** the export allocates none either and renders dominant colours

#### Scenario: A single-value annotation is selected

- **WHEN** the selected annotation stores one value per protein
- **THEN** the export allocates no atlas, whether or not the live renderer still holds one staged
for an earlier annotation

#### Scenario: A multi-value annotation has not been staged yet

- **WHEN** a multi-value annotation is selected and no frame has been rendered since
- **THEN** the export still allocates an atlas and renders multi-segment markers, planned at full
fidelity against its own context's limit, because no live plan exists to cap it

#### Scenario: The declared export dimension limit is truthful

- **WHEN** a device reports a texture limit below the configured maximum export dimension
- **THEN** the export's enforced maximum is the device's limit, and its rejection message names the
limit actually enforced
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
## 1. The spec

- [x] 1.1 Narrow "The live view has no atlas" to the device-limit case it was written for, so the
scenario's condition again determines its outcome
- [x] 1.2 State the two cases `defer-label-atlas-allocation` created: a single-value annotation
exports no atlas even when one is still allocated, and a multi-value annotation exports one
even when none has been staged yet
- [x] 1.3 State the sourcing rule normatively — the WANT question comes from the styling authority
the export stages its colours through, not from the last completed render's allocation

## 2. Confirm the code already matches

- [x] 2.1 `exportLabelStride` asks the gate before reading `this.atlas`, so both windows resolve
correctly and a live plan still caps the stride (shipped in the same PR)
- [x] 2.2 Both directions are pinned by tests in `webgl-renderer.export-transform.test.ts`: a
multi-label view with no live plan forwards `MAX_LABELS`, a single-label view forwards `null`
- [x] 2.3 No code change in this change — verify by diff that only `openspec/` is touched

## 3. Ship

- [x] 3.1 `openspec validate --strict`
- [x] 3.2 `pnpm precommit`, `pnpm format:check`, `pnpm test:ci`
- [x] 3.3 Archive on the branch, so the living spec stops describing the pre-#457 world
Loading
Loading