From 64ca48cf0556ba94ec3bac2206ce169153d1c445 Mon Sep 17 00:00:00 2001 From: Sanjay Udoshi Date: Mon, 27 Jul 2026 19:32:07 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(flow4d):=20Phase=20D=20=E2=80=94=20gov?= =?UTF-8?q?erned=20DRG=20catalog=20=E2=86=92=20conformance=20bridge=20(shi?= =?UTF-8?q?ps=20dark)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D1 ReferenceModelCompiler: pure declarative compile (ordered activities, timing targets, data-driven deviation labels) + content-address digest; thin DB adapter returns null for a version with no persisted executable layer (every version in the current inactive release). D2 milestone↔OCEL activity map + the `care-pathways:ocel-coverage` gap report. D4 PathwayInstanceReadService: flag+schema-guarded, append-only reads via the current-status views. D5 synthetic demo overlay — curated HF milestones → the SAME pure compiler → LOS-driven progress in the journey drawer, gated only by care-pathways.demo; new flow4d.pathway_progress serving flag composes with the assignment gate (both off by default). D3 CPG-on-FHIR-vs-bespoke decision memo. Serving stays dark (plan G-9): nothing per-patient from the DRG catalog reaches a screen without [SU]/clinical sign-off; the journey response is byte-identical when both flags are off. No migration. 25 new PHP tests + 6 JS tests, all green (compiler determinism/digest pin, coverage report, read-service inertness + append-only untouched, demo synthesis, journey inertness + demo attach, drawer render + schema tolerance). --- .env.example | 1 + .../CarePathwaysOcelCoverageCommand.php | 66 ++++ app/Http/Middleware/HandleInertiaRequests.php | 10 + .../CarePathways/FlowPathwayDemoService.php | 119 ++++++++ .../OcelActivityCoverageService.php | 97 ++++++ .../PathwayInstanceReadService.php | 220 +++++++++++++ .../CarePathways/ReferenceModelCompiler.php | 288 ++++++++++++++++++ .../PatientFlow/PatientJourneyService.php | 61 +++- config/care-pathways-ocel-map.php | 44 +++ config/services.php | 9 + ...D3-CPG-ON-FHIR-DECISION-MEMO-2026-07-27.md | 91 ++++++ .../NavigatorJourneyDrawer.tsx | 82 ++++- .../PatientFlowNavigator.css | 86 ++++++ .../PatientFlowNavigator.tsx | 16 + .../patientFlowNavigator/journeySchemas.ts | 48 +++ resources/js/types/index.ts | 6 + .../FlowPathwayDemoServiceTest.php | 63 ++++ .../CarePathways/OcelCoverageCommandTest.php | 104 +++++++ .../PathwayInstanceReadServiceTest.php | 178 +++++++++++ .../ReferenceModelCompilerVersionTest.php | 103 +++++++ .../PatientJourneyEndpointTest.php | 46 +++ .../ReferenceModelCompilerTest.php | 157 ++++++++++ .../NavigatorJourneyDrawer.test.tsx | 54 ++++ tests/js/patientFlow/journey.test.ts | 24 ++ 24 files changed, 1971 insertions(+), 2 deletions(-) create mode 100644 app/Console/Commands/CarePathwaysOcelCoverageCommand.php create mode 100644 app/Services/CarePathways/FlowPathwayDemoService.php create mode 100644 app/Services/CarePathways/OcelActivityCoverageService.php create mode 100644 app/Services/CarePathways/PathwayInstanceReadService.php create mode 100644 app/Services/CarePathways/ReferenceModelCompiler.php create mode 100644 config/care-pathways-ocel-map.php create mode 100644 docs/plans/FLOW-4D-D3-CPG-ON-FHIR-DECISION-MEMO-2026-07-27.md create mode 100644 tests/Feature/CarePathways/FlowPathwayDemoServiceTest.php create mode 100644 tests/Feature/CarePathways/OcelCoverageCommandTest.php create mode 100644 tests/Feature/CarePathways/PathwayInstanceReadServiceTest.php create mode 100644 tests/Feature/CarePathways/ReferenceModelCompilerVersionTest.php create mode 100644 tests/Unit/CarePathways/ReferenceModelCompilerTest.php diff --git a/.env.example b/.env.example index 9b497b21..0eed2881 100644 --- a/.env.example +++ b/.env.example @@ -278,6 +278,7 @@ EDDY_CALLBACK_TOKEN= # bearer Eddy uses on non-user telem ARENA_ENABLED=false ARENA_BASE_URL=http://arena:8100 # dev (compose); prod=http://127.0.0.1:8100 (systemd/uvicorn) or the arena container FLOW4D_CONFORMANCE_ENABLED=false # 4D Navigator per-patient adherence surface (composes with ARENA_ENABLED; [SU] owns prod enablement) +FLOW4D_PATHWAY_PROGRESS_ENABLED=false # 4D Navigator governed-pathway progress in the journey drawer (composes with CARE_PATHWAYS_ASSIGNMENT_ENABLED; DRG serving stays dark until [SU]/clinical sign-off — plan G-9). Demo overlay is separate (CARE_PATHWAYS_DEMO_ENABLED). # Governed enterprise integration runtime. Keep the outbound allowlist exact; # private key material stays outside the repository under the configured root. diff --git a/app/Console/Commands/CarePathwaysOcelCoverageCommand.php b/app/Console/Commands/CarePathwaysOcelCoverageCommand.php new file mode 100644 index 00000000..d0f4dd9d --- /dev/null +++ b/app/Console/Commands/CarePathwaysOcelCoverageCommand.php @@ -0,0 +1,66 @@ +report(); + + if ($this->option('json')) { + $this->line((string) json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + + return self::SUCCESS; + } + + $totals = $report['totals']; + + if ($totals['executable_versions'] === 0) { + $this->info('No governed version has a persisted executable milestone layer yet.'); + $this->line('The DRG catalog release is inactive by design; draft an executable layer with'); + $this->line(' php artisan care-pathways:draft-executable-layer'); + $this->line('then re-run this command to see the OCEL mapping gap.'); + + return self::SUCCESS; + } + + $this->table( + ['Pathway', 'Version', 'Activities', 'Mapped', 'Unmapped', 'Coverage'], + array_map(static fn (array $p): array => [ + $p['pathway_key'], + substr((string) $p['version_uuid'], 0, 8), + (string) $p['activities'], + (string) $p['mapped'], + (string) count($p['unmapped']), + $p['coverage'] === null ? '—' : number_format((float) $p['coverage'] * 100, 1).'%', + ], $report['pathways']), + ); + + $this->line(sprintf( + 'Coverage: %d of %d milestone activities map to an OCEL activity (%s).', + $totals['mapped'], + $totals['activities'], + $totals['coverage'] === null ? 'n/a' : number_format((float) $totals['coverage'] * 100, 1).'%', + )); + + foreach ($report['pathways'] as $pathway) { + if ($pathway['unmapped'] !== []) { + $this->warn(sprintf('%s — unmapped: %s', $pathway['pathway_key'], implode(', ', $pathway['unmapped']))); + } + if ($pathway['unknown_targets'] !== []) { + $this->error(sprintf('%s — target not in OCEL vocabulary: %s', $pathway['pathway_key'], implode(', ', $pathway['unknown_targets']))); + } + } + + return self::SUCCESS; + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 308adaa6..d02c3fa6 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -98,6 +98,16 @@ public function share(Request $request): array 'conformance_enabled' => (bool) config('services.arena.enabled') && (bool) config('services.flow4d.conformance'), ], + // FLOW-4D governed-pathway progress surface (plan §8 Phase D). Pre- + // composed with the care-pathways serving gate so the drawer holds + // ONE boolean and can never render a patient's real assigned-pathway + // progress while the read service would return nothing. The DRG + // catalog serving path stays dark (plan G-9); the synthetic demo + // overlay is separate and rides features.care_pathways_demo. + 'flow4d' => [ + 'pathway_progress_enabled' => (bool) config('services.flow4d.pathway_progress') + && (bool) config('care-pathways.assignment_enabled'), + ], // Feature flags the frontend reads to gate nav + surfaces. Virtual // Rounds ships disabled; the nav item stays hidden (never a dead // link) until VIRTUAL_ROUNDS_ENABLED is on — matching the server diff --git a/app/Services/CarePathways/FlowPathwayDemoService.php b/app/Services/CarePathways/FlowPathwayDemoService.php new file mode 100644 index 00000000..2ce592a6 --- /dev/null +++ b/app/Services/CarePathways/FlowPathwayDemoService.php @@ -0,0 +1,119 @@ + + */ + private const MILESTONES = [ + ['stable_key' => 'hf_arrival', 'title' => 'Admitted and assessed', 'phase' => 'arrival', 'sequence' => 0, 'expected_range' => ['day_offset_min' => 0, 'day_offset_max' => 0]], + ['stable_key' => 'hf_diuresis', 'title' => 'Diuresis started, daily weights', 'phase' => 'day_1', 'sequence' => 1, 'expected_range' => ['day_offset_min' => 0, 'day_offset_max' => 1]], + ['stable_key' => 'hf_echo', 'title' => 'Echocardiogram reviewed', 'phase' => 'day_1', 'sequence' => 2, 'expected_range' => ['day_offset_min' => 1, 'day_offset_max' => 1]], + ['stable_key' => 'hf_gdmt', 'title' => 'Guideline-directed medical therapy optimized', 'phase' => 'day_2', 'sequence' => 3, 'expected_range' => ['day_offset_min' => 1, 'day_offset_max' => 2]], + ['stable_key' => 'hf_education', 'title' => 'Self-care education and cardiac rehab referral', 'phase' => 'day_3', 'sequence' => 4, 'expected_range' => ['day_offset_min' => 2, 'day_offset_max' => 3]], + ['stable_key' => 'hf_discharge', 'title' => 'Discharge readiness and follow-up scheduled', 'phase' => 'discharge', 'sequence' => 5, 'expected_range' => ['day_offset_min' => 3, 'day_offset_max' => 4]], + ]; + + public function __construct( + private readonly ReferenceModelCompiler $compiler, + private readonly PathwayInstanceReadService $reader, + ) {} + + public function enabled(): bool + { + return (bool) config('care-pathways.demo.enabled'); + } + + /** + * Project a synthetic, length-of-stay-driven pathway progress DTO. Null when + * the demo flag is off. Pure aside from the config read + wall clock. + * + * @return array|null + */ + public function syntheticProgress(int $losDays, ?CarbonImmutable $now = null): ?array + { + if (! $this->enabled()) { + return null; + } + + $model = $this->compiler->compile([ + 'pathway_key' => 'demo-heart-failure', + 'label' => 'Heart Failure — Admission to Supported Transition', + 'semantic_version' => 'demo.1', + 'service_line_code' => 'Cardiology', + ], self::MILESTONES); + + if ($model === null) { + return null; + } + + $currentDay = max(0, $losDays); + $milestones = array_map( + fn (array $m): array => [ + 'stable_key' => $m['stable_key'], + 'title' => $m['title'], + 'phase' => $m['phase'], + 'sequence' => $m['sequence'], + 'status' => $this->statusForDay($m['day_offset_max'] ?? $m['day_offset_min'], $currentDay), + 'observed_at' => null, + 'expected' => [ + 'day_offset_min' => $m['day_offset_min'], + 'day_offset_max' => $m['day_offset_max'], + 'display' => $m['display'], + ], + ], + $model['milestones'], + ); + + return $this->reader->assemble( + demo: true, + notice: 'Demo — not clinical guidance', + pathway: [ + 'key' => $model['pathway_key'], + 'label' => $model['label'], + 'version_uuid' => null, + 'semantic_version' => $model['semantic_version'], + 'digest' => $model['digest'], + ], + milestones: $milestones, + source: 'synthetic_demo', + asOf: ($now ?? CarbonImmutable::now())->toJSON(), + ); + } + + private function statusForDay(?int $day, int $currentDay): string + { + return match (true) { + $day === null => 'planned', + $day < $currentDay => 'completed', + $day === $currentDay => 'current', + default => 'planned', + }; + } +} diff --git a/app/Services/CarePathways/OcelActivityCoverageService.php b/app/Services/CarePathways/OcelActivityCoverageService.php new file mode 100644 index 00000000..d2a7c6a1 --- /dev/null +++ b/app/Services/CarePathways/OcelActivityCoverageService.php @@ -0,0 +1,97 @@ +>, + * totals: array{executable_versions:int, activities:int, mapped:int, unmapped:int, unknown_targets:int, coverage:?float} + * } + */ + public function report(): array + { + /** @var array $map */ + $map = (array) config('care-pathways-ocel-map.activities', []); + $vocabulary = array_flip((array) config('care-pathways-ocel-map.ocel_vocabulary', [])); + + $versionIds = DB::table('care_pathways.milestone_definitions') + ->distinct() + ->orderBy('pathway_version_id') + ->pluck('pathway_version_id'); + + $pathways = []; + $totalActivities = 0; + $totalMapped = 0; + $totalUnknown = 0; + + foreach ($versionIds as $versionId) { + $model = $this->compiler->compileVersion((int) $versionId); + if ($model === null) { + continue; + } + + $unmapped = []; + $unknownTargets = []; + $mapped = 0; + + foreach ($model['activities'] as $activity) { + $target = $map[$activity] ?? null; + if ($target === null || $target === '') { + $unmapped[] = $activity; + + continue; + } + $mapped++; + if (! isset($vocabulary[$target])) { + $unknownTargets[] = $activity.' → '.$target; + } + } + + $count = count($model['activities']); + $totalActivities += $count; + $totalMapped += $mapped; + $totalUnknown += count($unknownTargets); + + $pathways[] = [ + 'pathway_key' => $model['pathway_key'], + 'label' => $model['label'], + 'version_uuid' => $model['source']['pathway_version_uuid'], + 'activities' => $count, + 'mapped' => $mapped, + 'unmapped' => $unmapped, + 'unknown_targets' => $unknownTargets, + 'coverage' => $count > 0 ? round($mapped / $count, 4) : null, + ]; + } + + return [ + 'pathways' => $pathways, + 'totals' => [ + 'executable_versions' => count($pathways), + 'activities' => $totalActivities, + 'mapped' => $totalMapped, + 'unmapped' => $totalActivities - $totalMapped, + 'unknown_targets' => $totalUnknown, + 'coverage' => $totalActivities > 0 ? round($totalMapped / $totalActivities, 4) : null, + ], + ]; + } +} diff --git a/app/Services/CarePathways/PathwayInstanceReadService.php b/app/Services/CarePathways/PathwayInstanceReadService.php new file mode 100644 index 00000000..a0f2c683 --- /dev/null +++ b/app/Services/CarePathways/PathwayInstanceReadService.php @@ -0,0 +1,220 @@ +|null + */ + public function progressForEncounterId(?int $sourceEncounterId): ?array + { + if ($sourceEncounterId === null || ! $this->available()) { + return null; + } + + $accessGrantId = DB::table('patient_experience.encounter_access_grants') + ->where('source_encounter_id', $sourceEncounterId) + ->where('status', 'active') + ->orderByDesc('access_grant_id') + ->value('access_grant_id'); + + return $accessGrantId === null ? null : $this->progressForAccessGrant((int) $accessGrantId); + } + + /** + * Project the latest assigned-pathway progress for one encounter access + * grant. Read-only; append-only-respecting. + * + * @return array|null + */ + public function progressForAccessGrant(int $accessGrantId): ?array + { + if (! $this->available()) { + return null; + } + + $instance = DB::table('patient_experience.pathway_instances') + ->where('access_grant_id', $accessGrantId) + ->orderByDesc('instantiated_at') + ->orderByDesc('pathway_instance_id') + ->first(); + + if ($instance === null) { + return null; + } + + $version = DB::table('care_pathways.versions as versions') + ->join('care_pathways.definitions as definitions', 'definitions.pathway_definition_id', '=', 'versions.pathway_definition_id') + ->where('versions.pathway_version_id', $instance->pathway_version_id) + ->first([ + 'versions.pathway_version_uuid', + 'versions.semantic_version', + 'definitions.pathway_key', + 'definitions.canonical_name', + ]); + + if ($version === null) { + return null; + } + + $milestones = DB::table('patient_experience.pathway_milestone_instances as mi') + ->join('care_pathways.milestone_definitions as md', 'md.milestone_definition_id', '=', 'mi.milestone_definition_id') + ->leftJoin('patient_experience.current_pathway_milestone_statuses as cs', 'cs.pathway_milestone_instance_id', '=', 'mi.pathway_milestone_instance_id') + ->where('mi.pathway_instance_id', $instance->pathway_instance_id) + ->orderBy('md.sequence') + ->orderBy('md.stable_key') + ->get([ + 'md.stable_key', + 'md.title', + 'md.phase', + 'md.sequence', + 'md.expected_range', + 'cs.status', + 'cs.source_observed_at', + ]) + ->map(fn (object $row): array => [ + 'stable_key' => (string) $row->stable_key, + 'title' => (string) ($row->title ?? ''), + 'phase' => $row->phase !== null ? (string) $row->phase : null, + 'sequence' => $row->sequence !== null ? (int) $row->sequence : null, + 'status' => $row->status !== null ? (string) $row->status : 'planned', + 'observed_at' => $this->iso($row->source_observed_at), + 'expected' => $this->expected($row->expected_range), + ]) + ->all(); + + return $this->assemble( + demo: false, + notice: null, + pathway: [ + 'key' => (string) $version->pathway_key, + 'label' => (string) $version->canonical_name, + 'version_uuid' => (string) $version->pathway_version_uuid, + 'semantic_version' => (string) $version->semantic_version, + ], + milestones: $milestones, + source: 'assigned_instance', + asOf: $this->iso($instance->recorded_at) ?? CarbonImmutable::now()->toJSON(), + ); + } + + /** + * Assemble the shared progress DTO from an ordered milestone list. Reused by + * the demo overlay (D5) so the drawer renders real and synthetic identically. + * + * @param array $pathway + * @param list> $milestones + * @return array + */ + public function assemble(bool $demo, ?string $notice, array $pathway, array $milestones, string $source, string $asOf): array + { + $counts = ['completed' => 0, 'current' => 0, 'planned' => 0, 'delayed' => 0, 'canceled' => 0]; + $currentStableKey = null; + + foreach ($milestones as $m) { + $status = (string) ($m['status'] ?? 'planned'); + if (array_key_exists($status, $counts)) { + $counts[$status]++; + } + if ($currentStableKey === null && $status === 'current') { + $currentStableKey = (string) $m['stable_key']; + } + } + + $total = count($milestones); + + return [ + 'state' => 'ok', + 'demo' => $demo, + // Phase D serves nothing clinical: real progress is dark behind the + // serving gate, and the demo is explicitly synthetic. This is always + // false — the drawer must never present pathway state as guidance. + 'clinical_use' => false, + 'notice' => $notice, + 'pathway' => $pathway, + 'milestones' => $milestones, + 'summary' => $counts + [ + 'total' => $total, + 'current_stable_key' => $currentStableKey, + // ERAS "elements met" framing, matching the adherence panel. + 'elements_met_label' => $counts['completed'].' of '.$total.' milestones complete', + ], + 'source' => $source, + 'as_of' => $asOf, + ]; + } + + /** + * Decode a milestone's flexible expected_range into a stable timing shape. + * + * @return array{day_offset_min:?int, day_offset_max:?int, display:?string} + */ + private function expected(mixed $expectedRange): array + { + $range = []; + if (is_array($expectedRange)) { + $range = $expectedRange; + } elseif (is_string($expectedRange) && $expectedRange !== '') { + try { + $decoded = json_decode($expectedRange, true, 512, JSON_THROW_ON_ERROR); + $range = is_array($decoded) ? $decoded : []; + } catch (Throwable) { + $range = []; + } + } + + return [ + 'day_offset_min' => isset($range['day_offset_min']) && is_numeric($range['day_offset_min']) ? (int) $range['day_offset_min'] : null, + 'day_offset_max' => isset($range['day_offset_max']) && is_numeric($range['day_offset_max']) ? (int) $range['day_offset_max'] : null, + 'display' => isset($range['display']) && $range['display'] !== '' ? (string) $range['display'] : null, + ]; + } + + private function iso(mixed $value): ?string + { + if ($value === null || $value === '') { + return null; + } + try { + return CarbonImmutable::parse((string) $value)->toJSON(); + } catch (Throwable) { + return null; + } + } +} diff --git a/app/Services/CarePathways/ReferenceModelCompiler.php b/app/Services/CarePathways/ReferenceModelCompiler.php new file mode 100644 index 00000000..a4968eb7 --- /dev/null +++ b/app/Services/CarePathways/ReferenceModelCompiler.php @@ -0,0 +1,288 @@ + $meta pathway_key, label, semantic_version, service_line_code, care_type, source_service_line, and (adapter-only) *_uuid / grouper_version + * @param iterable> $milestones each: stable_key, title, phase, sequence, expected_range + * @return array|null null when the version has no milestones (no executable layer to compile) + */ + public function compile(array $meta, iterable $milestones): ?array + { + $ordered = $this->orderMilestones($milestones); + if ($ordered === []) { + return null; + } + + $pathwayKey = (string) ($meta['pathway_key'] ?? ''); + $label = (string) ($meta['label'] ?? $pathwayKey); + $semanticVersion = isset($meta['semantic_version']) ? (string) $meta['semantic_version'] : null; + + $activities = array_map(static fn (array $m): string => $m['stable_key'], $ordered); + $timingTargets = []; + $deviationLabels = []; + $milestonesOut = []; + + foreach ($ordered as $m) { + $key = $m['stable_key']; + $title = $m['title'] !== '' ? $m['title'] : $this->humanize($key); + [$min, $max, $display] = $m['timing']; + + if ($min !== null || $max !== null || $display !== null) { + $timingTargets[] = [ + 'activity' => $key, + 'day_offset_min' => $min, + 'day_offset_max' => $max, + 'display' => $display, + ]; + } + + // Data-driven deviation vocabulary: a "not yet observed" code for + // every milestone, and a "late" code wherever a numeric day ceiling + // exists. Codes are stable (keyed on the milestone stable_key). + $deviationLabels[$key.'_missing'] = $title.' not yet observed'; + if ($max !== null) { + $deviationLabels[$key.'_late'] = $title.' beyond day '.$max; + } + + $milestonesOut[] = [ + 'stable_key' => $key, + 'title' => $title, + 'phase' => $m['phase'], + 'sequence' => $m['sequence'], + 'day_offset_min' => $min, + 'day_offset_max' => $max, + 'display' => $display, + ]; + } + + $body = [ + 'pathway_key' => $pathwayKey, + 'label' => $label, + 'version' => $this->majorVersion($semanticVersion), + 'semantic_version' => $semanticVersion, + 'owner' => $this->owner($meta), + 'case_type' => self::CASE_TYPE, + 'trigger' => $activities[0], + 'activities' => $activities, + 'timing_targets' => $timingTargets, + 'deviation_labels' => $deviationLabels, + ]; + + return $body + [ + 'milestones' => $milestonesOut, + 'source' => [ + 'pathway_version_uuid' => isset($meta['pathway_version_uuid']) ? (string) $meta['pathway_version_uuid'] : null, + 'semantic_version' => $semanticVersion, + 'catalog_release_uuid' => isset($meta['catalog_release_uuid']) ? (string) $meta['catalog_release_uuid'] : null, + 'grouper_version' => isset($meta['grouper_version']) ? (string) $meta['grouper_version'] : null, + ], + 'digest' => $this->digest($body), + ]; + } + + /** + * Load a governed version's definition metadata + persisted milestone layer + * and compile it. Read-only; never writes. Returns null when the version is + * unknown or has no milestone_definitions rows. + * + * @return array|null + */ + public function compileVersion(int $pathwayVersionId): ?array + { + $version = DB::table('care_pathways.versions as versions') + ->join('care_pathways.definitions as definitions', 'definitions.pathway_definition_id', '=', 'versions.pathway_definition_id') + ->join('care_pathways.catalog_releases as releases', 'releases.catalog_release_id', '=', 'versions.catalog_release_id') + ->where('versions.pathway_version_id', $pathwayVersionId) + ->select([ + 'versions.pathway_version_uuid', + 'versions.semantic_version', + 'definitions.pathway_key', + 'definitions.canonical_name', + 'definitions.care_type', + 'definitions.source_service_line', + 'definitions.service_line_code', + 'releases.catalog_release_uuid', + 'releases.grouper_version', + ]) + ->first(); + + if ($version === null) { + return null; + } + + $milestones = DB::table('care_pathways.milestone_definitions') + ->where('pathway_version_id', $pathwayVersionId) + ->orderBy('sequence') + ->orderBy('stable_key') + ->get(['stable_key', 'title', 'phase', 'sequence', 'expected_range']) + ->map(static fn (object $row): array => [ + 'stable_key' => (string) $row->stable_key, + 'title' => (string) ($row->title ?? ''), + 'phase' => $row->phase !== null ? (string) $row->phase : null, + 'sequence' => $row->sequence !== null ? (int) $row->sequence : null, + 'expected_range' => self::decodeJsonObject($row->expected_range), + ]) + ->all(); + + return $this->compile([ + 'pathway_key' => (string) $version->pathway_key, + 'label' => (string) $version->canonical_name, + 'semantic_version' => (string) $version->semantic_version, + 'care_type' => $version->care_type, + 'source_service_line' => $version->source_service_line, + 'service_line_code' => $version->service_line_code, + 'pathway_version_uuid' => (string) $version->pathway_version_uuid, + 'catalog_release_uuid' => (string) $version->catalog_release_uuid, + 'grouper_version' => (string) $version->grouper_version, + ], $milestones); + } + + /** + * Normalize + deterministically order the milestone rows: sequence ascending + * with nulls last, then stable_key ascending as a stable tiebreak. + * + * @param iterable> $milestones + * @return list + */ + private function orderMilestones(iterable $milestones): array + { + $rows = []; + foreach ($milestones as $m) { + $key = trim((string) ($m['stable_key'] ?? '')); + if ($key === '') { + continue; + } + $rows[] = [ + 'stable_key' => $key, + 'title' => trim((string) ($m['title'] ?? '')), + 'phase' => isset($m['phase']) && $m['phase'] !== '' ? (string) $m['phase'] : null, + 'sequence' => isset($m['sequence']) && $m['sequence'] !== null ? (int) $m['sequence'] : null, + 'timing' => $this->timing($m['expected_range'] ?? []), + ]; + } + + usort($rows, static function (array $a, array $b): int { + $sa = $a['sequence'] ?? PHP_INT_MAX; + $sb = $b['sequence'] ?? PHP_INT_MAX; + + return $sa <=> $sb ?: strcmp($a['stable_key'], $b['stable_key']); + }); + + return $rows; + } + + /** + * Read a milestone's timing target from its flexible expected_range object: + * numeric day_offset_min / day_offset_max when present, and a human display + * string when the range is expressed that way ({display:'Today'}). + * + * @return array{0:?int, 1:?int, 2:?string} + */ + private function timing(mixed $expectedRange): array + { + $range = is_array($expectedRange) ? $expectedRange : []; + + $min = array_key_exists('day_offset_min', $range) && is_numeric($range['day_offset_min']) + ? (int) $range['day_offset_min'] : null; + $max = array_key_exists('day_offset_max', $range) && is_numeric($range['day_offset_max']) + ? (int) $range['day_offset_max'] : null; + $display = isset($range['display']) && $range['display'] !== '' ? (string) $range['display'] : null; + + return [$min, $max, $display]; + } + + /** @param array $meta */ + private function owner(array $meta): string + { + $line = $meta['service_line_code'] ?? $meta['source_service_line'] ?? $meta['care_type'] ?? null; + $slug = $this->slug((string) ($line ?? '')); + + return 'clinical:'.($slug !== '' ? $slug : 'unassigned'); + } + + private function majorVersion(?string $semanticVersion): int + { + if ($semanticVersion === null) { + return 1; + } + if (preg_match('/\d+/', $semanticVersion, $m) === 1) { + return (int) $m[0]; + } + + return 1; + } + + /** @param array $body */ + private function digest(array $body): string + { + return hash('sha256', (string) json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); + } + + private function humanize(string $key): string + { + return ucfirst(trim(str_replace(['_', '-'], ' ', $key))); + } + + private function slug(string $value): string + { + $slug = strtolower(trim($value)); + $slug = (string) preg_replace('/[^a-z0-9]+/', '-', $slug); + + return trim($slug, '-'); + } + + /** + * Decode a jsonb column that Postgres returns as a string (or already-cast + * array) into an associative array, tolerantly. + * + * @return array + */ + private static function decodeJsonObject(mixed $value): array + { + if (is_array($value)) { + return $value; + } + if (is_string($value) && $value !== '') { + $decoded = json_decode($value, true); + + return is_array($decoded) ? $decoded : []; + } + + return []; + } +} diff --git a/app/Services/PatientFlow/PatientJourneyService.php b/app/Services/PatientFlow/PatientJourneyService.php index e177f9ec..4f55cc8d 100644 --- a/app/Services/PatientFlow/PatientJourneyService.php +++ b/app/Services/PatientFlow/PatientJourneyService.php @@ -8,6 +8,8 @@ use App\Models\Encounter; use App\Models\Evs\EvsRequest; use App\Models\Transport\TransportRequest; +use App\Services\CarePathways\FlowPathwayDemoService; +use App\Services\CarePathways\PathwayInstanceReadService; use App\Services\Flow\FlowLensService; use Carbon\CarbonImmutable; use Illuminate\Support\Collection; @@ -49,6 +51,8 @@ public function __construct( private readonly FlowEventRepository $events, private readonly FlowLensService $lens, private readonly FlowEpochService $epoch, + private readonly PathwayInstanceReadService $pathwayReader, + private readonly FlowPathwayDemoService $pathwayDemo, ) {} /** @@ -96,7 +100,7 @@ public function build(array $context, ?CarbonImmutable $from = null, ?CarbonImmu ->orderByDesc('evs_request_id') ->get(); - return [ + $payload = [ 'patient' => [ 'patient_context_ref' => $contextRef, // Mirrors FlowLensService::redactRow()'s display derivation — @@ -126,6 +130,61 @@ public function build(array $context, ?CarbonImmutable $from = null, ?CarbonImmu 'as_of' => $now->toJSON(), 'generated_at' => $now->toJSON(), ]; + + // FLOW-4D Phase D: assigned governed-pathway progress. The key is added + // ONLY when the serving gate produces a real assignment or the demo + // overlay applies — so the response is byte-identical (inert) while both + // are dark. Serving stays behind care-pathways.assignment_enabled (G-9); + // the demo is a synthetic, clearly-labeled overlay for one patient. + $pathwayProgress = $this->pathwayProgress($encounter, $now); + if ($pathwayProgress !== null) { + $payload['pathway_progress'] = $pathwayProgress; + } + + return $payload; + } + + /** + * Assigned-pathway progress for the drawer: the real (dark) serving read + * first, then the synthetic demo overlay for one designated patient. Null + * when neither applies — the journey response then omits the key entirely. + * + * @return array|null + */ + private function pathwayProgress(?Encounter $encounter, CarbonImmutable $now): ?array + { + $real = $this->pathwayReader->progressForEncounterId($encounter?->getKey()); + if ($real !== null) { + return $real; + } + + if (! $this->pathwayDemo->enabled() || $encounter === null || ! $this->isDesignatedDemoEncounter($encounter)) { + return null; + } + + $admittedAt = $encounter->admitted_at ? CarbonImmutable::parse((string) $encounter->admitted_at) : null; + $losDays = $admittedAt !== null ? intdiv(max(0, $admittedAt->diffInMinutes($now, false)), 1440) : 0; + + return $this->pathwayDemo->syntheticProgress($losDays, $now); + } + + /** + * The demo attaches to exactly one patient — the longest-stay active + * inpatient (earliest admit) — so the synthetic pathway shows meaningful + * progress and never appears on every patient. Only consulted when the demo + * flag is on. + */ + private function isDesignatedDemoEncounter(Encounter $encounter): bool + { + $designatedId = Encounter::query() + ->where('status', 'active') + ->where('is_deleted', false) + ->whereNotNull('admitted_at') + ->orderBy('admitted_at') + ->orderBy('encounter_id') + ->value('encounter_id'); + + return $designatedId !== null && (int) $designatedId === (int) $encounter->getKey(); } private function activeEncounter(string $patientRef): ?Encounter diff --git a/config/care-pathways-ocel-map.php b/config/care-pathways-ocel-map.php new file mode 100644 index 00000000..cdf55beb --- /dev/null +++ b/config/care-pathways-ocel-map.php @@ -0,0 +1,44 @@ + OCEL activity. Intentionally near-empty; every + // unmapped milestone is surfaced by the coverage command as the gap list. + 'activities' => [ + // 'sepsis_recognition_m01' => 'sepsis_recognition', // example shape + ], + + // Mirror of the sidecar reference-model activity vocabulary + // (arena/app/pathways.py). A mapping target outside this set is a typo the + // coverage command flags as an unknown target. + 'ocel_vocabulary' => [ + // sepsis (SEP-3) + 'sepsis_recognition', 'vitals_sirs', 'lactate_order', 'lactate_result', + 'blood_culture_order', 'antibiotic_administration', 'fluid_bolus_30mlkg', + 'vasopressor_start', 'repeat_lactate_order', 'repeat_lactate_result', + // surgical safety (WHO checklist) + 'Safety_Check', + // home hospital (AHCAH) + 'home-refer', 'home-activate', 'home-visit-complete', + 'home-escalation-open', 'home-escalation-resolve', 'home-discharge', + ], + +]; diff --git a/config/services.php b/config/services.php index 69ccaeb7..88e68602 100644 --- a/config/services.php +++ b/config/services.php @@ -93,6 +93,15 @@ // it (plan OQ-1 owns prod enablement and the coral-escalation question). 'flow4d' => [ 'conformance' => filter_var(env('FLOW4D_CONFORMANCE_ENABLED', false), FILTER_VALIDATE_BOOL), + + // FLOW-4D governed-pathway progress surface (plan §8 Phase D). Composes + // with — never replaces — the care-pathways serving gate: the journey + // drawer only shows a patient's assigned-pathway stage/milestone progress + // when this flag AND care-pathways.assignment_enabled are on. Off by + // default; the DRG-catalog serving path stays dark until [SU]/clinical + // sign-off (plan G-9). The synthetic demo overlay is independent and + // rides care-pathways.demo.enabled, never a serving flag. + 'pathway_progress' => filter_var(env('FLOW4D_PATHWAY_PROGRESS_ENABLED', false), FILTER_VALIDATE_BOOL), ], ]; diff --git a/docs/plans/FLOW-4D-D3-CPG-ON-FHIR-DECISION-MEMO-2026-07-27.md b/docs/plans/FLOW-4D-D3-CPG-ON-FHIR-DECISION-MEMO-2026-07-27.md new file mode 100644 index 00000000..f4bbf59f --- /dev/null +++ b/docs/plans/FLOW-4D-D3-CPG-ON-FHIR-DECISION-MEMO-2026-07-27.md @@ -0,0 +1,91 @@ +# FLOW-4D Phase D3 — Decision Memo: CPG-on-FHIR vs bespoke reference spec + +**Date:** 2026-07-27 **Author:** [C] **Status:** Decided (memo, not a dependency) +**Resolves:** [FLOW-4D-PATIENT-JOURNEY-AND-CONFORMANCE-PLAN §6.5, Open Question OQ-5](./FLOW-4D-PATIENT-JOURNEY-AND-CONFORMANCE-PLAN-2026-07-26.md) + +## The question + +To bridge the governed DRG care-pathway catalog to conformance, should we compile +a pathway version into: + +- **(A)** a **bespoke reference spec** — the shape `arena/app/pathways.py::PATHWAYS` + already uses (what Phase D1's `ReferenceModelCompiler` emits), or +- **(B)** **CPG-on-FHIR** `PlanDefinition` + **CQL** as the canonical, standards-based + intermediate (per the global standards-first rule: FHIR R4 / CPG-on-FHIR)? + +This was time-boxed to a spike. The outcome is this memo; no code depends on it. + +## What the D1/D4 build established + +1. **The operative part of a sidecar reference model is a Python callable, not + data.** Each enabled safety pathway (`evaluate_sepsis`, `evaluate_surgical_safety`, + `evaluate_home_hospital`) encodes imperative clinical timing — the SEP-3 3-hour + antibiotic window, the AHCAH two-visits-per-full-day floor, the 30-minute + escalation response. That logic is **not serializable and not derivable from the + catalog**. What *is* data — ordered activities, timing targets, deviation labels — + is exactly what D1's compiler emits. The compiler deliberately stops there + (`test_never_emits_an_evaluate_callable`). + +2. **The governed catalog stores declarative truth, not executable logic.** + `milestone_definitions.sequence` + `expected_range` (day offsets) + evidence claims + are a *description* of the pathway. There is no executable rule in the schema, and + the executable layer is not even persisted today (`milestone_definitions` is empty; + drafts are derived on demand from source prose). + +3. **So the real gap is turning a declarative milestone into an executable + conformance rule** — "was this milestone met, and was it late?" — over the OCEL + event stream. Options A and B differ only in *how that rule is expressed and run*. + +## Options + +| | (A) Bespoke spec | (B) CPG-on-FHIR PlanDefinition + CQL | +|---|---|---| +| Representation | `PATHWAYS`-shaped dict (D1 output) | `PlanDefinition.action[*]` (+ `.timing`), `Library`/CQL expressions, `DetectedIssue` semantics for deviations | +| Executable rule | Hand-authored Python evaluator per pathway | CQL expression evaluated by a CQL engine over a FHIR data layer | +| Standards fit | None (internal shape) | Strong — the HL7 CPG-on-FHIR IG is the canonical way to represent a computable pathway | +| Dependencies | None beyond the running pm4py sidecar | A CQL execution engine (`cql-execution` JS, `cqframework` Java, or a Python CQL lib) **not in the stack**, plus an OMOP/OCEL → FHIR projection CQL can query | +| Scale to 250 DRGs | Poor — per-pathway human authoring | Good — data-driven once the engine + mapping exist | +| Time-to-serve today | Immediate (matches the live sidecar) | Weeks (new engine + FHIR data layer + validation) | + +## Decision + +**Adopt CPG-on-FHIR `PlanDefinition` as the canonical *representation*; defer CQL as +the *execution* layer; ship the bespoke declarative compiler now.** Concretely: + +1. **Now — ship D1's bespoke declarative compiler.** It already emits the shape the + running sidecar consumes and is the pragmatic intermediate. Nothing serves in + Phase D (dark behind `assignment_enabled`, G-9), so there is no executable + requirement to satisfy yet. + +2. **Commit to a `PlanDefinition` projection.** When serving is greenlit, add a + `PlanDefinition` output to the compiler alongside the bespoke spec — a mechanical + projection of the declarative fields the compiler already holds: milestone → + `action`, `expected_range` → `action.timingRange` (or `timingTiming`), predecessor + keys → `action.relatedAction`, deviation labels → `DetectedIssue.code` vocabulary. + The catalog's governed schema remains the source of truth; `PlanDefinition` is a + standards-conformant *view* of it, not a second store. + +3. **Defer CQL execution until there is an executable requirement.** Introduce a CQL + engine only when per-patient conformance *from the DRG catalog* is actually enabled + ([SU] / clinical sign-off, G-9). At that point evaluate `cql-execution` (JS, + colocatable with the app) vs a Python CQL lib inside the arena sidecar, and the + OMOP/OCEL → FHIR (QI-Core) projection CQL would query. Until then the three + *enabled* safety pathways keep their hand-authored Python evaluators — a bounded, + reviewed set, not 250. + +## Why not adopt CQL now + +Standards-first is honored at the **representation** layer (`PlanDefinition`) without +prematurely importing a CQL **runtime**. Doing (B) fully today would mean standing up +a CQL engine and a FHIR data layer to serve *nothing* (Phase D is dark) — cost with no +recipient. The honest sequencing is: represent in the standard, execute in the +standard when there is something to execute. + +## Consequences / follow-ups + +- D1's compiler is the intermediate of record for now; `PlanDefinition` projection is a + tracked follow-up gated with serving (not scheduled). +- D2's `care-pathways:ocel-coverage` remains the gate report either way — CQL cannot + evaluate a milestone whose activity does not map to an OCEL/FHIR event, so the + milestone↔activity mapping is prerequisite to *both* options. +- No dependency is added by this memo. diff --git a/resources/js/Components/PatientFlowNavigator/NavigatorJourneyDrawer.tsx b/resources/js/Components/PatientFlowNavigator/NavigatorJourneyDrawer.tsx index cf5e2a94..7ec1ff47 100644 --- a/resources/js/Components/PatientFlowNavigator/NavigatorJourneyDrawer.tsx +++ b/resources/js/Components/PatientFlowNavigator/NavigatorJourneyDrawer.tsx @@ -16,7 +16,11 @@ import { dwellLabel, } from '@/features/patientFlowNavigator/journey'; import type { JourneyAlignAnchor } from '@/features/patientFlowNavigator/journey'; -import type { PatientJourney } from '@/features/patientFlowNavigator/journeySchemas'; +import type { + JourneyPathwayMilestone, + JourneyPathwayProgress, + PatientJourney, +} from '@/features/patientFlowNavigator/journeySchemas'; /** * The Patient Journey Drawer (FLOW-4D plan §7.1, finding PJ-1) — the plain- @@ -52,6 +56,10 @@ interface NavigatorJourneyDrawerProps { /** Phase C adherence panel (§7.2). Absent (null) = surface off — the * drawer renders byte-identical to Phase B. */ adherence?: DrawerAdherence | null; + /** Phase D governed-pathway progress (§8 D5). Absent (null) = both the + * serving gate and the demo overlay are off — nothing renders. Comes from the + * journey payload; the orchestrator applies the belt-and-suspenders flag gate. */ + pathwayProgress?: JourneyPathwayProgress | null; /** Drafts a governed exception note; resolves true when it lands PENDING. */ onExceptionNote?: (pathway: string, deviations: string[], note: string) => Promise; /** "Explain this deviation" via the AI plane — only wired when @@ -244,6 +252,75 @@ function AdherencePanel({ ); } +// Status by glyph AND word — never colour alone (token canon). Decorative glyph +// is aria-hidden; the word carries the state to assistive tech. +const PATHWAY_STATUS_GLYPH: Record = { + completed: '✓', + current: '◈', + delayed: '!', + canceled: '✕', + planned: '○', +}; + +function pathwayExpectedLabel(milestone: JourneyPathwayMilestone): string { + const expected = milestone.expected; + if (expected?.display) return expected.display; + const min = expected?.day_offset_min; + const max = expected?.day_offset_max; + if (typeof min === 'number' && typeof max === 'number' && min !== max) return `day ${min}–${max}`; + const day = typeof max === 'number' ? max : min; + return typeof day === 'number' ? `day ${day}` : ''; +} + +/** + * Phase D governed-pathway progress (§8 D5). Shows a patient's assigned-pathway + * stage/milestone progress — dark serving read OR the synthetic demo overlay. + * Never guidance: `clinical_use` is always false, and the demo carries an + * explicit "not clinical guidance" notice. ERAS elements-met framing mirrors the + * adherence panel; status is glyph + word, never colour alone. + */ +function PathwayProgressPanel({ progress }: { progress: JourneyPathwayProgress }) { + const { pathway, summary } = progress; + return ( +
+

