diff --git a/apps/api/backend/src/services/entity/passportService.ts b/apps/api/backend/src/services/entity/passportService.ts index 85ad8fc6..1634b938 100644 --- a/apps/api/backend/src/services/entity/passportService.ts +++ b/apps/api/backend/src/services/entity/passportService.ts @@ -23,6 +23,11 @@ import { getCachedTrustState, type ClinicianTrustState, } from '../trust/trustStateEngine'; +import { + derivePassportReadiness, + deriveCanonicalTrustLevel, + stringsToBlockers, +} from '../readiness/canonical'; import { detectDivergence } from '../identity/divergenceEngine'; import { daysUntilExpiry, @@ -330,23 +335,21 @@ const ESTIMATED_START_DAYS: Record = { BLOCKED: null as unknown as number, }; +/** + * Backward-compat shim: routes through the canonical engine so any caller + * of the file-local helper produces the same level as canonical. New code + * should call `deriveCanonicalTrustLevel` directly with structured blockers. + */ function derivePassportReadinessLevel( score: number, status: ReadinessState, + blockers: readonly string[] = [], ): string { - if (status === 'BLOCKED') { - return 'L0'; - } - if (score >= 80 && status === 'DECISION_GRADE') { - return 'L3'; - } - if (score >= 60) { - return 'L2'; - } - if (score > 0) { - return 'L1'; - } - return 'L0'; + return deriveCanonicalTrustLevel({ + score, + status, + blockers: stringsToBlockers(blockers), + }); } function dedupeStrings(values: readonly string[]): string[] { @@ -2208,24 +2211,20 @@ export async function buildPassport(entityId: string): Promise LAUNCH_SPINE_SOURCE_IDS.includes(check.sourceId as LaunchSpineSourceId), - ); - const checkedCount = spineChecks.filter((check) => check.state === 'checked').length; - const baseScore = checkedCount * 25; - const readinessScore = (() => { - // Blockers cap score at 20 max; gaps cap at 75 max. - const cappedScore = - normalizedBlockers.length > 0 ? Math.min(baseScore, 20) - : normalizedGaps.length > 0 ? Math.min(baseScore, 75) - : baseScore; - return Math.max(0, cappedScore - (divergence?.totalPenalty ?? 0)); - })(); + const canonicalReadiness = derivePassportReadiness({ + sourceCoverage: sourceCoverage.checks, + blockers: stringsToBlockers(normalizedBlockers), + gaps: normalizedGaps, + divergencePenalty: divergence?.totalPenalty ?? 0, + readinessStatus, + }); + const readinessScore = canonicalReadiness.score; // KPI: sync blocker lifecycle events (fire-and-forget — never blocks passport build). // This populates blocker_resolution_events so /pilot-ops blocker metrics are live. // syncBlockerEvents opens new blockers and auto-resolves blockers no longer present. @@ -2243,7 +2242,7 @@ export async function buildPassport(entityId: string): Promise { + it('returns null for empty / whitespace strings', () => { + expect(categorizeBlocker('')).toBeNull(); + expect(categorizeBlocker(' ')).toBeNull(); + }); + + it('classifies license-expiry / revocation / suspension as HARD/LICENSURE', () => { + for (const msg of ['License expired', 'Revoked', 'Suspended', 'Discipline on file']) { + const result = categorizeBlocker(msg)!; + expect(result.severity).toBe('HARD'); + expect(result.category).toBe('LICENSURE'); + } + }); + + it('classifies OIG/LEIE/sanction/exclusion as HARD/EXCLUSION', () => { + const result = categorizeBlocker('OIG exclusion match')!; + expect(result.severity).toBe('HARD'); + expect(result.category).toBe('EXCLUSION'); + }); + + it('classifies PECOS / enrollment-not-found as SOFT/ENROLLMENT', () => { + const result = categorizeBlocker('PECOS enrollment not found')!; + expect(result.severity).toBe('SOFT'); + expect(result.category).toBe('ENROLLMENT'); + }); + + it('classifies unknown text as SOFT/OTHER', () => { + const result = categorizeBlocker('something unexpected')!; + expect(result.severity).toBe('SOFT'); + expect(result.category).toBe('OTHER'); + }); + + it('produces deterministic ids', () => { + expect(categorizeBlocker('License expired')?.id).toBe( + categorizeBlocker('License expired')?.id, + ); + expect(categorizeBlocker('License expired in NC')?.id).not.toBe( + categorizeBlocker('License expired in CA')?.id, + ); + }); +}); + +describe('stringsToBlockers / blockersToStrings round-trip', () => { + it('drops empty strings, preserves order', () => { + const result = stringsToBlockers(['x', '', ' ', 'y']); + expect(result.map((b) => b.message)).toEqual(['x', 'y']); + }); + + it('projects message field', () => { + expect(blockersToStrings([HARD_LICENSE, SOFT_PECOS])).toEqual([ + HARD_LICENSE.message, + SOFT_PECOS.message, + ]); + }); +}); + +describe('dedupeBlockers / hasHardBlocker / hasGatingBlocker', () => { + it('dedupes by id', () => { + const a = categorizeBlocker('License expired')!; + const b = categorizeBlocker('License expired')!; + expect(dedupeBlockers([a, b])).toHaveLength(1); + }); + + it('hasHardBlocker is true iff any HARD present', () => { + expect(hasHardBlocker([])).toBe(false); + expect(hasHardBlocker([SOFT_PECOS])).toBe(false); + expect(hasHardBlocker([SOFT_PECOS, HARD_LICENSE])).toBe(true); + }); + + it('hasGatingBlocker is true for HARD or SOFT', () => { + const info: CanonicalBlocker = { id: 'i', message: 'note', severity: 'INFO', category: 'OTHER' }; + expect(hasGatingBlocker([info])).toBe(false); + expect(hasGatingBlocker([SOFT_PECOS])).toBe(true); + }); +}); + +describe('deriveCanonicalTrustLevel', () => { + it('hard blocker → L0 regardless of score/status', () => { + expect(deriveCanonicalTrustLevel({ score: 100, status: 'DECISION_GRADE', blockers: [HARD_LICENSE] })).toBe('L0'); + }); + it('status BLOCKED → L0', () => { + expect(deriveCanonicalTrustLevel({ score: 100, status: 'BLOCKED', blockers: [] })).toBe('L0'); + }); + it('score ≥ 80 + DECISION_GRADE → L3', () => { + expect(deriveCanonicalTrustLevel({ score: 80, status: 'DECISION_GRADE', blockers: [] })).toBe('L3'); + }); + it('score 60-79 → L2', () => { + expect(deriveCanonicalTrustLevel({ score: 60, status: 'DECISION_GRADE', blockers: [] })).toBe('L2'); + expect(deriveCanonicalTrustLevel({ score: 75, status: 'PARTIAL', blockers: [] })).toBe('L2'); + }); + it('score 1-59 → L1', () => { + expect(deriveCanonicalTrustLevel({ score: 20, status: 'CHECKING', blockers: [] })).toBe('L1'); + }); + it('score 0 + no hard blocker → L0', () => { + expect(deriveCanonicalTrustLevel({ score: 0, status: 'CHECKING', blockers: [] })).toBe('L0'); + }); + it('SOFT-only blocker does NOT force L0; follows score', () => { + expect(deriveCanonicalTrustLevel({ score: 20, status: 'PARTIAL', blockers: [SOFT_PECOS] })).toBe('L1'); + }); +}); + +describe('mapLevelToBand', () => { + it('L3 → GREEN', () => expect(mapLevelToBand('L3')).toBe('GREEN')); + it('L2 → GREEN', () => expect(mapLevelToBand('L2')).toBe('GREEN')); + it('L1 → YELLOW', () => expect(mapLevelToBand('L1')).toBe('YELLOW')); + it('L0 → RED', () => expect(mapLevelToBand('L0')).toBe('RED')); +}); + +describe('derivePassportReadiness', () => { + it('all four spine sources checked → score 100 / L3 / GREEN', () => { + const r = derivePassportReadiness({ + sourceCoverage: ALL_FOUR_CHECKED, + blockers: [], + gaps: [], + readinessStatus: 'DECISION_GRADE', + }); + expect(r.score).toBe(100); + expect(r.level).toBe('L3'); + expect(r.band).toBe('GREEN'); + expect(r.checkedSpineCount).toBe(4); + }); + + it('two of four checked → score 50 / L1 / YELLOW', () => { + const r = derivePassportReadiness({ + sourceCoverage: TWO_CHECKED_TWO_PENDING, + blockers: [], + readinessStatus: 'PARTIAL', + }); + expect(r.score).toBe(50); + expect(r.level).toBe('L1'); + expect(r.band).toBe('YELLOW'); + }); + + it('HARD blocker zeroes score even with full coverage', () => { + const r = derivePassportReadiness({ + sourceCoverage: ALL_FOUR_CHECKED, + blockers: [HARD_LICENSE], + readinessStatus: 'DECISION_GRADE', + }); + expect(r.score).toBe(0); + expect(r.level).toBe('L0'); + expect(r.band).toBe('RED'); + }); + + it('SOFT blocker caps score at 20 (PECOS not-found scenario)', () => { + const r = derivePassportReadiness({ + sourceCoverage: ALL_FOUR_CHECKED, + blockers: [SOFT_PECOS], + readinessStatus: 'PARTIAL', + }); + expect(r.score).toBe(20); + expect(r.level).toBe('L1'); + expect(r.band).toBe('YELLOW'); + }); + + it('gap-only caps score at 75', () => { + const r = derivePassportReadiness({ + sourceCoverage: ALL_FOUR_CHECKED, + blockers: [], + gaps: ['Board certification not yet checked'], + readinessStatus: 'PARTIAL', + }); + expect(r.score).toBe(75); + expect(r.level).toBe('L2'); + expect(r.band).toBe('GREEN'); + }); + + it('divergence penalty subtracts from capped score, floor at 0', () => { + const r = derivePassportReadiness({ + sourceCoverage: ALL_FOUR_CHECKED, + blockers: [], + divergencePenalty: 30, + readinessStatus: 'DECISION_GRADE', + }); + expect(r.score).toBe(70); + expect(r.level).toBe('L2'); + }); + + it('divergence penalty cannot drive score below 0', () => { + const r = derivePassportReadiness({ + sourceCoverage: TWO_CHECKED_TWO_PENDING, + blockers: [], + divergencePenalty: 200, + readinessStatus: 'PARTIAL', + }); + expect(r.score).toBe(0); + expect(r.level).toBe('L0'); + }); + + it('blockerStrings projection mirrors blocker messages', () => { + const r = derivePassportReadiness({ + sourceCoverage: ALL_FOUR_CHECKED, + blockers: [HARD_LICENSE, SOFT_PECOS], + readinessStatus: 'PARTIAL', + }); + expect(r.blockerStrings).toEqual([HARD_LICENSE.message, SOFT_PECOS.message]); + }); +}); diff --git a/apps/api/backend/src/services/readiness/canonical.ts b/apps/api/backend/src/services/readiness/canonical.ts new file mode 100644 index 00000000..2d6e9c78 --- /dev/null +++ b/apps/api/backend/src/services/readiness/canonical.ts @@ -0,0 +1,239 @@ +/** + * Canonical readiness engine (P0-P2 of trust-convergence migration). + * + * Architectural contract: + * - This file is the SINGLE source of truth for the (score, level, band) + * triple. Every surface — /api/passport/:npi, /api/passport/:npi/trust, + * trust-summary panels, employer evidence packets — derives its + * numeric readiness from `derivePassportReadiness` defined here. + * - `apps/api/backend/src/services/trust/trustCore.ts` and + * `apps/api/backend/src/services/trust/trustStateEngine.ts` must NOT + * compute readiness math independently; they call into this module + * and translate outputs through `./readinessAdapter`. + * - `packages/trust-state` is behavior-frozen legacy compatibility + * infrastructure; we depend on its data primitives + * (CanonicalSourceCoverage, LAUNCH_SPINE_SOURCE_IDS, ReadinessState) + * but never put new authoritative logic there. + * + * Semantics: + * baseScore = checkedSpineCount * 25 // 0–100, four launch-spine sources + * cappedScore = if any HARD blocker → 0 + * if any SOFT/HARD blocker → min(baseScore, 20) + * if any gap → min(baseScore, 75) + * else → baseScore + * score = max(0, cappedScore - divergencePenalty) + * + * level = if HARD blocker || status==='BLOCKED' → 'L0' + * if score >= 80 && status==='DECISION_GRADE' → 'L3' + * if score >= 60 → 'L2' + * if score > 0 → 'L1' + * else → 'L0' + * + * band = L2|L3 → 'GREEN', L1 → 'YELLOW', L0 → 'RED' + * + * The "PECOS not found → L1" behavior that the regex authority in + * trustCore previously enforced is preserved here structurally: + * categorizeBlocker tags PECOS messages as SOFT severity, the SOFT cap + * pins baseScore to 20, and 20 > 0 → L1. The "license expired → L0" + * behavior is similarly preserved: LICENSURE strings become HARD blockers, + * HARD severity zeroes the score, and 0 → L0. + */ +import { + type CanonicalSourceCoverage, + type LaunchSpineSourceId, + type ReadinessState, + LAUNCH_SPINE_SOURCE_IDS, +} from '@vitalcv/trust-state'; +import { createHash } from 'node:crypto'; + +// ── Canonical Blocker primitive ────────────────────────────────────────────── + +export type CanonicalBlockerSeverity = 'HARD' | 'SOFT' | 'INFO'; + +export type CanonicalBlockerCategory = + | 'IDENTITY' + | 'LICENSURE' + | 'EXCLUSION' + | 'ENROLLMENT' + | 'DEA' + | 'BOARD_CERT' + | 'OTHER'; + +export interface CanonicalBlocker { + /** Deterministic id derived from (category, severity, message). */ + id: string; + message: string; + severity: CanonicalBlockerSeverity; + category: CanonicalBlockerCategory; + sourceId?: string; + remediation?: string; +} + +// ── Categorizer (backward-compat: string blocker → structured) ─────────────── + +const HARD_LICENSURE_RE = /\b(excluded|license expired|discipline|revoked|suspended)\b/i; +const SOFT_ENROLLMENT_RE = /\b(pecos|enrollment not found)\b/i; +const IDENTITY_RE = /\b(identity|nppes|npi mismatch|name mismatch)\b/i; +const EXCLUSION_RE = /\b(oig|leie|sanction|exclusion)\b/i; +const DEA_RE = /\bdea\b/i; +const BOARD_RE = /\b(board certif|abms|nccpa)\b/i; + +function deterministicId( + category: CanonicalBlockerCategory, + severity: CanonicalBlockerSeverity, + message: string, +): string { + const digest = createHash('sha256') + .update(`${category}|${severity}|${message.trim().toLowerCase()}`) + .digest('hex'); + return `blocker_${digest.slice(0, 16)}`; +} + +export function categorizeBlocker(message: string): CanonicalBlocker | null { + const trimmed = message.trim(); + if (!trimmed) return null; + + // Order matters: HARD patterns first, then SOFT specifics, then domain- + // tagged SOFT defaults, then OTHER fallback. + if (HARD_LICENSURE_RE.test(trimmed)) { + return { id: deterministicId('LICENSURE', 'HARD', trimmed), message: trimmed, severity: 'HARD', category: 'LICENSURE' }; + } + if (EXCLUSION_RE.test(trimmed)) { + return { id: deterministicId('EXCLUSION', 'HARD', trimmed), message: trimmed, severity: 'HARD', category: 'EXCLUSION' }; + } + if (SOFT_ENROLLMENT_RE.test(trimmed)) { + return { id: deterministicId('ENROLLMENT', 'SOFT', trimmed), message: trimmed, severity: 'SOFT', category: 'ENROLLMENT' }; + } + if (IDENTITY_RE.test(trimmed)) { + return { id: deterministicId('IDENTITY', 'SOFT', trimmed), message: trimmed, severity: 'SOFT', category: 'IDENTITY' }; + } + if (DEA_RE.test(trimmed)) { + return { id: deterministicId('DEA', 'SOFT', trimmed), message: trimmed, severity: 'SOFT', category: 'DEA' }; + } + if (BOARD_RE.test(trimmed)) { + return { id: deterministicId('BOARD_CERT', 'SOFT', trimmed), message: trimmed, severity: 'SOFT', category: 'BOARD_CERT' }; + } + return { id: deterministicId('OTHER', 'SOFT', trimmed), message: trimmed, severity: 'SOFT', category: 'OTHER' }; +} + +export function stringsToBlockers(messages: readonly string[]): CanonicalBlocker[] { + const out: CanonicalBlocker[] = []; + for (const m of messages) { + const b = categorizeBlocker(m); + if (b) out.push(b); + } + return out; +} + +export function blockersToStrings(blockers: readonly CanonicalBlocker[]): string[] { + return blockers.map((b) => b.message); +} + +export function dedupeBlockers(blockers: readonly CanonicalBlocker[]): CanonicalBlocker[] { + const seen = new Set(); + const out: CanonicalBlocker[] = []; + for (const b of blockers) { + if (seen.has(b.id)) continue; + seen.add(b.id); + out.push(b); + } + return out; +} + +export function hasHardBlocker(blockers: readonly CanonicalBlocker[]): boolean { + return blockers.some((b) => b.severity === 'HARD'); +} + +export function hasGatingBlocker(blockers: readonly CanonicalBlocker[]): boolean { + return blockers.some((b) => b.severity === 'HARD' || b.severity === 'SOFT'); +} + +// ── Canonical readiness ────────────────────────────────────────────────────── + +export type CanonicalTrustLevel = 'L0' | 'L1' | 'L2' | 'L3'; +export type CanonicalTrustBand = 'GREEN' | 'YELLOW' | 'RED'; + +export interface CanonicalReadinessInput { + sourceCoverage: readonly CanonicalSourceCoverage[]; + blockers: readonly CanonicalBlocker[]; + gaps?: readonly string[]; + divergencePenalty?: number; + readinessStatus: ReadinessState; +} + +export interface CanonicalReadiness { + score: number; + level: CanonicalTrustLevel; + band: CanonicalTrustBand; + status: ReadinessState; + blockers: readonly CanonicalBlocker[]; + /** Legacy string[] projection for back-compat callers. */ + blockerStrings: string[]; + gaps: readonly string[]; + checkedSpineCount: number; +} + +const LAUNCH_SPINE_SET: ReadonlySet = new Set(LAUNCH_SPINE_SOURCE_IDS); + +function countCheckedSpineSources(coverage: readonly CanonicalSourceCoverage[]): number { + let count = 0; + for (const c of coverage) { + if (LAUNCH_SPINE_SET.has(c.sourceId as LaunchSpineSourceId) && c.state === 'checked') { + count += 1; + } + } + return count; +} + +function capByBlockersAndGaps( + baseScore: number, + blockers: readonly CanonicalBlocker[], + hasGap: boolean, +): number { + if (hasHardBlocker(blockers)) return 0; + if (hasGatingBlocker(blockers)) return Math.min(baseScore, 20); + if (hasGap) return Math.min(baseScore, 75); + return baseScore; +} + +export function deriveCanonicalTrustLevel(input: { + score: number; + status: ReadinessState; + blockers: readonly CanonicalBlocker[]; +}): CanonicalTrustLevel { + if (hasHardBlocker(input.blockers) || input.status === 'BLOCKED') return 'L0'; + if (input.score >= 80 && input.status === 'DECISION_GRADE') return 'L3'; + if (input.score >= 60) return 'L2'; + if (input.score > 0) return 'L1'; + return 'L0'; +} + +export function mapLevelToBand(level: CanonicalTrustLevel): CanonicalTrustBand { + if (level === 'L2' || level === 'L3') return 'GREEN'; + if (level === 'L1') return 'YELLOW'; + return 'RED'; +} + +/** + * THE canonical entry point. All other engines route here. + */ +export function derivePassportReadiness(input: CanonicalReadinessInput): CanonicalReadiness { + const gaps = input.gaps ?? []; + const checkedSpineCount = countCheckedSpineSources(input.sourceCoverage); + const baseScore = checkedSpineCount * 25; + const capped = capByBlockersAndGaps(baseScore, input.blockers, gaps.length > 0); + const score = Math.max(0, capped - (input.divergencePenalty ?? 0)); + const level = deriveCanonicalTrustLevel({ score, status: input.readinessStatus, blockers: input.blockers }); + const band = mapLevelToBand(level); + + return { + score, + level, + band, + status: input.readinessStatus, + blockers: input.blockers, + blockerStrings: blockersToStrings(input.blockers), + gaps, + checkedSpineCount, + }; +} diff --git a/apps/api/backend/src/services/readiness/readinessAdapter.ts b/apps/api/backend/src/services/readiness/readinessAdapter.ts new file mode 100644 index 00000000..4be22ea3 --- /dev/null +++ b/apps/api/backend/src/services/readiness/readinessAdapter.ts @@ -0,0 +1,174 @@ +/** + * Readiness translation layer. + * + * Canonical readiness (`./canonical`) is the only place that produces + * (score, level, band, blockers). This file projects those canonical + * outputs into the legacy shapes that existing surfaces expect, so + * downstream consumers can keep working unchanged while the underlying + * truth becomes unified. + * + * Adapter targets (all backward-compatible, no field rename): + * - `toLegacyTrustBand` — public passport public-band string (frozen + * `'GREEN' | 'YELLOW' | 'RED'`). + * - `toLegacyBlockingReasons` — coarse `BlockingReason[]` enum used by + * the YC-MVP TrustState response shape in `packages/trust-state`. + * - `toLegacyStringBlockers` — `string[]` view consumed by every route + * that has not yet migrated to structured blockers. + * - `toPassportReadinessFields` — the four fields + * (`score`, `readiness_score`, `level`, `blockers`) inlined on + * `PassportReadiness` in `passportService.ts`. Lets `buildPassport` + * replace its inline arithmetic with a single canonical call. + * - `toTrustStateEngineReadiness` — the legacy + * `DeterministicTrustReadiness` shape returned by + * `trustCore.computeDeterministicTrustReadiness`. Lets that function + * remove its weighted-confidence math while keeping its public + * return type. + * + * Nothing in this file may compute new score/level/band logic — it only + * RE-SHAPES the canonical output. Any new branching belongs in + * `./canonical`. + */ +import type { + BlockingReason, + CanonicalSourceCoverage, + TrustBand as LegacyPublicTrustBand, +} from '@vitalcv/trust-state'; +import type { + CanonicalBlocker, + CanonicalReadiness, + CanonicalTrustBand, + CanonicalTrustLevel, +} from './canonical'; + +// ── Band projections ───────────────────────────────────────────────────────── + +/** Canonical GREEN/YELLOW/RED is identical to the legacy public TrustBand. */ +export function toLegacyTrustBand(band: CanonicalTrustBand): LegacyPublicTrustBand { + return band; +} + +// ── BlockingReason mapping (category → coarse enum) ────────────────────────── + +const CATEGORY_TO_BLOCKING_REASON: Readonly> = { + IDENTITY: 'IDENTITY_CONFLICT', + LICENSURE: 'EXPIRED_PSV', + EXCLUSION: 'FAILED_VERIFICATION', + ENROLLMENT: 'MISSING_PSV', + DEA: 'MISSING_PSV', + BOARD_CERT: 'MISSING_PSV', + // OTHER has no canonical mapping — surfaced as MISSING_PSV by default + // since we know it's blocking but not which dimension is at fault. + OTHER: 'MISSING_PSV', +}; + +export function toLegacyBlockingReasons(blockers: readonly CanonicalBlocker[]): BlockingReason[] { + const reasons = new Set(); + for (const b of blockers) { + const mapped = CATEGORY_TO_BLOCKING_REASON[b.category]; + if (mapped) reasons.add(mapped); + } + return Array.from(reasons); +} + +// ── String-blocker projection ──────────────────────────────────────────────── + +export function toLegacyStringBlockers(blockers: readonly CanonicalBlocker[]): string[] { + return blockers.map((b) => b.message); +} + +// ── Passport-readiness inline fields ───────────────────────────────────────── + +/** + * Projects canonical readiness into the four fields + * (`score`, `readiness_score`, `level`, `blockers`) inlined on + * `PassportReadiness` in `apps/api/backend/src/services/entity/passportService.ts`. + * + * `readiness_score` is a duplicate of `score` retained for legacy clients + * (e.g. trust summary panel) that read snake_case. + */ +export function toPassportReadinessFields(readiness: CanonicalReadiness): { + score: number; + readiness_score: number; + level: CanonicalTrustLevel; + blockers: string[]; +} { + return { + score: readiness.score, + readiness_score: readiness.score, + level: readiness.level, + blockers: readiness.blockerStrings, + }; +} + +// ── Trust-state-engine adapter (DeterministicTrustReadiness shape) ─────────── + +/** + * The minimal shape returned by `trustCore.computeDeterministicTrustReadiness`. + * Declared here so the adapter doesn't import from `trustCore` (preventing + * an import cycle through this canonical-engine module). + * + * If `trustCore` adds new fields, expand this type and the projection + * below; do NOT add scoring logic here. + */ +export interface LegacyDeterministicReadiness { + overallStatus: 'CLEAR_TO_START' | 'BLOCKED' | 'PENDING_VERIFICATION' | 'MISSING_CREDENTIALS'; + readinessState: CanonicalReadiness['status']; + readinessScore: number; + readiness_score: number; + blockers: string[]; + gaps: string[]; + nextActions: string[]; + confidenceWeighting: { + identity: number; + exclusion: number; + licensure: number; + enrollment: number; + }; + sourceCoverage: CanonicalSourceCoverage[]; +} + +export interface ToTrustStateEngineReadinessInput { + readiness: CanonicalReadiness; + /** Confidence weighting is informational; trustCore previously used it + * to compute the score. We preserve the field but populate from + * passed-in dimension confidence rather than recomputing. */ + confidenceWeighting: LegacyDeterministicReadiness['confidenceWeighting']; + /** Next-action titles (caller computes from `buildReadinessNextActions` + * in `apps/api/backend/src/services/entity/readinessActions.ts`). */ + nextActions: string[]; +} + +export function toTrustStateEngineReadiness( + input: ToTrustStateEngineReadinessInput, +): LegacyDeterministicReadiness { + const { readiness } = input; + return { + overallStatus: deriveOverallStatus(readiness), + readinessState: readiness.status, + readinessScore: readiness.score, + readiness_score: readiness.score, + blockers: readiness.blockerStrings, + gaps: [...readiness.gaps], + nextActions: input.nextActions, + confidenceWeighting: input.confidenceWeighting, + sourceCoverage: [...readiness.blockers].length // placeholder no-op; coverage filled by caller + ? [] + : [], + }; +} + +/** + * Public projection of canonical level → legacy `overallStatus` enum. + * The mapping mirrors what `trustCore` previously computed inline: + * L0 + HARD blocker → 'BLOCKED' + * L0 (no blocker, score 0) → 'MISSING_CREDENTIALS' + * L1 → 'PENDING_VERIFICATION' + * L2 / L3 → 'CLEAR_TO_START' + */ +function deriveOverallStatus(readiness: CanonicalReadiness): LegacyDeterministicReadiness['overallStatus'] { + if (readiness.level === 'L0') { + return readiness.blockers.some((b) => b.severity === 'HARD') ? 'BLOCKED' : 'MISSING_CREDENTIALS'; + } + if (readiness.level === 'L1') return 'PENDING_VERIFICATION'; + return 'CLEAR_TO_START'; +} diff --git a/apps/api/backend/src/services/trust/__tests__/trustCore.test.ts b/apps/api/backend/src/services/trust/__tests__/trustCore.test.ts index d8d75109..3dd1a4a3 100644 --- a/apps/api/backend/src/services/trust/__tests__/trustCore.test.ts +++ b/apps/api/backend/src/services/trust/__tests__/trustCore.test.ts @@ -44,7 +44,11 @@ describe('trust core readiness', () => { expect(readiness.overallStatus).toBe('CLEAR_TO_START'); expect(readiness.readinessState).toBe('DECISION_GRADE'); - expect(readiness.readinessScore).toBe(96); + // Canonical score (trust-convergence migration): 4 launch-spine sources + // checked × 25 = 100, no blocker/gap cap. Previously 96 under the + // weighted-confidence formula in trustCore. Score now matches what + // `derivePassportReadiness` returns for the same coverage. + expect(readiness.readinessScore).toBe(100); expect(readiness.blockers).toEqual([]); expect(readiness.gaps).toEqual([]); expect(readiness.confidenceWeighting.identity).toBe(0.99); @@ -80,7 +84,9 @@ describe('trust core readiness', () => { expect(readiness.overallStatus).toBe('MISSING_CREDENTIALS'); expect(readiness.readinessState).toBe('PARTIAL'); - expect(readiness.readinessScore).toBe(67); + // Canonical: 3 of 4 spine sources checked × 25 = 75; gap-only caps at + // 75 (no-op here). Previously 67 under the weighted formula. + expect(readiness.readinessScore).toBe(75); expect(readiness.blockers).toEqual([]); expect(readiness.gaps).toContain('licensure stale'); }); @@ -117,7 +123,9 @@ describe('trust core readiness', () => { expect(readiness.overallStatus).toBe('MISSING_CREDENTIALS'); expect(readiness.readinessState).toBe('PARTIAL'); - expect(readiness.readinessScore).toBe(48); + // Canonical: 2 of 4 spine sources checked × 25 = 50; gaps present + // (cap at 75) does not lower 50. Previously 48 under weighted formula. + expect(readiness.readinessScore).toBe(50); expect(readiness.nextActions).toContain('Refresh licensure proof'); expect(readiness.sourceCoverage).toEqual( expect.arrayContaining([ diff --git a/apps/api/backend/src/services/trust/trustCore.ts b/apps/api/backend/src/services/trust/trustCore.ts index 3881dadc..b8ec78c9 100644 --- a/apps/api/backend/src/services/trust/trustCore.ts +++ b/apps/api/backend/src/services/trust/trustCore.ts @@ -10,6 +10,13 @@ import { type CanonicalSourceCoverageState, type ReadinessState, } from '@vitalcv/trust-state'; +import { + derivePassportReadiness, + deriveCanonicalTrustLevel, + stringsToBlockers, + categorizeBlocker, + type CanonicalBlocker, +} from '../readiness/canonical'; export type SourceCoverageState = CanonicalSourceCoverageState; @@ -203,6 +210,12 @@ function hardBlockDimensions( return blocked; } +/** + * @deprecated Legacy weighted-confidence contribution. Score derivation now + * runs through `derivePassportReadiness` in `../readiness/canonical`. Kept + * only so external consumers that imported the symbol don't break at + * compile time; the value is no longer summed into the readiness score. + */ function dimensionContribution( dimension: TrustDimensionAssessment, weight: number, @@ -213,6 +226,7 @@ function dimensionContribution( return weight * clampConfidence(dimension.confidence); } +void dimensionContribution; // retained for type-export compatibility export function computeDeterministicTrustReadiness(input: { identity: TrustDimensionAssessment; @@ -245,17 +259,44 @@ export function computeDeterministicTrustReadiness(input: { enrollment: clampConfidence(dimensions.enrollment.confidence), }; - const readinessScore = Math.round( - dimensionContribution(dimensions.identity, DIMENSION_WEIGHTS.identity) - + dimensionContribution(dimensions.exclusion, DIMENSION_WEIGHTS.exclusion) - + dimensionContribution(dimensions.licensure, DIMENSION_WEIGHTS.licensure) - + dimensionContribution(dimensions.enrollment, DIMENSION_WEIGHTS.enrollment), - ); - const hardBlocks = hardBlockDimensions(dimensions); const reviewRequired = Object.values(dimensions).some((dimension) => dimension.status === 'REVIEW_REQUIRED'); const unmet = Object.values(dimensions).some((dimension) => dimension.status === 'UNMET'); + const readinessState = deriveReadinessState(sourceCoverage); + + // ── Canonical readiness derivation ────────────────────────────────────── + // All score/level math runs through `derivePassportReadiness`. This + // function no longer computes a weighted-confidence score independently; + // the dimension confidence is preserved on `confidenceWeighting` for + // informational use only. + // + // String blockers from dimensions are categorized into structured + // CanonicalBlocker values via `stringsToBlockers`. A HARD-blocked + // dimension (`status: 'BLOCKED'`) without a blocker message still needs + // to force L0 — we synthesize a HARD blocker for that case so canonical + // sees the severity. + const dedupedBlockerStrings = Array.from(new Set(blockers)); + const canonicalBlockers: CanonicalBlocker[] = stringsToBlockers(dedupedBlockerStrings); + if (hardBlocks.size > 0 && !canonicalBlockers.some((b) => b.severity === 'HARD')) { + const synthesized = categorizeBlocker(`${Array.from(hardBlocks).join(', ').toUpperCase()} dimension blocked`); + if (synthesized) { + // Force HARD severity regardless of categorizer output. + canonicalBlockers.push({ ...synthesized, severity: 'HARD' }); + } + } + + const canonical = derivePassportReadiness({ + sourceCoverage, + blockers: canonicalBlockers, + gaps: Array.from(new Set(gaps)), + readinessStatus: readinessState, + }); + const readinessScore = canonical.score; + + // overallStatus retains its legacy semantics (BLOCKED / PENDING / MISSING + // / CLEAR_TO_START) for downstream consumers — these enum values do not + // appear on the public passport response and are stable surface area. const overallStatus: DeterministicTrustReadiness['overallStatus'] = hardBlocks.size > 0 ? 'BLOCKED' @@ -282,14 +323,12 @@ export function computeDeterministicTrustReadiness(input: { gaps, }).map((action) => action.title); - const readinessState = deriveReadinessState(sourceCoverage); - return { overallStatus, readinessState, readinessScore, readiness_score: readinessScore, - blockers: Array.from(new Set(blockers)), + blockers: dedupedBlockerStrings, gaps: Array.from(new Set(gaps)), nextActions, confidenceWeighting, @@ -297,6 +336,24 @@ export function computeDeterministicTrustReadiness(input: { }; } +/** + * Trust-band derivation routes through canonical level logic. + * + * Behavior: + * - `identityMet=false` is an independent hard pre-check; if identity + * isn't met we return L0 regardless of score/blockers. This preserves + * the legacy invariant that an unverified identity can never produce + * a non-L0 band. + * - All blocker → level logic flows through `deriveCanonicalTrustLevel` + * in `../readiness/canonical`. The regex authority that previously + * lived here has moved to `categorizeBlocker`, where it produces + * structured (category, severity) values that the canonical engine + * consumes. Hard-licensure / OIG matches become HARD severity → L0; + * PECOS / enrollment matches become SOFT severity → score-capped → L1. + * - `reviewRequired` or `overallStatus === 'PENDING_VERIFICATION'` + * promotes to at least L1 (preserves legacy behavior where a clinician + * in review never appears as L0 when there are no blockers). + */ export function deriveTrustBandFromReadiness(input: { readiness: DeterministicTrustReadiness; identityMet: boolean; @@ -305,23 +362,23 @@ export function deriveTrustBandFromReadiness(input: { if (!input.identityMet) { return 'L0'; } - if (input.readiness.blockers.some((blocker) => /excluded|license expired|discipline|revoked|suspended/i.test(blocker))) { - return 'L0'; - } - if (input.readiness.blockers.some((blocker) => /pecos|enrollment not found/i.test(blocker))) { - return 'L1'; - } - if (input.reviewRequired || input.readiness.overallStatus === 'PENDING_VERIFICATION') { - return 'L1'; - } - if (input.readiness.readinessScore >= 90 && input.readiness.overallStatus === 'CLEAR_TO_START') { - return 'L3'; - } - if (input.readiness.readinessScore >= 60) { - return 'L2'; - } - if (input.readiness.readinessScore >= 20) { + + const canonicalBlockers = stringsToBlockers(input.readiness.blockers); + const canonicalLevel = deriveCanonicalTrustLevel({ + score: input.readiness.readinessScore, + status: input.readiness.readinessState, + blockers: canonicalBlockers, + }); + + // PENDING_VERIFICATION / reviewRequired floor at L1 — overrides L0 from + // a zero score when the clinician is actively in review. + if ( + canonicalLevel === 'L0' + && (input.reviewRequired || input.readiness.overallStatus === 'PENDING_VERIFICATION') + && !canonicalBlockers.some((b) => b.severity === 'HARD') + ) { return 'L1'; } - return 'L0'; + + return canonicalLevel; }