feat: Add ability to use new display set split rules - #6137
wayfarer3130 wants to merge 34 commits into
Conversation
There was a problem hiding this comment.
Claude Code Review
This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.
Tip: disable this comment in your organization's Code Review settings.
✅ Deploy Preview for ohif-dev ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds opt-in metadata-driven display-set splitting with safe ChangesMetadata display-set splitting
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant DisplaySetService
participant CustomizationService
participant SplitRulesEngine
participant DisplaySetFactory
participant displaySetStore
DisplaySetService->>CustomizationService: read useMetadataDisplaySet
DisplaySetService->>SplitRulesEngine: group instances by splitRules
SplitRulesEngine-->>DisplaySetService: matched groups and unmatched instances
DisplaySetService->>DisplaySetFactory: createDisplaySetFromGroup
DisplaySetFactory->>displaySetStore: store split display set
DisplaySetService->>DisplaySetService: route unmatched instances to SOP handlers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (2)
extensions/default/src/getSopClassHandlerModule.js (1)
15-15: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low valueRemove the unused second argument from
makeDisplaySetcalls
makeDisplaySetonly forwardsinstancesandappContexttomakeImageSetDisplaySet, soinstanceIndex/displaySets.lengthare dead arguments here. Remove them from the three call sites to avoid confusion.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@extensions/default/src/getSopClassHandlerModule.js` at line 15, Update the three call sites of makeDisplaySet to pass only the required instances argument, removing the unused instanceIndex and displaySets.length arguments while preserving the existing makeDisplaySet implementation.platform/core/src/types/DisplaySet.ts (1)
93-112: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsider adding a type annotation for the
displaySetServiceparameter.The
displaySetServiceparameter onupdateInstanceshas no type annotation, making it implicitlyany. ImportingDisplaySetServicedirectly would create a circular dependency (the services layer imports fromtypes/), so a minimal interface would preserve type safety without the architectural concern.♻️ Suggested interface to avoid circular dependency
export type DisplaySet = { displaySetInstanceUID: string; instances: InstanceMetadata[]; isReconstructable?: boolean; StudyInstanceUID: string; SeriesInstanceUID?: string; SeriesNumber?: number; SeriesDescription?: string; numImages?: number; unsupported?: boolean; Modality?: string; imageIds?: string[]; images?: unknown[]; label?: string; /** Flag indicating if this is an overlay display set (e.g., SEG, RTSTRUCT) */ isOverlayDisplaySet?: boolean; /** Flag indicating this is a derived dataset */ isDerived?: boolean; /** flag indicating if it supports window level */ supportsWindowLevel?: boolean; // Details about how to display: /** * A URL that can be used to display the thumbnail. Typically a data url * This can be set to null to avoid trying to display a thumbnail, eg for * display sets without a thumbnail. */ thumbnailSrc?: string; /** A fetch method to get the thumbnail */ getThumbnailSrc?(imageId?: string): Promise<string>; /** An opaque type of this viewport, used internally to specify which viewport to use */ viewportType; /** * A fetch URL to display the content. This is used for content such as * pdf display. */ renderedUrl?: string; /** * The instance UID of the display set that this display set references. * This is used to determine if the display set is a referenced display set. * It usually is for SEG, RTSTRUCT, etc. */ referencedDisplaySetInstanceUID?: string; /** * The FrameOfReferenceUID shared by every frame within this display set. * It will be undefined if the frames do not all share the same Frame of Reference. */ FrameOfReferenceUID?: string; SeriesDate?: string; SeriesTime?: string; instance?: InstanceMetadata; /** * The predecessor image id refers to the SOP instance that is currently loaded * into this display set for SEG/SR/RTSTRUCT type values. The name is chosen * for consistency when this value is used as the origin instance * for saving a new instance intended to replace this instance where the * new instance has a "predecessor sequence". */ predecessorImageId?: string; /** * isLoaded is used for display sets containing a load operation that * is required before the display set can be shown. This is separate from * isHydrated, which means it is loaded into view. */ isLoaded?: boolean; isHydrated?: boolean; isRehydratable?: boolean; /** * The name of the comparison function (for sort) to use when comparing display * sets that are coming from same series instanceUID. */ compareSameSeries?: string; + /** + * Minimal interface for the DisplaySetService methods that + * `updateInstances` needs, avoiding a circular import from + * `types/` into the services layer. + */ /** * The deterministic, rule-namespaced group key assigned by the * `@cornerstonejs/metadata` split-rules engine when this display set was * created via the `useMetadataDisplaySet` customization. Used to reconcile * re-splits of the same series with already-created display sets. */ splitKey?: string; /** The id of the split rule that created this display set, when applicable. */ splitRuleId?: string; /** * Incremental-merge hook for split-rule display sets. Intentionally named * differently from `addInstances` (the SOP-class-handler merge hook) so the * legacy handler loop never feeds unmatched instances into split-rule * display sets. Returns the updated display set, or undefined when the * display set cannot merge the instances. */ - updateInstances?(instances: InstanceMetadata[], displaySetService): DisplaySet | undefined; + updateInstances?( + instances: InstanceMetadata[], + displaySetService: DisplaySetServiceLike + ): DisplaySet | undefined; }; + +/** + * Minimal interface for the DisplaySetService methods that `updateInstances` + * callers need, avoiding a circular import from `types/` into the services layer. + */ +export interface DisplaySetServiceLike { + setDisplaySetMetadataInvalidated(displaySetInstanceUID: string): void; + getDisplaySetsForSeries(seriesInstanceUID: string): DisplaySet[]; +}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@platform/core/src/types/DisplaySet.ts` around lines 93 - 112, Update the DisplaySet.updateInstances signature to replace the implicit-any displaySetService parameter with a minimal local interface describing the service members this hook uses. Define or reuse that interface within the types layer rather than importing DisplaySetService, preserving type safety without introducing a circular dependency.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@extensions/default/src/displaySetSplitting/makeImageSetDisplaySet.ts`:
- Around line 36-42: Update the volumeLoaderUtility lookup in
makeImageSetDisplaySet to check whether getModuleEntry returns undefined before
accessing exports. If the utility is unavailable, throw a clear descriptive
error; otherwise preserve the existing getDynamicVolumeInfo extraction and
invocation.
In
`@platform/core/src/services/CustomizationService/expression/expression.test.ts`:
- Around line 137-149: Rename the test around compileExpression to describe
graceful null property access in templates rather than runtime errors, warnings,
or an undefined result. Keep the existing `${a.b.c}` assertion and setup
unchanged.
---
Nitpick comments:
In `@extensions/default/src/getSopClassHandlerModule.js`:
- Line 15: Update the three call sites of makeDisplaySet to pass only the
required instances argument, removing the unused instanceIndex and
displaySets.length arguments while preserving the existing makeDisplaySet
implementation.
In `@platform/core/src/types/DisplaySet.ts`:
- Around line 93-112: Update the DisplaySet.updateInstances signature to replace
the implicit-any displaySetService parameter with a minimal local interface
describing the service members this hook uses. Define or reuse that interface
within the types layer rather than importing DisplaySetService, preserving type
safety without introducing a circular dependency.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: f0e9f3f4-e8d2-47ba-94e0-8102288831c9
📒 Files selected for processing (25)
extensions/default/package.jsonextensions/default/src/customizations/metadataDisplaySetCustomization.tsextensions/default/src/displaySetSplitting/makeDisplaySetFromInstanceGroup.tsextensions/default/src/displaySetSplitting/makeImageSetDisplaySet.tsextensions/default/src/displaySetSplitting/ohifDefaultSplitRules.test.tsextensions/default/src/displaySetSplitting/ohifDefaultSplitRules.tsextensions/default/src/getCustomizationModule.tsxextensions/default/src/getSopClassHandlerModule.jsplatform/app/public/customizations/index.htmlplatform/app/public/customizations/split/enableNewSplit.jsoncplatform/app/public/customizations/split/scoutSeries.jsoncplatform/core/src/services/CustomizationService/CustomizationService.function.test.tsplatform/core/src/services/CustomizationService/CustomizationService.tsplatform/core/src/services/CustomizationService/expression/compiler.tsplatform/core/src/services/CustomizationService/expression/expression.test.tsplatform/core/src/services/CustomizationService/expression/index.tsplatform/core/src/services/CustomizationService/expression/parser.tsplatform/core/src/services/CustomizationService/expression/tokenizer.tsplatform/core/src/services/DisplaySetService/DisplaySetService.test.tsplatform/core/src/services/DisplaySetService/DisplaySetService.tsplatform/core/src/services/DisplaySetService/displaySetStore.test.tsplatform/core/src/services/DisplaySetService/displaySetStore.tsplatform/core/src/services/DisplaySetService/normalizeSplitRules.tsplatform/core/src/types/DisplaySet.tsplatform/docs/docs/platform/services/customization-service/displaySetSplitting.md
…use-metadata-display-set
Viewers
|
||||||||||||||||||||||||||||
| Project |
Viewers
|
| Branch Review |
feat/customization-use-metadata-display-set
|
| Run status |
|
| Run duration | 02m 00s |
| Commit |
|
| Committer | Bill Wallace |
| View all properties for this run ↗︎ | |
| Test results | |
|---|---|
|
|
0
|
|
|
0
|
|
|
0
|
|
|
0
|
|
|
28
|
| View all changes introduced in this branch ↗︎ | |
…use-metadata-display-set
…use-metadata-display-set # Conflicts: # extensions/default/src/getCustomizationModule.tsx # platform/docs/docs/migration-guide/3p13-to-3p14/index.md
…etadata The safe function expression language was written on this branch and then ported to `@cornerstonejs/metadata`, where it belongs: it exists to express display set split rules, both sides of the wire have to compile the same rules, and a viewer-only copy cannot serve a server building a study index. Until now both copies existed, semantically identical bar formatting — two copies of one sandbox, so a hardening fix would land in one and silently miss the other. Deletes `CustomizationService/expression/` (~750 lines plus its 22-test suite, which came across with the port) and imports `compileExpression` from the package. Its only two non-test consumers were `$function` and one convenience line in `normalizeSplitRules`; nothing in any extension or mode used it. Also gates `$function` with a policy, read from `appConfig.customizationFunctionPolicy` and — like `customizationUrlPrefixes` — never from a customization, since a customization able to define the policy could lift its own restrictions. `denyAttributes` lists attribute paths where a marker is refused, as dotted patterns (`*` = one segment, trailing `**` = any depth). Array indices are not path segments, so a pattern describes the shape of a customization rather than a position in a list and survives a rule list being reordered. Nothing is denied by default; `['**']` disables `$function` entirely. A deny list rather than an allow list: the set of attributes a rule may legitimately compute is not knowable in advance — `customAttributes` keys are chosen by the rule's author — so an allow list would refuse working configurations by default, a worse failure than the one it prevents. An earlier draft of this work justified an allow list by claiming a computed `customAttributes.SeriesInstanceUID` could redirect where measurements are saved. That is not so: `storeMeasurements` builds its report from the measurement data plus the dialog's explicit destination, and no write path reads a display set attribute for its target. Documents two things that are deliberate rather than oversights: composing text from any attribute is the point of template literals in a rule (and so a rule set is content a reviewer reads, since it decides what the study browser says), and an expression naming an attribute the instance lacks resolves to `undefined` rather than being validated against a dictionary — a naturalized instance carries private and vendor attributes no dictionary enumerates, so validating would reject expressions that work. Records in `ohifDefaultSplitRules` that OHIF's hand-written rules are transitional and why they are not being converted to raw selector form yet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things stopped this branch's new dependency from being releasable. `extensions/default` pinned `@cornerstonejs/metadata` at 5.6.8 while `@OHIF/core` and `extensions/cornerstone` were at 5.8.2. As an exact-pinned peer dependency that is a peer conflict for anyone installing `@ohif/extension-default` beside `@OHIF/core`. Aligned at 5.8.2. `cs3d-set-version.mjs` did not list `metadata`, so a bump would have moved the other eight packages and left it behind — two CS3D builds in one install, which surfaces as a missing export rather than a version error. That script also updated nothing but the root `package.json`. It read the root `workspaces` field, which went away when the repo moved to pnpm, so `workspaceGlobs` was `[]` and it reported success having changed no pin. It now reads `pnpm-workspace.yaml` (falling back to the `workspaces` field) and exits non-zero rather than performing a silent no-op. Verified: 31 package files discovered where it previously found 1, 28 pins updated. Its closing advice still told the reader to run `bun install --config=./bunfig.update-lockfile.toml`; replaced with the pnpm command the "version" path in playwright.yml actually uses. The three `metadata` pins still have to move to the CS3D release that carries the safe functions. They are left at 5.8.2 deliberately — bumping to an unpublished version would break `pnpm install` on this branch, and CI resolves it through the CS3D_REF link path meanwhile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A rule's `compareInstances` had no effect on the resulting display set. The
split engine ordered each group by it, then `makeImageSetDisplaySet` called
`imageSet.sort(customizationService)`, which ignores the incoming order and
re-sorts from scratch — so the rule's order was computed and then thrown away.
Nothing warned, and because no default rule declares a comparator, no test
exercised it.
Two orderings met there and only one could win. Now they compose, with the
precedence the engine defines: OHIF's default order is the base, the rule's
comparator overrides it where it has an opinion, and a comparator returning 0
leaves the base alone.
- `ImageSet.sortInstances(images, customizationService)` is `sort()`'s body
applied to a supplied list. Extracted rather than reimplemented on the
split-rule side so OHIF's default order has one definition — and it has to be
a whole-list sort, because `sortImagesByPatientPosition` picks a reference
instance (the middle one, to avoid a scout) and projects onto its normal,
which no pairwise comparator expresses. `sort()` delegates to it.
- The split-rule factory orders once, through
`orderInstancesForRule(images, matchedRule, { sortInstances: <OHIF's> })`, on
both the initial build and the incremental merge. It runs after the image-list
attributes because the base order reads `isReconstructable`, which is why the
base is supplied here rather than to the engine.
- `makeImageSetDisplaySet` takes `skipSort`, set by the split-rule path. The
legacy SOP class handler path is unchanged: it has no rule to consult, so
OHIF's default order is the whole answer and is still applied there.
- The `useMetadataDisplaySet` customization gains optional `sortInstances` /
`compareInstances`, forwarded to the engine. These change the order the engine
walks runs in — and so which display sets a `runBy` rule produces — rather
than the final frame order; unset, the engine's acquisition order applies as
before.
No default rule declares a comparator, so no shipped behaviour changes.
Requires the ordering hooks added in cornerstonejs/cornerstone3D#2861.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`$function` compiled with the params the *data* declared. So a marker written
`params: ['a', 'b']` at a site the consumer invokes as `(instance, context)`
compiled cleanly and then computed nonsense, with nothing anywhere to warn
about it — the data author is the wrong party to state a calling convention it
cannot see.
`customizationService.registerFunctionSignatures({ '<path pattern>': params })`
moves that declaration to the code that calls the closure. Paths use the same
dotted patterns as `denyAttributes` (`*` for one segment, trailing `**` for any
depth) and the most specific match wins, so a `series.*` convention can be
overridden for one named fact. A marker whose own `params` disagree with the
registered signature is compiled with the registered one and warns; a marker
that spells the same signature out is accepted silently. With nothing registered
the previous behaviour stands: the default `['instance', 'context']`, and a
marker's own `params` honoured.
Deliberately a method rather than a customization or an app-config value.
Signatures are a property of the code doing the calling, and a customization
able to declare them could hand itself a different convention — the same reason
`denyAttributes` is app-config-only. Registering also clears the transformed
cache, since a signature changes how an already-resolved marker compiles.
`@ohif/extension-default` registers the split-rule signatures, which is what
makes an instance-ordering comparator declarable as data:
{ "compareInstances": { "$function": "a.SliceLocation - b.SliceLocation" } }
Both instances are in scope because the caller said they would be, not because
the rule guessed. Returning 0 declines to have an opinion, so OHIF's default
order carries whatever the comparator does not decide.
Note the safety of an expression was already settled before this: it is parsed
against a closed vocabulary with a fixed helper whitelist, once, at
customization-read time. What was missing was never safety but agreement about
the calling convention, which is what this adds.
Documents the one footgun it does not fix: bare identifiers still resolve
against the first argument, so in a comparator `SliceLocation` silently means
`a.SliceLocation`. Suppressing the implicit scope for comparator-shaped
signatures needs a compiler option upstream.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add the display set splitting specification (prefix SP), with user requirements (SP-FIX, SP-DESC, SP-READ, SP-DET, SP-SAFE, SP-REUSE) and implementation requirements (SP-FORM, SP-PIPE, SP-EXPR, SP-DEPLOY, and the proposed SP-GEN and SP-SRV). It includes mermaid diagrams of the rule pipeline, the assistant route, and reuse by a server. Move the $function expression language out of the display set splitting page into its own functionExpressions page. The splitting page keeps only what is specific to split rules. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ctions for split rules
OHIF had a second rule language on top of the metadata compiler: the
$function customization marker, its function-signature registry, the
customizationFunctionPolicy deny list, and the object-form series and
customAttributes maps of normalizeSplitRules. This removes all of it.
- Split rules in the useMetadataDisplaySet customization are
@cornerstonejs/metadata raw selector data, compiled by
createDisplaySetSplitRules. compileSplitRules compiles each entry on its
own and drops a rejected entry with a warning. A rule that code has already
compiled passes through.
- The OHIF default rules are raw data. The only code is the stackImage
classifier, which the customization supplies as `classifiers`.
- CustomizationService and its index are back to the master versions;
functionPolicy.ts and the $function tests are deleted.
- The SCOUT example first evaluates the series (does it mix localizer and
other images?), and then matches each localizer instance.
- The docs describe split rules as raw selector data, and
functionExpressions.md describes { expression } in split rules.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rewrite the display set splitting specification for the @cornerstonejs/metadata raw selector form, which is now the one rule format. Remove the descriptions of the OHIF $function marker, the signature registry and denyAttributes, which commit 1f2911c removed. Record the decisions on the open questions: - A rule with an error, or a rule that fails at run time, stops display set creation and shows an error that names the rule (SP-SAFE-3, SP-SAFE-7, SP-PIPE-9, SP-PIPE-13). The code still drops or falls back; the specification records each gap. - One display set from several series (SP-FIX-7) and a split of the frames of one multiframe instance (SP-FIX-8) are deferred. - The agent skill (SP-GEN-1) is a separate change; SP-SAFE-6 is a future item; the register entry waits for the register branch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and name the rule
Implements SP-SAFE-3, SP-SAFE-7, SP-PIPE-3, SP-PIPE-9 and SP-PIPE-13 of the
display set splitting specification.
- compileSplitRules compiles each rule alone and returns { rules, errors }.
It no longer drops a rule that does not compile.
- While the rule set has an error, makeDisplaySetForInstances creates no
display sets for any study, SEG and SR included, as other errors that
prevent a study load do. The block clears when the splitRules value
changes, and on mode exit.
- compileSplitRules wraps every function of every rule, so an error at run
time is a SplitRuleRunError that names the rule and the field (matches,
groupBy[1], runBy, series, compareInstances, customAttributes).
- An error from the engine, createDisplaySetFromGroup or extendInstances
stops further display sets for that study. The display sets that exist
stay, and other studies continue.
- Each failure shows one error notification through uiNotificationService
that stays until the user closes it.
- The spec records the global scope of a rule set error, the strict rule
schema (SP-FORM-7, SP-FORM-8, SP-EXPR-5, SP-EXPR-6), the wrap of rule
functions (SP-PIPE-14), and the current state of SP-PIPE-9 and SP-PIPE-13.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The display set factory records splitGroupId: the groupId of the matched rule, else the rule id, so it equals splitRuleId unless a deployment groups rules. A hanging protocol can match splitGroupId to find the display sets of several related rules, for example breast tomosynthesis, legacy mammography, and mammography already split, all with groupId "mammo". - customAttributes can no longer overwrite splitRuleId or splitGroupId. The reconciliation compares splitRuleId with the matched rule, so a changed splitRuleId changed the sort of a display set that grows (SP-PIPE-11). - The spec adds the user requirement SP-READ-6 and SP-PIPE-15, and closes the SP-PIPE-11 gap. The docs describe groups of rules. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e the split URL modules in a test split/dwiByBValue.jsonc is the rule that the display set splitting specification traces: one stack display set for each DiffusionBValue of a diffusion MR series. Frames without a b-value fall through to the default mixedDimensionalityBValue rule. The split/*.jsonc modules are data that no build step checked, so a module with an unknown key failed only when a user loaded it. The new test compiles each module over the OHIF default rules. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lify Before, only the Playwright workflow read the CS3D_REF line. The CircleCI unit test and Cypress jobs, and the Netlify deploy preview, always used the pinned @cornerstonejs/* versions, so a PR that needs an unreleased CS3D change failed there even when Playwright passed against the branch. - .scripts/cs3d-read-ref.mjs reads the PR body with the rules of the Playwright gate job. The gate keeps its copy inline, because it runs before any PR code. - .scripts/cs3d-read-ref.test.mjs runs the real gate script from the workflow file against the same bodies, and fails when the two disagree. - .scripts/ci/cs3d-apply-ref.sh applies the ref after the ordinary install: nothing, a version rewrite, or a clone, build and link of the branch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…use-metadata-display-set
The lint budget lints `.`, and eslint.config.mjs did not ignore libs/. When a CS3D_REF line names a branch, cs3d-apply-ref.sh clones and builds CS3D into libs/@cornerstonejs, so the CS3D files counted against the OHIF budget (99 errors and 106 warnings, against 96 and 94). A local CS3D checkout had the same effect. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…aySetRules example The module embeds the unchanged mammoViewSplit rule of the cornerstone3D displaySetRules example. The rule splits a mammogram that holds all four views in one series into one display set for each laterality and view. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…3D displaySetRules example" This reverts commit 2deea76.
…gleImages split module With the new split on, the default rule singleImageModality is off (priority null), so a CR, DX or MG series is one display set. A per-image split is a decision for each deployment, and a rule states it better than a fixed modality list. The rule stays in the defaults, so one $set turns it on again. split/dxCrSingleImages.jsonc shows the split as a rule: one display set for each image of a DX or CR series of fewer than 10 images. A series fact with minInstances: 10, under `not`, gives the size test. MG is not in the list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…use-metadata-display-set # Conflicts: # pnpm-lock.yaml
…use-metadata-display-set
`compileSplitRules` did not compile a rule when any behaviour field was a
function, so a JSON field next to a function stayed uncompiled. A function
`matches` with a `groupBy` entry `{ attribute: 'DiffusionBValue', number:
true }` put b=0 and b=800 in one display set, with no compile error, and a
JSON `runBy` next to a function failed at run time.
`createDisplaySetSplitRules` keeps a function at a function place as is, so
every rule now goes through that compiler. A `__proto__` rule id is now a
compile error: `rules['__proto__'] = rule` replaced the prototype of the
rule set, and the rule was lost with no error.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uctability The growth hook added the instances and sorted them before it computed `isReconstructable` again. OHIF's default sort reads `isReconstructable` to choose patient-position or instance-number order, so a display set that grew from one slice sorted by instance number, and a fresh load sorted by patient position. The initial build and the growth hook now compute the image-list attributes, sort, and then compute the attributes that depend on the order (`instance`, `messages`, the thumbnail) again. The spec adds SP-PIPE-16. `RESERVED_ATTRIBUTES` also holds `__proto__`, because `setAttributes` assigns each key and a `__proto__` key replaced the prototype of the ImageSet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CS3D_REF: fix/display-set-split-key-stability
The University of Calgary (UCalgary) funded this work.
Context
SOP class handlers build the display sets in OHIF. The stack handler decides how a series becomes one or more display sets, and it makes that decision in hand-written code.
@cornerstonejs/metadatacan now do the same work from rules that a deployment authors as data. Data rules make the same split available to two consumers: a server that builds a study index, and the viewer. Today each consumer implements the split separately, and the two implementations drift apart.This PR adopts the metadata engine. A customization controls the engine, and that customization is off by default. The split rules are
@cornerstonejs/metadataraw selector data, so a data-only customization, such as a JSONC file, can describe a rule.Changes and results
1.
useMetadataDisplaySet— the metadata engine splits the series, as an opt-inWhen you enable the customization,
DisplaySetServicesplits the instances of a series with the split-rule engine of@cornerstonejs/metadata. The service no longer uses the stack SOP class handler for these instances. Instances that no rule claims fall through to the registered handler loop without a change. Thedicom-video,dicom-microscopy,cornerstone-dicom-seg,-sr,-rt,-pmapanddicom-pdfextensions therefore continue to work.You can enable the customization in three ways:
@ohif/extension-default.customizationModule.metadataDisplaySet;?customization=split/enableNewSplit.extensions/default/src/displaySetSplitting/holds three items:ImageSet;stackSopClassUids.ts.stackSopClassUids.tsmakes one list serve two purposes: the registration list of the stack handler, and the ownership test of the split rules. The two purposes cannot disagree about which instances belong to the stack path.getSopClassHandlerModule.jsloses 227 lines to the shared factory.Three rules diverge from the upstream defaults on purpose. Each file records the reason:
singleImageModalitynull), see test case 4.multiFrameSliceLocationrequirement of the upstream rule. That requirement collapses ultrasound clips into one stack.defaultImageRuleisImageInstancegate of the upstream rule is narrower than the SOP class list of the stack handler.The rules are keyed by rule id, and each rule has a
priority.splitRulesis an object, and not an array. The key is the rule id. The engine tries the rules in ascending priority, and the first rule that matches wins. A priority ofnullturns a rule off.The OHIF defaults use the priorities 1 to 5:
singleImageModalitymultiFramemixedDimensionalityBValuevolume3ddefaultImageRuleA rule with a priority below 0 runs before every default rule. A rule with a priority above 10000 runs after every default rule, and sees only the instances that no default rule claims.
An earlier revision used an array, and a customization added a rule with
$unshift. An$unshiftof a rule with an id that is already present gave two rules with one id, and the engine then threw for every series. A key cannot occur twice, so a customization now replaces, moves or turns off a rule by its id:An entry with a missing or non-numeric priority is a rule error. §3 describes what the system does with a rule error.
Existing display sets only grow. New instances of a series can arrive after the first split, and the rules can change during a session. In both cases, an instance that already has a display set stays in it.
DisplaySetServicenever deletes a split-rule display set, never removes an instance from one, and keeps itsdisplaySetInstanceUID, so the viewport state survives. The service places only the instances that are new to the series:extendInstanceshook of that display set;splitKeyof their group;The result can differ from a split of the complete series from the start, and that is intended. For example, an ultrasound series with a
runByrule arrives asimg1andimg3(single images) andclip4(a clip). The first split gives[img1, img3]and[clip4]. Thenclip2arrives. A split from the start gives[img1] [clip2] [img3] [clip4]. The service gives[img1, img3] [clip2] [clip4]: the two existing display sets do not change, andclip2gets a new display set.A hanging protocol can find the display sets of a group of rules. Each display set records
splitRuleId(the rule that made it) andsplitGroupId.splitGroupIdis thegroupIdof the rule, else the rule id, so it equalssplitRuleIdunless a deployment groups rules. Several rules can make one kind of display set. For example, mammography can arrive as breast tomosynthesis, as legacy mammography with all its views in one series, and as mammography that the modality already split. Each form needs its own rule, and all three rules can have"groupId": "mammo". A hanging protocol then matchessplitGroupIdequal tomammo. The group id does not change the split: groups and split keys stay per rule id. A rule'scustomAttributescannot changesplitRuleId,splitGroupIdorsplitKey.2. The typed metadata cache holds the display sets
displaySetStoreputs the display sets in the DISPLAY_SET module of@cornerstonejs/metadata.DisplaySetService.getDisplaySetCache()is deprecated, and it returns a read-only snapshot. The migration guide isplatform/docs/docs/migration-guide/3p13-to-3p14/display-set-store.md.3. Split rules are
@cornerstonejs/metadataraw selector dataA JSONC URL module is data, and a JSON app config is data. A split rule must therefore be data too. OHIF has no rule language of its own. A split rule in the customization is a rule of the
@cornerstonejs/metadataraw selector: the same safe-function vocabulary that the upstream default rules use.createDisplaySetSplitRulescompiles the rules. A server that builds a study index compiles the same data, so the server and the viewer split a series in the same way.{ "requires": ["split/enableNewSplit"], "global": { "useMetadataDisplaySet": { "splitRules": { "$merge": { "ctScout": { "priority": -1, "viewportTypes": ["stack"], "series": [ { "name": "hasScout", "scope": "mixed", "when": { "attribute": "ImageType", "contains": "LOCALIZER" } } ], "matches": { "all": [ { "attribute": "Modality", "equals": "CT" }, { "seriesFact": "hasScout" }, { "attribute": "ImageType", "contains": "LOCALIZER" } ] }, "groupBy": ["SeriesInstanceUID"], "customAttributes": { "set": { "label": "SCOUT" }, "fromFirstInstance": { "SeriesDescription": { "expression": "`SCOUT ${SeriesDescription}`" } } } } } } } } }The rule first evaluates the series:
hasScoutis true when the series mixes localizer images and other images. The rule then matches each instance with a simple test: is this image a localizer? A series of localizers only, and a series without a localizer, stay whole.A second example module,
split/dwiByBValue.jsonc, holds thedwiByBValuerule of the specification: one display set for each b-value of a diffusion MR series. "How to test" gives the URLs for both modules.Where the structural form is not sufficient, a condition or a value is an expression:
{ "expression": "Modality === 'CT' && Rows > 256" }. The expression language iscompileExpressionin@cornerstonejs/metadata. Noevaland nonew Functionexist on the path from the data to the executed code.Code supplies what data cannot express, as a named classifier. The OHIF default rules are raw data too. The only OHIF test that data cannot express is the SOP class list of the stack handler.
@ohif/extension-defaultsupplies that test as thestackImageclassifier, inuseMetadataDisplaySet.classifiers, and a rule references it as{ "classifier": "stackImage" }. A mode that is written in TypeScript can also supply a function at any place in a rule. The compiler uses the function as it is.The compiler is strict (CS3D #2861). The table
splitRuleSchemain@cornerstonejs/metadatadefines each field of a rule: the forms that the field accepts, and the arguments that the compiled function gets. An unknown key, or a form that the field does not accept, is a compile error that names the rule, the path and the allowed keys. Before, a typo such asmatchscompiled, and the rule then claimed every instance. A comparator expression reads onlya,bandcontext.An earlier revision of this PR added a
$functioncustomization marker, a function-signature registry (registerFunctionSignatures), and acustomizationFunctionPolicy.denyAttributespolicy. Those three items made a second rule language in OHIF, beside the raw selector. This PR no longer contains them.CustomizationServiceis the same as onmaster.An expression that names an attribute that the instance does not carry evaluates to
undefined. This behaviour makes the sparse DICOM tags usable (DiffusionBValue != undefined). The same behaviour letsModallity === 'CT'compile correctly and then match nothing. The compiler does not validate the identifiers against a list of known attributes, and that is a decision. A naturalized instance carries private tags, vendor additions, and per-frame data that the naturalizer folds in. No dictionary lists all of these attributes.collectIdentifiersin@cornerstonejs/metadatareports the attributes that an expression reads. UsecollectIdentifiersto find a misspelled name.A rule error stops the display, and names the rule. A rule can be critical for the clinician. A rule that the system drops gives a grouping that looks correct, but that is not the grouping that the deployment intended. So the system does not drop a rule, and it does not give the series to the SOP class handlers instead:
compileSplitRulescompiles each rule alone and returns{ rules, errors }, so it can name every rule that fails. An unknown key, an unknown classifier, an invalid expression, or a missing or non-numeric priority makes a rule fail. While there is an error, the display set service creates no display sets for any study, not even SEG or SR display sets. This is the same as other errors that prevent the load of a study, because one rule set applies to every study.compileSplitRuleswraps each function of each rule, so the error is aSplitRuleRunErrorthat names the rule and the field (matches,groupBy[1],runBy,customAttributes, …). The service then creates no further display sets for that study. The display sets that exist stay, and other studies continue.splitRulesvalue changes, and on mode exit.sortInstancesandcompareInstancesof the customization are not wrapped. An error in one of them also stops the study, but the error names no rule.The specification records this behaviour as
SP-SAFE-3,SP-SAFE-7,SP-PIPE-3,SP-PIPE-9,SP-PIPE-13andSP-PIPE-14.The limit of the current vocabulary. A series fact is true or false. A scanner that does not set
LOCALIZERinImageTypegives the scout rule nothing to detect. A rule that finds such a scout needs a series fact that is a number, for example the lowestInstanceNumber. That fact needs a change in@cornerstonejs/metadata, and this PR does not contain it.4. The display set keeps the instance order that the rule declares
The
compareInstancesof a rule had no effect on the display set. The split engine ordered the instances of each group withcompareInstances. ThenmakeImageSetDisplaySetcalledimageSet.sort(customizationService).imageSet.sortignores the order of the list that it receives, and sorts that list again from the start. The engine therefore computed the order of the rule, and OHIF discarded that order at once. Nothing wrote a warning. No default rule declares a comparator, so no test found the defect.Two orders met at this point, and only one order could survive. The two orders now combine, and the engine defines the precedence:
The changes:
ImageSet.sortInstances(images, customizationService)is the body ofsort(), and it applies to a list that the caller supplies.sort()now callssortInstances. I extracted the body, and I did not write a second implementation on the split-rule side, so the default order of OHIF keeps one definition. The hook must sort a whole list, and a comparator cannot replace the hook.sortImagesByPatientPositionselects a reference instance — the middle instance, to avoid a scout — and projects the other instances onto the normal of that reference instance. No(a, b)function expresses that operation.orderInstancesForRule(images, matchedRule, { sortInstances: <the OHIF sort>, compareInstances, series }). The factory does this for the first build and inextendInstances. The factory applies the order after it applies the image-list attributes, because the base order readsisReconstructable. For that reason the factory supplies the base sort, and the engine does not.compareInstancesof the customization). The engine ordered the group with that comparator, and a re-sort without it discarded that order.InstanceGroup.series. A rule computes its facts from the whole series. Facts that the factory computes again from one display set can differ: a "this series mixes b-values" fact is true for the series, and false for each half after the split.extendInstancesreceives the facts of the new split when the new instances matched the rule of the display set. Otherwise the display set keeps its earlier facts.makeImageSetDisplaySetaccepts askipSortoption, and the split-rule path sets that option. The legacy SOP class handler path does not change. That path has no rule to read, so the default order of OHIF is the complete answer, andmakeImageSetDisplaySetstill applies it there.useMetadataDisplaySetcustomization accepts an optionalsortInstancesand an optionalcompareInstances, andDisplaySetServicesends both to the engine. These two options change the order in which the engine walks the runs, and therefore change the display sets that arunByrule produces. The two options do not change the final frame order. When you supply neither option, the engine uses acquisition order, as before.No default rule declares a comparator, so no shipped behaviour changes.
Text composition from attributes is intended
A rule can build the
labelor theSeriesDescriptionof a display set from any attribute that the instance carries. That capability is the purpose of the templates. A rule that renames a split is a main reason to write a rule, for exampleSCOUT ${SeriesDescription}, orb=0andb=1000.The capability has a consequence, and you must know the consequence before you write rules. A rule decides the text in the study browser and in the viewport overlays, and a viewer template does not decide that text. The instance that the rule reads carries the patient identifiers beside the acquisition tags. Treat a split-rule set as content that a reviewer reads, at the same level as the overlay configuration.
The viewer does not load a customization from the URL until
customizationUrlPrefixesnames a prefix. That prefix must have the same write controls as any other deployed configuration.displaySetSplitting.mdrecords this section.Dependency and release setup
@ohif/core,extensions/cornerstoneandextensions/defaultdepend on@cornerstonejs/metadata. Three defects stopped the release of that dependency:extensions/defaultpinnedmetadataat5.6.8. The rest of the repository used5.8.2. The pin is an exact peer dependency, so the two pins conflict for a user who installs@ohif/extension-defaultbeside@ohif/core. Both now use5.8.2..scripts/cs3d-set-version.mjsdid not listmetadata. A CS3D version bump therefore moved the other eight packages, and leftmetadataat the old version. One install then holds two CS3D builds, and the mismatch appears as a missing export, and not as a version error. The script now listsmetadata.workspacesfield of the rootpackage.json. That field left the repository when the repository moved to pnpm.workspaceGlobswas therefore[], and the script rewrote only the rootpackage.jsonand reported success. The script now readspnpm-workspace.yaml, and falls back to theworkspacesfield. The script exits with an error when it finds no globs. I verified the fix: the script found 31 package files, where it found 1 file before, and it updated 28 pins.Work to do before merge: all three
metadatapins must move to the CS3D release that contains CS3D #2861. The pins are now at5.10.3, after a merge frommaster, and they stay there on purpose. A pin to an unpublished version breakspnpm installfor every user of this branch, and CI resolves the dependency through theCS3D_REFlink path.The published
5.10.3does not contain what this PR needs. It does not exportcreateDisplaySetSplitRules,orderInstancesForRuleorresolveSplitRuleSet, and its split functions take an array of rules. Without the link to CS3D #2861, every split-rule display set fails.The split is off by default, so the default configuration is not affected.
CI change in this PR: CircleCI and Netlify read
CS3D_REFThis PR includes the commit
7ae88a5889, "chore(ci): apply the CS3D_REF of the pull request in CircleCI and Netlify". The commit is not about split rules. It is in this PR for these reasons:.github/workflows/playwright.ymlread theCS3D_REFline. The CircleCIUNIT_TESTSjob, the CircleCI Cypress job and the Netlify deploy preview always installed the pinned@cornerstonejs/*5.10.3.masterhas noCS3D_REFdependency, so its CI shows nothing. That PR would need dummy code to test the change.What the commit does:
.scripts/cs3d-read-ref.mjsreads the PR body with the rules of thegatejob inplaywright.yml. The gate keeps its own inline copy, because the gate runs before any code from the PR..scripts/cs3d-read-ref.test.mjsruns the real gate script from the workflow file against 32 PR bodies. The test fails when the two copies do not agree..scripts/ci/cs3d-apply-ref.shruns after the ordinary install. With no line, the script changes nothing. With a version, the script changes the pins and installs again. With a branch, the script clones, builds and links the branch, as the Playwright job does..circleci/config.ymlruns the script inUNIT_TESTSand in the Cypress job.netlify.tomlruns the script beforebuild:ci.GITHUB_TOKENin the CircleCI and Netlify settings prevents the unauthenticated rate limit.A second CI commit,
845eef5300, addslibs/**to the ignores ofeslint.config.mjs. The React Compiler lint budget lints., so the CS3D clone inlibs/@cornerstonejscounted against the OHIF budget: 99 errors and 106 warnings, against a budget of 96 errors and 94 warnings. A local CS3D checkout had the same effect.The two commits change only CI files,
eslint.config.mjsandcs3d-integration.md. The maintainers can move the two commits into a separate PR before the merge.With the two commits, the CircleCI jest tests, the CircleCI Cypress tests and the Netlify deploy preview pass against the linked CS3D branch. Two checks still fail, and both failures are expected:
CS3D_REFline names a branch, and the guard reports each branch ref on purpose.BUILD_PACKAGES_QUICK, the security audit. The audit runs only whenpnpm-lock.yamlchanges. This PR changes the lock file, andmasteralready has high and critical advisories, for examplebrace-expansion,react-routerandprotobufjs.How to test
The
ohif-integrationlabel and theCS3D_REFline at the top of this description tell.github/workflows/playwright.ymlto clonefix/display-set-split-key-stability. The workflow builds that branch withpnpm run build:esm, and links the branch intonode_modulesbefore the OHIF install.Test on the deployed build
Two deploys build this PR against the linked CS3D branch. You can use either deploy:
deploy/netlifycheck builds the deploy preview. The deploy preview links the CS3D branch because of the CI change in this PR (see the section above).cs3d-pr-6137, so the URL stays the same for each new commit.config/netlify.js. That config setscustomizationUrlPrefixes, so the?customization=URLs below work.1. The b-value series that the viewer rendered as 4D. The study
1.3.6.1.4.1.14519.5.2.1.4792.2001.921758700577562664959693695481has the MR seriesDTI_high_iso SENSE(2380 instances). 2310 instances haveDiffusionBValue= 800, and 70 instances have noDiffusionBValue. The 4D split of CS3D groups frames byDiffusionBValue, so the series becomes a 4D candidate.https://cs3d-pr-6137--ohif-platform-docs.netlify.app/viewer?StudyInstanceUIDs=1.3.6.1.4.1.14519.5.2.1.4792.2001.921758700577562664959693695481
mixedDimensionalityBValue(priority 3) splits the series into two display sets: the 2310 instances with a b-value, and the 70 instances without a b-value. The study browser shows two items for the series. Neither display set mixes frames with and without a b-value:https://cs3d-pr-6137--ohif-platform-docs.netlify.app/viewer?StudyInstanceUIDs=1.3.6.1.4.1.14519.5.2.1.4792.2001.921758700577562664959693695481&customization=split/enableNewSplit
https://deploy-preview-6137--ohif-dev.netlify.app/viewer?StudyInstanceUIDs=1.3.6.1.4.1.14519.5.2.1.4792.2001.921758700577562664959693695481
https://deploy-preview-6137--ohif-dev.netlify.app/viewer?StudyInstanceUIDs=1.3.6.1.4.1.14519.5.2.1.4792.2001.921758700577562664959693695481&customization=split/enableNewSplit
2. One display set for each b-value, as a rule that a deployment writes in JSONC. The new module
split/dwiByBValue.jsoncholds thedwiByBValuerule, which the specification traces. The rule has priority −1, so it runs before every default rule. The rule puts eachDiffusionBValueof a diffusion MR series into its own stack display set, with the description<SeriesDescription> b=<value>. Frames without a b-value fall through tomixedDimensionalityBValue, and become one more display set.https://deploy-preview-6137--ohif-dev.netlify.app/viewer?StudyInstanceUIDs=1.3.6.1.4.1.14519.5.2.1.4792.2001.921758700577562664959693695481&customization=split/dwiByBValue
https://cs3d-pr-6137--ohif-platform-docs.netlify.app/viewer?StudyInstanceUIDs=1.3.6.1.4.1.14519.5.2.1.4792.2001.921758700577562664959693695481&customization=split/dwiByBValue
DTI_high_iso SENSE b=800(2310 instances, stack) and one display set of the 70 instances without a b-value."requires": ["split/enableNewSplit"], so this URL turns on the split rules too.ACRIN-6698: MergedMSMB: AX DWI 100with the b-values 0, 100, 600 and 800. Those series hold the b-value in a vendor private tag: GE (0043,1039) or Siemens (0019,100C). The standardDiffusionBValue(0018,9087) is not present, sodwiByBValuedoes not split those series. The 4D split of CS3D reads the private tags, and the split rules do not read them yet.3. The CT scout rule.
?customization=split/scoutSeriesputs the localizer images of a CT series that also has other images into a separate display set with the labelSCOUT. Add the parameter to the URL of any study with such a CT series.4. One display set for each radiograph, as a rule. The new split turns off the default rule
singleImageModality(prioritynull). With the new split on, a CR, DX or MG series is one display set. The stack SOP class handler always makes one display set for each image of these three modalities. A per-image split is a decision for each deployment, and a rule states that decision better than a fixed modality list. The rule stays in the defaults, so{ singleImageModality: { priority: { $set: 1 } } }turns it on again.The new module
split/dxCrSingleImages.jsoncmakes one display set for each image of a DX or CR series with fewer than 10 images. MG is not in the list, and a series with 10 images or more stays one display set. The series facttenOrMoreImageshasminInstances: 10, andmatchesrequires{ "not": { "seriesFact": "tenOrMoreImages" } }.Multi-Energy Chest DRhas one DX series with 3 images.https://deploy-preview-6137--ohif-dev.netlify.app/viewer?StudyInstanceUIDs=1.3.6.1.4.1.9590.100.1.2.19841440611855834937505752510708699165&customization=split/enableNewSplit
https://deploy-preview-6137--ohif-dev.netlify.app/viewer?StudyInstanceUIDs=1.3.6.1.4.1.9590.100.1.2.19841440611855834937505752510708699165&customization=split/dxCrSingleImages
XR_RF AbdPelvis(MSB-08178) has two DX series with 2 images each, and one RF series with 11 images.https://deploy-preview-6137--ohif-dev.netlify.app/viewer?StudyInstanceUIDs=1.3.6.1.4.1.14519.5.2.1.1.84416332615988066829602832830236187384&customization=split/enableNewSplit
https://deploy-preview-6137--ohif-dev.netlify.app/viewer?StudyInstanceUIDs=1.3.6.1.4.1.14519.5.2.1.1.84416332615988066829602832830236187384&customization=split/dxCrSingleImages
urlSplitModules.test.tschecks that limit: the rule splits a DX series of 9 images, and keeps a DX series of 10 images together. The test also checks that an MG series stays together.Test on your own machine
The unit tests in
platform/core/src/servicesandextensions/default/src(38 suites, 374 tests) cover the compilation of the raw rules, the priorities, the rule errors and the notification, the display sets that only grow, the series facts, and the host comparator.extensions/default/src/displaySetSplitting/urlSplitModules.test.tsadds 1 suite and 3 tests. The test compiles eachsplit/*.jsoncmodule over the OHIF default rules, because no build step checks those data files. The test also checks the split ofdwiByBValue.Then run
pnpm dev, and add the same?customization=parameters to a viewer URL.config/dev.jssetscustomizationUrlPrefixes. The default config does not set that property.To see the instance order of a rule take effect, add a
compareInstancesto the scout rule:{ "attribute": "InstanceNumber", "number": true, "descending": true }. The frames of the scout display set then come back in the reverse order. Before the order fix in this PR, the same rule changed nothing.Not in this PR
Numeric series facts. A raw series fact is true or false, so a rule cannot yet compare an instance with a value that the whole series gives, such as the lowest
InstanceNumber(§3). That fact needs a change in@cornerstonejs/metadata.The
customAttributesof a rule can still overwrite display set fields that are not reserved.RESERVED_ATTRIBUTESinmakeDisplaySetFromInstanceGroupprotects the identity and the content of the display set:images,instances,uid,displaySetInstanceUID,splitKeyand theextendInstanceshook. A rule therefore cannot redirect the pixel requests. The data source derivesimageIdsfromimages, and it does not storeimageIds.RESERVED_ATTRIBUTESdoes not listStudyInstanceUID,SeriesInstanceUIDorSOPClassHandlerId.An earlier version of this description said that the gap matters because
SeriesInstanceUIDselects the series that receives a saved report. That statement was wrong.storeMeasurementsbuilds the report from the measurement data, and from the explicit destination that the dialog supplies (predecessorImageId,SeriesNumber,SeriesDescription). No write path reads an attribute of a display set to select its target. The real consequences of an overwrite of one of these three fields are:A fix belongs in
RESERVED_ATTRIBUTES, because the gap applies to a literal custom attribute and to a computed custom attribute equally. The gap does not leak data, and it does not misroute a request.