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,592 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from core.database import DATA_ROOT, get_connection, init_db
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ── Known field bounds ───────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
KNOWN_BOUNDS: dict[str, tuple[float | None, float | None]] = {
|
|
15
|
+
"age": (0, 120),
|
|
16
|
+
"bmi": (10, 60),
|
|
17
|
+
"weight_kg": (20, 300),
|
|
18
|
+
"weight_lbs": (44, 660),
|
|
19
|
+
"height_cm": (50, 250),
|
|
20
|
+
"height_m": (0.5, 2.5),
|
|
21
|
+
"prior_lines": (0, None),
|
|
22
|
+
"prior_therapies": (0, None),
|
|
23
|
+
"num_lesions": (0, None),
|
|
24
|
+
"num_metastases": (0, None),
|
|
25
|
+
"systolic_bp": (50, 250),
|
|
26
|
+
"diastolic_bp": (30, 150),
|
|
27
|
+
"heart_rate": (20, 250),
|
|
28
|
+
"temperature_c": (32, 43),
|
|
29
|
+
"temperature_f": (89, 109),
|
|
30
|
+
"creatinine": (0.1, 15),
|
|
31
|
+
"hemoglobin_gdl": (3, 20),
|
|
32
|
+
"platelet_count": (5, 1500),
|
|
33
|
+
"wbc_count": (0.1, 100),
|
|
34
|
+
"neutrophil_count": (0, 50),
|
|
35
|
+
"lymphocyte_count": (0, 30),
|
|
36
|
+
"albumin_gdl": (0.5, 6),
|
|
37
|
+
"bmi": (10, 60),
|
|
38
|
+
"follow_up_days": (0, None),
|
|
39
|
+
"follow_up_months": (0, None),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── I/O helpers ──────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
def _fmt(v: Any) -> str:
|
|
46
|
+
if v is None:
|
|
47
|
+
return "—"
|
|
48
|
+
if isinstance(v, float):
|
|
49
|
+
return f"{v:.4f}"
|
|
50
|
+
return str(v)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _fmt_pct(p: float) -> str:
|
|
54
|
+
return f"{p * 100:.1f}%"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _col_header(text: str, width: int = 72) -> str:
|
|
58
|
+
return f"\n{'─' * width}\n{text}\n{'─' * width}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ── Core ─────────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
def run_forensics(study_id: str) -> Path:
|
|
64
|
+
"""Run all forensics checks and write forensics_report.md.
|
|
65
|
+
|
|
66
|
+
Returns the path to the written report.
|
|
67
|
+
"""
|
|
68
|
+
study_dir = DATA_ROOT / study_id
|
|
69
|
+
if not study_dir.exists():
|
|
70
|
+
raise FileNotFoundError(f"Study directory not found: {study_dir}")
|
|
71
|
+
|
|
72
|
+
conn = get_connection(study_id)
|
|
73
|
+
init_db(conn)
|
|
74
|
+
|
|
75
|
+
study_row = conn.execute(
|
|
76
|
+
"SELECT id, name, is_locked, unmasked_at, created_at FROM studies WHERE id=?",
|
|
77
|
+
(study_id,),
|
|
78
|
+
).fetchone()
|
|
79
|
+
if not study_row:
|
|
80
|
+
conn.close()
|
|
81
|
+
raise FileNotFoundError(f"Study {study_id} not found in database.")
|
|
82
|
+
|
|
83
|
+
study_info = dict(study_row)
|
|
84
|
+
state = study_info["is_locked"]
|
|
85
|
+
is_unmasked = state >= 2
|
|
86
|
+
|
|
87
|
+
raw_table = f"raw_{study_id}"
|
|
88
|
+
masked_table = f"raw_masked_{study_id}"
|
|
89
|
+
|
|
90
|
+
col_info = _get_column_info(conn, study_id)
|
|
91
|
+
n_rows = conn.execute(
|
|
92
|
+
f"SELECT COUNT(*) AS cnt FROM {raw_table}"
|
|
93
|
+
).fetchone()["cnt"]
|
|
94
|
+
|
|
95
|
+
lines: list[str] = []
|
|
96
|
+
lines.append(f"# Forensics Report — {study_info['name']}")
|
|
97
|
+
lines.append(f"")
|
|
98
|
+
lines.append(f"- **Study ID:** {study_id}")
|
|
99
|
+
lines.append(f"- **Rows:** {n_rows}")
|
|
100
|
+
lines.append(f"- **Columns:** {len(col_info)}")
|
|
101
|
+
lines.append(f"- **State:** {['pre-lock', 'locked', 'unmasked'][state]}")
|
|
102
|
+
lines.append(f"- **Unmasked:** {study_info['unmasked_at'] or 'N/A'}")
|
|
103
|
+
lines.append(f"- **Generated:** {datetime.now(timezone.utc).isoformat()}")
|
|
104
|
+
lines.append(f"")
|
|
105
|
+
|
|
106
|
+
# ── Tier 1: Impossible timelines ──
|
|
107
|
+
lines.append(_col_header("Tier 1 — Impossible Timelines"))
|
|
108
|
+
_check_impossible_timelines(conn, raw_table, masked_table, col_info,
|
|
109
|
+
n_rows, is_unmasked, lines)
|
|
110
|
+
|
|
111
|
+
# ── Tier 1: Duplicate rows ──
|
|
112
|
+
lines.append(_col_header("Tier 1 — Duplicate Rows"))
|
|
113
|
+
_check_duplicates(conn, raw_table, col_info, lines)
|
|
114
|
+
|
|
115
|
+
# ── Tier 1: Out-of-range values ──
|
|
116
|
+
lines.append(_col_header("Tier 1 — Out-of-Range Values"))
|
|
117
|
+
_check_out_of_range(conn, raw_table, col_info, lines)
|
|
118
|
+
|
|
119
|
+
# ── Tier 2: Benford's Law ──
|
|
120
|
+
lines.append(_col_header("Tier 2 — Benford's Law (First-Digit Analysis)"))
|
|
121
|
+
_check_benford(conn, raw_table, col_info, n_rows, lines)
|
|
122
|
+
|
|
123
|
+
# ── Tier 2: Digit-preference clustering ──
|
|
124
|
+
lines.append(_col_header("Tier 2 — Digit-Preference / Rounding Clustering"))
|
|
125
|
+
_check_digit_preference(conn, raw_table, col_info, n_rows, lines)
|
|
126
|
+
|
|
127
|
+
# ── Summary ──
|
|
128
|
+
lines.append(_col_header("Summary of Checks Run"))
|
|
129
|
+
n_pass = sum(1 for l in lines if l.strip().startswith("- ✓"))
|
|
130
|
+
n_flags = sum(1 for l in lines if l.strip().startswith("- ⚠"))
|
|
131
|
+
n_blocked = sum(1 for l in lines if l.strip().startswith("- ⛔"))
|
|
132
|
+
lines.append(f"")
|
|
133
|
+
lines.append(f"- **Checks passing:** {n_pass}")
|
|
134
|
+
lines.append(f"- **Flags raised:** {n_flags}")
|
|
135
|
+
lines.append(f"- **Checks blocked/skipped:** {n_blocked}")
|
|
136
|
+
lines.append(f"- **Note:** These findings are statistical screens, not proof of fabrication.")
|
|
137
|
+
lines.append(f"")
|
|
138
|
+
|
|
139
|
+
conn.close()
|
|
140
|
+
|
|
141
|
+
report_path = study_dir / "forensics_report.md"
|
|
142
|
+
report_path.write_text("\n".join(lines))
|
|
143
|
+
return report_path
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ── Column helpers ───────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
def _get_column_info(conn, study_id: str) -> dict[str, dict]:
|
|
149
|
+
"""Return {col_name: {role, data_type, is_masked}}."""
|
|
150
|
+
rows = conn.execute(
|
|
151
|
+
"SELECT column_name, role, data_type, is_masked FROM variables WHERE study_id=?",
|
|
152
|
+
(study_id,),
|
|
153
|
+
).fetchall()
|
|
154
|
+
return {r["column_name"]: dict(r) for r in rows}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _is_numeric_column(col_name: str, col_info: dict) -> bool:
|
|
158
|
+
"""Whether a column is expected to contain numeric values."""
|
|
159
|
+
cinfo = col_info.get(col_name, {})
|
|
160
|
+
dt = cinfo.get("data_type", "")
|
|
161
|
+
return dt in ("continuous", "time_to_event")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _get_numeric_values(conn, raw_table: str, col_name: str) -> list[float]:
|
|
165
|
+
"""Read numeric values from a TEXT column, skipping nulls/blanks."""
|
|
166
|
+
rows = conn.execute(
|
|
167
|
+
f"SELECT \"{col_name}\" FROM {raw_table} "
|
|
168
|
+
f"WHERE \"{col_name}\" IS NOT NULL AND \"{col_name}\" != ''"
|
|
169
|
+
).fetchall()
|
|
170
|
+
vals: list[float] = []
|
|
171
|
+
for (v,) in rows:
|
|
172
|
+
try:
|
|
173
|
+
vals.append(float(v))
|
|
174
|
+
except (ValueError, TypeError):
|
|
175
|
+
pass
|
|
176
|
+
return vals
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ── Tier 1: Impossible timelines ─────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
def _find_time_pairs(col_info: dict) -> list[tuple[str, str]]:
|
|
182
|
+
pairs: list[tuple[str, str]] = []
|
|
183
|
+
time_cols = [
|
|
184
|
+
c for c, info in col_info.items()
|
|
185
|
+
if info.get("data_type") == "time_to_event"
|
|
186
|
+
]
|
|
187
|
+
for i, a in enumerate(time_cols):
|
|
188
|
+
for b in time_cols[i + 1:]:
|
|
189
|
+
a_lower = a.lower().replace("_", "").replace("-", "")
|
|
190
|
+
b_lower = b.lower().replace("_", "").replace("-", "")
|
|
191
|
+
if "pfs" in a_lower and "os" in b_lower:
|
|
192
|
+
pairs.append((a, b))
|
|
193
|
+
elif "os" in a_lower and "pfs" in b_lower:
|
|
194
|
+
pairs.append((b, a))
|
|
195
|
+
elif "progressionfree" in a_lower and "overallsurvival" in b_lower:
|
|
196
|
+
pairs.append((a, b))
|
|
197
|
+
elif "overallsurvival" in a_lower and "progressionfree" in b_lower:
|
|
198
|
+
pairs.append((b, a))
|
|
199
|
+
return pairs
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _check_impossible_timelines(
|
|
203
|
+
conn, raw_table: str, masked_table: str,
|
|
204
|
+
col_info: dict, n_rows: int, is_unmasked: bool,
|
|
205
|
+
lines: list[str],
|
|
206
|
+
) -> None:
|
|
207
|
+
if not is_unmasked:
|
|
208
|
+
masked_exists = conn.execute(
|
|
209
|
+
"SELECT COUNT(*) AS cnt FROM sqlite_master WHERE type='table' AND name=?",
|
|
210
|
+
(masked_table,),
|
|
211
|
+
).fetchone()["cnt"] > 0
|
|
212
|
+
if masked_exists:
|
|
213
|
+
lines.append(f"⛔ Study is masked. Outcome data is in shadow table — "
|
|
214
|
+
f"unmask first or re-run with unmasked study.")
|
|
215
|
+
else:
|
|
216
|
+
lines.append(f"⛔ Study is masked and no shadow table found.")
|
|
217
|
+
lines.append(f" Skipped: impossible timeline checks need visible outcome data.")
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
time_pairs = _find_time_pairs(col_info)
|
|
221
|
+
if not time_pairs:
|
|
222
|
+
lines.append(f"→ No time-to-event column pairs found to compare.")
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
for shorter, longer in time_pairs:
|
|
226
|
+
rows = conn.execute(
|
|
227
|
+
f"SELECT CAST(\"{shorter}\" AS REAL) AS s, "
|
|
228
|
+
f" CAST(\"{longer}\" AS REAL) AS l "
|
|
229
|
+
f"FROM {raw_table} "
|
|
230
|
+
f"WHERE \"{shorter}\" IS NOT NULL AND \"{shorter}\" != '' "
|
|
231
|
+
f" AND \"{longer}\" IS NOT NULL AND \"{longer}\" != ''"
|
|
232
|
+
).fetchall()
|
|
233
|
+
n_valid = len(rows)
|
|
234
|
+
violations = [(i + 1, r["s"], r["l"]) for i, r in enumerate(rows)
|
|
235
|
+
if r["s"] > r["l"] > 0]
|
|
236
|
+
zero_or_neg = [(i + 1, r["s"], r["l"]) for i, r in enumerate(rows)
|
|
237
|
+
if r["s"] <= 0 or r["l"] <= 0]
|
|
238
|
+
|
|
239
|
+
lines.append(f"")
|
|
240
|
+
lines.append(f"**{shorter} vs {longer}** (PFS should ≤ OS):")
|
|
241
|
+
lines.append(f"- Valid rows: {n_valid} / {n_rows}")
|
|
242
|
+
if violations:
|
|
243
|
+
lines.append(f"- ⚠ **{len(violations)} row(s)** where {shorter} > {longer}:")
|
|
244
|
+
for row_num, s, l in violations:
|
|
245
|
+
lines.append(f" Row {row_num}: {shorter}={s:.0f}, {longer}={l:.0f} (Δ {s - l:.0f})")
|
|
246
|
+
else:
|
|
247
|
+
lines.append(f"- ✓ No rows where {shorter} > {longer}.")
|
|
248
|
+
if zero_or_neg:
|
|
249
|
+
lines.append(f"- ⚠ **{len(zero_or_neg)} row(s)** with non-positive duration:")
|
|
250
|
+
for row_num, s, l in zero_or_neg:
|
|
251
|
+
lines.append(f" Row {row_num}: {shorter}={s:.0f}, {longer}={l:.0f}")
|
|
252
|
+
else:
|
|
253
|
+
lines.append(f"- ✓ All durations positive.")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ── Tier 1: Duplicate rows ───────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
def _check_duplicates(
|
|
259
|
+
conn, raw_table: str, col_info: dict, lines: list[str],
|
|
260
|
+
) -> None:
|
|
261
|
+
all_cols = list(col_info.keys())
|
|
262
|
+
if not all_cols:
|
|
263
|
+
lines.append(f"→ No columns to check.")
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
# Exact duplicates
|
|
267
|
+
col_list = ", ".join(f'"{c}"' for c in all_cols)
|
|
268
|
+
dup_rows = conn.execute(
|
|
269
|
+
f"SELECT {col_list}, COUNT(*) AS cnt FROM {raw_table} "
|
|
270
|
+
f"GROUP BY {col_list} HAVING cnt > 1"
|
|
271
|
+
).fetchall()
|
|
272
|
+
if dup_rows:
|
|
273
|
+
lines.append(f"- ⚠ **{len(dup_rows)} exact duplicate row group(s)** found:")
|
|
274
|
+
for d in dup_rows:
|
|
275
|
+
lines.append(f" Appears {d['cnt']}×: " + ", ".join(
|
|
276
|
+
f"{c}={d[c]}" for c in all_cols[:4]
|
|
277
|
+
) + ("..." if len(all_cols) > 4 else ""))
|
|
278
|
+
else:
|
|
279
|
+
lines.append(f"- ✓ No exact duplicate rows.")
|
|
280
|
+
|
|
281
|
+
# Near-duplicates via patient_id
|
|
282
|
+
raw_cols = [r[1] for r in conn.execute(
|
|
283
|
+
f"PRAGMA table_info({raw_table})"
|
|
284
|
+
).fetchall() if r[1] != "row_id"]
|
|
285
|
+
id_col = _find_patient_id_col(col_info, raw_cols)
|
|
286
|
+
if not id_col:
|
|
287
|
+
lines.append(f"- → No patient_id column found; near-duplicate check skipped.")
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
# For each patient_id with multiple rows, check for conflicting values
|
|
291
|
+
pids = conn.execute(
|
|
292
|
+
f"SELECT \"{id_col}\", COUNT(*) AS cnt FROM {raw_table} "
|
|
293
|
+
f"GROUP BY \"{id_col}\" HAVING cnt > 1"
|
|
294
|
+
).fetchall()
|
|
295
|
+
if not pids:
|
|
296
|
+
lines.append(f"- ✓ No duplicate patient IDs (near-duplicates cannot occur).")
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
lines.append(f"- ⚠ **{len(pids)} patient ID(s)** appear more than once:")
|
|
300
|
+
seen_ids: set[str] = set()
|
|
301
|
+
for pid_row in pids:
|
|
302
|
+
pid = pid_row[id_col]
|
|
303
|
+
if pid in seen_ids:
|
|
304
|
+
continue
|
|
305
|
+
seen_ids.add(pid)
|
|
306
|
+
rows_for_pid = conn.execute(
|
|
307
|
+
f"SELECT * FROM {raw_table} "
|
|
308
|
+
f"WHERE \"{id_col}\" = ? ORDER BY row_id",
|
|
309
|
+
(pid,),
|
|
310
|
+
).fetchall()
|
|
311
|
+
keys = [k for k in rows_for_pid[0].keys() if k != "row_id"]
|
|
312
|
+
# Compare all non-id columns
|
|
313
|
+
conflicts: list[str] = []
|
|
314
|
+
first = dict(rows_for_pid[0])
|
|
315
|
+
for row_dict in (dict(r) for r in rows_for_pid[1:]):
|
|
316
|
+
for k in keys:
|
|
317
|
+
if k == id_col:
|
|
318
|
+
continue
|
|
319
|
+
if str(first.get(k, "")) != str(row_dict.get(k, "")):
|
|
320
|
+
conflicts.append(k)
|
|
321
|
+
conflict_str = f", conflicting: {', '.join(conflicts)}" if conflicts else ""
|
|
322
|
+
lines.append(
|
|
323
|
+
f" {id_col}={pid} appears {pid_row['cnt']}×{conflict_str}"
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
lines.append(f"- Note: Raw duplicate IDs are also caught by `lock --allow-duplicate-ids`.")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _find_patient_id_col(col_info: dict, raw_cols: list[str] | None = None) -> str | None:
|
|
330
|
+
candidates = ["patient_id", "patientid", "subject_id", "id", "ptid", "pt_id"]
|
|
331
|
+
search_in = list(col_info.keys()) if col_info else []
|
|
332
|
+
if raw_cols:
|
|
333
|
+
search_in = list(set(search_in + raw_cols))
|
|
334
|
+
for c in candidates:
|
|
335
|
+
if c in search_in:
|
|
336
|
+
return c
|
|
337
|
+
for c in search_in:
|
|
338
|
+
if "id" in c.lower() and c.lower() != "row_id":
|
|
339
|
+
return c
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# ── Tier 1: Out-of-range values ──────────────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
def _check_out_of_range(
|
|
346
|
+
conn, raw_table: str, col_info: dict, lines: list[str],
|
|
347
|
+
) -> None:
|
|
348
|
+
checked_any = False
|
|
349
|
+
for col_name, cinfo in col_info.items():
|
|
350
|
+
if not _is_numeric_column(col_name, col_info):
|
|
351
|
+
continue
|
|
352
|
+
bounds = KNOWN_BOUNDS.get(col_name.lower())
|
|
353
|
+
if bounds is None:
|
|
354
|
+
continue
|
|
355
|
+
lo, hi = bounds
|
|
356
|
+
vals = _get_numeric_values(conn, raw_table, col_name)
|
|
357
|
+
if not vals:
|
|
358
|
+
continue
|
|
359
|
+
checked_any = True
|
|
360
|
+
violations: list[tuple[int, float]] = []
|
|
361
|
+
for i, v in enumerate(vals, 1):
|
|
362
|
+
if (lo is not None and v < lo) or (hi is not None and v > hi):
|
|
363
|
+
violations.append((i, v))
|
|
364
|
+
if violations:
|
|
365
|
+
lines.append(
|
|
366
|
+
f"- ⚠ **{col_name}**: {len(violations)} value(s) outside "
|
|
367
|
+
f"bounds [{_fmt(lo)}, {_fmt(hi)}]: "
|
|
368
|
+
+ ", ".join(f"row≈{r} ({_fmt(v)})" for r, v in violations[:5])
|
|
369
|
+
+ ("..." if len(violations) > 5 else "")
|
|
370
|
+
)
|
|
371
|
+
else:
|
|
372
|
+
lines.append(f"- ✓ **{col_name}**: all {len(vals)} values within "
|
|
373
|
+
f"bounds [{_fmt(lo)}, {_fmt(hi)}].")
|
|
374
|
+
if not checked_any:
|
|
375
|
+
lines.append(f"- → No columns with declared bounds in this dataset.")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ── Tier 2: Benford's Law ────────────────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
def _is_benford_eligible(
|
|
381
|
+
vals: list[float], n_rows: int,
|
|
382
|
+
) -> tuple[bool, str]:
|
|
383
|
+
"""Check whether a column is eligible for Benford's Law analysis.
|
|
384
|
+
|
|
385
|
+
Returns (eligible, explanation).
|
|
386
|
+
"""
|
|
387
|
+
if not vals:
|
|
388
|
+
return False, "No numeric values found."
|
|
389
|
+
|
|
390
|
+
n = len(vals)
|
|
391
|
+
if n < 30:
|
|
392
|
+
return False, (
|
|
393
|
+
f"Sample too small (N={n} < 30). Benford's Law requires "
|
|
394
|
+
f"at least ~30–50 observations for a meaningful test."
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# Must be naturally occurring (not bounded by artificial limits)
|
|
398
|
+
mn, mx = min(vals), max(vals)
|
|
399
|
+
if mn <= 0:
|
|
400
|
+
return False, (
|
|
401
|
+
f"Values include non-positive numbers (min={mn}). "
|
|
402
|
+
f"Benford's Law applies to positive, naturally-occurring magnitudes."
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
# Must span at least 2 orders of magnitude
|
|
406
|
+
ratio = mx / mn
|
|
407
|
+
if ratio < 100:
|
|
408
|
+
return False, (
|
|
409
|
+
f"Insufficient range: max/mn={ratio:.1f} (< 100, i.e. < 2 orders of magnitude). "
|
|
410
|
+
f"Benford's Law requires values spanning multiple orders of magnitude."
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
# Check for artificial constraints (e.g., bounded scales)
|
|
414
|
+
uniq = len(set(vals))
|
|
415
|
+
if uniq < 10:
|
|
416
|
+
return False, (
|
|
417
|
+
f"Too few unique values ({uniq}). "
|
|
418
|
+
f"Likely an artificially constrained or categorical variable."
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
if n < 50:
|
|
422
|
+
return True, (
|
|
423
|
+
f"Marginal sample size (N={n} < 50). Results should be interpreted "
|
|
424
|
+
f"with caution — Benford deviations at this N are often noise."
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
return True, (
|
|
428
|
+
f"Eligible: N={n}, range {mn:.0f}–{mx:.0f} ({ratio:.0f}×, "
|
|
429
|
+
f"{uniq} unique values)."
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _benford_expected(n: int) -> list[float]:
|
|
434
|
+
"""Expected Benford proportion for first digits 1–9 given n observations."""
|
|
435
|
+
return [n * math.log10(1 + 1 / d) for d in range(1, 10)]
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _chi_square_test(observed: list[int], expected: list[float]) -> float:
|
|
439
|
+
"""Pearson chi-square statistic."""
|
|
440
|
+
stat = 0.0
|
|
441
|
+
for o, e in zip(observed, expected):
|
|
442
|
+
if e > 0:
|
|
443
|
+
stat += (o - e) ** 2 / e
|
|
444
|
+
return stat
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _first_digit_dist(vals: list[float]) -> Counter[int]:
|
|
448
|
+
"""Return counter of first digits (1–9)."""
|
|
449
|
+
cnt: Counter[int] = Counter()
|
|
450
|
+
for v in vals:
|
|
451
|
+
s = f"{v:.10e}" # scientific notation to avoid float issues
|
|
452
|
+
first = s[0]
|
|
453
|
+
if first in "123456789":
|
|
454
|
+
cnt[int(first)] += 1
|
|
455
|
+
return cnt
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _check_benford(
|
|
459
|
+
conn, raw_table: str, col_info: dict, n_rows: int, lines: list[str],
|
|
460
|
+
) -> None:
|
|
461
|
+
numeric_cols = [
|
|
462
|
+
c for c, info in col_info.items()
|
|
463
|
+
if _is_numeric_column(c, col_info)
|
|
464
|
+
]
|
|
465
|
+
if not numeric_cols:
|
|
466
|
+
lines.append(f"→ No numeric columns found to check.")
|
|
467
|
+
return
|
|
468
|
+
|
|
469
|
+
lines.append(f"")
|
|
470
|
+
lines.append(f"**Benford's Law eligibility by column:**")
|
|
471
|
+
lines.append(f"")
|
|
472
|
+
|
|
473
|
+
for col_name in numeric_cols:
|
|
474
|
+
vals = _get_numeric_values(conn, raw_table, col_name)
|
|
475
|
+
eligible, reason = _is_benford_eligible(vals, n_rows)
|
|
476
|
+
lines.append(f"**{col_name}**: {'✓' if eligible else '⛔'} {reason}")
|
|
477
|
+
|
|
478
|
+
if eligible:
|
|
479
|
+
fd = _first_digit_dist(vals)
|
|
480
|
+
total = sum(fd.values())
|
|
481
|
+
expected = _benford_expected(total)
|
|
482
|
+
|
|
483
|
+
lines.append(f"")
|
|
484
|
+
lines.append(f" | Digit | Observed | Expected (Benford) | Δ% |")
|
|
485
|
+
lines.append(f" |-------|----------|-------------------|-----|")
|
|
486
|
+
for d in range(1, 10):
|
|
487
|
+
obs = fd.get(d, 0)
|
|
488
|
+
exp = expected[d - 1]
|
|
489
|
+
delta_pct = (obs - exp) / exp * 100 if exp > 0 else 0
|
|
490
|
+
lines.append(
|
|
491
|
+
f" | {d} | {obs} ({_fmt_pct(obs / total)}) | "
|
|
492
|
+
f"{exp:.1f} ({_fmt_pct(exp / total)}) | "
|
|
493
|
+
f"{delta_pct:+.1f}% |"
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
chi2 = _chi_square_test(
|
|
497
|
+
[fd.get(d, 0) for d in range(1, 10)], expected
|
|
498
|
+
)
|
|
499
|
+
lines.append(f"")
|
|
500
|
+
lines.append(f" χ² statistic: {chi2:.2f} (8 df)")
|
|
501
|
+
if chi2 > 15.5:
|
|
502
|
+
lines.append(f" ⚠ Chi-square test suggests deviation from "
|
|
503
|
+
f"Benford distribution (χ² > 15.5 at α=0.05).")
|
|
504
|
+
else:
|
|
505
|
+
lines.append(f" ✓ First-digit distribution consistent with "
|
|
506
|
+
f"Benford's Law (χ² ≤ 15.5).")
|
|
507
|
+
lines.append(f"")
|
|
508
|
+
|
|
509
|
+
n_ineligible = sum(
|
|
510
|
+
1 for c in numeric_cols
|
|
511
|
+
if not _is_benford_eligible(_get_numeric_values(conn, raw_table, c), n_rows)[0]
|
|
512
|
+
)
|
|
513
|
+
if n_ineligible == len(numeric_cols):
|
|
514
|
+
lines.append(f"*No Benford-eligible columns found in this dataset.*")
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
# ── Tier 2: Digit-preference clustering ──────────────────────────────────────
|
|
518
|
+
|
|
519
|
+
def _check_digit_preference(
|
|
520
|
+
conn, raw_table: str, col_info: dict, n_rows: int, lines: list[str],
|
|
521
|
+
) -> None:
|
|
522
|
+
numeric_cols = [
|
|
523
|
+
c for c, info in col_info.items()
|
|
524
|
+
if _is_numeric_column(c, col_info)
|
|
525
|
+
]
|
|
526
|
+
if not numeric_cols:
|
|
527
|
+
lines.append(f"→ No numeric columns found to check.")
|
|
528
|
+
return
|
|
529
|
+
|
|
530
|
+
if n_rows < 10:
|
|
531
|
+
lines.append(f"⛔ Sample too small (N={n_rows} < 10) for digit-preference analysis.")
|
|
532
|
+
return
|
|
533
|
+
|
|
534
|
+
lines.append(f"")
|
|
535
|
+
for col_name in numeric_cols:
|
|
536
|
+
vals = _get_numeric_values(conn, raw_table, col_name)
|
|
537
|
+
if len(vals) < 10:
|
|
538
|
+
lines.append(f"**{col_name}**: ⛔ Only {len(vals)} valid values, skipping.")
|
|
539
|
+
continue
|
|
540
|
+
|
|
541
|
+
n = len(vals)
|
|
542
|
+
# Last-digit distribution for decimal values
|
|
543
|
+
has_decimals = any(v != int(v) for v in vals)
|
|
544
|
+
if has_decimals:
|
|
545
|
+
last_digit = Counter()
|
|
546
|
+
for v in vals:
|
|
547
|
+
s = f"{v:.10f}".rstrip("0")
|
|
548
|
+
if "." in s:
|
|
549
|
+
last = s[-1]
|
|
550
|
+
if last.isdigit():
|
|
551
|
+
last_digit[last] += 1
|
|
552
|
+
else:
|
|
553
|
+
last_digit["0"] += 1
|
|
554
|
+
else:
|
|
555
|
+
last_digit["0"] += 1
|
|
556
|
+
|
|
557
|
+
# Check for .0/.5 clustering
|
|
558
|
+
dot_zero = sum(
|
|
559
|
+
v for d, v in last_digit.items() if d == "0"
|
|
560
|
+
)
|
|
561
|
+
dot_five = sum(
|
|
562
|
+
v for d, v in last_digit.items() if d == "5"
|
|
563
|
+
)
|
|
564
|
+
expected_per_digit = n / 10 if n >= 10 else 0
|
|
565
|
+
lines.append(f"**{col_name}**: {n} values")
|
|
566
|
+
if dot_zero > expected_per_digit * 1.5:
|
|
567
|
+
excess = dot_zero - expected_per_digit
|
|
568
|
+
lines.append(
|
|
569
|
+
f" ⚠ **{dot_zero} values ({_fmt_pct(dot_zero / n)}) end in .0** "
|
|
570
|
+
f"(expected ~{expected_per_digit:.0f}, excess ~{excess:.0f})"
|
|
571
|
+
)
|
|
572
|
+
else:
|
|
573
|
+
lines.append(f" ✓ No excess rounding to .0.")
|
|
574
|
+
if dot_five > expected_per_digit * 1.5:
|
|
575
|
+
excess = dot_five - expected_per_digit
|
|
576
|
+
lines.append(
|
|
577
|
+
f" ⚠ **{dot_five} values ({_fmt_pct(dot_five / n)}) end in .5** "
|
|
578
|
+
f"(expected ~{expected_per_digit:.0f}, excess ~{excess:.0f})"
|
|
579
|
+
)
|
|
580
|
+
else:
|
|
581
|
+
lines.append(f" ✓ No excess rounding to .5.")
|
|
582
|
+
else:
|
|
583
|
+
lines.append(f"**{col_name}**: All {n} values are whole numbers.")
|
|
584
|
+
|
|
585
|
+
# Summary
|
|
586
|
+
lines.append(f"")
|
|
587
|
+
n_small = sum(
|
|
588
|
+
1 for c in numeric_cols
|
|
589
|
+
if len(_get_numeric_values(conn, raw_table, c)) < 10
|
|
590
|
+
)
|
|
591
|
+
if n_small > 0:
|
|
592
|
+
lines.append(f"*{n_small} column(s) skipped due to insufficient valid values.*")
|