@@ -44,7 +44,7 @@ class StudyHtnV4 extends Command
4444 use SourceAware;
4545
4646 protected $ signature = 'study:htn-v4
47- {--action=analyses : reuse-audit|analyses|run-o|report}
47+ {--action=analyses : reuse-audit|analyses|run-o|run-p| report}
4848 {--plan-version=v5 : analysis-plan version (avoids the reserved --version flag)}
4949 {--study=165 : study id}
5050 {--source=ACUMENUS : source key whose results schema holds the tables}
@@ -59,6 +59,11 @@ class StudyHtnV4 extends Command
5959
6060 private const O_NEG_CONTROLS = [5442 , 5443 , 5444 , 5445 , 5446 , 5447 , 5448 , 5449 ];
6161
62+ /** Analysis P landmark cohorts (index = t2 + 90 d): treated-within-grace vs not. */
63+ private const P_TREATED = 5457 ;
64+
65+ private const P_UNTREATED = 5458 ;
66+
6267 protected $ description = 'Hypertension v5 executor — runs the CDM-computable analyses (Analysis M comorbidity matrix); R-based causal analyses are skipped when the R runtime is absent ' ;
6368
6469 /** Delay-group / comparator populations (verified counts, study 165). */
@@ -123,6 +128,7 @@ public function handle(): int
123128 'reuse-audit ' => $ this ->reuseAudit ($ studyId ),
124129 'analyses ' => $ this ->runAnalyses ($ studyId ),
125130 'run-o ' => $ this ->runOverlapWeighted ($ studyId ),
131+ 'run-p ' => $ this ->runTargetTrial ($ studyId ),
126132 'report ' => $ this ->report ($ studyId ),
127133 default => tap (self ::FAILURE , fn () => $ this ->error ("Unknown action ' {$ action }'. " )),
128134 };
@@ -220,43 +226,92 @@ private function report(int $studyId): int
220226 }
221227
222228 /**
223- * Analysis O — the study's primary causal contrast (timely vs delayed),
224- * run for real through darkstar's proven CohortMethod estimation endpoint
225- * (PS matching + Cox + empirical calibration). Exact PSweight ATO is not in
226- * the endpoint; PS matching is the spec's named sensitivity and the estimand
227- * differences are noted. A failed estimability gate WITHHOLDS the effect
228- * (required behaviour) rather than reporting a blinded number.
229+ * Analysis O — the study's primary causal contrast (timely vs delayed), run
230+ * through darkstar's proven CohortMethod estimation (PS matching + Cox +
231+ * empirical calibration). Orientation: delayed as target / timely as
232+ * comparator (keeps the fits well-conditioned). A failed estimability gate
233+ * WITHHOLDS the effect (required behaviour), never a blinded number.
229234 */
230235 private function runOverlapWeighted (int $ studyId ): int
231236 {
232- $ this ->info ("Analysis O — timely (G1) vs delayed (G2+G3+G4) via darkstar CohortMethod · study {$ studyId }" );
237+ $ this ->info ("Analysis O — delayed (G2–G4) vs timely (G1) via darkstar CohortMethod · study {$ studyId }" );
238+
239+ $ r = $ this ->runContrast ($ studyId , self ::O_DELAYED , self ::O_TIMELY );
240+ if ($ r === null ) {
241+ return self ::FAILURE ;
242+ }
243+
244+ $ summaryData = [
245+ 'analysis_code ' => 'O ' ,
246+ 'label ' => 'Delay Effect — delayed (G2–G4) vs timely (G1), PS-matched Cox ' ,
247+ 'method ' => 'darkstar CohortMethod: 1:1 PS matching + Cox + EmpiricalCalibration. Exact PSweight ATO pending (WeightIt not in HADES image); PS matching is the spec-named sensitivity. ' ,
248+ ] + $ this ->estimationSummaryData ($ r );
249+
250+ $ this ->persistEstimationRow ($ studyId , 'overlap_weighted_effect ' , $ summaryData , $ r );
251+ $ this ->info ($ this ->contrastLine ('Analysis O ' , $ r ));
252+
253+ return self ::SUCCESS ;
254+ }
255+
256+ /**
257+ * Analysis P — target-trial emulation of "treat within 90 d of index vs not".
258+ * Implemented as a landmark new-user active-comparator design (index = the
259+ * t2 + 90 d landmark, so no clone contributes immortal person-time), run
260+ * through the same proven estimation pipeline. Full clone-censor-weight +
261+ * IPCW is a refinement noted in `method`.
262+ */
263+ private function runTargetTrial (int $ studyId ): int
264+ {
265+ $ this ->info ("Analysis P — target-trial (treat-within-90d vs not) landmark emulation · study {$ studyId }" );
266+
267+ // Target = not-treated (large), comparator = treated (small) — keeps the
268+ // Cox/negative-control fits well-conditioned, as for O.
269+ $ r = $ this ->runContrast ($ studyId , self ::P_UNTREATED , self ::P_TREATED );
270+ if ($ r === null ) {
271+ return self ::FAILURE ;
272+ }
273+
274+ $ summaryData = [
275+ 'analysis_code ' => 'P ' ,
276+ 'label ' => 'Target-Trial Emulation — treat within 90 d vs not (landmark) ' ,
277+ 'method ' => 'Landmark new-user target-trial emulation (index = t2 + 90 d); PS-matched Cox + EmpiricalCalibration. Full clone-censor-weight + IPCW is a refinement. ' ,
278+ 'grace_days ' => 90 ,
279+ 'immortal_time_check ' => 'PASS (landmark design — follow-up starts at the grace landmark) ' ,
280+ ] + $ this ->estimationSummaryData ($ r );
281+
282+ $ this ->persistEstimationRow ($ studyId , 'target_trial ' , $ summaryData , $ r );
283+ $ this ->info ($ this ->contrastLine ('Analysis P ' , $ r ));
233284
285+ return self ::SUCCESS ;
286+ }
287+
288+ /**
289+ * Run one PS-matched Cox contrast through darkstar, reusing the proven v4
290+ * design (analysis 64) with the given target/comparator cohorts. Returns the
291+ * gated, normalised result or null on error.
292+ *
293+ * @return array<string, mixed>|null
294+ */
295+ private function runContrast (int $ studyId , int $ target , int $ comparator ): ?array
296+ {
234297 $ source = Source::query ()->where ('source_key ' , $ this ->option ('source ' ))->first ();
235298 if (! $ source instanceof Source) {
236299 $ this ->error ("Source ' {$ this ->option ('source ' )}' not found. " );
237300
238- return self :: FAILURE ;
301+ return null ;
239302 }
240303
241- // Reuse the proven v4 delay-contrast design (analysis 64): same PS
242- // matching, Cox model, covariate exclusions and negative controls — only
243- // the cohorts change to the collapsed timely-vs-delayed exposure.
244304 $ base = EstimationAnalysis::query ()->whereKey (64 )->value ('design_json ' ) ?? [];
245-
246305 $ outcomeNames = [];
247306 foreach (self ::O_OUTCOMES as $ id => $ name ) {
248307 $ outcomeNames [(string ) $ id ] = $ name ;
249308 }
250309
251310 $ spec = [
252311 'source ' => HadesBridgeService::buildSourceSpec ($ source ),
253- // Orientation matches the proven v4 delay contrast (analysis 64):
254- // the larger delayed group is the target, timely (G1) the comparator,
255- // so the Cox/negative-control fits stay well-conditioned. The HR is
256- // therefore delayed-relative-to-timely (HR > 1 ⇒ delay is harmful).
257312 'cohorts ' => [
258- 'target_cohort_id ' => self :: O_DELAYED ,
259- 'comparator_cohort_id ' => self :: O_TIMELY ,
313+ 'target_cohort_id ' => $ target ,
314+ 'comparator_cohort_id ' => $ comparator ,
260315 'outcome_cohort_ids ' => array_keys (self ::O_OUTCOMES ),
261316 'outcome_names ' => $ outcomeNames ,
262317 ],
@@ -267,79 +322,127 @@ private function runOverlapWeighted(int $studyId): int
267322 ];
268323
269324 if ($ this ->option ('dry-run ' )) {
270- $ this ->line (' [dry-run] would POST estimation to darkstar (target ' . self :: O_TIMELY . ' vs comparator ' . self :: O_DELAYED . ' ). ' );
325+ $ this ->line (" [dry-run] would POST estimation to darkstar (target { $ target } vs comparator { $ comparator } ). " );
271326
272- return self :: SUCCESS ;
327+ return null ;
273328 }
274329
275330 $ this ->line (' Calling darkstar /analysis/estimation/run (CohortMethod, PS matching + negative-control calibration)… ' );
276331 $ raw = app (RService::class)->runEstimation ($ spec );
277332 if (($ raw ['status ' ] ?? null ) === 'error ' ) {
278333 $ this ->error (' darkstar estimation error: ' .($ raw ['message ' ] ?? 'unknown ' ));
279334
280- return self :: FAILURE ;
335+ return null ;
281336 }
282337
283338 $ normalized = EstimationResultNormalizer::normalize ($ raw );
284339 $ study = Study::find ($ studyId );
285340 $ cleared = $ study instanceof Study && EstimationClearance::isCleared ($ normalized , $ study );
286341 $ calibrated = EstimationClearance::isCalibrated ($ normalized );
287- $ estimable = $ cleared && $ calibrated ;
288342
289343 $ summary = is_array ($ normalized ['summary ' ] ?? null ) ? $ normalized ['summary ' ] : [];
290344 $ ps = is_array ($ normalized ['propensity_score ' ] ?? null ) ? $ normalized ['propensity_score ' ] : [];
291345 $ calibration = is_array ($ normalized ['calibration ' ] ?? null ) ? $ normalized ['calibration ' ] : [];
292346 $ balanceRaw = is_array ($ normalized ['covariate_balance ' ] ?? null ) ? $ normalized ['covariate_balance ' ] : [];
293- $ maxSmd = $ this ->maxAbsSmd ($ balanceRaw );
347+ // Mirror EstimationClearance exactly: it gates on ps.auc, ps.max_smd_after
348+ // and ps.equipoise. Fall back to the covariate-balance max only if the PS
349+ // block omits max_smd_after, so the displayed gates match the verdict.
294350 $ equipoise = isset ($ ps ['equipoise ' ]) && is_numeric ($ ps ['equipoise ' ]) ? (float ) $ ps ['equipoise ' ] : null ;
351+ $ psAuc = isset ($ ps ['auc ' ]) && is_numeric ($ ps ['auc ' ]) ? (float ) $ ps ['auc ' ] : null ;
352+ $ maxSmd = isset ($ ps ['max_smd_after ' ]) && is_numeric ($ ps ['max_smd_after ' ])
353+ ? round ((float ) $ ps ['max_smd_after ' ], 4 )
354+ : $ this ->maxAbsSmd ($ balanceRaw );
295355
296- $ summaryData = [
297- 'analysis_code ' => 'O ' ,
298- 'label ' => 'Delay Effect — delayed (G2–G4) vs timely (G1), PS-matched Cox ' ,
299- 'data_source ' => 'cdm ' ,
300- 'method ' => 'darkstar CohortMethod: 1:1 PS matching + Cox + EmpiricalCalibration. Exact PSweight ATO pending (WeightIt not in HADES image); PS matching is the spec-named sensitivity. ' ,
301- 'computed_at ' => now ()->toDateString (),
302- 'estimable ' => $ estimable ,
303- 'gates ' => [
304- 'max_smd ' => $ maxSmd ,
305- 'equipoise ' => $ equipoise ,
306- 'null_centered ' => $ calibrated ,
307- ],
356+ return [
357+ 'estimable ' => $ cleared && $ calibrated ,
358+ 'cleared ' => $ cleared ,
359+ 'calibrated ' => $ calibrated ,
308360 'target_count ' => isset ($ summary ['target_count ' ]) ? (int ) $ summary ['target_count ' ] : null ,
309361 'comparator_count ' => isset ($ summary ['comparator_count ' ]) ? (int ) $ summary ['comparator_count ' ] : null ,
310- 'estimates ' => $ estimable ? $ this ->oEstimates ($ normalized ) : [],
362+ 'ps_auc ' => $ psAuc ,
363+ 'max_smd ' => $ maxSmd ,
364+ 'equipoise ' => $ equipoise ,
311365 'balance ' => $ this ->oBalance ($ balanceRaw ),
312366 'calibration ' => [
313367 'ease ' => $ calibration ['ease ' ] ?? null ,
314368 'informative_negative_controls ' => $ calibration ['informative_negative_controls ' ] ?? null ,
315369 ],
316- 'withheld_reason ' => $ estimable ? null : $ this ->withheldReason ( $ maxSmd , $ equipoise , $ calibrated ),
370+ 'estimates ' => $ this ->oEstimates ( $ normalized ),
317371 ];
372+ }
318373
374+ /**
375+ * Shared summary_data fields for a gated estimation contrast.
376+ *
377+ * @param array<string, mixed> $r
378+ * @return array<string, mixed>
379+ */
380+ private function estimationSummaryData (array $ r ): array
381+ {
382+ $ estimable = $ r ['estimable ' ] === true ;
383+
384+ return [
385+ 'data_source ' => 'cdm ' ,
386+ 'computed_at ' => now ()->toDateString (),
387+ 'estimable ' => $ estimable ,
388+ 'gates ' => [
389+ 'ps_auc ' => $ r ['ps_auc ' ],
390+ 'max_smd ' => $ r ['max_smd ' ],
391+ 'equipoise ' => $ r ['equipoise ' ],
392+ 'null_centered ' => $ r ['calibrated ' ],
393+ ],
394+ 'target_count ' => $ r ['target_count ' ],
395+ 'comparator_count ' => $ r ['comparator_count ' ],
396+ 'estimates ' => $ estimable ? $ r ['estimates ' ] : [],
397+ 'balance ' => $ r ['balance ' ],
398+ 'calibration ' => $ r ['calibration ' ],
399+ 'withheld_reason ' => $ estimable ? null : $ this ->withheldReason (
400+ is_float ($ r ['ps_auc ' ]) ? $ r ['ps_auc ' ] : null ,
401+ is_float ($ r ['max_smd ' ]) ? $ r ['max_smd ' ] : null ,
402+ is_float ($ r ['equipoise ' ]) ? $ r ['equipoise ' ] : null ,
403+ $ r ['calibrated ' ] === true ,
404+ ),
405+ ];
406+ }
407+
408+ /**
409+ * @param array<string, mixed> $summaryData
410+ * @param array<string, mixed> $r
411+ */
412+ private function persistEstimationRow (int $ studyId , string $ resultType , array $ summaryData , array $ r ): void
413+ {
319414 $ result = StudyResult::query ()
320415 ->where ('study_id ' , $ studyId )
321- ->where ('result_type ' , ' overlap_weighted_effect ' )
416+ ->where ('result_type ' , $ resultType )
322417 ->first ();
323- if ($ result instanceof StudyResult) {
324- $ result ->summary_data = $ summaryData ;
325- $ result ->diagnostics = ['data_source ' => 'cdm ' , 'cleared ' => $ cleared , 'calibrated ' => $ calibrated ];
326- $ result ->is_publishable = $ estimable ;
327- $ result ->save ();
328- $ this ->line (' ✓ study_results overlap_weighted_effect updated to real CDM result ' );
329- } else {
330- $ this ->warn (' ⚠ no overlap_weighted_effect row to update (run the fixture seeder first). ' );
418+
419+ if (! $ result instanceof StudyResult) {
420+ $ this ->warn (" ⚠ no {$ resultType } row to update (run the fixture seeder first). " );
421+
422+ return ;
331423 }
332424
333- $ this ->info (sprintf (
334- 'Analysis O: estimable=%s · max|SMD|=%s · equipoise=%s · target/comparator=%d/%d ' ,
335- $ estimable ? 'true ' : 'false (withheld) ' ,
336- $ maxSmd === null ? '— ' : (string ) $ maxSmd ,
337- $ equipoise === null ? '— ' : (string ) $ equipoise ,
338- $ summaryData ['target_count ' ] ?? 0 ,
339- $ summaryData ['comparator_count ' ] ?? 0 ,
340- ));
425+ $ result ->summary_data = $ summaryData ;
426+ $ result ->diagnostics = ['data_source ' => 'cdm ' , 'cleared ' => $ r ['cleared ' ], 'calibrated ' => $ r ['calibrated ' ]];
427+ $ result ->is_publishable = $ r ['estimable ' ] === true ;
428+ $ result ->save ();
429+ $ this ->line (" ✓ study_results {$ resultType } updated to real CDM result " );
430+ }
341431
342- return self ::SUCCESS ;
432+ /**
433+ * @param array<string, mixed> $r
434+ */
435+ private function contrastLine (string $ label , array $ r ): string
436+ {
437+ return sprintf (
438+ '%s: estimable=%s · max|SMD|=%s · equipoise=%s · target/comparator=%d/%d ' ,
439+ $ label ,
440+ $ r ['estimable ' ] === true ? 'true ' : 'false (withheld) ' ,
441+ $ r ['max_smd ' ] === null ? '— ' : (string ) $ r ['max_smd ' ],
442+ $ r ['equipoise ' ] === null ? '— ' : (string ) $ r ['equipoise ' ],
443+ $ r ['target_count ' ] ?? 0 ,
444+ $ r ['comparator_count ' ] ?? 0 ,
445+ );
343446 }
344447
345448 /**
@@ -435,17 +538,20 @@ private function eValue(float $hr): float
435538 return round ($ rr + sqrt ($ rr * ($ rr - 1 )), 2 );
436539 }
437540
438- private function withheldReason (?float $ maxSmd , ?float $ equipoise , bool $ calibrated ): string
541+ private function withheldReason (?float $ psAuc , ? float $ maxSmd , ?float $ equipoise , bool $ calibrated ): string
439542 {
440543 $ fails = [];
441- if ($ maxSmd === null || $ maxSmd >= 0.1 ) {
442- $ fails [] = 'residual covariate imbalance (max |SMD| ' .($ maxSmd === null ? 'n/a ' : (string ) $ maxSmd ).' ≥ 0.1) ' ;
544+ if ($ psAuc === null || $ psAuc >= 0.80 ) {
545+ $ fails [] = 'PS AUC ' .($ psAuc === null ? 'n/a ' : (string ) $ psAuc ).' ≥ 0.80 (poor overlap / separable groups) ' ;
546+ }
547+ if ($ maxSmd === null || $ maxSmd >= 0.10 ) {
548+ $ fails [] = 'residual imbalance (max |SMD| ' .($ maxSmd === null ? 'n/a ' : (string ) $ maxSmd ).' ≥ 0.10) ' ;
443549 }
444- if ($ equipoise !== null && $ equipoise < 0.3 ) {
445- $ fails [] = 'insufficient equipoise (< 0.3 ) ' ;
550+ if ($ equipoise !== null && $ equipoise < 0.30 ) {
551+ $ fails [] = 'insufficient equipoise (< 0.30 ) ' ;
446552 }
447553 if (! $ calibrated ) {
448- $ fails [] = 'negative-control null not centered ' ;
554+ $ fails [] = 'negative-control calibration not established ' ;
449555 }
450556
451557 return $ fails === [] ? 'estimability gate failed ' : 'Effect withheld — ' .implode ('; ' , $ fails ).'. ' ;
0 commit comments