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.
Files changed (93) hide show
  1. app/backend/_bootstrap.py +15 -0
  2. app/backend/exports/verification_script.py +19 -0
  3. app/backend/main.py +59 -0
  4. app/backend/routers/__init__.py +1 -0
  5. app/backend/routers/execution.py +478 -0
  6. app/backend/routers/ingestion.py +233 -0
  7. app/backend/routers/planning.py +265 -0
  8. app/backend/routers/reporting.py +531 -0
  9. app/backend/state.py +44 -0
  10. core/__init__.py +0 -0
  11. core/cli/__init__.py +0 -0
  12. core/cli/main.py +1705 -0
  13. core/database.py +62 -0
  14. core/ingestion/__init__.py +0 -0
  15. core/ingestion/csv_loader.py +191 -0
  16. core/ingestion/variable_classifier.py +171 -0
  17. core/masking/__init__.py +0 -0
  18. core/masking/gate.py +128 -0
  19. core/models.py +138 -0
  20. core/planning/__init__.py +0 -0
  21. core/planning/diagnostics.py +89 -0
  22. core/planning/lock.py +232 -0
  23. core/planning/study_plan.py +73 -0
  24. core/planning/test_selector.py +518 -0
  25. core/provenance/__init__.py +0 -0
  26. core/provenance/hashing.py +38 -0
  27. core/provenance/tracker.py +105 -0
  28. core/reporting/__init__.py +62 -0
  29. core/reporting/appendix.py +58 -0
  30. core/reporting/bundle.py +378 -0
  31. core/reporting/excel_export.py +683 -0
  32. core/reporting/flowchart/__init__.py +20 -0
  33. core/reporting/flowchart/flowchart.py +511 -0
  34. core/reporting/forensics.py +592 -0
  35. core/reporting/forest_plot.py +614 -0
  36. core/reporting/lineage.py +562 -0
  37. core/reporting/manuscript_draft.py +726 -0
  38. core/reporting/plots.py +568 -0
  39. core/reporting/strobe_checklist.py +460 -0
  40. core/stats/__init__.py +0 -0
  41. core/stats/descriptive.py +104 -0
  42. core/stats/inferential.py +540 -0
  43. core/stats/multiple_comparisons.py +62 -0
  44. core/stats/post_hoc.py +62 -0
  45. exports/verification_script.py +19 -0
  46. research_tool_cli-0.1.0.dist-info/METADATA +16 -0
  47. research_tool_cli-0.1.0.dist-info/RECORD +93 -0
  48. research_tool_cli-0.1.0.dist-info/WHEEL +5 -0
  49. research_tool_cli-0.1.0.dist-info/entry_points.txt +2 -0
  50. research_tool_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  51. research_tool_cli-0.1.0.dist-info/top_level.txt +4 -0
  52. tests/__init__.py +0 -0
  53. tests/conftest.py +45 -0
  54. tests/test_amendments.py +296 -0
  55. tests/test_analyze_batch_robustness.py +162 -0
  56. tests/test_app_statistical_reporting.py +383 -0
  57. tests/test_benchmark_21.py +556 -0
  58. tests/test_bundle.py +277 -0
  59. tests/test_cox_ph_plan.py +498 -0
  60. tests/test_csv_loader.py +368 -0
  61. tests/test_end_to_end.py +302 -0
  62. tests/test_excel_export.py +164 -0
  63. tests/test_flowchart.py +244 -0
  64. tests/test_forensics.py +305 -0
  65. tests/test_forest_plot.py +374 -0
  66. tests/test_from_json.py +176 -0
  67. tests/test_latest_plan_version.py +164 -0
  68. tests/test_lineage.py +329 -0
  69. tests/test_lock_immutability.py +133 -0
  70. tests/test_m1_fk_violation.py +85 -0
  71. tests/test_m2_data_hash_consistency.py +40 -0
  72. tests/test_m3_correction_timing.py +59 -0
  73. tests/test_m4_dedup_field.py +59 -0
  74. tests/test_m5_excel_hash_scope.py +45 -0
  75. tests/test_m6_fisher_exact_naming.py +44 -0
  76. tests/test_m7_duplicate_study_plan.py +55 -0
  77. tests/test_m8_filter_superseded.py +58 -0
  78. tests/test_m9_table1_groupby.py +56 -0
  79. tests/test_manuscript_draft.py +289 -0
  80. tests/test_masking_gate.py +196 -0
  81. tests/test_masking_migration.py +111 -0
  82. tests/test_multiple_comparisons.py +56 -0
  83. tests/test_plan_validation.py +289 -0
  84. tests/test_plots.py +394 -0
  85. tests/test_post_hoc_tagging.py +49 -0
  86. tests/test_posthoc_analyze.py +203 -0
  87. tests/test_provenance_tracker.py +110 -0
  88. tests/test_roadmap_features.py +148 -0
  89. tests/test_stats_descriptive.py +57 -0
  90. tests/test_stats_inferential.py +380 -0
  91. tests/test_strobe_checklist.py +172 -0
  92. tests/test_test_selector.py +350 -0
  93. 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ research-tool = core.cli.main:main
@@ -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.
@@ -0,0 +1,4 @@
1
+ app
2
+ core
3
+ exports
4
+ tests
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()
@@ -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"