+ Pathway + {progress.demo && ( + + Demo + + )} +

+
+ {pathway.label} + {summary.elements_met_label} +
+ {progress.notice &&

{progress.notice}

} +
    + {progress.milestones.map((milestone) => { + const expected = pathwayExpectedLabel(milestone); + return ( +
  1. + + {milestone.title} + + {milestone.status} + {expected ? · {expected} : null} + +
  2. + ); + })} +
+

+ {pathway.semantic_version ? `Against ${pathway.key} v${pathway.semantic_version}` : pathway.key} + {progress.source === 'synthetic_demo' ? ' · synthetic demo' : ` · ${batchTimeLabel(progress.as_of)}`} +

+
+ ); +} + export default function NavigatorJourneyDrawer({ journey, state, @@ -257,6 +334,7 @@ export default function NavigatorJourneyDrawer({ onCopyLink, copiedLink, adherence = null, + pathwayProgress = null, onExceptionNote, onExplainDeviation, }: NavigatorJourneyDrawerProps) { @@ -366,6 +444,8 @@ export default function NavigatorJourneyDrawer({ /> )} + {pathwayProgress && } +

Stay segments

{journey.segments.length === 0 &&

No movement in window

} diff --git a/resources/js/Components/PatientFlowNavigator/PatientFlowNavigator.css b/resources/js/Components/PatientFlowNavigator/PatientFlowNavigator.css index 3527fa2d..06e20040 100644 --- a/resources/js/Components/PatientFlowNavigator/PatientFlowNavigator.css +++ b/resources/js/Components/PatientFlowNavigator/PatientFlowNavigator.css @@ -2180,6 +2180,92 @@ margin-top: 6px; } +/* Phase D (§8 D5) — governed-pathway progress. Reuses the sanctioned F-9 + navigator overlay palette (dark-only wall instrument; do not migrate to + Tailwind tokens). Status is glyph + word, never colour alone. */ +.patient-flow-pathway-demo { + margin-left: 8px; + border: 1px solid rgba(224, 163, 63, 0.55); + border-radius: 5px; + color: #e0a33f; + font-size: 11px; + padding: 0 5px; + vertical-align: middle; +} + +.patient-flow-pathway-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + margin-top: 4px; +} + +.patient-flow-pathway-name { + color: #f3efe5; +} + +.patient-flow-pathway-headline { + color: #ddd6c7; +} + +.patient-flow-pathway-milestones { + list-style: none; + margin: 6px 0 0; + padding: 0; +} + +.patient-flow-pathway-milestone { + display: flex; + align-items: baseline; + gap: 8px; + padding: 2px 0; + color: #ddd6c7; +} + +.patient-flow-pathway-glyph { + flex: none; + width: 14px; + text-align: center; + color: #ddd6c7; +} + +.patient-flow-pathway-milestone.status-completed .patient-flow-pathway-glyph { + color: #f3efe5; +} + +.patient-flow-pathway-milestone.status-current { + color: #f3efe5; +} + +.patient-flow-pathway-milestone.status-current .patient-flow-pathway-glyph { + color: #e0a33f; +} + +.patient-flow-pathway-milestone.status-delayed .patient-flow-pathway-glyph { + color: #e0a33f; +} + +.patient-flow-pathway-title { + flex: 1 1 auto; +} + +.patient-flow-pathway-meta { + flex: none; + color: #ddd6c7; + font-size: 11px; +} + +.patient-flow-pathway-state { + text-transform: capitalize; +} + +.patient-flow-journey-provenance { + margin: 6px 0 0; + color: #ddd6c7; + font-size: 11px; +} + /* F-6 pt 2 — the atomic-rebootstrap notice: quiet, informational, never an alarm (the refresh is expected behavior on the 6h demo wall). */ .patient-flow-rebuild-notice { diff --git a/resources/js/Components/PatientFlowNavigator/PatientFlowNavigator.tsx b/resources/js/Components/PatientFlowNavigator/PatientFlowNavigator.tsx index 3167c981..d2c17a1e 100644 --- a/resources/js/Components/PatientFlowNavigator/PatientFlowNavigator.tsx +++ b/resources/js/Components/PatientFlowNavigator/PatientFlowNavigator.tsx @@ -341,6 +341,11 @@ export default function PatientFlowNavigator({ // the lens leg composes here. Flag off → this file renders byte-identical. const conformanceEnabled = Boolean(page.props.arena?.conformance_enabled) && patientDotsVisible; const arenaAiEnabled = Boolean(page.props.arena?.ai_enabled); + // Phase D (§8 D5): governed-pathway progress. Real serving is pre-composed + // server-side (FLOW4D_PATHWAY_PROGRESS_ENABLED ∧ assignment gate); the demo + // overlay rides the demo flag. Both require a patient-dots lens. + const pathwayProgressServing = Boolean(page.props.flow4d?.pathway_progress_enabled) && patientDotsVisible; + const carePathwaysDemo = Boolean(page.props.features?.care_pathways_demo) && patientDotsVisible; const openEddyWithPrefill = useEddyStore((state) => state.openWithPrefill); const canvasRef = useRef(null); @@ -460,6 +465,16 @@ export default function PatientFlowNavigator({ // Phase C: the open journey's cached adherence verdicts (null = surface off // for this page/persona — the drawer renders byte-identical to Phase B). const [adherence, setAdherence] = useState(null); + // Phase D (§8 D5): the open journey's pathway progress, if present. The + // backend only emits the block for real serving (dark) or the designated demo + // patient; the belt-and-suspenders flag gate here mirrors that so a stale + // payload can never render past a just-flipped flag. + const pathwayProgress = useMemo(() => { + const block = journeyData?.pathway_progress ?? null; + if (!block) return null; + if (block.demo) return carePathwaysDemo ? block : null; + return pathwayProgressServing ? block : null; + }, [journeyData, carePathwaysDemo, pathwayProgressServing]); const journeyPatientRef = useRef(null); const journeyFollowRef = useRef(false); useEffect(() => { @@ -2321,6 +2336,7 @@ export default function PatientFlowNavigator({ onCopyLink={copyJourneyLink} copiedLink={journeyLinkCopied} adherence={adherence} + pathwayProgress={pathwayProgress} onExceptionNote={conformanceEnabled ? draftExceptionNote : undefined} onExplainDeviation={conformanceEnabled && arenaAiEnabled && eddyEnabled ? explainDeviation : undefined} /> diff --git a/resources/js/features/patientFlowNavigator/journeySchemas.ts b/resources/js/features/patientFlowNavigator/journeySchemas.ts index 36d3d50b..7bec70df 100644 --- a/resources/js/features/patientFlowNavigator/journeySchemas.ts +++ b/resources/js/features/patientFlowNavigator/journeySchemas.ts @@ -101,6 +101,51 @@ export const journeyBarrierSchema = z.object({ attribution: z.string(), }); +// FLOW-4D Phase D: governed-pathway progress (dark serving path + synthetic +// demo overlay). Present only when the serving gate produced a real assignment +// or the demo overlay applies; tolerant-nullish so its absence never white-screens. +export const journeyPathwayMilestoneSchema = z.object({ + stable_key: z.string(), + title: z.string(), + phase: z.string().nullish(), + sequence: z.number().nullish(), + status: z.string(), + observed_at: isoString.nullish(), + expected: z.object({ + day_offset_min: z.number().nullish(), + day_offset_max: z.number().nullish(), + display: z.string().nullish(), + }), +}); + +export const journeyPathwayProgressSchema = z.object({ + state: z.string(), + demo: z.boolean(), + // Always false in Phase D — the drawer must never present this as guidance. + clinical_use: z.boolean(), + notice: z.string().nullish(), + pathway: z.object({ + key: z.string(), + label: z.string(), + version_uuid: z.string().nullish(), + semantic_version: z.string().nullish(), + digest: z.string().nullish(), + }), + milestones: z.array(journeyPathwayMilestoneSchema), + summary: z.object({ + completed: z.number(), + current: z.number(), + planned: z.number(), + delayed: z.number(), + canceled: z.number(), + total: z.number(), + current_stable_key: z.string().nullish(), + elements_met_label: z.string(), + }), + source: z.string(), + as_of: isoString, +}); + export const patientJourneySchema = z.object({ patient: z.object({ patient_context_ref: z.string(), @@ -143,11 +188,14 @@ export const patientJourneySchema = z.object({ refreshed_at: isoString.nullish(), status: z.string(), }).nullish(), + pathway_progress: journeyPathwayProgressSchema.nullish(), as_of: isoString, generated_at: isoString, }); export type PatientJourney = z.infer; +export type JourneyPathwayProgress = z.infer; +export type JourneyPathwayMilestone = z.infer; export type JourneyEvent = z.infer; export type JourneySegment = z.infer; export type JourneyPhase = z.infer; diff --git a/resources/js/types/index.ts b/resources/js/types/index.ts index 1aca2b8f..61000961 100644 --- a/resources/js/types/index.ts +++ b/resources/js/types/index.ts @@ -50,6 +50,12 @@ export interface PageProps { /** ARENA_ENABLED ∧ FLOW4D_CONFORMANCE_ENABLED, pre-composed server-side. */ conformance_enabled?: boolean; }; + flow4d?: { + /** FLOW4D_PATHWAY_PROGRESS_ENABLED ∧ CARE_PATHWAYS_ASSIGNMENT_ENABLED, + * pre-composed server-side (plan §8 D5). Off = the governed-pathway serving + * path stays dark; the demo overlay rides features.care_pathways_demo. */ + pathway_progress_enabled?: boolean; + }; features?: { care_pathways_demo?: boolean; care_pathways_catalog?: boolean; diff --git a/tests/Feature/CarePathways/FlowPathwayDemoServiceTest.php b/tests/Feature/CarePathways/FlowPathwayDemoServiceTest.php new file mode 100644 index 00000000..38cdbbf4 --- /dev/null +++ b/tests/Feature/CarePathways/FlowPathwayDemoServiceTest.php @@ -0,0 +1,63 @@ + false]); + + $this->assertNull(app(FlowPathwayDemoService::class)->syntheticProgress(2)); + } + + public function test_projects_length_of_stay_driven_progress(): void + { + config(['care-pathways.demo.enabled' => true]); + + $progress = app(FlowPathwayDemoService::class)->syntheticProgress(2, CarbonImmutable::parse('2026-07-27T12:00:00Z')); + + $this->assertNotNull($progress); + $this->assertTrue($progress['demo']); + $this->assertFalse($progress['clinical_use']); + $this->assertSame('Demo — not clinical guidance', $progress['notice']); + $this->assertSame('synthetic_demo', $progress['source']); + $this->assertSame('demo-heart-failure', $progress['pathway']['key']); + $this->assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $progress['pathway']['digest']); + + // Day offsets (max) are [0,1,1,2,3,4]; at LOS day 2 everything before day + // 2 is complete, day 2 is current, later days are still planned. + $this->assertSame( + ['completed', 'completed', 'completed', 'current', 'planned', 'planned'], + array_column($progress['milestones'], 'status'), + ); + $this->assertSame(3, $progress['summary']['completed']); + $this->assertSame(1, $progress['summary']['current']); + $this->assertSame(2, $progress['summary']['planned']); + $this->assertSame(6, $progress['summary']['total']); + $this->assertSame('hf_gdmt', $progress['summary']['current_stable_key']); + $this->assertSame('3 of 6 milestones complete', $progress['summary']['elements_met_label']); + } + + public function test_day_zero_marks_only_arrival_current(): void + { + config(['care-pathways.demo.enabled' => true]); + + $progress = app(FlowPathwayDemoService::class)->syntheticProgress(0); + + $this->assertSame('current', $progress['milestones'][0]['status']); // hf_arrival, day 0 + $this->assertSame('hf_arrival', $progress['summary']['current_stable_key']); + $this->assertSame(0, $progress['summary']['completed']); + } +} diff --git a/tests/Feature/CarePathways/OcelCoverageCommandTest.php b/tests/Feature/CarePathways/OcelCoverageCommandTest.php new file mode 100644 index 00000000..51bd8dfe --- /dev/null +++ b/tests/Feature/CarePathways/OcelCoverageCommandTest.php @@ -0,0 +1,104 @@ +configureCarePathwayFixture(); + $this->seedCarePathwayRawFixture(); + app(CatalogImportService::class)->adopt(1, 'test-data-steward'); + } + + public function test_report_is_empty_when_no_version_has_an_executable_layer(): void + { + $report = app(OcelActivityCoverageService::class)->report(); + + $this->assertSame(0, $report['totals']['executable_versions']); + $this->assertSame([], $report['pathways']); + $this->assertNull($report['totals']['coverage']); + } + + public function test_command_reports_the_empty_layer_honestly(): void + { + $this->artisan('care-pathways:ocel-coverage') + ->expectsOutputToContain('No governed version has a persisted executable milestone layer yet.') + ->assertExitCode(0); + } + + public function test_report_counts_mapped_and_lists_the_unmapped_gap(): void + { + $versionId = (int) DB::table('care_pathways.versions')->orderBy('source_rank')->value('pathway_version_id'); + $this->seedMilestones($versionId); + + // Map exactly one of the three milestone activities to a real OCEL activity. + config(['care-pathways-ocel-map.activities' => [ + 'day_1_m01' => 'antibiotic_administration', + 'day_2_m01' => 'not_a_real_ocel_activity', // target outside the vocabulary → flagged + ]]); + + $report = app(OcelActivityCoverageService::class)->report(); + + $this->assertSame(1, $report['totals']['executable_versions']); + $this->assertSame(3, $report['totals']['activities']); + $this->assertSame(2, $report['totals']['mapped']); + $this->assertSame(1, $report['totals']['unmapped']); + $this->assertSame(1, $report['totals']['unknown_targets']); + + $pathway = $report['pathways'][0]; + $this->assertSame(['arrival_m00'], $pathway['unmapped']); // the honest gap + $this->assertSame(['day_2_m01 → not_a_real_ocel_activity'], $pathway['unknown_targets']); + } + + public function test_json_output_is_machine_readable(): void + { + $versionId = (int) DB::table('care_pathways.versions')->orderBy('source_rank')->value('pathway_version_id'); + $this->seedMilestones($versionId); + + $this->artisan('care-pathways:ocel-coverage --json')->assertExitCode(0); + } + + private function seedMilestones(int $versionId): void + { + $rows = [ + ['stable_key' => 'arrival_m00', 'title' => 'Admitted and settled', 'phase' => 'arrival', 'sequence' => 0, 'expected_range' => ['display' => 'Today']], + ['stable_key' => 'day_1_m01', 'title' => 'Initial workup complete', 'phase' => 'day_1', 'sequence' => 1, 'expected_range' => ['day_offset_min' => 0, 'day_offset_max' => 1]], + ['stable_key' => 'day_2_m01', 'title' => 'Repeat imaging reviewed', 'phase' => 'day_2', 'sequence' => 2, 'expected_range' => ['day_offset_min' => 1, 'day_offset_max' => 2]], + ]; + + foreach ($rows as $row) { + MilestoneDefinition::query()->create([ + 'pathway_version_id' => $versionId, + 'milestone_uuid' => (string) Str::uuid(), + 'stable_key' => $row['stable_key'], + 'title' => $row['title'], + 'phase' => $row['phase'], + 'sequence' => $row['sequence'], + 'predecessor_keys' => [], + 'expected_range' => $row['expected_range'], + 'review_state' => 'approved', + ]); + } + } +} diff --git a/tests/Feature/CarePathways/PathwayInstanceReadServiceTest.php b/tests/Feature/CarePathways/PathwayInstanceReadServiceTest.php new file mode 100644 index 00000000..a21bd9b1 --- /dev/null +++ b/tests/Feature/CarePathways/PathwayInstanceReadServiceTest.php @@ -0,0 +1,178 @@ + */ + private array $milestones = []; + + private int $accessGrantId; + + protected function setUp(): void + { + parent::setUp(); + + $this->configureCarePathwayFixture(); + $this->seedCarePathwayRawFixture(); + $this->activateFixtureRelease(); + + $this->version = PathwayVersion::query()->orderBy('source_rank')->firstOrFail(); + $this->seedThreeMilestones(); + + $grant = app(SyntheticPatientProjectionProvisioner::class)->provision('flow4d-pathway-progress')['grant']; + $this->accessGrantId = (int) $grant->getKey(); + + $service = app(PatientPathwayInstanceService::class); + $instance = $service->instantiate($grant, $this->version, 'test-pathway-adapter', 'flow4d-assignment-ref', now()->subMinutes(20)); + $service->recordMilestoneStatus($instance, $this->milestones['arrival_m00'], 'completed', 'flow4d-m0-completed', now()->subMinutes(15)); + $service->recordMilestoneStatus($instance, $this->milestones['day_1_m01'], 'current', 'flow4d-m1-current', now()->subMinutes(5)); + $service->recordMilestoneStatus($instance, $this->milestones['day_2_m01'], 'planned', 'flow4d-m2-planned', now()->subMinute()); + + config(['care-pathways.assignment_enabled' => true]); + } + + public function test_projects_ordered_milestone_progress_for_the_access_grant(): void + { + $progress = app(PathwayInstanceReadService::class)->progressForAccessGrant($this->accessGrantId); + + $this->assertNotNull($progress); + $this->assertFalse($progress['demo']); + $this->assertFalse($progress['clinical_use']); // Phase D never serves guidance + $this->assertNull($progress['notice']); + $this->assertSame('assigned_instance', $progress['source']); + + $this->assertSame( + ['arrival_m00', 'day_1_m01', 'day_2_m01'], + array_column($progress['milestones'], 'stable_key'), + ); + $this->assertSame( + ['completed', 'current', 'planned'], + array_column($progress['milestones'], 'status'), + ); + + $this->assertSame(1, $progress['summary']['completed']); + $this->assertSame(1, $progress['summary']['current']); + $this->assertSame(1, $progress['summary']['planned']); + $this->assertSame(3, $progress['summary']['total']); + $this->assertSame('day_1_m01', $progress['summary']['current_stable_key']); + $this->assertSame('1 of 3 milestones complete', $progress['summary']['elements_met_label']); + + // Timing carried through from the governed milestone definitions. + $day1 = $progress['milestones'][1]; + $this->assertSame(['day_offset_min' => 0, 'day_offset_max' => 1, 'display' => null], $day1['expected']); + } + + public function test_resolves_progress_from_a_flow_encounter_id(): void + { + // The journey spine calls with a flow encounter id; the service resolves + // the active grant via encounter_access_grants.source_encounter_id. + DB::table('patient_experience.encounter_access_grants') + ->where('access_grant_id', $this->accessGrantId) + ->update(['source_encounter_id' => 987654]); + + $viaEncounter = app(PathwayInstanceReadService::class)->progressForEncounterId(987654); + $viaGrant = app(PathwayInstanceReadService::class)->progressForAccessGrant($this->accessGrantId); + + $this->assertNotNull($viaEncounter); + $this->assertSame($viaGrant, $viaEncounter); + } + + public function test_is_inert_when_the_serving_gate_is_off(): void + { + config(['care-pathways.assignment_enabled' => false]); + $service = app(PathwayInstanceReadService::class); + + $this->assertFalse($service->available()); + $this->assertNull($service->progressForAccessGrant($this->accessGrantId)); + $this->assertNull($service->progressForEncounterId(987654)); + } + + public function test_reads_never_mutate_the_append_only_status_history(): void + { + $before = DB::table('patient_experience.pathway_milestone_status_events')->count(); + + app(PathwayInstanceReadService::class)->progressForAccessGrant($this->accessGrantId); + app(PathwayInstanceReadService::class)->progressForAccessGrant($this->accessGrantId); + + $this->assertSame($before, DB::table('patient_experience.pathway_milestone_status_events')->count()); + $this->assertSame(3, $before); // one per recorded milestone, nothing added by reads + } + + public function test_returns_null_when_the_grant_has_no_instance(): void + { + // A grant with no pathway assignment yields nothing (honest empty). + $otherGrant = app(SyntheticPatientProjectionProvisioner::class)->provision('flow4d-no-instance')['grant']; + + $this->assertNull(app(PathwayInstanceReadService::class)->progressForAccessGrant((int) $otherGrant->getKey())); + } + + private function seedThreeMilestones(): void + { + $rows = [ + ['stable_key' => 'arrival_m00', 'title' => 'Admitted and settled', 'phase' => 'arrival', 'sequence' => 0, 'expected_range' => ['display' => 'Today']], + ['stable_key' => 'day_1_m01', 'title' => 'Initial workup complete', 'phase' => 'day_1', 'sequence' => 1, 'expected_range' => ['day_offset_min' => 0, 'day_offset_max' => 1]], + ['stable_key' => 'day_2_m01', 'title' => 'Repeat imaging reviewed', 'phase' => 'day_2', 'sequence' => 2, 'expected_range' => ['day_offset_min' => 1, 'day_offset_max' => 2]], + ]; + + foreach ($rows as $row) { + $this->milestones[$row['stable_key']] = MilestoneDefinition::query()->create([ + 'pathway_version_id' => $this->version->getKey(), + 'milestone_uuid' => (string) Str::uuid(), + 'stable_key' => $row['stable_key'], + 'title' => $row['title'], + 'phase' => $row['phase'], + 'sequence' => $row['sequence'], + 'predecessor_keys' => [], + 'expected_range' => $row['expected_range'], + 'review_state' => 'approved', + ]); + } + } + + private function activateFixtureRelease(): void + { + $summary = app(CatalogImportService::class)->adopt(1, 'test-data-steward'); + + DB::table('care_pathways.definitions')->update(['lifecycle_state' => 'active', 'updated_at' => now()]); + DB::table('care_pathways.versions')->update([ + 'institutional_approval_status' => 'approved', + 'activation_status' => 'active', + 'updated_at' => now(), + ]); + DB::table('care_pathways.catalog_releases') + ->where('catalog_release_id', $summary['catalog_release_id']) + ->update([ + 'state' => 'active', + 'clinical_signoff_complete' => true, + 'clinical_signoff_count' => 2, + 'activated_by_user_id' => 999, + 'activated_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/tests/Feature/CarePathways/ReferenceModelCompilerVersionTest.php b/tests/Feature/CarePathways/ReferenceModelCompilerVersionTest.php new file mode 100644 index 00000000..2aac8b96 --- /dev/null +++ b/tests/Feature/CarePathways/ReferenceModelCompilerVersionTest.php @@ -0,0 +1,103 @@ +configureCarePathwayFixture(); + $this->seedCarePathwayRawFixture(); + app(CatalogImportService::class)->adopt(1, 'test-data-steward'); + } + + public function test_returns_null_for_a_version_with_no_milestones(): void + { + $versionId = (int) DB::table('care_pathways.versions')->orderBy('source_rank')->value('pathway_version_id'); + + $this->assertNull(app(ReferenceModelCompiler::class)->compileVersion($versionId)); + } + + public function test_returns_null_for_an_unknown_version(): void + { + $this->assertNull(app(ReferenceModelCompiler::class)->compileVersion(999999)); + } + + public function test_compiles_a_version_with_a_persisted_milestone_layer(): void + { + $versionId = (int) DB::table('care_pathways.versions')->orderBy('source_rank')->value('pathway_version_id'); + $this->seedMilestones($versionId); + + $model = app(ReferenceModelCompiler::class)->compileVersion($versionId); + + $this->assertNotNull($model); + // Ordered by sequence then stable_key, sourced straight from the catalog. + $this->assertSame(['arrival_m00', 'day_1_m01', 'day_2_m01'], $model['activities']); + $this->assertSame('arrival_m00', $model['trigger']); + $this->assertSame('Encounter', $model['case_type']); + $this->assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $model['digest']); + + // The adapter fills governance pinning from the version + release rows. + $this->assertNotNull($model['source']['pathway_version_uuid']); + $this->assertSame($model['semantic_version'], $model['source']['semantic_version']); + $this->assertNotNull($model['source']['catalog_release_uuid']); + } + + public function test_compilation_is_stable_across_calls(): void + { + $versionId = (int) DB::table('care_pathways.versions')->orderBy('source_rank')->value('pathway_version_id'); + $this->seedMilestones($versionId); + $compiler = app(ReferenceModelCompiler::class); + + $this->assertSame( + $compiler->compileVersion($versionId)['digest'], + $compiler->compileVersion($versionId)['digest'], + ); + } + + private function seedMilestones(int $versionId): void + { + $rows = [ + ['stable_key' => 'day_2_m01', 'title' => 'Repeat imaging reviewed', 'phase' => 'day_2', 'sequence' => 2, 'expected_range' => ['day_offset_min' => 1, 'day_offset_max' => 2]], + ['stable_key' => 'day_1_m01', 'title' => 'Initial workup complete', 'phase' => 'day_1', 'sequence' => 1, 'expected_range' => ['day_offset_min' => 0, 'day_offset_max' => 1]], + ['stable_key' => 'arrival_m00', 'title' => 'Admitted and settled', 'phase' => 'arrival', 'sequence' => 0, 'expected_range' => ['display' => 'Today']], + ]; + + foreach ($rows as $row) { + MilestoneDefinition::query()->create([ + 'pathway_version_id' => $versionId, + 'milestone_uuid' => (string) Str::uuid(), + 'stable_key' => $row['stable_key'], + 'title' => $row['title'], + 'phase' => $row['phase'], + 'sequence' => $row['sequence'], + 'predecessor_keys' => [], + 'expected_range' => $row['expected_range'], + 'review_state' => 'approved', + ]); + } + } +} diff --git a/tests/Feature/PatientFlow/PatientJourneyEndpointTest.php b/tests/Feature/PatientFlow/PatientJourneyEndpointTest.php index 2863112a..bfebc619 100644 --- a/tests/Feature/PatientFlow/PatientJourneyEndpointTest.php +++ b/tests/Feature/PatientFlow/PatientJourneyEndpointTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\PatientFlow; use App\Models\BedRequest; +use App\Models\Encounter; use App\Models\PatientFlow\FlowEvent; use App\Models\User; use App\Services\Mobile\MobilePatientContextService; @@ -120,6 +121,51 @@ public function test_journey_returns_the_ordered_story_for_a_patient_scope(): vo $this->assertStringContainsString($contextRef, $response->getContent()); } + public function test_pathway_progress_is_absent_while_the_care_pathway_flags_are_dark(): void + { + // FLOW-4D Phase D ships dark: with the serving gate and the demo flag + // both off, the journey response must omit the pathway_progress key + // entirely (byte-identical inertness). + config(['care-pathways.assignment_enabled' => false, 'care-pathways.demo.enabled' => false]); + + $patientRef = $this->seedJourneyEvents(); + $contextRef = app(MobilePatientContextService::class)->contextRefFor($patientRef); + + $response = $this->actingAs($this->admin()) + ->getJson("/api/patient-flow/journey?persona=house_supervisor&scope=patient:{$contextRef}") + ->assertOk(); + + $this->assertArrayNotHasKey('pathway_progress', $response->json()); + } + + public function test_demo_overlay_attaches_a_synthetic_pathway_for_the_designated_patient(): void + { + // FLOW-4D Phase D5: the demo flag (independent of every serving gate) + // shows one designated patient a synthetic, clearly-labeled pathway. + config(['care-pathways.demo.enabled' => true, 'care-pathways.assignment_enabled' => false]); + + $patientRef = $this->seedJourneyEvents(); + Encounter::create([ + 'patient_ref' => $patientRef, + 'admitted_at' => CarbonImmutable::now()->subDays(2), + 'status' => 'active', + 'is_deleted' => false, + ]); + $contextRef = app(MobilePatientContextService::class)->contextRefFor($patientRef); + + $response = $this->actingAs($this->admin()) + ->getJson("/api/patient-flow/journey?persona=house_supervisor&scope=patient:{$contextRef}") + ->assertOk() + ->assertJsonPath('pathway_progress.demo', true) + ->assertJsonPath('pathway_progress.clinical_use', false) + ->assertJsonPath('pathway_progress.notice', 'Demo — not clinical guidance') + ->assertJsonPath('pathway_progress.source', 'synthetic_demo'); + + $this->assertNotEmpty($response->json('pathway_progress.milestones')); + // The synthetic pathway carries no real patient identity. + $this->assertStringNotContainsString($patientRef, $response->getContent()); + } + public function test_journey_requires_a_patient_scope(): void { $this->seedJourneyEvents(); diff --git a/tests/Unit/CarePathways/ReferenceModelCompilerTest.php b/tests/Unit/CarePathways/ReferenceModelCompilerTest.php new file mode 100644 index 00000000..df10da93 --- /dev/null +++ b/tests/Unit/CarePathways/ReferenceModelCompilerTest.php @@ -0,0 +1,157 @@ +, 1: list>} + */ + private function fixture(): array + { + $meta = [ + 'pathway_key' => 'acute-example-a', + 'label' => 'Acute Example A', + 'semantic_version' => '43.1-source.1', + 'service_line_code' => 'NEURO', + 'care_type' => 'Medical', + ]; + + // Deliberately out of order so ordering is exercised. + $milestones = [ + ['stable_key' => 'day_2_m01', 'title' => 'Repeat imaging reviewed', 'phase' => 'day_2', 'sequence' => 2, 'expected_range' => ['day_offset_min' => 1, 'day_offset_max' => 2]], + ['stable_key' => 'day_1_m01', 'title' => 'Initial workup complete', 'phase' => 'day_1', 'sequence' => 1, 'expected_range' => ['day_offset_min' => 0, 'day_offset_max' => 1]], + ['stable_key' => 'arrival_m00', 'title' => 'Admitted and settled', 'phase' => 'arrival', 'sequence' => 0, 'expected_range' => ['display' => 'Today']], + ]; + + return [$meta, $milestones]; + } + + public function test_compiles_the_sidecar_spec_shape(): void + { + [$meta, $milestones] = $this->fixture(); + $model = (new ReferenceModelCompiler)->compile($meta, $milestones); + + $this->assertNotNull($model); + $this->assertSame('acute-example-a', $model['pathway_key']); + $this->assertSame('Acute Example A', $model['label']); + $this->assertSame(43, $model['version']); // major int from '43.1-source.1' + $this->assertSame('43.1-source.1', $model['semantic_version']); + $this->assertSame('clinical:neuro', $model['owner']); + $this->assertSame('Encounter', $model['case_type']); + } + + public function test_orders_activities_by_sequence_then_stable_key(): void + { + [$meta, $milestones] = $this->fixture(); + $model = (new ReferenceModelCompiler)->compile($meta, $milestones); + + $this->assertSame(['arrival_m00', 'day_1_m01', 'day_2_m01'], $model['activities']); + // trigger is the first ordered activity. + $this->assertSame('arrival_m00', $model['trigger']); + } + + public function test_extracts_timing_targets_from_both_expected_range_shapes(): void + { + [$meta, $milestones] = $this->fixture(); + $model = (new ReferenceModelCompiler)->compile($meta, $milestones); + + $byActivity = array_column($model['timing_targets'], null, 'activity'); + + $this->assertSame(['activity' => 'arrival_m00', 'day_offset_min' => null, 'day_offset_max' => null, 'display' => 'Today'], $byActivity['arrival_m00']); + $this->assertSame(['activity' => 'day_1_m01', 'day_offset_min' => 0, 'day_offset_max' => 1, 'display' => null], $byActivity['day_1_m01']); + $this->assertSame(['activity' => 'day_2_m01', 'day_offset_min' => 1, 'day_offset_max' => 2, 'display' => null], $byActivity['day_2_m01']); + } + + public function test_derives_a_data_driven_deviation_vocabulary(): void + { + [$meta, $milestones] = $this->fixture(); + $model = (new ReferenceModelCompiler)->compile($meta, $milestones); + + // A "missing" code for every milestone. + $this->assertSame('Initial workup complete not yet observed', $model['deviation_labels']['day_1_m01_missing']); + // A "late" code only where a numeric day ceiling exists. + $this->assertSame('Initial workup complete beyond day 1', $model['deviation_labels']['day_1_m01_late']); + $this->assertArrayHasKey('arrival_m00_missing', $model['deviation_labels']); + $this->assertArrayNotHasKey('arrival_m00_late', $model['deviation_labels']); // display-only, no ceiling + } + + public function test_never_emits_an_evaluate_callable(): void + { + [$meta, $milestones] = $this->fixture(); + $model = (new ReferenceModelCompiler)->compile($meta, $milestones); + + // The imperative evaluator is the open gap (D2/D3), deliberately absent. + $this->assertArrayNotHasKey('evaluate', $model); + } + + public function test_digest_is_deterministic_and_content_addressed(): void + { + [$meta, $milestones] = $this->fixture(); + $compiler = new ReferenceModelCompiler; + + $a = $compiler->compile($meta, $milestones); + // Re-shuffle input order: the digest must be identical (content, not order). + $b = $compiler->compile($meta, array_reverse($milestones)); + + $this->assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $a['digest']); + $this->assertSame($a['digest'], $b['digest']); + + // Changing content (a timing ceiling) must change the digest. + $mutated = $milestones; + $mutated[1]['expected_range'] = ['day_offset_min' => 0, 'day_offset_max' => 5]; + $c = $compiler->compile($meta, $mutated); + $this->assertNotSame($a['digest'], $c['digest']); + } + + public function test_digest_is_pinned_for_the_fixture(): void + { + [$meta, $milestones] = $this->fixture(); + $model = (new ReferenceModelCompiler)->compile($meta, $milestones); + + // Content-address pin: a change to the compiler's declarative body shape + // is a governance-relevant event and must be an explicit, reviewed edit. + $this->assertSame( + 'ea3e0ff9b1d0921b6f08c92f8017e5a9bc490cf7f191601c004d0719d9b618ee', + $model['digest'], + ); + } + + public function test_returns_null_when_the_version_has_no_executable_layer(): void + { + [$meta] = $this->fixture(); + + // No milestones → nothing to compile. The honest state for every version + // in the current inactive catalog release, not an error. + $this->assertNull((new ReferenceModelCompiler)->compile($meta, [])); + } + + public function test_owner_falls_back_when_no_service_line(): void + { + $model = (new ReferenceModelCompiler)->compile( + ['pathway_key' => 'x', 'label' => 'X', 'semantic_version' => '1.0.0'], + [['stable_key' => 'm1', 'title' => 'M1', 'sequence' => 1, 'expected_range' => []]], + ); + + $this->assertSame('clinical:unassigned', $model['owner']); + $this->assertSame(1, $model['version']); + // A milestone with an empty range still appears as an activity, with a + // missing-code but no timing target. + $this->assertSame(['m1'], $model['activities']); + $this->assertSame([], $model['timing_targets']); + $this->assertArrayHasKey('m1_missing', $model['deviation_labels']); + } +} diff --git a/tests/js/patientFlow/NavigatorJourneyDrawer.test.tsx b/tests/js/patientFlow/NavigatorJourneyDrawer.test.tsx index 739b55d8..40bf41ad 100644 --- a/tests/js/patientFlow/NavigatorJourneyDrawer.test.tsx +++ b/tests/js/patientFlow/NavigatorJourneyDrawer.test.tsx @@ -173,3 +173,57 @@ describe('NavigatorJourneyDrawer adherence panel (Phase C)', () => { ); }); }); + +function pathwayProgressFixture( + demo = true, +): NonNullable['pathwayProgress']> { + return { + state: 'ok', + demo, + clinical_use: false, + notice: demo ? 'Demo — not clinical guidance' : null, + pathway: { + key: 'demo-heart-failure', + label: 'Heart Failure — Admission to Supported Transition', + version_uuid: null, + semantic_version: 'demo.1', + digest: 'a'.repeat(64), + }, + milestones: [ + { stable_key: 'hf_arrival', title: 'Admitted and assessed', phase: 'arrival', sequence: 0, status: 'completed', observed_at: null, expected: { day_offset_min: 0, day_offset_max: 0, display: null } }, + { stable_key: 'hf_gdmt', title: 'Guideline-directed medical therapy optimized', phase: 'day_2', sequence: 3, status: 'current', observed_at: null, expected: { day_offset_min: 1, day_offset_max: 2, display: null } }, + { stable_key: 'hf_discharge', title: 'Discharge readiness and follow-up scheduled', phase: 'discharge', sequence: 5, status: 'planned', observed_at: null, expected: { day_offset_min: 3, day_offset_max: 4, display: null } }, + ], + summary: { completed: 1, current: 1, planned: 1, delayed: 0, canceled: 0, total: 3, current_stable_key: 'hf_gdmt', elements_met_label: '1 of 3 milestones complete' }, + source: 'synthetic_demo', + as_of: '2026-07-27T12:00:00Z', + }; +} + +describe('NavigatorJourneyDrawer — pathway progress (Phase D5)', () => { + it('renders the assigned-pathway panel with status by word, never colour alone', () => { + renderDrawer({ pathwayProgress: pathwayProgressFixture() }); + + expect(screen.getByLabelText('Assigned pathway progress')).toBeTruthy(); + expect(screen.getByText('Heart Failure — Admission to Supported Transition')).toBeTruthy(); + expect(screen.getByText('1 of 3 milestones complete')).toBeTruthy(); + expect(screen.getByText('Guideline-directed medical therapy optimized')).toBeTruthy(); + // Status is a written word (the glyph is aria-hidden decoration). + expect(screen.getByText('current')).toBeTruthy(); + expect(screen.getByText('completed')).toBeTruthy(); + }); + + it('shows the Demo badge and the not-clinical-guidance notice for a synthetic pathway', () => { + renderDrawer({ pathwayProgress: pathwayProgressFixture(true) }); + + expect(screen.getByText('Demo')).toBeTruthy(); + expect(screen.getByText('Demo — not clinical guidance')).toBeTruthy(); + }); + + it('renders nothing pathway-related when the block is absent (dark by default)', () => { + renderDrawer({ pathwayProgress: null }); + + expect(screen.queryByLabelText('Assigned pathway progress')).toBeNull(); + expect(screen.queryByText('Heart Failure — Admission to Supported Transition')).toBeNull(); + }); +}); diff --git a/tests/js/patientFlow/journey.test.ts b/tests/js/patientFlow/journey.test.ts index 23609ac0..4d922fbc 100644 --- a/tests/js/patientFlow/journey.test.ts +++ b/tests/js/patientFlow/journey.test.ts @@ -74,6 +74,30 @@ describe('patientJourneySchema', () => { }); expect(parsed.success).toBe(true); }); + + it('accepts the Phase D pathway_progress block and tolerates its absence', () => { + // Absent → still valid (dark by default). + expect(journeyFixture().pathway_progress ?? null).toBeNull(); + + const parsed = patientJourneySchema.safeParse({ + ...journeyFixture(), + pathway_progress: { + state: 'ok', + demo: true, + clinical_use: false, + notice: 'Demo — not clinical guidance', + pathway: { key: 'demo-heart-failure', label: 'Heart Failure', version_uuid: null, semantic_version: 'demo.1', digest: 'b'.repeat(64) }, + milestones: [ + { stable_key: 'hf_arrival', title: 'Admitted and assessed', phase: 'arrival', sequence: 0, status: 'completed', observed_at: null, expected: { day_offset_min: 0, day_offset_max: 0, display: null } }, + ], + summary: { completed: 1, current: 0, planned: 0, delayed: 0, canceled: 0, total: 1, current_stable_key: null, elements_met_label: '1 of 1 milestones complete' }, + source: 'synthetic_demo', + as_of: '2026-07-27T12:00:00Z', + }, + }); + expect(parsed.success).toBe(true); + expect(parsed.success && parsed.data.pathway_progress?.demo).toBe(true); + }); }); describe('alignAnchorMs / alignedTimeLabel', () => { From 20541ebf85e2659449a8d06adcf185cd34af7b9b Mon Sep 17 00:00:00 2001 From: Sanjay Udoshi Date: Mon, 27 Jul 2026 19:34:19 -0400 Subject: [PATCH 2/2] docs(flow4d): Phase D plan tick + devlog (ships dark) + D3 memo --- ...DEVLOG-flow4d-pathway-bridge-2026-07-27.md | 89 +++++++++++++++++++ ...JOURNEY-AND-CONFORMANCE-PLAN-2026-07-26.md | 16 ++-- 2 files changed, 97 insertions(+), 8 deletions(-) create mode 100644 docs/DEVLOG-flow4d-pathway-bridge-2026-07-27.md diff --git a/docs/DEVLOG-flow4d-pathway-bridge-2026-07-27.md b/docs/DEVLOG-flow4d-pathway-bridge-2026-07-27.md new file mode 100644 index 00000000..472a79d1 --- /dev/null +++ b/docs/DEVLOG-flow4d-pathway-bridge-2026-07-27.md @@ -0,0 +1,89 @@ +# DEVLOG — FLOW-4D Phase D: Governed DRG catalog → conformance bridge (ships dark) + +**Date:** 2026-07-27 **Branch:** `feature/flow4d-pathway-bridge` **PR:** #115 **Branch commit:** `64ca48cf` (squash hash filled in on merge) +**Plan:** [FLOW-4D-PATIENT-JOURNEY-AND-CONFORMANCE-PLAN §8 Phase D](./plans/FLOW-4D-PATIENT-JOURNEY-AND-CONFORMANCE-PLAN-2026-07-26.md) +**Memo:** [D3 CPG-on-FHIR decision](./plans/FLOW-4D-D3-CPG-ON-FHIR-DECISION-MEMO-2026-07-27.md) + +## What shipped + +The bridge between the governed 250-DRG care-pathway catalog and the Arena +conformance engine — built end-to-end, **shipped dark**. Nothing per-patient from +the DRG catalog reaches a screen without [SU]/clinical sign-off (plan G-9). The only +visible artifact is a synthetic demo overlay under the already-on demo flag. + +- **D1 `ReferenceModelCompiler`** (`app/Services/CarePathways/`) — pure `compile(meta, + milestones)` → the declarative portion of the sidecar `PATHWAYS` spec (ordered + activities, timing targets, data-driven deviation labels) + a content-address + sha256 digest; thin `compileVersion(id)` DB adapter returning `null` when a version + has no executable layer. +- **D2** `config/care-pathways-ocel-map.php` (near-empty) + `care-pathways:ocel-coverage` + — the honest unmapped-milestone gap report. +- **D3** decision memo (see above). +- **D4 `PathwayInstanceReadService`** — flag + `Schema::hasTable` guarded, append-only + reads via the `current_*_statuses` views, grant resolved by `source_encounter_id`. +- **D5 `FlowPathwayDemoService`** — curated HF milestones → the *same* pure compiler → + LOS-driven synthetic progress, attached to one designated demo patient in the + journey drawer (`PathwayProgressPanel`), gated by `care_pathways.demo`. +- Flags: new `services.flow4d.pathway_progress` (`FLOW4D_PATHWAY_PROGRESS_ENABLED`, + off) composed with `care-pathways.assignment_enabled` into the Inertia + `flow4d.pathway_progress_enabled`. + +No migration. 25 new PHP tests + 6 JS tests, all green; tsc, `vite build`, Pint, +`check-ui-canon.sh` clean. + +## Discoveries that shaped the design (from live-DB recon) + +1. **The executable layer is never persisted.** `care_pathways.versions` = 250 rows, + `sections` = 7000, but `milestone_definitions`/`activity_definitions` = **0** — the + catalog is drafted from prose on demand, not stored. Everything is `inactive` + (0 active releases, 0 approved/active versions). → The compiler is a pure transform + with a DB adapter that honestly returns `null` for every current version, and the + demo synthesizes curated milestones through that same pure core rather than reading + the (empty) catalog. Seed-independent, always visible. +2. **The sidecar reference model's operative part is a Python *callable*, not data.** + `arena/app/pathways.py::PATHWAYS[*]["evaluate"]` encodes imperative clinical timing + (SEP-3 3h, AHCAH visit floor) and is not serializable/derivable. PATHWAYS is + hardcoded at import with **no registration endpoint**. → The compiler emits only + the declarative spec (and asserts it never emits `evaluate`); D3's memo owns the + executable-representation question. No sidecar change. +3. **`patient_experience.pathway_*` tables don't exist on dev** (the `2026_07_22` + migration hasn't run there). → D4 is `Schema::hasTable`-guarded and inert on dev; + tested under `RefreshDatabase`. +4. **Flow ↔ patient_experience linkage is real and established:** + `encounter_access_grants.source_encounter_id` = flow `Encounter` PK (used across + Rounds/Messaging). D4 resolves the active grant through it — no invented join. + +## Design decisions + +- **Declarative compiler, not an executable one.** Ship the bespoke spec (matches the + running sidecar); adopt CPG-on-FHIR `PlanDefinition` as the canonical representation + as a serving-gated follow-up; defer a CQL engine until there's something to execute + (D3). +- **Demo ties D1→D5.** The investor artifact runs curated milestones through the real + compiler, then derives status purely from length of stay — honest scaffolding, + `clinical_use=false`, explicit "not clinical guidance" notice, no DB writes. +- **Two-gate dark serving.** Real progress needs `assignment_enabled` (service) AND the + composed Inertia flag (orchestrator) — belt-and-suspenders. The journey response + omits `pathway_progress` entirely when both are off (byte-identical inertness, + test-pinned). +- **F-9 palette respected.** `PathwayProgressPanel` reuses the sanctioned dark-only + navigator overlay palette; status is glyph + word (never colour alone). No net-new + hue introduced. The design-hook findings on the navigator CSS are the pre-existing + sanctioned exception (CLAUDE.md) — not touched. + +## Deploy / enablement note + +**Not enabled.** This PR is code only; it changes no prod flags. The serving path +(`FLOW4D_PATHWAY_PROGRESS_ENABLED` + `CARE_PATHWAYS_ASSIGNMENT_ENABLED`) stays off. +The demo overlay becomes visible wherever `CARE_PATHWAYS_DEMO_ENABLED` is on (dev + +investor prod) once the frontend is deployed — a synthetic, clearly-labeled pathway +in the journey drawer for one designated demo patient. Deploy is a normal +`./deploy.sh --frontend` (no migration, no `--db`); do it only on the usual cadence +with [SU] awareness, since it surfaces the demo overlay on the investor env. + +## Deferred (with serving; not scheduled) + +- The `PlanDefinition` projection output on the compiler (D3). +- Any real per-patient serving from the DRG catalog — [SU]/clinical, G-9. +- Authoring/persisting the executable milestone layer (`milestone_definitions`) for + governed versions, which the coverage report will then make substantive. diff --git a/docs/plans/FLOW-4D-PATIENT-JOURNEY-AND-CONFORMANCE-PLAN-2026-07-26.md b/docs/plans/FLOW-4D-PATIENT-JOURNEY-AND-CONFORMANCE-PLAN-2026-07-26.md index 8f0598e1..5cf44a59 100644 --- a/docs/plans/FLOW-4D-PATIENT-JOURNEY-AND-CONFORMANCE-PLAN-2026-07-26.md +++ b/docs/plans/FLOW-4D-PATIENT-JOURNEY-AND-CONFORMANCE-PLAN-2026-07-26.md @@ -259,15 +259,15 @@ Flag: `FLOW4D_CONFORMANCE_ENABLED` (config `services.flow4d.conformance`, defaul - Also fixed in-phase: canvas token clicks never opened the journey drawer (Phase B kind test `'patient'` vs userData `'patient-token'`); trace/follow effects no-oped when the journey resolved before the lazy scene chunk (sceneNonce re-run guard) **Acceptance MET (dev walk 2026-07-27, 11/11 checks):** deviant seeded sepsis case → amber bracket in-scene (exactly 1 under trace, all when idle) → panel reads "2 of 4 elements met · Antibiotic beyond the 3-hour target — Antibiotics 42 min past the 3 h target · against SEP-3 v1 · owner: critical care · as of {t} batch · 30-minute batch" → census scope isolates + chips → exception note landed `ops.recommendations(draft)/actions(draft)/approvals(pending)` with an identity-free payload. Flag off: gates 404, toggle/radio/legend absent, drawer byte-identical (pinned by tests). -### Phase D — Governed DRG catalog bridge (~4–5 d) `feature/flow4d-pathway-bridge` [C build; SU/clinical to ever serve] +### Phase D — Governed DRG catalog bridge (~4–5 d) `feature/flow4d-pathway-bridge` [C build; SU/clinical to ever serve] — **SHIPPED DARK (PR #115, branch `64ca48cf`); NOT deployed-visible beyond the demo overlay; serving stays off pending [SU]/clinical (G-9)** Everything here ships **dark** behind the existing deferred serving-flag family (G-9); the only visible artifact is the demo overlay under `CARE_PATHWAYS_DEMO_ENABLED`. -- [ ] D1 `app/Domain/CarePathways/ReferenceModelCompiler.php`: active-or-demo pathway version → sidecar reference spec (milestone_definitions.sequence + expected_range → ordered activities + timing targets + deviation labels); pure + unit-tested against fixture versions; emits the same spec shape `PATHWAYS` uses (sidecar gains a `POST /conformance/reference-models` registration or file-drop — decide in-phase; sidecar stays stateless per-request preferred) -- [ ] D2 Activity mapping table: pathway milestone stable_keys ↔ OCEL activities (starts as config map + coverage report command `care-pathways:ocel-coverage` listing unmappable milestones — the honest gap list clinical review will need) -- [ ] D3 (spike, timeboxed) Evaluate compiling via CPG-on-FHIR PlanDefinition + CQL as the canonical intermediate instead of a bespoke spec (§6.5; OQ-5) — outcome is a decision memo, not a dependency -- [ ] D4 `PathwayInstanceReadService` over `patient_experience.pathway_instances` + status events (append-only reads); journey drawer shows assigned-pathway stage/milestone progress **only when the relevant serving flag is on** — flag checks at service AND component -- [ ] D5 Demo overlay: `/care-pathways/demo`-style synthetic in-memory pathway instance attached to a demo patient in the drawer (no DB writes, no activation, labeled "Demo — not clinical guidance") — the investor-visible artifact -- [ ] Tests: compiler determinism + digest pinning (a version's compiled model is content-addressed), flag-off inertness (byte-identical responses), append-only discipline untouched -**Acceptance:** a governed pathway version compiles to a conformance-checkable reference model with a coverage report; demo overlay demonstrates the full loop on synthetic data; nothing clinical serves anywhere with flags off — proven by tests, asserted with G-9's convention (no inferring clinical approval). +- [x] D1 `app/Services/CarePathways/ReferenceModelCompiler.php` (placed with the care-pathways service cluster, not `app/Domain/`): version → the *declarative* portion of the sidecar spec (ordered activities + timing targets + data-driven deviation labels) + a content-address digest; pure `compile()` unit-tested (determinism, digest pin, ordering, both `expected_range` shapes) + a thin `compileVersion()` DB adapter that returns **null** for a version with no persisted executable layer — which, for the inactive release (`milestone_definitions` empty), is every version. **Scope note:** the compiler does NOT emit the sidecar's `evaluate` callable (imperative timing logic, not data-derivable); no sidecar registration endpoint added (PATHWAYS stays hardcoded/stateless — the honest fit given the callable). +- [x] D2 Activity mapping (`config/care-pathways-ocel-map.php`, near-empty by design) + `care-pathways:ocel-coverage` command reporting unmapped milestones + targets outside the OCEL vocabulary — the honest gap list clinical review will need (reports the empty layer honestly on the current catalog). +- [x] D3 (spike) CPG-on-FHIR PlanDefinition + CQL vs bespoke spec → decision memo `docs/plans/FLOW-4D-D3-CPG-ON-FHIR-DECISION-MEMO-2026-07-27.md`: adopt PlanDefinition as canonical *representation* (projection follow-up, gated with serving), defer CQL *execution* until there's an executable requirement, ship the bespoke declarative compiler now. No dependency added. +- [x] D4 `PathwayInstanceReadService` over `patient_experience.pathway_instances` + the `current_*_statuses` views (append-only reads); `available()` = `assignment_enabled` ∧ `Schema::hasTable` (inert on dev — tables absent + flag off); resolves the active grant via `encounter_access_grants.source_encounter_id`; flag check at service AND (belt-and-suspenders) the orchestrator/component. +- [x] D5 Demo overlay: `FlowPathwayDemoService` synthesizes curated HF milestones → the **same** pure compiler → LOS-driven progress attached to one designated demo patient (earliest-admit active inpatient) in the journey drawer; no DB writes, `clinical_use=false`, "Demo — not clinical guidance" notice; `PathwayProgressPanel` (status by glyph+word, F-9 scoped palette); new `flow4d.pathway_progress` serving flag composes with `assignment_enabled` (Inertia `flow4d.pathway_progress_enabled`); demo rides `care_pathways.demo`. +- [x] Tests: compiler determinism + digest pin (content-addressed); flag-off inertness (journey response omits `pathway_progress` — byte-identical); append-only discipline untouched (reads never write). 25 PHP + 6 JS, all green; tsc + vite build + check-ui-canon + Pint clean. +**Acceptance:** a governed pathway version compiles to a conformance-checkable reference model with a coverage report; demo overlay demonstrates the full loop on synthetic data; nothing clinical serves anywhere with flags off — proven by tests, asserted with G-9's convention (no inferring clinical approval). **Met.** Deferred-with-serving: the `PlanDefinition` projection (D3) and any real per-patient serving from the DRG catalog ([SU]/clinical, G-9). ### Phase E — Wayfinding & 2D structure (~4 d) `feature/flow4d-wayfinding` [C] — **SHIPPED + DEPLOYED PROD (PR #107, squash `5f01dd81`; deployed 2026-07-27 at `17dd51c6`)** - [x] E1 Minimap/floor-stack widget (camera footprint, selection dot, delay/deviation pips; click = fit-to-floor); CVD + never-color-alone treatment; suppress in wall/kiosk param — `minimapProjection.ts` (pure round-trip) + `NavigatorMinimap` SVG plate, polls camera/selection from the scene so the ~7 Hz camera stream never re-renders the orchestrator; `?wall=1` suppressed