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.
@@ -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