coreval

R-CMD-check License: MIT Lifecycle: experimental

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.

About that ordering

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.


The workflow this is built for

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 left

Step 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

# 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 .xlsx

Checking one dataset

This 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")

The catch, and it’s an important one

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.


Checking a whole study

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.


Reading the results

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 wrong

head(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 checked

head(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.

Saving it, and tracking what you didn’t fix

write_findings(result, "issues.xlsx")   # one workbook, several sheets
write_findings(result, "issues.csv")    # issues.csv + siblings

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


The whole API

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.


Recipes

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:

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  2

Which 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")]))

What’s covered, and what isn’t

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.

First, how anyone knows a rule works

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.

Where the 1,348 rules go

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.

How many of the 797 are actually proven

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

The 37 nobody can confirm

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.

The 10 blocked on data I don’t ship

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

The 55 that disagree

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.

The ADaM question, since people ask

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.

Where it’s genuinely weak

Three honest problems, none of them hidden from you at runtime:

Keeping up with CDISC

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.

How accurate is it?

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

Why there’s more than one number
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.

What this is, and isn’t

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.

Status

Under active development, and the API may still change. Already useful for finding real problems in real data. See NEWS.md.

Contributing

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.

License

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.