@erosolaraijs/cure 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/dist/bin/cure.d.ts +10 -0
- package/dist/bin/cure.d.ts.map +1 -0
- package/dist/bin/cure.js +169 -0
- package/dist/bin/cure.js.map +1 -0
- package/dist/capabilities/cancerTreatmentCapability.d.ts +167 -0
- package/dist/capabilities/cancerTreatmentCapability.d.ts.map +1 -0
- package/dist/capabilities/cancerTreatmentCapability.js +912 -0
- package/dist/capabilities/cancerTreatmentCapability.js.map +1 -0
- package/dist/capabilities/index.d.ts +2 -0
- package/dist/capabilities/index.d.ts.map +1 -0
- package/dist/capabilities/index.js +3 -0
- package/dist/capabilities/index.js.map +1 -0
- package/dist/compliance/hipaa.d.ts +337 -0
- package/dist/compliance/hipaa.d.ts.map +1 -0
- package/dist/compliance/hipaa.js +929 -0
- package/dist/compliance/hipaa.js.map +1 -0
- package/dist/examples/cancerTreatmentDemo.d.ts +21 -0
- package/dist/examples/cancerTreatmentDemo.d.ts.map +1 -0
- package/dist/examples/cancerTreatmentDemo.js +216 -0
- package/dist/examples/cancerTreatmentDemo.js.map +1 -0
- package/dist/integrations/clinicalTrials/clinicalTrialsGov.d.ts +265 -0
- package/dist/integrations/clinicalTrials/clinicalTrialsGov.d.ts.map +1 -0
- package/dist/integrations/clinicalTrials/clinicalTrialsGov.js +808 -0
- package/dist/integrations/clinicalTrials/clinicalTrialsGov.js.map +1 -0
- package/dist/integrations/ehr/fhir.d.ts +455 -0
- package/dist/integrations/ehr/fhir.d.ts.map +1 -0
- package/dist/integrations/ehr/fhir.js +859 -0
- package/dist/integrations/ehr/fhir.js.map +1 -0
- package/dist/integrations/genomics/genomicPlatforms.d.ts +362 -0
- package/dist/integrations/genomics/genomicPlatforms.d.ts.map +1 -0
- package/dist/integrations/genomics/genomicPlatforms.js +1079 -0
- package/dist/integrations/genomics/genomicPlatforms.js.map +1 -0
- package/package.json +52 -0
- package/src/bin/cure.ts +182 -0
- package/src/capabilities/cancerTreatmentCapability.ts +1161 -0
- package/src/capabilities/index.ts +2 -0
- package/src/compliance/hipaa.ts +1365 -0
- package/src/examples/cancerTreatmentDemo.ts +241 -0
- package/src/integrations/clinicalTrials/clinicalTrialsGov.ts +1143 -0
- package/src/integrations/ehr/fhir.ts +1304 -0
- package/src/integrations/genomics/genomicPlatforms.ts +1480 -0
- package/src/ml/outcomePredictor.ts +1301 -0
- package/src/safety/drugInteractions.ts +942 -0
- package/src/validation/retrospectiveValidator.ts +887 -0
|
@@ -0,0 +1,887 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retrospective Validation Framework
|
|
3
|
+
*
|
|
4
|
+
* Provides tools for validating the treatment recommendation system against
|
|
5
|
+
* real patient outcomes. Essential for:
|
|
6
|
+
* - Model performance assessment
|
|
7
|
+
* - Calibration checking
|
|
8
|
+
* - Concordance analysis with actual treatments
|
|
9
|
+
* - Outcome correlation studies
|
|
10
|
+
* - Continuous quality improvement
|
|
11
|
+
*
|
|
12
|
+
* IMPORTANT: All patient data used for validation must be properly de-identified
|
|
13
|
+
* or used with appropriate IRB approval and patient consent.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { EventEmitter } from 'events';
|
|
17
|
+
import { createHash } from 'crypto';
|
|
18
|
+
|
|
19
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
20
|
+
// VALIDATION DATA TYPES
|
|
21
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
22
|
+
|
|
23
|
+
export interface ValidationPatient {
|
|
24
|
+
id: string; // De-identified ID
|
|
25
|
+
demographics: {
|
|
26
|
+
ageAtDiagnosis: number;
|
|
27
|
+
gender: 'male' | 'female';
|
|
28
|
+
ethnicity?: string;
|
|
29
|
+
};
|
|
30
|
+
diagnosis: {
|
|
31
|
+
cancerType: string;
|
|
32
|
+
histology?: string;
|
|
33
|
+
stage: string;
|
|
34
|
+
diagnosisDate: Date;
|
|
35
|
+
biomarkers?: { name: string; value: string | number; status?: string }[];
|
|
36
|
+
genomicAlterations?: { gene: string; alteration: string }[];
|
|
37
|
+
msiStatus?: 'MSI-H' | 'MSI-L' | 'MSS';
|
|
38
|
+
tmbValue?: number;
|
|
39
|
+
pdl1Score?: number;
|
|
40
|
+
hrdStatus?: boolean;
|
|
41
|
+
};
|
|
42
|
+
treatment: {
|
|
43
|
+
regimen: string;
|
|
44
|
+
drugs: string[];
|
|
45
|
+
setting: string;
|
|
46
|
+
startDate: Date;
|
|
47
|
+
endDate?: Date;
|
|
48
|
+
cycles?: number;
|
|
49
|
+
doseModifications?: boolean;
|
|
50
|
+
};
|
|
51
|
+
outcomes: {
|
|
52
|
+
bestResponse?: 'CR' | 'PR' | 'SD' | 'PD' | 'NE';
|
|
53
|
+
responseDate?: Date;
|
|
54
|
+
progressionDate?: Date;
|
|
55
|
+
deathDate?: Date;
|
|
56
|
+
lastFollowUpDate: Date;
|
|
57
|
+
causeOfDeath?: 'disease' | 'treatment' | 'other' | 'unknown';
|
|
58
|
+
toxicities?: { name: string; grade: number; date?: Date }[];
|
|
59
|
+
};
|
|
60
|
+
ecogAtBaseline?: number;
|
|
61
|
+
priorLines?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SystemRecommendation {
|
|
65
|
+
patientId: string;
|
|
66
|
+
recommendedRegimen: string;
|
|
67
|
+
recommendedDrugs: string[];
|
|
68
|
+
predictions: {
|
|
69
|
+
responseRate: number;
|
|
70
|
+
pfsMonths: number;
|
|
71
|
+
osMonths: number;
|
|
72
|
+
toxicityRisk: number;
|
|
73
|
+
};
|
|
74
|
+
matchingBiomarkers: string[];
|
|
75
|
+
confidenceScore: number;
|
|
76
|
+
timestamp: Date;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ValidationResult {
|
|
80
|
+
patientId: string;
|
|
81
|
+
concordance: {
|
|
82
|
+
regimenMatch: boolean;
|
|
83
|
+
partialMatch: boolean; // At least one drug matches
|
|
84
|
+
matchedDrugs: string[];
|
|
85
|
+
};
|
|
86
|
+
outcomeComparison: {
|
|
87
|
+
predictedResponse: number;
|
|
88
|
+
actualResponse?: 'CR' | 'PR' | 'SD' | 'PD' | 'NE';
|
|
89
|
+
responseCorrect?: boolean;
|
|
90
|
+
|
|
91
|
+
predictedPFS: number;
|
|
92
|
+
actualPFS?: number; // months
|
|
93
|
+
pfsDifference?: number;
|
|
94
|
+
|
|
95
|
+
predictedOS: number;
|
|
96
|
+
actualOS?: number; // months
|
|
97
|
+
osDifference?: number;
|
|
98
|
+
|
|
99
|
+
predictedToxicityRisk: number;
|
|
100
|
+
actualGrade3PlusToxicity: boolean;
|
|
101
|
+
};
|
|
102
|
+
clinicalBenefit: {
|
|
103
|
+
objectiveResponse: boolean;
|
|
104
|
+
diseaseControl: boolean;
|
|
105
|
+
durableBenefit: boolean; // PFS > 6 months
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface CohortAnalysis {
|
|
110
|
+
cohortId: string;
|
|
111
|
+
description: string;
|
|
112
|
+
patientCount: number;
|
|
113
|
+
dateRange: { start: Date; end: Date };
|
|
114
|
+
|
|
115
|
+
// Demographics
|
|
116
|
+
demographics: {
|
|
117
|
+
medianAge: number;
|
|
118
|
+
ageRange: [number, number];
|
|
119
|
+
genderDistribution: { male: number; female: number };
|
|
120
|
+
stageDistribution: Record<string, number>;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// Concordance metrics
|
|
124
|
+
concordance: {
|
|
125
|
+
fullConcordance: number; // Percentage
|
|
126
|
+
partialConcordance: number;
|
|
127
|
+
noConcordance: number;
|
|
128
|
+
concordanceByBiomarker: Record<string, number>;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// Prediction accuracy
|
|
132
|
+
predictionAccuracy: {
|
|
133
|
+
responseAccuracy: number;
|
|
134
|
+
responseSensitivity: number; // Ability to predict responders
|
|
135
|
+
responseSpecificity: number; // Ability to predict non-responders
|
|
136
|
+
responseAUC?: number;
|
|
137
|
+
|
|
138
|
+
pfsCIndex: number; // Concordance index for PFS
|
|
139
|
+
pfsCalibration: number; // How well predicted PFS matches actual
|
|
140
|
+
|
|
141
|
+
osCIndex: number;
|
|
142
|
+
osCalibration: number;
|
|
143
|
+
|
|
144
|
+
toxicityAccuracy: number;
|
|
145
|
+
toxicityAUC?: number;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// Clinical impact
|
|
149
|
+
clinicalImpact: {
|
|
150
|
+
objectiveResponseRate: number;
|
|
151
|
+
diseaseControlRate: number;
|
|
152
|
+
medianPFS: number;
|
|
153
|
+
medianOS: number;
|
|
154
|
+
|
|
155
|
+
// Comparison metrics
|
|
156
|
+
concordantVsDiscordant: {
|
|
157
|
+
concordantORR: number;
|
|
158
|
+
discordantORR: number;
|
|
159
|
+
concordantMedianPFS: number;
|
|
160
|
+
discordantMedianPFS: number;
|
|
161
|
+
pValue?: number;
|
|
162
|
+
};
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// Subgroup analyses
|
|
166
|
+
subgroupAnalyses: {
|
|
167
|
+
subgroup: string;
|
|
168
|
+
patientCount: number;
|
|
169
|
+
concordance: number;
|
|
170
|
+
responseAccuracy: number;
|
|
171
|
+
medianPFS: number;
|
|
172
|
+
}[];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
176
|
+
// VALIDATION SERVICE
|
|
177
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
178
|
+
|
|
179
|
+
export class RetrospectiveValidationService extends EventEmitter {
|
|
180
|
+
private validationCohorts: Map<string, ValidationPatient[]> = new Map();
|
|
181
|
+
private systemRecommendations: Map<string, SystemRecommendation> = new Map();
|
|
182
|
+
private validationResults: Map<string, ValidationResult> = new Map();
|
|
183
|
+
|
|
184
|
+
constructor() {
|
|
185
|
+
super();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Load a validation cohort
|
|
190
|
+
*/
|
|
191
|
+
loadCohort(cohortId: string, patients: ValidationPatient[]): void {
|
|
192
|
+
// De-identify and validate data
|
|
193
|
+
const validatedPatients = patients.map(p => this.validateAndDeidentify(p));
|
|
194
|
+
this.validationCohorts.set(cohortId, validatedPatients);
|
|
195
|
+
this.emit('cohort-loaded', { cohortId, patientCount: validatedPatients.length });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Load system recommendations for comparison
|
|
200
|
+
*/
|
|
201
|
+
loadRecommendations(recommendations: SystemRecommendation[]): void {
|
|
202
|
+
for (const rec of recommendations) {
|
|
203
|
+
this.systemRecommendations.set(rec.patientId, rec);
|
|
204
|
+
}
|
|
205
|
+
this.emit('recommendations-loaded', { count: recommendations.length });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Run validation for a cohort
|
|
210
|
+
*/
|
|
211
|
+
async runValidation(cohortId: string): Promise<CohortAnalysis> {
|
|
212
|
+
const patients = this.validationCohorts.get(cohortId);
|
|
213
|
+
if (!patients) {
|
|
214
|
+
throw new Error(`Cohort ${cohortId} not found`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const results: ValidationResult[] = [];
|
|
218
|
+
|
|
219
|
+
// Validate each patient
|
|
220
|
+
for (const patient of patients) {
|
|
221
|
+
const recommendation = this.systemRecommendations.get(patient.id);
|
|
222
|
+
const result = this.validatePatient(patient, recommendation);
|
|
223
|
+
results.push(result);
|
|
224
|
+
this.validationResults.set(patient.id, result);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Compute cohort-level metrics
|
|
228
|
+
const analysis = this.computeCohortAnalysis(cohortId, patients, results);
|
|
229
|
+
|
|
230
|
+
this.emit('validation-complete', { cohortId, analysis });
|
|
231
|
+
|
|
232
|
+
return analysis;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Validate a single patient
|
|
237
|
+
*/
|
|
238
|
+
private validatePatient(
|
|
239
|
+
patient: ValidationPatient,
|
|
240
|
+
recommendation?: SystemRecommendation
|
|
241
|
+
): ValidationResult {
|
|
242
|
+
// Concordance analysis
|
|
243
|
+
const concordance = this.assessConcordance(patient, recommendation);
|
|
244
|
+
|
|
245
|
+
// Calculate actual outcomes
|
|
246
|
+
const actualPFS = this.calculatePFS(patient);
|
|
247
|
+
const actualOS = this.calculateOS(patient);
|
|
248
|
+
const actualGrade3Plus = (patient.outcomes.toxicities || []).some(t => t.grade >= 3);
|
|
249
|
+
|
|
250
|
+
// Compare predictions to outcomes
|
|
251
|
+
const outcomeComparison = {
|
|
252
|
+
predictedResponse: recommendation?.predictions.responseRate || 0,
|
|
253
|
+
actualResponse: patient.outcomes.bestResponse,
|
|
254
|
+
responseCorrect: this.assessResponseAccuracy(
|
|
255
|
+
recommendation?.predictions.responseRate || 0,
|
|
256
|
+
patient.outcomes.bestResponse
|
|
257
|
+
),
|
|
258
|
+
predictedPFS: recommendation?.predictions.pfsMonths || 0,
|
|
259
|
+
actualPFS,
|
|
260
|
+
pfsDifference: actualPFS !== undefined ? actualPFS - (recommendation?.predictions.pfsMonths || 0) : undefined,
|
|
261
|
+
predictedOS: recommendation?.predictions.osMonths || 0,
|
|
262
|
+
actualOS,
|
|
263
|
+
osDifference: actualOS !== undefined ? actualOS - (recommendation?.predictions.osMonths || 0) : undefined,
|
|
264
|
+
predictedToxicityRisk: recommendation?.predictions.toxicityRisk || 0,
|
|
265
|
+
actualGrade3PlusToxicity: actualGrade3Plus
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// Clinical benefit assessment
|
|
269
|
+
const clinicalBenefit = {
|
|
270
|
+
objectiveResponse: ['CR', 'PR'].includes(patient.outcomes.bestResponse || ''),
|
|
271
|
+
diseaseControl: ['CR', 'PR', 'SD'].includes(patient.outcomes.bestResponse || ''),
|
|
272
|
+
durableBenefit: actualPFS !== undefined && actualPFS >= 6
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
patientId: patient.id,
|
|
277
|
+
concordance,
|
|
278
|
+
outcomeComparison,
|
|
279
|
+
clinicalBenefit
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Assess concordance between recommendation and actual treatment
|
|
285
|
+
*/
|
|
286
|
+
private assessConcordance(
|
|
287
|
+
patient: ValidationPatient,
|
|
288
|
+
recommendation?: SystemRecommendation
|
|
289
|
+
): ValidationResult['concordance'] {
|
|
290
|
+
if (!recommendation) {
|
|
291
|
+
return {
|
|
292
|
+
regimenMatch: false,
|
|
293
|
+
partialMatch: false,
|
|
294
|
+
matchedDrugs: []
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const actualDrugs = patient.treatment.drugs.map(d => d.toLowerCase());
|
|
299
|
+
const recommendedDrugs = recommendation.recommendedDrugs.map(d => d.toLowerCase());
|
|
300
|
+
|
|
301
|
+
const matchedDrugs = recommendedDrugs.filter(rd =>
|
|
302
|
+
actualDrugs.some(ad => this.drugsMatch(ad, rd))
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
const regimenMatch = matchedDrugs.length === recommendedDrugs.length &&
|
|
306
|
+
matchedDrugs.length === actualDrugs.length;
|
|
307
|
+
const partialMatch = matchedDrugs.length > 0;
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
regimenMatch,
|
|
311
|
+
partialMatch,
|
|
312
|
+
matchedDrugs
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Check if two drug names refer to the same drug
|
|
318
|
+
*/
|
|
319
|
+
private drugsMatch(drug1: string, drug2: string): boolean {
|
|
320
|
+
// Normalize names
|
|
321
|
+
const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
322
|
+
if (normalize(drug1) === normalize(drug2)) return true;
|
|
323
|
+
|
|
324
|
+
// Check brand/generic equivalents
|
|
325
|
+
const equivalents: Record<string, string[]> = {
|
|
326
|
+
'pembrolizumab': ['keytruda'],
|
|
327
|
+
'nivolumab': ['opdivo'],
|
|
328
|
+
'osimertinib': ['tagrisso'],
|
|
329
|
+
'alectinib': ['alecensa'],
|
|
330
|
+
'trastuzumab': ['herceptin'],
|
|
331
|
+
'pertuzumab': ['perjeta'],
|
|
332
|
+
'olaparib': ['lynparza'],
|
|
333
|
+
'palbociclib': ['ibrance'],
|
|
334
|
+
'carboplatin': ['paraplatin'],
|
|
335
|
+
'paclitaxel': ['taxol'],
|
|
336
|
+
'docetaxel': ['taxotere']
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
for (const [generic, brands] of Object.entries(equivalents)) {
|
|
340
|
+
const allNames = [generic, ...brands].map(normalize);
|
|
341
|
+
if (allNames.includes(normalize(drug1)) && allNames.includes(normalize(drug2))) {
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Calculate PFS in months
|
|
351
|
+
*/
|
|
352
|
+
private calculatePFS(patient: ValidationPatient): number | undefined {
|
|
353
|
+
const startDate = patient.treatment.startDate;
|
|
354
|
+
let endDate: Date;
|
|
355
|
+
|
|
356
|
+
if (patient.outcomes.progressionDate) {
|
|
357
|
+
endDate = patient.outcomes.progressionDate;
|
|
358
|
+
} else if (patient.outcomes.deathDate) {
|
|
359
|
+
endDate = patient.outcomes.deathDate;
|
|
360
|
+
} else {
|
|
361
|
+
// Censored at last follow-up
|
|
362
|
+
endDate = patient.outcomes.lastFollowUpDate;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const months = (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24 * 30.44);
|
|
366
|
+
return Math.round(months * 10) / 10;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Calculate OS in months
|
|
371
|
+
*/
|
|
372
|
+
private calculateOS(patient: ValidationPatient): number | undefined {
|
|
373
|
+
const startDate = patient.diagnosis.diagnosisDate;
|
|
374
|
+
let endDate: Date;
|
|
375
|
+
|
|
376
|
+
if (patient.outcomes.deathDate) {
|
|
377
|
+
endDate = patient.outcomes.deathDate;
|
|
378
|
+
} else {
|
|
379
|
+
// Censored at last follow-up
|
|
380
|
+
endDate = patient.outcomes.lastFollowUpDate;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const months = (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24 * 30.44);
|
|
384
|
+
return Math.round(months * 10) / 10;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Assess if response prediction was accurate
|
|
389
|
+
*/
|
|
390
|
+
private assessResponseAccuracy(predictedRate: number, actualResponse?: string): boolean | undefined {
|
|
391
|
+
if (!actualResponse || actualResponse === 'NE') return undefined;
|
|
392
|
+
|
|
393
|
+
const predicted = predictedRate >= 0.5 ? 'responder' : 'non-responder';
|
|
394
|
+
const actual = ['CR', 'PR'].includes(actualResponse) ? 'responder' : 'non-responder';
|
|
395
|
+
|
|
396
|
+
return predicted === actual;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Compute cohort-level analysis
|
|
401
|
+
*/
|
|
402
|
+
private computeCohortAnalysis(
|
|
403
|
+
cohortId: string,
|
|
404
|
+
patients: ValidationPatient[],
|
|
405
|
+
results: ValidationResult[]
|
|
406
|
+
): CohortAnalysis {
|
|
407
|
+
// Demographics
|
|
408
|
+
const ages = patients.map(p => p.demographics.ageAtDiagnosis);
|
|
409
|
+
const maleCount = patients.filter(p => p.demographics.gender === 'male').length;
|
|
410
|
+
|
|
411
|
+
const stageDistribution: Record<string, number> = {};
|
|
412
|
+
for (const p of patients) {
|
|
413
|
+
stageDistribution[p.diagnosis.stage] = (stageDistribution[p.diagnosis.stage] || 0) + 1;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Concordance metrics
|
|
417
|
+
const fullConcordance = results.filter(r => r.concordance.regimenMatch).length / results.length;
|
|
418
|
+
const partialConcordance = results.filter(r => r.concordance.partialMatch && !r.concordance.regimenMatch).length / results.length;
|
|
419
|
+
|
|
420
|
+
// Response accuracy
|
|
421
|
+
const responseResults = results.filter(r => r.outcomeComparison.responseCorrect !== undefined);
|
|
422
|
+
const responseAccuracy = responseResults.length > 0
|
|
423
|
+
? responseResults.filter(r => r.outcomeComparison.responseCorrect).length / responseResults.length
|
|
424
|
+
: 0;
|
|
425
|
+
|
|
426
|
+
// Sensitivity (correctly predicted responders)
|
|
427
|
+
const actualResponders = results.filter(r =>
|
|
428
|
+
['CR', 'PR'].includes(r.outcomeComparison.actualResponse || '')
|
|
429
|
+
);
|
|
430
|
+
const responseSensitivity = actualResponders.length > 0
|
|
431
|
+
? actualResponders.filter(r => r.outcomeComparison.predictedResponse >= 0.5).length / actualResponders.length
|
|
432
|
+
: 0;
|
|
433
|
+
|
|
434
|
+
// Specificity (correctly predicted non-responders)
|
|
435
|
+
const actualNonResponders = results.filter(r =>
|
|
436
|
+
['SD', 'PD'].includes(r.outcomeComparison.actualResponse || '')
|
|
437
|
+
);
|
|
438
|
+
const responseSpecificity = actualNonResponders.length > 0
|
|
439
|
+
? actualNonResponders.filter(r => r.outcomeComparison.predictedResponse < 0.5).length / actualNonResponders.length
|
|
440
|
+
: 0;
|
|
441
|
+
|
|
442
|
+
// PFS concordance index (simplified)
|
|
443
|
+
const pfsCIndex = this.calculateCIndex(
|
|
444
|
+
results.map(r => r.outcomeComparison.predictedPFS),
|
|
445
|
+
results.map(r => r.outcomeComparison.actualPFS || 0)
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
// PFS calibration
|
|
449
|
+
const pfsCalibration = this.calculateCalibration(
|
|
450
|
+
results.map(r => r.outcomeComparison.predictedPFS),
|
|
451
|
+
results.map(r => r.outcomeComparison.actualPFS || 0)
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
// OS metrics (similar calculation)
|
|
455
|
+
const osCIndex = this.calculateCIndex(
|
|
456
|
+
results.map(r => r.outcomeComparison.predictedOS),
|
|
457
|
+
results.map(r => r.outcomeComparison.actualOS || 0)
|
|
458
|
+
);
|
|
459
|
+
const osCalibration = this.calculateCalibration(
|
|
460
|
+
results.map(r => r.outcomeComparison.predictedOS),
|
|
461
|
+
results.map(r => r.outcomeComparison.actualOS || 0)
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
// Toxicity accuracy
|
|
465
|
+
const toxicityResults = results.filter(r => r.outcomeComparison.predictedToxicityRisk > 0);
|
|
466
|
+
const toxicityAccuracy = toxicityResults.length > 0
|
|
467
|
+
? toxicityResults.filter(r => {
|
|
468
|
+
const predicted = r.outcomeComparison.predictedToxicityRisk >= 0.3;
|
|
469
|
+
return predicted === r.outcomeComparison.actualGrade3PlusToxicity;
|
|
470
|
+
}).length / toxicityResults.length
|
|
471
|
+
: 0;
|
|
472
|
+
|
|
473
|
+
// Clinical outcomes
|
|
474
|
+
const objectiveResponseRate = results.filter(r => r.clinicalBenefit.objectiveResponse).length / results.length;
|
|
475
|
+
const diseaseControlRate = results.filter(r => r.clinicalBenefit.diseaseControl).length / results.length;
|
|
476
|
+
|
|
477
|
+
const pfsValues = results.map(r => r.outcomeComparison.actualPFS).filter(v => v !== undefined) as number[];
|
|
478
|
+
const medianPFS = this.calculateMedian(pfsValues);
|
|
479
|
+
|
|
480
|
+
const osValues = results.map(r => r.outcomeComparison.actualOS).filter(v => v !== undefined) as number[];
|
|
481
|
+
const medianOS = this.calculateMedian(osValues);
|
|
482
|
+
|
|
483
|
+
// Concordant vs discordant outcomes
|
|
484
|
+
const concordantResults = results.filter(r => r.concordance.regimenMatch || r.concordance.partialMatch);
|
|
485
|
+
const discordantResults = results.filter(r => !r.concordance.regimenMatch && !r.concordance.partialMatch);
|
|
486
|
+
|
|
487
|
+
const concordantORR = concordantResults.length > 0
|
|
488
|
+
? concordantResults.filter(r => r.clinicalBenefit.objectiveResponse).length / concordantResults.length
|
|
489
|
+
: 0;
|
|
490
|
+
const discordantORR = discordantResults.length > 0
|
|
491
|
+
? discordantResults.filter(r => r.clinicalBenefit.objectiveResponse).length / discordantResults.length
|
|
492
|
+
: 0;
|
|
493
|
+
|
|
494
|
+
const concordantPFS = this.calculateMedian(
|
|
495
|
+
concordantResults.map(r => r.outcomeComparison.actualPFS).filter(v => v !== undefined) as number[]
|
|
496
|
+
);
|
|
497
|
+
const discordantPFS = this.calculateMedian(
|
|
498
|
+
discordantResults.map(r => r.outcomeComparison.actualPFS).filter(v => v !== undefined) as number[]
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
// Subgroup analyses
|
|
502
|
+
const subgroupAnalyses = this.performSubgroupAnalyses(patients, results);
|
|
503
|
+
|
|
504
|
+
return {
|
|
505
|
+
cohortId,
|
|
506
|
+
description: `Validation cohort with ${patients.length} patients`,
|
|
507
|
+
patientCount: patients.length,
|
|
508
|
+
dateRange: {
|
|
509
|
+
start: new Date(Math.min(...patients.map(p => p.diagnosis.diagnosisDate.getTime()))),
|
|
510
|
+
end: new Date(Math.max(...patients.map(p => p.outcomes.lastFollowUpDate.getTime())))
|
|
511
|
+
},
|
|
512
|
+
demographics: {
|
|
513
|
+
medianAge: this.calculateMedian(ages),
|
|
514
|
+
ageRange: [Math.min(...ages), Math.max(...ages)],
|
|
515
|
+
genderDistribution: {
|
|
516
|
+
male: maleCount / patients.length,
|
|
517
|
+
female: (patients.length - maleCount) / patients.length
|
|
518
|
+
},
|
|
519
|
+
stageDistribution
|
|
520
|
+
},
|
|
521
|
+
concordance: {
|
|
522
|
+
fullConcordance,
|
|
523
|
+
partialConcordance,
|
|
524
|
+
noConcordance: 1 - fullConcordance - partialConcordance,
|
|
525
|
+
concordanceByBiomarker: this.calculateConcordanceByBiomarker(patients, results)
|
|
526
|
+
},
|
|
527
|
+
predictionAccuracy: {
|
|
528
|
+
responseAccuracy,
|
|
529
|
+
responseSensitivity,
|
|
530
|
+
responseSpecificity,
|
|
531
|
+
pfsCIndex,
|
|
532
|
+
pfsCalibration,
|
|
533
|
+
osCIndex,
|
|
534
|
+
osCalibration,
|
|
535
|
+
toxicityAccuracy
|
|
536
|
+
},
|
|
537
|
+
clinicalImpact: {
|
|
538
|
+
objectiveResponseRate,
|
|
539
|
+
diseaseControlRate,
|
|
540
|
+
medianPFS,
|
|
541
|
+
medianOS,
|
|
542
|
+
concordantVsDiscordant: {
|
|
543
|
+
concordantORR,
|
|
544
|
+
discordantORR,
|
|
545
|
+
concordantMedianPFS: concordantPFS,
|
|
546
|
+
discordantMedianPFS: discordantPFS
|
|
547
|
+
}
|
|
548
|
+
},
|
|
549
|
+
subgroupAnalyses
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Calculate concordance index (C-index)
|
|
555
|
+
*/
|
|
556
|
+
private calculateCIndex(predicted: number[], actual: number[]): number {
|
|
557
|
+
let concordant = 0;
|
|
558
|
+
let discordant = 0;
|
|
559
|
+
let tied = 0;
|
|
560
|
+
|
|
561
|
+
for (let i = 0; i < predicted.length; i++) {
|
|
562
|
+
for (let j = i + 1; j < predicted.length; j++) {
|
|
563
|
+
if (actual[i] !== actual[j]) {
|
|
564
|
+
if ((predicted[i] > predicted[j] && actual[i] > actual[j]) ||
|
|
565
|
+
(predicted[i] < predicted[j] && actual[i] < actual[j])) {
|
|
566
|
+
concordant++;
|
|
567
|
+
} else if (predicted[i] !== predicted[j]) {
|
|
568
|
+
discordant++;
|
|
569
|
+
} else {
|
|
570
|
+
tied++;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const total = concordant + discordant + tied;
|
|
577
|
+
return total > 0 ? (concordant + 0.5 * tied) / total : 0.5;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Calculate calibration score (0-1, higher is better)
|
|
582
|
+
*/
|
|
583
|
+
private calculateCalibration(predicted: number[], actual: number[]): number {
|
|
584
|
+
if (predicted.length === 0) return 0;
|
|
585
|
+
|
|
586
|
+
// Group predictions into deciles and compare to actual
|
|
587
|
+
const combined = predicted.map((p, i) => ({ predicted: p, actual: actual[i] }))
|
|
588
|
+
.filter(c => c.actual > 0)
|
|
589
|
+
.sort((a, b) => a.predicted - b.predicted);
|
|
590
|
+
|
|
591
|
+
if (combined.length < 10) {
|
|
592
|
+
// Simple correlation for small samples
|
|
593
|
+
const meanPred = combined.reduce((s, c) => s + c.predicted, 0) / combined.length;
|
|
594
|
+
const meanActual = combined.reduce((s, c) => s + c.actual, 0) / combined.length;
|
|
595
|
+
|
|
596
|
+
const errors = combined.map(c => Math.abs(c.predicted - c.actual) / Math.max(c.actual, 1));
|
|
597
|
+
const meanError = errors.reduce((s, e) => s + e, 0) / errors.length;
|
|
598
|
+
|
|
599
|
+
return Math.max(0, 1 - meanError);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// For larger samples, use decile calibration
|
|
603
|
+
const decileSize = Math.ceil(combined.length / 10);
|
|
604
|
+
let totalError = 0;
|
|
605
|
+
|
|
606
|
+
for (let i = 0; i < 10; i++) {
|
|
607
|
+
const start = i * decileSize;
|
|
608
|
+
const end = Math.min((i + 1) * decileSize, combined.length);
|
|
609
|
+
const decile = combined.slice(start, end);
|
|
610
|
+
|
|
611
|
+
const avgPredicted = decile.reduce((s, c) => s + c.predicted, 0) / decile.length;
|
|
612
|
+
const avgActual = decile.reduce((s, c) => s + c.actual, 0) / decile.length;
|
|
613
|
+
|
|
614
|
+
const relativeError = avgActual > 0 ? Math.abs(avgPredicted - avgActual) / avgActual : 0;
|
|
615
|
+
totalError += relativeError;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return Math.max(0, 1 - totalError / 10);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Calculate median of an array
|
|
623
|
+
*/
|
|
624
|
+
private calculateMedian(values: number[]): number {
|
|
625
|
+
if (values.length === 0) return 0;
|
|
626
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
627
|
+
const mid = Math.floor(sorted.length / 2);
|
|
628
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Calculate concordance by biomarker subgroup
|
|
633
|
+
*/
|
|
634
|
+
private calculateConcordanceByBiomarker(
|
|
635
|
+
patients: ValidationPatient[],
|
|
636
|
+
results: ValidationResult[]
|
|
637
|
+
): Record<string, number> {
|
|
638
|
+
const biomarkerGroups: Record<string, { concordant: number; total: number }> = {};
|
|
639
|
+
|
|
640
|
+
for (let i = 0; i < patients.length; i++) {
|
|
641
|
+
const patient = patients[i];
|
|
642
|
+
const result = results[i];
|
|
643
|
+
|
|
644
|
+
// Check various biomarker categories
|
|
645
|
+
const biomarkers: string[] = [];
|
|
646
|
+
|
|
647
|
+
if (patient.diagnosis.msiStatus === 'MSI-H') biomarkers.push('MSI-H');
|
|
648
|
+
if (patient.diagnosis.tmbValue && patient.diagnosis.tmbValue >= 10) biomarkers.push('TMB-H');
|
|
649
|
+
if (patient.diagnosis.pdl1Score && patient.diagnosis.pdl1Score >= 50) biomarkers.push('PD-L1≥50%');
|
|
650
|
+
if (patient.diagnosis.hrdStatus) biomarkers.push('HRD+');
|
|
651
|
+
|
|
652
|
+
// Check genomic alterations
|
|
653
|
+
for (const alt of patient.diagnosis.genomicAlterations || []) {
|
|
654
|
+
biomarkers.push(`${alt.gene} ${alt.alteration}`);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Record concordance for each biomarker
|
|
658
|
+
for (const biomarker of biomarkers) {
|
|
659
|
+
if (!biomarkerGroups[biomarker]) {
|
|
660
|
+
biomarkerGroups[biomarker] = { concordant: 0, total: 0 };
|
|
661
|
+
}
|
|
662
|
+
biomarkerGroups[biomarker].total++;
|
|
663
|
+
if (result.concordance.regimenMatch || result.concordance.partialMatch) {
|
|
664
|
+
biomarkerGroups[biomarker].concordant++;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Convert to concordance rates
|
|
670
|
+
const concordanceByBiomarker: Record<string, number> = {};
|
|
671
|
+
for (const [biomarker, data] of Object.entries(biomarkerGroups)) {
|
|
672
|
+
if (data.total >= 5) { // Only include groups with meaningful sample size
|
|
673
|
+
concordanceByBiomarker[biomarker] = data.concordant / data.total;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
return concordanceByBiomarker;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Perform subgroup analyses
|
|
682
|
+
*/
|
|
683
|
+
private performSubgroupAnalyses(
|
|
684
|
+
patients: ValidationPatient[],
|
|
685
|
+
results: ValidationResult[]
|
|
686
|
+
): CohortAnalysis['subgroupAnalyses'] {
|
|
687
|
+
const subgroups: CohortAnalysis['subgroupAnalyses'] = [];
|
|
688
|
+
|
|
689
|
+
// By cancer type
|
|
690
|
+
const cancerTypes = [...new Set(patients.map(p => p.diagnosis.cancerType))];
|
|
691
|
+
for (const cancerType of cancerTypes) {
|
|
692
|
+
const indices = patients.map((p, i) => p.diagnosis.cancerType === cancerType ? i : -1).filter(i => i >= 0);
|
|
693
|
+
if (indices.length >= 10) {
|
|
694
|
+
const subgroupResults = indices.map(i => results[i]);
|
|
695
|
+
const subgroupPatients = indices.map(i => patients[i]);
|
|
696
|
+
|
|
697
|
+
subgroups.push({
|
|
698
|
+
subgroup: cancerType,
|
|
699
|
+
patientCount: indices.length,
|
|
700
|
+
concordance: subgroupResults.filter(r => r.concordance.regimenMatch || r.concordance.partialMatch).length / indices.length,
|
|
701
|
+
responseAccuracy: this.calculateSubgroupResponseAccuracy(subgroupResults),
|
|
702
|
+
medianPFS: this.calculateMedian(
|
|
703
|
+
subgroupResults.map(r => r.outcomeComparison.actualPFS).filter(v => v !== undefined) as number[]
|
|
704
|
+
)
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// By stage
|
|
710
|
+
const stages = ['I', 'II', 'III', 'IV'];
|
|
711
|
+
for (const stageGroup of stages) {
|
|
712
|
+
const indices = patients.map((p, i) => p.diagnosis.stage.startsWith(stageGroup) ? i : -1).filter(i => i >= 0);
|
|
713
|
+
if (indices.length >= 10) {
|
|
714
|
+
const subgroupResults = indices.map(i => results[i]);
|
|
715
|
+
|
|
716
|
+
subgroups.push({
|
|
717
|
+
subgroup: `Stage ${stageGroup}`,
|
|
718
|
+
patientCount: indices.length,
|
|
719
|
+
concordance: subgroupResults.filter(r => r.concordance.regimenMatch || r.concordance.partialMatch).length / indices.length,
|
|
720
|
+
responseAccuracy: this.calculateSubgroupResponseAccuracy(subgroupResults),
|
|
721
|
+
medianPFS: this.calculateMedian(
|
|
722
|
+
subgroupResults.map(r => r.outcomeComparison.actualPFS).filter(v => v !== undefined) as number[]
|
|
723
|
+
)
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// By age group
|
|
729
|
+
const ageGroups = [
|
|
730
|
+
{ name: 'Age <50', filter: (p: ValidationPatient) => p.demographics.ageAtDiagnosis < 50 },
|
|
731
|
+
{ name: 'Age 50-65', filter: (p: ValidationPatient) => p.demographics.ageAtDiagnosis >= 50 && p.demographics.ageAtDiagnosis < 65 },
|
|
732
|
+
{ name: 'Age 65-75', filter: (p: ValidationPatient) => p.demographics.ageAtDiagnosis >= 65 && p.demographics.ageAtDiagnosis < 75 },
|
|
733
|
+
{ name: 'Age ≥75', filter: (p: ValidationPatient) => p.demographics.ageAtDiagnosis >= 75 }
|
|
734
|
+
];
|
|
735
|
+
|
|
736
|
+
for (const ageGroup of ageGroups) {
|
|
737
|
+
const indices = patients.map((p, i) => ageGroup.filter(p) ? i : -1).filter(i => i >= 0);
|
|
738
|
+
if (indices.length >= 10) {
|
|
739
|
+
const subgroupResults = indices.map(i => results[i]);
|
|
740
|
+
|
|
741
|
+
subgroups.push({
|
|
742
|
+
subgroup: ageGroup.name,
|
|
743
|
+
patientCount: indices.length,
|
|
744
|
+
concordance: subgroupResults.filter(r => r.concordance.regimenMatch || r.concordance.partialMatch).length / indices.length,
|
|
745
|
+
responseAccuracy: this.calculateSubgroupResponseAccuracy(subgroupResults),
|
|
746
|
+
medianPFS: this.calculateMedian(
|
|
747
|
+
subgroupResults.map(r => r.outcomeComparison.actualPFS).filter(v => v !== undefined) as number[]
|
|
748
|
+
)
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
return subgroups;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Calculate response accuracy for a subgroup
|
|
758
|
+
*/
|
|
759
|
+
private calculateSubgroupResponseAccuracy(results: ValidationResult[]): number {
|
|
760
|
+
const withResponse = results.filter(r => r.outcomeComparison.responseCorrect !== undefined);
|
|
761
|
+
return withResponse.length > 0
|
|
762
|
+
? withResponse.filter(r => r.outcomeComparison.responseCorrect).length / withResponse.length
|
|
763
|
+
: 0;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Validate and de-identify patient data
|
|
768
|
+
*/
|
|
769
|
+
private validateAndDeidentify(patient: ValidationPatient): ValidationPatient {
|
|
770
|
+
// Generate hashed de-identified ID
|
|
771
|
+
const deidentifiedId = createHash('sha256')
|
|
772
|
+
.update(patient.id + 'SALT_CHANGE_IN_PRODUCTION')
|
|
773
|
+
.digest('hex')
|
|
774
|
+
.substring(0, 12);
|
|
775
|
+
|
|
776
|
+
return {
|
|
777
|
+
...patient,
|
|
778
|
+
id: deidentifiedId
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Generate validation report
|
|
784
|
+
*/
|
|
785
|
+
generateReport(analysis: CohortAnalysis): string {
|
|
786
|
+
const lines: string[] = [
|
|
787
|
+
'═'.repeat(70),
|
|
788
|
+
' RETROSPECTIVE VALIDATION REPORT',
|
|
789
|
+
'═'.repeat(70),
|
|
790
|
+
'',
|
|
791
|
+
`Cohort: ${analysis.cohortId}`,
|
|
792
|
+
`Patients: ${analysis.patientCount}`,
|
|
793
|
+
`Date Range: ${analysis.dateRange.start.toISOString().split('T')[0]} to ${analysis.dateRange.end.toISOString().split('T')[0]}`,
|
|
794
|
+
'',
|
|
795
|
+
'─'.repeat(70),
|
|
796
|
+
' CONCORDANCE METRICS',
|
|
797
|
+
'─'.repeat(70),
|
|
798
|
+
`Full Concordance: ${(analysis.concordance.fullConcordance * 100).toFixed(1)}%`,
|
|
799
|
+
`Partial Concordance: ${(analysis.concordance.partialConcordance * 100).toFixed(1)}%`,
|
|
800
|
+
`No Concordance: ${(analysis.concordance.noConcordance * 100).toFixed(1)}%`,
|
|
801
|
+
'',
|
|
802
|
+
'─'.repeat(70),
|
|
803
|
+
' PREDICTION ACCURACY',
|
|
804
|
+
'─'.repeat(70),
|
|
805
|
+
`Response Accuracy: ${(analysis.predictionAccuracy.responseAccuracy * 100).toFixed(1)}%`,
|
|
806
|
+
`Response Sensitivity: ${(analysis.predictionAccuracy.responseSensitivity * 100).toFixed(1)}%`,
|
|
807
|
+
`Response Specificity: ${(analysis.predictionAccuracy.responseSpecificity * 100).toFixed(1)}%`,
|
|
808
|
+
`PFS C-Index: ${analysis.predictionAccuracy.pfsCIndex.toFixed(3)}`,
|
|
809
|
+
`PFS Calibration: ${(analysis.predictionAccuracy.pfsCalibration * 100).toFixed(1)}%`,
|
|
810
|
+
`OS C-Index: ${analysis.predictionAccuracy.osCIndex.toFixed(3)}`,
|
|
811
|
+
`Toxicity Accuracy: ${(analysis.predictionAccuracy.toxicityAccuracy * 100).toFixed(1)}%`,
|
|
812
|
+
'',
|
|
813
|
+
'─'.repeat(70),
|
|
814
|
+
' CLINICAL OUTCOMES',
|
|
815
|
+
'─'.repeat(70),
|
|
816
|
+
`Objective Response Rate: ${(analysis.clinicalImpact.objectiveResponseRate * 100).toFixed(1)}%`,
|
|
817
|
+
`Disease Control Rate: ${(analysis.clinicalImpact.diseaseControlRate * 100).toFixed(1)}%`,
|
|
818
|
+
`Median PFS: ${analysis.clinicalImpact.medianPFS.toFixed(1)} months`,
|
|
819
|
+
`Median OS: ${analysis.clinicalImpact.medianOS.toFixed(1)} months`,
|
|
820
|
+
'',
|
|
821
|
+
' Concordant vs Discordant Treatment:',
|
|
822
|
+
` Concordant ORR: ${(analysis.clinicalImpact.concordantVsDiscordant.concordantORR * 100).toFixed(1)}%`,
|
|
823
|
+
` Discordant ORR: ${(analysis.clinicalImpact.concordantVsDiscordant.discordantORR * 100).toFixed(1)}%`,
|
|
824
|
+
` Concordant Median PFS: ${analysis.clinicalImpact.concordantVsDiscordant.concordantMedianPFS.toFixed(1)} months`,
|
|
825
|
+
` Discordant Median PFS: ${analysis.clinicalImpact.concordantVsDiscordant.discordantMedianPFS.toFixed(1)} months`,
|
|
826
|
+
'',
|
|
827
|
+
'─'.repeat(70),
|
|
828
|
+
' SUBGROUP ANALYSES',
|
|
829
|
+
'─'.repeat(70),
|
|
830
|
+
...analysis.subgroupAnalyses.map(sg =>
|
|
831
|
+
`${sg.subgroup}: n=${sg.patientCount}, Concordance=${(sg.concordance * 100).toFixed(0)}%, ` +
|
|
832
|
+
`Accuracy=${(sg.responseAccuracy * 100).toFixed(0)}%, mPFS=${sg.medianPFS.toFixed(1)}mo`
|
|
833
|
+
),
|
|
834
|
+
'',
|
|
835
|
+
'═'.repeat(70)
|
|
836
|
+
];
|
|
837
|
+
|
|
838
|
+
return lines.join('\n');
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Export validation results
|
|
843
|
+
*/
|
|
844
|
+
exportResults(cohortId: string, format: 'json' | 'csv'): string {
|
|
845
|
+
const patients = this.validationCohorts.get(cohortId);
|
|
846
|
+
if (!patients) return '';
|
|
847
|
+
|
|
848
|
+
const results: any[] = [];
|
|
849
|
+
for (const patient of patients) {
|
|
850
|
+
const result = this.validationResults.get(patient.id);
|
|
851
|
+
if (result) {
|
|
852
|
+
results.push({
|
|
853
|
+
patientId: patient.id,
|
|
854
|
+
cancerType: patient.diagnosis.cancerType,
|
|
855
|
+
stage: patient.diagnosis.stage,
|
|
856
|
+
actualRegimen: patient.treatment.regimen,
|
|
857
|
+
regimenMatch: result.concordance.regimenMatch,
|
|
858
|
+
partialMatch: result.concordance.partialMatch,
|
|
859
|
+
predictedResponse: result.outcomeComparison.predictedResponse,
|
|
860
|
+
actualResponse: result.outcomeComparison.actualResponse,
|
|
861
|
+
responseCorrect: result.outcomeComparison.responseCorrect,
|
|
862
|
+
predictedPFS: result.outcomeComparison.predictedPFS,
|
|
863
|
+
actualPFS: result.outcomeComparison.actualPFS,
|
|
864
|
+
predictedOS: result.outcomeComparison.predictedOS,
|
|
865
|
+
actualOS: result.outcomeComparison.actualOS,
|
|
866
|
+
objectiveResponse: result.clinicalBenefit.objectiveResponse,
|
|
867
|
+
diseaseControl: result.clinicalBenefit.diseaseControl
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (format === 'json') {
|
|
873
|
+
return JSON.stringify(results, null, 2);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// CSV format
|
|
877
|
+
const headers = Object.keys(results[0] || {});
|
|
878
|
+
const rows = [
|
|
879
|
+
headers.join(','),
|
|
880
|
+
...results.map(r => headers.map(h => JSON.stringify(r[h] ?? '')).join(','))
|
|
881
|
+
];
|
|
882
|
+
|
|
883
|
+
return rows.join('\n');
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
export default RetrospectiveValidationService;
|