If you build SDTM datasets, you know the loop: write code, export, upload to a validation tool, wait, read the report, work out which line of code caused each finding, fix, repeat.
Hardly any of that time is spent fixing things. It goes on finding out what’s broken.
coreval does the finding part on your machine, in seconds. You still run your qualified tool before you submit — you just arrive with a lot less for it to find.
coreval is a personal open-source project. It’s not a CDISC product, isn’t affiliated with or endorsed by CDISC, and isn’t qualified or validated software. Treat every result as a hint, not a verdict. Your qualified tool and your own review are still what decide whether data is good to go.
This is the one you’ll use most while writing code. You have a data frame; check it.
Note row 2: 2024-02-30. February never has 30 days.
ae <- data.frame(
STUDYID = "DEMO", DOMAIN = "AE", USUBJID = c("S1", "S1", "S2"),
AESEQ = c(1, 2, 1), AETERM = c("Headache", "Nausea", "Rash"),
AESTDTC = c("2024-01-10", "2024-02-30", "2024-01-12"),
AEENDTC = c("2024-01-12", "2024-02-01", "")
)
result <- check_dataset(ae)
result
#>
#> ── coreval — AE ────────────────────────────────────────────────────────────────
#>
#> 9 problems across 4 records (167 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 · AESTDTC, AEENDTC
#> row 2 AESTDTC = "2024-02-30", AEENDTC = "2024-02-01"
#> row 3 AESTDTC = "2024-01-12", AEENDTC = (empty)
#> CORE-000547 · also SEND66, SEND67, SEND68, ...
#>
#> [wrong value]
#> The Study Day of Start of Observation (--STDY) is not present in the dataset
#> when Start Date/Time of Observation (--STDTC) is present.
#> 1 record · AESTDTC
#> AESTDTC = "2024-01-10"
#> CORE-000328 · also FB3202
#>
#> [wrong value]
#> The label of the variable does not correspond to the label in the IG
#> 1 record · variable_name, variable_label, library_variable_label
#> variable_name = "STUDYID", library_variable_label = "Study Identifier", variable_label = (empty)
#> CORE-000398 · also CG0303, SEND276
#>
#> [wrong value]
#> Study Day of End of Observation (--ENDY) variable is missing when End
#> Date/Time of Observation (--ENDTC) is present.
#> 1 record · AEENDTC
#> AEENDTC = "2024-01-12"
#> CORE-000776 · also FB3203
#>
#> [missing required]
#> At least one required variable is missing from dataset
#> 1 record
#> missing required variables: AEDECOD
#> CORE-000355 · also CG0014, SEND12, TIG0299, ...
#>
#> [missing required]
#> Required TO dataset for study of tobacco product(s) is not present.
#> 1 record · TO
#> not in the dataset: TO
#> CORE-000590 · also TIG0001
#>
#> [missing optional]
#> At least one expected variable is missing from dataset
#> 1 record
#> missing expected variables: AELLT, AELLTCD, AEPTCD, AEHLT, AEHLTCD, AEHLGT, AEHLGTCD, AEBODSYS, ...
#> CORE-000334 · also CG0016, TIG0301, SEND13, ...
#>
#> [missing optional]
#> Dataset ADSL does not exist
#> 1 record · ADSL
#> not in the dataset: ADSL
#> CORE-000560 · also 1
#>
#> [missing optional]
#> DM dataset is missing.
#> 1 record · DM
#> not in the dataset: DM
#> CORE-000581 · also CG0368, TIG0532, TRC1736a, ...
#>
#> ────────────────────────────────────────────────────────────────────────────────
#> 44 checks could not run.
#> 25 need other datasets (DM, POOLDEF, SUPPAE, SV, TA, TO, ...)
#> → run check_study() on the whole folder to cover these
#> 18 need a define.xml
#> 1 for other reasons, see result$skipped
#>
#> 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")Each problem is described in words, with the rows and values that caused it, and the rule number at the end in case you want to look it up.
The tag on each problem is worth understanding. CDISC Open Rules carry no severity field - Pinnacle 21’s Notes/Minor/Major/Critical is P21’s own layer, not CDISC’s - so coreval does not report one and will not invent one. What it does instead is separate the findings that are definitely wrong from the ones that may be fine:
wrong value - the data contains something that breaks
the rule, like a month of 13. Nothing about your study explains it away.
Start here.missing required - something the standard marks
Required is absent.missing optional - something Expected is absent, or a
value is blank. Often legitimate: a screen-failure subject with no
reference dates, a variable your raw data does not carry yet.Problems are ordered by that first and by how many records they touch
second, and within a problem the record holding a real offending value
is shown before one that is merely empty. It is also a
triage column on every finding, so you can sort a
spreadsheet by it.
When you want the rows themselves — to filter or count — they are in
result$findings, with the same description in an
issue column:
result$findings[result$findings$Value == "2024-02-30", ]
#> Dataset Record Variable Value
#> <char> <int> <char> <char>
#> 1: AE 2 AESTDTC 2024-02-30
#> issue
#> <char>
#> 1: Variable value is not in correct ISO 8601 date or datetime format
#> triage rule_id
#> <char> <char>
#> 1: wrong value CORE-000547You can pass a file instead of a data frame — .xpt,
.sas7bdat or .csv:
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 AE — on a file with no
DOMAIN column the name ae1 is taken at face
value. If it guesses wrong, just say so:
check_dataset(ae, domain = "AE").
Lots of CDISC rules compare one dataset against
another — an adverse event date against the subject’s reference
dates in DM, a visit against the trial design. Give coreval
a single dataset and those questions simply can’t be answered.
coreval won’t guess. It skips them and tells you what it wanted:
cross <- result$skipped[grepl("was not supplied", result$skipped$reason), ]
nrow(cross)
#> [1] 25
head(unique(cross$reason), 3)
#> [1] "needs DM, which was not supplied - check the whole study folder to run this rule"
#> [2] "needs TV, which was not supplied - check the whole study folder to run this rule"
#> [3] "needs SV, which was not supplied - check the whole study folder to run this rule"If it ran those anyway, it would be comparing your data against columns that aren’t there, and reporting problems that don’t exist. Saying nothing is better than making something up.
The same honesty applies to a limitation you should know about up
front: 9 rules need CDISC’s controlled terminology — the codelists
saying which values are legal for SEX, AEOUT
and the like. Those lists run to roughly 438 MB, far too much to bundle,
so this release does not ship them. Rules that need them are reported as
skipped, by name, with that reason, and are never counted as
passing.
Most rules do still run — across AE, DM, LB and VS, 76–84% of the applicable ones work on a single dataset. But the ones that can’t are the cross-dataset checks, and those are often the ones you care about.
So a short findings list here doesn’t mean your data is clean. It’s a quick first pass, not a verdict.
Once the datasets exist as files, point coreval at the folder. Here’s a small one, built on the fly so this vignette runs without any data of your own:
dir <- tempfile("coreval_demo_")
dir.create(dir)
dm <- data.frame(
STUDYID = "DEMO", DOMAIN = "DM", USUBJID = c("S1", "S2", "S3"),
RFSTDTC = c("2024-01-05", "2024-01-06", ""),
AGE = c(34, 61, 47), AGEU = c("YEARS", "YEARS", ""),
SEX = c("M", "F", "F")
)
haven::write_xpt(dm, file.path(dir, "dm.xpt"))
haven::write_xpt(ae, file.path(dir, "ae.xpt"))Point it at the folder, not a file:
coreval reads everything in there, and reading it all at once is the
point — now the cross-dataset rules have both halves to work with. If
there’s a Define-XML in the folder it finds it and uses it (that needs
the xml2 package).
If you want to look at what was parsed, or check the same large study more than once without re-reading it, do the read yourself and pass the object instead:
You get two tables back, and you want to look at both.
head(study_result$findings)
#> Dataset Record Variable
#> <char> <int> <char>
#> 1: AE NA AESTDY
#> 2: AE NA AESTDTC
#> 3: AE NA $dataset_variables
#> 4: AE NA $expected_variables
#> 5: AE NA $dataset_variables
#> 6: AE NA $required_variables
#> Value
#> <char>
#> 1: Not in dataset
#> 2: 2024-01-10
#> 3: ['STUDYID', 'DOMAIN', 'USUBJID', 'AESEQ', 'AETERM', 'AESTDTC', 'AEENDTC']
#> 4: ['AELLT', 'AELLTCD', 'AEPTCD', 'AEHLT', 'AEHLTCD', 'AEHLGT', 'AEHLGTCD', 'AEBODSYS', 'AEBDSYCD', 'AESOC', 'AESOCCD', 'AESER', 'AEACN', 'AEREL', 'AESTDTC', 'AEENDTC']
#> 5: ['STUDYID', 'DOMAIN', 'USUBJID', 'AESEQ', 'AETERM', 'AESTDTC', 'AEENDTC']
#> 6: ['STUDYID', 'DOMAIN', 'USUBJID', 'AESEQ', 'AETERM', 'AEDECOD']
#> issue
#> <char>
#> 1: The Study Day of Start of Observation (--STDY) is not present in the dataset when Start Date/Time of Observation (--STDTC) is present.
#> 2: The Study Day of Start of Observation (--STDY) is not present in the dataset when Start Date/Time of Observation (--STDTC) is present.
#> 3: At least one expected variable is missing from dataset
#> 4: At least one expected variable is missing from dataset
#> 5: At least one required variable is missing from dataset
#> 6: At least one required variable is missing from dataset
#> triage rule_id
#> <char> <char>
#> 1: wrong value CORE-000328
#> 2: wrong value CORE-000328
#> 3: missing optional CORE-000334
#> 4: missing optional CORE-000334
#> 5: missing required CORE-000355
#> 6: missing required CORE-000355One row per problem, pointing straight at it:
| 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 |
triage |
wrong value, missing required or
missing optional |
rule_id |
the CDISC rule, if you want to look it up |
One thing that surprises people: Not in dataset under
Value means the rule wanted a variable you don’t have —
which is usually exactly the finding.
It’s a plain data frame, so slice it however you like:
f <- study_result$findings
head(f[f$Dataset == "DM", ])
#> Dataset Record Variable
#> <char> <int> <char>
#> 1: DM 1 SUBJID
#> 2: DM 2 SUBJID
#> 3: DM 3 SUBJID
#> 4: DM 3 AGE
#> 5: DM 3 AGEU
#> 6: DM NA $dataset_variables
#> Value
#> <char>
#> 1: Not in dataset
#> 2: Not in dataset
#> 3: Not in dataset
#> 4: 47
#> 5:
#> 6: ['STUDYID', 'DOMAIN', 'USUBJID', 'RFSTDTC', 'AGE', 'AGEU', 'SEX']
#> issue triage
#> <char> <char>
#> 1: SUBJID is not unique within study missing optional
#> 2: SUBJID is not unique within study missing optional
#> 3: SUBJID is not unique within study missing optional
#> 4: AGEU is missing when AGE is provided. wrong value
#> 5: AGEU is missing when AGE is provided. wrong value
#> 6: At least one expected variable is missing from dataset missing optional
#> rule_id
#> <char>
#> 1: CORE-000186
#> 2: CORE-000186
#> 3: CORE-000186
#> 4: CORE-000189
#> 5: CORE-000189
#> 6: CORE-000334
sort(table(f$rule_id), decreasing = TRUE)[1:3]
#>
#> CORE-000547 CORE-000398 CORE-000334
#> 21 6 4head(study_result$skipped)
#> rule_id domain
#> <char> <char>
#> 1: CORE-000494 AE
#> 2: CORE-000507 AE
#> 3: CORE-000929 AE
#> 4: FDA.SENDIG.FB6501 AE
#> 5: FDA.SENDIG.FB6502 AE
#> 6: FDA.SENDIG.FB6503 AE
#> reason
#> <char>
#> 1: evaluation failed: rule type 'Define Item Metadata Check against Library Metadata' needs define.xml: no define.xml found in this study
#> 2: evaluation failed: rule type 'Variable Metadata Check against Define XML' needs define.xml: no define.xml found in this study
#> 3: evaluation failed: rule type 'Define Item Metadata Check against Library Metadata' needs define.xml: no define.xml found in this study
#> 4: evaluation failed: rule type 'Domain Presence Check against Define XML' needs define.xml: no define.xml found in this study
#> 5: evaluation failed: rule type 'Domain Presence Check against Define XML' needs define.xml: no define.xml found in this study
#> 6: evaluation failed: rule type 'Domain Presence Check against Define XML' needs define.xml: no define.xml found in this studyThis is the table people skip, and it’s the one that bites. An empty findings table means one of two things: your data is clean, or a lot of rules never ran. Those look identical if you only read the findings. coreval always shows you both, with a reason for every rule it couldn’t run.
write_findings(study_result, "issues.xlsx") # one workbook, a sheet per table
write_findings(study_result, "issues.csv") # issues.csv + _skipped + _aboutBoth tables get written every time, for the reason just above. Excel
output needs the writexl package.
The saved file has three empty columns — Status,
Owner and Notes — for you to fill in once it
is open. Not every finding is something you will fix: some are expected,
some belong to someone else, some are waiting on a data query. Those
decisions are worth recording next to the finding rather than in a
separate document. Pass tracking = FALSE if you would
rather not have them.
One function answers every question about the rule set.
rules <- list_rules()
nrow(rules)
#> [1] 797
table(rules$source)
#>
#> deprecated_dir fda_business_rules_draft published
#> 163 27 566
#> sdtmig_draft sendig_draft
#> 11 30
attr(rules, "rules_version")
#> [1] "b540283d85e88fb8ee5f08ead5f03fac73eb1b8b"That last one is the exact CDISC commit the bundled rules came from.
Worth writing down next to your results — though
write_findings() already records it in every file it
saves.
Not every rule carries the same weight. source separates
fully-vetted published rules from deprecated and draft ones;
?list_rules says what each means.
Note the count above includes the deprecated ones.
list_rules() is the catalog of what is bundled; a
check excludes superseded rules, because running one alongside its
replacement reports the same problem twice. Listing is not running.
Ask it what applies to a domain, or what a rule the report named actually checks:
Six functions, and three of them do the work:
check_dataset(x) |
one dataset — a data frame, or an
.xpt/.sas7bdat/.csv |
check_study(path) |
a whole folder |
write_findings(result, path) |
save to Excel or CSV |
list_rules() |
the rule set, one rule, or the rules for a domain |
filter_findings(result, ...) |
narrow a result |
read_study(path) |
read a folder yourself, when you want to inspect it |
Plus print() and summary() on a result,
which you get by typing the result’s name.
Nothing leaves your machine. No internet, no API key, no account. The rules and the standards metadata are bundled inside the package.
Think of the accuracy number as a floor. For most rules CDISC publishes an answer sheet: some example data, and the exact rows a correct implementation should flag. Every rule here is run against those examples and compared row by row, and coreval agrees on about 96% of the fully published ones.
But those examples are small and tidy, and real submissions are neither, so agreement is a lower bound on correctness rather than a score. The README says where the remaining gaps are. The advice stays the same either way: run your qualified tool before you submit.