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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
66 changes: 66 additions & 0 deletions app/Console/Commands/CarePathwaysOcelCoverageCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

namespace App\Console\Commands;

use App\Services\CarePathways\OcelActivityCoverageService;
use Illuminate\Console\Command;

class CarePathwaysOcelCoverageCommand extends Command
{
protected $signature = 'care-pathways:ocel-coverage
{--json : Emit the coverage report as JSON}';

protected $description = 'Report which governed care-pathway milestones map to Arena OCEL activities — the honest gap list for clinical mapping review (FLOW-4D plan D2).';

public function handle(OcelActivityCoverageService $service): int
{
$report = $service->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;
}
}
10 changes: 10 additions & 0 deletions app/Http/Middleware/HandleInertiaRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
119 changes: 119 additions & 0 deletions app/Services/CarePathways/FlowPathwayDemoService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php

declare(strict_types=1);

namespace App\Services\CarePathways;

use Carbon\CarbonImmutable;

/**
* Builds the synthetic, clearly-labeled "assigned pathway" the 4D Navigator
* journey drawer shows for one designated demo patient (FLOW-4D plan Phase D5) —
* the investor-visible artifact of the governed-catalog → conformance bridge.
*
* It is honest scaffolding, not clinical guidance:
* - Curated milestones are compiled through the SAME pure ReferenceModelCompiler
* the governed catalog uses, so the demo exercises the real transformation.
* - Progress is derived purely from the patient's length of stay — no DB
* writes, no pathway_instances row, no activation.
* - The emitted DTO is `clinical_use=false` and carries an explicit
* "Demo — not clinical guidance" notice.
*
* Gated only by `care-pathways.demo.enabled` (on in dev/investor prod). It never
* touches — and is independent of — the serving flags (plan G-9).
*/
final class FlowPathwayDemoService
{
/**
* Curated Heart-Failure admission→transition milestones, echoing the
* existing heart-failure-awareness demo scenario. Day offsets drive the
* length-of-stay progression.
*
* @var list<array{stable_key:string, title:string, phase:string, sequence:int, expected_range:array{day_offset_min:int, day_offset_max:int}}>
*/
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<string, mixed>|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',
};
}
}
97 changes: 97 additions & 0 deletions app/Services/CarePathways/OcelActivityCoverageService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php

declare(strict_types=1);

namespace App\Services\CarePathways;

use Illuminate\Support\Facades\DB;

/**
* Reports how much of the governed catalog's compiled milestone vocabulary maps
* to Arena OCEL activities (FLOW-4D plan Phase D2) — the honest gap list a
* clinical mapping review needs before any per-patient conformance could ever be
* served from the DRG catalog.
*
* Read-only. Operates over the persisted executable layer (milestone_definitions);
* for the current inactive catalog release that layer is empty, so the report is
* honestly empty until milestones are authored (care-pathways:draft-executable-layer).
*/
final class OcelActivityCoverageService
{
public function __construct(private readonly ReferenceModelCompiler $compiler) {}

/**
* @return array{
* pathways: list<array<string, mixed>>,
* totals: array{executable_versions:int, activities:int, mapped:int, unmapped:int, unknown_targets:int, coverage:?float}
* }
*/
public function report(): array
{
/** @var array<string, string> $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,
],
];
}
}
Loading
Loading