diff --git a/platform/app/src/components/inference/studyProcessing/ModelExecutionResultDrawer.test.tsx b/platform/app/src/components/inference/studyProcessing/ModelExecutionResultDrawer.test.tsx index 8958f2ac6..60695122b 100644 --- a/platform/app/src/components/inference/studyProcessing/ModelExecutionResultDrawer.test.tsx +++ b/platform/app/src/components/inference/studyProcessing/ModelExecutionResultDrawer.test.tsx @@ -17,7 +17,9 @@ import { cardioSyntaxResultFixtures } from './modelResults/cardioSyntaxFixtures' import { cathEfClipResultFixtures } from './modelResults/cathEfClipFixtures'; import { cathEfResultFixtures } from './modelResults/cathEfFixtures'; import { deepCoroClipResultFixtures } from './modelResults/deepCoroClipFixtures'; +import { deepCoroCtoResultFixtures } from './modelResults/deepCoroCtoFixtures'; import { deepCoroMaceResultFixtures } from './modelResults/deepCoroMaceFixtures'; +import { deepCoroSyntaxResultFixtures } from './modelResults/deepCoroSyntaxFixtures'; import { deepRVClipResultFixtures } from './modelResults/deepRVClipFixtures'; import { deepRVResultFixtures } from './modelResults/deepRVFixtures'; @@ -268,7 +270,7 @@ describe('ModelExecutionResultDrawer', () => { test('selects the DeepCORO-CLIP renderer for an exact supported result', () => { const result = { ...modelExecutionResultFixtures.available, - modelName: 'DeepCoro_CLIP_generic', + modelName: 'DeepCORO-CLIP', modelVersion: '1.0.0', result: deepCoroClipResultFixtures.validV1, }; @@ -342,7 +344,7 @@ describe('ModelExecutionResultDrawer', () => { test('selects the DeepCORO-MACE renderer for an exact supported result', () => { const result = { ...modelExecutionResultFixtures.available, - modelName: 'DeepCORO_MACE', + modelName: 'DeepCORO-MACE', modelVersion: '1.0.0', result: deepCoroMaceResultFixtures.validV1, }; @@ -365,6 +367,37 @@ describe('ModelExecutionResultDrawer', () => { ).toHaveLength(0); }); + test.each([ + ['DeepCORO-CTO', '2.0.0', deepCoroCtoResultFixtures.validV2, 'deepcoro-cto-result'], + ['DeepCORO-SYNTAX', '5.0.0', deepCoroSyntaxResultFixtures.validV5, 'deepcoro-syntax-result'], + ])( + 'selects the %s renderer for an exact supported result', + (modelName, modelVersion, result, testId) => { + act(() => { + renderer = TestRenderer.create( + + ); + }); + + expect(renderer!.root.findAllByProps({ 'data-testid': testId })).toHaveLength(1); + expect( + renderer!.root.findAllByProps({ 'data-testid': 'generic-model-result-collection' }) + ).toHaveLength(0); + } + ); + test.each([ [modelExecutionResultFailureFixtures.notReady, 'viewable completed result'], [modelExecutionResultFailureFixtures.terminalWithoutResult, 'missing'], diff --git a/platform/app/src/components/inference/studyProcessing/index.ts b/platform/app/src/components/inference/studyProcessing/index.ts index 5e3c421d6..871781e25 100644 --- a/platform/app/src/components/inference/studyProcessing/index.ts +++ b/platform/app/src/components/inference/studyProcessing/index.ts @@ -42,7 +42,9 @@ export * from './modelResults/cardioSyntaxContract'; export * from './modelResults/cathEfClipContract'; export * from './modelResults/cathEfContract'; export * from './modelResults/deepCoroClipContract'; +export * from './modelResults/deepCoroCtoContract'; export * from './modelResults/deepCoroMaceContract'; +export * from './modelResults/deepCoroSyntaxContract'; export * from './modelResults/deepRVClipContract'; export * from './modelResults/deepRVContract'; export * from './modelResults/modelResultRendererRegistry'; @@ -50,7 +52,9 @@ export * from './modelResults/CardioSyntaxResult'; export * from './modelResults/CathEfClipResult'; export * from './modelResults/CathEfResult'; export * from './modelResults/DeepCoroClipResult'; +export * from './modelResults/DeepCoroCtoResult'; export * from './modelResults/DeepCoroMaceResult'; +export * from './modelResults/DeepCoroSyntaxResult'; export * from './modelResults/DeepRVClipResult'; export * from './modelResults/DeepRVResult'; export * from './modelResults/ModelResultRenderer'; diff --git a/platform/app/src/components/inference/studyProcessing/modelResults/DeepCoroClipContract.md b/platform/app/src/components/inference/studyProcessing/modelResults/DeepCoroClipContract.md index 90b885bb9..86adff8c2 100644 --- a/platform/app/src/components/inference/studyProcessing/modelResults/DeepCoroClipContract.md +++ b/platform/app/src/components/inference/studyProcessing/modelResults/DeepCoroClipContract.md @@ -2,7 +2,7 @@ ## Approved scope -- Priority model: `DeepCoro_CLIP_generic` +- Supported model identities: `DeepCoro_CLIP_generic` and deployed display identity `DeepCORO-CLIP` - First supported execution model version: `1.0.0` - Deployed image family: `heartwisehub/pacs-ai-deepcoro-clip-generic:1.0` - Transport: the existing tenant-scoped execution-result endpoint from issues #392 and #393 @@ -13,7 +13,7 @@ Only aggregate model/version/status counts and JSON key names/types were inspected on 2026-08-16. No patient identifiers, study identifiers, result values, or source payloads were copied into this repository. The deployed service reports completed results under canonical model -`DeepCoro_CLIP_generic` and version `1.0.0`. +`DeepCoro_CLIP_generic` or `DeepCORO-CLIP`, and version `1.0.0`. Every supported payload contains: diff --git a/platform/app/src/components/inference/studyProcessing/modelResults/DeepCoroCtoContract.md b/platform/app/src/components/inference/studyProcessing/modelResults/DeepCoroCtoContract.md new file mode 100644 index 000000000..58b64576f --- /dev/null +++ b/platform/app/src/components/inference/studyProcessing/modelResults/DeepCoroCtoContract.md @@ -0,0 +1,10 @@ +# DeepCORO-CTO result presentation contract + +The dedicated renderer is selected only for `DeepCORO-CTO` version `2.0.0`. It accepts the +deployed per-artery JSON output containing the imaging J-CTO score, selected artery, four +morphology components, and LAD/RCA/LCx scores. + +Scores and probabilities must be finite and within their documented ranges. Model-authored +diagnosis, labels, warnings, and recommendation markup are not displayed. The UI uses fixed +component labels and fixed research/LCx cautions. Malformed or future payloads fall back to the +generic renderer. diff --git a/platform/app/src/components/inference/studyProcessing/modelResults/DeepCoroCtoResult.test.tsx b/platform/app/src/components/inference/studyProcessing/modelResults/DeepCoroCtoResult.test.tsx new file mode 100644 index 000000000..80813fc28 --- /dev/null +++ b/platform/app/src/components/inference/studyProcessing/modelResults/DeepCoroCtoResult.test.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { DeepCoroCtoResult } from './DeepCoroCtoResult'; +import { deepCoroCtoResultFixtures } from './deepCoroCtoFixtures'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (_key: string, options: Record = {}) => String(options.defaultValue ?? ''), + i18n: { language: 'en-US' }, + }), +})); + +describe('DeepCoroCtoResult', () => { + test('presents score, components, and per-artery results', () => { + let renderer: TestRenderer.ReactTestRenderer; + act(() => { + renderer = TestRenderer.create( + + ); + }); + expect(renderer!.root.findAllByProps({ 'data-testid': 'deepcoro-cto-result' })).toHaveLength(1); + expect(renderer!.root.findAll(node => node.type === 'table')).toHaveLength(2); + const rendered = JSON.stringify(renderer!.toJSON()); + expect(rendered).toContain('RCA'); + expect(rendered).toContain('71.0%'); + expect(rendered).toContain('difficult'); + }); + + test('uses fixed safety text instead of model-authored strings', () => { + let renderer: TestRenderer.ReactTestRenderer; + act(() => { + renderer = TestRenderer.create( + + ); + }); + const rendered = JSON.stringify(renderer!.toJSON()); + expect(rendered).not.toContain('Synthetic J-CTO result'); + expect(rendered).not.toContain(''); + expect(renderer!.root.findAllByProps({ role: 'note' })).toHaveLength(1); + }); + + test('shows a fixed caution for a selected LCx result', () => { + let renderer: TestRenderer.ReactTestRenderer; + act(() => { + renderer = TestRenderer.create( + + ); + }); + expect( + renderer!.root.findAllByProps({ 'data-testid': 'deepcoro-cto-lcx-warning' }) + ).toHaveLength(1); + }); +}); diff --git a/platform/app/src/components/inference/studyProcessing/modelResults/DeepCoroCtoResult.tsx b/platform/app/src/components/inference/studyProcessing/modelResults/DeepCoroCtoResult.tsx new file mode 100644 index 000000000..6532d1b86 --- /dev/null +++ b/platform/app/src/components/inference/studyProcessing/modelResults/DeepCoroCtoResult.tsx @@ -0,0 +1,251 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + DEEP_CORO_CTO_ARTERIES, + DEEP_CORO_CTO_COMPONENTS, + type DeepCoroCtoComponentKey, + type DeepCoroCtoResultPayload, +} from './deepCoroCtoContract'; + +export interface DeepCoroCtoResultProps { + payload: DeepCoroCtoResultPayload; +} + +const componentLabels: Record = { + jcto_blunt_stump: ['ProcessingDeepCoroCtoBluntStump', 'Blunt or flush stump'], + jcto_calcification: ['ProcessingDeepCoroCtoCalcification', 'Calcification at occlusion'], + jcto_bending_gt45: ['ProcessingDeepCoroCtoBending', 'Bending greater than 45°'], + jcto_occlusion_length_gt20: [ + 'ProcessingDeepCoroCtoOcclusionLength', + 'Occlusion length at least 20 mm', + ], +}; + +export function DeepCoroCtoResult({ payload }: DeepCoroCtoResultProps) { + const { t, i18n } = useTranslation('StudyList'); + const { jctoScore, components, ctoArtery, perArtery } = payload.predictions; + const percentage = new Intl.NumberFormat(i18n?.language, { + style: 'percent', + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }); + + return ( +
+
+

+ {t('ProcessingDeepCoroCtoTitle', { defaultValue: 'DeepCORO-CTO J-CTO assessment' })} +

+

+ {t('ProcessingDeepCoroCtoDescription', { + defaultValue: + 'Model-estimated imaging J-CTO score and morphology components by coronary artery.', + })} +

+
+ +
+
+
+ {t('ProcessingDeepCoroCtoSelectedArtery', { defaultValue: 'Selected CTO artery' })} +
+
{ctoArtery}
+
+
+
+ {t('ProcessingDeepCoroCtoScore', { defaultValue: 'Imaging J-CTO score' })} +
+
+ {jctoScore.predictedInteger} + + ({t('ProcessingDeepCoroCtoRaw', { defaultValue: 'raw' })}{' '} + {jctoScore.predicted.toFixed(1)}) + +
+
+
+
+ {t('ProcessingDeepCoroCtoDifficulty', { defaultValue: 'Difficulty band' })} +
+
{jctoScore.difficulty}
+
+ {jctoScore.componentsAboveThreshold}/4{' '} + {t('ProcessingDeepCoroCtoComponentsPresent', { + defaultValue: 'components above threshold', + })} +
+
+
+ + {ctoArtery === 'LCx' && ( +

+ {t('ProcessingDeepCoroCtoLcxWarning', { + defaultValue: + 'Caution: LCx predictions are unreliable because the model was validated on very few LCx CTO cases.', + })} +

+ )} + +
+

+ {t('ProcessingDeepCoroCtoComponents', { defaultValue: 'J-CTO morphology components' })} +

+
+ + + + + + + + + + + {DEEP_CORO_CTO_COMPONENTS.map(key => { + const component = components[key]; + const [labelKey, label] = componentLabels[key]; + return ( + + + + + + + ); + })} + +
+ {t('ProcessingDeepCoroCtoComponent', { defaultValue: 'Component' })} + + {t('ProcessingDeepCoroCtoProbability', { defaultValue: 'Probability' })} + + {t('ProcessingDeepCoroCtoThreshold', { defaultValue: 'Threshold' })} + + {t('ProcessingDeepCoroCtoState', { defaultValue: 'State' })} +
+ {t(labelKey, { defaultValue: label })} + + {percentage.format(component.probability)} + + {percentage.format(component.threshold)} + + {component.present + ? t('ProcessingDeepCoroCtoPresent', { defaultValue: 'Present' }) + : t('ProcessingDeepCoroCtoAbsent', { defaultValue: 'Not present' })} +
+
+
+ +
+

+ {t('ProcessingDeepCoroCtoPerArtery', { defaultValue: 'Per-artery scores' })} +

+
+ + + + + + + + + + {DEEP_CORO_CTO_ARTERIES.map(artery => ( + + + + + + ))} + +
+ {t('ProcessingDeepCoroCtoArtery', { defaultValue: 'Artery' })} + + {t('ProcessingDeepCoroCtoRawScore', { defaultValue: 'Raw score' })} + + {t('ProcessingDeepCoroCtoRoundedScore', { defaultValue: 'Rounded score' })} +
+ {artery} + {artery === ctoArtery && ( + + {t('ProcessingDeepCoroCtoSelected', { defaultValue: 'Selected' })} + + )} + + {perArtery[artery].jctoScore.toFixed(2)} + + {perArtery[artery].jctoScoreInteger} +
+
+
+ +

+ {t('ProcessingDeepCoroCtoResearchNote', { + defaultValue: + 'Research preview only. This imaging score covers four morphology components (0–4) and excludes the classic point for a previously failed attempt. Confirm every finding by operator review.', + })} +

+
+ ); +} + +export default DeepCoroCtoResult; diff --git a/platform/app/src/components/inference/studyProcessing/modelResults/DeepCoroMaceContract.md b/platform/app/src/components/inference/studyProcessing/modelResults/DeepCoroMaceContract.md index a4af68eb7..8a8defec6 100644 --- a/platform/app/src/components/inference/studyProcessing/modelResults/DeepCoroMaceContract.md +++ b/platform/app/src/components/inference/studyProcessing/modelResults/DeepCoroMaceContract.md @@ -1,6 +1,7 @@ -# DeepCORO_MACE result contract +# DeepCORO-MACE result contract -The dedicated renderer is selected only for `DeepCORO_MACE` version `1.0.0` and the deployed +The dedicated renderer is selected for `DeepCORO_MACE` or the deployed display identity +`DeepCORO-MACE`, version `1.0.0`, and the deployed payload shape represented by `deepCoroMaceContract.ts`. The contract requires the one-year horizon, all four primary endpoints, all five exploratory diff --git a/platform/app/src/components/inference/studyProcessing/modelResults/DeepCoroSyntaxContract.md b/platform/app/src/components/inference/studyProcessing/modelResults/DeepCoroSyntaxContract.md new file mode 100644 index 000000000..6587acef6 --- /dev/null +++ b/platform/app/src/components/inference/studyProcessing/modelResults/DeepCoroSyntaxContract.md @@ -0,0 +1,9 @@ +# DeepCORO-SYNTAX result presentation contract + +The dedicated renderer is selected only for `DeepCORO-SYNTAX` version `5.0.0`. It accepts the +deployed JSON output containing the modified SYNTAX score, left and right territory scores, +severity probabilities, and intermediate-to-high operating threshold. + +Only finite, bounded scores and probabilities with the documented units and categories are +rendered. Model-authored diagnosis, recommendation markup, and threshold notes are not displayed. +Malformed or future payloads fall back to the generic renderer. diff --git a/platform/app/src/components/inference/studyProcessing/modelResults/DeepCoroSyntaxResult.test.tsx b/platform/app/src/components/inference/studyProcessing/modelResults/DeepCoroSyntaxResult.test.tsx new file mode 100644 index 000000000..204ef5e85 --- /dev/null +++ b/platform/app/src/components/inference/studyProcessing/modelResults/DeepCoroSyntaxResult.test.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { DeepCoroSyntaxResult } from './DeepCoroSyntaxResult'; +import { deepCoroSyntaxResultFixtures } from './deepCoroSyntaxFixtures'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (_key: string, options: Record = {}) => String(options.defaultValue ?? ''), + i18n: { language: 'en-US' }, + }), +})); + +describe('DeepCoroSyntaxResult', () => { + test('presents global, territory, severity, and threshold results', () => { + let renderer: TestRenderer.ReactTestRenderer; + act(() => { + renderer = TestRenderer.create( + + ); + }); + const rendered = JSON.stringify(renderer!.toJSON()); + expect(rendered).toContain('24.5'); + expect(rendered).toContain('17.2'); + expect(rendered).toContain('7.3'); + expect(rendered).toContain('68.0%'); + expect(rendered).toContain('At or above threshold'); + }); + + test('does not display model-authored diagnosis, note, or recommendations', () => { + let renderer: TestRenderer.ReactTestRenderer; + act(() => { + renderer = TestRenderer.create( + + ); + }); + const rendered = JSON.stringify(renderer!.toJSON()); + expect(rendered).not.toContain('Synthetic modified SYNTAX'); + expect(rendered).not.toContain('Synthetic model-authored note'); + expect(rendered).not.toContain(''); + expect(renderer!.root.findAllByProps({ role: 'note' })).toHaveLength(1); + }); +}); diff --git a/platform/app/src/components/inference/studyProcessing/modelResults/DeepCoroSyntaxResult.tsx b/platform/app/src/components/inference/studyProcessing/modelResults/DeepCoroSyntaxResult.tsx new file mode 100644 index 000000000..a0624cf41 --- /dev/null +++ b/platform/app/src/components/inference/studyProcessing/modelResults/DeepCoroSyntaxResult.tsx @@ -0,0 +1,174 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + DEEP_CORO_SYNTAX_SEVERITY_CLASSES, + type DeepCoroSyntaxResultPayload, +} from './deepCoroSyntaxContract'; + +export interface DeepCoroSyntaxResultProps { + payload: DeepCoroSyntaxResultPayload; +} + +export function DeepCoroSyntaxResult({ payload }: DeepCoroSyntaxResultProps) { + const { t, i18n } = useTranslation('StudyList'); + const { syntax, territory, severityHead, intermediateToHigh } = payload.predictions; + const percentage = new Intl.NumberFormat(i18n?.language, { + style: 'percent', + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }); + + const scores = [ + { + key: 'global', + label: t('ProcessingDeepCoroSyntaxModifiedScore', { defaultValue: 'Modified SYNTAX score' }), + value: syntax.value, + detail: syntax.band, + }, + { + key: 'left', + label: t('ProcessingDeepCoroSyntaxLeftTerritory', { defaultValue: 'Left territory' }), + value: territory.left, + }, + { + key: 'right', + label: t('ProcessingDeepCoroSyntaxRightTerritory', { defaultValue: 'Right territory' }), + value: territory.right, + }, + ]; + + return ( +
+
+

+ {t('ProcessingDeepCoroSyntaxTitle', { defaultValue: 'DeepCORO-SYNTAX estimates' })} +

+

+ {t('ProcessingDeepCoroSyntaxDescription', { + defaultValue: + 'Model-estimated modified SYNTAX scores and research severity probabilities from coronary angiography.', + })} +

+
+ +
+ {scores.map(score => ( +
+
{score.label}
+
+ + {score.value.toFixed(1)} + + + {t('ProcessingDeepCoroSyntaxPoints', { defaultValue: 'points' })} + + {score.detail && ( + + {score.detail} + + )} +
+
+ ))} +
+ +
+

+ {t('ProcessingDeepCoroSyntaxSeverity', { defaultValue: 'Severity probabilities' })} +

+
+ + + + + + + + + + {DEEP_CORO_SYNTAX_SEVERITY_CLASSES.map(severityClass => ( + + + + + + ))} + +
+ {t('ProcessingDeepCoroSyntaxCategory', { defaultValue: 'Category' })} + + {t('ProcessingDeepCoroSyntaxProbability', { defaultValue: 'Probability' })} + + {t('ProcessingDeepCoroSyntaxModelClass', { defaultValue: 'Model class' })} +
+ {severityClass} + + {percentage.format(severityHead.probabilities[severityClass])} + + {severityHead.class === severityClass + ? t('ProcessingDeepCoroSyntaxSelected', { defaultValue: 'Selected' }) + : '—'} +
+
+
+ +
+
+
+ {t('ProcessingDeepCoroSyntaxThreshold', { + defaultValue: 'Intermediate-to-high research threshold', + })} +
+
+ {intermediateToHigh.threshold.toFixed(1)}{' '} + {t('ProcessingDeepCoroSyntaxPoints', { defaultValue: 'points' })} ·{' '} + {intermediateToHigh.positive + ? t('ProcessingDeepCoroSyntaxAtOrAbove', { defaultValue: 'At or above threshold' }) + : t('ProcessingDeepCoroSyntaxBelow', { defaultValue: 'Below threshold' })} +
+
+
+ +

+ {t('ProcessingDeepCoroSyntaxResearchNote', { + defaultValue: + 'Research use only. This modified SYNTAX estimate is a triage aid, may underestimate occlusive or multivessel disease, and must be confirmed by image review before use.', + })} +

+
+ ); +} + +export default DeepCoroSyntaxResult; diff --git a/platform/app/src/components/inference/studyProcessing/modelResults/ModelResultRenderer.test.tsx b/platform/app/src/components/inference/studyProcessing/modelResults/ModelResultRenderer.test.tsx index 56459d210..8c80ebb05 100644 --- a/platform/app/src/components/inference/studyProcessing/modelResults/ModelResultRenderer.test.tsx +++ b/platform/app/src/components/inference/studyProcessing/modelResults/ModelResultRenderer.test.tsx @@ -5,7 +5,9 @@ import { cardioSyntaxResultFixtures } from './cardioSyntaxFixtures'; import { cathEfClipResultFixtures } from './cathEfClipFixtures'; import { cathEfResultFixtures } from './cathEfFixtures'; import { deepCoroClipResultFixtures } from './deepCoroClipFixtures'; +import { deepCoroCtoResultFixtures } from './deepCoroCtoFixtures'; import { deepCoroMaceResultFixtures } from './deepCoroMaceFixtures'; +import { deepCoroSyntaxResultFixtures } from './deepCoroSyntaxFixtures'; import { deepRVClipResultFixtures } from './deepRVClipFixtures'; import { deepRVResultFixtures } from './deepRVFixtures'; import { echoPrimeResultFixtures } from './echoPrimeFixtures'; @@ -92,7 +94,7 @@ describe('ModelResultRenderer', () => { { { ).toHaveLength(0); }); + test('renders a valid DeepCORO-CTO payload with the custom component', () => { + act(() => { + renderer = TestRenderer.create( + + ); + }); + expect(renderer!.root.findAllByProps({ 'data-testid': 'deepcoro-cto-result' })).toHaveLength(1); + expect( + renderer!.root.findAllByProps({ 'data-testid': 'generic-model-result-collection' }) + ).toHaveLength(0); + }); + + test('renders a valid DeepCORO-SYNTAX payload with the custom component', () => { + act(() => { + renderer = TestRenderer.create( + + ); + }); + expect(renderer!.root.findAllByProps({ 'data-testid': 'deepcoro-syntax-result' })).toHaveLength( + 1 + ); + expect( + renderer!.root.findAllByProps({ 'data-testid': 'generic-model-result-collection' }) + ).toHaveLength(0); + }); + test.each([ ['PanEcho', '1.0.0', panEchoResultFixtures.successfulV1, 'panecho-result'], ['EchoPrime', '1.2.0', echoPrimeResultFixtures.successfulV1, 'echoprime-result'], diff --git a/platform/app/src/components/inference/studyProcessing/modelResults/ModelResultRenderer.tsx b/platform/app/src/components/inference/studyProcessing/modelResults/ModelResultRenderer.tsx index affe48788..f2b7bf7b5 100644 --- a/platform/app/src/components/inference/studyProcessing/modelResults/ModelResultRenderer.tsx +++ b/platform/app/src/components/inference/studyProcessing/modelResults/ModelResultRenderer.tsx @@ -5,7 +5,9 @@ import { CardioSyntaxResult } from './CardioSyntaxResult'; import { CathEfClipResult } from './CathEfClipResult'; import { CathEfResult } from './CathEfResult'; import { DeepCoroClipResult } from './DeepCoroClipResult'; +import { DeepCoroCtoResult } from './DeepCoroCtoResult'; import { DeepCoroMaceResult } from './DeepCoroMaceResult'; +import { DeepCoroSyntaxResult } from './DeepCoroSyntaxResult'; import { DeepRVClipResult } from './DeepRVClipResult'; import { DeepRVResult } from './DeepRVResult'; import { EchoPrimeResult } from './EchoPrimeResult'; @@ -81,9 +83,15 @@ export function ModelResultRenderer({ result }: ModelResultRendererProps) { case 'deepcoro-clip': customResult = ; break; + case 'deepcoro-cto': + customResult = ; + break; case 'deepcoro-mace': customResult = ; break; + case 'deepcoro-syntax': + customResult = ; + break; case 'deeprv-clip': customResult = ; break; diff --git a/platform/app/src/components/inference/studyProcessing/modelResults/deepCoroClipContract.ts b/platform/app/src/components/inference/studyProcessing/modelResults/deepCoroClipContract.ts index d4140327a..bb25dfc49 100644 --- a/platform/app/src/components/inference/studyProcessing/modelResults/deepCoroClipContract.ts +++ b/platform/app/src/components/inference/studyProcessing/modelResults/deepCoroClipContract.ts @@ -1,4 +1,5 @@ export const DEEP_CORO_CLIP_MODEL_NAME = 'DeepCoro_CLIP_generic'; +export const DEEP_CORO_CLIP_MODEL_NAMES = [DEEP_CORO_CLIP_MODEL_NAME, 'DeepCORO-CLIP'] as const; export const DEEP_CORO_CLIP_SUPPORTED_MODEL_VERSIONS = ['1.0.0'] as const; export const DEEP_CORO_CLIP_ARTERY_GROUPS = { diff --git a/platform/app/src/components/inference/studyProcessing/modelResults/deepCoroCtoContract.test.ts b/platform/app/src/components/inference/studyProcessing/modelResults/deepCoroCtoContract.test.ts new file mode 100644 index 000000000..f867c686c --- /dev/null +++ b/platform/app/src/components/inference/studyProcessing/modelResults/deepCoroCtoContract.test.ts @@ -0,0 +1,63 @@ +import { parseDeepCoroCtoResultPayload } from './deepCoroCtoContract'; +import { deepCoroCtoResultFixtures } from './deepCoroCtoFixtures'; + +describe('DeepCORO-CTO result contract', () => { + test('accepts and sanitizes the deployed 2.0.0 payload', () => { + const parsed = parseDeepCoroCtoResultPayload({ + ...deepCoroCtoResultFixtures.validV2, + patientId: 'must-not-survive', + }); + expect(parsed).toEqual(deepCoroCtoResultFixtures.parsedV2); + expect(parsed).not.toHaveProperty('patientId'); + }); + + test.each([ + [ + 'score above four', + { jctoScore: { ...deepCoroCtoResultFixtures.validV2.predictions.jctoScore, predicted: 4.1 } }, + ], + ['invalid artery', { ctoArtery: 'LM' }], + ['missing component', { components: {} }], + [ + 'invalid component probability', + { + components: { + ...deepCoroCtoResultFixtures.validV2.predictions.components, + jcto_calcification: { + ...deepCoroCtoResultFixtures.validV2.predictions.components.jcto_calcification, + probability: -0.1, + }, + }, + }, + ], + ['missing per-artery output', { perArtery: {} }], + ])('rejects %s', (_name, predictionOverride) => { + expect( + parseDeepCoroCtoResultPayload({ + ...deepCoroCtoResultFixtures.validV2, + predictions: { + ...deepCoroCtoResultFixtures.validV2.predictions, + ...predictionOverride, + }, + }) + ).toBeNull(); + }); + + test('accepts and strips the deployed LCx warning text', () => { + const parsed = parseDeepCoroCtoResultPayload({ + ...deepCoroCtoResultFixtures.validV2, + predictions: { + ...deepCoroCtoResultFixtures.validV2.predictions, + ctoArtery: 'LCx', + ctoArteryWarning: { en: 'authored warning', fr: 'avertissement' }, + }, + }); + expect(parsed?.predictions.ctoArtery).toBe('LCx'); + expect(parsed?.predictions).not.toHaveProperty('ctoArteryWarning'); + }); + + test('contains no patient or study identifiers in its fixtures', () => { + const text = JSON.stringify(deepCoroCtoResultFixtures).toLowerCase(); + expect(text).not.toMatch(/patient|studyinstanceuid|accession/); + }); +}); diff --git a/platform/app/src/components/inference/studyProcessing/modelResults/deepCoroCtoContract.ts b/platform/app/src/components/inference/studyProcessing/modelResults/deepCoroCtoContract.ts new file mode 100644 index 000000000..09bc66560 --- /dev/null +++ b/platform/app/src/components/inference/studyProcessing/modelResults/deepCoroCtoContract.ts @@ -0,0 +1,177 @@ +export const DEEP_CORO_CTO_MODEL_NAME = 'DeepCORO-CTO'; +export const DEEP_CORO_CTO_SUPPORTED_MODEL_VERSIONS = ['2.0.0'] as const; + +export const DEEP_CORO_CTO_COMPONENTS = [ + 'jcto_blunt_stump', + 'jcto_calcification', + 'jcto_bending_gt45', + 'jcto_occlusion_length_gt20', +] as const; +export const DEEP_CORO_CTO_ARTERIES = ['LAD', 'RCA', 'LCx'] as const; +export const DEEP_CORO_CTO_DIFFICULTIES = [ + 'easy', + 'intermediate', + 'difficult', + 'very difficult', +] as const; + +export type DeepCoroCtoComponentKey = (typeof DEEP_CORO_CTO_COMPONENTS)[number]; +export type DeepCoroCtoArtery = (typeof DEEP_CORO_CTO_ARTERIES)[number]; +export type DeepCoroCtoDifficulty = (typeof DEEP_CORO_CTO_DIFFICULTIES)[number]; + +export interface DeepCoroCtoComponent { + probability: number; + threshold: number; + present: boolean; +} + +export interface DeepCoroCtoArteryResult { + jctoScore: number; + jctoScoreInteger: number; + components: Record; +} + +export interface DeepCoroCtoResultPayload { + diagnosis: string; + predictions: { + jctoScore: { + predicted: number; + predictedInteger: number; + componentsAboveThreshold: number; + difficulty: DeepCoroCtoDifficulty; + }; + components: Record; + ctoArtery: DeepCoroCtoArtery; + perArtery: Record; + }; + modelRecommendations: { + en: string; + fr: string; + presentable: boolean; + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNumberInRange(value: unknown, minimum: number, maximum: number): value is number { + return ( + typeof value === 'number' && Number.isFinite(value) && value >= minimum && value <= maximum + ); +} + +function isOneOf(value: unknown, options: readonly T[]): value is T { + return typeof value === 'string' && options.some(option => option === value); +} + +function parseComponent(value: unknown): DeepCoroCtoComponent | null { + if ( + !isRecord(value) || + typeof value.name !== 'string' || + !isNumberInRange(value.probability, 0, 1) || + !isNumberInRange(value.threshold, 0, 1) || + typeof value.present !== 'boolean' + ) { + return null; + } + return { probability: value.probability, threshold: value.threshold, present: value.present }; +} + +function parseArteryResult(value: unknown): DeepCoroCtoArteryResult | null { + if ( + !isRecord(value) || + !isNumberInRange(value.jctoScore, 0, 4) || + !Number.isInteger(value.jctoScoreInteger) || + !isNumberInRange(value.jctoScoreInteger, 0, 4) || + !isRecord(value.components) + ) { + return null; + } + + const components = {} as Record; + for (const key of DEEP_CORO_CTO_COMPONENTS) { + const probability = value.components[key]; + if (!isNumberInRange(probability, 0, 1)) { + return null; + } + components[key] = probability; + } + return { jctoScore: value.jctoScore, jctoScoreInteger: value.jctoScoreInteger, components }; +} + +export function parseDeepCoroCtoResultPayload(value: unknown): DeepCoroCtoResultPayload | null { + if ( + !isRecord(value) || + typeof value.diagnosis !== 'string' || + !isRecord(value.predictions) || + !isRecord(value.modelRecommendations) + ) { + return null; + } + + const { predictions, modelRecommendations } = value; + const score = predictions.jctoScore; + if ( + !isRecord(score) || + !isRecord(predictions.components) || + !isRecord(predictions.perArtery) || + !isNumberInRange(score.predicted, 0, 4) || + !Number.isInteger(score.predictedInteger) || + !isNumberInRange(score.predictedInteger, 0, 4) || + !Number.isInteger(score.componentsAboveThreshold) || + !isNumberInRange(score.componentsAboveThreshold, 0, 4) || + !isOneOf(score.difficulty, DEEP_CORO_CTO_DIFFICULTIES) || + !isOneOf(predictions.ctoArtery, DEEP_CORO_CTO_ARTERIES) || + typeof modelRecommendations.en !== 'string' || + typeof modelRecommendations.fr !== 'string' || + typeof modelRecommendations.presentable !== 'boolean' + ) { + return null; + } + + if (predictions.ctoArteryWarning !== undefined) { + const warning = predictions.ctoArteryWarning; + if (!isRecord(warning) || typeof warning.en !== 'string' || typeof warning.fr !== 'string') { + return null; + } + } + + const components = {} as Record; + for (const key of DEEP_CORO_CTO_COMPONENTS) { + const component = parseComponent(predictions.components[key]); + if (!component) { + return null; + } + components[key] = component; + } + + const perArtery = {} as Record; + for (const artery of DEEP_CORO_CTO_ARTERIES) { + const arteryResult = parseArteryResult(predictions.perArtery[artery]); + if (!arteryResult) { + return null; + } + perArtery[artery] = arteryResult; + } + + return { + diagnosis: value.diagnosis, + predictions: { + jctoScore: { + predicted: score.predicted, + predictedInteger: score.predictedInteger, + componentsAboveThreshold: score.componentsAboveThreshold, + difficulty: score.difficulty, + }, + components, + ctoArtery: predictions.ctoArtery, + perArtery, + }, + modelRecommendations: { + en: modelRecommendations.en, + fr: modelRecommendations.fr, + presentable: modelRecommendations.presentable, + }, + }; +} diff --git a/platform/app/src/components/inference/studyProcessing/modelResults/deepCoroCtoFixtures.ts b/platform/app/src/components/inference/studyProcessing/modelResults/deepCoroCtoFixtures.ts new file mode 100644 index 000000000..bfc928afb --- /dev/null +++ b/platform/app/src/components/inference/studyProcessing/modelResults/deepCoroCtoFixtures.ts @@ -0,0 +1,85 @@ +import type { DeepCoroCtoResultPayload } from './deepCoroCtoContract'; + +const components = { + jcto_blunt_stump: { + name: 'Synthetic blunt stump label', + probability: 0.32, + threshold: 0.5, + present: false, + }, + jcto_calcification: { + name: 'Synthetic calcification label', + probability: 0.71, + threshold: 0.5, + present: true, + }, + jcto_bending_gt45: { + name: 'Synthetic bend label', + probability: 0.64, + threshold: 0.5, + present: true, + }, + jcto_occlusion_length_gt20: { + name: 'Synthetic length label', + probability: 0.41, + threshold: 0.5, + present: false, + }, +}; + +const artery = (jctoScore: number, jctoScoreInteger: number) => ({ + jctoScore, + jctoScoreInteger, + components: { + jcto_blunt_stump: 0.32, + jcto_calcification: 0.71, + jcto_bending_gt45: 0.64, + jcto_occlusion_length_gt20: 0.41, + }, +}); + +export const deepCoroCtoResultFixtures = { + validV2: { + diagnosis: 'Synthetic J-CTO result for component testing.', + predictions: { + jctoScore: { + predicted: 2.1, + predictedInteger: 2, + componentsAboveThreshold: 2, + difficulty: 'difficult', + }, + components, + ctoArtery: 'RCA', + perArtery: { LAD: artery(0.31, 0), RCA: artery(2.1, 2), LCx: artery(0.12, 0) }, + }, + modelRecommendations: { + en: 'Synthetic recommendation that must not be displayed.', + fr: 'Recommandation synthétique qui ne doit pas être affichée.', + presentable: true, + }, + }, + parsedV2: { + diagnosis: 'Synthetic J-CTO result for component testing.', + predictions: { + jctoScore: { + predicted: 2.1, + predictedInteger: 2, + componentsAboveThreshold: 2, + difficulty: 'difficult', + }, + components: { + jcto_blunt_stump: { probability: 0.32, threshold: 0.5, present: false }, + jcto_calcification: { probability: 0.71, threshold: 0.5, present: true }, + jcto_bending_gt45: { probability: 0.64, threshold: 0.5, present: true }, + jcto_occlusion_length_gt20: { probability: 0.41, threshold: 0.5, present: false }, + }, + ctoArtery: 'RCA', + perArtery: { LAD: artery(0.31, 0), RCA: artery(2.1, 2), LCx: artery(0.12, 0) }, + }, + modelRecommendations: { + en: 'Synthetic recommendation that must not be displayed.', + fr: 'Recommandation synthétique qui ne doit pas être affichée.', + presentable: true, + }, + } satisfies DeepCoroCtoResultPayload, +}; diff --git a/platform/app/src/components/inference/studyProcessing/modelResults/deepCoroMaceContract.ts b/platform/app/src/components/inference/studyProcessing/modelResults/deepCoroMaceContract.ts index c855b0d57..a2a589252 100644 --- a/platform/app/src/components/inference/studyProcessing/modelResults/deepCoroMaceContract.ts +++ b/platform/app/src/components/inference/studyProcessing/modelResults/deepCoroMaceContract.ts @@ -1,4 +1,5 @@ export const DEEP_CORO_MACE_MODEL_NAME = 'DeepCORO_MACE'; +export const DEEP_CORO_MACE_MODEL_NAMES = [DEEP_CORO_MACE_MODEL_NAME, 'DeepCORO-MACE'] as const; export const DEEP_CORO_MACE_SUPPORTED_MODEL_VERSIONS = ['1.0.0'] as const; export const DEEP_CORO_MACE_PRIMARY_ENDPOINTS = [ diff --git a/platform/app/src/components/inference/studyProcessing/modelResults/deepCoroSyntaxContract.test.ts b/platform/app/src/components/inference/studyProcessing/modelResults/deepCoroSyntaxContract.test.ts new file mode 100644 index 000000000..c8cdd09d7 --- /dev/null +++ b/platform/app/src/components/inference/studyProcessing/modelResults/deepCoroSyntaxContract.test.ts @@ -0,0 +1,56 @@ +import { parseDeepCoroSyntaxResultPayload } from './deepCoroSyntaxContract'; +import { deepCoroSyntaxResultFixtures } from './deepCoroSyntaxFixtures'; + +describe('DeepCORO-SYNTAX result contract', () => { + test('accepts and sanitizes the deployed 5.0.0 payload', () => { + const parsed = parseDeepCoroSyntaxResultPayload({ + ...deepCoroSyntaxResultFixtures.validV5, + patientId: 'must-not-survive', + }); + expect(parsed).toEqual(deepCoroSyntaxResultFixtures.validV5); + expect(parsed).not.toHaveProperty('patientId'); + }); + + test.each([ + [ + 'score outside range', + { syntax: { ...deepCoroSyntaxResultFixtures.validV5.predictions.syntax, value: 101 } }, + ], + [ + 'invalid unit', + { syntax: { ...deepCoroSyntaxResultFixtures.validV5.predictions.syntax, unit: '%' } }, + ], + ['missing territory', { territory: undefined }], + [ + 'invalid severity probability', + { + severityHead: { + ...deepCoroSyntaxResultFixtures.validV5.predictions.severityHead, + probabilities: { + ...deepCoroSyntaxResultFixtures.validV5.predictions.severityHead.probabilities, + '23-32': 1.1, + }, + }, + }, + ], + [ + 'invalid threshold state', + { intermediateToHigh: { positive: 'yes', threshold: 16.9, note: 'x' } }, + ], + ])('rejects %s', (_name, predictionOverride) => { + expect( + parseDeepCoroSyntaxResultPayload({ + ...deepCoroSyntaxResultFixtures.validV5, + predictions: { + ...deepCoroSyntaxResultFixtures.validV5.predictions, + ...predictionOverride, + }, + }) + ).toBeNull(); + }); + + test('contains no patient or study identifiers in its fixture', () => { + const text = JSON.stringify(deepCoroSyntaxResultFixtures).toLowerCase(); + expect(text).not.toMatch(/patient|studyinstanceuid|accession/); + }); +}); diff --git a/platform/app/src/components/inference/studyProcessing/modelResults/deepCoroSyntaxContract.ts b/platform/app/src/components/inference/studyProcessing/modelResults/deepCoroSyntaxContract.ts new file mode 100644 index 000000000..0e551b353 --- /dev/null +++ b/platform/app/src/components/inference/studyProcessing/modelResults/deepCoroSyntaxContract.ts @@ -0,0 +1,132 @@ +export const DEEP_CORO_SYNTAX_MODEL_NAME = 'DeepCORO-SYNTAX'; +export const DEEP_CORO_SYNTAX_SUPPORTED_MODEL_VERSIONS = ['5.0.0'] as const; + +export const DEEP_CORO_SYNTAX_BANDS = [ + 'Zero', + 'Low (1-22)', + 'Intermediate (23-32)', + 'High (>=33)', +] as const; +export const DEEP_CORO_SYNTAX_SEVERITY_CLASSES = ['0', '1-22', '23-32', '>=33'] as const; + +export type DeepCoroSyntaxBand = (typeof DEEP_CORO_SYNTAX_BANDS)[number]; +export type DeepCoroSyntaxSeverityClass = (typeof DEEP_CORO_SYNTAX_SEVERITY_CLASSES)[number]; + +export interface DeepCoroSyntaxResultPayload { + diagnosis: string; + predictions: { + syntax: { + value: number; + unit: 'points'; + band: DeepCoroSyntaxBand; + bandIndex: number; + }; + territory: { + left: number; + right: number; + unit: 'points'; + }; + severityHead: { + class: DeepCoroSyntaxSeverityClass; + probabilities: Record; + }; + intermediateToHigh: { + positive: boolean; + threshold: number; + note: string; + }; + }; + modelRecommendations: { + en: string; + fr: string; + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNumberInRange(value: unknown, minimum: number, maximum: number): value is number { + return ( + typeof value === 'number' && Number.isFinite(value) && value >= minimum && value <= maximum + ); +} + +function isOneOf(value: unknown, options: readonly T[]): value is T { + return typeof value === 'string' && options.some(option => option === value); +} + +export function parseDeepCoroSyntaxResultPayload( + value: unknown +): DeepCoroSyntaxResultPayload | null { + if ( + !isRecord(value) || + typeof value.diagnosis !== 'string' || + !isRecord(value.predictions) || + !isRecord(value.modelRecommendations) + ) { + return null; + } + + const { predictions, modelRecommendations } = value; + const syntax = predictions.syntax; + const territory = predictions.territory; + const severityHead = predictions.severityHead; + const intermediateToHigh = predictions.intermediateToHigh; + + if ( + !isRecord(syntax) || + !isRecord(territory) || + !isRecord(severityHead) || + !isRecord(severityHead.probabilities) || + !isRecord(intermediateToHigh) || + !isNumberInRange(syntax.value, 0, 100) || + syntax.unit !== 'points' || + !isOneOf(syntax.band, DEEP_CORO_SYNTAX_BANDS) || + !Number.isInteger(syntax.bandIndex) || + !isNumberInRange(syntax.bandIndex, 0, 3) || + !isNumberInRange(territory.left, 0, 100) || + !isNumberInRange(territory.right, 0, 100) || + territory.unit !== 'points' || + !isOneOf(severityHead.class, DEEP_CORO_SYNTAX_SEVERITY_CLASSES) || + typeof intermediateToHigh.positive !== 'boolean' || + !isNumberInRange(intermediateToHigh.threshold, 0, 100) || + typeof intermediateToHigh.note !== 'string' || + typeof modelRecommendations.en !== 'string' || + typeof modelRecommendations.fr !== 'string' + ) { + return null; + } + + const probabilities = {} as Record; + for (const severityClass of DEEP_CORO_SYNTAX_SEVERITY_CLASSES) { + const probability = severityHead.probabilities[severityClass]; + if (!isNumberInRange(probability, 0, 1)) { + return null; + } + probabilities[severityClass] = probability; + } + + return { + diagnosis: value.diagnosis, + predictions: { + syntax: { + value: syntax.value, + unit: 'points', + band: syntax.band, + bandIndex: syntax.bandIndex, + }, + territory: { left: territory.left, right: territory.right, unit: 'points' }, + severityHead: { class: severityHead.class, probabilities }, + intermediateToHigh: { + positive: intermediateToHigh.positive, + threshold: intermediateToHigh.threshold, + note: intermediateToHigh.note, + }, + }, + modelRecommendations: { + en: modelRecommendations.en, + fr: modelRecommendations.fr, + }, + }; +} diff --git a/platform/app/src/components/inference/studyProcessing/modelResults/deepCoroSyntaxFixtures.ts b/platform/app/src/components/inference/studyProcessing/modelResults/deepCoroSyntaxFixtures.ts new file mode 100644 index 000000000..fcc6bf07b --- /dev/null +++ b/platform/app/src/components/inference/studyProcessing/modelResults/deepCoroSyntaxFixtures.ts @@ -0,0 +1,24 @@ +import type { DeepCoroSyntaxResultPayload } from './deepCoroSyntaxContract'; + +export const deepCoroSyntaxResultFixtures = { + validV5: { + diagnosis: 'Synthetic modified SYNTAX result for component testing.', + predictions: { + syntax: { value: 24.5, unit: 'points', band: 'Intermediate (23-32)', bandIndex: 2 }, + territory: { left: 17.2, right: 7.3, unit: 'points' }, + severityHead: { + class: '23-32', + probabilities: { '0': 0.03, '1-22': 0.17, '23-32': 0.68, '>=33': 0.12 }, + }, + intermediateToHigh: { + positive: true, + threshold: 16.902657, + note: 'Synthetic model-authored note that must not be displayed.', + }, + }, + modelRecommendations: { + en: 'Synthetic recommendation that must not be displayed.', + fr: 'Recommandation synthétique qui ne doit pas être affichée.', + }, + } satisfies DeepCoroSyntaxResultPayload, +}; diff --git a/platform/app/src/components/inference/studyProcessing/modelResults/modelResultRendererRegistry.test.ts b/platform/app/src/components/inference/studyProcessing/modelResults/modelResultRendererRegistry.test.ts index 3ce449383..e9f1a7bf8 100644 --- a/platform/app/src/components/inference/studyProcessing/modelResults/modelResultRendererRegistry.test.ts +++ b/platform/app/src/components/inference/studyProcessing/modelResults/modelResultRendererRegistry.test.ts @@ -3,7 +3,9 @@ import { cardioSyntaxResultFixtures } from './cardioSyntaxFixtures'; import { cathEfClipResultFixtures } from './cathEfClipFixtures'; import { cathEfResultFixtures } from './cathEfFixtures'; import { deepCoroClipResultFixtures } from './deepCoroClipFixtures'; +import { deepCoroCtoResultFixtures } from './deepCoroCtoFixtures'; import { deepCoroMaceResultFixtures } from './deepCoroMaceFixtures'; +import { deepCoroSyntaxResultFixtures } from './deepCoroSyntaxFixtures'; import { deepRVClipResultFixtures } from './deepRVClipFixtures'; import { deepRVResultFixtures } from './deepRVFixtures'; import { parseEchoPrimeResultPayload } from './echoPrimeContract'; @@ -89,6 +91,28 @@ function deepCoroMaceResult( }; } +function deepCoroCtoResult( + overrides: Partial> = {} +) { + return { + modelName: 'DeepCORO-CTO', + modelVersion: '2.0.0', + result: deepCoroCtoResultFixtures.validV2, + ...overrides, + }; +} + +function deepCoroSyntaxResult( + overrides: Partial> = {} +) { + return { + modelName: 'DeepCORO-SYNTAX', + modelVersion: '5.0.0', + result: deepCoroSyntaxResultFixtures.validV5, + ...overrides, + }; +} + describe('model result renderer registry', () => { test('selects CardioSyntax only for the exact supported model and version', () => { expect(resolveModelResultRenderer(result())).toEqual({ @@ -224,16 +248,22 @@ describe('model result renderer registry', () => { }); }); - test('selects DeepCORO-CLIP only for the exact supported model and version', () => { + test('selects DeepCORO-CLIP for the canonical model identity', () => { expect(resolveModelResultRenderer(deepCoroResult())).toEqual({ kind: 'deepcoro-clip', payload: deepCoroClipResultFixtures.validV1, }); }); + test('selects DeepCORO-CLIP for the deployed display identity', () => { + expect(resolveModelResultRenderer(deepCoroResult({ modelName: 'DeepCORO-CLIP' }))).toEqual({ + kind: 'deepcoro-clip', + payload: deepCoroClipResultFixtures.validV1, + }); + }); + test.each([ ['different capitalization', { modelName: 'deepcoro_clip_generic' }], - ['display name instead of canonical identity', { modelName: 'DeepCORO-CLIP' }], ['unsupported version', { modelVersion: '2.0.0' }], ['missing version', { modelVersion: null }], ])('keeps the generic renderer for DeepCORO-CLIP with %s', (_name, override) => { @@ -359,16 +389,22 @@ describe('model result renderer registry', () => { }); }); - test('selects DeepCORO-MACE only for the exact supported model and version', () => { + test('selects DeepCORO-MACE for the canonical model identity', () => { expect(resolveModelResultRenderer(deepCoroMaceResult())).toEqual({ kind: 'deepcoro-mace', payload: deepCoroMaceResultFixtures.parsedV1, }); }); + test('selects DeepCORO-MACE for the deployed display identity', () => { + expect(resolveModelResultRenderer(deepCoroMaceResult({ modelName: 'DeepCORO-MACE' }))).toEqual({ + kind: 'deepcoro-mace', + payload: deepCoroMaceResultFixtures.parsedV1, + }); + }); + test.each([ ['different capitalization', { modelName: 'deepcoro_mace' }], - ['display name punctuation', { modelName: 'DeepCORO-MACE' }], ['unsupported version', { modelVersion: '2.0.0' }], ['missing version', { modelVersion: null }], ])('keeps the generic renderer for DeepCORO-MACE with %s', (_name, override) => { @@ -402,6 +438,60 @@ describe('model result renderer registry', () => { }); }); + test('selects DeepCORO-CTO for its deployed identity and version', () => { + expect(resolveModelResultRenderer(deepCoroCtoResult())).toEqual({ + kind: 'deepcoro-cto', + payload: deepCoroCtoResultFixtures.parsedV2, + }); + }); + + test.each([ + ['different capitalization', { modelName: 'deepcoro-cto' }], + ['unsupported version', { modelVersion: '1.0.0' }], + ['missing version', { modelVersion: null }], + ])('keeps the generic renderer for DeepCORO-CTO with %s', (_name, override) => { + const executionResult = deepCoroCtoResult(override); + expect(resolveModelResultRenderer(executionResult)).toEqual({ + kind: 'generic', + payload: executionResult.result, + }); + }); + + test('keeps the generic renderer for malformed DeepCORO-CTO output', () => { + const payload = { ...deepCoroCtoResultFixtures.validV2, predictions: {} }; + expect(resolveModelResultRenderer(deepCoroCtoResult({ result: payload }))).toEqual({ + kind: 'generic', + payload, + }); + }); + + test('selects DeepCORO-SYNTAX for its deployed identity and version', () => { + expect(resolveModelResultRenderer(deepCoroSyntaxResult())).toEqual({ + kind: 'deepcoro-syntax', + payload: deepCoroSyntaxResultFixtures.validV5, + }); + }); + + test.each([ + ['different capitalization', { modelName: 'deepcoro-syntax' }], + ['unsupported version', { modelVersion: '1.0.0' }], + ['missing version', { modelVersion: null }], + ])('keeps the generic renderer for DeepCORO-SYNTAX with %s', (_name, override) => { + const executionResult = deepCoroSyntaxResult(override); + expect(resolveModelResultRenderer(executionResult)).toEqual({ + kind: 'generic', + payload: executionResult.result, + }); + }); + + test('keeps the generic renderer for malformed DeepCORO-SYNTAX output', () => { + const payload = { ...deepCoroSyntaxResultFixtures.validV5, predictions: {} }; + expect(resolveModelResultRenderer(deepCoroSyntaxResult({ result: payload }))).toEqual({ + kind: 'generic', + payload, + }); + }); + test('does not modify the unknown payload used by the generic renderer', () => { const payload = { future: { nested: ['value'] } }; diff --git a/platform/app/src/components/inference/studyProcessing/modelResults/modelResultRendererRegistry.ts b/platform/app/src/components/inference/studyProcessing/modelResults/modelResultRendererRegistry.ts index c50ce99f3..129f92543 100644 --- a/platform/app/src/components/inference/studyProcessing/modelResults/modelResultRendererRegistry.ts +++ b/platform/app/src/components/inference/studyProcessing/modelResults/modelResultRendererRegistry.ts @@ -18,17 +18,29 @@ import { parseCathEfResultPayload, } from './cathEfContract'; import { - DEEP_CORO_CLIP_MODEL_NAME, + DEEP_CORO_CLIP_MODEL_NAMES, DEEP_CORO_CLIP_SUPPORTED_MODEL_VERSIONS, type DeepCoroClipResultPayload, parseDeepCoroClipResultPayload, } from './deepCoroClipContract'; import { - DEEP_CORO_MACE_MODEL_NAME, + DEEP_CORO_CTO_MODEL_NAME, + DEEP_CORO_CTO_SUPPORTED_MODEL_VERSIONS, + type DeepCoroCtoResultPayload, + parseDeepCoroCtoResultPayload, +} from './deepCoroCtoContract'; +import { + DEEP_CORO_MACE_MODEL_NAMES, DEEP_CORO_MACE_SUPPORTED_MODEL_VERSIONS, type DeepCoroMaceResultPayload, parseDeepCoroMaceResultPayload, } from './deepCoroMaceContract'; +import { + DEEP_CORO_SYNTAX_MODEL_NAME, + DEEP_CORO_SYNTAX_SUPPORTED_MODEL_VERSIONS, + type DeepCoroSyntaxResultPayload, + parseDeepCoroSyntaxResultPayload, +} from './deepCoroSyntaxContract'; import { DEEP_RV_CLIP_MODEL_NAME, DEEP_RV_CLIP_SUPPORTED_MODEL_VERSIONS, @@ -77,10 +89,18 @@ export type ResolvedModelResultRenderer = kind: 'deepcoro-clip'; payload: DeepCoroClipResultPayload; } + | { + kind: 'deepcoro-cto'; + payload: DeepCoroCtoResultPayload; + } | { kind: 'deepcoro-mace'; payload: DeepCoroMaceResultPayload; } + | { + kind: 'deepcoro-syntax'; + payload: DeepCoroSyntaxResultPayload; + } | { kind: 'deeprv-clip'; payload: DeepRVClipResultPayload; @@ -146,7 +166,7 @@ const cathEfAdapter: ModelResultRendererAdapter = { const deepCoroClipAdapter: ModelResultRendererAdapter = { matches: ({ modelName, modelVersion }) => - modelName === DEEP_CORO_CLIP_MODEL_NAME && + DEEP_CORO_CLIP_MODEL_NAMES.some(name => name === modelName) && DEEP_CORO_CLIP_SUPPORTED_MODEL_VERSIONS.some(version => version === modelVersion), resolve: ({ result }) => { const payload = parseDeepCoroClipResultPayload(result); @@ -154,9 +174,19 @@ const deepCoroClipAdapter: ModelResultRendererAdapter = { }, }; +const deepCoroCtoAdapter: ModelResultRendererAdapter = { + matches: ({ modelName, modelVersion }) => + modelName === DEEP_CORO_CTO_MODEL_NAME && + DEEP_CORO_CTO_SUPPORTED_MODEL_VERSIONS.some(version => version === modelVersion), + resolve: ({ result }) => { + const payload = parseDeepCoroCtoResultPayload(result); + return payload ? { kind: 'deepcoro-cto', payload } : null; + }, +}; + const deepCoroMaceAdapter: ModelResultRendererAdapter = { matches: ({ modelName, modelVersion }) => - modelName === DEEP_CORO_MACE_MODEL_NAME && + DEEP_CORO_MACE_MODEL_NAMES.some(name => name === modelName) && DEEP_CORO_MACE_SUPPORTED_MODEL_VERSIONS.some(version => version === modelVersion), resolve: ({ result }) => { const payload = parseDeepCoroMaceResultPayload(result); @@ -164,6 +194,16 @@ const deepCoroMaceAdapter: ModelResultRendererAdapter = { }, }; +const deepCoroSyntaxAdapter: ModelResultRendererAdapter = { + matches: ({ modelName, modelVersion }) => + modelName === DEEP_CORO_SYNTAX_MODEL_NAME && + DEEP_CORO_SYNTAX_SUPPORTED_MODEL_VERSIONS.some(version => version === modelVersion), + resolve: ({ result }) => { + const payload = parseDeepCoroSyntaxResultPayload(result); + return payload ? { kind: 'deepcoro-syntax', payload } : null; + }, +}; + const deepRVClipAdapter: ModelResultRendererAdapter = { matches: ({ modelName, modelVersion }) => modelName === DEEP_RV_CLIP_MODEL_NAME && @@ -209,7 +249,9 @@ const modelResultRendererAdapters: readonly ModelResultRendererAdapter[] = [ cathEfClipAdapter, cathEfAdapter, deepCoroClipAdapter, + deepCoroCtoAdapter, deepCoroMaceAdapter, + deepCoroSyntaxAdapter, deepRVClipAdapter, deepRVAdapter, panEchoAdapter, diff --git a/platform/i18n/src/locales/en-US/StudyList.json b/platform/i18n/src/locales/en-US/StudyList.json index 43d81841b..7f33a3f2d 100644 --- a/platform/i18n/src/locales/en-US/StudyList.json +++ b/platform/i18n/src/locales/en-US/StudyList.json @@ -152,6 +152,46 @@ "ProcessingDeepCoroClipThrombus": "Thrombus", "ProcessingDeepCoroClipTitle": "DeepCORO-CLIP vessel findings", "ProcessingDeepCoroClipVessel": "Vessel", + "ProcessingDeepCoroCtoAbsent": "Not present", + "ProcessingDeepCoroCtoArtery": "Artery", + "ProcessingDeepCoroCtoBending": "Bending greater than 45°", + "ProcessingDeepCoroCtoBluntStump": "Blunt or flush stump", + "ProcessingDeepCoroCtoCalcification": "Calcification at occlusion", + "ProcessingDeepCoroCtoComponent": "Component", + "ProcessingDeepCoroCtoComponents": "J-CTO morphology components", + "ProcessingDeepCoroCtoComponentsPresent": "components above threshold", + "ProcessingDeepCoroCtoDescription": "Model-estimated imaging J-CTO score and morphology components by coronary artery.", + "ProcessingDeepCoroCtoDifficulty": "Difficulty band", + "ProcessingDeepCoroCtoLcxWarning": "Caution: LCx predictions are unreliable because the model was validated on very few LCx CTO cases.", + "ProcessingDeepCoroCtoOcclusionLength": "Occlusion length at least 20 mm", + "ProcessingDeepCoroCtoPerArtery": "Per-artery scores", + "ProcessingDeepCoroCtoPresent": "Present", + "ProcessingDeepCoroCtoProbability": "Probability", + "ProcessingDeepCoroCtoRaw": "raw", + "ProcessingDeepCoroCtoRawScore": "Raw score", + "ProcessingDeepCoroCtoResearchNote": "Research preview only. This imaging score covers four morphology components (0–4) and excludes the classic point for a previously failed attempt. Confirm every finding by operator review.", + "ProcessingDeepCoroCtoRoundedScore": "Rounded score", + "ProcessingDeepCoroCtoScore": "Imaging J-CTO score", + "ProcessingDeepCoroCtoSelected": "Selected", + "ProcessingDeepCoroCtoSelectedArtery": "Selected CTO artery", + "ProcessingDeepCoroCtoState": "State", + "ProcessingDeepCoroCtoThreshold": "Threshold", + "ProcessingDeepCoroCtoTitle": "DeepCORO-CTO J-CTO assessment", + "ProcessingDeepCoroSyntaxAtOrAbove": "At or above threshold", + "ProcessingDeepCoroSyntaxBelow": "Below threshold", + "ProcessingDeepCoroSyntaxCategory": "Category", + "ProcessingDeepCoroSyntaxDescription": "Model-estimated modified SYNTAX scores and research severity probabilities from coronary angiography.", + "ProcessingDeepCoroSyntaxLeftTerritory": "Left territory", + "ProcessingDeepCoroSyntaxModelClass": "Model class", + "ProcessingDeepCoroSyntaxModifiedScore": "Modified SYNTAX score", + "ProcessingDeepCoroSyntaxPoints": "points", + "ProcessingDeepCoroSyntaxProbability": "Probability", + "ProcessingDeepCoroSyntaxResearchNote": "Research use only. This modified SYNTAX estimate is a triage aid, may underestimate occlusive or multivessel disease, and must be confirmed by image review before use.", + "ProcessingDeepCoroSyntaxRightTerritory": "Right territory", + "ProcessingDeepCoroSyntaxSelected": "Selected", + "ProcessingDeepCoroSyntaxSeverity": "Severity probabilities", + "ProcessingDeepCoroSyntaxThreshold": "Intermediate-to-high research threshold", + "ProcessingDeepCoroSyntaxTitle": "DeepCORO-SYNTAX estimates", "ProcessingDeepRVAssistiveNote": "AI-generated estimate of right ventricular systolic function. Review the complete angiographic study and clinical context before use.", "ProcessingDeepRVDescription": "Model-estimated right ventricular systolic function from coronary angiography.", "ProcessingDeepRVDiagnosisNormal": "Normal RV systolic function", diff --git a/platform/i18n/src/locales/fr/StudyList.json b/platform/i18n/src/locales/fr/StudyList.json index 7087d5937..6efc25dfe 100644 --- a/platform/i18n/src/locales/fr/StudyList.json +++ b/platform/i18n/src/locales/fr/StudyList.json @@ -148,6 +148,46 @@ "ProcessingDeepCoroClipThrombus": "Thrombus", "ProcessingDeepCoroClipTitle": "Résultats vasculaires DeepCORO-CLIP", "ProcessingDeepCoroClipVessel": "Vaisseau", + "ProcessingDeepCoroCtoAbsent": "Absent", + "ProcessingDeepCoroCtoArtery": "Artère", + "ProcessingDeepCoroCtoBending": "Courbure supérieure à 45°", + "ProcessingDeepCoroCtoBluntStump": "Moignon émoussé ou sans saillie", + "ProcessingDeepCoroCtoCalcification": "Calcification au site de l’occlusion", + "ProcessingDeepCoroCtoComponent": "Composante", + "ProcessingDeepCoroCtoComponents": "Composantes morphologiques J-CTO", + "ProcessingDeepCoroCtoComponentsPresent": "composantes au-dessus du seuil", + "ProcessingDeepCoroCtoDescription": "Score J-CTO d’imagerie et composantes morphologiques estimés par le modèle pour chaque artère coronaire.", + "ProcessingDeepCoroCtoDifficulty": "Niveau de difficulté", + "ProcessingDeepCoroCtoLcxWarning": "Attention : les prédictions pour l’artère circonflexe sont peu fiables, car le modèle a été validé sur très peu de cas de CTO de cette artère.", + "ProcessingDeepCoroCtoOcclusionLength": "Longueur de l’occlusion d’au moins 20 mm", + "ProcessingDeepCoroCtoPerArtery": "Scores par artère", + "ProcessingDeepCoroCtoPresent": "Présente", + "ProcessingDeepCoroCtoProbability": "Probabilité", + "ProcessingDeepCoroCtoRaw": "brut", + "ProcessingDeepCoroCtoRawScore": "Score brut", + "ProcessingDeepCoroCtoResearchNote": "Aperçu de recherche uniquement. Ce score d’imagerie couvre quatre composantes morphologiques (0–4) et exclut le point classique pour une tentative antérieure échouée. Confirmer chaque résultat par une revue de l’opérateur.", + "ProcessingDeepCoroCtoRoundedScore": "Score arrondi", + "ProcessingDeepCoroCtoScore": "Score J-CTO d’imagerie", + "ProcessingDeepCoroCtoSelected": "Sélectionnée", + "ProcessingDeepCoroCtoSelectedArtery": "Artère CTO sélectionnée", + "ProcessingDeepCoroCtoState": "État", + "ProcessingDeepCoroCtoThreshold": "Seuil", + "ProcessingDeepCoroCtoTitle": "Évaluation J-CTO DeepCORO-CTO", + "ProcessingDeepCoroSyntaxAtOrAbove": "Au seuil ou au-dessus", + "ProcessingDeepCoroSyntaxBelow": "Sous le seuil", + "ProcessingDeepCoroSyntaxCategory": "Catégorie", + "ProcessingDeepCoroSyntaxDescription": "Scores SYNTAX modifiés et probabilités de sévérité de recherche estimés par le modèle à partir de la coronarographie.", + "ProcessingDeepCoroSyntaxLeftTerritory": "Territoire gauche", + "ProcessingDeepCoroSyntaxModelClass": "Classe du modèle", + "ProcessingDeepCoroSyntaxModifiedScore": "Score SYNTAX modifié", + "ProcessingDeepCoroSyntaxPoints": "points", + "ProcessingDeepCoroSyntaxProbability": "Probabilité", + "ProcessingDeepCoroSyntaxResearchNote": "Réservé à la recherche. Cette estimation SYNTAX modifiée est une aide au triage, peut sous-estimer la maladie occlusive ou pluritronculaire et doit être confirmée par une revue des images avant utilisation.", + "ProcessingDeepCoroSyntaxRightTerritory": "Territoire droit", + "ProcessingDeepCoroSyntaxSelected": "Sélectionnée", + "ProcessingDeepCoroSyntaxSeverity": "Probabilités de sévérité", + "ProcessingDeepCoroSyntaxThreshold": "Seuil de recherche intermédiaire à élevé", + "ProcessingDeepCoroSyntaxTitle": "Estimations DeepCORO-SYNTAX", "ProcessingDeepRVAssistiveNote": "Estimation de la fonction systolique ventriculaire droite générée par IA. Examinez l’angiographie complète et le contexte clinique avant utilisation.", "ProcessingDeepRVDescription": "Fonction systolique ventriculaire droite estimée par le modèle à partir de la coronarographie.", "ProcessingDeepRVDiagnosisNormal": "Fonction systolique du VD normale", diff --git a/platform/i18n/src/locales/test-LNG/StudyList.json b/platform/i18n/src/locales/test-LNG/StudyList.json index 168280e3b..807676c4b 100644 --- a/platform/i18n/src/locales/test-LNG/StudyList.json +++ b/platform/i18n/src/locales/test-LNG/StudyList.json @@ -146,6 +146,46 @@ "ProcessingDeepCoroClipThrombus": "Test thrombus", "ProcessingDeepCoroClipTitle": "Test DeepCORO-CLIP vessel findings", "ProcessingDeepCoroClipVessel": "Test vessel", + "ProcessingDeepCoroCtoAbsent": "Test not present", + "ProcessingDeepCoroCtoArtery": "Test artery", + "ProcessingDeepCoroCtoBending": "Test bending greater than 45°", + "ProcessingDeepCoroCtoBluntStump": "Test blunt or flush stump", + "ProcessingDeepCoroCtoCalcification": "Test calcification at occlusion", + "ProcessingDeepCoroCtoComponent": "Test component", + "ProcessingDeepCoroCtoComponents": "Test J-CTO morphology components", + "ProcessingDeepCoroCtoComponentsPresent": "Test components above threshold", + "ProcessingDeepCoroCtoDescription": "Test DeepCORO-CTO description", + "ProcessingDeepCoroCtoDifficulty": "Test difficulty band", + "ProcessingDeepCoroCtoLcxWarning": "Test LCx warning", + "ProcessingDeepCoroCtoOcclusionLength": "Test occlusion length", + "ProcessingDeepCoroCtoPerArtery": "Test per-artery scores", + "ProcessingDeepCoroCtoPresent": "Test present", + "ProcessingDeepCoroCtoProbability": "Test probability", + "ProcessingDeepCoroCtoRaw": "Test raw", + "ProcessingDeepCoroCtoRawScore": "Test raw score", + "ProcessingDeepCoroCtoResearchNote": "Test DeepCORO-CTO research note", + "ProcessingDeepCoroCtoRoundedScore": "Test rounded score", + "ProcessingDeepCoroCtoScore": "Test imaging J-CTO score", + "ProcessingDeepCoroCtoSelected": "Test selected", + "ProcessingDeepCoroCtoSelectedArtery": "Test selected CTO artery", + "ProcessingDeepCoroCtoState": "Test state", + "ProcessingDeepCoroCtoThreshold": "Test threshold", + "ProcessingDeepCoroCtoTitle": "Test DeepCORO-CTO J-CTO assessment", + "ProcessingDeepCoroSyntaxAtOrAbove": "Test at or above threshold", + "ProcessingDeepCoroSyntaxBelow": "Test below threshold", + "ProcessingDeepCoroSyntaxCategory": "Test category", + "ProcessingDeepCoroSyntaxDescription": "Test DeepCORO-SYNTAX description", + "ProcessingDeepCoroSyntaxLeftTerritory": "Test left territory", + "ProcessingDeepCoroSyntaxModelClass": "Test model class", + "ProcessingDeepCoroSyntaxModifiedScore": "Test modified SYNTAX score", + "ProcessingDeepCoroSyntaxPoints": "Test points", + "ProcessingDeepCoroSyntaxProbability": "Test probability", + "ProcessingDeepCoroSyntaxResearchNote": "Test DeepCORO-SYNTAX research note", + "ProcessingDeepCoroSyntaxRightTerritory": "Test right territory", + "ProcessingDeepCoroSyntaxSelected": "Test selected", + "ProcessingDeepCoroSyntaxSeverity": "Test severity probabilities", + "ProcessingDeepCoroSyntaxThreshold": "Test intermediate-to-high research threshold", + "ProcessingDeepCoroSyntaxTitle": "Test DeepCORO-SYNTAX estimates", "ProcessingDeepRVAssistiveNote": "Test DeepRV assistive note", "ProcessingDeepRVDescription": "Test DeepRV description", "ProcessingDeepRVDiagnosisNormal": "Test normal RV systolic function",