Eight more deterministic fix classes: 79 of 537 rules become 131

0.0.1.9000’s ceiling section named 48 rules as genuinely reachable with more work and published that as a limit rather than a target. These are those handlers, plus four rules that ceiling had filed as undecidable and that became reachable once the shipped CT carried term-level NCI codes. 52 rules moved, across 8 mechanics — counting rules overstates the work, so both numbers are given. Every handler recomputes the problem from the data and declines to emit an expression when the recomputation shows the remedy is not decided by it.

  • variable_name_format (25 rules: AD0013, AD0062, AD0063, AD0065, AD0068, AD0073, AD0074, AD0094, AD0185AD0193, AD0213, AD0215, AD0273, AD0299, AD0303, AD0314, AD1018, AD1019) — an ADaM variable name that breaks the rule’s own name template. The rule quotes the template (TRTxxP, CRITy(FL/FN), (R2)AyLO, *ORGy), so the legal names are enumerable: .expand_name_template() expands it to every name it permits and the repair is the one that differs from the flagged name by zero-padding alone (TRT1PTRT01P, AVALCAT01AVALCAT1). A rename changes no values. Refused when the index is 0, when it is 10 against a [1-9] rule (that is a renumbering, and what the parameter means is a study decision), when the padded name would collide with an existing column, and — for AD0013 — when the name is over 8 characters, because shortening it is a naming decision. Only the illegal-character substitution is emitted there.
  • ts_val_format (9 rules: SD1215, SD1217, SD1219, SD1221, SD2245SD2249) — a TSVAL in the wrong format for its parameter. Deterministic only when the recorded value re-expresses itself: a date that parses under exactly one known format ("01JAN2021""2021-01-01"), an integer wrapped in spaces or separators, or a duration that states its own unit ("18 YEARS""P18Y"). A bare "52" for LENGTH is refused: weeks, months and years are all in the protocol and none of them is in the data. That is the same restraint that keeps SD0029/SD0026 unhandled.
  • duplicate_ts_record (8 rules: SD1214, SD1216, SD1218, SD1220, SD1222, SD1224, SD1225, SD1227) — a Trial Summary parameter with more than one record. Deterministic only when the records are exact duplicates once the sequence variable is set aside; if they disagree on TSVAL, which one the protocol meant is not in the data.
  • define_missing_entry (2 rules: SD0060, SD1063) — a variable, or a whole dataset, in the data but not in define.xml. The skeleton specification row is recomputed from the column: type from its class, length from the longest value it actually holds. The label is left NA where the column carries none, the same convention missing_variable and missing_ts_param already use.
  • type_mismatch (SD0059) — define.xml declares one type and the column holds another. Only the lossless conversion is emitted (character digits to integer, an integer column to character). A numeric column read as text because one record says "UNKNOWN" is refused: deleting that value, or changing define.xml to agree with it, are both decisions about the data.
  • datetime_component (2 rules: AD0044, AD0045) — a --DT or --TM that disagrees with its --DTM. The repaired value is a substring of the --DTM already recorded, so nothing is invented. Refused when any disagreeing record has no ISO 8601 --DTM to read from — no partial expression is emitted. The direction is the ADaM derivation and the suggestion says so on every row.
  • non_ascii (SD1029) — non-ASCII or non-printable characters in a value. Deterministic only for characters whose ASCII equivalent is not a choice: a non-breaking space is a space, a typographic quote is a quote, an en dash is a hyphen. An accented letter or a CJK character is not transliterated, and the suggestion names the offending code points instead — respelling a term changes the term, which is data.
  • code_decode_pair (4 rules: CT2003, CT2006, SD0040, SD1043) — a --TEST that is not the CDISC term sharing its --TESTCD’s NCI concept code. Deterministic in that direction only: a --TESTCD outside CT is a sponsor term whose long name only the study knows, and it is left alone.

sdtm_ct now carries term-level NCI concept codes

  • New column code on sdtm_ct: the NCI concept code of each term, not only of its codelist. CDISC gives a --TESTCD term and its --TEST term the same code, which is exactly what code_decode_pair needs and exactly what 0.0.1.9000’s ceiling named as the reason those four rules were out of reach.
  • It costs 105 KB installed (895 KB without it, 1,000 KB with; +57 KB in sdtm_ct.rda itself, 223 KB → 285 KB). Judged worth it: code/decode disagreement is a routine submission finding and the remedy really is a lookup. R CMD check still finishes at 0 notes.

What fires on a real report

Measured against the 1,840 shipped CDISCPILOT01 findings:

  • None of the eight new handlers fires. That report triggers 8 distinct rules (SD0029, SD0021, SD0026, SD1076, SD2236, SD1131, CT2002, SD0006) and no new class covers any of them. The test suite asserts the count is exactly 0 rather than leaving it unsaid.
  • The only handler that fires on that report is still drop_variable, on the 28 SD1076 findings, and it still declines all 28. Unchanged.
  • The evidence for the eight new handlers is the unit tests, which recompute from data and evaluate every emitted expression to prove it runs and produces the intended result. A handler that has never seen a real report is not proven by a rule count, and this note stays until one does.

