// Display names mirror config/taxonomy.yaml (learner_focal_options / learner_focal_options_by_modality / // learner_pattern_options). Copy rules: SPEC §14.4. import type { Mark, Modality, OutcomeResult, PatternSelection } from '../types/contracts'; export type FocalLabel = Exclude; export type PatternLabel = PatternSelection['label']; export type LabelOption = { id: T; display: string }; /** The X-ray focal findings (taxonomy `learner_focal_options`). */ export const FOCAL_LABELS: LabelOption[] = [ { id: 'pneumothorax', display: 'Pneumothorax' }, { id: 'effusion', display: 'Pleural effusion' }, { id: 'consolidation', display: 'Consolidation' }, { id: 'atelectasis', display: 'Atelectasis' }, { id: 'nodule', display: 'Nodule' }, { id: 'mass', display: 'Mass' }, { id: 'calcification', display: 'Calcification' }, { id: 'fracture', display: 'Fracture' }, { id: 'pleural_thickening', display: 'Pleural thickening' }, ]; /** Volumetric (CT / MR) focal findings; organs are zones, not findings. */ export const VOLUMETRIC_LABELS: LabelOption[] = [ { id: 'pancreatic_tumour', display: 'Pancreatic tumour' }, { id: 'liver_tumour', display: 'Liver tumour' }, { id: 'brain_tumour', display: 'Brain tumour' }, { id: 'lung_tumour', display: 'Lung tumour' }, { id: 'colon_tumour', display: 'Colon tumour' }, ]; const byId = (ids: FocalLabel[]) => ids.map((id) => [...FOCAL_LABELS, ...VOLUMETRIC_LABELS].find((l) => l.id === id)!); /** Mirrors taxonomy `learner_focal_options_by_modality`: what a learner may mark on each scan type. */ export const FOCAL_LABELS_BY_MODALITY: Record[]> = { cxr: FOCAL_LABELS, ct: byId(['pancreatic_tumour', 'liver_tumour', 'lung_tumour', 'colon_tumour']), mr: byId(['brain_tumour']), }; export const MODALITIES: Modality[] = ['cxr', 'ct', 'mr']; export const isModality = (v: unknown): v is Modality => typeof v === 'string' && (MODALITIES as string[]).includes(v); /** The findings a learner can choose or mark on this scan type: focal labels of the modality, plus the whole-film * patterns on an X-ray (CT / MR have no pattern findings). An unknown modality falls back to the X-ray list. */ export function labelsFor(modality: Modality | string | null | undefined): LabelOption[] { const m: Modality = isModality(modality) ? modality : 'cxr'; return m === 'cxr' ? [...FOCAL_LABELS, ...PATTERN_LABELS] : FOCAL_LABELS_BY_MODALITY[m]; } /** Scan-type names as the learner sees them. */ export const MODALITY_DISPLAY: Record = { cxr: { short: 'Chest X-ray', long: 'Chest X-ray', region: 'chest' }, ct: { short: 'CT', long: 'Abdominal CT', region: 'abdomen' }, mr: { short: 'MRI', long: 'Brain MRI', region: 'brain' }, }; export const modalityDisplay = (m: string | null | undefined, form: 'short' | 'long' = 'long') => (isModality(m) ? MODALITY_DISPLAY[m][form] : MODALITY_DISPLAY.cxr[form]); export const PATTERN_LABELS: { id: PatternLabel; display: string }[] = [ { id: 'cardiomegaly', display: 'Cardiomegaly' }, { id: 'emphysema', display: 'Emphysema' }, { id: 'fibrosis', display: 'Fibrosis' }, { id: 'diffuse_nodule', display: 'Diffuse nodules' }, ]; const ALL: Record = Object.fromEntries( [...FOCAL_LABELS, ...VOLUMETRIC_LABELS, ...PATTERN_LABELS].map((l) => [l.id, l.display]), ); ALL.not_sure = 'Not sure'; export function labelDisplay(id: string | null | undefined): string { if (!id) return 'Unlabeled'; return ALL[id] ?? id.replace(/_/g, ' '); } /** Zone ids name the PATIENT's side (CLAUDE.md rule 3). "right_upper_zone" → "right upper zone". */ export function zoneDisplay(zone: string | null | undefined): string { return zone ? zone.replace(/_/g, ' ') : ''; } export type OutcomeTone = 'found' | 'missed' | 'overcall' | 'neutral'; /** Outcome copy (SPEC §14.3 step 4) and tone. Cyan = truth, amber = learner. Never red/green. */ export const OUTCOME_COPY: Record = { found: { text: 'Found it', tone: 'found' }, pattern_found: { text: 'Found it', tone: 'found' }, true_positive: { text: 'Found it', tone: 'found' }, true_negative: { text: 'Correctly called normal', tone: 'found' }, mislabeled: { text: 'Found it, named it wrong', tone: 'missed', missType: 'interpretation' }, missed_search: { text: 'Never looked there', tone: 'missed', missType: 'search' }, missed_recognition: { text: 'Looked past it', tone: 'missed', missType: 'recognition' }, missed_decision: { text: 'Looked, judged it normal', tone: 'missed', missType: 'decision' }, pattern_missed: { text: 'Not called', tone: 'missed' }, false_positive: { text: "Called something that isn't there", tone: 'overcall', missType: 'overcall' }, pattern_false: { text: "Called something that isn't there", tone: 'overcall', missType: 'overcall' }, duplicate: { text: 'Second mark on the same finding', tone: 'neutral' }, // CT / MR: public reference sets are not exhaustive, so a mark the reference does not label is reported, never penalised. unmatched: { text: 'Not in the reference', tone: 'neutral' }, }; export const PROXY_TOOLTIP = 'Based on your cursor, magnifier and zoom — a proxy for where you looked.'; export const LEVELS = ['MS1', 'MS2', 'MS3', 'MS4', 'intern', 'resident', 'PA/NP student', 'other'] as const; export const MODES = [ { id: 'practice', display: 'Practice', note: 'Films chosen for you. Feedback after every film.' }, { id: 'drill', display: 'One finding type', note: 'One finding at a time, mixed with normal films.' }, { id: 'assess_A', display: 'Test set A', note: 'A fixed set of 20 films. Feedback at the end.' }, { id: 'assess_B', display: 'Test set B', note: 'A fixed set of 20 films. Feedback at the end.' }, { id: 'review', display: 'Review missed', note: 'Films like the ones you missed before.' }, ] as const; export function modeDisplay(mode: string): string { return MODES.find((m) => m.id === mode)?.display ?? mode; }