Skip to content

Commit d10ea30

Browse files
authored
Merge pull request #372 from Acumenus-Data-Sciences/feature/htn-v5-real-analysis-p
feat(studies): real Analysis P (target-trial landmark emulation)
2 parents 4058aa4 + 4afd3e0 commit d10ea30

5 files changed

Lines changed: 291 additions & 70 deletions

File tree

backend/app/Console/Commands/StudyHtnV4.php

Lines changed: 168 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -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).'.';

docs/devlog/modules/studies/2026-07-04-htn-v5-frontend-surfacing.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,34 @@ the M and O frontend views show a green "Real CDM" provenance note.
121121
**Provenance now:** M + O = real CDM; N, P, Q, R, triangulation = fixture (need
122122
custom darkstar R endpoints — darkstar has no target-trial or IV endpoint).
123123

124+
## Follow-up 3 — real Analysis P (target-trial, landmark emulation)
125+
126+
Added `study:htn-v4 --action=run-p`. Implemented as a **landmark new-user
127+
target-trial emulation** rather than hand-writing/validating a full
128+
clone-censor-weight + IPCW endpoint (documented as the refinement): index = the
129+
**t2 + 90 d grace landmark**, so no clone contributes immortal person-time (the
130+
immortal-time check passes by construction). Strategy cohorts (additive,
131+
`scripts/sql/htn-v5-estimation-cohorts.sql`): **5457** = treated with an
132+
antihypertensive within 90 d (637), **5458** = not (102,671), both restricted to
133+
members alive & observed at the landmark. Run through the same proven darkstar
134+
CohortMethod estimation (PS matching + Cox + negative-control calibration).
135+
136+
**Result: withheld — PS AUC 0.913 ≥ 0.80** (treated vs untreated are near-perfectly
137+
separable → poor overlap; max |SMD| 0.0991 and equipoise 0.3507 pass). This is the
138+
true finding: the treatment/delay contrasts fail on positivity/overlap — the exact
139+
motivation for the O/P/R triangulation design.
140+
141+
**Gate-consistency fix (O + P):** the gate display had omitted the PS-AUC gate and
142+
used the covariate-balance SMD instead of `ps.max_smd_after`, so a contrast could
143+
show all-green-but-withheld. `runContrast` now mirrors `EstimationClearance`
144+
exactly (auc < 0.80 ∧ max_smd_after < 0.10 ∧ equipoise ≥ 0.30 ∧ calibrated); the
145+
`GateBanner` shows PS AUC; the withheld reason names the actual failing gate (O:
146+
max |SMD| 0.2434; P: PS AUC 0.913). `runContrast`/`estimationSummaryData`/
147+
`persistEstimationRow` are now shared by both O and P.
148+
149+
**Provenance now:** M + O + P = real CDM (O and P correctly withheld); N, Q, R,
150+
triangulation = fixture. R (2SRI IV) still needs a net-new darkstar endpoint.
151+
124152
### Hard environmental blockers (why the rest stays fixture)
125153
- **The R / HADES runtime is absent from this compose stack** (`r-runtime` = "no
126154
such service"). That makes **O (ATO), P (target-trial + IPCW), R (site IV /

0 commit comments

Comments
 (0)