The ceiling, remeasured

Counted by data-raw/make-rule-catalogue.R on every rebuild:

  • 387 undecidable in principle (was 391 — the four that moved are the code/decode rules). The remedy is in the protocol, the CRF or a clinician’s head. More handlers do not reach these.
  • 19 need data p21bridge does not ship, unchanged: MedDRA and WHODrug lookups (14), the five TSVAL parameters whose value is an FDA SRS or NDF-RT term, and the SDTM implementation guide’s own variable type list.
  • 0 left in the bucket 0.0.1.9000 called “reachable with more work”. The pattern block that enumerated those 48 is kept verbatim in the build script and now matches nothing, which makes it a regression check on this release’s claim. It is not a claim that no rule anywhere is still reachable: this release itself reached four rules the previous ceiling had filed as undecidable, by shipping one extra column of data.
  • SD0029, SD0026 and SD0021 — 1,790 of the 1,840 real findings — remain human_judgement. That judgement was not reversed to inflate coverage. Nothing in these eight handlers invents a value: every repaired string is a substring of, or a re-spacing of, something already in the data, a controlled terminology lookup, or a column rename.

Findings are now classified by rule id, not only by message text

  • New dataset p21_rules: the Pinnacle 21 rule catalogue, 537 rules (306 SD, 225 AD, 6 CT), keyed by Pinnacle 21 id and carrying the Publisher (FDA) id, the canonical message and description, the category and the severity (423 Error, 113 Warning, 1 Notice). Built reproducibly by data-raw/make-rule-catalogue.R from the Pinnacle 21 Community reports distributed in phuse-org/phuse-scripts (MIT licence).
  • New rule_info() looks rules up by id.
  • suggest_fix() classifies a finding by looking its rule id up in the catalogue first and only then by matching the report’s message text. The new class_source column records which path fired ("catalogue" or "message_regex"). The message path is unchanged and still handles rules the catalogue does not have, so no finding is classified worse than in 0.0.0.9000; an unrecognised finding still degrades to fix_class = "human_judgement" with deterministic = FALSE.
  • New fix class required_value_null (Pinnacle 21 SD0002 and friends): the variable exists but required records are empty. The count and the affected USUBJIDs are recomputed from the data; no expression is emitted, because the missing values are study data.
  • 29 of the 537 catalogued rules named a mechanic suggest_fix() could recompute; the next section takes that to 79, and the section above it to 131.
  • Limit: the catalogue is a snapshot of the Pinnacle 21 Community rule set as of the harvested reports (SDTM 3.2 / ADaM 1.0 configurations, reports dated 2017-09-29 and PHUSE Test Data Factory v1.0). Newer rule ids are simply absent and take the message path. Re-run data-raw/make-rule-catalogue.R against newer reports to refresh it.
  • Limit: where the same rule id appeared in more than one report with different text or severity (14 ids), the first report wins. The script prints the affected ids on every build.

Seven more deterministic fix classes: 29 of 537 rules become 79

The previous release’s own limit was that widening coverage needs more fix handlers, not more rules. These are those handlers. Every one recomputes the problem from the data and declines to emit an expression when the recomputation shows the remedy is not decided by the data.

  • drop_variable (7 rules: SD1076, SD1078, SD1073, SD1074, SD1075, SD0058, AD0252) - a variable the standard says should not be in the dataset. Deterministic only when the variable holds no data; a populated prohibited variable usually belongs in SUPPQUAL, which is a decision about the study’s data, so the handler reports the populated count and stops.
  • missing_ts_param (35 rules: SD2201-SD2235, SD1226) - a required Trial Summary parameter has no record. The record’s structure is fully determined and the shipped CT confirms whether the TSPARMCD is a real term; TSPARM and TSVAL come from the protocol and are emitted as NA, exactly as the value of a missing required variable is.
  • domain_value (SD0004) - DOMAIN is set from the dataset name, which is what the rule itself defines as correct; a split dataset (LBCH, LBHE) still yields the right two-character code.
  • studyid_mismatch (SD1005) - STUDYID is set to the value DM carries, and only when DM carries exactly one.
  • variable_order (SD1079) - columns are reordered to the specification’s order, which changes no values. Refused outright when the specification does not cover every column, because a partial order is a guess.
  • label_mismatch (3 rules: SD0063, SD0063A, AD0018) - the label is set from the specification. Refused when the specification declares no label (p21bridge does not ship the SDTM/ADaM standard labels) or when that label is over the 40-character transport limit.
  • split_length (SD1116) - split datasets are harmonised to the longest value any of them actually holds, so they can be recombined without truncation.
  • key_not_unique gains SD1038 and now renumbers a --SEQ variable within the other key the message names rather than within itself, which is what “Non-unique value for TSSEQ within TSPARMCD” actually asks for.
  • read_findings() now resolves the Variables/Values pairing that a real Pinnacle 21 Details sheet uses. Those sheets write the placeholder VARIABLE in Variables and put the variable’s name in Values ("VARIABLE, LABEL" against "ANL01FL, Analysis Flag 01"). Before this, every finding of that shape resolved to a variable literally named "VARIABLE" - in the shipped CDISCPILOT01 report that is 28 of the 1,840 findings, which is every finding any new handler covers, so nothing could be recomputed from a real report.
  • New fixture inst/extdata/cdiscpilot01_datasets.rds: the real CDISCPILOT01 columns Pinnacle 21 flagged under SD1076, from the same MIT-licensed source, so the handlers are tested against real data and not an imitation of it.

