Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 30 additions & 31 deletions apps/api/backend/src/services/entity/passportService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -330,23 +335,21 @@ const ESTIMATED_START_DAYS: Record<string, number> = {
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[] {
Expand Down Expand Up @@ -2208,24 +2211,20 @@ export async function buildPassport(entityId: string): Promise<TrustPassport | n
const normalizedBlockers = dedupeStrings(blockers).filter(isReadinessBlockingFinding);
const normalizedGaps = dedupeStrings(gaps);

// Derive readiness from actual source coverage — not from blocker/gap string lists.
// Canonical readiness derivation. ALL score/level/band math routes
// through `derivePassportReadiness` in ../readiness/canonical. This
// function (`buildPassport`) is the canonical caller; trustCore and
// trustStateEngine route their derivations through the same engine
// via the same module.
const readinessStatus: ReadinessState = deriveReadinessState(sourceCoverage.checks);
// Derive readiness score from source coverage rather than hardcoding.
// Each checked launch-spine source contributes 25 points (4 sources × 25 = 100 max).
// Non-checked sources contribute 0. This ensures the score reflects real source state.
const spineChecks = sourceCoverage.checks.filter(
(check) => 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.
Expand All @@ -2243,7 +2242,7 @@ export async function buildPassport(entityId: string): Promise<TrustPassport | n
status: readinessStatus,
score: readinessScore,
readiness_score: readinessScore,
level: derivePassportReadinessLevel(readinessScore, readinessStatus),
level: canonicalReadiness.level,
blockers: normalizedBlockers,
gaps: normalizedGaps,
nextActions,
Expand Down
232 changes: 232 additions & 0 deletions apps/api/backend/src/services/readiness/__tests__/canonical.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import {
categorizeBlocker,
blockersToStrings,
derivePassportReadiness,
deriveCanonicalTrustLevel,
dedupeBlockers,
hasGatingBlocker,
hasHardBlocker,
mapLevelToBand,
stringsToBlockers,
type CanonicalBlocker,
} from '../canonical';
import {
createCanonicalSourceCoverage,
type CanonicalSourceCoverage,
} from '@vitalcv/trust-state';

const ALL_FOUR_CHECKED: CanonicalSourceCoverage[] = [
createCanonicalSourceCoverage({ sourceId: 'NPPES_API', state: 'checked', reason: 'ok', checkedAt: '2026-01-01T00:00:00Z' }),
createCanonicalSourceCoverage({ sourceId: 'OIG_LEIE', state: 'checked', reason: 'ok', checkedAt: '2026-01-01T00:00:00Z' }),
createCanonicalSourceCoverage({ sourceId: 'PECOS_PUBLIC', state: 'checked', reason: 'ok', checkedAt: '2026-01-01T00:00:00Z' }),
createCanonicalSourceCoverage({ sourceId: 'STATE_BOARD', state: 'checked', reason: 'ok', checkedAt: '2026-01-01T00:00:00Z' }),
];

const TWO_CHECKED_TWO_PENDING: CanonicalSourceCoverage[] = [
createCanonicalSourceCoverage({ sourceId: 'NPPES_API', state: 'checked', reason: 'ok', checkedAt: '2026-01-01T00:00:00Z' }),
createCanonicalSourceCoverage({ sourceId: 'OIG_LEIE', state: 'checked', reason: 'ok', checkedAt: '2026-01-01T00:00:00Z' }),
createCanonicalSourceCoverage({ sourceId: 'PECOS_PUBLIC', state: 'pending', reason: 'not yet checked' }),
createCanonicalSourceCoverage({ sourceId: 'STATE_BOARD', state: 'pending', reason: 'not yet checked' }),
];

const HARD_LICENSE: CanonicalBlocker = categorizeBlocker('License expired in NC')!;
const SOFT_PECOS: CanonicalBlocker = categorizeBlocker('PECOS enrollment not found')!;

describe('categorizeBlocker', () => {
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]);
});
});
Loading
Loading