diff --git a/app/.eslint-baseline.json b/app/.eslint-baseline.json index 2d2e5a8c..6705a123 100644 --- a/app/.eslint-baseline.json +++ b/app/.eslint-baseline.json @@ -99,12 +99,15 @@ "src/features/assessment/components/__tests__/AssessmentResults.test.tsx": 1, "src/features/assessment/components/__tests__/EnhancedAssessmentQuestion.test.tsx": 1, "src/features/assessment/hooks/useAssessmentPerformance.ts": 13, + "src/features/assessment/stores/__tests__/answerQuestionValidation.unit.test.ts": 1, "src/features/assessment/stores/__tests__/assessmentStore.basic.test.ts": 1, "src/features/assessment/stores/__tests__/assessmentStore.notes.test.ts": 1, "src/features/assessment/stores/__tests__/assessmentStore.test.ts": 1, + "src/features/assessment/stores/__tests__/crisisDetectionParity.unit.test.ts": 1, "src/features/assessment/stores/__tests__/crisisTelemetryEmit.unit.test.ts": 1, "src/features/assessment/stores/__tests__/crisisTelemetryFields.regression.test.ts": 1, "src/features/assessment/stores/assessmentStore.ts": 26, + "src/features/assessment/types/__tests__/crisisDetectionValidation.unit.test.ts": 1, "src/features/assessment/types/__tests__/schemas.test.ts": 1, "src/features/assessment/types/scoring.ts": 2, "src/features/assessment/types/validation.ts": 1, diff --git a/app/__tests__/clinical/assessment-accuracy/comprehensive-scoring-validation.test.ts b/app/__tests__/clinical/assessment-accuracy/comprehensive-scoring-validation.test.ts index 2996e41b..fc2bbf41 100644 --- a/app/__tests__/clinical/assessment-accuracy/comprehensive-scoring-validation.test.ts +++ b/app/__tests__/clinical/assessment-accuracy/comprehensive-scoring-validation.test.ts @@ -167,9 +167,14 @@ describe('COMPREHENSIVE CLINICAL SCORING VALIDATION - ALL 48 COMBINATIONS', () = if (expectCrisis) { expect(finalStore.crisisDetection).toBeTruthy(); + // DEBUG-229 / MAINT-226 Decision E — dual-threshold tiers: ≥20 (no Q9) + // is the intervention tier `phq9_severe_score`; 15–19 is the support tier + // `phq9_moderate_severe_score`. Q9>0 always wins as suicidal-ideation. const expectedTrigger = suicidalResponse > 0 ? 'phq9_suicidal_ideation' - : 'phq9_moderate_severe_score'; + : score >= CRISIS_THRESHOLDS.PHQ9_SEVERE_THRESHOLD + ? 'phq9_severe_score' + : 'phq9_moderate_severe_score'; expect(finalStore.crisisDetection?.primaryTrigger).toBe(expectedTrigger); // triggerValue: when answerQuestion fires inline crisis for phq9_9 > 0, // the detection carries the *actual response value* (1, 2, or 3) — that diff --git a/app/src/features/assessment/stores/__tests__/answerQuestionValidation.unit.test.ts b/app/src/features/assessment/stores/__tests__/answerQuestionValidation.unit.test.ts new file mode 100644 index 00000000..a6911bac --- /dev/null +++ b/app/src/features/assessment/stores/__tests__/answerQuestionValidation.unit.test.ts @@ -0,0 +1,76 @@ +/** + * answerQuestionValidation.unit.test.ts — DEBUG-229 (TEST-08 / SEC-06) + * + * `answerQuestion` MUST validate per-question responses against the 0–3 clinical + * scale (`AssessmentResponseSchema`) BEFORE storing/scoring. Out-of-range values + * are rejected fail-loud: the answer is not stored, `error` is set, and the + * inline Q9 crisis check never runs on a corrupt value. + */ +import { jest } from '@jest/globals'; + +jest.mock('@/core/services/supabase/SupabaseService', () => { + const fn = jest.fn(); + return { __esModule: true, default: { trackCrisisDetection: fn }, supabaseService: { trackCrisisDetection: fn } }; +}); +jest.mock('react-native', () => ({ Alert: { alert: jest.fn() }, Linking: { openURL: jest.fn() } })); +jest.mock('@react-native-async-storage/async-storage'); +jest.mock('expo-secure-store'); + +const mockWellnessBlobs: Record = {}; +jest.mock('@/core/services/security/SecureStorageService', () => ({ + __esModule: true, + default: { + storeWellnessBlob: jest.fn(async (key: string, data: unknown) => { + mockWellnessBlobs[key] = data; + return { success: true, operationType: 'store' as const, storageKey: `wellness_async_${key}`, operationTimeMs: 0, dataSize: 0 }; + }), + retrieveWellnessBlob: jest.fn(async (key: string) => mockWellnessBlobs[key] ?? null), + deleteWellnessBlob: jest.fn(async (key: string) => { delete mockWellnessBlobs[key]; }), + }, +})); + +import { useAssessmentStore } from '../assessmentStore'; +import supabaseService from '@/core/services/supabase/SupabaseService'; +import type { AssessmentResponse } from '../../types/index'; + +const mockTrack = (supabaseService as any).trackCrisisDetection as jest.Mock; +// Force out-of-range / wrong-type values past the TS signature for the runtime guard test. +const bad = (v: unknown) => v as AssessmentResponse; + +describe('DEBUG-229 — answerQuestion 0–3 validation (fail-loud reject)', () => { + beforeEach(async () => { + jest.clearAllMocks(); + useAssessmentStore.getState().resetAssessment(); + await useAssessmentStore.getState().startAssessment('phq9'); + }); + + it('stores a valid in-range response (0–3) and scores it', async () => { + await useAssessmentStore.getState().answerQuestion('phq9_1', 3 as AssessmentResponse); + const s = useAssessmentStore.getState(); + expect(s.answers.find((a) => a.questionId === 'phq9_1')?.response).toBe(3); + expect(s.error).toBeNull(); + }); + + it.each([5, 4, -1, 1.5, NaN, null, undefined, '2'])( + 'rejects out-of-range/invalid response %p — not stored, error set', + async (value) => { + await useAssessmentStore.getState().answerQuestion('phq9_1', bad(value)); + const s = useAssessmentStore.getState(); + expect(s.answers.find((a) => a.questionId === 'phq9_1')).toBeUndefined(); + expect(s.error).toBeTruthy(); + }, + ); + + it('does NOT fire the inline Q9 crisis check for an out-of-range Q9 value', async () => { + await useAssessmentStore.getState().answerQuestion('phq9_9', bad(5)); + const s = useAssessmentStore.getState(); + expect(s.answers.find((a) => a.questionId === 'phq9_9')).toBeUndefined(); + expect(s.crisisDetection).toBeNull(); + expect(mockTrack).not.toHaveBeenCalled(); + }); + + it('a valid Q9>0 still triggers the inline crisis path (guard does not over-reject)', async () => { + await useAssessmentStore.getState().answerQuestion('phq9_9', 1 as AssessmentResponse); + expect(useAssessmentStore.getState().crisisDetection?.primaryTrigger).toBe('phq9_suicidal_ideation'); + }); +}); diff --git a/app/src/features/assessment/stores/__tests__/crisisDetectionParity.unit.test.ts b/app/src/features/assessment/stores/__tests__/crisisDetectionParity.unit.test.ts new file mode 100644 index 00000000..330cc038 --- /dev/null +++ b/app/src/features/assessment/stores/__tests__/crisisDetectionParity.unit.test.ts @@ -0,0 +1,108 @@ +/** + * crisisDetectionParity.unit.test.ts — DEBUG-229 / MAINT-226 Decision E + * + * Pins the "ONE source of truth" invariant: the store's production crisis path + * (startAssessment → answerQuestion → completeAssessment, read back via + * `store.crisisDetection`) MUST return the same `primaryTrigger` + `severityLevel` + * as the pure `detectCrisis()` in `@/features/crisis/types/safety` for the + * dual-threshold boundary set {14, 15, 19, 20, Q9>0}. + * + * The store's CrisisDetectionService now delegates to the pure function, so a + * future threshold/trigger-vocabulary divergence (the exact defect TEST-07 found) + * cannot silently reappear. Asserts through the production store API — never a + * test-local reimplementation (AC #3). + */ +import { jest } from '@jest/globals'; + +jest.mock('@/core/services/supabase/SupabaseService', () => { + const fn = jest.fn(); + return { __esModule: true, default: { trackCrisisDetection: fn }, supabaseService: { trackCrisisDetection: fn } }; +}); +jest.mock('react-native', () => ({ Alert: { alert: jest.fn() }, Linking: { openURL: jest.fn() } })); +jest.mock('@react-native-async-storage/async-storage'); +jest.mock('expo-secure-store'); + +const mockWellnessBlobs: Record = {}; +jest.mock('@/core/services/security/SecureStorageService', () => ({ + __esModule: true, + default: { + storeWellnessBlob: jest.fn(async (key: string, data: unknown) => { + mockWellnessBlobs[key] = data; + return { success: true, operationType: 'store' as const, storageKey: `wellness_async_${key}`, operationTimeMs: 0, dataSize: 0 }; + }), + retrieveWellnessBlob: jest.fn(async (key: string) => mockWellnessBlobs[key] ?? null), + deleteWellnessBlob: jest.fn(async (key: string) => { delete mockWellnessBlobs[key]; }), + }, +})); + +import { useAssessmentStore } from '../assessmentStore'; +import { detectCrisis } from '@/features/crisis/types/safety'; +import type { PHQ9Result } from '@/features/assessment/types'; +import type { AssessmentResponse } from '../../types/index'; + +type Ans = { questionId: string; response: AssessmentResponse }; + +/** Greedy fill q1..q8 to the target, leaving phq9_9 = `q9` (default 0). */ +function phq9Answers(targetScore: number, q9 = 0): Ans[] { + const ids = ['phq9_1', 'phq9_2', 'phq9_3', 'phq9_4', 'phq9_5', 'phq9_6', 'phq9_7', 'phq9_8']; + const out: Ans[] = []; + let remaining = targetScore - q9; + for (let i = 0; i < ids.length; i++) { + const left = ids.length - i; + const minNeeded = Math.max(0, remaining - (left - 1) * 3); + const r = Math.max(minNeeded, Math.min(3, remaining)) as AssessmentResponse; + out.push({ questionId: ids[i], response: r }); + remaining -= r; + } + out.push({ questionId: 'phq9_9', response: q9 as AssessmentResponse }); + return out; +} + +const pureResult = (totalScore: number, suicidalIdeation = false): PHQ9Result => ({ + totalScore, + severity: 'minimal', + isCrisis: false, + suicidalIdeation, + completedAt: Date.now(), + answers: [], +}); + +async function runStore(answers: Ans[]) { + const store = useAssessmentStore.getState(); + store.resetAssessment(); + await store.startAssessment('phq9'); + for (const a of answers) { + await useAssessmentStore.getState().answerQuestion(a.questionId, a.response); + } + await useAssessmentStore.getState().completeAssessment(); + return useAssessmentStore.getState().crisisDetection; +} + +describe('DEBUG-229 — pure detectCrisis ⇄ store CrisisDetectionService parity', () => { + beforeEach(() => { + jest.clearAllMocks(); + useAssessmentStore.getState().resetAssessment(); + }); + + it('PHQ-9 14, Q9=0 → both produce no crisis', async () => { + expect(detectCrisis(pureResult(14), 'u1')).toBeNull(); + expect(await runStore(phq9Answers(14))).toBeNull(); + }); + + it.each([15, 19, 20, 24])('PHQ-9 %i, Q9=0 → store matches pure (primaryTrigger + severityLevel)', async (score) => { + const pure = detectCrisis(pureResult(score), 'u1'); + expect(pure).not.toBeNull(); + const fromStore = await runStore(phq9Answers(score)); + expect(fromStore).toBeTruthy(); + expect(fromStore!.primaryTrigger).toBe(pure!.primaryTrigger); + expect(fromStore!.severityLevel).toBe(pure!.severityLevel); + }); + + it('PHQ-9 17 with Q9>0 → both report suicidal-ideation primary at "high"', async () => { + const pure = detectCrisis(pureResult(17, true), 'u1'); + expect(pure!.primaryTrigger).toBe('phq9_suicidal_ideation'); + const fromStore = await runStore(phq9Answers(17, 2)); + expect(fromStore!.primaryTrigger).toBe(pure!.primaryTrigger); + expect(fromStore!.severityLevel).toBe(pure!.severityLevel); + }); +}); diff --git a/app/src/features/assessment/stores/__tests__/crisisTelemetryFields.regression.test.ts b/app/src/features/assessment/stores/__tests__/crisisTelemetryFields.regression.test.ts index 178593ea..cd302fd6 100644 --- a/app/src/features/assessment/stores/__tests__/crisisTelemetryFields.regression.test.ts +++ b/app/src/features/assessment/stores/__tests__/crisisTelemetryFields.regression.test.ts @@ -159,14 +159,17 @@ describe('DEBUG-218 — crisis_detected carries real severity_bucket + assessmen }); describe('score-based completed path (completeAssessment) — non-Q9 crises', () => { - it('PHQ-9 ≥20 without Q9 → phq9_moderate_severe_score, severity_bucket "critical", assessment_type "phq9"', async () => { + // DEBUG-229 / MAINT-226 Decision E: ≥20 now emits the intervention-tier + // trigger `phq9_severe_score` (was `phq9_moderate_severe_score` — the store's + // missing-severe-tier bug this work item fixes). severity_bucket stays "critical". + it('PHQ-9 ≥20 without Q9 → phq9_severe_score, severity_bucket "critical", assessment_type "phq9"', async () => { await useAssessmentStore.getState().startAssessment('phq9'); await answer(phq9(20)); // greedy fill leaves phq9_9 = 0 await useAssessmentStore.getState().completeAssessment(); expect(mockTrack).toHaveBeenCalledTimes(1); const p = lastPayload(); - expect(p.trigger_type).toBe('phq9_moderate_severe_score'); + expect(p.trigger_type).toBe('phq9_severe_score'); expect(p.severity_bucket).toBe('critical'); expect(p.assessment_type).toBe('phq9'); expectNoUndefinedString(p); diff --git a/app/src/features/assessment/stores/assessmentStore.ts b/app/src/features/assessment/stores/assessmentStore.ts index 9069e58a..3d41f635 100644 --- a/app/src/features/assessment/stores/assessmentStore.ts +++ b/app/src/features/assessment/stores/assessmentStore.ts @@ -47,6 +47,10 @@ import { CRISIS_THRESHOLDS, ASSESSMENT_RESPONSE_LABELS, } from '../types/index'; +// DEBUG-229 / MAINT-226 Decision E: pure detectCrisis is the single source of +// truth for crisis thresholds + trigger taxonomy; the store delegates to it. +import { detectCrisis as detectCrisisPure } from '@/features/crisis/types/safety'; +import { validateSingleResponse } from '../types/schemas'; // Clinical scoring algorithms (validated for 100% accuracy) const PHQ9_QUESTIONS = [ @@ -309,35 +313,25 @@ class CrisisDetectionService { const startTime = Date.now(); try { - let triggerType: CrisisDetection['primaryTrigger'] | null = null; - let triggerValue = result.totalScore; - - if (type === 'phq9') { - const phqResult = result as PHQ9Result; - if (phqResult.suicidalIdeation) { - triggerType = 'phq9_suicidal_ideation'; - triggerValue = 1; // Indicates suicidal ideation present - } else if (phqResult.totalScore >= CRISIS_THRESHOLDS.PHQ9_CRISIS_SCORE) { - triggerType = 'phq9_moderate_severe_score'; - } - } else if (type === 'gad7') { - if (result.totalScore >= CRISIS_THRESHOLDS.GAD7_CRISIS_SCORE) { - triggerType = 'gad7_severe_score'; - } - } - - if (!triggerType) { + // DEBUG-229 / MAINT-226 Decision E: delegate threshold + trigger logic to the + // pure detectCrisis (single source of truth). This fixes the store's previous + // divergence — it emitted `phq9_moderate_severe_score` even at ≥20 and never + // the intervention-tier `phq9_severe_score`. userId is not part of the + // store-side detection (pass ''); assessmentId is overridden below with the + // live session id so handleCrisisDetection's per-session dedup works. + const pure = detectCrisisPure(result, ''); + if (!pure) { return null; } const detection = { isTriggered: true, - primaryTrigger: triggerType, - secondaryTriggers: [], - // DEBUG-218: populate severityLevel + assessmentType so the score-based - // crisis_detected telemetry carries real buckets (never "undefined"). - severityLevel: crisisSeverityLevel(type, result.totalScore), - triggerValue, + primaryTrigger: pure.primaryTrigger, + secondaryTriggers: pure.secondaryTriggers, + // severityLevel + assessmentType populate the score-based crisis_detected + // telemetry buckets (DEBUG-218) — now sourced from the pure function. + severityLevel: pure.severityLevel, + triggerValue: pure.triggerValue, assessmentType: type, timestamp: Date.now(), assessmentId @@ -558,6 +552,15 @@ export const useAssessmentStore = create()( throw new Error('No active assessment session'); } + // DEBUG-229 (TEST-08 / SEC-06): validate the per-question response against + // the 0–3 clinical scale BEFORE storing/scoring. Reject fail-loud — a + // corrupt value must never reach scoring or the inline Q9 crisis check. + const responseCheck = validateSingleResponse(response); + if (!responseCheck.success) { + set({ error: `Invalid response (must be 0–3): ${responseCheck.error}` }); + return; + } + try { const answer: AssessmentAnswer = { questionId, diff --git a/app/src/features/assessment/types/__tests__/crisisDetectionValidation.unit.test.ts b/app/src/features/assessment/types/__tests__/crisisDetectionValidation.unit.test.ts new file mode 100644 index 00000000..17429916 --- /dev/null +++ b/app/src/features/assessment/types/__tests__/crisisDetectionValidation.unit.test.ts @@ -0,0 +1,49 @@ +/** + * crisisDetectionValidation.unit.test.ts — DEBUG-229 / MAINT-226 Decision E + * + * `validateCrisisDetection` must recognize the support tier introduced by the + * dual-threshold fix: a `phq9_moderate_severe_score` detection is valid only in + * the 15–19 band (≥ PHQ9_MODERATE_SEVERE_THRESHOLD, < PHQ9_SEVERE_THRESHOLD). + * The pre-existing `phq9_severe_score` ≥20 guard is unchanged. + */ +import { validateCrisisDetection } from '@/features/assessment/types/validation'; +import type { CrisisDetection, CrisisTriggerType } from '@/features/crisis/types/safety'; + +function detection( + primaryTrigger: CrisisTriggerType, + triggerValue: number, +): CrisisDetection { + return { + id: 'd1', + isTriggered: true, + primaryTrigger, + secondaryTriggers: [], + severityLevel: primaryTrigger === 'phq9_severe_score' ? 'critical' : 'high', + triggerValue, + assessmentType: 'phq9', + timestamp: Date.now(), + assessmentId: 'a1', + userId: 'u1', + detectionResponseTimeMs: 0, + context: { triggeringAnswers: [], timeOfDay: 'morning' }, + }; +} + +describe('DEBUG-229 — validateCrisisDetection support tier', () => { + it('phq9_moderate_severe_score at 17 is valid (within 15–19 support band)', () => { + expect(validateCrisisDetection(detection('phq9_moderate_severe_score', 17)).isValid).toBe(true); + }); + + it('phq9_moderate_severe_score at 15 (floor) is valid', () => { + expect(validateCrisisDetection(detection('phq9_moderate_severe_score', 15)).isValid).toBe(true); + }); + + it('phq9_moderate_severe_score at 12 is invalid (below support floor)', () => { + expect(validateCrisisDetection(detection('phq9_moderate_severe_score', 12)).isValid).toBe(false); + }); + + it('phq9_severe_score at 22 is valid; at 18 is invalid (intervention floor unchanged)', () => { + expect(validateCrisisDetection(detection('phq9_severe_score', 22)).isValid).toBe(true); + expect(validateCrisisDetection(detection('phq9_severe_score', 18)).isValid).toBe(false); + }); +}); diff --git a/app/src/features/assessment/types/validation.ts b/app/src/features/assessment/types/validation.ts index ef3577ed..75dc87a6 100644 --- a/app/src/features/assessment/types/validation.ts +++ b/app/src/features/assessment/types/validation.ts @@ -359,11 +359,23 @@ export function validateCrisisDetection(detection: unknown): ValidationResult { // Validate trigger conditions if (detection.assessmentType === 'phq9') { - if (detection.primaryTrigger === 'phq9_severe_score' && - detection.triggerValue < CRISIS_SAFETY_THRESHOLDS.PHQ9_CRISIS_SCORE) { + if (detection.primaryTrigger === 'phq9_severe_score' && + detection.triggerValue < CRISIS_SAFETY_THRESHOLDS.PHQ9_SEVERE_THRESHOLD) { errors.push({ code: 'INVALID_PHQ9_CRISIS_TRIGGER', - message: `PHQ-9 score ${detection.triggerValue} below crisis threshold ${CRISIS_SAFETY_THRESHOLDS.PHQ9_CRISIS_SCORE}`, + message: `PHQ-9 score ${detection.triggerValue} below crisis threshold ${CRISIS_SAFETY_THRESHOLDS.PHQ9_SEVERE_THRESHOLD}`, + field: 'triggerValue', + value: detection.triggerValue, + severity: 'critical' + }); + } + // DEBUG-229 / MAINT-226 Decision E: the support tier is valid only in the + // 15–19 band (≥ moderate-severe floor, < active-intervention floor). + if (detection.primaryTrigger === 'phq9_moderate_severe_score' && + detection.triggerValue < CRISIS_SAFETY_THRESHOLDS.PHQ9_MODERATE_SEVERE_THRESHOLD) { + errors.push({ + code: 'INVALID_PHQ9_SUPPORT_TRIGGER', + message: `PHQ-9 score ${detection.triggerValue} below support threshold ${CRISIS_SAFETY_THRESHOLDS.PHQ9_MODERATE_SEVERE_THRESHOLD}`, field: 'triggerValue', value: detection.triggerValue, severity: 'critical' diff --git a/app/src/features/crisis/__tests__/detection.quick.test.ts b/app/src/features/crisis/__tests__/detection.quick.test.ts index a88c1af3..db2073b1 100644 --- a/app/src/features/crisis/__tests__/detection.quick.test.ts +++ b/app/src/features/crisis/__tests__/detection.quick.test.ts @@ -30,15 +30,33 @@ const gad = (totalScore: number): GAD7Result => ({ }); describe('detectCrisis — quick branch coverage', () => { - it('PHQ-9 score below 20 with no Q9 → no crisis', () => { - expect(detectCrisis(phq(19), 'u1')).toBeNull(); + // DEBUG-229 / MAINT-226 Decision E — dual-threshold support-vs-intervention tiers. + // The "≥15 = support resources offered" contract requires 15–19 to FIRE a distinct + // support tier; the old assertion `detectCrisis(phq(19)) === null` actively pinned + // the zero-false-negative bug and has been inverted. + it('PHQ-9 14 with no Q9 → no crisis (below support floor)', () => { + expect(detectCrisis(phq(14), 'u1')).toBeNull(); }); - it('PHQ-9 score at 20 with no Q9 → crisis with phq9_severe_score primaryTrigger', () => { + it('PHQ-9 15 with no Q9 → support tier (phq9_moderate_severe_score, high)', () => { + const d = detectCrisis(phq(15), 'u1'); + expect(d).not.toBeNull(); + expect(d!.primaryTrigger).toBe('phq9_moderate_severe_score'); + expect(d!.severityLevel).toBe('high'); + }); + + it('PHQ-9 19 with no Q9 → support tier (phq9_moderate_severe_score, high)', () => { + const d = detectCrisis(phq(19), 'u1'); + expect(d).not.toBeNull(); + expect(d!.primaryTrigger).toBe('phq9_moderate_severe_score'); + expect(d!.severityLevel).toBe('high'); + }); + + it('PHQ-9 score at 20 with no Q9 → intervention tier (phq9_severe_score, critical)', () => { const d = detectCrisis(phq(20), 'u1'); expect(d).not.toBeNull(); expect(d!.primaryTrigger).toBe('phq9_severe_score'); - expect(d!.severityLevel).toBe('high'); + expect(d!.severityLevel).toBe('critical'); }); it('PHQ-9 with Q9>0 (suicidal) and low total → crisis with phq9_suicidal_ideation primaryTrigger', () => { @@ -56,6 +74,14 @@ describe('detectCrisis — quick branch coverage', () => { expect(d!.secondaryTriggers).toContain('phq9_severe_score'); }); + it('PHQ-9 with Q9>0 (suicidal) in the 15–19 band → suicidal primary, support tier retained as secondary', () => { + const d = detectCrisis(phq(17, true), 'u1'); + expect(d).not.toBeNull(); + expect(d!.primaryTrigger).toBe('phq9_suicidal_ideation'); + expect(d!.severityLevel).toBe('high'); // 17 < 20 + expect(d!.secondaryTriggers).toContain('phq9_moderate_severe_score'); + }); + it('GAD-7 score below 15 → no crisis', () => { expect(detectCrisis(gad(14), 'u1')).toBeNull(); }); diff --git a/app/src/features/crisis/types/safety.ts b/app/src/features/crisis/types/safety.ts index f55840d3..5687afc3 100644 --- a/app/src/features/crisis/types/safety.ts +++ b/app/src/features/crisis/types/safety.ts @@ -347,15 +347,26 @@ export function detectCrisis( primaryTrigger = 'phq9_suicidal_ideation'; } - // Check for severe depression score - if (result.totalScore >= CRISIS_SAFETY_THRESHOLDS.PHQ9_CRISIS_SCORE) { + // Score tier (DEBUG-229 / MAINT-226 Decision E — dual-threshold contract): + // ≥20 → active-intervention tier (phq9_severe_score, critical) + // 15–19 → support tier (phq9_moderate_severe_score, high) — "≥15 = support + // resources offered". Omitting this band was the zero-false-negative bug. + // Q9>0 keeps precedence as primaryTrigger; the score tier is recorded as a + // secondary trigger so both signals are preserved end-to-end. + if (result.totalScore >= CRISIS_SAFETY_THRESHOLDS.PHQ9_SEVERE_THRESHOLD) { triggers.push('phq9_severe_score'); if (!primaryTrigger!) { primaryTrigger = 'phq9_severe_score'; + severityLevel = 'critical'; + } + } else if (result.totalScore >= CRISIS_SAFETY_THRESHOLDS.PHQ9_MODERATE_SEVERE_THRESHOLD) { + triggers.push('phq9_moderate_severe_score'); + if (!primaryTrigger!) { + primaryTrigger = 'phq9_moderate_severe_score'; severityLevel = 'high'; } } - } + } // GAD-7 Crisis Detection else { if (result.totalScore >= CRISIS_SAFETY_THRESHOLDS.GAD7_CRISIS_SCORE) {