What the new handlers do on a real report

Measured, not estimated, against the 1,840 shipped CDISCPILOT01 findings:

  • One of the seven fires: drop_variable, on all 28 SD1076 findings.
  • It declines all 28. Every permissible variable Pinnacle 21 flagged is fully populated in the real data - VS.EPOCH on 29,643 of 29,643 records, CM.VISIT on 7,510 of 7,510 - so dropping any of them would delete real values. Zero fix expressions are emitted for that report, which is the correct answer, not a gap.
  • The other six handlers never fire on this report, because CDISCPILOT01 triggers only 8 distinct rules and none of them is theirs. Their evidence is the unit tests, which assert on recomputed values and evaluate every emitted expression - not a real-world firing.

The honest ceiling on the other 458 rules (superseded above)

Counted by data-raw/make-rule-catalogue.R by explicit pattern, so it is re-runnable and arguable:

  • 391 are undecidable in principle. Which of two disagreeing values is right, what a missing unit or result should be, whether an actual arm differs from the planned one for a good reason. The remedy is in the protocol, the CRF or a clinician’s head. More handlers do not reach these.
  • 19 need data p21bridge does not ship: MedDRA and WHODrug lookups (14), the five TSVAL parameters whose value is a dictionary or CT term, and the SDTM IG’s own variable type list. The shipped CT export also drops term-level NCI codes, which is what checking a --TESTCD/--TEST pair against each other would need.
  • 48 are genuinely reachable with more work: 26 illegal ADaM variable names (zero-padding TRT1P to TRT01P), 9 TSVAL format rules, 8 “Multiple parameter records” in TS that are exact duplicates, 2 define.xml presence rules, 2 *DT/*TM values derivable from their *DTM, and non-ASCII characters in --TEST/--TESTCD.

Controlled terminology is checked against CDISC CT, not only the spec

  • New dataset sdtm_ct: CDISC SDTM controlled terminology, release 2026-03-27, all 1,208 codelists and 45,566 submission values, with extensibility. Nothing is subset away; only the columns validation does not use (definition, synonyms, NCI preferred term) are dropped, which keeps the installed data under 250 KB with xz compression.
  • New ct_codelist(), ct_check() and ct_release().
  • suggest_fix() resolves a controlled-terminology finding against the study specification when it declares a codelist, and otherwise against sdtm_ct - from the codelist named in the finding message, or failing that from a codelist whose submission value is the variable name. The evidence names which source and which release was used.
  • Limit: the shipped release is 2026-03-27. Data coded to another CT release will disagree at the margins; state ct_release() in the study documentation, and rebuild with data-raw/make-ct-dictionary.R to move.
  • Limit: only SDTM CT ships. ADaM, CDASH, SEND, define-XML and Protocol terminology are not included, so a CT finding on an ADaM-only codelist falls back to the specification.
  • Limit: the variable-name fallback assumes the SDTM convention that a codelist’s submission value is the variable name (SEX, AESEV, NY). It can pick the wrong codelist for a variable that breaks that convention, which is why the evidence always names the codelist it used.

Fixtures

  • inst/extdata/cdiscpilot01_findings.csv: 1,840 real Pinnacle 21 findings against CDISCPILOT01, from the Details sheet of the same MIT-licensed report the catalogue was built from.
  • The hand-built example report was corrected: its rule ids, messages, categories and severities were fabricated in 0.0.0.9000 and contradicted the real catalogue (its SD0002 was a missing-variable finding; the real SD0002 is a null value in a required variable). It now uses real rules where a real rule covers the planted defect, and deliberately unknown ids (SD1310, SD1234, DD0011, OD0018) where the message path is being exercised. It has 12 findings, up from 11.
  • read_findings() now recognises Pinnacle 21 ID as a rule-id column, which is what real report Details sheets call it. Version 0.0.0.9000 could not read an unmodified Pinnacle 21 export.
  • Initial development version.
  • read_findings() reads a Pinnacle 21 style validation report from .csv, .xlsx or .xls into one normalised tibble, mapping the column names used by different report vintages onto rule_id, severity, domain, variable, message and count, and deriving rule_class from the SD/AD/CT/DD/OD rule-id namespaces.
  • trace_findings() resolves each finding to the dataset object, the variable within it and the specification row that declares it, with an explicit resolved flag and a reason for anything that could not be traced.
  • suggest_fix() proposes a concrete remedy for six deterministic finding classes (missing required variable, variable length, controlled terminology, ISO 8601 dates, key uniqueness, referential integrity), including the R expression that fixes it where that is safely derivable, and labels everything else as needing human judgement.
  • finding_summary() counts findings by severity, domain and rule class.
  • Example report fixtures (.csv and .xlsx), example datasets and an example specification ship in inst/extdata, so every example and test runs offline.