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,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.*")