irw-validate 1.0.0__tar.gz
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.
- irw_validate-1.0.0/PKG-INFO +161 -0
- irw_validate-1.0.0/README.md +142 -0
- irw_validate-1.0.0/__init__.py +28 -0
- irw_validate-1.0.0/_checks.py +325 -0
- irw_validate-1.0.0/cli.py +143 -0
- irw_validate-1.0.0/compat.py +17 -0
- irw_validate-1.0.0/contract.py +214 -0
- irw_validate-1.0.0/core.py +343 -0
- irw_validate-1.0.0/extra.py +241 -0
- irw_validate-1.0.0/irw_validate.egg-info/PKG-INFO +161 -0
- irw_validate-1.0.0/irw_validate.egg-info/SOURCES.txt +44 -0
- irw_validate-1.0.0/irw_validate.egg-info/dependency_links.txt +1 -0
- irw_validate-1.0.0/irw_validate.egg-info/entry_points.txt +2 -0
- irw_validate-1.0.0/irw_validate.egg-info/requires.txt +7 -0
- irw_validate-1.0.0/irw_validate.egg-info/top_level.txt +1 -0
- irw_validate-1.0.0/live_copies.py +200 -0
- irw_validate-1.0.0/live_cov_range.py +127 -0
- irw_validate-1.0.0/live_dup.py +206 -0
- irw_validate-1.0.0/model.py +113 -0
- irw_validate-1.0.0/pyproject.toml +70 -0
- irw_validate-1.0.0/repair_cov_age.py +126 -0
- irw_validate-1.0.0/repair_dedupe.py +145 -0
- irw_validate-1.0.0/repair_id_collision.py +169 -0
- irw_validate-1.0.0/repair_occasion.py +122 -0
- irw_validate-1.0.0/setup.cfg +4 -0
- irw_validate-1.0.0/sweep_legacy.py +148 -0
- irw_validate-1.0.0/tests/test_contract.py +114 -0
- irw_validate-1.0.0/tests/test_item_variants.py +75 -0
- irw_validate-1.0.0/tests/test_response_tokens.py +148 -0
- irw_validate-1.0.0/tests/test_validate.py +422 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: irw-validate
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Check a table against the Item Response Warehouse format, with an exit code
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://itemresponsewarehouse.org
|
|
7
|
+
Project-URL: Source, https://github.com/ben-domingue/irw/tree/main/irw_validate
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Science/Research
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Scientific/Engineering
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: pandas>=1.3.0
|
|
15
|
+
Provides-Extra: live
|
|
16
|
+
Requires-Dist: redivis>=0.18.0; extra == "live"
|
|
17
|
+
Provides-Extra: rdata
|
|
18
|
+
Requires-Dist: pyreadr; extra == "rdata"
|
|
19
|
+
|
|
20
|
+
# `irw_validate` — one format validator, with an exit code
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
irw-validate out/*.csv # exit 1 if anything blocks
|
|
24
|
+
irw-validate out/x.csv --profile core # the validate_irw.R subset
|
|
25
|
+
irw-validate out/x.csv --strict # warnings block too
|
|
26
|
+
irw-validate out/x.csv --json # for CI
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Exit codes: `0` ok · `1` something blocks · `2` bad input. Same contract as `red_up`.
|
|
30
|
+
|
|
31
|
+
## Installing
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
pip install irw-validate # once published
|
|
35
|
+
pip install -e /path/to/irw/src/irw_validate # from a checkout
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
pandas and the standard library, nothing else — no Redivis, no credentials, no
|
|
39
|
+
network. That is deliberate: this is the thing an outside contributor runs
|
|
40
|
+
against their own file before depositing it, and they should not have to clone
|
|
41
|
+
the pipeline or hold a token to do it.
|
|
42
|
+
|
|
43
|
+
Two optional extras exist for cases that are not that:
|
|
44
|
+
|
|
45
|
+
| Extra | Adds | Needed for |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| `[rdata]` | pyreadr | validating `.Rdata`/`.rda`/`.rds` directly. Converting to CSV first needs nothing. |
|
|
48
|
+
| `[live]` | redivis | the `live_*` and `repair_*` modules, which read published tables. Pipeline tools; `validate_file` never touches the network. |
|
|
49
|
+
|
|
50
|
+
Until 2026-09-09 this package shipped inside `irw-red-up`, the uploader
|
|
51
|
+
distribution, so the only way to get the validator was to install the writer.
|
|
52
|
+
That was an accident of packaging: `../pyproject.toml` keeps `red_up` out of
|
|
53
|
+
Python-pkg because a write-scoped uploader would change what that package is,
|
|
54
|
+
and none of that reasoning applies to a checker that opens a CSV.
|
|
55
|
+
|
|
56
|
+
`irw-validate` is a console script. In a checkout without the install,
|
|
57
|
+
`python3 -m irw_validate.cli` works from `src/`.
|
|
58
|
+
|
|
59
|
+
## Why this exists
|
|
60
|
+
|
|
61
|
+
The checks were forked, and neither half could gate anything:
|
|
62
|
+
|
|
63
|
+
| | checks | callers | exit code |
|
|
64
|
+
|---|---|---|---|
|
|
65
|
+
| `misc/validate_irw.R` | 5 | **nothing in the repo** | no |
|
|
66
|
+
| `irw_triage_updated.py::run_qc` | ~20 | 50 scripts in `data/` | no — its `__main__` exits 0 however many fail |
|
|
67
|
+
|
|
68
|
+
So "is this table valid?" had two answers and no way to act on either. This is
|
|
69
|
+
roadmap item 1 (`ben-domingue/irw#1703`), sub-items 1.3 and 1.4 — the other half
|
|
70
|
+
of the work `red_up` started.
|
|
71
|
+
|
|
72
|
+
It also makes `datastandard.md` executable. The standard is 348 lines of rules
|
|
73
|
+
stated in prose — a sample floor of 100 unique ids, table names ≤40 characters,
|
|
74
|
+
`[id, item, resp]` first — and until now not one was enforced by anything.
|
|
75
|
+
`ARCHITECTURE.md`'s Rule 2 asks for exactly this: *where a rule can be made
|
|
76
|
+
executable, make it executable instead of writing it down.*
|
|
77
|
+
|
|
78
|
+
## Profiles, and why severity is not a property of a check
|
|
79
|
+
|
|
80
|
+
**Severity depends on the (check, profile) pair.** This is the central design
|
|
81
|
+
decision, and it exists because the checks were written for *triage* — is a
|
|
82
|
+
machine's guess at a conversion worth a human's time — and are now also asked to
|
|
83
|
+
gate *publication*, which has a different cost of being wrong.
|
|
84
|
+
|
|
85
|
+
`resp_scale_mixed` is the worked example. It is `fail` today, and
|
|
86
|
+
`data/cao_2026_cdss.py` documents a table that trips it legitimately: an unused
|
|
87
|
+
top category on a left-skewed 1–7 scale reads as a second scale. Had every
|
|
88
|
+
heuristic become a blocking error, the gate would have rejected that correct
|
|
89
|
+
table on day one.
|
|
90
|
+
|
|
91
|
+
| profile | used by | contents |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| `core` | `validate_irw.R` parity, external contributors | the five R checks only |
|
|
94
|
+
| `triage` | `run_qc`'s 50 callers | core + heuristics, **exactly today's severities** |
|
|
95
|
+
| `upload` | the gate, the CLI, CI (default) | core + heuristics + the standard's prose rules; heuristics capped at `warn` except `GATE_ERRORS` |
|
|
96
|
+
| `legacy` | the 922 `.Rdata` sweep (1.5, not built) | `upload` minus rules that postdate the tables |
|
|
97
|
+
|
|
98
|
+
`GATE_ERRORS` is currently exactly `{resp_variation*}` — a `resp` with one
|
|
99
|
+
distinct value carries no information for any model, at any altitude. It grows
|
|
100
|
+
one documented case at a time.
|
|
101
|
+
|
|
102
|
+
### Literal missing-value tokens (#2029)
|
|
103
|
+
|
|
104
|
+
For `upload` and `legacy`, every non-missing `resp` must parse as a number.
|
|
105
|
+
There is no 1% allowance for invalid values. File validation preserves literal
|
|
106
|
+
text such as `NA`, `N/A`, `NULL` and whitespace so it can report an error with
|
|
107
|
+
the count and up to five examples. A genuinely empty CSV field (including
|
|
108
|
+
`""`) remains missing: partial missingness is a `resp_na` warning; an entirely
|
|
109
|
+
missing response column still blocks. No source file or input frame is edited.
|
|
110
|
+
|
|
111
|
+
CSV/TSV/TXT validation makes a second pass reading only `resp` with pandas'
|
|
112
|
+
default NA-token conversion disabled. Other columns keep their existing
|
|
113
|
+
parsing behavior, and clean numeric response columns still infer numeric types.
|
|
114
|
+
Item-text tables use their separate schema and retain their existing reader.
|
|
115
|
+
The `core` and `triage` profiles retain their earlier parsing and numeric
|
|
116
|
+
threshold; callers using `run_qc` should use `validate_file(..., profile="upload")`
|
|
117
|
+
on the written file when they need this publication check.
|
|
118
|
+
|
|
119
|
+
For an in-memory frame, genuine nulls remain missing and literal text is
|
|
120
|
+
checked, but a token already erased by an upstream reader cannot be recovered.
|
|
121
|
+
The existing 512 MiB file-size cap still applies; files over it receive only
|
|
122
|
+
name checks. This change does not repair historical tables, resolve the meaning
|
|
123
|
+
of their missingness, or alter published response counts. Review source coding
|
|
124
|
+
before changing rows; the finding deliberately does not prescribe deletion.
|
|
125
|
+
|
|
126
|
+
## The override
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
irw-validate x.csv --override-check resp_scale_mixed \
|
|
130
|
+
--override "two response formats, one construct; author confirmed 2026-09-02"
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The reason is the flag's **argument**, so overriding without saying why is
|
|
134
|
+
structurally impossible. It is not called `--force` or `--no-verify`: those names
|
|
135
|
+
invite reflex use. A reason under 20 characters is rejected. Overridden findings
|
|
136
|
+
are reprinted under `OVERRIDDEN` rather than suppressed, and appended to
|
|
137
|
+
`processing_notes/validator_overrides.csv`.
|
|
138
|
+
|
|
139
|
+
Without `--override-check` the reason waives every error; with it, only the named
|
|
140
|
+
checks, so unrelated failures keep blocking.
|
|
141
|
+
|
|
142
|
+
## The 50 callers
|
|
143
|
+
|
|
144
|
+
`data/*.py` scripts do `from irw_triage_updated import run_qc` and read
|
|
145
|
+
`.name` / `.status` / `.detail`. **None of them needed an edit.** The check
|
|
146
|
+
bodies were *moved* into `_checks.py` verbatim and re-exported, so `run_qc`
|
|
147
|
+
behaves exactly as before — profiles are layered on top by `core.py`, never
|
|
148
|
+
underneath.
|
|
149
|
+
|
|
150
|
+
`tests/test_validate.py` pins the exact `(name, status)` emission order for eight
|
|
151
|
+
fixtures, captured before the move. That golden test is the reason the refactor
|
|
152
|
+
was safe to make at all: 50 files that otherwise only fail at someone else's
|
|
153
|
+
runtime.
|
|
154
|
+
|
|
155
|
+
## Staying merged
|
|
156
|
+
|
|
157
|
+
A shared runtime between R and Python is not possible here — `validate_irw.R`'s
|
|
158
|
+
whole value is that it works for a stranger with an R session and a URL, with
|
|
159
|
+
nothing else installed. So instead the R file carries `# @check <name>` markers,
|
|
160
|
+
and a test parses them and asserts set-equality with `model.CORE_CHECKS`. Two
|
|
161
|
+
languages, one list, enforced. Edit one copy and the suite fails.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# `irw_validate` — one format validator, with an exit code
|
|
2
|
+
|
|
3
|
+
```
|
|
4
|
+
irw-validate out/*.csv # exit 1 if anything blocks
|
|
5
|
+
irw-validate out/x.csv --profile core # the validate_irw.R subset
|
|
6
|
+
irw-validate out/x.csv --strict # warnings block too
|
|
7
|
+
irw-validate out/x.csv --json # for CI
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Exit codes: `0` ok · `1` something blocks · `2` bad input. Same contract as `red_up`.
|
|
11
|
+
|
|
12
|
+
## Installing
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
pip install irw-validate # once published
|
|
16
|
+
pip install -e /path/to/irw/src/irw_validate # from a checkout
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
pandas and the standard library, nothing else — no Redivis, no credentials, no
|
|
20
|
+
network. That is deliberate: this is the thing an outside contributor runs
|
|
21
|
+
against their own file before depositing it, and they should not have to clone
|
|
22
|
+
the pipeline or hold a token to do it.
|
|
23
|
+
|
|
24
|
+
Two optional extras exist for cases that are not that:
|
|
25
|
+
|
|
26
|
+
| Extra | Adds | Needed for |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| `[rdata]` | pyreadr | validating `.Rdata`/`.rda`/`.rds` directly. Converting to CSV first needs nothing. |
|
|
29
|
+
| `[live]` | redivis | the `live_*` and `repair_*` modules, which read published tables. Pipeline tools; `validate_file` never touches the network. |
|
|
30
|
+
|
|
31
|
+
Until 2026-09-09 this package shipped inside `irw-red-up`, the uploader
|
|
32
|
+
distribution, so the only way to get the validator was to install the writer.
|
|
33
|
+
That was an accident of packaging: `../pyproject.toml` keeps `red_up` out of
|
|
34
|
+
Python-pkg because a write-scoped uploader would change what that package is,
|
|
35
|
+
and none of that reasoning applies to a checker that opens a CSV.
|
|
36
|
+
|
|
37
|
+
`irw-validate` is a console script. In a checkout without the install,
|
|
38
|
+
`python3 -m irw_validate.cli` works from `src/`.
|
|
39
|
+
|
|
40
|
+
## Why this exists
|
|
41
|
+
|
|
42
|
+
The checks were forked, and neither half could gate anything:
|
|
43
|
+
|
|
44
|
+
| | checks | callers | exit code |
|
|
45
|
+
|---|---|---|---|
|
|
46
|
+
| `misc/validate_irw.R` | 5 | **nothing in the repo** | no |
|
|
47
|
+
| `irw_triage_updated.py::run_qc` | ~20 | 50 scripts in `data/` | no — its `__main__` exits 0 however many fail |
|
|
48
|
+
|
|
49
|
+
So "is this table valid?" had two answers and no way to act on either. This is
|
|
50
|
+
roadmap item 1 (`ben-domingue/irw#1703`), sub-items 1.3 and 1.4 — the other half
|
|
51
|
+
of the work `red_up` started.
|
|
52
|
+
|
|
53
|
+
It also makes `datastandard.md` executable. The standard is 348 lines of rules
|
|
54
|
+
stated in prose — a sample floor of 100 unique ids, table names ≤40 characters,
|
|
55
|
+
`[id, item, resp]` first — and until now not one was enforced by anything.
|
|
56
|
+
`ARCHITECTURE.md`'s Rule 2 asks for exactly this: *where a rule can be made
|
|
57
|
+
executable, make it executable instead of writing it down.*
|
|
58
|
+
|
|
59
|
+
## Profiles, and why severity is not a property of a check
|
|
60
|
+
|
|
61
|
+
**Severity depends on the (check, profile) pair.** This is the central design
|
|
62
|
+
decision, and it exists because the checks were written for *triage* — is a
|
|
63
|
+
machine's guess at a conversion worth a human's time — and are now also asked to
|
|
64
|
+
gate *publication*, which has a different cost of being wrong.
|
|
65
|
+
|
|
66
|
+
`resp_scale_mixed` is the worked example. It is `fail` today, and
|
|
67
|
+
`data/cao_2026_cdss.py` documents a table that trips it legitimately: an unused
|
|
68
|
+
top category on a left-skewed 1–7 scale reads as a second scale. Had every
|
|
69
|
+
heuristic become a blocking error, the gate would have rejected that correct
|
|
70
|
+
table on day one.
|
|
71
|
+
|
|
72
|
+
| profile | used by | contents |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `core` | `validate_irw.R` parity, external contributors | the five R checks only |
|
|
75
|
+
| `triage` | `run_qc`'s 50 callers | core + heuristics, **exactly today's severities** |
|
|
76
|
+
| `upload` | the gate, the CLI, CI (default) | core + heuristics + the standard's prose rules; heuristics capped at `warn` except `GATE_ERRORS` |
|
|
77
|
+
| `legacy` | the 922 `.Rdata` sweep (1.5, not built) | `upload` minus rules that postdate the tables |
|
|
78
|
+
|
|
79
|
+
`GATE_ERRORS` is currently exactly `{resp_variation*}` — a `resp` with one
|
|
80
|
+
distinct value carries no information for any model, at any altitude. It grows
|
|
81
|
+
one documented case at a time.
|
|
82
|
+
|
|
83
|
+
### Literal missing-value tokens (#2029)
|
|
84
|
+
|
|
85
|
+
For `upload` and `legacy`, every non-missing `resp` must parse as a number.
|
|
86
|
+
There is no 1% allowance for invalid values. File validation preserves literal
|
|
87
|
+
text such as `NA`, `N/A`, `NULL` and whitespace so it can report an error with
|
|
88
|
+
the count and up to five examples. A genuinely empty CSV field (including
|
|
89
|
+
`""`) remains missing: partial missingness is a `resp_na` warning; an entirely
|
|
90
|
+
missing response column still blocks. No source file or input frame is edited.
|
|
91
|
+
|
|
92
|
+
CSV/TSV/TXT validation makes a second pass reading only `resp` with pandas'
|
|
93
|
+
default NA-token conversion disabled. Other columns keep their existing
|
|
94
|
+
parsing behavior, and clean numeric response columns still infer numeric types.
|
|
95
|
+
Item-text tables use their separate schema and retain their existing reader.
|
|
96
|
+
The `core` and `triage` profiles retain their earlier parsing and numeric
|
|
97
|
+
threshold; callers using `run_qc` should use `validate_file(..., profile="upload")`
|
|
98
|
+
on the written file when they need this publication check.
|
|
99
|
+
|
|
100
|
+
For an in-memory frame, genuine nulls remain missing and literal text is
|
|
101
|
+
checked, but a token already erased by an upstream reader cannot be recovered.
|
|
102
|
+
The existing 512 MiB file-size cap still applies; files over it receive only
|
|
103
|
+
name checks. This change does not repair historical tables, resolve the meaning
|
|
104
|
+
of their missingness, or alter published response counts. Review source coding
|
|
105
|
+
before changing rows; the finding deliberately does not prescribe deletion.
|
|
106
|
+
|
|
107
|
+
## The override
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
irw-validate x.csv --override-check resp_scale_mixed \
|
|
111
|
+
--override "two response formats, one construct; author confirmed 2026-09-02"
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The reason is the flag's **argument**, so overriding without saying why is
|
|
115
|
+
structurally impossible. It is not called `--force` or `--no-verify`: those names
|
|
116
|
+
invite reflex use. A reason under 20 characters is rejected. Overridden findings
|
|
117
|
+
are reprinted under `OVERRIDDEN` rather than suppressed, and appended to
|
|
118
|
+
`processing_notes/validator_overrides.csv`.
|
|
119
|
+
|
|
120
|
+
Without `--override-check` the reason waives every error; with it, only the named
|
|
121
|
+
checks, so unrelated failures keep blocking.
|
|
122
|
+
|
|
123
|
+
## The 50 callers
|
|
124
|
+
|
|
125
|
+
`data/*.py` scripts do `from irw_triage_updated import run_qc` and read
|
|
126
|
+
`.name` / `.status` / `.detail`. **None of them needed an edit.** The check
|
|
127
|
+
bodies were *moved* into `_checks.py` verbatim and re-exported, so `run_qc`
|
|
128
|
+
behaves exactly as before — profiles are layered on top by `core.py`, never
|
|
129
|
+
underneath.
|
|
130
|
+
|
|
131
|
+
`tests/test_validate.py` pins the exact `(name, status)` emission order for eight
|
|
132
|
+
fixtures, captured before the move. That golden test is the reason the refactor
|
|
133
|
+
was safe to make at all: 50 files that otherwise only fail at someone else's
|
|
134
|
+
runtime.
|
|
135
|
+
|
|
136
|
+
## Staying merged
|
|
137
|
+
|
|
138
|
+
A shared runtime between R and Python is not possible here — `validate_irw.R`'s
|
|
139
|
+
whole value is that it works for a stranger with an R session and a URL, with
|
|
140
|
+
nothing else installed. So instead the R file carries `# @check <name>` markers,
|
|
141
|
+
and a test parses them and asserts set-equality with `model.CORE_CHECKS`. Two
|
|
142
|
+
languages, one list, enforced. Edit one copy and the suite fails.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""One IRW format validator, with an exit code (ben-domingue/irw#1703, 1.3).
|
|
2
|
+
|
|
3
|
+
from irw_validate import validate_file, validate_frame, exit_code
|
|
4
|
+
|
|
5
|
+
report = validate_file("out/mytable.csv")
|
|
6
|
+
print(report.ok, [f.check for f in report.errors])
|
|
7
|
+
|
|
8
|
+
Command line:
|
|
9
|
+
|
|
10
|
+
irw-validate out/*.csv # exit 1 if anything blocks
|
|
11
|
+
irw-validate out/x.csv --profile core # the validate_irw.R subset
|
|
12
|
+
irw-validate out/x.csv --strict # warnings block too
|
|
13
|
+
|
|
14
|
+
Why this exists: the checks were forked between `misc/validate_irw.R` (5 checks,
|
|
15
|
+
called by nothing) and `automated_finding/irw_triage_updated.py::run_qc` (~20
|
|
16
|
+
checks, called by fifty scripts but only ever advisorily -- its __main__ exits 0
|
|
17
|
+
however many fail). Neither had an exit code, so neither could gate anything.
|
|
18
|
+
"""
|
|
19
|
+
from .core import (MAX_BYTES, format_report, validate_file, validate_frame,
|
|
20
|
+
validate_paths)
|
|
21
|
+
from .model import (CORE_CHECKS, GATE_ERRORS, PROFILES, Finding, Report,
|
|
22
|
+
exit_code, severity_for)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"validate_file", "validate_frame", "validate_paths", "format_report",
|
|
26
|
+
"Finding", "Report", "exit_code", "severity_for",
|
|
27
|
+
"CORE_CHECKS", "GATE_ERRORS", "PROFILES", "MAX_BYTES",
|
|
28
|
+
]
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""The IRW format checks themselves, moved verbatim from
|
|
2
|
+
`automated_finding/irw_triage_updated.py::run_qc` (#1703 sub-item 1.3).
|
|
3
|
+
|
|
4
|
+
**Moved, not rewritten.** Fifty scripts in `data/` call `run_qc` and read
|
|
5
|
+
`c.name` / `c.status` / `c.detail` off what it returns, so the check bodies and
|
|
6
|
+
above all their *emission order* are preserved exactly. `irw_validate.compat`
|
|
7
|
+
re-exports this as `run_qc`, and `automated_finding/irw_triage_updated.py`
|
|
8
|
+
re-exports that, which is why none of the fifty needed an edit.
|
|
9
|
+
|
|
10
|
+
`irw_validate.core` layers severity profiles, extra checks and an exit code on
|
|
11
|
+
top of this; nothing here knows about any of that. The golden test in
|
|
12
|
+
`tests/test_validate.py` pins the (name, status) sequence for eight fixtures so
|
|
13
|
+
the move is provably behaviour-preserving.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import collections
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from math import sqrt
|
|
21
|
+
|
|
22
|
+
import pandas as pd
|
|
23
|
+
|
|
24
|
+
#: Columns that say WHEN or UNDER WHAT a measurement was taken, per
|
|
25
|
+
#: `datastandard.md`. `group`, `study` and `treat` are deliberately absent: they
|
|
26
|
+
#: describe the person or the arm, so a person appearing twice under one is a
|
|
27
|
+
#: question, not an answer (#1835).
|
|
28
|
+
OCCASION = ("rt", "rater", "wave", "timepoint", "date", "trialnum", "trial",
|
|
29
|
+
"order", "session", "occasion", "period", "block", "subtest")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Check:
|
|
34
|
+
name: str
|
|
35
|
+
status: str # "pass" | "warn" | "fail"
|
|
36
|
+
detail: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
IRW_REQUIRED = ["id", "item", "resp"]
|
|
40
|
+
ITEM_LEVEL_PREFIXES = ("itemcov_", "qmatrix", "item_family", "rater")
|
|
41
|
+
|
|
42
|
+
_COMPOSITE_TOKENS = {
|
|
43
|
+
"total", "totals", "composite", "subscale", "subscales", "overall",
|
|
44
|
+
"average", "averages", "avg", "mean", "sum", "index", "score", "scores",
|
|
45
|
+
}
|
|
46
|
+
# Whole-label pre/post markers (optionally with a short subscale suffix, e.g.
|
|
47
|
+
# "pre-A", "post_F"). Matched only against the ENTIRE label: a genuine raw
|
|
48
|
+
# item at a pre-wave is usually "pre_anxiety_3", which must not trip this.
|
|
49
|
+
_PREPOST_LABEL = re.compile(
|
|
50
|
+
r"^(pre|post|baseline|follow[-_ ]?up)[-_ ]?[a-z0-9]{0,2}$", re.I)
|
|
51
|
+
|
|
52
|
+
def _looks_composite(label) -> bool:
|
|
53
|
+
"""Does this item label name a computed score rather than a question?"""
|
|
54
|
+
s = str(label).strip()
|
|
55
|
+
if not s:
|
|
56
|
+
return False
|
|
57
|
+
if _PREPOST_LABEL.match(s):
|
|
58
|
+
return True
|
|
59
|
+
# Token-wise, so "meaning_1" doesn't match on "mean" and "scoreboard_2"
|
|
60
|
+
# doesn't match on "score".
|
|
61
|
+
tokens = {t.lower() for t in re.split(r"[^A-Za-z0-9]+", s) if t}
|
|
62
|
+
return bool(tokens & _COMPOSITE_TOKENS)
|
|
63
|
+
|
|
64
|
+
def irw_metadata(df: pd.DataFrame) -> dict:
|
|
65
|
+
"""The IRW's own metadata/density computation, ported from their R/Python."""
|
|
66
|
+
d = df.loc[~df["resp"].isna()].copy()
|
|
67
|
+
d["resp"] = pd.to_numeric(d["resp"], errors="coerce")
|
|
68
|
+
n_resp = len(d)
|
|
69
|
+
n_part = d["id"].nunique()
|
|
70
|
+
n_item = d["item"].nunique()
|
|
71
|
+
# response frequency distribution — the professor's table(df$resp)
|
|
72
|
+
resp_counts = d["resp"].value_counts().sort_index()
|
|
73
|
+
resp_table = {str(k): int(v) for k, v in resp_counts.head(20).items()}
|
|
74
|
+
return {
|
|
75
|
+
"n_responses": n_resp,
|
|
76
|
+
"n_categories": int(d["resp"].nunique()),
|
|
77
|
+
"n_participants": n_part,
|
|
78
|
+
"n_items": n_item,
|
|
79
|
+
"responses_per_participant": round(n_resp / n_part, 2) if n_part else 0,
|
|
80
|
+
"responses_per_item": round(n_resp / n_item, 2) if n_item else 0,
|
|
81
|
+
"density": round((sqrt(n_resp) / n_part) * (sqrt(n_resp) / n_item), 4)
|
|
82
|
+
if n_part and n_item else 0,
|
|
83
|
+
"resp_distribution": resp_table,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def run_qc(df: pd.DataFrame, coercion_method: str = "",
|
|
88
|
+
original_cols: list = None) -> list:
|
|
89
|
+
"""QC checks. The first block is ported directly from the IRW's official
|
|
90
|
+
validate_irw.R (statuses: pass=OK, warn=NOTE, fail=ERROR). The second block
|
|
91
|
+
is extra heuristics we add on top, clearly labelled."""
|
|
92
|
+
checks = []
|
|
93
|
+
original_cols = original_cols or []
|
|
94
|
+
|
|
95
|
+
# ===== ported from validate_irw.R =====================================
|
|
96
|
+
|
|
97
|
+
# required columns (ERROR if missing)
|
|
98
|
+
missing = [c for c in IRW_REQUIRED if c not in df.columns]
|
|
99
|
+
if missing:
|
|
100
|
+
checks.append(Check("required_columns", "fail",
|
|
101
|
+
f"missing required columns: {', '.join(missing)}"))
|
|
102
|
+
return checks # nothing else is meaningful without these
|
|
103
|
+
checks.append(Check("required_columns", "pass", "id/item/resp present"))
|
|
104
|
+
|
|
105
|
+
# NAs in required columns: all-NA = ERROR, some-NA = NOTE
|
|
106
|
+
for col in IRW_REQUIRED:
|
|
107
|
+
n_na = df[col].isna().sum()
|
|
108
|
+
if n_na == len(df):
|
|
109
|
+
checks.append(Check(f"{col}_na", "fail", f"{col} is entirely NA"))
|
|
110
|
+
elif n_na > 0:
|
|
111
|
+
checks.append(Check(f"{col}_na", "warn", f"{col} has {n_na} NAs"))
|
|
112
|
+
|
|
113
|
+
# resp must be numeric (ERROR)
|
|
114
|
+
resp_num = pd.to_numeric(df["resp"], errors="coerce")
|
|
115
|
+
if resp_num.notna().mean() < 0.99:
|
|
116
|
+
checks.append(Check("resp_numeric", "fail",
|
|
117
|
+
f"resp is not numeric (only "
|
|
118
|
+
f"{resp_num.notna().mean():.0%} parse as numbers)"))
|
|
119
|
+
else:
|
|
120
|
+
checks.append(Check("resp_numeric", "pass", "resp is numeric"))
|
|
121
|
+
|
|
122
|
+
# duplicate id+item: ERROR if no longitudinal column, else NOTE
|
|
123
|
+
longitudinal = [c for c in ("wave", "timepoint", "date") if c in df.columns]
|
|
124
|
+
dups = df.duplicated(subset=["id", "item"]).sum()
|
|
125
|
+
if dups > 0 and not longitudinal:
|
|
126
|
+
checks.append(Check("dup_id_item", "fail",
|
|
127
|
+
f"{dups} duplicate id+item rows with no "
|
|
128
|
+
"wave/timepoint/date column"))
|
|
129
|
+
elif dups > 0:
|
|
130
|
+
checks.append(Check("dup_id_item", "warn",
|
|
131
|
+
f"{dups} duplicate id+item rows "
|
|
132
|
+
f"(longitudinal column {longitudinal} present — likely ok)"))
|
|
133
|
+
else:
|
|
134
|
+
checks.append(Check("dup_id_item", "pass", "id+item rows unique"))
|
|
135
|
+
|
|
136
|
+
# covariate naming: extra columns without a recognized name/prefix = NOTE.
|
|
137
|
+
# (Broadened from validate_irw.R's narrow list to the full documented
|
|
138
|
+
# standard, so legitimate columns like item_family/treat aren't flagged.)
|
|
139
|
+
#
|
|
140
|
+
# OCCASION belongs in this set, and leaving it out made the validator
|
|
141
|
+
# contradict itself: `dup_id_item` accepts `trialnum` as the column that
|
|
142
|
+
# explains a repeat, and `cov_prefix` then told you to rename it `cov_`,
|
|
143
|
+
# which would both misdescribe it -- a covariate is invariant to the person,
|
|
144
|
+
# a trial index is the opposite -- and stop `dup_id_item` from seeing it,
|
|
145
|
+
# re-breaking the table the rename had just fixed. Seen on `motion` and
|
|
146
|
+
# `rr98_accuracy` (irw#1842 block J). One list, so the two cannot drift.
|
|
147
|
+
known = {"id", "item", "resp", "date", "treat", "item_family"} | set(OCCASION)
|
|
148
|
+
known_prefix = ("cov_", "itemcov_", "qmatrix", "trial_")
|
|
149
|
+
unprefixed = [c for c in df.columns
|
|
150
|
+
if c not in known and not c.startswith(known_prefix)]
|
|
151
|
+
if unprefixed:
|
|
152
|
+
checks.append(Check("cov_prefix", "warn",
|
|
153
|
+
f"unrecognized columns (prefix with cov_ if "
|
|
154
|
+
f"covariates): {', '.join(unprefixed)}"))
|
|
155
|
+
|
|
156
|
+
# ===== extra heuristics (beyond the official validator) ===============
|
|
157
|
+
|
|
158
|
+
# resp scale sanity — flag a resp that looks continuous/mis-parsed
|
|
159
|
+
ncat = resp_num.nunique()
|
|
160
|
+
if ncat <= 1:
|
|
161
|
+
checks.append(Check("resp_variation*", "fail",
|
|
162
|
+
"resp has no variation (1 unique value)"))
|
|
163
|
+
elif ncat > 50:
|
|
164
|
+
checks.append(Check("resp_ordinal*", "warn",
|
|
165
|
+
f"{ncat} distinct resp values — confirm continuous, "
|
|
166
|
+
"not mis-parsed"))
|
|
167
|
+
|
|
168
|
+
# P1 #3: resp coding direction — can't auto-verify; always warn after melt.
|
|
169
|
+
if coercion_method == "wide-to-long":
|
|
170
|
+
checks.append(Check(
|
|
171
|
+
"resp_direction*", "warn",
|
|
172
|
+
"Cannot auto-verify: within each item, higher resp values must "
|
|
173
|
+
"indicate more of the construct (IRW standard). Confirm no "
|
|
174
|
+
"unreversed items."
|
|
175
|
+
))
|
|
176
|
+
|
|
177
|
+
# P1 #4: imputed values — column name signals and mean-imputation signature.
|
|
178
|
+
if original_cols:
|
|
179
|
+
imputed_signals = [c for c in original_cols
|
|
180
|
+
if re.search(r"_imp(?:uted)?$|_filled$|_flag$", c,
|
|
181
|
+
re.I)]
|
|
182
|
+
if imputed_signals:
|
|
183
|
+
checks.append(Check("imputed_values*", "warn",
|
|
184
|
+
f"Columns suggest imputed values may be present: "
|
|
185
|
+
f"{imputed_signals}. IRW requires their removal."))
|
|
186
|
+
# Mean-imputation signature: any item where one value accounts for >60% of rows.
|
|
187
|
+
if resp_num.notna().any():
|
|
188
|
+
by_item = df.groupby("item")["resp"]
|
|
189
|
+
for item_name, grp in by_item:
|
|
190
|
+
vc = grp.value_counts(normalize=True)
|
|
191
|
+
if not vc.empty and vc.iloc[0] > 0.60:
|
|
192
|
+
checks.append(Check("imputed_values*", "warn",
|
|
193
|
+
f"Item '{item_name}' has one resp value "
|
|
194
|
+
f"accounting for {vc.iloc[0]:.0%} of responses "
|
|
195
|
+
"— possible mean imputation."))
|
|
196
|
+
break # one warning is enough
|
|
197
|
+
|
|
198
|
+
# P1 #5: date column validation.
|
|
199
|
+
if "date" in df.columns:
|
|
200
|
+
d = pd.to_numeric(df["date"], errors="coerce")
|
|
201
|
+
if d.isna().mean() > 0.1:
|
|
202
|
+
checks.append(Check("date_numeric*", "warn",
|
|
203
|
+
"date column is not numeric — IRW requires Unix "
|
|
204
|
+
"seconds (or seconds since first observation)"))
|
|
205
|
+
elif d.notna().any() and d.max() < 1e8:
|
|
206
|
+
checks.append(Check("date_range*", "warn",
|
|
207
|
+
f"date max={d.max():.0f} — looks too small for "
|
|
208
|
+
"Unix seconds; verify units"))
|
|
209
|
+
|
|
210
|
+
# P1 #6: rt column validation.
|
|
211
|
+
if "rt" in df.columns:
|
|
212
|
+
rt = pd.to_numeric(df["rt"], errors="coerce")
|
|
213
|
+
if rt.isna().mean() > 0.1:
|
|
214
|
+
checks.append(Check("rt_numeric*", "warn",
|
|
215
|
+
"rt column is not numeric"))
|
|
216
|
+
elif rt.notna().any():
|
|
217
|
+
if rt.median() > 60000:
|
|
218
|
+
checks.append(Check("rt_units*", "warn",
|
|
219
|
+
f"rt median={rt.median():.0f} — likely "
|
|
220
|
+
"milliseconds, not seconds (IRW requires "
|
|
221
|
+
"seconds)"))
|
|
222
|
+
if (rt < 0).any():
|
|
223
|
+
checks.append(Check("rt_negative*", "warn",
|
|
224
|
+
"rt has negative values"))
|
|
225
|
+
|
|
226
|
+
# treat column should be 0/1 if present
|
|
227
|
+
if "treat" in df.columns:
|
|
228
|
+
bad = set(pd.unique(df["treat"].dropna())) - {0, 1}
|
|
229
|
+
if bad:
|
|
230
|
+
checks.append(Check("treat_binary*", "warn",
|
|
231
|
+
f"treat has non-0/1 values {sorted(bad)[:5]}"))
|
|
232
|
+
|
|
233
|
+
# P2 #7: item-level columns dropped during melt — remind user to verify.
|
|
234
|
+
if original_cols and coercion_method == "wide-to-long":
|
|
235
|
+
item_level_found = [c for c in original_cols
|
|
236
|
+
if any(c.startswith(p) for p in ITEM_LEVEL_PREFIXES)]
|
|
237
|
+
if item_level_found:
|
|
238
|
+
checks.append(Check("item_level_cols*", "warn",
|
|
239
|
+
f"Item-level columns {item_level_found} were "
|
|
240
|
+
"excluded from the melt — verify they are "
|
|
241
|
+
"correctly aligned after conversion."))
|
|
242
|
+
|
|
243
|
+
# P2 #7: multi-scale detection — distinct item-name prefixes suggest separate
|
|
244
|
+
# constructs that must be split into separate tables.
|
|
245
|
+
if "item" in df.columns:
|
|
246
|
+
prefixes = [re.split(r"[\d_]", str(i))[0].lower()
|
|
247
|
+
for i in df["item"].unique() if str(i)]
|
|
248
|
+
prefix_counts = pd.Series(prefixes).value_counts()
|
|
249
|
+
dominant = prefix_counts[prefix_counts >= 3]
|
|
250
|
+
if len(dominant) >= 2:
|
|
251
|
+
checks.append(Check("multi_scale*", "warn",
|
|
252
|
+
f"Item names suggest {len(dominant)} subscales "
|
|
253
|
+
f"({list(dominant.index)[:4]}) — IRW requires "
|
|
254
|
+
"separate tables per construct."))
|
|
255
|
+
|
|
256
|
+
# Response-scale homogeneity. The existing multi_scale* check reads item
|
|
257
|
+
# *names*; this one reads the responses themselves, which is what actually
|
|
258
|
+
# catches a mailing that bundled several instruments. Two distinct
|
|
259
|
+
# failures fall out of the same per-item range profile:
|
|
260
|
+
# * a substantial minority of items on a different range -> two scales
|
|
261
|
+
# in one table, which breaks "one table per construct" and leaves `resp`
|
|
262
|
+
# meaning different things in different rows;
|
|
263
|
+
# * one or two isolated items off the modal range -> almost always not
|
|
264
|
+
# an item at all (an administrative or count column swept in).
|
|
265
|
+
# Both were live defects in the 2026-08-26 Eugene-Springfield build:
|
|
266
|
+
# `sdv` spanned 1-5, 1-7, 1-8 and 1-9 at once, and `submiss` -- a
|
|
267
|
+
# missing-response count, 94.8% zero -- was the only column in the HPQ
|
|
268
|
+
# outside its 1-5 scale.
|
|
269
|
+
# A mix of numbers and invalid text has no comparable response-scale range.
|
|
270
|
+
# Skip this heuristic so the numeric finding can be returned (#2029).
|
|
271
|
+
# This also avoids int/string comparisons for in-memory inputs, whether
|
|
272
|
+
# the incompatible values occur within one item or across several items.
|
|
273
|
+
mixed_numeric_text = (resp_num.notna().any()
|
|
274
|
+
and (df["resp"].notna() & resp_num.isna()).any())
|
|
275
|
+
if {"item", "resp"}.issubset(df.columns) and not mixed_numeric_text:
|
|
276
|
+
rng = df.dropna(subset=["resp"]).groupby("item")["resp"].agg(["min", "max"])
|
|
277
|
+
if len(rng) >= 3:
|
|
278
|
+
profile = collections.Counter(zip(rng["min"], rng["max"]))
|
|
279
|
+
(modal, modal_n), = profile.most_common(1)
|
|
280
|
+
off = rng[(rng["min"] != modal[0]) | (rng["max"] != modal[1])]
|
|
281
|
+
# Only a range that *exceeds* the modal one is evidence of a
|
|
282
|
+
# different scale; an item nobody answered at the ceiling simply
|
|
283
|
+
# has a lower observed max.
|
|
284
|
+
over = off[(off["max"] > modal[1]) | (off["min"] < modal[0])]
|
|
285
|
+
share = len(over) / len(rng)
|
|
286
|
+
if share >= 0.15:
|
|
287
|
+
other = collections.Counter(zip(over["min"], over["max"]))
|
|
288
|
+
checks.append(Check("resp_scale_mixed", "fail",
|
|
289
|
+
f"items span more than one response scale: "
|
|
290
|
+
f"{modal_n} on {modal[0]}-{modal[1]} and {len(over)} on "
|
|
291
|
+
f"{[f'{a}-{b}' for a, b in list(other)[:3]]}. IRW requires "
|
|
292
|
+
"one table per construct; split before submitting."))
|
|
293
|
+
elif len(over):
|
|
294
|
+
checks.append(Check("item_scale_outlier", "warn",
|
|
295
|
+
f"{len(over)} item(s) fall outside the table's "
|
|
296
|
+
f"{modal[0]}-{modal[1]} scale: {list(over.index)[:4]}. An "
|
|
297
|
+
"isolated out-of-range column is usually not an item -- "
|
|
298
|
+
"check for an administrative or count field."))
|
|
299
|
+
|
|
300
|
+
# Composite columns masquerading as items. A summary table melts into a
|
|
301
|
+
# perfectly well-formed id/item/resp frame and passes every structural
|
|
302
|
+
# check above -- the only tell is what the items are NAMED.
|
|
303
|
+
if "item" in df.columns:
|
|
304
|
+
labels = [i for i in df["item"].unique() if str(i).strip()]
|
|
305
|
+
comp = [i for i in labels if _looks_composite(i)]
|
|
306
|
+
if labels and len(comp) == len(labels):
|
|
307
|
+
checks.append(Check("composite_items*", "fail",
|
|
308
|
+
f"every item label names a computed score "
|
|
309
|
+
f"({[str(c) for c in comp[:4]]}) — this looks "
|
|
310
|
+
"like a summary/aggregate table, not raw "
|
|
311
|
+
"item-level responses"))
|
|
312
|
+
elif comp:
|
|
313
|
+
checks.append(Check("composite_items*", "warn",
|
|
314
|
+
f"{len(comp)}/{len(labels)} item labels name "
|
|
315
|
+
f"computed scores ({[str(c) for c in comp[:4]]}) "
|
|
316
|
+
"— drop them, or confirm they are real items"))
|
|
317
|
+
|
|
318
|
+
# IRW's own density signal — very sparse data is worth a look
|
|
319
|
+
meta = irw_metadata(df)
|
|
320
|
+
if meta["density"] < 0.01:
|
|
321
|
+
checks.append(Check("density*", "warn",
|
|
322
|
+
f"very sparse (density={meta['density']}); fine for "
|
|
323
|
+
"adaptive/booklet designs, else verify"))
|
|
324
|
+
|
|
325
|
+
return checks
|