Check your SDTM data against CDISC rules without leaving R.
You just finished writing your DM code. You have a data frame. Check it:
library(coreval)
check_dataset(dm)── coreval — DM ────────────────────────────────────────────────────────────
9 problems across 6 records (170 checks ran)
wrong value 4 the data breaks the rule - start here
missing required 2 the standard requires it
missing optional 3 often legitimate: not collected, screen failure, ...
[wrong value]
Variable value is not in correct ISO 8601 date or datetime format
2 records · RFSTDTC
row 4 RFSTDTC = "2024-13-01"
row 3 RFSTDTC = (empty)
CORE-000547 · also SEND66, SEND67, SEND68, ...
[wrong value]
AGEU is missing when AGE is provided.
1 record · AGE, AGEU
row 3 AGE = "47", AGEU = (empty)
CORE-000189 · also CG0665, TIG0699
... and 7 more here. See result$findings for all of them.
────────────────────────────────────────────────────────────────────────────
45 checks could not run.
30 need other datasets (AE, AG, CM, DD, DS, EX, ...)
→ run check_study() on the whole folder to cover these
15 need a define.xml
No standard declared, so rules from every standard ran.
Narrow with standard = "SDTMIG" (or "SENDIG", "TIG", ...)
Fix what you can, then run this again.
To track the rest: write_findings(result, "issues.xlsx")
It tells you what’s wrong in words, which rows, which variables, and the actual values. No export, no upload, no waiting, no looking rule numbers up in a PDF.
CDISC Open Rules carry no severity field. I checked the source — there’s nothing like Pinnacle 21’s Notes / Minor / Major / Critical. That’s P21’s own layer, not CDISC’s, so coreval can’t report a CDISC severity and won’t invent one.
What it does instead is separate the findings that are definitely wrong from the ones that might be perfectly fine:
| What it means | |
|---|---|
| wrong value | Your data contains something that breaks the rule — a month of 13, a value outside its codelist, two variables contradicting each other. Nothing about your study explains these away. Start here. |
| missing required | Something the standard marks Required isn’t there. |
| missing optional | Something Expected or Permissible is absent, or a value is blank. Often legitimate — a screen-failure subject with no reference dates, a variable your raw data doesn’t carry yet. |
That last row is the point. An empty RFSTDTC is not the
same kind of problem as RFSTDTC = "2024-13-01", and sorting
by “how many rows are affected” puts them in the wrong order. coreval
sorts by this first, row count second — and within a problem, it shows
you the row with the real bad value before the row that’s merely
empty.
This is coreval’s own triage, not a regulatory grading. It’s a
triage column on every finding, so you can sort by it in
the spreadsheet too.
You’re a programmer. You’ve just written the code that builds DM. You don’t want to export transport files and open Pinnacle 21 to find out you left a month of 13 in a date.
result <- check_dataset(dm) # 1. see what's wrong, in plain language
# 2. fix what you can
result <- check_dataset(dm) # 3. run it again — it takes a second
write_findings(result, "dm_issues.xlsx") # 4. track what's leftStep 4 gives you a spreadsheet with the problem described in words,
plus empty Status, Owner and
Notes columns to fill in — so “expected, see protocol
deviation log” gets recorded next to the finding instead of in some
other document.
Then the full run still happens: you or your study lead runs the whole study through your qualified tool. This doesn’t replace that, and isn’t trying to. It just means the expensive check finds far less, and you found the obvious things in seconds instead of half an hour.
# install.packages("pak")
pak::pak("hrach-gevorgyan/coreval")Needs R 4.1 or newer. Only data.table and
haven to run. Two optional extras:
install.packages("xml2") # to read Define-XML
install.packages("writexl") # to write .xlsxThis is the one to reach for while you’re writing code. Pass a data frame:
ae <- data.frame(
STUDYID = "S1", DOMAIN = "AE", USUBJID = c("01", "01"),
AESEQ = c(1, 2),
AETERM = c("Headache", "Rash"),
AESTDTC = c("2024-01-10", "2024-02-30")
)
result <- check_dataset(ae)Or a file — .xpt, .sas7bdat or
.csv:
result <- check_dataset("ae.xpt")coreval works out the domain from your DOMAIN column,
and falls back to the file name only when the data has no
DOMAIN column at all. That order matters for a split
dataset: ae1.xpt is checked as AE because its
DOMAIN column says so — on a file with no
DOMAIN column, the name ae1 is taken at face
value. If it guesses wrong, tell it:
result <- check_dataset(ae, domain = "AE")Plenty of CDISC rules compare one dataset against another — an AE date against the subject’s reference dates in DM, a visit against the trial design. Hand coreval a single dataset and those rules simply cannot be answered.
coreval doesn’t guess. It skips them, and tells you which dataset it wanted:
head(result$skipped, 3)
#> rule_id domain reason
#> 1 CORE-000138 AE needs DM, which was not supplied - check ...
#> 2 CORE-000140 AE needs TV, which was not supplied - check ...
#> 3 CORE-000168 AE needs SV, which was not supplied - check ...Running them anyway would compare your data against columns that aren’t there and report problems that don’t exist. Better to say nothing than to make something up.
Most rules do still run — measured across AE, DM, LB and VS, 76–84% of the applicable rules work on the dataset alone. But the ones that can’t are the cross-dataset checks, and those often matter most.
A short findings list here doesn’t mean your data is clean. It’s a quick first pass while you code. Run the whole study before you draw conclusions.
When you do have the full folder:
result <- check_study("path/to/study/sdtm")A folder, not a file. coreval reads everything in it — XPT, SAS, CSV, whichever you have — and reading it all at once is what makes the cross-dataset rules possible. If there’s a Define-XML in there, it finds it and uses it.
If you want to look at what was parsed, or check the same large study twice without re-reading it, do the read yourself:
study <- read_study("path/to/study/sdtm")
names(study$datasets)
#> [1] "AE" "CM" "DM" "EX" "LB" "VS"
check_study(study)A study report is grouped by dataset, and tells you where the trouble is before showing you any detail:
── coreval ─────────────────────────────────────────────────────────────
27 problems across 12 records in 4 datasets (689 checks ran)
DM 9 problems 4 records
AE 7 problems 4 records
VS 7 problems 3 records
STUDY 4 problems 1 record
── DM ──────────────────────────────────────────────────────────────────
SUBJID is not unique within study
3 records · SUBJID
not in the dataset: SUBJID
CORE-000186
...
print(result, n = 20) shows more problems per dataset;
rows = 5 shows more example records per problem.
You get two tables. Both matter.
Printing the result gives you the readable report above. When you
want the raw rows — to filter, count, or feed somewhere else — they’re
in result$findings:
result$findings —
what’s wronghead(result$findings[, c("Dataset", "Record", "Variable", "Value", "triage", "rule_id")])
#> Dataset Record Variable Value triage rule_id
#> AE 2 AESTDTC 2024-02-30 wrong value CORE-000547
#> AE 2 RFSTDTC Not in dataset wrong value CORE-000547
#> AE 1 AESTDY Not in dataset missing optional CORE-000328(issue is dropped from that view only so the table fits
the page — it is there on every row, and it is the column worth
reading.)
One row per problem, pointing at the exact spot:
| Column | What it tells you |
|---|---|
Dataset |
Which dataset (or STUDY for whole-study checks) |
Record |
Row number, counting from 1 |
Variable |
The variable being complained about |
Value |
What was actually in there |
issue |
What’s wrong, in words — the rule’s own description |
triage |
wrong value, missing required or
missing optional |
rule_id |
The CDISC rule, if you need to look it up |
Not in dataset under Value means the rule
wanted a variable you don’t have — which is usually the point.
result$skipped —
what couldn’t be checkedhead(result$skipped)
#> rule_id domain reason
#> 1 CORE-000916 AE Match Datasets: unsupported join type...This is the one people skip, and it’s the one that
bites. An empty findings table means one of two things: your
data is clean, or half the rules never ran. Those look identical if you
only read findings. coreval always shows you both.
write_findings(result, "issues.xlsx") # one workbook, several sheets
write_findings(result, "issues.csv") # issues.csv + siblingsYou get findings, skipped, an
about sheet, and truncated if any rule matched
more records than were kept. Both tables get written every time, for the
reason above — and about carries the provenance
with the file: which standard it was scoped to, how
many checks ran, whether it was filtered before export, and whether any
counts were capped. A shared spreadsheet outlives the console session
that made it, and whoever opens it can’t see what you saw.
The file has three empty columns — Status,
Owner, Notes — for you to fill in once it’s
open. Not every finding is a bug you’ll fix: some are expected, some
belong to someone else, some are waiting on a data query. Those
decisions belong next to the finding, not in a separate document nobody
opens.
Dataset Record Variable Value issue rule_id Status Owner Notes
DM 4 RFSTDTC 2024-13-01 Variable value is not in ... CORE-000547
DM 3 AGE 47 AGEU is missing when AGE ... CORE-000189
Pass tracking = FALSE if you’re reading the file back
into R and don’t want the extra columns.
Six functions. Three of them do the work:
check_dataset(x) |
check one dataset — a data frame, or an .xpt /
.sas7bdat / .csv |
check_study(path) |
check a whole folder |
write_findings(result, path) |
save to Excel or CSV, with tracking columns |
The other three are there when you need them:
list_rules() |
the rule set — also list_rules(id = ...) to look up a
rule the report named, and list_rules(domain = "AE") for
what applies where |
filter_findings(result, ...) |
narrow a result by triage, dataset, rule or variable |
read_study(path) |
read a folder yourself, if you want to inspect it or check it twice without re-reading |
Plus print() and summary() on a result,
which you get by typing the result’s name.
That’s it. If you only ever learn check_dataset() and
write_findings(), you have most of the value.
What does CORE-000547 actually mean?
rule <- list_rules(id = "CORE-000547")
rule$issue
#> [1] "Variable value is not in correct ISO 8601 date or datetime format"
rule$legacy_ids
#> [1] "SEND66, SEND67, SEND68, TIG0267, TIG0268, TIG0269"
rule$guidance
#> [1] "The SENDIG requires dates and times of day to be stored according to the
#> international standard ISO 8601 (SENDIG v3.0 4.4)"
rule$standard
#> [1] "SENDIG, SENDIG-DART, SENDIG-GENETOX, TIG"list_rules() always returns a data frame with one row
per rule, so pick the columns you want off it. (t(rule)
gives the whole row as a column, which is often easier to read for a
single rule.)
Three things worth knowing here:
legacy_ids are what Pinnacle 21 and
the published Conformance Rules spreadsheets call the same rule. That’s
how you match a coreval finding to a P21 report — including to a
severity CDISC itself doesn’t publish. The console report shows them
too: CORE-000189 · also CG0665, TIG0699.guidance is the sentence from the
Implementation Guide the rule exists to enforce — the why,
which no rule message carries.
print(result, guidance = TRUE) shows it under each problem;
it’s off by default because it roughly doubles the report’s length.Just the things that are definitely wrong
filter_findings(result, triage = "wrong value")Returns a result, so it prints as a report. Also takes
dataset, rule and variable.
A three-line summary, for a script
summary(result)
#> 5 problems across 4 records (111 checks ran, 21 could not)
#> wrong value 2
#> missing required 1
#> missing optional 2Which rules even apply to AE?
list_rules(domain = "AE")Only the rules for my standard and IG version
result <- check_dataset(dm, standard = "SDTMIG", version = "3.4")Rules are written per Implementation Guide version, so this genuinely narrows what runs — for DM: 134 rules for SDTMIG generally, 96 for 3.2, 132 for 3.4.
Worth knowing: this genuinely narrows what runs, and the report tells
you how many rules it set aside. CDISC’s coverage is uneven — the
general “dates must be valid ISO 8601” rule is published for SENDIG and
TIG but not for SDTMIG — so narrowing can mean a real
problem stops being reported. Leave standard unset if you
would rather see everything.
Only the fully-vetted rules, no drafts
subset(list_rules(), source == "published")Which rule version am I running?
attr(list_rules(), "rules_version")
#> [1] "b540283d85e88fb8ee5f08ead5f03fac73eb1b8b"write_findings() records it in every exported file, so
you rarely need to ask.
That’s the exact CDISC commit the bundled rules came from. Worth recording next to your results.
Just the AE findings
subset(result$findings, Dataset == "AE")What’s failing most?
sort(table(result$findings$rule_id), decreasing = TRUE)How many records are actually affected?
nrow(unique(result$findings[, c("Dataset", "Record")]))CDISC publishes its rules in the open. Their repository has 1,348 rules in it. coreval ships 797. Here’s exactly where the other 551 went, and why.
For most rules, CDISC publishes three things: the rule itself, some example data, and an answer sheet — a file saying which exact rows a correct implementation should flag in that example.
That answer sheet is everything. It’s how I prove my version of a rule does what CDISC’s does, instead of just believing it. Every rule in coreval is run against CDISC’s own examples and compared row by row.
Some rules ship without one. CDISC publishes the rule, sometimes even the example data, but never says what the right answer is. I can implement such a rule, and it will produce findings, and neither of us will have any way to know whether they’re correct.
I don’t ship those. A check you can’t verify is worse than no check, because it looks exactly like a check that works.
| rules | ||
|---|---|---|
| In coreval | 797 | everything in a data format this can read, where CDISC gave an answer sheet — plus 30 that came along inside those folders without one |
| Written for a different kind of data | 259 | USDM — study design documents in JSON, not the row-and-column datasets this reads. Not a gap; a different tool’s job. |
| No answer sheet | 292 | includes all 93 ADaM rules. CDISC ships example data for every one of them and an answer sheet for none. |
Of the rules that are both readable and have an answer sheet, coreval has 767 of 767.
Every rule falls into exactly one of four buckets. They add up to 797.
| rules | what it means | |
|---|---|---|
| Confirmed | 695 | Run against CDISC’s own example data. Flagged exactly the rows their answer sheet says, no more and no fewer. |
| Nothing to check against | 37 | CDISC ships no usable answer for these. Not my gap and not theirs to fix quickly — nobody can confirm them, including CDISC. |
| Blocked on data I don’t ship | 10 | The rule is fine and the answer sheet is fine. I’m missing a reference list it needs. |
| Still disagreeing | 55 | coreval flags different rows than the answer sheet says. The actual work left. |
Of the rules that can be confirmed at all — 695 of 750, 93%.
| rules | ||
|---|---|---|
| No answer sheet at all | 30 | CDISC published the rule and no worked example. There is nothing to compare against. |
| Answer sheet exists, but the example data can’t trigger the rule | 6 | e.g. the rule only applies to Events datasets and the folder
contains DM, TX and VS. Or the rule scopes
TA/TE and only an SE dataset was
shipped. |
| The rule itself is empty | 1 | CORE-000536 ships a check block with no conditions in
it. There is nothing to run. |
One of those 6 deserves naming: CORE-000229 says
RELSUB is a Special-Purpose dataset, while CDISC’s own data
model says it’s a Relationship dataset. The two halves of its own scope
can never both be true. I’ve left it alone rather than bend the model to
fit — matching a rule against a class its own publisher disagrees with
would be guessing.
These are never counted as passing. They’re reported as skipped, by name, with the reason, every time you run.
| rules | ||
|---|---|---|
| Need CDISC’s terminology lists | 9 | The codelists — which values are legal for SEX,
AEOUT and so on. That’s the 438 MB problem below, and it is
not in this release. |
| Need a CDISC Library code I don’t carry | 1 | One rule wants a variable’s controlled-terminology C-code. |
This is the honest “my fault” column, and it’s 10 rules — all of them the same packaging decision, not bugs.
Put another way, the work that is actually left:
797 rules
-37 nobody can confirm these, ever
-10 blocked until I ship more reference data
---
750 should end up confirmed
695 are
---
55 still to finish
This is where the real work is, and I won’t dress it up: for 55 rules, coreval flags different rows than CDISC’s answer sheet says it should. 26 of them are deprecated rules that a check never actually runs, so 29 affect real output.
Every one of the 55 has been investigated individually and written down. 53 are cases where CDISC’s example contradicts CDISC’s own rule or is missing an answer it should have. The other 2 are split datasets: coreval finds the same problem but reports it against the file it is in, with that file’s own row numbers, where CDISC reports it against the merged domain. That is deliberate — a finding has to point at a file you can open and a row you can find.
Some are bugs on my side. Others are cases where CDISC’s own example data contradicts itself — a file whose stated answer doesn’t match its own rows, usually because the data was edited after the answers were generated. Each one is investigated individually and written down rather than quietly ignored.
Either way, the honest reading is: treat a finding from those rules with more suspicion than the rest. That’s why every finding carries its rule id — so you can look it up.
ADaM isn’t missing because I skipped it. All 93 ADaM rules come with example data and zero answer sheets. Including them would mean shipping 93 checks that nobody — me, you, or CDISC — can confirm are right.
The day CDISC publishes answer sheets for them, they go in. Nothing else has to change.
Three honest problems, none of them hidden from you at runtime:
The rules are pinned to one exact commit of CDISC’s repository —
recorded in the package, visible with
attr(list_rules(), "rules_version"), and written into every
file write_findings() saves. So a result is always
traceable to the precise rule set that produced it.
Updating is deliberate, not automatic: a maintainer tool
(data-raw/check_upstream.R) reports when CDISC’s repository
has moved, and re-pinning means re-extracting and re-running every rule
against every example again. That way a new version can’t silently
change your results, and any rule that breaks shows up before release
rather than in your data.
Not every rule carries the same weight either —
list_rules() has a source column saying
whether a rule is fully published, superseded, or still a draft.
CDISC publishes, for each rule, data that should trigger it, data that shouldn’t, and the exact records their own engine flags. coreval replays all of it and compares record by record.
On published rules that ship reference data: 540 of 562, about 96%.
| What’s counted | Agreement |
|---|---|
| Published rules with reference data — the meaningful one | 540 / 562 (96%) |
| All published rules, including those with nothing to compare against | 540 / 566 (95%) |
| Every bundled rule, including deprecated and draft | 695 / 797 (87%) |
30 rules ship no reference data at all. CDISC publishes the rule but no examples, so there’s nothing to compare against — they can’t pass or fail. Counting them as failures understates things; hiding them overstates. So both are here.
Deprecated and draft rules are a weaker pool. Their examples predate CDISC’s current conventions — some number records from the spreadsheet header row, so they expect a “record 5” in a four-row file. That’s the example data being old, not coreval being wrong.
Most of the remaining disagreements are problems in the reference data, usually a file whose own stated values contradict its own rows — a sign the data was edited after the expected results were generated.
Please don’t read 96% as a quality score. CDISC’s examples are mostly simple, single-file datasets, so they don’t exercise much of what real submissions do. I once found a bug that silently switched off a third of the rules on split-domain studies — it moved that number by exactly zero. It’s a floor, not a ceiling. Which is the same reason the advice stays: run your qualified tool before you submit.
I built coreval to stop wasting my own time, and I’m sharing it in case it saves yours.
It is an independent, personal open-source project. Not a CDISC product, not affiliated with CDISC, not endorsed by CDISC, and not a CORE-certified engine.
It is not qualified or validated software, and not a substitute for it. A clean run here doesn’t mean your submission will be accepted, and a finding here doesn’t mean it’ll be rejected. It doesn’t replace your organisation’s own validation procedures.
What it is: a fast local check that catches the obvious problems while you’re still writing the code, and that tells you honestly when it couldn’t check something.
Under active development, and the API may still change. Already useful for finding real problems in real data. See NEWS.md.
Issues and pull requests welcome — especially a dataset that produces a wrong or missing finding. That’s the most useful bug report there is. Please read the Code of Conduct first.
Package code is MIT (LICENSE.md). Bundled rule definitions come from cdisc-org/cdisc-open-rules and remain under CDISC’s terms — see NOTICE.md.
Not affiliated with, endorsed by, or certified by CDISC.