Findings that were not real.
has_same_values ignored blanks, and
is_not_unique_relationship against a missing column depended on row
order.Problems that were never looked for.
_variables.csv keeps such a column as a
date-time; CDISC's ADaM examples lost CORE-000690, which finds two
variables sharing one label, and ten others that way.USDM is checked when you ask for it. check_study() and read_study()
take usdm = TRUE. Without it, a folder holding a USDM study design
document stops with a message saying how to check it. Most studies are
tabular, and a few USDM rules take minutes on one file.
CDISC's October 2026 rules. The bundled rules are updated to CDISC's current release, and this one changes answers rather than wording: 103 rules check something different, 138 report different columns with each finding, and a few change which datasets they apply to. Findings on real data will move. Two rules CDISC gave test data for are now graded and pass, and a date "not equal" comparison follows CDISC's engine when both dates are blank: they count as equal, not different (CORE-000370, CORE-001044).
A stray space in a CSV header is no longer part of the variable's name.
CDISC's own example data writes DOMAIN and MHDY. Untrimmed, the
declared variable was invisible: a rule asking whether it exists reported a
violation that CDISC's engine does not, and where DOMAIN itself went
missing every dataset in the folder was classed wrongly, which silently
changed which rules ran against it. Values keep their own whitespace.
Reports and exports.
filter_findings(variable =) finds a variable reported as missing, and
refuses a typo instead of matching nothing.Faster, and lighter. CDISC's pilot submission is checked in about 19 seconds against 26 before, with the same findings. A study ten times its size, about three million records, takes 156 seconds against 184 and a little less memory. USDM study documents are read in half the time.
Stricter testing. The conformance harness no longer counts a rule as passing when the only cases it could grade were the ones expecting nothing, and split fixtures are graded. See docs/COVERAGE.md for the current counts and every rule that moved.
Broken input is refused, not checked in part. 95 broken and odd inputs were fed through every public function. What changed as a result:
write.csv()
writes as the text NA, was read as text. Both now read as the file says,
and CSV text in the Windows encoding is repaired like any other input.use_case, or include_deprecated = "yes", quietly changed
which rules ran. Both are refused. list_rules(use_case =) now filters
without a domain too, as documented.check_study(42) and write_findings() to a missing folder gave internal
messages. They now say what is wrong.Twice as fast on large studies, with less memory. CDISC's pilot study repeated to 5.9 million rows now checks in 451 seconds instead of 892, and peaks at 5.4 GB instead of 7.1 GB, with identical findings. Dates are parsed once per distinct value rather than once per row, and the Match Datasets join no longer holds six copies of the table at once. docs/BENCHMARKS.md has the measurements and the script.
Tested against CDISC's engine on a complete real study. CDISC's pilot submission, CDISCPILOT01, was checked by both tools with the same settings and every finding compared, rule by rule. docs/REAL-STUDY.md is the record and says how to repeat it. Three defects came out of it, none of which CDISC's small rule examples could have exposed:
Fixed: text in the Windows encoding SAS writes was left unchecked. A transport file does not say how its text is encoded, and a curly apostrophe written on Windows arrives as a byte that is not a character in UTF-8. Every pattern rule meeting one warned and skipped that row. Such text is now read as Windows-1252; valid text is untouched.
Fixed: numbers from transport files carried conversion noise. XPT stores numbers in IBM floating point, and the same visit number could arrive as 9.2999999999999989 in LB and 9.3000000000000007 in SV. 250 lab records were reported as having a visit that is not among the subject's visits. Numbers read from XPT and SAS files are now rounded to 15 significant digits, below the noise and above any precision clinical data carries. The pilot study now gives identical findings read from its XPT files and from its Dataset-JSON copy.
Fixed: a parent record key written with padding matched nothing. SAS
often writes a number into a character field right-aligned, so IDVARVAL 1
becomes " 1". No SUPP or RELREC row found its parent, and CORE-000206
reported all 64,637 of them as pointing at records that do not exist. Keys are
compared without surrounding blanks.
Fixed: checking a USDM study ran nothing and reported it clean. Three separate faults, and the conformance scores could see none of them, because the harness reaches the rules by a route a user never takes.
A USDM document states its version as "4.0.0" and the rules say "4.0", and versions were compared as exact text, so not one rule was selected. Versions are now compared as versions everywhere: "4.0.0", "4.0" and "4" are one, and "3-4" is "3.4". That comparison had been written out three separate times, with three slightly different normalisations, and is now one function.
The 96 JSONata rules were only ever run by the harness. check_study() now
runs them, once per study rather than once per entity table, and reports each
finding against the entity and row it is about, the same numbers the other
USDM rules report against.
37 USDM terminology rules were refused for lacking a study-wide Controlled Terminology version, which they never read: a USDM document names the terminology version beside every coded value. The harness always supplied a version, so they scored as passing.
A check where nothing ran now stops with an error. If every rule is filtered away before any can be tried, that is a check that did not happen, and it used to print as a study with no problems.
Checking one dataset no longer reports on datasets it was never given. A DM on its own was told that ADSL and the tobacco-product dataset were missing. Rules about what the whole study contains are now skipped for a single dataset, saying so, the way rules needing another dataset already were.
A data frame with no labels is not reported for wrong labels. A table built in code has no variable labels until someone adds them, and every label rule reported a mismatch against a blank. Those rules now skip for a dataset carrying no labels at all; a dataset with some labels still has every blank one checked.
The README is rewritten, at about a third of its length. The detail it carried now lives in the getting-started vignette, which gains sections on narrowing to one standard, on the Pinnacle 21 IDs and guidance each rule carries, and on USDM.
coreval reads USDM studies and runs their rules. 257 USDM rules are bundled and 253 of them agree with CDISC's own answer sheets, on every positive and negative fixture: all 157 Record Data rules and all 96 JSONata rules. The bundle goes from 797 rules to 1,054, and the full sweep from 705 passing to 958, with the 55 disagreements unchanged.
The 4 that do not run are JSON Schema Check and are skipped naming the type.
Which errors they report depends on the internal error tree of one JSON
Schema library, including how it walks the branches of an anyOf; another
validator reports a different shape, and deriving one from the other would be
inventing findings rather than checking data.
Ten defects came out of getting there, and every one of them made a check quietly do nothing rather than report something wrong.
A USDM study is one JSON document, and it is now read as one. CDISC publishes the Record Data fixtures as flattened per-entity CSVs, which is its own test harness's format rather than anything a user has, so the rules were only ever runnable against CDISC's own files. The document is now flattened into those tables the way the reference flattens it.
It is a port and not a fresh design, because the traversal order fixes the
order of records in each table, which fixes the record number a finding is
reported against. The reference walks with the JSONPath query $..*, where
* matches an object's keys and matches nothing on an array, so it is
neither breadth- nor depth-first.
No CDISC fixture pairs a document with the tables it should produce: the
Record Data fixtures ship only tables and the JSONata fixtures only
documents. So the pairing is made rather than assumed.
tests/conformance/dump_usdm_tables.py runs CDISC's own service over a
document and writes what it produces, and
tests/conformance/compare_usdm_reader.R diffs coreval's tables against
those. Across documents holding 50 to 75 entities and up to 17,593 records,
every entity, row, column and cell matches.
JSONata rules run. 96 rules state their whole check as a JSONata
expression over a USDM study document rather than as a Check block. coreval
bundles the reference JSONata implementation as JavaScript and runs it in
QuickJSR's embedded engine, offline, with CDISC's own utility functions
assembled into the prelude the way CDISC's engine assembles them. QuickJSR
is a Suggests, with no dependencies of its own; without it these rules skip
with a reason.
The bundled evaluator is version 1.8.7 rather than the current 2.x. 2.x is
built on native async/await, and settling a promise needs the host to
pump the engine's job queue, which QuickJSR cannot; 1.8.7 uses a generator
trampoline and returns its result directly. That is a real divergence from
the reference, so it is measured and not argued: every one of the 96 rules
returns exactly the paths its committed sheet names, on every fixture.
Fixed: the conformance harness ran rules check_study() would not. Three
bundled rules carry types missing from the supported list, so the harness
evaluated them while the package itself skipped them, and the scoreboard
carried two passes no user could ever get. Both types are conjunctions of
types already supported and are evaluated by the same machinery, so they are
now declared rather than quietly exercised. The harness also refuses an
unsupported type up front, instead of letting the rule fall through and be
skipped for a reason that is not true: the four JSON Schema rules were
reported as shipping "no datasets, only define.xml", with no define.xml
anywhere near them.
Fixed: a dataset was named after its file rather than what the manifest
declared. A test case's _datasets.csv can carry a Dataset Name column,
and where it does, that is the dataset's name. USDM fixtures truncate the
file stem to 27 characters, so a rule scoped to
StudyProtocolDocumentVersion found no dataset in scope at all while the
dataset sat there under the truncated spelling. Two files declaring one name
now raise rather than silently resolving to the first.
Fixed: a declared Join Type: left was ignored. Ten Match Datasets specs
declare it, and it is the reference's signal to keep the rows that matched
nothing and blank their joined-in columns. Those rows are the point of such a
rule: which epochs no activity instance points at, which timings nothing
schedules. Both reported nothing.
Fixed: a grouped distinct collapsed to a plain column when every group
held one value, so an unmatched row read as NA instead of the empty set
and the rule found nothing there. Comparison operators can now read a
set-valued operand rather than raising on one. The ordinal operators refuse
it instead, which is a change too: list(...) < "2" does not raise in R, it
compares the deparsed text and answers FALSE for every row.
Fixed: empty answered NA for an Operations binding that resolved to
nothing. 63 rules ask exactly that of a codelist lookup, where "this code is
not in the codelist" is the finding, and NA is not a violation: it made the
enclosing condition NA and the row vanished from the report while its
neighbours were listed. A dataset column's NA still is not blank, which is
what keeps the rules that rely on an unmatched join reading as populated.
Controlled terminology preferred terms now ship, in their own lazily read file, so a study with no rule asking for one pays nothing for them. The rules that do ask were refused outright before. Four further defects sat behind that refusal: the CT family for a declared package type was guessed from a short list instead of being derived the way the reference derives it, so every DDF lookup went to the wrong terminology; the codelist code was resolved against an empty binding list; the term separator was the empty string, which splits a codelist into single characters; and a code absent from the codelist aborted the whole operation instead of answering "not found".
get_xhtml_errors is implemented, with the XHTML schemas bundled so
validation happens offline. The XHTML modules are the W3C's rather than
CDISC's, under their own permissive grant, and inst/COPYRIGHTS says so.
xml2 stays in Suggests: without it these rules skip with a reason.
Fixed: an escaped quote inside a quoted CSV field was read as two quotes.
RFC 4180 escapes a quote by doubling it, so "<ref klass=""Range""/>" is the
value <ref klass="Range"/>. fread does not collapse the pair when the
field contains no separator, and handed back the doubled form;
utils::read.csv reads the same file correctly, so this was not ambiguity in
the data.
It produced a wrong value rather than a failure, which is why it went unnoticed: a rule matching such a value against a pattern got a confident answer computed from text the file does not contain. Only files that really contain a doubled quote are re-read, so the common case pays one scan and nothing more.
Fixed: a column called name shadowed the column being aggregated.
data.table evaluates a grouped expression with the columns in scope, so
looking up the variable name inside one found a column of that name instead
of the function's own argument, and then tried to resolve that column's first
value as a variable. No SDTM domain has a column called name, which is why
it never showed; every USDM entity does. The column is now taken out before
the grouped call rather than looked up inside it.
codelist_terms has a second shape, and it now runs. One asks for every
term of a codelist, once for the dataset. The other asks a question per row:
for this row's code, in the terminology version this row cites, what is the
matching term's submission value. Only the first was implemented, so the
second bound nothing and every rule comparing against it answered from an
empty set.
A variable a fixture declares Boolean is read as a logical. Left as text,
"True" was compared against R's coercion of TRUE to "TRUE" and a row
that plainly satisfied its rule read as clean. It worked for "FALSE" only
because that casing happens to match.
Fixed: empty read a set-valued binding as populated. A grouped
Operations binding resolves to one set per row, and is.na() on a list is
FALSE for every element including an empty one, so a row whose set held
nothing read as populated and the rule found no violation.
Fixed: a paired join key could collide with a column the matched dataset
already had. Renaming the right side's instanceType onto the left's
parent_entity collides when the right carries a parent_entity of its own,
leaving two columns of one name and a merge that matches nothing. The keys
are now renamed after the colliding columns have been moved aside.
A Match Datasets key can now name a different column on each side.
A key is usually one column both datasets share, but it can also be a pair,
Left: id / Right: parent_id, for a child whose foreign key is spelled
differently from its parent's id. Both forms mix inside one Keys list.
Handing that list to the variable-name resolver raised
"non-character object(s)", which surfaced as an evaluation failure saying
nothing about keys. No bundled rule uses the paired form.
A matched dataset is now found whatever case its name is written in. The
lookup was case-sensitive while every reader here upper-cases dataset keys,
so a Match Datasets entry naming Code against a CODE key missed
silently: the join was skipped and the rule then compared against a column
that had never been joined in. SDTM rules name domains in upper case
already, so nothing shipped was affected.
A column that collides across a join is now reachable under both spellings.
The reference renames the columns a rule explicitly names as
Name.column, then lets pandas suffix the remaining true collisions as
column.Name. The two halves use opposite orders. Only the first was
implemented, so a rule naming the suffixed form found literal text.
map and codelist_extensible run, and an Operations entry can carry
group_aliases so an aggregate computed over one dataset joins onto another
by that dataset's own column names.
Fixed: max was a date operation, and should never have been. The
reference has four separate operations where coreval had two: max/min are
plain aggregates over whatever the column holds, and only max_date/
min_date parse ISO 8601. Both went through the date picker here, which
validates against a date regex and yields nothing for anything else, so max
over a text or numeric column produced no binding and the rule using it
quietly found nothing.
Exactly one bundled rule uses max, and its column is a date, so no shipped
result was wrong. It is fixed because the next rule to use it might not be,
and because a missing binding is the silent kind of failure. min now exists
too.
A rule can now scope by USDM entity (Scope: Entities). A scope key the
resolver did not recognise was skipped, which meant every other test passed
by default and the rule matched every dataset in the study rather than none.
Reads CDISC Dataset-JSON and Dataset-NDJSON. These are the formats CDISC
publishes as the successor to transport files, and check_study() now takes
a folder of either. jsonlite is a Suggests, so the runtime dependencies
are still data.table and haven; without it a Dataset-JSON study raises
rather than reading as empty. Where a folder holds both, transport files win,
since a folder holding both is a conversion in progress and the transport
files are what a submission is made of.
Verified by round-trip rather than against an answer sheet, because CDISC publishes no Dataset-JSON reference data: the same study written as XPT, as Dataset-JSON and as Dataset-NDJSON has to produce the same findings, on the same rows, from the same rules. A dropped row or a shifted column breaks that and keeps the count.
Two kinds of broken file are refused rather than read. Rows that do not all
match the declared column count are malformed, and a file whose records
count disagrees with the rows it carries has been truncated. CDISC's own
engine returns an empty dataset when a Dataset-JSON fails schema validation,
which turns a broken file into a clean bill of health.
Fixed: a malformed CSV lost records, or every column name. Three separate ways, all silent, all found by reading the warnings the conformance sweep had been printing and nobody had opened:
pr.csv holds three records and coreval saw two.ce.csv came back with columns called 1234.0 and Fracture and no
CETERM, CECAT or CESCAT. Every rule about them found nothing and the
dataset looked clean. Both rules reading it still passed, because finding
nothing was what their answer sheet expected.V1..V13 from a ten-column file.The reader now pads a short row instead of dropping it, as the reference does through pandas, keeps the header as the header, and states the separator rather than letting it be guessed. A row with more fields than its header is still reported, since that file really is invalid.
A define.xml that cannot be read now says so. It used to return the same nothing as a study with no define.xml at all, so a truncated or non-Define file was indistinguishable from one that was never supplied. Worse once the define became a source of the CT version: the rules that needed it skipped saying "this study does not say which terminology it follows, pass ct_package", which is true of the parsed result and useless to someone whose file simply stops mid-element. The warning now names the file and the reason, down to the line.
Define-XML now says which controlled terminology a study follows. A
Define-XML 2.1 records it in def:Standards, the same way TS records it in
TSVCDVER, and coreval reads it instead of asking. TS still wins where both
say; the define is used when TS is absent or cites no CDISC version.
This is what CORE-000929 was actually blocked on. The rule compares the term
codes in a variable's codelist against the DOMAIN codelist's, and that
codelist has 150 terms in the 2020-12-18 terminology its fixture's define
cites and 85 in the newest. Answered from the wrong release it flagged a
clean CM.
The term codes inside a variable's Define-XML codelist are read
(define_variable_codelist_coded_codes). These come from the study's own
define.xml and were previously refused as detail coreval does not read, which
was the wrong diagnosis: nothing about them needs bundling.
Fixed: is_contained_by ignored a collection on the target side. The
operator handled a comparator that varies per row but not a target that is
itself a set per row, so it compared the set against the values and reported
a violation for every row. The reference treats a row as contained when ANY
of its items is in the comparator, which reads as too weak and is what it
does.
Three more rule types run: split_by, get_codelist_attributes and the
per-row form of contains_all/not_contains_all. The last was the reason
CORE-000934 found nothing - the reference compares row by row when each row
has its own collection, and coreval only had the dataset-level path, so its
single verdict landed on row 1.
The controlled terminology version is read from TS. Studies record it
themselves - TSVCDREF names the publisher and TSVCDVER the version - so
check_study() picks it up without being told. ct_package stays as the
manual override. Rows citing someone other than CDISC are ignored, and where
a real TS carries stale rows the version most rows agree on wins.
Controlled Terminology checks now run. Nine rules ask whether a value is
a legal term - SEX may be F, M, U or INTERSEX and nothing else -
and they were skipped because CDISC's terminology caches are 438 MB. Almost
all of that is definitions and synonyms no rule asks for; the submission
values, C-codes and extensible flags that conformance actually needs are
0.54 MB, so every published package is now bundled.
Tell it which version your study follows:
check_study(dir, ct_package = "sdtmct-2026-03-27")
list_ct_packages("sdtm")
coreval will not choose for you. Terminology moves between releases - SEX
gained INTERSEX and lost UNDIFFERENTIATED - so judging a study against a
version it never declared would both invent violations and hide real ones.
Without ct_package those rules are skipped saying exactly that. The table
is read on first use, so a session that never runs one pays nothing.
Fixed: an Operations id without a $ was treated as literal text. Six
rules (CDISC.SDTMIG.CG0555-CG0560) declare ids like pkunit_terms bare,
so PPORRESU is_not_contained_by pkunit_terms compared the column against
the string "pkunit_terms" - never contained by it, so every row whose
PPTEST lacked "norm" was reported, in a clean dataset as much as a dirty
one.
Fixed: an unimplemented Operations type reported nothing instead of
saying so. The dispatch fell through to no binding, so the rule's condition
degraded to literal text and the rule quietly found nothing. CORE-000934 did
this: CDISC's engine reports two rows on its own fixture and check_study()
reported none. It now names the operation it cannot run.
The progress bar now names the domain being checked and how far through the
study it is, and its percentage is weighted by how many records each domain
holds rather than by a plain count of rules. A check against a 161,600-row
AE costs hundreds of times one against a 200-row SJ, so the old bar
sprinted through the small domains and appeared to hang on the big one.
The one-row-per-(record, variable) table that some rule types need is built column by column into a preallocated vector rather than as one table per variable stitched together. It is inherently large - 27 columns of a 161,600-row AE is 4.4 million rows - and the old shape held every converted column and the finished table at the same time. R's reported high-water on a 511,000-row study fell from 744 MB to 589 MB.
Date columns are scanned once instead of two to four times. Validating a
date, detecting its precision and parsing it each re-ran the same expensive
regex over the same column; the components are now computed once and passed
along. --DY comparisons were the worst case, scanning two columns four
times.
The per-rule synthetic datasets are built once per domain instead of once per rule. Those builders never depended on the rule, yet about 60 rules each asked for the identical answer, and computing one of them scans every variable against every record. Checking a 511,000-row study went from 77s to about 47s. The cache lives for one sweep only and holds 0.1 MB.
The bundled rule table is built once per session instead of once per domain. It was 600 MB of the 2.9 GB a 51,000-row study allocated - a fifth of everything, none of it touching your data. Checking a 511,000-row study went from 113s to 77s.
Checking a large study is dramatically faster. The cross-dataset match
that joins a supplemental or related dataset to its parent looped over every
child row and rescanned the whole parent each time. It was 90% of
check_study()'s entire runtime and got worse than linearly with size. It is
now a single grouped join per distinct (RDOMAIN, IDVAR) combination.
On a CDISCPILOT-shaped study:
| rows | before | after | |---|---|---| | 5,000 | 18.4s | 4.9s | | 51,000 | 122s | 11.1s | | 511,000 | ~21 min | 73s |
The answers are unchanged: the new join was compared against the old one on 173 fixture cases and is bit-identical, the conformance sweep is unmoved at 697/54/46, and no failing rule's reported records changed either.
domain_label now means what the standard calls a domain, not what your
own dataset metadata happens to call it. The two differ: SENDIG calls LB
"Laboratory" where SDTMIG calls it "Laboratory Test Results". CORE-000272
asks whether --CAT equals that label, so on a SEND study coreval was
answering a different question and missing the finding. Per-standard dataset
labels are now bundled (483 of them, 4 standards). A domain no standard
defines still falls back to your own label.
Fixed a false-positive class: a variable that isn't there is no longer
treated as failing a date or uniqueness test. Three operators were written
as plain negations of their positive counterparts, which answer FALSE for a
column the dataset does not have - so negating them answered TRUE.
is_incomplete_date was the damaging one: CORE-000138/139 ask whether
DM.RFSTDTC is an incomplete date while --STDY is populated, so on a study
with no DM every record carrying a study day was reported. On a
three-dataset test study that was 267 of 393 findings - noise that buries the
real ones. is_unique_set and is_unique_relationship had the same shape.
Found by running CDISC's own rules engine over the same data and comparing rule by rule: it reports nothing for those rules, coreval reported hundreds. The conformance pass rate did not move at all, in either direction.
Codelist checks against Define-XML now run. coreval reads the codelist C-code a Define-XML file attaches to each variable, and bundles the code the CDISC Library expects for it, so a rule can tell you when your define declares the wrong codelist for a variable or declares none where the standard has one. One rule (SENDIG SEND49) moves from skipped to running.
Only the identity of a codelist, never its terms: which values are legal
for SEX is Controlled Terminology, around 438 MB, and still deliberately
not bundled. Rules asking what is inside a codelist continue to skip with a
reason.
Rules refreshed from a newer upstream commit of CDISC's rule repository. One rule's scope changed (CORE-000892 now applies to Special Purpose domains rather than Findings), which changes the domains it runs on from 62 to 25.
write_findings() names the offending argument when given a path that isn't
a single string, instead of surfacing an error from inside data.table.
Documentation fix: findings is one row per affected record. Three places
called it "one row per problem", which is what summary() counts and a
different number.
First release. The API is settled for 0.1.x; anything that changes will go through a deprecation cycle rather than disappearing.
A personal open-source project. Not affiliated with or endorsed by CDISC, and not a CORE-certified engine. It's a quick local check to run before your qualified validation tool, never instead of it.
check_dataset(): a data frame you already have
open, or a single .xpt, .sas7bdat or .csv file. No study folder needed.
This is the one for when you're mid-way through writing the code that builds a
domain. coreval works out the domain from your DOMAIN column, or the file
name. Rules that need a dataset you didn't supply are skipped and say so,
rather than being run against columns that aren't there.check_study() on a folder. It reads XPT, SAS
and CSV, and picks up Define-XML (2.0 or 2.1) if it's there. Reading
everything at once is what makes the cross-dataset rules work. read_study()
is there when you want the parsed study itself, or want to check the same
large study more than once without re-reading it.$findings as an issue column, so a rule number is never the
only thing you get.wrong value (your data breaks
the rule: a month of 13, a value outside its codelist) from missing required
and missing optional (often legitimate: a screen-failure subject, a
variable your raw data does not carry yet). Sorting by row count alone put
those in the wrong order. It is a triage column on every finding, so a
spreadsheet can be sorted by it too.print(result, n = 20, rows = 5) shows more.missing required variables: SUBJID, SITEID, COUNTRY.write_findings() to Excel or CSV. You get
findings, skipped, an about sheet and - when a rule matched more
records than were kept - truncated. The file carries empty Status,
Owner and Notes columns for you to fill in, so "expected, see protocol
deviation log" lives next to the finding instead of in another document.
Pass tracking = FALSE to leave those out.about records which
standard the run was scoped to, how many checks ran, whether the result was
filtered before export, and whether any counts were capped. A shared
spreadsheet outlives the console session that made it, and whoever opens it
cannot otherwise tell that it is partial.CG0665, SEND66, TIG0699,
FB0801 - which are the ids P21 and the published Conformance Rules
spreadsheets use. The report shows them next to the CORE id, so a finding
here can be matched to a finding there, including to a severity CDISC itself
does not publish. All 797 rules have at least one.print(result, guidance = TRUE) shows
it under each problem - off by default, since it roughly doubles the report -
and list_rules() always returns it.list_rules() now
answers all three: list_rules() for the catalog, list_rules(id = "CORE-000547") to look up a rule the report named, list_rules(domain = "AE") for what applies to a domain. The columns are the same whatever you
ask, so the result is safe to filter, join and script against.
rule_info(), rules_for_domain() and rules_version() are gone -
the last is now attr(list_rules(), "rules_version"), and
write_findings() records it in every exported file anyway.summary() gives the counts in three lines, for a script or a quick
"did that fix help?", and returns them as a row you can log.filter_findings() narrows a result by triage, dataset, rule or
variable, and returns a result - so it still prints as a readable report,
and says it is a subset rather than passing for the whole picture.Checking is dramatically faster - roughly 50x on large data. Four things
were doing per-row work on whole columns: the date operators called
grepl()/regexpr() once per value; findings were assembled one
data.table per violating record; the code building reported values re-decided what kind
of thing each variable was for every row; and the uniqueness operators
answered "does this key repeat?" by building an interaction factor and
sorting it, rather than by hashing.
| rows | before | after | |---|---|---| | 10 000 | 37 s | 1.1 s | | 200 000 | ~12 min | 18 s | | 1 000 000 | ~1 hour | 71 s |
Finding counts are identical at every size.
Findings are capped at 1000 records per rule by default. A rule can flag
every row - a missing EPOCH on a 200 000-row LB is 200 000 identical
findings, beyond what anyone reads or Excel can hold. The true count is kept
in the new truncated table and shown in the report ("1 000 000 records
(first 1 000 kept)"), so nothing is under-reported. max_records = Inf
keeps everything.
A progress bar appears for long checks when running interactively, so a slow
study no longer looks like a hang. options(coreval.progress = FALSE) turns
it off; it is already off in scripts.
'nzchar()' requires a character vector. read.csv(stringsAsFactors = TRUE) and plenty
of older code still produce factors, so they are converted rather than
refused.DOMAIN no longer changes the answer. "AE " was
treated as a domain of that name: it scoped to a different rule set, and
resolved "--STDTC" to "AE STDTC" - a column nothing has - so every
"--" rule silently found nothing. (The padded value is still reported as
a problem in its own right, by the rules that exist to catch exactly that.)DOMAIN, says which it is. The
old message claimed there was "no single DOMAIN value", which reads as "your
column is inconsistent" to someone whose data simply has no rows yet.days_in_month() was wrong for vector input: it built its lookup table with
c(31, ifelse(leap, 29, 28), 31, ...), which produces one element per YEAR
rather than one per month, so for n years the table was 11 + n long and every
month from March on read the wrong slot. Correct for a single value and
wrong for a column - which the old per-row date code hid completely.
"2003-11-31" was rejected when checked alone and accepted when checked as
part of a column.standard = "SDTMIG"
was accepted and silently ignored before, so an SDTM study was also measured
against SENDIG rules - 73 of the 270 rules in scope for DM are SENDIG-only.
Declaring it now halves the rules and more than halves the reported problems,
losing no real coverage. Matched exactly, so a SENDIG study does not pick
up SENDIG-DART rules.check_dataset(dm, standard = "SDTMIG", version = "3.4"). Rules
are written per version - 408 SDTMIG rules exist for 3.2 against 445 for 3.4,
and 86 apply to exactly one version - so without this a 3.2 study is measured
against rules written for a guide it does not follow. "3-4" and "3.4" are
both accepted, since CORE test cases write the first and the rules the
second.include_deprecated = TRUE. A deprecated rule has a published replacement,
so running both reports the same defect twice.inst/COPYRIGHTS. cdisc-open-rules is MIT licensed, and MIT requires the
copyright and permission notice to accompany substantial portions of the
work - coreval bundles 797 extracted rules and CDISC standards metadata, but
the notice lived only in NOTICE.md, which is excluded from the build and so never
reached anyone who installed the package. A test now guards it.check_study() takes a folder path, so read_study() is now optional:
check_study("study/sdtm") instead of reading first. Reading yourself is
still worth it to inspect what was parsed, or to check the same large study
twice without re-reading it.sdtm_domain_classes() is no longer exported - a domain-to-class lookup
table answers a question nobody working with their own data has.evaluate_rule() is no longer exported. It returned a raw logical vector,
needed a rule record fetched from package internals, and had no story a user
could follow now that check_dataset() exists.Unpublished/SDTMIG and Unpublished/SENDIG -
folders the extractor never opened. Both are SDTM- and SEND-shaped, so this
engine can read their data, and only the ones CDISC already ships expected
results for are taken (11 of 128, and 30 of 77). That is the whole remaining
gap: of the 767 upstream rules in a readable format that CDISC ships results
for, coreval now has 767.read_study() cannot read it at all.
Unpublished/ADAMIG (93) ships test data but no expected results for a
single rule, so nothing there can be verified. Another 292 readable rules
have no expected results either.contains_case_insensitive and
does_not_contain_case_insensitive.TO, PT, IN, RELREF and nine others
resolved to nothing. Every rule scoped to one failed its Scope > Classes
check and was skipped as "no dataset matches the rule's scope" - with the
right dataset sitting in the test case. Taken from the same CDISC cache as
the rest of the table, and checked against the 42 shared domains first: no
conflicts.2024-03), grouping and uniqueness
checks, and set membership.Operations pipeline that
pre-computes values rules refer to.0.0 comes back
as 0.