research-tool-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- app/backend/_bootstrap.py +15 -0
- app/backend/exports/verification_script.py +19 -0
- app/backend/main.py +59 -0
- app/backend/routers/__init__.py +1 -0
- app/backend/routers/execution.py +478 -0
- app/backend/routers/ingestion.py +233 -0
- app/backend/routers/planning.py +265 -0
- app/backend/routers/reporting.py +531 -0
- app/backend/state.py +44 -0
- core/__init__.py +0 -0
- core/cli/__init__.py +0 -0
- core/cli/main.py +1705 -0
- core/database.py +62 -0
- core/ingestion/__init__.py +0 -0
- core/ingestion/csv_loader.py +191 -0
- core/ingestion/variable_classifier.py +171 -0
- core/masking/__init__.py +0 -0
- core/masking/gate.py +128 -0
- core/models.py +138 -0
- core/planning/__init__.py +0 -0
- core/planning/diagnostics.py +89 -0
- core/planning/lock.py +232 -0
- core/planning/study_plan.py +73 -0
- core/planning/test_selector.py +518 -0
- core/provenance/__init__.py +0 -0
- core/provenance/hashing.py +38 -0
- core/provenance/tracker.py +105 -0
- core/reporting/__init__.py +62 -0
- core/reporting/appendix.py +58 -0
- core/reporting/bundle.py +378 -0
- core/reporting/excel_export.py +683 -0
- core/reporting/flowchart/__init__.py +20 -0
- core/reporting/flowchart/flowchart.py +511 -0
- core/reporting/forensics.py +592 -0
- core/reporting/forest_plot.py +614 -0
- core/reporting/lineage.py +562 -0
- core/reporting/manuscript_draft.py +726 -0
- core/reporting/plots.py +568 -0
- core/reporting/strobe_checklist.py +460 -0
- core/stats/__init__.py +0 -0
- core/stats/descriptive.py +104 -0
- core/stats/inferential.py +540 -0
- core/stats/multiple_comparisons.py +62 -0
- core/stats/post_hoc.py +62 -0
- exports/verification_script.py +19 -0
- research_tool_cli-0.1.0.dist-info/METADATA +16 -0
- research_tool_cli-0.1.0.dist-info/RECORD +93 -0
- research_tool_cli-0.1.0.dist-info/WHEEL +5 -0
- research_tool_cli-0.1.0.dist-info/entry_points.txt +2 -0
- research_tool_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- research_tool_cli-0.1.0.dist-info/top_level.txt +4 -0
- tests/__init__.py +0 -0
- tests/conftest.py +45 -0
- tests/test_amendments.py +296 -0
- tests/test_analyze_batch_robustness.py +162 -0
- tests/test_app_statistical_reporting.py +383 -0
- tests/test_benchmark_21.py +556 -0
- tests/test_bundle.py +277 -0
- tests/test_cox_ph_plan.py +498 -0
- tests/test_csv_loader.py +368 -0
- tests/test_end_to_end.py +302 -0
- tests/test_excel_export.py +164 -0
- tests/test_flowchart.py +244 -0
- tests/test_forensics.py +305 -0
- tests/test_forest_plot.py +374 -0
- tests/test_from_json.py +176 -0
- tests/test_latest_plan_version.py +164 -0
- tests/test_lineage.py +329 -0
- tests/test_lock_immutability.py +133 -0
- tests/test_m1_fk_violation.py +85 -0
- tests/test_m2_data_hash_consistency.py +40 -0
- tests/test_m3_correction_timing.py +59 -0
- tests/test_m4_dedup_field.py +59 -0
- tests/test_m5_excel_hash_scope.py +45 -0
- tests/test_m6_fisher_exact_naming.py +44 -0
- tests/test_m7_duplicate_study_plan.py +55 -0
- tests/test_m8_filter_superseded.py +58 -0
- tests/test_m9_table1_groupby.py +56 -0
- tests/test_manuscript_draft.py +289 -0
- tests/test_masking_gate.py +196 -0
- tests/test_masking_migration.py +111 -0
- tests/test_multiple_comparisons.py +56 -0
- tests/test_plan_validation.py +289 -0
- tests/test_plots.py +394 -0
- tests/test_post_hoc_tagging.py +49 -0
- tests/test_posthoc_analyze.py +203 -0
- tests/test_provenance_tracker.py +110 -0
- tests/test_roadmap_features.py +148 -0
- tests/test_stats_descriptive.py +57 -0
- tests/test_stats_inferential.py +380 -0
- tests/test_strobe_checklist.py +172 -0
- tests/test_test_selector.py +350 -0
- tests/test_variable_classifier.py +124 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: research-tool-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Retrospective clinical research tool with outcome-masking and provenance
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Dist: pandas>=2.0
|
|
8
|
+
Requires-Dist: scipy>=1.11
|
|
9
|
+
Requires-Dist: statsmodels>=0.14
|
|
10
|
+
Requires-Dist: lifelines>=0.27
|
|
11
|
+
Requires-Dist: tableone>=0.8
|
|
12
|
+
Requires-Dist: matplotlib>=3.8
|
|
13
|
+
Requires-Dist: openpyxl>=3.1
|
|
14
|
+
Requires-Dist: fastapi>=0.141.1
|
|
15
|
+
Requires-Dist: uvicorn>=0.52.0
|
|
16
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
app/backend/_bootstrap.py,sha256=SAx23rcUXwhvKi3lpSpU3mpxbPCBDooNIZy0SBTnSSc,423
|
|
2
|
+
app/backend/main.py,sha256=E5d3ikqABAf3BPR8xe6YlDwtZDiwMm7FSarFYiBN7xE,2456
|
|
3
|
+
app/backend/state.py,sha256=hvM3FkqIfimp1fTzMx-yjnlT3TlbmC8mi0CjRujEjlA,2010
|
|
4
|
+
app/backend/exports/verification_script.py,sha256=hCmuYtWjAJZpqmzm_I9Gog7-otFnI-2ZAVDioFTQEFc,627
|
|
5
|
+
app/backend/routers/__init__.py,sha256=o_veNOEGGrtVNmc2PqvBn3AO2L5w2mBcTJoLlkTlz5E,53
|
|
6
|
+
app/backend/routers/execution.py,sha256=cKRIIeHXanuYXx5BnkkcW7PYrieKjPijJVXw21NYn7s,19442
|
|
7
|
+
app/backend/routers/ingestion.py,sha256=PQrdgP5kVqtSoow2ELmMSxKt7BCqmmkGD4jc6pAPZ_Q,7860
|
|
8
|
+
app/backend/routers/planning.py,sha256=8ZRaNEsdCcBbWUsM4GMB9YLQWYLN2c9vu0FlkogIjUQ,9423
|
|
9
|
+
app/backend/routers/reporting.py,sha256=AY27F0-_PqCGqoXw7xfGibNRFhBrRplppsvtR07T0rk,25191
|
|
10
|
+
core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
core/database.py,sha256=0xrBAMw-FvOE0rLCWaxQBg_kPGcAqP2eDY34uGy3S7c,1767
|
|
12
|
+
core/models.py,sha256=tno6tVGkft6LlMOSsq3BMZXcojJXyyjqWXz4TSh2Nng,4810
|
|
13
|
+
core/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
core/cli/main.py,sha256=JeVPK6ogW92MPqpTV5sf9M3SOIWzW2tjkRWBg5kkzkM,75989
|
|
15
|
+
core/ingestion/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
core/ingestion/csv_loader.py,sha256=_CZZ3cZdXP5lHPPzDClP-4rCGwy7zCfAGM5b25PNB8Q,6772
|
|
17
|
+
core/ingestion/variable_classifier.py,sha256=4km47kjAcKQhmLFcAKjZqYTYivfVEbtU8i3J5vlq6gw,6552
|
|
18
|
+
core/masking/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
19
|
+
core/masking/gate.py,sha256=cWm2x-Bq5FiFoc7vxqCUX-LpZb1zXfqj9sW2xDXwhTY,4031
|
|
20
|
+
core/planning/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
21
|
+
core/planning/diagnostics.py,sha256=65d-VQr_dYZlfKzPQ6eLl7qCk0Qx_BBCINufMKn8PhM,3084
|
|
22
|
+
core/planning/lock.py,sha256=PZhIU6dMSpHhuq-FmI4PWrmYfJ41zffBC7-9z5vWXDQ,8476
|
|
23
|
+
core/planning/study_plan.py,sha256=EaJGO9hOR08sK9tMn7fqdcVZ3bbu8SSNIad4z-fBFZU,2685
|
|
24
|
+
core/planning/test_selector.py,sha256=lTZJhKohvsNmgQjpW9ejCt455AsiAkVFoNkM-pPvcVQ,18588
|
|
25
|
+
core/provenance/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
26
|
+
core/provenance/hashing.py,sha256=y5fOC7zQCmaBJj1w414_BZcLb7QnfKt0nUkMbo_iDhU,1241
|
|
27
|
+
core/provenance/tracker.py,sha256=Zqqe_ymJarJFrgeDwLeU0bQDttOTnXGkz6kfEi7tsGM,2911
|
|
28
|
+
core/reporting/__init__.py,sha256=g08sAieAAvGwvBH961e2WSQFPXua5xOfvJpnnigHPZU,2210
|
|
29
|
+
core/reporting/appendix.py,sha256=uKLPIj6CHFYRXCx5KnIw5Hd1LYspHpP_9L91dfQ208c,2230
|
|
30
|
+
core/reporting/bundle.py,sha256=qxey3WSy3VMA_IjXbna8LdfnCxg_2tU8qAS08YwA8lg,15935
|
|
31
|
+
core/reporting/excel_export.py,sha256=qGbjqb_t3TfV18-t_sB0eGvU5Tk__1o-XVVeUTQX0ys,27860
|
|
32
|
+
core/reporting/forensics.py,sha256=5B146teYI5aRRjE7oxRJKdu7ov_YIyBsRzq6UTEJkIU,22537
|
|
33
|
+
core/reporting/forest_plot.py,sha256=J46wjwY635K6Zwy1j2s3Zl0_rAUQ4bm-3Q-O4S24Ieo,23868
|
|
34
|
+
core/reporting/lineage.py,sha256=jcausgk7H-Ax4nRCXAR5cMh5IEh3GwBQbhvWXoGsyDE,20007
|
|
35
|
+
core/reporting/manuscript_draft.py,sha256=du-wd2B3ibL-97UvFaD_tPaashepbhLpnC2zAG-Ew5I,35883
|
|
36
|
+
core/reporting/plots.py,sha256=DdGR0hAGfOuNvRTyy2YjCzWjvpS3ofvfzIhZgcIRTvc,20589
|
|
37
|
+
core/reporting/strobe_checklist.py,sha256=VTflC3kuz6LjycOV_st3XtRgbWwBbhcU0MtxqSHbwv4,20142
|
|
38
|
+
core/reporting/flowchart/__init__.py,sha256=rHR7skK22xh-7_ZvDnjd5V_Y4aJB_vvMsJ0TbK1VRO4,396
|
|
39
|
+
core/reporting/flowchart/flowchart.py,sha256=oYwvOOFg3sh2a3NRT4YbYCBnL2ZTmatdjQtejiN5594,21161
|
|
40
|
+
core/stats/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
41
|
+
core/stats/descriptive.py,sha256=aKHZOurnmqp9H1tXNyAaYBZKTbP75F9xQVqxy7mbylQ,3155
|
|
42
|
+
core/stats/inferential.py,sha256=27YNhzDgtwMDq7xWkTT0n_PWo-J54xYMic66DsiGyfM,21333
|
|
43
|
+
core/stats/multiple_comparisons.py,sha256=Xf3aWcxC5EUMUeQ5_QLgVzeRYWR3rt2jJ95H3TGKBEQ,1963
|
|
44
|
+
core/stats/post_hoc.py,sha256=e-lvVATgz4r63clz2syHlswmEz7vb2cjqIwmGySFvjQ,1983
|
|
45
|
+
exports/verification_script.py,sha256=hCmuYtWjAJZpqmzm_I9Gog7-otFnI-2ZAVDioFTQEFc,627
|
|
46
|
+
research_tool_cli-0.1.0.dist-info/licenses/LICENSE,sha256=pak0F9cy-IuaMLXFmjYJe9_lIYUf6Et3tRH76enap1A,1070
|
|
47
|
+
tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
48
|
+
tests/conftest.py,sha256=PrWKP1NK0OVWhhiTxC74tOBRAdBIe3Gh82VAT5nib6Q,1618
|
|
49
|
+
tests/test_amendments.py,sha256=LPFAWwAYnV5ihRgQi6Bzxshe2hBm-CnJ9FJnskxuuV0,11566
|
|
50
|
+
tests/test_analyze_batch_robustness.py,sha256=bX_lMk7LK03FvAXN9soCtTF6AwQAY--0UYEeE49ym24,5965
|
|
51
|
+
tests/test_app_statistical_reporting.py,sha256=ykDimDlo5VaYPSqwWQUssABoUNRB5abRJPWa3edzhXE,16834
|
|
52
|
+
tests/test_benchmark_21.py,sha256=az9odKbxjgQx_TK7_5gdefV54VIrajH-jmzw_lAKOpY,21827
|
|
53
|
+
tests/test_bundle.py,sha256=fCTRjoinAo5Ca2RMdp5nJztYyCfs7Q_S9qiJjNFbV80,9723
|
|
54
|
+
tests/test_cox_ph_plan.py,sha256=EA8wcm6qcjP_inpKSv9Tqhc1j4fzpP2F3Ci9i5YvU98,17828
|
|
55
|
+
tests/test_csv_loader.py,sha256=2_zmuUbT8UnF42ASD9VfVwNhb6c4QcVzSlVubt9Nrrc,11439
|
|
56
|
+
tests/test_end_to_end.py,sha256=-_ndmea7epE2uusaEdv6kmRlyUk2hOWvnWyFY-3K1cQ,12706
|
|
57
|
+
tests/test_excel_export.py,sha256=kR5d-Q1KM2oYhq2e14afptVFcdO5rNPSv2WHcA8Rhz8,6390
|
|
58
|
+
tests/test_flowchart.py,sha256=rihtvXgWq5CyrvQH3PbutU2f_fCe4jXB9Z5x6sbBYsA,10004
|
|
59
|
+
tests/test_forensics.py,sha256=mM4qn_WOjUfmc8zQUHMwjxKl35S6RJhivA18lTuZZrA,11571
|
|
60
|
+
tests/test_forest_plot.py,sha256=L0RVhfCxvg74ufm820T6DmV9_SlJoE4DnRT20rgkyMk,16433
|
|
61
|
+
tests/test_from_json.py,sha256=r99iANNRISku6_HnMimU_1X9fRyf5DjTHv6GNTWXdAE,6209
|
|
62
|
+
tests/test_latest_plan_version.py,sha256=tHNdOkKjBYLvp329jJ9bMZAJ3ltfHiymUq5lzIivMR4,5655
|
|
63
|
+
tests/test_lineage.py,sha256=-E1eS-SgZ8USchIoExv0rw_uXXSNA997n6Tl_f9cSFA,12615
|
|
64
|
+
tests/test_lock_immutability.py,sha256=OEj4vG3bzLnSogZxIiyD0mqRRkQeqvsjJ__rNhhhEcE,4681
|
|
65
|
+
tests/test_m1_fk_violation.py,sha256=Dd6DLBw6wUH-HGKRL8fmL21s29xXoVCbVfcueElgIOM,2828
|
|
66
|
+
tests/test_m2_data_hash_consistency.py,sha256=r8eYDTtMUhJXeKf3DtXAwLPrrYp3sVHY18W96kSATgw,1614
|
|
67
|
+
tests/test_m3_correction_timing.py,sha256=yyFfoimRoPN9_CaftkUwatLa_qNwiLEM3prVzYKtoO0,2602
|
|
68
|
+
tests/test_m4_dedup_field.py,sha256=70dMAT_DGe0rAAmVv7e7avDaNoZJS4r_4KqSe3UEVNg,2400
|
|
69
|
+
tests/test_m5_excel_hash_scope.py,sha256=iAfeOwHGNK-tPQUYOkJgUF7d8hi4wIEI4d8O6bThSxM,1733
|
|
70
|
+
tests/test_m6_fisher_exact_naming.py,sha256=4Xx8KU3nBR6AmAuxrSi7s1UiGh0iN8MVya8JkOLm5Ng,1823
|
|
71
|
+
tests/test_m7_duplicate_study_plan.py,sha256=Ecn5GtfxtSIUelwM2Xj-SMIPhbb06s5wiV122lcYSMk,2054
|
|
72
|
+
tests/test_m8_filter_superseded.py,sha256=Gdr4xcQNeq3F4Osye6yzlg_XdwkHI_I2HFHMzIz75u0,2258
|
|
73
|
+
tests/test_m9_table1_groupby.py,sha256=hAd_rAiIYO0jRvO6bLRG0X8jMfRu2Rqssdya3fmq0lo,2079
|
|
74
|
+
tests/test_manuscript_draft.py,sha256=b_UhPzqf3KwFpjsKFUZTEdL6T4dq_S564pJ45wONd2I,11124
|
|
75
|
+
tests/test_masking_gate.py,sha256=Kcu5JJCmBfjIHnk-rjfH_dSgN1Bok03hZ3JGL7VO5EA,7307
|
|
76
|
+
tests/test_masking_migration.py,sha256=MtSv6fysXUYda4m0DAjmpJwHjNSMgnR8PT44hDHAEJo,4306
|
|
77
|
+
tests/test_multiple_comparisons.py,sha256=pjGbHaxZFehlv4XjVUmaJqXXA6hiYDgRPvsBN-wa1zY,1746
|
|
78
|
+
tests/test_plan_validation.py,sha256=jY65gOlU1WYuq1OH1Nm4FXS0aB2VmD5H2S_tW8MjNS0,10612
|
|
79
|
+
tests/test_plots.py,sha256=wj6ufFjMmYcNfc4_XneLbmT432LFcmV5ILchKAQ8Q9s,16511
|
|
80
|
+
tests/test_post_hoc_tagging.py,sha256=ogXzo3x-ebPGfppJd-MsE1IqeuxAspT5gpy9jmUmqIM,1652
|
|
81
|
+
tests/test_posthoc_analyze.py,sha256=H2SHNskPgJZqfxA7xDDYuata6IcpUCd0wg7lS3hFq6k,7491
|
|
82
|
+
tests/test_provenance_tracker.py,sha256=gvR2PecHWIYywWo8z5K0te3sQLBEqCM31H5wx3nAsoM,3326
|
|
83
|
+
tests/test_roadmap_features.py,sha256=ccdfw6VamQ4qmV1UHapJyr9M9eEG4pz2aet_dGX0aXc,5173
|
|
84
|
+
tests/test_stats_descriptive.py,sha256=ofYKXYCJQx1J-YC7pzCmWMvziH-wQzjVOLOrOthW0n4,2036
|
|
85
|
+
tests/test_stats_inferential.py,sha256=2JO48LCU0NzZYNgwF5v1VJ9tHQqwuC9OsFyWOiGNCF4,14932
|
|
86
|
+
tests/test_strobe_checklist.py,sha256=HavKdMTSuw4CUiZWZuRC90sjGHdTK2sUUIvs5okWrro,6468
|
|
87
|
+
tests/test_test_selector.py,sha256=R8TndFSdYZYOnlgKqWXOZHZWpKRaqx-0zpSZM-7ZRoQ,15000
|
|
88
|
+
tests/test_variable_classifier.py,sha256=rtMSgkEBptQiiWi6_WZ-ILoC05CtBMsBH98g30ltNEM,4605
|
|
89
|
+
research_tool_cli-0.1.0.dist-info/METADATA,sha256=kKA0onLNvpYT4fnbO0_uE3Sys96asB8t3UxaiDy8d_0,481
|
|
90
|
+
research_tool_cli-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
91
|
+
research_tool_cli-0.1.0.dist-info/entry_points.txt,sha256=SX_KdcqEHF-psCnClJXhYtxvR_hv0PTsU4ICVSHKOAY,53
|
|
92
|
+
research_tool_cli-0.1.0.dist-info/top_level.txt,sha256=N6yCvTfFcmNhrHyaC-db76SKeUuBNskl2EXUFGYq8Z4,23
|
|
93
|
+
research_tool_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Manjunath N K
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
tests/__init__.py
ADDED
|
File without changes
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Synthetic patient data generator for tests.
|
|
2
|
+
|
|
3
|
+
Never commits real patient data. This is the sole source of test data
|
|
4
|
+
for the entire project.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import csv
|
|
9
|
+
import io
|
|
10
|
+
import random
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def synthetic_patients(n: int = 30, seed: int = 42) -> str:
|
|
14
|
+
"""Return a CSV string of n synthetic patients.
|
|
15
|
+
|
|
16
|
+
Schema mimics a retrospective myeloma study:
|
|
17
|
+
age, sex, iss_stage, prior_lines, high_risk_cytogenetics,
|
|
18
|
+
treatment_arm, response_category, pfs_days, pfs_event, os_days, os_event
|
|
19
|
+
"""
|
|
20
|
+
rng = random.Random(seed)
|
|
21
|
+
rows = [
|
|
22
|
+
["age", "sex", "iss_stage", "prior_lines", "high_risk_cytogenetics",
|
|
23
|
+
"treatment_arm", "response_category", "pfs_days", "pfs_event", "os_days", "os_event"]
|
|
24
|
+
]
|
|
25
|
+
for _ in range(n):
|
|
26
|
+
age = rng.randint(45, 85)
|
|
27
|
+
sex = rng.choice(["M", "F"])
|
|
28
|
+
iss = rng.choice(["I", "II", "III"])
|
|
29
|
+
prior = rng.randint(0, 6)
|
|
30
|
+
high_risk = rng.choice(["yes", "no"])
|
|
31
|
+
arm = rng.choice(["A", "B"])
|
|
32
|
+
resp = rng.choices(
|
|
33
|
+
["CR", "PR", "MR", "SD", "PD"],
|
|
34
|
+
weights=[15, 30, 20, 20, 15],
|
|
35
|
+
)[0]
|
|
36
|
+
pfs_days = max(0, int(rng.expovariate(1 / 300)))
|
|
37
|
+
pfs_event = 1 if resp in ("PD",) or pfs_days < 200 else rng.choices([0, 1], weights=[40, 60])[0]
|
|
38
|
+
os_days = max(0, int(rng.expovariate(1 / 500)))
|
|
39
|
+
os_event = rng.choices([0, 1], weights=[30, 70])[0]
|
|
40
|
+
rows.append([age, sex, iss, prior, high_risk, arm, resp, pfs_days, pfs_event, os_days, os_event])
|
|
41
|
+
|
|
42
|
+
buf = io.StringIO()
|
|
43
|
+
w = csv.writer(buf)
|
|
44
|
+
w.writerows(rows)
|
|
45
|
+
return buf.getvalue()
|
tests/test_amendments.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Tests for the amendment workflow — pre-unmask and post-hoc amendments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from core.database import get_connection, init_db, DATA_ROOT
|
|
13
|
+
from core.masking.gate import seal_outcomes
|
|
14
|
+
from core.planning.study_plan import StudyPlan
|
|
15
|
+
from core.planning.lock import lock_plan, lock_amendment, load_plan, _next_version
|
|
16
|
+
from core.reporting.manuscript_draft import generate_draft
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
STUDY_ID = "test_amendments"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture(autouse=True)
|
|
23
|
+
def _setup():
|
|
24
|
+
p = DATA_ROOT / STUDY_ID
|
|
25
|
+
if p.exists():
|
|
26
|
+
shutil.rmtree(p)
|
|
27
|
+
p.mkdir(parents=True)
|
|
28
|
+
|
|
29
|
+
conn = get_connection(STUDY_ID)
|
|
30
|
+
init_db(conn)
|
|
31
|
+
conn.execute(
|
|
32
|
+
"INSERT OR REPLACE INTO studies (id, name, created_at, data_dir, study_type, is_locked) "
|
|
33
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
34
|
+
(STUDY_ID, "Amend Test", "2025-01-01", str(DATA_ROOT / STUDY_ID), "cohort", 0),
|
|
35
|
+
)
|
|
36
|
+
raw = f"raw_{STUDY_ID}"
|
|
37
|
+
conn.execute(f"CREATE TABLE IF NOT EXISTS {raw} (row_id INTEGER PRIMARY KEY, age TEXT, response TEXT, treatment_arm TEXT)")
|
|
38
|
+
conn.execute(f"INSERT INTO {raw} (age, response, treatment_arm) VALUES ('65', 'CR', 'A')")
|
|
39
|
+
conn.execute(f"INSERT INTO {raw} (age, response, treatment_arm) VALUES ('70', 'PR', 'B')")
|
|
40
|
+
conn.execute("DELETE FROM variables WHERE study_id=?", (STUDY_ID,))
|
|
41
|
+
conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'age', 'baseline', 'continuous')", (STUDY_ID,))
|
|
42
|
+
conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'response', 'outcome', 'categorical')", (STUDY_ID,))
|
|
43
|
+
conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'treatment_arm', 'baseline', 'categorical')", (STUDY_ID,))
|
|
44
|
+
conn.commit()
|
|
45
|
+
conn.close()
|
|
46
|
+
|
|
47
|
+
seal_outcomes(STUDY_ID)
|
|
48
|
+
yield
|
|
49
|
+
if p.exists():
|
|
50
|
+
shutil.rmtree(p)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _set_state(state: int):
|
|
54
|
+
conn = get_connection(STUDY_ID)
|
|
55
|
+
init_db(conn)
|
|
56
|
+
conn.execute("UPDATE studies SET is_locked=? WHERE id=?", (state, STUDY_ID))
|
|
57
|
+
conn.commit()
|
|
58
|
+
conn.close()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _lock_v1():
|
|
62
|
+
"""Lock a simple v1 plan (must be called while still masked)."""
|
|
63
|
+
plan = StudyPlan(study_id=STUDY_ID, study_type="cohort", primary_comparison="test",
|
|
64
|
+
planned_tests=[{"variable_name": "age", "test_name": "t_test"}])
|
|
65
|
+
lock_plan(STUDY_ID, plan)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _insert_analysis(is_pre_registered: int, p_value: float | None = 0.03,
|
|
69
|
+
test_name: str = "chi_square"):
|
|
70
|
+
conn = get_connection(STUDY_ID)
|
|
71
|
+
init_db(conn)
|
|
72
|
+
conn.execute(
|
|
73
|
+
"""INSERT INTO analysis_results
|
|
74
|
+
(study_id, study_plan_version, variable_ids_used, test_name,
|
|
75
|
+
statistic, p_value, status_json, is_pre_registered, computed_at)
|
|
76
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
77
|
+
(STUDY_ID, 1, "[]", test_name, 5.2, p_value,
|
|
78
|
+
json.dumps({"status": "completed"}),
|
|
79
|
+
is_pre_registered, "2025-01-01T00:00:00"),
|
|
80
|
+
)
|
|
81
|
+
conn.commit()
|
|
82
|
+
conn.close()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ── Test 1: Pre-unmask amend creates new version ──────────────────────────
|
|
86
|
+
|
|
87
|
+
def test_pre_unmask_amend_creates_new_version():
|
|
88
|
+
_set_state(1) # locked/masked
|
|
89
|
+
plan = StudyPlan(study_id=STUDY_ID, study_type="cohort", primary_comparison="test",
|
|
90
|
+
planned_tests=[{"variable_name": "response", "test_name": "chi_square"}])
|
|
91
|
+
lock_plan(STUDY_ID, plan) # v1
|
|
92
|
+
|
|
93
|
+
path = lock_amendment(
|
|
94
|
+
STUDY_ID,
|
|
95
|
+
amendment_reason="Added t-test",
|
|
96
|
+
planned_tests=[{"variable_name": "response", "test_name": "t_test"}],
|
|
97
|
+
)
|
|
98
|
+
assert "v2" in path.name, f"Expected v2, got {path.name}"
|
|
99
|
+
# v1 should still exist
|
|
100
|
+
v1_path = DATA_ROOT / STUDY_ID / "study_plan.v1.locked.json"
|
|
101
|
+
assert v1_path.exists(), "v1 should still exist"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ── Test 2: Pre-unmask amend refused post-unmask ─────────────────────────
|
|
105
|
+
|
|
106
|
+
def test_pre_unmask_amend_refused_after_unmask():
|
|
107
|
+
_lock_v1()
|
|
108
|
+
_set_state(2) # unmasked
|
|
109
|
+
with pytest.raises(RuntimeError, match="already been unmasked"):
|
|
110
|
+
lock_amendment(
|
|
111
|
+
STUDY_ID,
|
|
112
|
+
amendment_reason="Should fail",
|
|
113
|
+
planned_tests=[{"variable_name": "response", "test_name": "t_test"}],
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ── Test 3: Post-hoc amend refused pre-unmask ────────────────────────────
|
|
118
|
+
|
|
119
|
+
def test_post_hoc_amend_refused_before_unmask():
|
|
120
|
+
_set_state(1) # locked/masked, not unmasked
|
|
121
|
+
plan = StudyPlan(study_id=STUDY_ID, study_type="cohort", primary_comparison="test",
|
|
122
|
+
planned_tests=[{"variable_name": "response", "test_name": "chi_square"}])
|
|
123
|
+
lock_plan(STUDY_ID, plan) # v1
|
|
124
|
+
|
|
125
|
+
with pytest.raises(RuntimeError, match="before unmasking"):
|
|
126
|
+
lock_amendment(
|
|
127
|
+
STUDY_ID,
|
|
128
|
+
amendment_reason="Should fail",
|
|
129
|
+
post_hoc_tests=[{"variable_name": "response", "test_name": "t_test"}],
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ── Test 4: Post-hoc result is_pre_registered=0 ──────────────────────────
|
|
134
|
+
|
|
135
|
+
def test_amend_requires_reason():
|
|
136
|
+
with pytest.raises(ValueError, match="amendment_reason is required"):
|
|
137
|
+
lock_amendment(STUDY_ID, amendment_reason="",
|
|
138
|
+
planned_tests=[{"variable_name": "response", "test_name": "chi_square"}])
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ── Test 5: Draft with post-hoc results MUST contain the header ──────────
|
|
142
|
+
|
|
143
|
+
def test_draft_with_post_hoc_contains_header():
|
|
144
|
+
_lock_v1()
|
|
145
|
+
_set_state(2)
|
|
146
|
+
_insert_analysis(is_pre_registered=0) # post-hoc result
|
|
147
|
+
draft = generate_draft(STUDY_ID)
|
|
148
|
+
assert "Post-Hoc / Exploratory Analyses" in draft, \
|
|
149
|
+
"Draft with post-hoc results must contain the post-hoc section header"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ── Test 6: Count disclosure sentence appears ────────────────────────────
|
|
153
|
+
|
|
154
|
+
def test_count_disclosure_sentence_present():
|
|
155
|
+
_lock_v1()
|
|
156
|
+
_set_state(2)
|
|
157
|
+
_insert_analysis(is_pre_registered=0, p_value=0.03)
|
|
158
|
+
_insert_analysis(is_pre_registered=0, p_value=0.50, test_name="t_test")
|
|
159
|
+
draft = generate_draft(STUDY_ID)
|
|
160
|
+
assert "2 additional post-hoc/exploratory analyses" in draft, \
|
|
161
|
+
"Should disclose 2 post-hoc analyses"
|
|
162
|
+
assert "1 of the 2 post-hoc analyses reached p < 0.05" in draft, \
|
|
163
|
+
"Should disclose 1 significant out of 2"
|
|
164
|
+
assert "corrected for multiple comparisons within their own family" in draft, \
|
|
165
|
+
"Should mention separate-family correction"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ── Test 7: No post-hoc results → no post-hoc section ────────────────────
|
|
169
|
+
|
|
170
|
+
def test_no_post_hoc_no_section():
|
|
171
|
+
_lock_v1()
|
|
172
|
+
_set_state(2)
|
|
173
|
+
_insert_analysis(is_pre_registered=1) # only pre-registered
|
|
174
|
+
draft = generate_draft(STUDY_ID)
|
|
175
|
+
assert "Post-Hoc / Exploratory Analyses" not in draft, \
|
|
176
|
+
"Should not have post-hoc section when no post-hoc results exist"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ── Test 8: Export JSON contains analysis_summary ────────────────────────
|
|
180
|
+
|
|
181
|
+
def test_export_contains_analysis_summary():
|
|
182
|
+
_lock_v1()
|
|
183
|
+
_set_state(2)
|
|
184
|
+
_insert_analysis(is_pre_registered=1, p_value=0.02) # pre-registered
|
|
185
|
+
_insert_analysis(is_pre_registered=0, p_value=0.03) # post-hoc
|
|
186
|
+
draft = generate_draft(STUDY_ID)
|
|
187
|
+
# The count-disclosure sentence in the Discussion covers this
|
|
188
|
+
assert "1 additional post-hoc/exploratory analyses" in draft, \
|
|
189
|
+
"Count disclosure should show 1 post-hoc analysis"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_lock_plan_still_refuses_after_unmask():
|
|
193
|
+
"""lock_plan() must remain uncallable after unmasking — core HARKing guard."""
|
|
194
|
+
_lock_v1()
|
|
195
|
+
_set_state(2) # unmasked
|
|
196
|
+
plan = StudyPlan(study_id=STUDY_ID, study_type="cohort", primary_comparison="new test",
|
|
197
|
+
planned_tests=[{"variable_name": "response", "test_name": "chi_square"}])
|
|
198
|
+
with pytest.raises(RuntimeError, match="Cannot lock a plan after unmasking"):
|
|
199
|
+
lock_plan(STUDY_ID, plan)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ── Test: Amendment preserves cox_ph_models ──────────────────────────────
|
|
203
|
+
|
|
204
|
+
def test_amendment_preserves_cox_ph_models():
|
|
205
|
+
"""lock_amendment() must carry forward cox_ph_models from the prior plan version."""
|
|
206
|
+
from core.planning.study_plan import CoxPHModel
|
|
207
|
+
|
|
208
|
+
_set_state(1) # locked/masked
|
|
209
|
+
cox_model = CoxPHModel(
|
|
210
|
+
model_name="pfs_model",
|
|
211
|
+
survival_time_col="pfs_days",
|
|
212
|
+
event_col="pfs_event",
|
|
213
|
+
primary_treatment_col="treatment_arm",
|
|
214
|
+
covariate_cols=["age"],
|
|
215
|
+
rationale="Test model",
|
|
216
|
+
)
|
|
217
|
+
plan = StudyPlan(
|
|
218
|
+
study_id=STUDY_ID,
|
|
219
|
+
study_type="cohort",
|
|
220
|
+
primary_comparison="PFS by arm",
|
|
221
|
+
planned_tests=[{"variable_name": "response", "test_name": "chi_square"}],
|
|
222
|
+
cox_ph_models=[cox_model],
|
|
223
|
+
)
|
|
224
|
+
lock_plan(STUDY_ID, plan) # v1
|
|
225
|
+
|
|
226
|
+
# Amend: add a new test
|
|
227
|
+
lock_amendment(
|
|
228
|
+
STUDY_ID,
|
|
229
|
+
amendment_reason="Added t-test",
|
|
230
|
+
planned_tests=[{"variable_name": "response", "test_name": "t_test"}],
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# Load v2 and verify cox_ph_models survived
|
|
234
|
+
v2 = load_plan(STUDY_ID, version=2)
|
|
235
|
+
assert len(v2.cox_ph_models) == 1, \
|
|
236
|
+
f"Expected 1 cox_ph_model in v2, got {len(v2.cox_ph_models)}"
|
|
237
|
+
assert v2.cox_ph_models[0].model_name == "pfs_model"
|
|
238
|
+
assert v2.cox_ph_models[0].event_col == "pfs_event"
|
|
239
|
+
assert v2.cox_ph_models[0].covariate_cols == ["age"]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def test_analyze_dedup_no_rerun():
|
|
243
|
+
"""Analyze must skip tests that already have a completed result."""
|
|
244
|
+
_lock_v1()
|
|
245
|
+
_set_state(2)
|
|
246
|
+
import argparse
|
|
247
|
+
from core.cli.main import cmd_analyze
|
|
248
|
+
|
|
249
|
+
# First analyze — should run
|
|
250
|
+
ns = argparse.Namespace(study_id=STUDY_ID, force=False, post_hoc=False, rerun=False)
|
|
251
|
+
cmd_analyze(ns)
|
|
252
|
+
|
|
253
|
+
conn = get_connection(STUDY_ID)
|
|
254
|
+
count1 = conn.execute(
|
|
255
|
+
"SELECT COUNT(*) as cnt FROM analysis_results WHERE study_id=?", (STUDY_ID,)
|
|
256
|
+
).fetchone()["cnt"]
|
|
257
|
+
conn.close()
|
|
258
|
+
assert count1 == 1, f"Expected 1 result after first analyze, got {count1}"
|
|
259
|
+
|
|
260
|
+
# Second analyze — should skip
|
|
261
|
+
cmd_analyze(ns)
|
|
262
|
+
|
|
263
|
+
conn = get_connection(STUDY_ID)
|
|
264
|
+
count2 = conn.execute(
|
|
265
|
+
"SELECT COUNT(*) as cnt FROM analysis_results WHERE study_id=?", (STUDY_ID,)
|
|
266
|
+
).fetchone()["cnt"]
|
|
267
|
+
conn.close()
|
|
268
|
+
assert count2 == 1, f"Expected still 1 result after skip, got {count2}"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_analyze_rerun_creates_new_supersedes_old():
|
|
272
|
+
"""--rerun must produce a new result and mark old as superseded."""
|
|
273
|
+
_lock_v1()
|
|
274
|
+
_set_state(2)
|
|
275
|
+
import argparse
|
|
276
|
+
from core.cli.main import cmd_analyze
|
|
277
|
+
|
|
278
|
+
ns = argparse.Namespace(study_id=STUDY_ID, force=False, post_hoc=False, rerun=False)
|
|
279
|
+
cmd_analyze(ns)
|
|
280
|
+
|
|
281
|
+
# Rerun with --rerun
|
|
282
|
+
ns.rerun = True
|
|
283
|
+
cmd_analyze(ns)
|
|
284
|
+
|
|
285
|
+
conn = get_connection(STUDY_ID)
|
|
286
|
+
rows = conn.execute(
|
|
287
|
+
"SELECT id, superseded_previous_result_id FROM analysis_results WHERE study_id=? ORDER BY id",
|
|
288
|
+
(STUDY_ID,),
|
|
289
|
+
).fetchall()
|
|
290
|
+
conn.close()
|
|
291
|
+
assert len(rows) == 2, f"Expected 2 rows after rerun, got {len(rows)}"
|
|
292
|
+
# First row should have NULL superseded (it's the original)
|
|
293
|
+
assert rows[0]["superseded_previous_result_id"] is None
|
|
294
|
+
# Second row should point to first
|
|
295
|
+
assert rows[1]["superseded_previous_result_id"] == rows[0]["id"], \
|
|
296
|
+
f"Second row should supersede first. Got {rows[1]['superseded_previous_result_id']} vs {rows[0]['id']}"
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Regression tests for analyze batch robustness.
|
|
2
|
+
|
|
3
|
+
Bug: --test "8:kaplan_meier_logrank:..." stored ID "8" as variable_name,
|
|
4
|
+
but event_col derivation expected column name "pfs_days" → produced "8_event".
|
|
5
|
+
|
|
6
|
+
Bug: One test failure aborted entire batch — Cox PH never ran.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import shutil
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from core.database import get_connection, init_db, DATA_ROOT
|
|
18
|
+
from core.masking.gate import seal_outcomes, unmask_study
|
|
19
|
+
from core.planning.study_plan import StudyPlan
|
|
20
|
+
from core.planning.lock import lock_plan, load_plan
|
|
21
|
+
from core.cli.main import cmd_analyze
|
|
22
|
+
import argparse
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
STUDY_ID = "test_batch_robust"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture(autouse=True)
|
|
29
|
+
def _setup():
|
|
30
|
+
p = DATA_ROOT / STUDY_ID
|
|
31
|
+
if p.exists():
|
|
32
|
+
shutil.rmtree(p)
|
|
33
|
+
p.mkdir(parents=True)
|
|
34
|
+
|
|
35
|
+
conn = get_connection(STUDY_ID)
|
|
36
|
+
init_db(conn)
|
|
37
|
+
conn.execute(
|
|
38
|
+
"INSERT OR REPLACE INTO studies (id, name, created_at, data_dir, study_type, is_locked) "
|
|
39
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
40
|
+
(STUDY_ID, "Batch Robust Test", "2025-01-01", str(DATA_ROOT / STUDY_ID), "cohort", 0),
|
|
41
|
+
)
|
|
42
|
+
raw = f"raw_{STUDY_ID}"
|
|
43
|
+
conn.execute(f"CREATE TABLE IF NOT EXISTS {raw} (row_id INTEGER PRIMARY KEY, age TEXT, pfs_days TEXT, pfs_event TEXT, treatment_arm TEXT)")
|
|
44
|
+
for i in range(30):
|
|
45
|
+
arm = "A" if i % 2 == 0 else "B"
|
|
46
|
+
event = "1" if i % 3 == 0 else "0"
|
|
47
|
+
conn.execute(f"INSERT INTO {raw} (age, pfs_days, pfs_event, treatment_arm) VALUES (?, ?, ?, ?)",
|
|
48
|
+
(str(30 + i), str(100 + i * 5), event, arm))
|
|
49
|
+
conn.execute("DELETE FROM variables WHERE study_id=?", (STUDY_ID,))
|
|
50
|
+
conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'age', 'baseline', 'continuous')", (STUDY_ID,))
|
|
51
|
+
conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'pfs_days', 'outcome', 'time_to_event')", (STUDY_ID,))
|
|
52
|
+
conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'pfs_event', 'outcome', 'categorical')", (STUDY_ID,))
|
|
53
|
+
conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'treatment_arm', 'baseline', 'categorical')", (STUDY_ID,))
|
|
54
|
+
conn.commit()
|
|
55
|
+
conn.close()
|
|
56
|
+
|
|
57
|
+
seal_outcomes(STUDY_ID)
|
|
58
|
+
yield
|
|
59
|
+
if p.exists():
|
|
60
|
+
shutil.rmtree(p)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _set_state(state: int):
|
|
64
|
+
conn = get_connection(STUDY_ID)
|
|
65
|
+
init_db(conn)
|
|
66
|
+
conn.execute("UPDATE studies SET is_locked=? WHERE id=?", (state, STUDY_ID))
|
|
67
|
+
conn.commit()
|
|
68
|
+
conn.close()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_var_id_resolved_to_column_name():
|
|
72
|
+
"""Variable ID '8' in --test must resolve to column_name before event_col derivation."""
|
|
73
|
+
# Get the actual variable ID for pfs_days
|
|
74
|
+
conn = get_connection(STUDY_ID)
|
|
75
|
+
row = conn.execute("SELECT id FROM variables WHERE study_id=? AND column_name=?", (STUDY_ID, "pfs_days")).fetchone()
|
|
76
|
+
conn.close()
|
|
77
|
+
pfs_var_id = str(row["id"])
|
|
78
|
+
|
|
79
|
+
_set_state(1)
|
|
80
|
+
plan = StudyPlan(
|
|
81
|
+
study_id=STUDY_ID,
|
|
82
|
+
study_type="cohort",
|
|
83
|
+
primary_comparison="PFS by arm",
|
|
84
|
+
planned_tests=[
|
|
85
|
+
{"variable_name": pfs_var_id, "test_name": "kaplan_meier_logrank", "rationale": "KM test"},
|
|
86
|
+
],
|
|
87
|
+
)
|
|
88
|
+
lock_plan(STUDY_ID, plan)
|
|
89
|
+
_set_state(2)
|
|
90
|
+
unmask_study(STUDY_ID)
|
|
91
|
+
|
|
92
|
+
args = argparse.Namespace(study_id=STUDY_ID, force=False, post_hoc=False, rerun=False)
|
|
93
|
+
cmd_analyze(args)
|
|
94
|
+
|
|
95
|
+
conn = get_connection(STUDY_ID)
|
|
96
|
+
rows = conn.execute(
|
|
97
|
+
"SELECT test_name, status_json FROM analysis_results WHERE study_id=?",
|
|
98
|
+
(STUDY_ID,),
|
|
99
|
+
).fetchall()
|
|
100
|
+
conn.close()
|
|
101
|
+
|
|
102
|
+
km_results = [r for r in rows if r["test_name"] == "kaplan_meier_logrank"]
|
|
103
|
+
assert len(km_results) == 1
|
|
104
|
+
status = json.loads(km_results[0]["status_json"])
|
|
105
|
+
# Should succeed, not fail with "8_event not found"
|
|
106
|
+
assert status["status"] == "completed", f"Expected completed, got {status}"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_batch_continues_after_test_failure():
|
|
110
|
+
"""One test failure must not abort remaining tests in the batch."""
|
|
111
|
+
_set_state(1)
|
|
112
|
+
plan = StudyPlan(
|
|
113
|
+
study_id=STUDY_ID,
|
|
114
|
+
study_type="cohort",
|
|
115
|
+
primary_comparison="PFS by arm",
|
|
116
|
+
planned_tests=[
|
|
117
|
+
# This will fail: no event column "nonexistent_event"
|
|
118
|
+
{"variable_name": "pfs_days", "test_name": "kaplan_meier_logrank", "rationale": "KM test"},
|
|
119
|
+
# This should still run
|
|
120
|
+
{"variable_name": "age", "test_name": "t_test", "rationale": "Age comparison"},
|
|
121
|
+
],
|
|
122
|
+
)
|
|
123
|
+
lock_plan(STUDY_ID, plan)
|
|
124
|
+
_set_state(2)
|
|
125
|
+
unmask_study(STUDY_ID)
|
|
126
|
+
|
|
127
|
+
# Monkey-patch run_test to simulate failure for KM
|
|
128
|
+
import core.cli.main as main_mod
|
|
129
|
+
original_run_test = main_mod.run_test
|
|
130
|
+
|
|
131
|
+
def mock_run_test(test_name, data, **kwargs):
|
|
132
|
+
if test_name == "kaplan_meier_logrank":
|
|
133
|
+
raise ValueError("Simulated failure: no linked event column")
|
|
134
|
+
return original_run_test(test_name, data, **kwargs)
|
|
135
|
+
|
|
136
|
+
main_mod.run_test = mock_run_test
|
|
137
|
+
try:
|
|
138
|
+
args = argparse.Namespace(study_id=STUDY_ID, force=False, post_hoc=False, rerun=False)
|
|
139
|
+
cmd_analyze(args)
|
|
140
|
+
finally:
|
|
141
|
+
main_mod.run_test = original_run_test
|
|
142
|
+
|
|
143
|
+
conn = get_connection(STUDY_ID)
|
|
144
|
+
rows = conn.execute(
|
|
145
|
+
"SELECT test_name, status_json FROM analysis_results WHERE study_id=?",
|
|
146
|
+
(STUDY_ID,),
|
|
147
|
+
).fetchall()
|
|
148
|
+
conn.close()
|
|
149
|
+
|
|
150
|
+
# Both tests should have results
|
|
151
|
+
assert len(rows) == 2, f"Expected 2 results, got {len(rows)}"
|
|
152
|
+
|
|
153
|
+
km_result = [r for r in rows if r["test_name"] == "kaplan_meier_logrank"][0]
|
|
154
|
+
ttest_result = [r for r in rows if r["test_name"] == "t_test"][0]
|
|
155
|
+
|
|
156
|
+
km_status = json.loads(km_result["status_json"])
|
|
157
|
+
ttest_status = json.loads(ttest_result["status_json"])
|
|
158
|
+
|
|
159
|
+
# KM should be error, t_test should be completed
|
|
160
|
+
assert km_status["status"] == "error"
|
|
161
|
+
assert "Simulated failure" in km_status.get("reason", "")
|
|
162
|
+
assert ttest_status["status"] == "completed"
|